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();