diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 99f7e6304..d66d5679f 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -41,7 +41,8 @@ const MASK_STEPS: usize = 32; /// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than /// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord /// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`. -/// Text, images and symbol instances can't bend and are dropped. +/// Text, images and symbol instances can't bend and are dropped. The art of a linked opacity mask +/// bends with the object it masks. pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option { let mut out = n.clone(); out.appearance.scale_strokes(width_scale); @@ -63,9 +64,21 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max } _ => return None, } + map_mask(&mut out, |art| warp_node(art, f, flat_tol, max_du, doc_tol, width_scale)); Some(out) } +/// Replace the art of `n`'s linked opacity mask, if it has one, with what `map` makes of it. Where +/// `map` draws nothing, the mask's art becomes an empty group. +fn map_mask(n: &mut Node, map: impl FnOnce(&Node) -> Option) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + let id = m.art.id; + m.art = Arc::new(map(&m.art).unwrap_or_else(|| Node::group(id, vec![]))); + } +} + /// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords /// would, else the chords. Points `f` cannot place (not finite) are left out of the chords. fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData { @@ -338,7 +351,8 @@ fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option { /// Everything else matches [`warp_node`] deliberately, so curves are the only difference between /// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every /// path, an empty path or a group left with no children is dropped, and strokes are scaled by -/// `width_scale` at every level rather than by the transform's own factor. +/// `width_scale` at every level rather than by the transform's own factor, the art of linked +/// opacity masks included. fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option { let mut out = prepared(n, width_scale)?; out.transform(a, false); @@ -365,6 +379,7 @@ fn prepared(n: &Node, width_scale: f64) -> Option { } _ => return None, } + map_mask(&mut out, |art| prepared(art, width_scale)); Some(out) } @@ -416,6 +431,7 @@ fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node { if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind { *children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect(); } + map_mask(&mut out, |art| Some(widen_strokes(art, factor))); out } @@ -590,6 +606,9 @@ fn anchors_of(n: &Node) -> usize { if let Some(c) = n.children() { stack.extend(c.iter().map(Arc::as_ref)); } + if let Some(m) = &n.extras().mask { + stack.push(&m.art); + } } total } @@ -1133,4 +1152,52 @@ mod tests { } } } + + /// A linked opacity mask on brush art bends and widens with the art, on a bent run and on a + /// straight one, so a mask drawn over the art still covers it. An unlinked mask stays put. + #[test] + fn a_linked_opacity_mask_bends_with_the_art() { + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let stroked = |id: u64| { + Node::path( + NodeId(id), + vectorcraft_geom::shapes::ellipse(b), + Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + 2.0, + ), + ) + }; + let masked = |linked: bool| { + let mut n = stroked(0); + let mut mask = vectorcraft_doc::OpacityMask::new(stroked(1), true); + mask.linked = linked; + n.extras_mut().mask = Some(Box::new(mask)); + n + }; + let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let bent_track = tracks(&round, 0.05).remove(0); + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((320.0, 0.0)); + let straight_track = tracks(&line, 0.01).remove(0); + let along = AlongMap::linear(b.width(), 100.0, 140.0); + for (name, t) in [("bent", &bent_track), ("straight", &straight_track)] { + let out = bend(t, &masked(true), b, along, 2.0).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(mask.art.kind, out.kind, "{name} run: the mask's art bends with the art it was drawn over"); + assert_eq!(mask.art.appearance, out.appearance, "{name} run: the mask's strokes widen with the art's"); + + let out = bend(t, &masked(false), b, along, 2.0).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(*mask.art, stroked(1), "{name} run: an unlinked mask stays where it was"); + + let out = bend_widened(t, &masked(true), b, along, 2.0, &|_| 0.5).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(mask.art.kind, out.kind, "{name} run, widened: the mask's art bends with the art"); + assert_eq!(mask.art.appearance, out.appearance, "{name} run, widened: the mask's strokes narrow with the art's"); + } + assert_eq!(anchors_of(&masked(true)), 8, "the anchor budget counts the mask's art"); + } }