From 27230233ccabf8cec2149d66cb0f5dab6baa40f3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:09:18 +0200 Subject: [PATCH] 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>) {