From 7cd6b5d983a75fcd9e9c44febdc2fb39b8fd2672 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:20:49 +0200 Subject: [PATCH] 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"); + } }