From 27230233ccabf8cec2149d66cb0f5dab6baa40f3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:09:18 +0200 Subject: [PATCH 1/3] Effects: a Transform effect on a group paints each copy with its own gradient (#2451) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/group.rs | 28 ++++++++++++++++++++++++ crates/effects/src/reshape.rs | 40 +++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 69425b907..2211a4364 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -491,4 +491,32 @@ mod tests { assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed"); } + + #[test] + fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; + for (k, paint) in paints.into_iter().enumerate() { + let square = + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); + let mut g = Node::group(NodeId(10), vec![square]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(&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() { + // 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() { + Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"), + Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"), + p => panic!("a gradient: {p:?}"), + } + } + } + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index a6e2926ac..ebfd22464 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -268,13 +268,21 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed)); for_each_leaf(art, &mut |leaf| { let Some((path, rule)) = leaf_geometry(leaf) else { return }; + // A gradient paints each copy by itself, not the one piece the copies make together. + if let Some((m, copies)) = transform + && copies > 0 + && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient()) + { + copy_with_gradients(leaf, &path, m, copies); + return; + } let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }); set_geometry(leaf, out, rule); }); - if e.id == "distort.transform" { - let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); + if let Some((m, copies)) = transform { transform_placed(art, m, copies); } } @@ -283,6 +291,34 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { } } +/// 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, 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. The copies stop where [`crate::distort::transform_within`]'s would. +fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) { + let each = path.anchor_count(); + let mut anchors = each; + let mut placements: Vec> = vec![Arc::new(leaf.clone())]; + let mut xf = Affine::IDENTITY; + for _ in 0..copies { + anchors = anchors.saturating_add(each); + if anchors > crate::distort::MAX_COPY_ANCHORS { + break; + } + xf *= m; + let mut copy = leaf.clone(); + copy.transform(xf, vectorcraft_doc::Scaling::default()); + if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { + break; + } + placements.push(Arc::new(copy)); + } + // The copies become nodes of their own under a group with the object's id and name. + let mut g = Node::group(leaf.id, placements); + g.name = leaf.name.clone(); + *leaf = g; +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { From a6012d86e85329d0ed8ca35c45505c8c528ab229 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:13:56 +0200 Subject: [PATCH 2/3] Effects: a path with its own Transform effect paints each copy with its own gradient (#2451) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 +- crates/effects/src/reshape.rs | 23 ++++++++++++++++++++++- crates/render/src/fx.rs | 4 +++- crates/render/src/tests_objectfx.rs | 21 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 89ba2d491..3f10343db 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RasterFx, outset, raster_effects}; -pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; +pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ebfd22464..fa59cae55 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -32,6 +32,17 @@ pub fn needs_outline(n: &Node) -> bool { ) } +/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient +/// 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_with_gradients`]), as a group's copies do. +pub fn has_gradient_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) +} + /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a /// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke /// paints ([`outline_text`], which leaves inline graphics out). @@ -127,6 +138,16 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { a.transform(*xf, false); 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_gradient_copies(n) => { + let mut one = n.clone(); + one.opacity = 1.0; + one.blend = Default::default(); + one.isolate = false; + one.extras_mut().mask = None; + one.appearance.effects.clear(); + Node::group(n.id, vec![Arc::new(one)]) + } _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), _ => return None, }; @@ -171,7 +192,7 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { /// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a /// symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { - if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) { + if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) { return None; } as_art(n, symbol_art) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 23352a280..f5281fb47 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -68,11 +68,13 @@ pub(crate) fn has_fx(n: &Node) -> bool { /// Does `n` carry visible object-level effects that only apply through its art (type, images, /// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with -/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), or is it a +/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), is it a path +/// whose Transform effect copies it with a gradient ([`effects::has_gradient_copies`]), or is it a /// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) || effects::has_item_geometry(n) + || effects::has_gradient_copies(n) || effects::has_container_appearance(n) || effects::has_crop_marks(n) || effects::has_adjustment(n) diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 7a9add652..f4c72553f 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -697,3 +697,24 @@ fn a_strokes_own_glow_paints_around_that_stroke_only() { let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); assert!(worst <= 4, "max channel difference {worst}"); } + +#[test] +fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() { + use vectorcraft_color::GradientGeom; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(20.0, 50.0), aspect: 1.0, focal: None }; + // Fitted to the box on each render, and placed on the object. + for paint in [ramp.clone(), GradientPaint { geom: Some(geom), ..ramp }] { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 40.0, 20.0, 60.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint)), Paint::None, 0.0)); + n.appearance.effects = vec![fx("distort.transform", json!({"moveH": 40, "copies": 1}))]; + let img = render(&doc_with(Document::new(100.0, 100.0), n)); + let at = |x: usize| img.pixels.chunks(4).nth(50 * 100 + x).map(|p| (p[0], p[2])).unwrap_or_default(); + // The left edge of each copy is red, its right edge blue. + for (left, right) in [(1, 18), (41, 58)] { + assert!(at(left).0 > 200 && at(left).1 < 60, "{:?} at {left}", at(left)); + assert!(at(right).1 > 200 && at(right).0 < 60, "{:?} at {right}", at(right)); + } + } +} From 7cd6b5d983a75fcd9e9c44febdc2fb39b8fd2672 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:20:49 +0200 Subject: [PATCH 3/3] Effects: Expand Appearance and export give each Transform copy of a gradient path its own gradient (#2490) A path with a Transform effect that makes copies and a gradient was still merged into one path by bake_leaf and expand_leaf, so the gradient spanned every copy. Route it through reshape / expand_outlined like the renderer does. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/bake.rs | 39 ++++++++++++++++++++++++++++++ crates/effects/src/reshape.rs | 7 +++++- crates/engine/src/cmd/effectcmd.rs | 28 +++++++++++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index e0165c2ef..0f5b89d1c 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -120,6 +120,13 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { let m = reshape(n, symbol.as_deref())?; return Some(bake_node(d, &m).unwrap_or(m)); } + // A path whose Transform effect copies it with a gradient: each copy an object of its own, so + // its gradient fits it rather than spanning every copy. + if crate::has_gradient_copies(n) + && let Some(m) = reshape(n, None) + { + return Some(bake_pieces(d, m)); + } // Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that // Transform effects only move stays type). The new pieces (results, outlines, copies, the // fills' and strokes' art) get ids of their own. @@ -968,4 +975,36 @@ mod tests { // An effect that also scales. assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none()); } + /// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies. + fn gradient_copies(id: u64) -> Node { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); + n + } + + /// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right. + fn gradient_pieces(n: &Node) -> Vec { + let mut out = vec![]; + let mut walk = vec![n]; + while let Some(n) = walk.pop() { + match n.children() { + Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)), + _ => { + assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}"); + out.extend(n.geometric_bounds().map(|b| b.width())); + } + } + } + out + } + + #[test] + fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { + let d = bake_document(&doc_with(gradient_copies(50))).unwrap(); + // Three squares of their own, not one path spanning all three. + assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index fa59cae55..e0c469718 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -198,10 +198,15 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { as_art(n, symbol_art) } -/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has +/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand +/// Appearance: [`reshape`]d when it has /// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None` /// when it has neither. pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { + // A path whose Transform effect copies it with a gradient: each copy an object of its own. + if has_gradient_copies(n) { + return as_art(n, symbol_art); + } let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty(); if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) { return None; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 1781f3355..abffe675c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -504,10 +504,11 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b expand_pieces(d, &mut m, false, &mut stroke_art); m }) - } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) { effects::expand_leaf(d, &v, &mut stroke_art) } else { - // Type, images, symbol instances, live objects: through their outlines. + // Type, images, symbol instances, live objects (and paths whose Transform effect copies + // them with a gradient): through their outlines. let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, @@ -995,4 +996,27 @@ mod tests { assert_eq!(ch.len(), 1); assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6); } + #[test] + fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + let (mut s, id) = session_with_rect(); + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + s.edit("gradient", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.set_fill(ramp); + Ok(()) + }) + .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(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let mut boxes = vec![]; + node(&s, id).walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) { + boxes.push(n.geometric_bounds().unwrap().width()); + } + }); + assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient"); + } }