From b9b1a1e967b0b99e35bcdd357017329b3e89c30e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:11:57 -0800 Subject: [PATCH 1/3] Effects: linked opacity masks follow group effects, Free Distort and envelopes A linked opacity mask moves with the art it masks, and an unlinked one stays in place. Geometry effects on a group or layer reshaped its members but left their linked masks where they were, Free Distort left a linked mask behind, and an envelope bent unlinked masks too. - Geometry effects on a group or layer run over the art of the linked masks of the paths, compound paths and groups inside it, nested and inverted masks included. Each Transform copy of a masked path carries its own mask, and an image or symbol instance moved by a Transform effect carries its linked mask. The effects on an object do not move that object's own mask. - When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, or the mask would get fewer Transform copies), the art and its masks keep their shape under that effect. Mask art counts toward the budget. - Free Distort (`object.distort`) warps a linked mask with its object; the params text says so. - An envelope bends only a linked mask. Docs: a line in docs/target-app-parity.md. --- crates/doc/src/live/expand.rs | 3 +- crates/effects/src/group/tests.rs | 263 ++++++++++++++++ crates/effects/src/reshape.rs | 329 +++++++++++++++++--- crates/engine/src/cmd/xform.rs | 8 +- crates/engine/src/tests/envelope_distort.rs | 26 ++ crates/engine/src/tests/xform.rs | 26 ++ docs/target-app-parity.md | 1 + 7 files changed, 614 insertions(+), 42 deletions(-) diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 8675dcc011..388bac227a 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -119,7 +119,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { { out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); } - if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { + // A linked opacity mask bends with the object it masks; an unlinked one stays where it is. + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()).filter(|m| m.linked) { m.art = Arc::new(map_node(&m.art, w)); } match &mut out.kind { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 16e893ce99..23f2f24c02 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -690,6 +690,269 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { } } +/// A white 10 pt square with id `id` at the origin. +fn white_square(id: u64) -> Node { + Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)) +} + +/// `n` masked by `art`, linked or not. +fn masked_by(mut n: Node, art: Node, linked: bool) -> Node { + let mut mask = vectorcraft_doc::OpacityMask::new(art, true); + mask.linked = linked; + n.extras_mut().mask = Some(Box::new(mask)); + n +} + +/// The geometry effects that reshape a group's members, one at a time. +fn reshaping_effects() -> Vec { + let fx = |id: &str, params: serde_json::Value| Effect { id: id.into(), params, visible: true }; + let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + vec![ + fx("distort.transform", serde_json::json!({"moveH": 30})), + fx("distort.transform", serde_json::json!({"rotate": 90})), + fx("distort.twist", serde_json::json!({"angle": 90})), + fx("distort.freeDistort", corners), + fx("warp.arc", serde_json::json!({})), + fx("distort.roughen", serde_json::json!({"size": 5, "seed": 3})), + fx("distort.zigZag", serde_json::json!({})), + fx("distort.puckerBloat", serde_json::json!({"amount": 50})), + fx("distort.tweak", serde_json::json!({})), + fx("path.offsetPath", serde_json::json!({"offset": 3})), + fx("stylize.roundCorners", serde_json::json!({"radius": 3})), + ] +} + +/// A group's geometry effect reshapes the art of a member's linked opacity mask as it reshapes the +/// member: a mask drawn over the member still covers it. An unlinked mask stays where it is. +#[test] +fn a_groups_geometry_effects_reshape_a_members_linked_mask_with_it() { + let square = white_square(1); + let original = square.path_data().cloned(); + for e in reshaping_effects() { + for linked in [true, false] { + let member = masked_by(square.clone(), white_square(4), linked); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = member.extras().mask.as_deref().unwrap(); + assert!(member.path_data().cloned() != original, "{}: the member is reshaped", e.id); + let want = if linked { member.path_data().cloned() } else { original.clone() }; + assert_eq!(mask.art.path_data().cloned(), want, "{} (linked: {linked})", e.id); + assert_eq!(mask.linked, linked); + } + } +} + +/// The mask of a group inside the group, the mask of a path in it, a mask inside that mask's art and +/// an inverted mask all take the outer group's effect with the paths they mask. +#[test] +fn a_groups_geometry_effects_reshape_nested_and_inverted_masks() { + for e in reshaping_effects() { + let innermost = masked_by(white_square(7), white_square(8), true); + let path = masked_by(white_square(1), masked_by(white_square(6), innermost, true), true); + let mut inner = masked_by(Node::group(NodeId(5), vec![Arc::new(path)]), white_square(4), true); + if let Some(m) = inner.extras_if_any().and_then(|x| x.mask.as_mut()) { + m.invert = true; + } + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let path = &inner.children().unwrap()[0]; + let want = path.path_data().cloned(); + let mask_of = |n: &Node| n.extras().mask.as_deref().map(|m| (*m.art).clone()).unwrap(); + let (outer_mask, path_mask) = (mask_of(inner), mask_of(path)); + assert!(inner.extras().mask.as_deref().is_some_and(|m| m.invert)); + assert_eq!(outer_mask.path_data().cloned(), want, "{}: the inner group's inverted mask", e.id); + assert_eq!(path_mask.path_data().cloned(), want, "{}: the path's mask", e.id); + assert_eq!(mask_of(&path_mask).path_data().cloned(), want, "{}: the mask inside the mask's art", e.id); + assert_eq!(mask_of(&mask_of(&path_mask)).path_data().cloned(), want, "{}: one level deeper", e.id); + } +} + +/// A Transform effect's copies of a path with a linked mask are objects of their own, each with the +/// mask moved with it; the original keeps its mask where it was. +#[test] +fn each_transform_copy_of_a_masked_member_carries_its_mask() { + let member = masked_by(white_square(1), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member)]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); + assert_eq!(copies.len(), 3); + for c in copies { + let mask = c.extras().mask.as_deref().unwrap(); + assert_eq!(mask.art.geometric_bounds(), c.geometric_bounds()); + } +} + +/// A Transform effect's copies of the paths of a member group give the group's linked mask the same +/// copies: the mask covers each copy. +#[test] +fn a_member_groups_mask_takes_the_transform_copies_of_its_paths() { + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let want = inner.geometric_bounds(); + assert_eq!(want, Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "the path and its copy"); + assert_eq!(inner.extras().mask.as_deref().unwrap().art.geometric_bounds(), want); +} + +/// A mask's art of several objects takes Transform copies whole, as the art it masks does: each copy +/// of the mask's art is a group of its objects, so the copies do not interleave object by object. +#[test] +fn a_masks_art_of_several_objects_is_copied_whole() { + let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + let copies = mask.children().unwrap(); + assert_eq!(copies.len(), 2, "the mask's art and its copy"); + for c in copies { + let ids: Vec = c.children().into_iter().flatten().map(|m| m.id).collect(); + assert_eq!(ids, [NodeId(4), NodeId(5)], "each copy holds the mask's objects"); + } + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0))); +} + +/// The linked mask of a mask's art that is copied whole takes the same Transform copies. +#[test] +fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() { + let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let mask_art = masked_by(parts, white_square(6), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)), "the mask's art and its copy"); + let own = &mask.extras().mask.as_deref().expect("the mask's art keeps its own mask").art; + assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too"); +} + +/// When a member group's linked mask can't take all the Transform copies its paths take (past the +/// budget), the art keeps its shape under the effect, so the mask still covers the paths. +#[test] +fn art_whose_mask_would_take_fewer_copies_keeps_its_shape() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true }; + let member = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the path, its mask and a copy of the path, not for a copy of the mask. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 3 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let member = &art.children().unwrap()[0]; + let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(member.geometric_bounds(), square, "the path keeps its shape"); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), square, "and so does its mask"); +} + +/// The copies of a path with a linked mask count the art of the mask each copy carries against the +/// budget: they stop where the masks would pass it. +#[test] +fn transform_copies_count_the_art_of_their_masks() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 2}), visible: true }; + let member = masked_by(white_square(1), white_square(4), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the path, its mask and one copy of both, not for a second copy. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 4 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let copies = art.children().unwrap()[0].children().expect("the object and its copy are objects of their own"); + assert_eq!(copies.len(), 2); + for c in copies { + assert_eq!(c.extras().mask.as_deref().unwrap().art.geometric_bounds(), c.geometric_bounds()); + } +} + +/// A Transform effect that scales strokes moves a group's paths as objects, their masks with them. +/// The mask of a member group, whose art holds a path with a mask of its own, moves with the group's +/// paths too. +#[test] +fn a_transform_that_moves_paths_as_objects_moves_a_member_groups_nested_mask() { + let mask_art = masked_by(white_square(4), white_square(5), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(3, 20.0, Color::BLACK)]); + let params = serde_json::json!({"moveH": 30, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let want = inner.children().unwrap()[0].geometric_bounds(); + assert_eq!(want, Some(Rect::new(30.0, 0.0, 40.0, 10.0))); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), want, "the group's mask"); + assert_eq!(mask.extras().mask.as_deref().unwrap().art.geometric_bounds(), want, "the mask inside its art"); +} + +/// A group's Transform effect moves an image member's linked opacity mask with the image, and its +/// copies' masks with the copies; an unlinked mask stays. +#[test] +fn a_groups_transform_moves_an_image_members_linked_mask() { + for linked in [true, false] { + for copies in [0, 2] { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), white_square(4), linked); + let mut g = Node::group(NodeId(10), vec![Arc::new(image)]); + let params = serde_json::json!({"moveH": 30, "copies": copies}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let first = &art.children().unwrap()[0]; + let images: Vec<&Node> = match first.children() { + Some(ch) => ch.iter().map(|c| &**c).collect(), + None => vec![first], + }; + assert_eq!(images.len(), copies + 1); + for im in images { + let mask = im.extras().mask.as_deref().unwrap(); + let want = if linked { im.geometric_bounds() } else { Some(Rect::new(0.0, 0.0, 10.0, 10.0)) }; + assert_eq!(mask.art.geometric_bounds(), want, "linked {linked}, copies {copies}"); + } + } + } +} + +/// When a path in a member's mask art would pass the effects' budget, the whole mask stays as it +/// was, rather than part of its art moving with the member, and the member keeps its shape with it. +#[test] +fn a_mask_whose_art_passes_the_budget_stays_whole() { + // Bloat bends a path's sides into curves drawn with more lines the larger they are: many more + // on the mask's large square. + let bloat = Effect { id: "distort.puckerBloat".into(), params: serde_json::json!({"amount": 50}), visible: true }; + let large = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), Appearance::default()); + let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), Arc::new(large.clone())]); + let member = masked_by(white_square(1), mask_art.clone(), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + // Room for the member and the mask's small square to take the effect, not for the large one. + let bounds = art.geometric_bounds().unwrap(); + let ctx = crate::GeomContext { seed: 10, ..crate::GeomContext::of(&art) }; + let square = white_square(1).path_data().cloned().unwrap(); + let made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), &square, bounds, &ctx)).lines; + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 2 * made + crate::Budget::of(large.path_data().unwrap()).lines }; + let large_made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), large.path_data().unwrap(), bounds, &ctx)).lines; + assert!(large_made > crate::Budget::of(large.path_data().unwrap()).lines, "the large square's curves cost more lines"); + crate::reshape::reshape_leaves_within(&mut art, &[&bloat], budget, None); + let member = &art.children().unwrap()[0]; + assert_eq!(member.path_data(), Some(&square), "the member keeps its shape with its mask"); + assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole"); +} + /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit /// pitch, each crossing every bar of the other direction: n² crossings. fn crossing_bars(n: usize, effect: &str) -> Node { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..14be7ee124 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -677,57 +677,114 @@ fn hoist_copies(art: &mut Node) { /// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). /// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect /// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when -/// `owner` does not hold it. +/// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of +/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]). When a mask +/// stays as it was, or a Transform effect makes fewer copies than it asks for, art with linked +/// masks keeps its shape under that effect. fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { let mut whole = crate::Carry::default(); if fx.is_empty() { return whole; } - let mut leaves_safe = true; - for_each_leaf(art, &mut |leaf| { - if !leaves_safe { - return; - } - if let Some((_, rule)) = leaf_geometry(leaf) { - let has_stroke = leaf.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); - if rule != FillRule::NonZero || has_stroke { - leaves_safe = false; - } - } - }); + let mut masks_safe = true; + each_linked_mask(art, false, 0, &mut |m| masks_safe &= leaves_safe(m)); let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); - let style_safe = leaves_safe && !art_stroke; + let style_safe = leaves_safe(art) && masks_safe && !art_stroke; let listed = fx; let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe); let fx = opt_fx.as_slice(); let seed = art.id.0; let mut used = art_cost(art); + each_linked_mask(art, false, 0, &mut |m| used = used.plus(art_cost(m))); let max = budget.at_least(used); // Read before the effects run: a path's own copies don't make it art of several objects. let copy_whole = copies_whole(art); + let masked = has_linked_mask(art, false); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); + let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc }; + // Art with linked masks keeps its shape under an effect that a mask in it could not take + // as the art it masks did, so no mask is left apart from its art. + let before = masked.then(|| (art.clone(), used)); + let ran = step.run(art, max, &mut used, copy_whole, false, 0); + if (ran.masks || ran.short) + && let Some((was, cost)) = before + { + *art = was; + used = cost; + continue; + } + if let Some(carry) = ran.whole { + whole.push(carry); + } + } + whole +} + +/// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its +/// merged params, the reference box and seed of the object's art, where a deferred live-effect +/// plug-in is listed, whether each Transform copy becomes an object, and the document that the +/// swatches and patterns of painted pieces are looked up in. +struct Step<'a> { + e: &'a Effect, + params: &'a serde_json::Value, + bounds: Rect, + seed: u64, + effect: Option, + apart: bool, + doc: Option<&'a Document>, +} + +/// What [`Step::run`] did to a piece of art. +struct Ran { + /// A path in the art kept its shape (past the budget). + kept: bool, + /// The linked opacity mask of an object in the art stayed as it was while the paths it masks + /// took the effect, or could have ([`Step::reshape_masks`]). + masks: bool, + /// A Transform effect made fewer copies of a path, image or symbol instance than it asks for + /// (past the budget or out of reach), where a linked mask could take a different number. + short: bool, + /// How what paints the art as a whole follows the effect, when it does. + whole: Option, +} + +impl Ran { + /// Nothing in the art kept its shape or made fewer copies, and every linked mask in it moved. + fn complete(&self) -> bool { + !self.kept && !self.masks && !self.short + } +} + +impl Step<'_> { + /// Run the effect over `art`, whose paths and those of the linked opacity masks in it keep to + /// `max` together with what they cost so far (`used`). `copy_whole` is [`copies_whole`] of the + /// art before its first effect, or of a mask's art before this effect. With `own`, `art` is the + /// art of a mask, and its own linked mask takes the effect too. `depth` counts the masks `art` is + /// in. + fn run(&self, art: &mut Node, max: Budget, used: &mut Budget, copy_whole: bool, own: bool, depth: usize) -> Ran { + let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed); if let Some(plugin) = painted_plugin(e) { - let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + let at = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); for_each_leaf(art, &mut |leaf| { let Some((path, _)) = leaf_geometry(leaf) else { return }; let before = Budget::of(&path); let room = max.minus(used.minus(before)); - if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc, at) { - used = used.minus(before).plus(made); + if let Some(made) = paint_leaf(leaf, plugin, params, bounds, room, self.doc, at) { + *used = used.minus(before).plus(made); } }); - continue; + return Ran { kept: false, masks: false, short: false, whole: None }; } // Placed gradients follow a Transform effect, Free Distort and the Warp effects // ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from // its bounds before the effect to its bounds after it. Under those and the other effects, // the placed gradients of type's outlines in `art` (`outline_text`) move and scale with // the outlines' bounds. - let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed); + let follows = crate::Carry::follows(&e.id, params, bounds, seed); let boxes = follows && crate::Carry::by_bounds(&e.id); let mut outlines = vec![]; if !follows || boxes { @@ -741,21 +798,44 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if follows { group_marks(art, &mut vec![], &HashSet::new(), &mut groups); } - // The ids of the paths that kept their shape (past the budget). + // The ids of the paths that kept their shape (past the budget), and of those whose linked + // masks moved with them as they were transformed as objects. let mut kept_ids = HashSet::new(); + let mut moved_ids = HashSet::new(); let transform = (e.id == "distort.transform").then(|| { - let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); - let options = crate::distort::TransformOptions::of(¶ms); - (m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed)) + let (m, copies) = crate::distort::transform_map(bounds, params, seed); + let options = crate::distort::TransformOptions::of(params); + (m, copies, options, crate::distort::transform_affine(bounds, params, seed)) }); // Art of several objects is copied whole, so the copies don't interleave member by member. + // The copies of its members carry their linked masks, moved with them. if let Some((m, copies, options, _)) = transform && copies > 0 && copy_whole { - used = copy_art(art, m, copies, copy_scaling(options), max); - continue; + if !own { + *used = copy_art(art, m, copies, copy_scaling(options), max); + return Ran { kept: false, masks: false, short: false, whole: None }; + } + // A mask's art: its copies keep to what the rest of the art leaves of `max`, and the + // linked mask of the art itself takes them too. + let before = copy_cost(art); + let made = copy_art(art, m, copies, copy_scaling(options), max.minus(used.minus(before))); + *used = used.minus(before).plus(made); + let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies); + let none = HashSet::new(); + let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth); + return Ran { kept: false, masks, short, whole: None }; } + // A shortfall in the copies matters for the objects a linked mask above them covers, which + // the mask would take a different number of copies than, and anywhere in a mask's art. + let watch = transform.is_some_and(|(_, copies, options, _)| copies > 0 && options.objects) && (own || has_linked_mask(art, false)); + let mut covered = HashSet::new(); + if watch && !own { + masked_ids(art, false, &mut covered); + } + let counts = |id: NodeId| watch && (own || covered.contains(&id)); + let mut short = false; for_each_leaf(art, &mut |leaf| { let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. @@ -766,15 +846,22 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // strokes; a gradient paints each copy by itself, not the one piece the copies // make together; and Expand Appearance (`apart`) makes each copy an object. // Fills and strokes that stack paint each copy below the one before (#5920, #6316). + // Each copy of a path with a linked mask carries the mask, moved with it. let separate = copies > 0 - && (apart + && (self.apart || leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient() - || paints_copies_apart(&leaf.appearance, rule)); + || paints_copies_apart(&leaf.appearance, rule) + || leaf.extras().mask.as_ref().is_some_and(|m| m.linked)); + // The copies of a path replace it by a group of them, the path included. + let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len); if options.reach_appearance() { let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); - used = used.minus(before).plus(made); - if !moved { + *used = used.minus(before).plus(made); + if moved { + moved_ids.insert(leaf.id); + short |= counts(leaf.id) && copied(leaf) <= copies; + } else { kept_ids.insert(leaf.id); } return; @@ -782,19 +869,27 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if separate { let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; let made = copy_leaf(leaf, &path, m, copies, scaling, room); - used = used.minus(before).plus(made); + *used = used.minus(before).plus(made); + moved_ids.insert(leaf.id); + short |= counts(leaf.id) && copied(leaf) <= copies; return; } } - let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); - let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { + let stage = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + let Some(out) = apply_one(&e.id, params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { kept_ids.insert(leaf.id); return; }; - used = used.minus(before).plus(Budget::of(&out)); + // A Transform effect's copies of a path are subpaths of it, each with the path's anchors. + if counts(leaf.id) + && let Some((_, copies, _, _)) = transform + { + short |= out.anchor_count() < path.anchor_count().saturating_mul(copies.saturating_add(1)); + } + *used = used.minus(before).plus(Budget::of(&out)); if follows && !boxes { // A gradient fitted to the path fits the path's own bounds. - let carry = crate::Carry::of(&e.id, ¶ms, bounds, path.bounds(), None, seed); + let carry = crate::Carry::of(&e.id, params, bounds, path.bounds(), None, seed); if carry.moves(&leaf.appearance) { carry.apply(&mut leaf.appearance); } @@ -807,24 +902,172 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if let Some((m, copies, options, _)) = transform && options.objects { + let placed = if watch { masked_placed(art, own) } else { 0 }; transform_placed(art, m, copies); + short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1)); } if outlines.iter().any(Option::is_some) { rebase_type_outlines(art, &mut outlines.into_iter()); } + // Under Transform copies, a mask takes the copies as the paths it masks do; the copies of a + // path with a mask of its own each carry it ([`copy_leaf`]). + let masks = self.reshape_masks(art, own, &Skip { moved: &moved_ids, kept: &kept_ids }, max, used, depth); // What paints the art as a whole, and the own fills and strokes of the groups inside it, // follow their bounds under Free Distort and Warp (they stay when no path changed them). // Each stays when a path under it that its bounds measure kept its shape (past the budget). let mut after = vec![]; let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; if !groups.is_empty() { - carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); + carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, params, bounds, from, to, seed)); } - if follows && !held { - whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); + let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), art.geometric_bounds(), seed)); + Ran { kept: !kept_ids.is_empty(), masks, short, whole } + } + + /// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path, + /// compound path, group and layer under it, with the art's reference box, as the paths the mask + /// covers take it: the mask moves and bends with what it masks. The mask of an object + /// transformed as an object moved with it already and is left as it is. A mask stays as it was + /// past [`vectorcraft_doc::depth::MAX_DEPTH`] masks inside masks, and for the reasons + /// [`Step::reshape_own_mask`] gives. Returns whether a mask stayed for one of those reasons. + fn reshape_masks(&self, n: &mut Node, own: bool, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) { + return false; + } + let mut stayed = own && self.reshape_own_mask(n, skip, max, used, depth); + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if has_linked_mask(c, true) { + stayed |= self.reshape_masks(Arc::make_mut(c), true, skip, max, used, depth); + } + } + } + stayed + } + + /// Run the effect over the art of the linked opacity mask of `n` itself, when `n` is a path, + /// compound path, group or layer that `skip` does not list as moved. The mask stays as it was + /// when a path it masks kept its shape, and when its own art could not take the effect whole: a + /// path in it would keep its shape, its copies would stop short, or a mask inside it would stay + /// (no mask is left half moved). Returns whether the mask stayed for one of those reasons. + fn reshape_own_mask(&self, n: &mut Node, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !reshapes_mask_of(n) || skip.moved.contains(&n.id) { + return false; + } + let stays = holds_any(n, skip.kept); + let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return false }; + let before = *used; + let mut art = (*m.art).clone(); + let whole = copies_whole(&art); + let ran = (!stays).then(|| self.run(&mut art, max, used, whole, true, depth + 1)); + match ran { + Some(ran) if ran.complete() => { + // What paints the mask's art as a whole follows the effect as the art does. + if let Some(carry) = ran.whole + && carry.moves(&art.appearance) + { + carry.apply(&mut art.appearance); + } + m.art = Arc::new(art); + false + } + _ => { + *used = before; + true + } } } - whole +} + +/// The objects whose linked masks [`Step::reshape_masks`] leaves as they are: those transformed +/// as objects (`moved`), and those holding a path that kept its shape (`kept`). +struct Skip<'a> { + moved: &'a HashSet, + kept: &'a HashSet, +} + +/// Is `n` an object whose linked opacity mask a geometry effect on the art it is in reshapes with +/// it: a path, compound path, group or layer? An image or symbol instance moves its mask only +/// with the map a Transform effect places it by ([`transform_placed`]). +fn reshapes_mask_of(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. }) +} + +/// Does `n` (with `own`) or a path, compound path, group or layer under it carry a linked opacity +/// mask that [`Step::reshape_masks`] reshapes? +fn has_linked_mask(n: &Node, own: bool) -> bool { + let mine = own && reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked); + mine || match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().any(|c| has_linked_mask(c, true)), + _ => false, + } +} + +/// Run `f` on the art of the linked opacity mask of `n` (with `own`) and of each path, compound +/// path, group and layer under it, and on the masks inside that art, as [`Step::reshape_masks`] +/// reaches them: `depth` masks deep already, up to [`vectorcraft_doc::depth::MAX_DEPTH`]. +fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(&Node)) { + if depth >= vectorcraft_doc::depth::MAX_DEPTH { + return; + } + if own + && reshapes_mask_of(n) + && let Some(m) = n.extras().mask.as_deref().filter(|m| m.linked) + { + f(&m.art); + each_linked_mask(&m.art, true, depth + 1, f); + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind { + for c in children { + each_linked_mask(c, true, depth, f); + } + } +} + +/// Does [`Step::reshape_masks`] reshape the linked opacity mask of `n` itself? +fn has_own_linked_mask(n: &Node) -> bool { + reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked) +} + +/// Add to `out` the ids of the objects under `n` that the linked opacity mask of an object above +/// them covers (every one of them with `under`). +fn masked_ids(n: &Node, under: bool, out: &mut HashSet) { + for c in n.children().into_iter().flatten() { + if under { + out.insert(c.id); + } + masked_ids(c, under || has_own_linked_mask(c), out); + } +} + +/// How many images and symbol instances under `n` the linked opacity mask of an object above them +/// covers (every one of them with `under`). +fn masked_placed(n: &Node, under: bool) -> usize { + n.children().into_iter().flatten().map(|c| usize::from(under && is_placed(c)) + masked_placed(c, under || has_own_linked_mask(c))).sum() +} + +/// Is `n`, or anything under it, one of `ids`? +fn holds_any(n: &Node, ids: &HashSet) -> bool { + if ids.is_empty() { + return false; + } + let mut any = false; + n.walk(&mut |c| any |= ids.contains(&c.id)); + any +} + +/// Do the paths under `n` that [`for_each_leaf`] visits all fill by the non-zero rule without a +/// stroke that paints? Effects then reshape their outlines alike in any order that the +/// optimization of [`crate::geometry::optimize_geometry_effects`] takes. +fn leaves_safe(n: &Node) -> bool { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => { + let has_stroke = n.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); + leaf_geometry(n).is_none_or(|(_, rule)| rule == FillRule::NonZero && !has_stroke) + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().all(|c| leaves_safe(c)), + _ => true, + } } /// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by @@ -1045,7 +1288,9 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling let mut copy = leaf.clone(); copy.transform(xf, scaling); // Each copy's own geometry effects make a different amount, as the copy and its effects scale. - let cost = Budget::of(&moved).plus(effects_cost(©, &moved)); + let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); + // Each copy carries the art of its linked masks, moved with it. + each_linked_mask(©, true, 0, &mut |m| cost = cost.plus(art_cost(m))); if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { break; } @@ -1623,6 +1868,12 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() { one.appearance.transform_gradients(xf); } + // So does the art of its linked opacity mask. + if xf != Affine::IDENTITY + && let Some(m) = one.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) + { + Arc::make_mut(&mut m.art).transform(xf, false); + } one }; if copies == 0 { diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 9038598659..5f505f25c5 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Free Distort", [], None, - "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles", + "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays", has_doc, distort ), @@ -104,7 +104,8 @@ impl Projective { } /// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine -/// approximation at their centre. +/// approximation at their centre. The art of a linked opacity mask is warped with the object it +/// masks; an unlinked one stays where it is. fn warp_node(n: &mut Node, pr: &Projective) { n.pin_gradients(); match &mut n.kind { @@ -131,6 +132,9 @@ fn warp_node(n: &mut Node, pr: &Projective) { return; } } + if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) { + warp_node(Arc::make_mut(&mut m.art), pr); + } n.appearance.warp_gradients(&|p| pr.affine_near(p)); } diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 8a1e46c68a..274e9c6548 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -601,6 +601,32 @@ fn an_opacity_mask_on_type_bends_as_other_art() { assert_eq!(kept, 1, "the type's stroke stays a stroke"); } +/// An envelope bends the art of a linked opacity mask on its contents with them; an unlinked mask +/// stays where it is. +#[test] +fn an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one() { + for linked in [true, false] { + let mut s = session(); + let a = rect(&mut s); + let m = rect(&mut s); + sel(&mut s, &[a, m]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + if !linked { + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + } + let before = node(&s, a).extras().mask.as_ref().unwrap().art.path_data().cloned(); + sel(&mut s, &[a]); + let e = arch(&mut s, 60.0, json!({})); + let mut found = vec![]; + for n in &evaluated(&s, e) { + n.walk(&mut |c| found.extend(c.extras().mask.as_ref().map(|k| (c.path_data().cloned(), k.art.path_data().cloned())))); + } + let [(bent, mask)] = found.as_slice() else { panic!("one masked object: {found:?}") }; + assert!(bent != &before, "the contents bend"); + assert_eq!(mask, if linked { bent } else { &before }, "linked: {linked}"); + } +} + /// Expand turns an envelope of stroked type into its bent glyph outlines with the stroke on them, /// at its own weight, as it draws (#6644). #[test] diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index e9aa3c5329..dcfda305d9 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -526,6 +526,32 @@ fn distort_command_maps_corners() { assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0))); } +/// Free Distort warps the art of a linked opacity mask with the object it masks, and one undo puts +/// both back; an unlinked mask stays where it is. +#[test] +fn distort_command_warps_a_linked_opacity_mask_with_its_object() { + for linked in [true, false] { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + if !linked { + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + } + let mask_path = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().mask.as_ref().unwrap().art.path_data().cloned(); + let path = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().cloned(); + let before = (path(&s), mask_path(&s)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap(); + assert!(path(&s) != before.0); + let want = if linked { path(&s) } else { before.1.clone() }; + assert_eq!(mask_path(&s), want, "linked: {linked}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s), mask_path(&s)), before); + } +} + #[test] fn distort_perspective_midpoint_is_projective() { // Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..52ef4e5f45 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -594,6 +594,7 @@ since 2026-10-01. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Linked opacity masks follow geometry effects, Free Distort and envelopes:** a linked opacity mask moves with the art it masks, and an unlinked one stays in place, as [Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html) describes for moving masked art. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, and each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too. The effects on an object do not move that object's own mask. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object in the same undo step. An envelope bends only a linked mask; before, it bent an unlinked one too. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, or the mask would get fewer Transform copies), the art and its masks keep their shape under that effect. Existing documents with a geometry effect on a group whose members have linked masks draw differently: before, those masks stayed where they were. Which masks follow the effects is VectorCraft's own rule (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. From 94e983943850f823c58285f7034c6d3f0e62440a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:21:32 -0800 Subject: [PATCH 2/3] Opacity masks: linked masks follow group effects through type, plug-ins and perspective Type and live objects in the art of a linked opacity mask are outlined and evaluated before a group's or layer's geometry effects run over it, so the mask moves and bends with the art it masks. Before, the effects reached only the paths in a mask's art. - With Scale Strokes & Effects on, a Transform effect scales the strokes of an image's or symbol instance's linked mask as it scales those of a path's mask. - A mask's art takes Transform copies in the form it had before the first effect, as the art it masks does. - A linked mask whose art is one image or symbol instance takes a Transform effect's move and copies with the art it masks. Under any other geometry effect, which images and symbol instances do not take, art whose masks hold one keeps its shape. - A symbol instance in perspective projects the linked masks in its art with that art. - A live-effect plug-in that paints its pieces leaves art with linked masks as it is, since a mask can't take the pieces. Docs: the docs/target-app-parity.md line moves next to the Transparency entry and lists the cases where existing documents draw differently. --- crates/effects/src/group/tests.rs | 225 +++++++++++++++++++++++++++++ crates/effects/src/reshape.rs | 150 ++++++++++++++----- crates/engine/src/cmd/xform.rs | 2 +- crates/engine/src/tests/plugins.rs | 24 +++ crates/engine/src/tests/xform.rs | 19 +++ docs/target-app-parity.md | 5 +- 6 files changed, 387 insertions(+), 38 deletions(-) diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 23f2f24c02..5c4522bb4a 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -839,6 +839,26 @@ fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() { assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too"); } +/// The mask of a mask's art copied whole stays in the budget while that art is copied: when the +/// budget has no room for that mask's copy, the art keeps its shape with its masks. +#[test] +fn the_mask_of_a_masks_art_copied_whole_counts_against_the_budget() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true }; + let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let mask_art = masked_by(parts, white_square(6), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut art = Node::group(NodeId(10), vec![Arc::new(inner)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the four squares and the copies of three of them, not for the copy of the fourth. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 7 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let inner = &art.children().unwrap()[0]; + let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(inner.geometric_bounds(), square, "the path keeps its shape"); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 15.0, 10.0)), "and so does its mask"); +} + /// When a member group's linked mask can't take all the Transform copies its paths take (past the /// budget), the art keeps its shape under the effect, so the mask still covers the paths. #[test] @@ -953,6 +973,211 @@ fn a_mask_whose_art_passes_the_budget_stays_whole() { assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole"); } +/// Live objects in a member's linked mask are evaluated before the group's geometry effects run +/// over the mask's art, so the mask still covers the member: a Repeat in it moves and bends with +/// the member. +#[test] +fn a_groups_effects_reshape_a_live_object_in_a_members_mask() { + let mut grid = vectorcraft_doc::RepeatSpec::grid(vec![Arc::new(white_square(5))], 0.0, 0.0); + if let vectorcraft_doc::RepeatKind::Grid { rows, cols, .. } = &mut grid.kind { + (*rows, *cols) = (1, 1); + } + let repeat = Node::new(NodeId(4), NodeKind::Repeat(grid)); + let drawn: Vec = crate::expand_live(None, &repeat); + let leaf = |n: &Node| { + let mut found = None; + n.walk(&mut |c| found = found.take().or_else(|| c.path_data().cloned())); + found + }; + assert_eq!(drawn.iter().find_map(leaf), white_square(1).path_data().cloned(), "the Repeat draws one square over the member"); + for e in [reshaping_effects()[0].clone(), reshaping_effects()[4].clone()] { + let member = masked_by(white_square(1), repeat.clone(), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = &member.extras().mask.as_deref().unwrap().art; + assert!(member.path_data() != white_square(1).path_data(), "{}: the member is reshaped", e.id); + assert_eq!(leaf(mask), member.path_data().cloned(), "{}: the mask's square takes the effect with it", e.id); + } +} + +/// Type in a member's linked mask is outlined before the group's Transform effect moves the mask's +/// art with the member. +#[test] +fn a_groups_transform_moves_type_in_a_members_mask() { + if skip_without_craft_fonts() { + return; + } + let member = masked_by(white_square(1), type_h(), true); + let before = crate::reshape::outline_text(&type_h()).unwrap().geometric_bounds().unwrap(); + let mut g = Node::group(NodeId(10), vec![Arc::new(member)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + let mut text = false; + mask.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_))); + assert!(!text, "the type is outlined"); + let after = mask.geometric_bounds().unwrap(); + assert!((after.x0 - before.x0 - 30.0).abs() < 0.5 && (after.y0 - before.y0).abs() < 0.5, "{before:?} -> {after:?}"); +} + +/// The linked mask of a compound path in a group, and of a path in a layer, takes the geometry +/// effects with the art it masks. +#[test] +fn a_compound_paths_and_a_layers_members_masks_take_the_effects() { + let geometry = |n: &Node| crate::reshape::leaf_geometry(n).map(|(p, _)| p); + for e in reshaping_effects() { + let square = white_square(1); + let compound = Node::new(NodeId(1), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: FillRule::NonZero }); + let compound = masked_by(compound, white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(compound), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert!(geometry(member) != square.path_data().cloned(), "{}: the compound path is reshaped", e.id); + assert_eq!(geometry(&member.extras().mask.as_deref().unwrap().art), geometry(member), "{}: compound path", e.id); + + let mut layer = Node::layer(NodeId(10), "Layer", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + ch.extend([Arc::new(masked_by(square.clone(), white_square(4), true)), sq(2, 20.0, Color::BLACK)]); + } + layer.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &layer).unwrap(); + let member = &art.children().unwrap()[0]; + assert!(member.path_data() != square.path_data(), "{}: the layer's path is reshaped", e.id); + assert_eq!(member.extras().mask.as_deref().unwrap().art.path_data(), member.path_data(), "{}: layer", e.id); + } +} + +/// A group's Transform effect moves a symbol instance member's linked mask with the instance. +#[test] +fn a_groups_transform_moves_a_symbol_instance_members_linked_mask() { + let instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(instance, white_square(4), true)), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let instance = &art.children().unwrap()[0]; + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {instance:?}") }; + assert!((xf.translation().x - 30.0).abs() < 1e-9, "the instance moves: {xf:?}"); + let mask = instance.extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap(); + assert!((mask.x0 - 30.0).abs() < 1e-9 && (mask.x1 - 40.0).abs() < 1e-9, "its mask moves with it: {mask:?}"); +} + +/// With Scale Strokes & Effects on, a group's Transform effect scales the stroke of an image +/// member's linked mask as it scales that of a path member's mask. +#[test] +fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() { + let stroked = + Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0)); + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), stroked.clone(), true); + let path = masked_by(white_square(2), stroked, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(image), Arc::new(path)]); + let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let width = |n: &Node| n.extras().mask.as_deref().unwrap().art.appearance.stroke().map(|s| s.width); + let ch = art.children().unwrap(); + assert_eq!(width(&ch[1]), Some(4.0), "the path's mask"); + assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask"); +} + +/// The art of a member group's mask is copied as it was before the first effect: a mask of one +/// path takes a second Transform effect's copies path by path, as the path it masks does, though +/// the first effect (with Scale Strokes & Effects) made it a group of copies. +#[test] +fn a_masks_art_is_copied_as_it_was_before_the_first_effect() { + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + for strokes in [true, false] { + let params = serde_json::json!({"moveH": 30, "copies": 2, "strokes": strokes}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + } + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), inner.geometric_bounds()); + let kinds = |n: &Node| n.children().unwrap().iter().map(|c| std::mem::discriminant(&c.kind)).collect::>(); + let paths = &inner.children().unwrap()[0]; + assert_eq!(kinds(paths).len(), 3, "the first effect's three paths"); + assert!(paths.children().unwrap().iter().all(|c| matches!(c.kind, NodeKind::Compound { .. })), "each with its copies"); + assert_eq!(kinds(mask), kinds(paths), "the mask's art takes the copies as the paths do"); +} + +/// A 10 × 10 image with id `id` at the origin. +fn image_node(id: u64) -> Node { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + Node::new(NodeId(id), NodeKind::Image(Box::new(im))) +} + +/// A linked mask whose art is an image moves with the path it masks under a group's Transform +/// effect, and a member group's image mask takes the copies its paths take. +#[test] +fn a_groups_transform_moves_a_linked_mask_whose_art_is_an_image() { + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(white_square(1), image_node(4), true)), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert_eq!(member.geometric_bounds(), Some(Rect::new(30.0, 0.0, 40.0, 10.0))); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), member.geometric_bounds()); + + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), image_node(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + assert_eq!(inner.geometric_bounds(), Some(Rect::new(0.0, 0.0, 70.0, 10.0))); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), inner.geometric_bounds(), "the image and its copies"); +} + +/// An image in a linked mask's art takes no geometry effect but Transform, so under a Warp effect +/// the art keeps its shape with its mask. +#[test] +fn art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp() { + let member = masked_by(white_square(1), Node::group(NodeId(3), vec![Arc::new(image_node(4))]), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[4].clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert_eq!(member.path_data(), white_square(1).path_data(), "the path keeps its shape"); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0))); +} + +/// A symbol instance in perspective projects the linked masks of the objects in its art with them. +#[test] +fn perspective_projects_the_masks_in_a_symbols_art() { + let art = Node::group(NodeId(20), vec![Arc::new(masked_by(white_square(21), white_square(22), true))]); + let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut at = vectorcraft_doc::PerspectiveAttachment::new("left", 0.0); + at.projection = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.01, 0.0, 1.0]); + instance.extras_mut().perspective = Some(Box::new(at)); + let out = crate::reshape::reshape(None, &instance, Some(&art)).unwrap(); + let mut masked = vec![]; + out.walk(&mut |c| masked.extend(c.extras().mask.as_deref().map(|m| (c.path_data().cloned(), m.art.path_data().cloned())))); + let [(path, mask)] = masked.as_slice() else { panic!("one masked path: {masked:?}") }; + assert!(path != &white_square(21).path_data().cloned(), "the path is projected"); + assert_eq!(mask, path, "and so is its mask"); +} + /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit /// pitch, each crossing every bar of the other direction: n² crossings. fn crossing_bars(n: usize, effect: &str) -> Node { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 14be7ee124..feea93f18a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -11,7 +11,7 @@ //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use vectorcraft_doc::live::Hooks; @@ -580,7 +580,7 @@ fn as_art_opts( transform_items(&mut art, paint_doc); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { - project_leaves(&mut art, &h); + project_leaves(&mut art, &h, paint_doc); } // The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip // group). @@ -598,9 +598,29 @@ fn as_art_opts( Some(out) } -/// Map every path under `art` (and its gradients) through the projective map `h`, curves split -/// finely enough to follow it. -fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) { +/// Map every path under `art` (and its gradients), and every path in the art of the linked opacity +/// masks of the objects under it, through the projective map `h`, curves split finely enough to +/// follow it. The type and live objects in the art of those masks are outlined and evaluated +/// first ([`outlined_members`], with the symbols of `doc`). +fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography, doc: Option<&Document>) { + project_paths(art, h); + each_linked_mask_mut(art, false, 0, &mut |m| { + *m = outlined_mask_art(doc, m); + project_paths(m, h); + }); +} + +/// `art`, the art of an opacity mask, with type and live objects in it outlined and evaluated +/// ([`outlined_members`]), and as art that keeps its look ([`as_art`]) when it is type itself. +fn outlined_mask_art(doc: Option<&Document>, art: &Node) -> Node { + match art.kind { + NodeKind::Text(_) => as_art(doc, art, None).unwrap_or_else(|| art.clone()), + _ => outlined_members(doc, art), + } +} + +/// The paths of [`project_leaves`] outside the art of masks. +fn project_paths(art: &mut Node, h: &vectorcraft_geom::Homography) { let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p); let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25)); let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f); @@ -678,7 +698,8 @@ fn hoist_copies(art: &mut Node) { /// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect /// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when /// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of -/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]). When a mask +/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the +/// type and live objects in it outlined and evaluated first ([`outlined_members`]). When a mask /// stays as it was, or a Transform effect makes fewer copies than it asks for, art with linked /// masks keeps its shape under that effect. fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { @@ -686,8 +707,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if fx.is_empty() { return whole; } + let masked = has_linked_mask(art, false); + if masked { + each_linked_mask_mut(art, false, 0, &mut |m| *m = outlined_mask_art(doc, m)); + } let mut masks_safe = true; - each_linked_mask(art, false, 0, &mut |m| masks_safe &= leaves_safe(m)); + each_linked_mask(art, false, 0, &mut |_, m| masks_safe &= leaves_safe(m)); let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); let style_safe = leaves_safe(art) && masks_safe && !art_stroke; let listed = fx; @@ -695,17 +720,21 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget let fx = opt_fx.as_slice(); let seed = art.id.0; let mut used = art_cost(art); - each_linked_mask(art, false, 0, &mut |m| used = used.plus(art_cost(m))); + each_linked_mask(art, false, 0, &mut |_, m| used = used.plus(art_cost(m))); let max = budget.at_least(used); - // Read before the effects run: a path's own copies don't make it art of several objects. + // Read before the effects run: a path's own copies don't make it art of several objects. The + // same holds for the art of each mask, listed by the object it masks. let copy_whole = copies_whole(art); - let masked = has_linked_mask(art, false); + let mut wholes = HashMap::new(); + each_linked_mask(art, false, 0, &mut |id, m| { + wholes.entry(id).or_insert_with(|| copies_whole(m)); + }); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); - let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc }; + let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc, wholes: &wholes }; // Art with linked masks keeps its shape under an effect that a mask in it could not take // as the art it masks did, so no mask is left apart from its art. let before = masked.then(|| (art.clone(), used)); @@ -726,8 +755,9 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget /// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its /// merged params, the reference box and seed of the object's art, where a deferred live-effect -/// plug-in is listed, whether each Transform copy becomes an object, and the document that the -/// swatches and patterns of painted pieces are looked up in. +/// plug-in is listed, whether each Transform copy becomes an object, the document that the +/// swatches and patterns of painted pieces are looked up in, and [`copies_whole`] of the art of +/// each linked mask before the first effect, by the id of the object it masks. struct Step<'a> { e: &'a Effect, params: &'a serde_json::Value, @@ -736,6 +766,7 @@ struct Step<'a> { effect: Option, apart: bool, doc: Option<&'a Document>, + wholes: &'a HashMap, } /// What [`Step::run`] did to a piece of art. @@ -762,12 +793,17 @@ impl Ran { impl Step<'_> { /// Run the effect over `art`, whose paths and those of the linked opacity masks in it keep to /// `max` together with what they cost so far (`used`). `copy_whole` is [`copies_whole`] of the - /// art before its first effect, or of a mask's art before this effect. With `own`, `art` is the - /// art of a mask, and its own linked mask takes the effect too. `depth` counts the masks `art` is - /// in. + /// art, or of a mask's art, before the first effect. With `own`, `art` is the art of a mask, + /// and its own linked mask takes the effect too. `depth` counts the masks `art` is in. A + /// live-effect plug-in that paints its pieces leaves a mask's art, and art that holds objects + /// with linked masks, as they are and reports the masks as stayed: a mask can't take the + /// pieces as the art it masks does. fn run(&self, art: &mut Node, max: Budget, used: &mut Budget, copy_whole: bool, own: bool, depth: usize) -> Ran { let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed); if let Some(plugin) = painted_plugin(e) { + if own || has_linked_mask(art, false) { + return Ran { kept: false, masks: true, short: false, whole: None }; + } let at = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); for_each_leaf(art, &mut |leaf| { let Some((path, _)) = leaf_geometry(leaf) else { return }; @@ -807,6 +843,11 @@ impl Step<'_> { let options = crate::distort::TransformOptions::of(params); (m, copies, options, crate::distort::transform_affine(bounds, params, seed)) }); + // Images and symbol instances take only a Transform effect ([`transform_placed`]): a mask's + // art that holds one can't take another effect as the art it masks does. + if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) { + return Ran { kept: false, masks: true, short: false, whole: None }; + } // Art of several objects is copied whole, so the copies don't interleave member by member. // The copies of its members carry their linked masks, moved with them. if let Some((m, copies, options, _)) = transform @@ -818,10 +859,12 @@ impl Step<'_> { return Ran { kept: false, masks: false, short: false, whole: None }; } // A mask's art: its copies keep to what the rest of the art leaves of `max`, and the - // linked mask of the art itself takes them too. + // linked mask of the art itself takes them too. That mask's art stays in `used`, as + // [`copy_art`] counts only the members. let before = copy_cost(art); - let made = copy_art(art, m, copies, copy_scaling(options), max.minus(used.minus(before))); - *used = used.minus(before).plus(made); + let rest = used.minus(before).plus(art.extras().mask.as_deref().map_or(Budget { anchors: 0, lines: 0 }, |k| copy_cost(&k.art))); + let made = copy_art(art, m, copies, copy_scaling(options), max.minus(rest)); + *used = rest.plus(made); let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies); let none = HashSet::new(); let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth); @@ -902,9 +945,20 @@ impl Step<'_> { if let Some((m, copies, options, _)) = transform && options.objects { + // A mask's art that is one image or symbol instance is placed as the only member of a + // group of it. + let root = is_placed(art); + if root { + let id = art.id; + let one = std::mem::replace(art, Node::group(id, vec![])); + *art = Node::group(id, vec![Arc::new(one)]); + } let placed = if watch { masked_placed(art, own) } else { 0 }; - transform_placed(art, m, copies); + transform_placed(art, m, copies, copy_scaling(options)); short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1)); + if root && let Some([one]) = art.children().map(Vec::as_slice) { + *art = (**one).clone(); + } } if outlines.iter().any(Option::is_some) { rebase_type_outlines(art, &mut outlines.into_iter()); @@ -955,10 +1009,11 @@ impl Step<'_> { return false; } let stays = holds_any(n, skip.kept); + let id = n.id; let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return false }; let before = *used; let mut art = (*m.art).clone(); - let whole = copies_whole(&art); + let whole = self.wholes.get(&id).copied().unwrap_or_else(|| copies_whole(&art)); let ran = (!stays).then(|| self.run(&mut art, max, used, whole, true, depth + 1)); match ran { Some(ran) if ran.complete() => { @@ -1003,10 +1058,11 @@ fn has_linked_mask(n: &Node, own: bool) -> bool { } } -/// Run `f` on the art of the linked opacity mask of `n` (with `own`) and of each path, compound -/// path, group and layer under it, and on the masks inside that art, as [`Step::reshape_masks`] -/// reaches them: `depth` masks deep already, up to [`vectorcraft_doc::depth::MAX_DEPTH`]. -fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(&Node)) { +/// Run `f` on the id of `n` (with `own`) and of each path, compound path, group and layer under it +/// that carries a linked opacity mask, with the art of that mask, and on the masks inside that +/// art, as [`Step::reshape_masks`] reaches them: `depth` masks deep already, up to +/// [`vectorcraft_doc::depth::MAX_DEPTH`]. +fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(NodeId, &Node)) { if depth >= vectorcraft_doc::depth::MAX_DEPTH { return; } @@ -1014,7 +1070,7 @@ fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(&Node)) && reshapes_mask_of(n) && let Some(m) = n.extras().mask.as_deref().filter(|m| m.linked) { - f(&m.art); + f(n.id, &m.art); each_linked_mask(&m.art, true, depth + 1, f); } if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind { @@ -1024,6 +1080,29 @@ fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(&Node)) } } +/// [`each_linked_mask`] that changes the art of each mask: `f` runs on a mask's art before the +/// masks inside it are reached. +fn each_linked_mask_mut(n: &mut Node, own: bool, depth: usize, f: &mut dyn FnMut(&mut Node)) { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) { + return; + } + if own + && reshapes_mask_of(n) + && let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) + { + let art = Arc::make_mut(&mut m.art); + f(art); + each_linked_mask_mut(art, true, depth + 1, f); + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if has_linked_mask(c, true) { + each_linked_mask_mut(Arc::make_mut(c), true, depth, f); + } + } + } +} + /// Does [`Step::reshape_masks`] reshape the linked opacity mask of `n` itself? fn has_own_linked_mask(n: &Node) -> bool { reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked) @@ -1290,7 +1369,7 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling // Each copy's own geometry effects make a different amount, as the copy and its effects scale. let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); // Each copy carries the art of its linked masks, moved with it. - each_linked_mask(©, true, 0, &mut |m| cost = cost.plus(art_cost(m))); + each_linked_mask(©, true, 0, &mut |_, m| cost = cost.plus(art_cost(m))); if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { break; } @@ -1822,17 +1901,20 @@ fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine { /// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as /// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more /// follow, each mapped once more than the last. Without copies the object itself takes the map, -/// as a path does. -fn transform_placed(n: &mut Node, m: Affine, copies: usize) { - transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS) +/// as a path does. The art of each one's linked opacity mask takes the map with `scaling`, as a +/// path's mask does. +fn transform_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling) { + let mut room = crate::distort::MAX_COPY_OBJECTS.saturating_sub(placed_count(n)); + copy_placed(n, m, copies, scaling, &mut room); } /// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art, /// the originals included: each copy adds the object once more, and a Transform after this one /// copies the lot again, so copies stop where the result would pass `max` (#2042). +#[cfg(test)] pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) { let mut room = max.saturating_sub(placed_count(n)); - copy_placed(n, m, copies, &mut room); + copy_placed(n, m, copies, vectorcraft_doc::Scaling::default(), &mut room); } /// An object that a Transform effect places through its transform rather than reshapes: an image @@ -1845,11 +1927,11 @@ fn is_placed(n: &Node) -> bool { /// document order, each copy taking one from `room` (what the art can still grow by), until the /// budget is full. As with a path, the copies stop before one reaches past /// [`crate::distort::within_reach`]. -fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { +fn copy_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, room: &mut usize) { let Some(children) = n.children_mut() else { return }; for c in children.iter_mut() { if !is_placed(c) { - copy_placed(Arc::make_mut(c), m, copies, room); + copy_placed(Arc::make_mut(c), m, copies, scaling, room); continue; } let src = (**c).clone(); @@ -1872,7 +1954,7 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { if xf != Affine::IDENTITY && let Some(m) = one.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) { - Arc::make_mut(&mut m.art).transform(xf, false); + Arc::make_mut(&mut m.art).transform(xf, scaling); } one }; diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 5f505f25c5..617d9d944c 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -104,7 +104,7 @@ impl Projective { } /// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine -/// approximation at their centre. The art of a linked opacity mask is warped with the object it +/// approximation at their center. The art of a linked opacity mask is warped with the object it /// masks; an unlinked one stays where it is. fn warp_node(n: &mut Node, pr: &Projective) { n.pin_gradients(); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..29f9d5a6e2 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -505,6 +505,30 @@ fn a_painted_group_effect_links_to_the_document_s_swatches() { assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green"); } +/// A painted effect on a group leaves the group's art as it is when a path in it has a linked +/// opacity mask, which can't take the pieces as the path does: the mask still covers the path. +#[test] +fn a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let m = rect(&mut s, 10.0, "#ffffff"); + run(&mut s, "select.set", json!({"ids": [a.0, m.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + rect(&mut s, 100.0, "#0000ff"); + run(&mut s, "select.all", json!({})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.maskedpaint", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.maskedpaint"})); + let art = vectorcraft_render::effects::evaluate_container(Some(&s.doc().unwrap().doc), &node(&s, g)).unwrap(); + let mut masked = vec![]; + art.walk(&mut |c| masked.extend(c.extras().mask.as_ref().map(|k| (c.geometric_bounds(), k.art.geometric_bounds())))); + assert!(!masked.is_empty(), "the masked path"); + for (path, mask) in masked { + assert_eq!(path, mask, "the mask covers the path"); + } +} + /// The output of a painted effect that returns one green square linked to swatch `Leaf`. fn leaf_square() -> String { let mut leaf = solid("#00ff00"); diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index dcfda305d9..54c653e938 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -552,6 +552,25 @@ fn distort_command_warps_a_linked_opacity_mask_with_its_object() { } } +/// Free Distort of a group warps the linked opacity mask of a path inside it with the path. +#[test] +fn distort_command_warps_the_linked_mask_of_a_path_in_a_group() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let c = rect(&mut s, 150.0, 0.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let before = s.doc().unwrap().doc.node(a).unwrap().path_data().cloned(); + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + s.execute("object.distort", &json!({"corners": [[10, 0], [190, 0], [200, 100], [0, 100]]})).unwrap(); + let a = s.doc().unwrap().doc.node(a).unwrap(); + assert!(a.path_data().cloned() != before, "the path is warped"); + assert_eq!(a.extras().mask.as_ref().unwrap().art.path_data(), a.path_data(), "and so is its mask"); +} + #[test] fn distort_perspective_midpoint_is_projective() { // Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 52ef4e5f45..4ef9318c80 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -593,9 +593,7 @@ since 2026-10-01. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. -- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. -- **Linked opacity masks follow geometry effects, Free Distort and envelopes:** a linked opacity mask moves with the art it masks, and an unlinked one stays in place, as [Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html) describes for moving masked art. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, and each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too. The effects on an object do not move that object's own mask. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object in the same undo step. An envelope bends only a linked mask; before, it bent an unlinked one too. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, or the mask would get fewer Transform copies), the art and its masks keep their shape under that effect. Existing documents with a geometry effect on a group whose members have linked masks draw differently: before, those masks stayed where they were. Which masks follow the effects is VectorCraft's own rule (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`). Scores and hours unchanged. -- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. +- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. @@ -1238,6 +1236,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off. Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. +- **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. From b4fbcc5672ef22be4ac60a48652812a1532c5779 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:58:57 -0800 Subject: [PATCH 3/3] Opacity masks: a strokes-only Transform effect scales a member's mask With Transform Objects off, a group's Transform effect scales the strokes and moves the patterns of a member's linked mask as it does the member's, instead of leaving the mask as it was. --- crates/effects/src/group/tests.rs | 18 ++++++++++++++++++ crates/effects/src/reshape.rs | 6 +++++- docs/target-app-parity.md | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index ddfe566fb8..a7fb05b2a6 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1091,6 +1091,24 @@ fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() { assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask"); } +/// With Transform Objects off and Scale Strokes & Effects on, a group's Transform effect scales the +/// strokes of a member's linked mask as it scales the member's, and moves neither. +#[test] +fn a_transform_of_strokes_only_scales_a_members_mask_strokes() { + let stroked = |id: u64| { + Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0)) + }; + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(stroked(1), stroked(4), true))]); + let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = &member.extras().mask.as_deref().unwrap().art; + assert_eq!(member.appearance.stroke().map(|s| s.width), Some(4.0), "the member's stroke"); + assert_eq!(mask.appearance.stroke().map(|s| s.width), Some(4.0), "the mask's stroke"); + assert_eq!(mask.geometric_bounds(), member.geometric_bounds()); +} + /// The art of a member group's mask is copied as it was before the first effect: a mask of one /// path takes a second Transform effect's copies path by path, as the path it masks does, though /// the first effect (with Scale Strokes & Effects) made it a group of copies. diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 6fa307d02e..483ac44d6e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -947,7 +947,11 @@ impl Step<'_> { let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); *used = used.minus(before).plus(made); if moved { - moved_ids.insert(leaf.id); + // With Transform Objects off the path stays, and its mask takes the + // effect's stroke and pattern changes as its own art. + if options.objects { + moved_ids.insert(leaf.id); + } short |= counts(leaf.id) && copied(leaf) <= copies; } else { kept_ids.insert(leaf.id); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1bbf2c8a60..6b37d9e457 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1240,7 +1240,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off. Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. -- **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. +- **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. With Transform Objects off, a member's mask takes the effect's stroke and pattern changes as the member does. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::a_transform_of_strokes_only_scales_a_members_mask_strokes`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel.