From 33917ff2fd3a5e385bfc67dcb9785c351d799141 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 00:16:52 -0700 Subject: [PATCH] Outline Stroke: random Transform effects keep the values the canvas drew Random in a Transform effect takes its seed from the object's id. The copies of a gradient path that its Transform effect makes were given new ids before their strokes were outlined, so a random Transform on the stroke moved each outline elsewhere than the canvas drew it. The copies are now outlined first and given new ids after. A fill with a random Transform effect stayed on a copy of the path with a new id, so it drew elsewhere too. It now paints the outline its geometry effects make of the path, on a copy of its own. outlines_keep_the_random_transforms_the_canvas_drew checks Outline Stroke, Expand and Flatten Transparency against the canvas for both cases and for a random Transform that makes the copies. --- crates/engine/src/cmd/expand.rs | 9 ++++--- crates/engine/src/cmd/pathops.rs | 27 ++++++++++++++----- crates/engine/src/tests_outlinestroke.rs | 33 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index f1c841ff8..bd8dfff69 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -366,7 +366,7 @@ pub(crate) fn reshaped(l: &Node) -> Option { /// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its /// outline and box: when the fill has visible geometry effects, `l` with the outline they make of /// `path` as a plain path, and that outline's box. -fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> { +pub(crate) fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> { use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry}; if !has_geometry(item.effects()) { return Some((l.clone(), path.clone(), bounds)); @@ -595,10 +595,13 @@ fn moves_only(a: Affine) -> bool { (m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps } +/// Is `e` a visible Transform effect with Random on (which takes its seed from the object's id)? +pub(crate) fn seeded(e: &vectorcraft_doc::Effect) -> bool { + e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true) +} + /// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's? fn seeded_here(n: &Node) -> bool { - let seeded = - |e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true); n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded)) } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 795925259..658b368f6 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -13,7 +13,7 @@ use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; -use super::expand::reshaped; +use super::expand::{fill_shape, reshaped, seeded}; use super::*; pub fn specs() -> Vec { @@ -644,8 +644,11 @@ pub(crate) fn outlined_stroke( /// the path, each stroke becomes its outline (or brush art), grouped when there are several. /// As on the canvas, the path's geometry effects ([`reshaped`]) shape the path first, and each /// stroke's own geometry effects then shape the path that stroke outlines; [`outlined_stroke`] -/// gets the stroke without them. A path whose Transform effect copies it with a gradient becomes -/// the art the canvas draws for it, each copy with its own gradient and its strokes outlined. +/// gets the stroke without them. A fill with a random Transform effect paints the outline its +/// geometry effects make of the path, on a copy of its own. A path whose Transform effect copies +/// it with a gradient becomes the art the canvas draws for it, each copy with its own gradient and +/// its strokes outlined before the art gets new ids. Random in a Transform effect takes its seed +/// from the object's id, so these effects give the outline the values the canvas drew. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_gradient_copies, is_geometry, reshape}; @@ -653,9 +656,9 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br return None; } if has_gradient_copies(l) { - let mut art = d.reid(&reshape(l, None)?); + let mut art = reshape(l, None)?; outline_member_strokes(d, brushes, &mut art); - return Some(art); + return Some(d.reid(&art)); } let reshaped = reshaped(l); let l = reshaped.as_ref().unwrap_or(l); @@ -665,7 +668,19 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br let mut fills: Vec = vec![]; for item in &l.appearance.items { match item { - AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()), + AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => { + let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(l, item, &path, b))).flatten(); + let Some((shape, ..)) = shaped else { + fills.push(item.clone()); + continue; + }; + if !fills.is_empty() { + parts.push(styled_copy(d, l, std::mem::take(&mut fills))); + } + let mut plain = item.clone(); + plain.effects_mut().retain(|e| !is_geometry(&e.id)); + parts.push(styled_copy(d, &shape, vec![plain])); + } AppearanceItem::Stroke(st) if outlinable(st) => { if !fills.is_empty() { parts.push(styled_copy(d, l, std::mem::take(&mut fills))); diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs index c6f6a11f9..84428432a 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests_outlinestroke.rs @@ -303,6 +303,39 @@ fn each_transform_copy_keeps_its_own_gradient() { assert_eq!(differ, none_differ); } +/// Random in a Transform effect takes its seed from the object's id. The outline draws as the +/// canvas drew the object with Random in a Transform effect that copies a gradient square, in a +/// Transform effect on the stroke of such a square, and in a Transform effect on the fill of a +/// plain square. +#[test] +fn outlines_keep_the_random_transforms_the_canvas_drew() { + let random = json!({"moveH": 30, "moveV": 30, "rotate": 40, "random": true}); + let cases = [ + ("random copies", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "rotate": 30, "copies": 1, "random": true}), None), + ("copies, random stroke", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "copies": 1}), Some(1)), + ("random fill", json!({"color": "#33aa55"}), json!({}), Some(0)), + ]; + let mut differ = vec![]; + for (case, fill, object, item) in &cases { + for (command, p) in outlining() { + let mut s = session(); + square_with(&mut s, fill.clone(), Value::Null, &[]); + if object != &json!({}) { + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": object})).unwrap(); + } + s.execute("stroke.set", &json!({"weight": 12})).unwrap(); + if let Some(item) = item { + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": random, "item": item})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + differ.push((*case, command, differing(&before, &render(&s), 64))); + } + } + let none_differ: Vec<_> = cases.iter().flat_map(|(case, ..)| outlining().map(|(command, _)| (*case, command, 0))).collect(); + assert_eq!(differ, none_differ); +} + #[test] fn brushed_strokes_outline_to_their_brush_art() { let mut s = session();