From 59304f9964acc0d3911fc3f35920e8b6d4761583 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 14:27:25 -0800 Subject: [PATCH] Fix #6316: a Transform effect's copies of a group are whole groups below the original A Transform effect with copies on a group copied each member on its own, so one member's copies lay over the fills of the next member. Each copy is now a whole group, the original on top and each copy below the one before. A path's copies follow the same order. Guides are not copied, and the copies stop at the node limit. A group with its own fill or stroke, a clipping group and live type keep their previous copies. --- crates/effects/src/bake/tests.rs | 4 +- crates/effects/src/catalog.rs | 2 +- crates/effects/src/group/tests.rs | 5 +- crates/effects/src/reshape.rs | 132 ++++++++++++++++++++--- crates/effects/src/tests/transformfx.rs | 106 +++++++++++++++--- crates/engine/src/cmd/effectcmd/tests.rs | 6 +- docs/effects-parity.md | 2 +- 7 files changed, 221 insertions(+), 36 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index a2959a843a..4751b137c9 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -603,7 +603,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() { let m = bake_appearance(None, &n).unwrap(); let w = widths(&m); assert_eq!(w.len(), 2, "{w:?}"); - assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}"); + assert!((w[1] - 102.0).abs() < 0.5 && (w[0] - 204.0).abs() < 0.5, "the original on top: {w:?}"); assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined"); } @@ -619,7 +619,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() { p => panic!("not a pattern: {p:?}"), }) .collect(); - assert_eq!(xs, vec![0.0, 30.0]); + assert_eq!(xs, vec![30.0, 0.0], "the original on top"); } #[test] diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a8176880bd..95111b5fb4 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -247,7 +247,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 619d06a911..94456c7d57 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -594,7 +594,7 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() { }; for kind in [image as fn() -> NodeKind, instance] { assert_eq!(starts(kind(), serde_json::json!({"moveH": 30})), [30.0], "{:?}", kind()); - assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [0.0, 30.0, 60.0], "{:?}", kind()); + assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [60.0, 30.0, 0.0], "{:?}", kind()); } } @@ -677,7 +677,8 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { 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 (i, c) in copies.iter().enumerate() { + // The original is on top, after the copies below it. + for (i, c) in copies.iter().rev().enumerate() { // Each copy's own box: an unplaced gradient fits it, a placed one moved with it. assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6); match c.appearance.fill_paint() { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 070060dcf9..4db4bb3f5a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -46,8 +46,8 @@ pub fn has_gradient_copies(n: &Node) -> bool { /// Is `n` a path or compound path with a visible Transform effect that makes copies and fills and /// strokes that paint the copies differently as one path than as separate objects /// ([`paints_copies_apart`])? Such an object is drawn through its art ([`outline_art`]), each copy -/// an object of its own above the one before ([`copy_leaf`]), as Expand Appearance makes it -/// (#5920). +/// an object of its own below the one before ([`copy_leaf`]), as Expand Appearance makes it +/// (#5920, #6316). pub(crate) fn has_layered_copies(n: &Node) -> bool { leaf_geometry(n).is_some_and(|(_, rule)| paints_copies_apart(&n.appearance, rule)) && has_transform_copies(n) } @@ -698,6 +698,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b let seed = art.id.0; let mut used = art_cost(art); 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); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); @@ -738,6 +740,14 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b let options = crate::distort::TransformOptions::of(¶ms); (m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed)) }); + // Art of several objects is copied whole, so the copies don't interleave member by member. + if let Some((m, copies, options, _)) = transform + && copies > 0 + && copy_whole + { + used = copy_art(art, m, copies, copy_scaling(options), max); + continue; + } for_each_leaf(art, &mut |leaf| { let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. @@ -747,7 +757,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b // Transform Patterns and Scale Strokes & Effects change each copy's fills and // 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 above the one before (#5920). + // Fills and strokes that stack paint each copy below the one before (#5920, #6316). let separate = copies > 0 && (apart || leaf.appearance.has_unplaced_gradient() @@ -813,6 +823,19 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b /// doesn't grow with them (a horizontal line stretched vertically). const MAX_STROKE_SCALE: f64 = 1.0e6; +/// How a Transform effect with `options` scales what it maps: strokes, effects and patterns as +/// its options say. +fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::Scaling { + vectorcraft_doc::Scaling { + strokes: options.strokes, + effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), + patterns: options.patterns, + // A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does. + keep_fit_when_flat: true, + ..Default::default() + } +} + /// A Transform effect whose Transform Patterns or Scale Strokes & Effects is on (`options`), with /// map `m` and `copies`, applied to `leaf` (a path or compound path with geometry `path`) as Object /// › Transform with those options transforms an object (#885): the strokes' weights and dashes @@ -829,14 +852,7 @@ fn transform_leaf( options: crate::distort::TransformOptions, budget: Budget, ) -> (Budget, bool) { - let scaling = vectorcraft_doc::Scaling { - strokes: options.strokes, - effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), - patterns: options.patterns, - // A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does. - keep_fit_when_flat: true, - ..Default::default() - }; + let scaling = copy_scaling(options); if !options.objects { if options.strokes && let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0) @@ -861,10 +877,92 @@ fn transform_leaf( (copy_leaf(leaf, path, m, copies, scaling, budget), true) } +/// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a +/// group or layer of several objects, at least one of which paints, with no type left in it and no +/// fill or stroke of its own that paints. Type that stays type is copied in its place by the baking +/// that keeps it, a clipping group's first member is its clip, and the art's own fills and strokes +/// paint the shape of everything under it, copies included. +fn copies_whole(art: &Node) -> bool { + fn parts(n: &Node, count: &mut usize, painted: &mut bool, text: &mut bool) { + match &n.kind { + NodeKind::Text(_) => *text = true, + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => { + *count += 1; + *painted |= n.appearance.items.iter().any(crate::paints); + } + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + *count += 1; + *painted = true; + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + *painted |= n.appearance.items.iter().any(crate::paints); + children.iter().for_each(|c| parts(c, count, painted, text)); + } + _ => {} + } + } + if art.clips() || !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || art.appearance.items.iter().any(crate::paints) { + return false; + } + let (mut count, mut painted, mut text) = (0, false, false); + parts(art, &mut count, &mut painted, &mut text); + count > 1 && painted && !text +} + +/// Remove the guides anywhere under `n`. +fn without_guides(n: &mut Node) { + let Some(children) = n.children_mut() else { return }; + children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })); + children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c))); +} + +/// Replace the members of `art` by groups of them: the original on top and `copies` more below it, +/// each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies a +/// group, without its guides. The copies stop where [`copy_leaf`]'s would, before they pass `budget` or +/// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all. Returns what the art costs after. +fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { + let Some(members) = art.children().cloned() else { return art_cost(art) }; + let original = Node::group(art.id, members); + let mut made = art_cost(&original); + let mut drawn = original.clone(); + without_guides(&mut drawn); + let mut nodes = 0usize; + original.walk(&mut |_| nodes += 1); + let mut one = 0usize; + drawn.walk(&mut |_| one += 1); + let mut placements: Vec> = vec![]; + let mut xf = Affine::IDENTITY; + for _ in 0..copies { + xf *= m; + if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) { + break; + } + nodes = nodes.saturating_add(one); + if nodes > crate::distort::MAX_COPY_OBJECTS { + break; + } + let mut copy = drawn.clone(); + copy.transform(xf, scaling); + let cost = art_cost(©); + if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { + break; + } + placements.push(Arc::new(copy)); + made = made.plus(cost); + } + // The last copy is the lowest. + placements.reverse(); + placements.push(Arc::new(original)); + if let Some(children) = art.children_mut() { + *children = placements; + } + made +} + /// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies` -/// more, each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies -/// an object. Each copy is an object of its own, so its gradients fit it (a placed gradient moves -/// with it) rather than spanning every copy, and its strokes and patterns can differ from the +/// more below it, each mapped once more by `m` than the last (with `scaling`), as a Transform effect +/// copies an object. Each copy is an object of its own, so its gradients fit it (a placed gradient +/// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the /// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass /// `budget`, and before one's strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the /// copies cost, the original included. @@ -890,7 +988,9 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling placements.push(Arc::new(copy)); made = made.plus(cost); } - // The copies become nodes of their own under a group with the object's id and name. + // The copies become nodes of their own under a group with the object's id and name, the object + // on top. + placements.reverse(); let mut g = Node::group(leaf.id, placements); g.name = leaf.name.clone(); *leaf = g; @@ -1463,6 +1563,8 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { placements.push(Arc::new(copy)); } *room -= placements.len() - 1; + // The object stays on top of its copies, as a path does. + placements.reverse(); let mut g = Node::group(src.id, placements); g.name = src.name.clone(); *c = Arc::new(g); diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index 84368b6f64..e424a8293b 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -105,7 +105,7 @@ fn each_copy_scales_its_stroke_once_more() { let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true})); let art = reshape(None, &n, None).unwrap(); let widths: Vec = leaves(&art).iter().map(|p| stroke_of(p).0).collect(); - assert_eq!(widths, vec![2.0, 4.0, 8.0]); + assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top"); // Without the option the copies keep the original's weight. let plain = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2})); assert!(!has_transform_art(&plain)); @@ -127,7 +127,7 @@ fn transform_patterns_moves_each_copys_pattern() { assert!(has_transform_options(&n)); let art = reshape(None, &n, None).unwrap(); let xs: Vec = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect(); - assert_eq!(xs, vec![0.0, 30.0, 60.0]); + assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top"); // Without it, every copy's pattern stays put (the tiles don't follow the copies). let n = patterned(json!({"moveH": 30, "patterns": false, "strokes": false, "objects": true})); assert!(!has_transform_art(&n)); @@ -218,7 +218,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true})); let art = reshape(None, &n, None).unwrap(); let widths: Vec = stroked_leaves(&art).iter().map(|p| stroke_of(p).0).collect(); - assert_eq!(widths, vec![2.0, 4.0, 8.0]); + assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top"); } #[test] @@ -268,7 +268,7 @@ fn a_fills_own_transform_moves_each_copys_pattern_with_transform_patterns() { assert!(has_transform_options(&n)); let art = reshape(None, &n, None).unwrap(); let xs: Vec = pattern_leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect(); - assert_eq!(xs, vec![0.0, 30.0, 60.0]); + assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top"); // Off, the pattern stays put. assert!(!has_transform_art(&fill_with_effect(json!({"moveH": 30, "copies": 2})))); } @@ -294,7 +294,7 @@ fn a_strokes_own_transform_draws_bakes_expands_and_measures_alike() { let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); let drawn = reshape(None, &n, None).unwrap(); let widths = |art: &Node| stroked_leaves(art).iter().map(|p| stroke_of(p).0).collect::>(); - assert_eq!(widths(&drawn), vec![2.0, 4.0]); + assert_eq!(widths(&drawn), vec![4.0, 2.0], "the original on top"); // Expand Appearance. assert_eq!(widths(&expand_outlined(None, &n, None).unwrap()), widths(&drawn)); // Export. @@ -394,9 +394,9 @@ fn filled_and_stroked(fill: Paint, params: Value) -> Node { } #[test] -fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() { - // Black fill, white stroke, each copy scaled and turned (#5920): each copy is an object of its - // own above the one before, so its fill covers the strokes of the copies below. +fn copies_with_a_fill_and_a_stroke_are_drawn_one_below_another() { + // Black fill, white stroke, each copy scaled and turned (#5920, #6316): each copy is an object + // of its own below the one before, so the fills above cover the strokes of the copies below. let params = json!({"scaleH": 97, "scaleV": 97, "rotate": 10, "copies": 25}); let n = filled_and_stroked(Paint::solid(Color::BLACK), params.clone()); assert!(has_transform_art(&n)); @@ -407,10 +407,10 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() { assert!(matches!(p.kind, NodeKind::Path { .. }), "one shape each, not one path of every copy"); assert!(!p.appearance.fill_paint().is_none() && p.appearance.stroke().is_some()); } - // The original is at the bottom, the smallest copy on top. - assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); - let top = paths[25].geometric_bounds().unwrap(); - assert!(top.width() < 100.0 && top.height() < 100.0, "{top:?}"); + // The smallest copy is at the bottom, the original on top. + assert_eq!(paths[25].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + let bottom = paths[0].geometric_bounds().unwrap(); + assert!(bottom.width() < 100.0 && bottom.height() < 100.0, "{bottom:?}"); // Drawn as Expand Appearance makes it. assert_eq!(leaves(&expand_outlined(None, &n, None).unwrap()).len(), 26); @@ -427,3 +427,85 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() { // No copies: nothing to stack. assert!(!has_transform_art(&filled_and_stroked(Paint::solid(Color::BLACK), json!({"rotate": 10})))); } + +/// A group of two squares side by side, each with a black fill and a white stroke, with the +/// Transform effect `params`. +fn two_squares(params: Value) -> Node { + let square = |id, x| { + let a = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0); + std::sync::Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), a)) + }; + let mut g = Node::group(NodeId(20), vec![square(21, 0.0), square(22, 120.0)]); + g.appearance.effects.push(transform(params)); + g +} + +#[test] +fn a_groups_copies_are_whole_groups_below_the_original() { + let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 95, "scaleV": 95, "copies": 10}))).unwrap(); + let copies = art.children().unwrap(); + assert_eq!(copies.len(), 11, "the original and 10 copies"); + for c in copies { + assert_eq!(c.children().map(Vec::len), Some(2), "each copy holds both squares"); + } + // The smallest copy is at the bottom, the original on top with its members in their order. + let width = |i: usize| copies[i].geometric_bounds().unwrap().width(); + assert!((0..10).all(|i| width(i) < width(i + 1)), "{:?}", (0..11).map(width).collect::>()); + assert_eq!(copies[10].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 220.0, 100.0)); + let members = copies[10].children().unwrap(); + assert_eq!(members[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_eq!(members[1].geometric_bounds().unwrap(), Rect::new(120.0, 0.0, 220.0, 100.0)); + + // Without copies the members stay members of the art. + let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 50, "scaleV": 50}))).unwrap(); + assert_eq!(art.children().unwrap().iter().map(|c| c.id).collect::>(), [NodeId(21), NodeId(22)]); + + // A group's own fill paints the shape of all its copies, so each member keeps its place. + let mut own_fill = two_squares(json!({})); + own_fill.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into(); + let copies = transform(json!({"moveH": 300, "copies": 2})); + crate::reshape::reshape_leaves_within(&mut own_fill, &[&copies], crate::geometry::Budget::EFFECTS, None); + assert_eq!(own_fill.children().map(Vec::len), Some(2), "a stack of copies per member"); + + // A guide in the group stays one guide. + let mut guided = two_squares(json!({})); + let mut guide = Node::path(NodeId(23), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + if let NodeKind::Path { guide: on, .. } = &mut guide.kind { + *on = true; + } + if let Some(members) = guided.children_mut() { + members.push(std::sync::Arc::new(guide)); + } + guided.appearance.effects.clear(); + crate::reshape::reshape_leaves_within(&mut guided, &[&copies], crate::geometry::Budget::EFFECTS, None); + assert_eq!(guided.children().map(Vec::len), Some(3), "the original and 2 copies"); + let mut guides = 0; + guided.walk(&mut |n| guides += usize::from(matches!(n.kind, NodeKind::Path { guide: true, .. }))); + assert_eq!(guides, 1); + + // A clipping group's first member stays its clip. + let mut clipping = two_squares(json!({})); + if let NodeKind::Group { clip, .. } = &mut clipping.kind { + *clip = true; + } + clipping.appearance.effects.clear(); + crate::reshape::reshape_leaves_within(&mut clipping, &[&copies], crate::geometry::Budget::EFFECTS, None); + assert_eq!(clipping.children().map(Vec::len), Some(2), "the clip and the clipped member"); +} + +#[test] +fn a_groups_copies_stop_at_the_node_limit() { + // Empty groups cost no anchors, so only the count of nodes stops their copies. + let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![]))); + let mut g = two_squares(json!({})); + if let Some(members) = g.children_mut() { + members.extend(empties); + } + g.appearance.effects.clear(); + let copies = transform(json!({"moveH": 1, "copies": 1000})); + crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None); + let mut nodes = 0usize; + g.walk(&mut |_| nodes += 1); + assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}"); + assert!(g.children().unwrap().len() < 1001, "the limit stopped the copies"); +} diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index e3fca957b8..088094e556 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -581,7 +581,7 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() { let copies = n.children().unwrap(); assert_eq!(copies.len(), 3, "the path and its two copies"); let xs: Vec = copies.iter().map(|c| c.geometric_bounds().unwrap().x0).collect(); - assert_eq!(xs, vec![100.0, 250.0, 400.0]); + assert_eq!(xs, vec![400.0, 250.0, 100.0], "the original on top"); for c in copies { assert!(matches!(c.kind, NodeKind::Path { .. }), "{}", c.kind_label()); assert_eq!(fill(c), Some(Color::rgb(1.0, 0.0, 0.0))); @@ -600,10 +600,10 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() { s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); let columns = node(&s, id).children().unwrap().to_vec(); assert_eq!(columns.len(), 3); - for (col, x) in columns.iter().zip([100.0, 250.0, 400.0]) { + for (col, x) in columns.iter().zip([400.0, 250.0, 100.0]) { let cells = col.children().unwrap_or_else(|| panic!("a column is a group: {}", col.kind_label())); let ys: Vec<(f64, f64)> = cells.iter().map(|c| (c.geometric_bounds().unwrap().x0, c.geometric_bounds().unwrap().y0)).collect(); - assert_eq!(ys, vec![(x, 100.0), (x, 250.0)]); + assert_eq!(ys, vec![(x, 250.0), (x, 100.0)]); assert!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. }))); } } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 8f90ce5f4d..f2b9beda5a 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---|---:|---| | Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | | Crop Marks | Crop Marks | 1/1 | — | -| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612); drawn, each copy of a path with a fill and a stroke (or several, an even-odd fill or a partly transparent one) paints above the one before, its fill covering the strokes of the copies below, instead of one path of every copy (#5920) | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612); drawn, each copy of a path with a fill and a stroke (or several, an even-odd fill or a partly transparent one) paints below the one before, the fills above covering the strokes of the copies below, instead of one path of every copy (#5920), and a group's copies each hold all its members, the original group on top (#6316) | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — |