Brushes: opacity masks on art brush pieces bend with the art

An art, scatter or pattern brush piece whose art carries a linked opacity
mask was bent along the path while the mask's art kept its place in the
brush's own coordinates, so the mask no longer covered the piece. The
mask's art now bends and has its strokes scaled with the object it
masks, on bent runs, on straight runs and where the brush narrows along
the path. A mask whose art cannot bend becomes an empty group. The
anchor budget for a brushed stroke counts the mask's art as well.
This commit is contained in:
dotCooCoo
2026-10-31 19:43:15 +07:00
committed by bflatastic
parent 2a5e2e8588
commit b1ed0a512b
+69 -2
View File
@@ -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<Node> {
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<Node>) {
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<Affine> {
/// 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<Node> {
let mut out = prepared(n, width_scale)?;
out.transform(a, false);
@@ -365,6 +379,7 @@ fn prepared(n: &Node, width_scale: f64) -> Option<Node> {
}
_ => 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");
}
}