From 8b67c4a943057db238a734c6c4b8e8dd1cc4c94a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 02:39:16 -0700 Subject: [PATCH 1/2] Effects: expanded, flattened and exported art keeps the random Transform the canvas drew Random in a Transform effect takes its seed from the object's id. When art evaluated from an object repeats ids (the copies a Transform effect makes, a copied group's members included, and the object under Crop Marks), Expand Appearance and the baking in bake_document gave the pieces new ids before expanding or baking their effects. The result then drew with other random values than the canvas did: on main, 2,382 to 5,205 pixels of a 200 x 200 render changed after Expand Appearance, and 7% to 14% after Flatten Transparency, for 50 pt squares with a random Transform on their stroke, on their group or under Crop Marks. bake_pieces now bakes each piece with the ids it has and gives new ids after. expand_pieces expands the object's own pieces, and the other members that repeat an id and hold a random Transform, before their ids change. Expand Appearance now expands a node value (expanded_node). The Crop Marks branch expands the object before the object and the marks get new ids. The reply still lists the expanded object first, then what was expanded inside it. --- crates/effects/src/bake.rs | 10 +- crates/engine/src/cmd/effectcmd.rs | 145 ++++++++++++++++++++++------- crates/engine/src/cmd/expand.rs | 2 +- crates/engine/src/tests_expand.rs | 75 +++++++++++++++ crates/engine/src/tests_flatten.rs | 22 +++++ 5 files changed, 216 insertions(+), 38 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2bdd0f288..47381cbaa 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -151,17 +151,19 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { bake_leaf(d, n) } -/// `m`, art evaluated from an object, with its children baked and given ids of their own (the -/// pieces copied from the source keep the source's ids until then). +/// `m`, art evaluated from an object, with its children baked and then given ids of their own (the +/// pieces copied from the source keep the source's ids until then). The pieces are baked with the +/// ids the renderer draws them with: Random in a Transform effect takes its seed from the id. fn bake_pieces(d: &mut Document, mut m: Node) -> Node { let mut seen = std::collections::HashSet::from([m.id]); let children = m.children().cloned().unwrap_or_default(); let children = children .into_iter() .map(|c| { - let mut c = Arc::unwrap_or_clone(c); + let c = Arc::unwrap_or_clone(c); + let mut c = bake_node(d, &c).unwrap_or(c); fresh_ids(d, &mut c, &mut seen); - Arc::new(bake_node(d, &c).unwrap_or(c)) + Arc::new(c) }) .collect(); if let Some(ch) = m.children_mut() { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index e4660bf67..098e83780 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,6 +13,7 @@ use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at}; +use super::expand::has_seeded; use super::rasterfx; use super::*; @@ -442,18 +443,72 @@ fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> { Some((rasterfx::raster_image(d, n, below, true)?, below)) } -/// Give the members of `m`, art evaluated from the object of the same id (its pieces share that -/// id), ids of their own and expand the pieces: those made from its own fills and strokes, or with -/// `all` every member. -fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects::StrokeArt) { +/// Expand the pieces of `m`, art evaluated from the object of the same id (its pieces share that +/// id): those made from its own fills and strokes, or with `all` every member. The other members +/// that repeat an id ([`repeats_an_id`]) and hold a random Transform effect ([`has_seeded`]) are +/// expanded as well ([`expand_in_place`]). Then the members get ids of their own. Random in a +/// Transform effect takes its seed from the id, and the canvas draws the pieces with the ids they +/// have before that. Returns the ids each of those other members' expansions lists, by the member's +/// new id. +fn expand_pieces( + d: &mut Document, + m: &mut Node, + all: bool, + brushes: &[vectorcraft_brush::Brush], + stroke_art: effects::StrokeArt, +) -> std::collections::HashMap> { let id = m.id; let mut seen = std::collections::HashSet::from([id]); + let mut expanded = std::collections::HashMap::new(); for c in m.children_mut().into_iter().flatten() { - let own = all || c.id == id; let c = Arc::make_mut(c); - effects::fresh_ids(d, c, &mut seen); - if own { + let mut listed = None; + if all || c.id == id { effects::expand_art(d, c, stroke_art); + } else if has_seeded(c) && repeats_an_id(c, &seen) { + listed = Some(expand_in_place(d, c, brushes)); + } + fresh_ids_listed(d, c, &mut seen, listed.as_deref_mut().unwrap_or_default()); + if let Some(listed) = listed { + expanded.insert(c.id, listed); + } + } + expanded +} + +/// Does `n` hold an id of `seen`, or one id twice: does [`effects::fresh_ids`] give it new ids? +fn repeats_an_id(n: &Node, seen: &std::collections::HashSet) -> bool { + let mut ids = std::collections::HashSet::new(); + let mut repeats = false; + n.walk(&mut |c| repeats |= seen.contains(&c.id) || !ids.insert(c.id)); + repeats +} + +/// [`expanded_node`] of `c` in place. Returns the ids the expansion lists. +fn expand_in_place(d: &mut Document, c: &mut Node, brushes: &[vectorcraft_brush::Brush]) -> Vec { + let mut listed = vec![]; + if let Some(e) = expanded_node(d, c.clone(), brushes, &mut listed) { + *c = e; + } + listed +} + +/// [`effects::fresh_ids`] on `c`, with each id in `listed` that names a node of `c` changed to +/// that node's new id. +fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections::HashSet, listed: &mut [NodeId]) { + let mut before = vec![]; + c.walk(&mut |n| before.push(n.id)); + effects::fresh_ids(d, c, seen); + let mut before = before.into_iter(); + let mut renamed = std::collections::HashMap::new(); + c.walk(&mut |n| { + if let Some(old) = before.next() { + renamed.entry(old).or_insert(n.id); + } + }); + for e in listed { + if let Some(new) = renamed.get(e) { + *e = *new; } } } @@ -462,46 +517,63 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects: /// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects /// are baked, raster effects become an embedded image, and the object keeps its transparency. pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { - let Some(mut n) = d.node(id).cloned() else { return }; + let Some(n) = d.node(id).cloned() else { return }; + if let Some(m) = expanded_node(d, n, brushes, out) + && let Some(slot) = d.node_mut(id) + { + *slot = m; + } +} + +/// [`expand_node`] of `n`, which need not be in `d`: `n` with its appearance expanded, `None` when +/// it has nothing to expand. `out` gets the ids of the objects whose appearance expands, `n`'s +/// first when its own does. +fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) -> Option { + let id = n.id; if !expandable(&n) { - return; + return None; } // Colour adjustments: the colours inside become the adjusted ones (an embedded image a // recoloured copy), then the rest of the appearance expands. + let mut changed = false; if effects::has_adjustment(&n) && let Some(m) = effects::adjust_in_document(d, &n) - && let Some(slot) = d.node_mut(id) { - *slot = m.clone(); out.push(id); n = m; + changed = true; if !expandable(&n) { - return; + return Some(n); } } // (A symbol instance has become its art, a group.) let container = is_container(&n); - // Crop Marks: a group of the object and its marks, then the object expands on its own. + // Crop Marks: the object and its marks become a group. The object expands on its own first, + // with `id`: the canvas draws it with that id, which Random in a Transform effect takes its + // seed from. Then the object and the marks get ids of their own. if let Some(mut m) = effects::crop_marks_art(&n) { let mut seen = std::collections::HashSet::from([id]); - for c in m.children_mut().into_iter().flatten() { - effects::fresh_ids(d, Arc::make_mut(c), &mut seen); + let mut expanded = vec![]; + for (i, c) in m.children_mut().into_iter().flatten().enumerate() { + let c = Arc::make_mut(c); + if i == 0 { + expanded = expand_in_place(d, c, brushes); + fresh_ids_listed(d, c, &mut seen, &mut expanded); + } else { + effects::fresh_ids(d, c, &mut seen); + } } - let object = m.children().and_then(|c| c.first()).map(|c| c.id); - if let Some(slot) = d.node_mut(id) { - *slot = m; - out.push(id); - } - if let Some(object) = object { - expand_node(d, object, brushes, out); - } - return; + out.push(id); + out.extend(expanded); + return Some(m); } let image = raster_image(d, &n); let mut v = n.clone(); rasterfx::strip_raster(&mut v, false); let mut stroke_art = |d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st); + // Members of a group or layer that `expand_pieces` expanded, by id, with what their expansion lists. + let mut members = std::collections::HashMap::new(); let expanded = match image { // Blur, feather and inner glow change the object itself: it becomes the image (a layer // keeps it as its only member). @@ -510,7 +582,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b let m = if container { // A group's or layer's own fills and strokes become art among its members. effects::evaluate_container(&v).map(|mut m| { - expand_pieces(d, &mut m, false, &mut stroke_art); + members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art); m }) } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) { @@ -523,7 +595,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b _ => None, }; effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| { - expand_pieces(d, &mut m, true, &mut stroke_art); + expand_pieces(d, &mut m, true, brushes, &mut stroke_art); m }) }; @@ -537,20 +609,27 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b } } }; - if let Some(m) = expanded - && let Some(slot) = d.node_mut(id) - { - *slot = m; + if let Some(m) = expanded { + n = m; + changed = true; if out.last() != Some(&id) { out.push(id); } } if container { - let children: Vec = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(); - for c in children { - expand_node(d, c, brushes, out); + for c in n.children_mut().into_iter().flatten() { + match members.remove(&c.id) { + Some(listed) => out.extend(listed), + None => { + if let Some(e) = expanded_node(d, (**c).clone(), brushes, out) { + *c = Arc::new(e); + changed = true; + } + } + } } } + changed.then_some(n) } fn expand_appearance(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index ebeacf63a..6718566ac 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -604,7 +604,7 @@ fn seeded_here(n: &Node) -> bool { /// Does `n`, something in it or one of their opacity masks have a random Transform effect /// ([`seeded_here`])? -fn has_seeded(n: &Node) -> bool { +pub(crate) fn has_seeded(n: &Node) -> bool { seeded_here(n) || n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art)) || (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c)))) diff --git a/crates/engine/src/tests_expand.rs b/crates/engine/src/tests_expand.rs index 571c6c6d3..903cb7edf 100644 --- a/crates/engine/src/tests_expand.rs +++ b/crates/engine/src/tests_expand.rs @@ -84,6 +84,81 @@ fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() { assert_eq!(node(&s, id).appearance.items.len(), 3); } +/// The objects [`random_transforms`] makes. +pub(super) const RANDOM_CASES: [&str; 5] = ["copies", "group copies", "nested group copies", "crop marks", "group crop marks"]; + +/// Selected art with Random in Transform effects, from 50 pt squares at y 60 with an 8 pt black +/// stroke, as `case` says. "copies": a square at x 20 filled with a gradient, a random Transform +/// effect on its stroke and a Transform effect that copies it 80 pt right. "group copies": the +/// same square without the second effect, in a group that has it. "nested group copies": the same +/// square in a group with a random Transform effect, in a group with the second effect. "crop +/// marks": a red square at x 20 with a random Transform effect and crop marks. "group crop marks": +/// red squares at x 20 and x 110 in a group with a random Transform effect and crop marks. Returns +/// the object's id. +pub(super) fn random_transforms(s: &mut Session, case: &str) -> NodeId { + let random = json!({"moveH": 15, "moveV": 15, "rotate": 40, "random": true}); + let square = |s: &mut Session, x: f64, fill: Value| { + let id = run(s, "shape.rectangle", json!({"x": x, "y": 60, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(s, "paint.setFill", fill); + run(s, "paint.setStroke", json!({"color": "#000000"})); + run(s, "stroke.set", json!({"weight": 8})); + id + }; + let red = json!({"color": "#ff0000"}); + let id = match case { + "copies" | "group copies" | "nested group copies" => { + let a = square(s, 20.0, json!({"gradient": {"kind": "linear"}})); + run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": 1})); + if case == "nested group copies" { + run(s, "object.group", json!({})); + run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null})); + } + if case == "copies" { a } else { run(s, "object.group", json!({}))["id"].as_u64().unwrap() } + } + "crop marks" => square(s, 20.0, red), + _ => { + let a = square(s, 20.0, red.clone()); + let b = square(s, 110.0, red); + run(s, "select.set", json!({"ids": [a, b]})); + run(s, "object.group", json!({}))["id"].as_u64().unwrap() + } + }; + if case.ends_with("copies") { + run(s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 80, "copies": 1}, "item": null})); + } else { + run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null})); + run(s, "effect.apply", json!({"effect": "cropMarks", "item": null})); + } + NodeId(id) +} + +/// Random in a Transform effect takes its seed from the object's id. The expanded art draws as the +/// canvas drew each of [`random_transforms`]' objects. +#[test] +fn expanded_art_keeps_the_random_transforms_the_canvas_drew() { + let mut differ = vec![]; + for case in RANDOM_CASES { + let mut s = session(); + let id = random_transforms(&mut s, case); + let before = render(&s); + let r = run(&mut s, "effect.expandAppearance", json!({})); + differ.push((case, differing(&before, &render(&s), 64))); + let mut art = node(&s, id); + // Crop marks: the reply names the group of the object and its marks, then the object. + if case.ends_with("crop marks") { + art = (*art.children().unwrap()[0]).clone(); + assert_eq!((&r["ids"][0], &r["ids"][1]), (&json!(id.0), &json!(art.id.0)), "{case}"); + } + // No object keeps an effect, and in the copies cases no object keeps a stroke: every copy + // is expanded. + art.walk(&mut |n| { + let stroked = case.ends_with("copies") && n.appearance.stroke().is_some_and(|st| !st.paint.is_none()); + assert!(n.appearance.effects.is_empty() && !stroked, "{case}: {n:?}"); + }); + } + assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0))); +} + #[test] fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() { let mut s = session(); diff --git a/crates/engine/src/tests_flatten.rs b/crates/engine/src/tests_flatten.rs index fdfaf58e4..69882a32b 100644 --- a/crates/engine/src/tests_flatten.rs +++ b/crates/engine/src/tests_flatten.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::{BezPath, Point, Rect, Shape}; use vectorcraft_render::{Rendered, Renderer}; +use super::tests_expand::{RANDOM_CASES, random_transforms}; use super::tests_menucmds::{place_undecodable_png, warns_undecoded}; use super::*; @@ -410,6 +411,27 @@ fn outlined_type_keeps_its_gradient_on_the_outlines_its_transform_effect_moves() assert!(diffs.iter().all(|&d| d < 0.0002), "shares of the pixels that changed: {diffs:?}"); } +/// Random in a Transform effect takes its seed from the object's id. Each of [`random_transforms`]' +/// objects at 50% opacity draws as the canvas drew it with its geometry effects baked, as Flatten +/// Transparency bakes them first, and after Flatten Transparency, within the seams between its +/// regions (the same objects without Random change 1.4% to 2.3% of the pixels). +#[test] +fn flattened_art_keeps_the_random_transforms_the_canvas_drew() { + let _settings = settings_lock(); + let mut diffs = vec![]; + for case in RANDOM_CASES { + let mut s = session(); + let id = random_transforms(&mut s, case); + run(&mut s, "transparency.set", json!({"ids": [id.0], "opacity": 50})); + let before = picture(&s); + let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap(); + let baked = Renderer::new().render_region(&baked, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + flatten(&mut s, json!({"ids": [id.0]})); + diffs.push((case, changed(&before, &baked), changed(&before, &picture(&s)))); + } + assert!(diffs.iter().all(|&(_, baked, flat)| baked == 0.0 && flat < 0.03), "shares of the pixels that changed: {diffs:?}"); +} + #[test] fn preserve_alpha_composites_over_nothing() { let _settings = settings_lock(); From b7ea047d81d8452b4b4741ac7118ec44df3c1e55 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 08:05:47 -0700 Subject: [PATCH 2/2] Effects: Expand Appearance names each expanded copy by its own id Art evaluated from an object can hold one id more than once until the pieces get ids of their own. The reply then named a repeated id by the new id of its first node: for a square with a random Transform on its stroke, in a group whose Transform effect copies it, the reply listed the copies' group instead of the two copies. An object with a color adjustment and crop marks was listed twice. The expansion now records each object it lists with its place in the art, and the new ids are taken from those places. An object is listed once. --- crates/engine/src/cmd/effectcmd.rs | 69 ++++++++++++++++++------------ crates/engine/src/tests_expand.rs | 34 +++++++++++++++ 2 files changed, 75 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 098e83780..92de1c83c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -448,7 +448,7 @@ fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> { /// that repeat an id ([`repeats_an_id`]) and hold a random Transform effect ([`has_seeded`]) are /// expanded as well ([`expand_in_place`]). Then the members get ids of their own. Random in a /// Transform effect takes its seed from the id, and the canvas draws the pieces with the ids they -/// have before that. Returns the ids each of those other members' expansions lists, by the member's +/// have before that. Returns what each of those other members' expansions lists, by the member's /// new id. fn expand_pieces( d: &mut Document, @@ -456,7 +456,7 @@ fn expand_pieces( all: bool, brushes: &[vectorcraft_brush::Brush], stroke_art: effects::StrokeArt, -) -> std::collections::HashMap> { +) -> std::collections::HashMap> { let id = m.id; let mut seen = std::collections::HashSet::from([id]); let mut expanded = std::collections::HashMap::new(); @@ -484,8 +484,13 @@ fn repeats_an_id(n: &Node, seen: &std::collections::HashSet) -> bool { repeats } -/// [`expanded_node`] of `c` in place. Returns the ids the expansion lists. -fn expand_in_place(d: &mut Document, c: &mut Node, brushes: &[vectorcraft_brush::Brush]) -> Vec { +/// An object whose appearance expanded: its id, and its place in the walk ([`Node::walk`]) of the +/// art it was listed in. Art evaluated from an object can hold one id more than once, and the place +/// tells which of those nodes it is. +type Listed = (NodeId, usize); + +/// [`expanded_node`] of `c` in place. Returns what the expansion lists, by places in the walk of `c`. +fn expand_in_place(d: &mut Document, c: &mut Node, brushes: &[vectorcraft_brush::Brush]) -> Vec { let mut listed = vec![]; if let Some(e) = expanded_node(d, c.clone(), brushes, &mut listed) { *c = e; @@ -493,22 +498,15 @@ fn expand_in_place(d: &mut Document, c: &mut Node, brushes: &[vectorcraft_brush: listed } -/// [`effects::fresh_ids`] on `c`, with each id in `listed` that names a node of `c` changed to -/// that node's new id. -fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections::HashSet, listed: &mut [NodeId]) { - let mut before = vec![]; - c.walk(&mut |n| before.push(n.id)); +/// [`effects::fresh_ids`] on `c`, with each of `listed` (places in the walk of `c`) given the new +/// id of the node at its place. +fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections::HashSet, listed: &mut [Listed]) { effects::fresh_ids(d, c, seen); - let mut before = before.into_iter(); - let mut renamed = std::collections::HashMap::new(); - c.walk(&mut |n| { - if let Some(old) = before.next() { - renamed.entry(old).or_insert(n.id); - } - }); - for e in listed { - if let Some(new) = renamed.get(e) { - *e = *new; + let mut ids = vec![]; + c.walk(&mut |n| ids.push(n.id)); + for (id, at) in listed { + if let Some(new) = ids.get(*at) { + *id = *new; } } } @@ -518,7 +516,10 @@ fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections: /// are baked, raster effects become an embedded image, and the object keeps its transparency. pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { let Some(n) = d.node(id).cloned() else { return }; - if let Some(m) = expanded_node(d, n, brushes, out) + let mut listed = vec![]; + let m = expanded_node(d, n, brushes, &mut listed); + out.extend(listed.into_iter().map(|(id, _)| id)); + if let Some(m) = m && let Some(slot) = d.node_mut(id) { *slot = m; @@ -526,9 +527,9 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b } /// [`expand_node`] of `n`, which need not be in `d`: `n` with its appearance expanded, `None` when -/// it has nothing to expand. `out` gets the ids of the objects whose appearance expands, `n`'s -/// first when its own does. -fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) -> Option { +/// it has nothing to expand. `out` gets the objects whose appearance expands, by places in the walk +/// of the expanded `n`, `n`'s first when its own does. +fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) -> Option { let id = n.id; if !expandable(&n) { return None; @@ -536,10 +537,13 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // Colour adjustments: the colours inside become the adjusted ones (an embedded image a // recoloured copy), then the rest of the appearance expands. let mut changed = false; + // Is `n` in `out` yet? + let mut listed_n = false; if effects::has_adjustment(&n) && let Some(m) = effects::adjust_in_document(d, &n) { - out.push(id); + out.push((id, 0)); + listed_n = true; n = m; changed = true; if !expandable(&n) { @@ -563,8 +567,10 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br effects::fresh_ids(d, c, &mut seen); } } - out.push(id); - out.extend(expanded); + if !listed_n { + out.push((id, 0)); + } + out.extend(expanded.into_iter().map(|(id, at)| (id, at + 1))); return Some(m); } let image = raster_image(d, &n); @@ -612,12 +618,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br if let Some(m) = expanded { n = m; changed = true; - if out.last() != Some(&id) { - out.push(id); + if !listed_n { + out.push((id, 0)); } } if container { + // The place of each member in the walk of `n`: after `n` and the members before it. + let mut at = 1; for c in n.children_mut().into_iter().flatten() { + let from = out.len(); match members.remove(&c.id) { Some(listed) => out.extend(listed), None => { @@ -627,6 +636,10 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br } } } + for (_, place) in &mut out[from..] { + *place += at; + } + at += c.count(); } } changed.then_some(n) diff --git a/crates/engine/src/tests_expand.rs b/crates/engine/src/tests_expand.rs index 903cb7edf..9256494ae 100644 --- a/crates/engine/src/tests_expand.rs +++ b/crates/engine/src/tests_expand.rs @@ -159,6 +159,40 @@ fn expanded_art_keeps_the_random_transforms_the_canvas_drew() { assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0))); } +/// The reply lists each object whose appearance expanded once, by the id it has in the document. +/// In "group copies" the two copies of the square share an id until Expand Appearance gives them +/// ids of their own, and the reply names the group, then each copy by its new id. +#[test] +fn the_reply_names_each_expanded_copy_by_its_own_id() { + for case in RANDOM_CASES { + let mut s = session(); + let id = random_transforms(&mut s, case); + let r = run(&mut s, "effect.expandAppearance", json!({})); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + let unique: std::collections::HashSet<_> = ids.iter().collect(); + assert_eq!(unique.len(), ids.len(), "{case}: {ids:?}"); + assert!(ids.iter().all(|&i| s.doc().unwrap().doc.node(NodeId(i)).is_some()), "{case}: {ids:?}"); + if case == "group copies" { + let art = node(&s, id); + let copies: Vec = art.children().unwrap()[0].children().unwrap().iter().map(|c| c.id.0).collect(); + assert_eq!(ids, [vec![id.0], copies].concat(), "{case}"); + } + } +} + +/// A square with a color adjustment and crop marks: the reply names the group of the square and its +/// marks once. The adjusted square has a basic appearance, so the reply does not name it. +#[test] +fn the_reply_names_an_adjusted_object_with_crop_marks_once() { + let mut s = session(); + let id = square(&mut s); + run(&mut s, "effect.apply", json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})); + run(&mut s, "effect.apply", json!({"effect": "cropMarks", "item": null})); + let r = run(&mut s, "effect.expandAppearance", json!({})); + assert!(node(&s, id).children().is_some_and(|c| c.len() > 1)); + assert_eq!(r["ids"], json!([id.0])); +} + #[test] fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() { let mut s = session();