diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 313476c01..8835d769f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -79,8 +79,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, - needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options, + image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index fa77c5702..38d79c125 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -39,8 +39,13 @@ pub fn needs_outline(n: &Node) -> bool { /// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy /// paints its gradient by itself ([`copy_leaf`]), as a group's copies do. pub fn has_gradient_copies(n: &Node) -> bool { + (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) && has_transform_copies(n) +} + +/// Is `n` a path or compound path with a visible Transform effect that makes copies? Expand +/// Appearance makes each copy an object of its own ([`expand_outlined`]). +pub fn has_transform_copies(n: &Node) -> bool { matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) - && (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) && geometry_effects(n) .iter() .any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0) @@ -249,11 +254,20 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { - outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default()) + outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false) } -/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]). -fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option { +/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]); with +/// `apart`, also a path or compound path whose Transform effect makes copies +/// ([`has_transform_copies`]), as the one piece the copies are made from. +fn outline_art_with( + doc: Option<&Document>, + n: &Node, + symbol_art: Option<&Node>, + outline: OutlineHook, + nodes: &SymbolNodes, + apart: bool, +) -> Option { let mut art = match &n.kind { NodeKind::Text(_) => outline_text(n)?, NodeKind::SymbolInstance { symbol, xf } => { @@ -268,7 +282,7 @@ fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, Node::group(n.id, vec![Arc::new(a)]) } // The object as the one piece the copies of its Transform effect are made from. - _ if has_transform_art(n) => { + _ if has_transform_art(n) || (apart && has_transform_copies(n)) => { let mut one = n.clone(); one.opacity = 1.0; one.blend = Default::default(); @@ -429,10 +443,12 @@ pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> O /// geometry effects, else type with fills or strokes of its own outlined, or an image with fills /// or strokes of its own that paint a shape, in the same form. `None` when it has none of these. pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { - // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its - // patterns: each copy an object of its own. - if has_transform_art(n) { - return as_art(doc, n, symbol_art); + // A path whose Transform effect copies it, or scales its strokes or moves its patterns: each + // copy an object of its own with the path's fills and strokes, the copies of each Transform + // effect in a group of their own (copies of copies nest), as the reference app expands them + // (#5612). + if has_transform_art(n) || has_transform_copies(n) { + return as_art_opts(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true); } let own_paint = match n.kind { NodeKind::Text(_) => !n.appearance.items.is_empty(), @@ -451,6 +467,12 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node> /// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]). pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option { + as_art_opts(doc, n, symbol_art, outline, nodes, false) +} + +/// [`as_art_with`]; with `apart`, a path's or compound path's Transform effects make each copy an +/// object of its own ([`copy_leaf`]) rather than one path of every copy. +fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes, apart: bool) -> Option { let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -478,9 +500,12 @@ pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<& Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect()) } } - _ => outline_art_with(doc, n, symbol_art, outline, nodes)?, + _ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?, }; - reshape_leaves(&mut art, &fx); + reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart); + if apart { + hoist_copies(&mut art); + } transform_items(&mut art); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { @@ -546,6 +571,28 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { /// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost. pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) { + reshape_leaves_apart(art, fx, budget, false); +} + +/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding +/// the group of its copies ([`copy_leaf`]) as its only member: the copies become its members, so +/// the expanded path is one group of its copies rather than a group in a group. +fn hoist_copies(art: &mut Node) { + let id = art.id; + let Some(children) = art.children_mut() else { return }; + let [only] = children.as_slice() else { return }; + if only.id != id || !matches!(only.kind, NodeKind::Group { clip: false, .. }) { + return; + } + let copies = only.children().cloned().unwrap_or_default(); + if !copies.is_empty() { + *children = copies; + } +} + +/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects +/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. +fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) { if fx.is_empty() { return; } @@ -588,14 +635,14 @@ pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budg if let Some((m, copies, options, map)) = transform { // 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. - let gradient = copies > 0 && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient()); + // make together; and Expand Appearance (`apart`) makes each copy an object. + let separate = copies > 0 && (apart || leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient()); if options.reach_appearance() { let made = transform_leaf(leaf, &path, map, copies, options, room); used = used.minus(before).plus(made); return; } - if gradient { + if separate { let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room); used = used.minus(before).plus(made); return; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8718e725f..1fcd4602e 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -758,11 +758,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art); m }) - } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_transform_art(&v) { + } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && !effects::has_transform_art(&v) + && !effects::has_transform_copies(&v) + { effects::expand_leaf(d, &v, &mut stroke_art) } else { // Type, images, symbol instances, live objects (and paths whose Transform effect copies - // them with a gradient or changes their fills and strokes): through their outlines. + // them, each copy an object of its own, or changes their fills and strokes): through + // their outlines. let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => { d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_render::instance_art(&s.art, &n)) diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 7e44e0772..ce8f51183 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -366,14 +366,16 @@ fn expand_appearance_bakes_geometry() { assert!(s.execute("effect.expandAppearance", &json!({})).is_err()); } +/// Each copy is an object of its own (#5612). #[test] -fn expand_transform_copies_makes_compound() { +fn expand_transform_copies_makes_a_group_of_the_copies() { let (mut s, id) = session_with_rect(); s.execute("paint.setStroke", &json!({"none": true})).unwrap(); s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap(); s.execute("effect.expandAppearance", &json!({})).unwrap(); let n = node(&s, id); - assert!(matches!(n.kind, NodeKind::Compound { .. })); + assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label()); + assert_eq!(n.children().map(|c| c.len()), Some(2)); let b = n.geometric_bounds().unwrap(); assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}"); } @@ -474,6 +476,53 @@ fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() { assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient"); } +/// Expand Appearance on a solid-filled path whose Transform effect makes copies gives a group of +/// one path per copy, each with the path's fill, not one compound path (#5612); a second Transform +/// effect's copies of those copies nest, a group per copy of the first. +#[test] +fn expand_appearance_makes_each_copy_of_a_transform_an_object() { + use vectorcraft_color::{Color, Paint}; + let fill = |n: &vectorcraft_doc::Node| n.appearance.fill_paint().color(); + let (mut s, id) = session_with_rect(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Group { clip: false, .. }), "{}", n.kind_label()); + assert!(n.appearance.effects.is_empty()); + 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]); + 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))); + assert!((c.geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9); + } + let mut ids: Vec = vec![]; + n.walk(&mut |c| ids.push(c.id)); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 4, "the group and each copy have ids of their own"); + // Two Transform effects: three columns, each a group of the column's two copies. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*s.doc().unwrap().doc, *before); + s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); + s.execute("effect.setParams", &json!({"index":1,"params":{"moveV":150,"copies":1},"ids":[id.0],"item":null})).unwrap(); + 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]) { + 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!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. }))); + } +} + /// The object's own effect ids, in order. fn object_effects(s: &Session, id: NodeId) -> Vec { node(s, id).appearance.effects.iter().map(|e| e.id.clone()).collect() diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 17b70253c..2878c5682 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); previously: Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -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) | +| 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) | | 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 | — | @@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. | | 2026-10-30 | minor | Pixelate › Color Halftone: Max Radius is in pixels of the Document Raster Effects resolution, 4–127 px, as the [filter effects reference](https://helpx.adobe.com/photoshop/using/filter-effects-reference.html) gives it, instead of points (#3260): fine screens are possible at print resolutions (4 px at 300 ppi ≈ 0.96 pt). The dialog and the Appearance panel show it in px whatever the document units; documents saved before (document versions 1 and 2) open with each radius converted, so their dots stay the same size, and a file saved for an older app version (native versions before today's) holds them in points again. Scores unchanged. Tests: `vectorcraft_effects` `color_halftone_sizes_its_dots_in_pixels_of_the_raster_effects_resolution` and `color_halftones_radius_is_in_raster_pixels_not_a_distance`, `vectorcraft_doc` `halftone_radii::tests`, `vectorcraft_format` `halftone_radii_of_older_documents_open_in_pixels`, `vectorcraft_engine` `a_preserve_editing_pdf_opens_with_its_halftone_radius_in_pixels`, `dialogs::tests::color_halftones_max_radius_stays_in_pixels_whatever_the_units`. | | 2026-10-30 | minor | Effect dialogs: bounded length fields (Color Halftone Max Radius) clamp on commit and visibly reject invalid input (#4638); scores unchanged. | | 2026-10-23 | minor | Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)): the Pathfinder panel's Trap… (`object.pathfinder.trap`, one undo step, the traps grouped above the topmost selected object and selected) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add an overprinting strip of the lighter colour inside the darker one where two solid colours meet (Reverse Traps: the darker into the lighter, but not a rich black), with Thickness, Height/Width, Tint Reduction and Traps with Process Color; the effect also has Precision and Remove Redundant Points. Recounted: Hard Mix (#3061) and Soft Mix had shipped, so Pathfinder 11/13 → 13/13 and Illustrator effects 46/50 → 49/50 (SVG Filters remains); remaining 6–10 → 4–8 h. Tests: `vectorcraft_pathops` `trap::tests`, `vectorcraft_effects` `trap::tests` and `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `vectorcraft_engine` `tests_trap`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. |