diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index ef72ae5721..fe200c2f28 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -272,6 +272,14 @@ impl ImageBlob { self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255)) } + /// The alpha of each pixel, row by row from the top: the width, the height and one byte a + /// pixel. `None` when the image can't be decoded. The last image decoded is kept, as for + /// [`Self::sample`]. + pub fn alpha(&self) -> Option<(u32, u32, Vec)> { + let img = self.decoded()?; + Some((img.width(), img.height(), img.pixels().map(|p| p.0[3]).collect())) + } + /// The decoded pixels (the last image decoded is kept for the next sample). fn decoded(&self) -> Option> { thread_local! { diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 0616261d8a..a3e31f1842 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -60,6 +60,161 @@ pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool { false } +/// Which overlaps of the pieces of a mesh-warped image ([`Node::warp_piece_image`]) can show +/// through. Where an upper piece overlaps a lower one only over fully opaque pixels of the image, +/// the SVG and PDF writers draw the upper piece over the lower one without a knockout mask for it: +/// a mask there would make the opaque pixels on the upper piece's antialiased edge lighter. +pub struct WarpSeams { + pieces: Vec>, + alphas: Vec<(u32, u32, Vec)>, +} + +/// A piece of a mesh-warped image whose pixels decide whether its overlaps show through. +struct SeamPiece { + /// The piece's triangle, in document space. + triangle: [vectorcraft_geom::Point; 3], + /// Maps document space to the pixels of its image as decoded. + to_pixels: vectorcraft_geom::Affine, + /// The index of its image's alpha in [`WarpSeams::alphas`]. + alpha: usize, +} + +/// How far from an overlap, in image pixels, a less than opaque pixel makes the overlap show +/// through: a smoothed image blends each pixel with its neighbors. +const SEAM_MARGIN: f64 = 2.0; + +impl WarpSeams { + /// The overlaps of `pieces` (pieces of a mesh-warped image, in paint order), with the pixels of + /// their images looked up in `doc`. Each distinct image is decoded once. + pub fn new(doc: &Document, pieces: &[&Node]) -> Self { + let mut images: Vec<*const vectorcraft_doc::ImageBlob> = vec![]; + let mut alphas = vec![]; + let pieces = pieces + .iter() + .map(|n| { + let image = n.warp_piece_image()?; + let NodeKind::Image(im) = &image.kind else { return None }; + let e = image.extras(); + if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() { + return None; + } + let triangle = triangle(n.knockout_outline()?)?; + let blob = doc.image_blob(im)?; + let alpha = match images.iter().position(|b| std::ptr::eq(*b, blob)) { + Some(i) => i, + None => { + alphas.push(blob.alpha()?); + images.push(std::ptr::from_ref(blob)); + alphas.len() - 1 + } + }; + let (w, h, _) = &alphas[alpha]; + if im.width == 0 || im.height == 0 || im.xf.determinant().abs() < 1e-12 { + return None; + } + let scale = vectorcraft_geom::Affine::scale_non_uniform(f64::from(*w) / f64::from(im.width), f64::from(*h) / f64::from(im.height)); + Some(SeamPiece { triangle, to_pixels: scale * im.xf.inverse(), alpha }) + }) + .collect(); + WarpSeams { pieces, alphas } + } + + /// Whether piece `above` (an index into the pieces) drawn over piece `below` can show the + /// lower piece through where they overlap: either piece or its image is partly transparent, a + /// color mask or a colorize paint changes its alpha, or a pixel of either image within + /// [`SEAM_MARGIN`] of the overlap is less than opaque. `true` for a piece that is not a + /// triangle of an image that can be decoded; `false` when the triangles do not overlap. + pub fn shows_through(&self, below: usize, above: usize) -> bool { + let (Some(Some(a)), Some(Some(b))) = (self.pieces.get(below), self.pieces.get(above)) else { return true }; + let overlap = convex_intersection(&a.triangle, &b.triangle); + overlap.len() >= 3 && [a, b].into_iter().any(|p| self.translucent_near(p, &overlap)) + } + + /// Whether a pixel of piece `p`'s image within [`SEAM_MARGIN`] of the convex polygon `overlap` + /// (document space) is less than opaque. + fn translucent_near(&self, p: &SeamPiece, overlap: &[vectorcraft_geom::Point]) -> bool { + let Some((w, h, alpha)) = self.alphas.get(p.alpha) else { return true }; + let poly: Vec = overlap.iter().map(|q| p.to_pixels * *q).collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for q in &poly { + (x0, y0, x1, y1) = (x0.min(q.x), y0.min(q.y), x1.max(q.x), y1.max(q.y)); + } + let span = |lo: f64, hi: f64, n: u32| { + let lo = (lo - SEAM_MARGIN).floor().clamp(0.0, f64::from(n)) as u32; + let hi = (hi + SEAM_MARGIN).ceil().clamp(0.0, f64::from(n)) as u32; + lo..hi + }; + let orientation = signed_area(&poly).signum(); + for y in span(y0, y1, *h) { + for x in span(x0, x1, *w) { + let c = vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + let near = poly.iter().zip(poly.iter().cycle().skip(1)).all(|(s, e)| { + let edge = *e - *s; + orientation * edge.cross(c - *s) >= -SEAM_MARGIN * edge.hypot() + }); + let i = y as usize * *w as usize + x as usize; + if near && alpha.get(i).is_none_or(|a| *a < 255) { + return true; + } + } + } + false + } +} + +/// The three corners of the straight-sided triangle `outline` draws, in document space. +fn triangle(outline: &Node) -> Option<[vectorcraft_geom::Point; 3]> { + let (bp, _) = clip_outline(outline)?; + let mut points = vec![]; + for el in bp.elements() { + match el { + vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if points.is_empty() || points.last() != Some(p) => { + points.push(*p) + } + vectorcraft_geom::PathEl::MoveTo(_) | vectorcraft_geom::PathEl::LineTo(_) | vectorcraft_geom::PathEl::ClosePath => {} + _ => return None, + } + } + if points.len() == 4 && points.first() == points.last() { + points.pop(); + } + let [a, b, c] = points.as_slice() else { return None }; + Some([*a, *b, *c]) +} + +/// Twice the signed area of polygon `poly`. +fn signed_area(poly: &[vectorcraft_geom::Point]) -> f64 { + poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.to_vec2().cross(b.to_vec2())).sum() +} + +/// The intersection of convex polygons `subject` and `clip`, as a convex polygon (empty when they +/// do not overlap). +fn convex_intersection(subject: &[vectorcraft_geom::Point], clip: &[vectorcraft_geom::Point]) -> Vec { + let orientation = signed_area(clip).signum(); + if orientation == 0.0 { + return vec![]; + } + let mut out = subject.to_vec(); + for (s, e) in clip.iter().zip(clip.iter().cycle().skip(1)) { + let edge = *e - *s; + let side = |p: vectorcraft_geom::Point| orientation * edge.cross(p - *s); + let input = std::mem::take(&mut out); + for (p, q) in input.iter().zip(input.iter().cycle().skip(1)) { + let (sp, sq) = (side(*p), side(*q)); + if sp >= 0.0 { + out.push(*p); + } + if (sp >= 0.0) != (sq >= 0.0) { + out.push(*p + (*q - *p) * (sp / (sp - sq))); + } + } + if out.is_empty() { + break; + } + } + out +} + /// Text as one path of its glyph outlines, in document space. fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d84c54060d..743af8860f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use adjust::{ is_channel, }; pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; -pub use clip::{clip_outline, knockout_silhouette, warp_pieces_translucent}; +pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art, diff --git a/crates/engine/src/tests/warp_images.rs b/crates/engine/src/tests/warp_images.rs index 1fc6b736e1..9dc781ee16 100644 --- a/crates/engine/src/tests/warp_images.rs +++ b/crates/engine/src/tests/warp_images.rs @@ -295,7 +295,12 @@ fn a_warp_on_a_group_bends_the_image_in_it() { /// A selected embedded 40×40 px red image at half alpha filling [`FRAME`], with Effect › Warp › /// Arc at 50 %. fn half_clear_arc(s: &mut Session) { - let img = image::RgbaImage::from_pixel(40, 40, image::Rgba([255, 0, 0, 128])); + clear_arc(s, |_| 128); +} + +/// [`half_clear_arc`] with each column `x` of pixels at alpha `alpha(x)`. +fn clear_arc(s: &mut Session, alpha: impl Fn(u32) -> u8) { + let img = image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([255, 0, 0, alpha(x)])); let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); s.edit("Place", |d, sel| { @@ -346,3 +351,34 @@ fn a_half_transparent_warped_image_has_no_darker_seams() { back.execute("document.open", &json!({"name": "half.pdf", "dataBase64": pdf["dataBase64"]})).unwrap(); assert_eq!(not_half_red(&back, 120..=152), vec![], "pdf"); } + +/// An image whose left half is opaque and whose right half is at half alpha keeps its opaque +/// pixels opaque where the pieces of a warp overlap, away from the partly transparent pixels, on +/// the canvas and in a PDF opened again. +#[test] +fn opaque_pixels_of_a_partly_transparent_warped_image_have_no_lighter_seams() { + let mut s = session(); + clear_arc(&mut s, |x| if x < 20 { 255 } else { 128 }); + // The points of a fine grid over the left 30 % of the bent image, away from its outer edges, + // where the document drawn on white at 8 px a point is not red: the first few. + let not_red = |s: &Session| { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 300.0, 300.0), 8.0, true); + let mut out = vec![]; + for i in 0..300 { + for j in 0..800 { + let (u, v) = (0.03 + 0.27 * f64::from(i) / 299.0, 0.03 + 0.94 * f64::from(j) / 799.0); + let p = arced(50.0, u, v); + let px = img.pixel((p.x * 8.0).floor() as u32, (p.y * 8.0).floor() as u32); + if px[1] > 6 && out.len() < 8 { + out.push((u, v, px)); + } + } + } + out + }; + assert_eq!(not_red(&s), vec![], "canvas"); + let pdf = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap(); + let mut back = session(); + back.execute("document.open", &json!({"name": "mixed.pdf", "dataBase64": pdf["dataBase64"]})).unwrap(); + assert_eq!(not_red(&back), vec![], "pdf"); +} diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 4d4b57c3d2..bb73980177 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -118,7 +118,8 @@ impl Exporter<'_> { /// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece /// is drawn through one soft mask of where the pieces above it that reach its box don't cover /// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a - /// few triangles and no mask nests in another. + /// few triangles and no mask nests in another. A piece above that overlaps it only over opaque + /// pixels ([`vectorcraft_effects::WarpSeams`]) is left out of its mask. fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc, Measured)], page: Rect) { // Each piece's triangle and its box: a piece paints inside its triangle only. let silhouette = |c: &Node| { @@ -126,11 +127,18 @@ impl Exporter<'_> { Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b)) }; let silhouettes: Vec> = elements.iter().map(|(c, _)| silhouette(c)).collect(); + let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect(); + let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces); for (i, (c, measured)) in elements.iter().enumerate() { let Some((_, reach)) = &silhouettes[i] else { continue }; let reach = reach.inflate(1.0, 1.0); - let shapes: Vec<(&Node, Option<&Measured>)> = - silhouettes[i + 1..].iter().flatten().filter(|(_, b)| rects_overlap(*b, reach)).map(|(n, _)| (n, None)).collect(); + let shapes: Vec<(&Node, Option<&Measured>)> = silhouettes + .iter() + .enumerate() + .skip(i + 1) + .filter_map(|(j, s)| s.as_ref().filter(|(_, b)| rects_overlap(*b, reach) && seams.shows_through(i, j))) + .map(|(n, _)| (n, None)) + .collect(); if shapes.is_empty() { self.measured_node(s, c, page, false, Some(measured)); continue; diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index f1c989e282..a4cdf26271 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -638,6 +638,8 @@ impl Writer<'_> { /// through one mask of where the pieces above it that reach it don't cover /// (their triangles, [`vectorcraft_effects::knockout_silhouette`], black over white): /// only neighbors overlap, so each mask holds a few triangles and no mask nests in another. + /// A piece above that overlaps it only over opaque pixels ([`vectorcraft_effects::WarpSeams`]) + /// is left out of its mask. fn warp_pieces(&mut self, elements: &[&std::sync::Arc]) { // Each piece's triangle and its box: a piece paints inside its triangle only. let silhouette = |c: &Node| { @@ -645,11 +647,19 @@ impl Writer<'_> { Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::BLACK)?, b)) }; let silhouettes: Vec> = elements.iter().map(|c| silhouette(c)).collect(); + let pieces: Vec<&Node> = elements.iter().map(|c| &***c).collect(); + let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces); for (i, c) in elements.iter().enumerate() { let Some((own, reach)) = &silhouettes[i] else { continue }; let reach = reach.inflate(1.0, 1.0); let overlaps = |b: &Rect| b.x0 <= reach.x1 && reach.x0 <= b.x1 && b.y0 <= reach.y1 && reach.y0 <= b.y1; - let above: Vec<&Node> = silhouettes[i + 1..].iter().flatten().filter(|(_, b)| overlaps(b)).map(|(n, _)| n).collect(); + let above: Vec<&Node> = silhouettes + .iter() + .enumerate() + .skip(i + 1) + .filter_map(|(j, s)| s.as_ref().filter(|(_, b)| overlaps(b) && seams.shows_through(i, j))) + .map(|(n, _)| n) + .collect(); if above.is_empty() { self.node(c); continue; diff --git a/crates/svg/tests/it/warp_pieces.rs b/crates/svg/tests/it/warp_pieces.rs index 575e98be16..9c8b3e1760 100644 --- a/crates/svg/tests/it/warp_pieces.rs +++ b/crates/svg/tests/it/warp_pieces.rs @@ -72,6 +72,52 @@ fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap() } } +/// An image whose left half is opaque and whose right half is at half alpha, cut into 4 × 4 cells: +/// its opaque pixels stay opaque on the pieces' antialiased edges, away from the partly +/// transparent pixels, and the right half stays half transparent. +#[test] +fn opaque_pixels_stay_opaque_where_warp_pieces_overlap() { + let img = image::RgbaImage::from_fn(16, 16, |x, _| image::Rgba([255, 0, 0, if x < 8 { 255 } else { 128 }])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + for masked in [false, true] { + let mut d = Document::new(100.0, 100.0); + d.images.insert("pic".into(), ImageBlob::png(png.clone())); + let im = ImageObject { + key: "pic".into(), + width: 16, + height: 16, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone()))); + let g = mesh_warp_image(&n, &im, &|p| p, 4, false, masked); + d.insert(d.default_layer(), 0, g).unwrap(); + let svg = export_full(&d, &ExportOptions::default(), None).svg; + let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default()).unwrap(); + let mut pixmap = resvg::tiny_skia::Pixmap::new(400, 400).unwrap(); + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(4.0, 4.0), &mut pixmap.as_mut()); + let alpha = |x0: u32, x1: u32| { + let mut range = (255u8, 0u8); + for y in 48..352 { + for x in x0..x1 { + let a = pixmap.pixel(x, y).unwrap().alpha(); + range = (range.0.min(a), range.1.max(a)); + } + } + range + }; + // The first column of cells and its seam with the second, (12, 12)–(29.75, 88). + let opaque = alpha(48, 119); + assert!(opaque.0 >= 253, "masked {masked}: {opaque:?}"); + // The right half, (62, 12)–(88, 88). + let half = alpha(248, 352); + assert!(half.0 >= 104 && half.1 <= 134, "masked {masked}: {half:?}"); + } +} + /// Pieces of an opaque image are drawn one over another without knockout masks; a colorize paint /// draws a gray image's light pixels partly transparent, so its pieces take them. #[test]