From 732434b4e5d4da7a54893f3e2c49277d00241d6f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 20:43:10 -0800 Subject: [PATCH] Effects: placed gradients move with Transform and Free Distort effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gradient placed with the Gradient tool stayed where it was when the object's Transform or Free Distort effect moved, turned or scaled the outline, so the shape slid across its own gradient. The Transform effect now maps placed gradients by its own transform, and each of its copies takes them as many times as it takes the transform. Under Free Distort, each path's placed gradients move and scale from the path's bounds before the effect to its bounds after it. This change does not alter gradients fitted to the object. The canvas, Expand Appearance and export, Pathfinder and compound shapes, groups, a fill's or stroke's own effects, and Object › Expand of a gradient fill apply the same rule. In a group, the own fills and strokes of the group, of the groups inside it and of a symbol's art follow it too, and images and symbol instances that a group's Transform effect moves take the placed gradients of their own fills and strokes along. A group whose path keeps its shape because the effect would pass the geometry budget keeps its own gradients in place. This changes how existing documents draw: a gradient placed on a path, or on an image's or symbol instance's own fill, inside a group with a Transform effect now moves with it. Before, only the gradients of type moved (#2214). How gradients follow each effect is VectorCraft's own rule. The Distort & Transform help page describes what the effects do to the object: https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html --- crates/effects/src/bake.rs | 46 +++-- crates/effects/src/bake/tests.rs | 130 ++++++++++++++ crates/effects/src/catalog.rs | 4 +- crates/effects/src/distort.rs | 26 +-- crates/effects/src/geometry.rs | 115 +++++++++++++ crates/effects/src/group.rs | 25 ++- crates/effects/src/group/tests.rs | 178 +++++++++++++++++++- crates/effects/src/lib.rs | 4 +- crates/effects/src/reshape.rs | 243 ++++++++++++++++++++++++--- crates/effects/src/tests.rs | 48 ++++++ crates/engine/src/cmd/expand.rs | 35 ++-- crates/engine/src/tests/objexpand.rs | 31 ++++ crates/render/src/fx/bounds.rs | 13 ++ crates/render/src/fx/entries.rs | 7 +- crates/render/src/fx/pixels.rs | 25 ++- crates/render/src/fx/renderer.rs | 42 +++-- crates/render/src/tests/objectfx.rs | 46 +++++ docs/target-app-parity.md | 1 + 18 files changed, 928 insertions(+), 91 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index cdde7d135..3eaf685f0 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -28,7 +28,7 @@ use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ - GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, + GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; @@ -71,12 +71,27 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> { } } -fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> PathData { +/// `path` with the geometry effects of `effects` applied, and how gradients follow them +/// ([`crate::Carry`]). +fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { if !has_geometry(effects) || path.is_empty() { - return path.clone(); + return (path.clone(), crate::Carry::default()); } let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); - apply_geometry_with(effects, path, b, ctx) + apply_geometry_carrying(effects, path, b, ctx) +} + +/// The geometry `g` with the own effects of `item` (one of the fills and strokes `ctx` is the +/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient +/// moved along with them. +fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { + let (ig, carry) = apply(item_effects(item), g, &ctx.item(item)); + let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() }; + if !carry.moves(&ap) { + return (ig, carried.clone()); + } + carry.apply(&mut ap); + (ig, ap.items.pop().unwrap_or_else(|| carried.clone())) } /// A path node of `kind`'s flavour (compound when the result has several subpaths). @@ -414,12 +429,15 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea } /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of -/// its effects, and `n` without those effects. +/// its effects, and `n` without those effects, its gradients moved along with them. fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { let (base, rule) = geometry(n)?; let ctx = GeomContext::of(n); - let g = apply(&n.appearance.effects, &base, &ctx); let mut m = n.clone(); + let (g, carry) = apply(&n.appearance.effects, &base, &ctx); + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } m.appearance.effects.retain(|e| !is_geometry(&e.id)); Some((g, rule, ctx, m)) } @@ -436,9 +454,9 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option { .appearance .items .iter() - .map(|item| { - let ig = apply(item_effects(item), &g, &ctx.item(item)); - let mut it = item.clone(); + .zip(&m.appearance.items) + .map(|(item, carried)| { + let (ig, mut it) = apply_item(item, carried, &g, &ctx); clear_item_effects(&mut it); Arc::new(piece(d, n, ig, rule, it)) }) @@ -470,9 +488,8 @@ pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option< let (g, rule, ctx, mut m) = leaf_base(n)?; m.appearance.effects.retain(|e| e.visible); let mut pieces = vec![]; - for item in n.appearance.items.iter().filter(|i| paints(i)) { - let ig = apply(item.effects(), &g, &ctx.item(item)); - let mut it = item.clone(); + for (item, carried) in n.appearance.items.iter().zip(&m.appearance.items).filter(|(i, _)| paints(i)) { + let (ig, mut it) = apply_item(item, carried, &g, &ctx); clear_item_effects(&mut it); it.effects_mut().retain(|e| e.visible); let p = match &it { @@ -562,9 +579,8 @@ pub fn bake_appearance(n: &Node) -> Option { Arc::new(p) }; let mut pieces = vec![]; - for item in n.appearance.items.iter().filter(|i| paints(i)) { - let ig = apply(item_effects(item), &g, &ctx.item(item)); - let mut it = item.clone(); + for (item, carried) in n.appearance.items.iter().zip(&m.appearance.items).filter(|(i, _)| paints(i)) { + let (ig, mut it) = apply_item(item, carried, &g, &ctx); clear_item_effects(&mut it); pieces.push(match &it { AppearanceItem::Stroke(st) if st.brush.is_none() => { diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index e16742270..0484f9cbe 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -45,6 +45,136 @@ fn object_effects_become_geometry_and_raster_effects_stay() { assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); } +#[test] +fn a_baked_transform_moves_a_placed_gradient_with_the_path() { + 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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let start = |n: &Node| match n.appearance.fill_paint() { + Paint::Gradient(g) => g.geom.unwrap().start.x, + p => panic!("a gradient: {p:?}"), + }; + // Alone, with a stroke whose own effect makes one path for each fill and stroke, and as the + // fill's own effect. + for (split, own) in [(false, false), (true, false), (true, true)] { + let mut ap = Appearance::basic(placed.clone(), Paint::solid(Color::BLACK), if split { 1.0 } else { 0.0 }); + if split && let AppearanceItem::Stroke(s) = &mut ap.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + } + let transform = fx("distort.transform", json!({"moveH": 30.0})); + if own { + ap.items[0].effects_mut().push(transform.clone()); + } + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + if !own { + n.appearance.effects.push(transform); + } + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let fill: &Node = if split { &m.children().unwrap()[0] } else { m }; + assert!((fill.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6); + assert!((start(fill) - 30.0).abs() < 1e-6, "split {split}, own {own}: the gradient starts at {}", start(fill)); + } +} + +/// A stroke's own Transform effect or Free Distort moves the gradient placed on that stroke. +#[test] +fn a_strokes_own_transform_and_free_distort_move_its_placed_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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + // Moved 30 pt right by Transform, and one width (100 pt) right by Free Distort. + let moves = [ + (fx("distort.transform", json!({"moveH": 30.0})), 30.0), + (fx("distort.freeDistort", json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]})), 100.0), + ]; + for (e, moved) in moves { + let mut ap = Appearance::basic(Paint::None, placed.clone(), 4.0); + let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") }; + stroke.effects_mut().push(e.clone()); + let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + let d = bake_document(&doc_with(n)).unwrap(); + let mut starts = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + starts.push(g.geom.unwrap().start.x); + } + } + }); + assert!(!starts.is_empty() && starts.iter().all(|s| (s - moved).abs() < 1e-6), "{}: {starts:?}", e.id); + } +} + +#[test] +fn a_fills_gradient_follows_the_effects_around_its_transform_with_options() { + 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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let moved = fx("distort.transform", json!({"moveH": 30.0})); + let scales_strokes = fx("distort.transform", json!({"strokes": true})); + // A move before and a move after the fill's Transform effect with Scale Strokes & Effects on. + for fill_fx in [vec![moved.clone(), scales_strokes.clone()], vec![scales_strokes, moved]] { + let mut ap = Appearance::basic(placed.clone(), Paint::None, 0.0); + *ap.items[0].effects_mut() = fill_fx.clone(); + let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + let d = bake_document(&doc_with(n)).unwrap(); + let mut starts = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + if let Paint::Gradient(g) = c.appearance.fill_paint() { + starts.push((c.geometric_bounds().unwrap().x0, g.geom.unwrap().start.x)); + } + }); + assert!(!starts.is_empty() && starts.iter().all(|(x0, s)| (x0 - 30.0).abs() < 1e-6 && (s - 30.0).abs() < 1e-6), "{fill_fx:?}: {starts:?}"); + } +} + +#[test] +fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds() { + 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 = |kind| GradientPaint::new(Gradient::new(kind, stops.clone())); + let linear = GradientGeom { start: Point::new(20.0, 50.0), end: Point::new(80.0, 50.0), aspect: 1.0, focal: None }; + let radial = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(60.0, 30.0), aspect: 0.5, focal: None }; + let placed_linear = Paint::Gradient(Box::new(GradientPaint { geom: Some(linear), ..ramp(GradientKind::Linear) })); + let placed_radial = Paint::Gradient(Box::new(GradientPaint { geom: Some(radial), ..ramp(GradientKind::Radial) })); + let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear))); + let baked = |fill: &Paint, e: Effect| { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill.clone(), Paint::None, 0.0)); + n.appearance.effects.push(e); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + match m.appearance.fill_paint() { + Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()), + p => panic!("a gradient: {p:?}"), + } + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + // The bottom right corner moves 50 pt right, so the bounds grow from 0..100 to 0..150 across. + let corners = json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.5, 1.0], [0.0, 1.0]]}); + let distort = || fx("distort.freeDistort", corners.clone()); + let (b, g) = baked(&placed_linear, distort()); + assert!((b.x1 - 150.0).abs() < 1e-6, "{b:?}"); + let geom = g.geom.unwrap(); + assert!(near(geom.start, Point::new(30.0, 50.0)) && near(geom.end, Point::new(120.0, 50.0)), "{geom:?}"); + // A radial gradient's center and ellipse take the same stretch. + let (_, g) = baked(&placed_radial, distort()); + let Paint::Gradient(mut expected) = placed_radial.clone() else { panic!("a gradient") }; + expected.transform(vectorcraft_geom::Affine::new([1.5, 0.0, 0.0, 1.0, 0.0, 0.0])); + assert_eq!(g.geom, expected.geom); + assert!(near(g.geom.unwrap().start, Point::new(60.0, 30.0)), "{:?}", g.geom); + // A fitted gradient stays fitted, and fits the distorted outline. + assert_eq!(baked(&fitted, distort()).1.geom, None); +} + #[test] fn item_effects_split_into_one_path_per_item() { let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 844063932..a014807a7 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -223,7 +223,7 @@ pub fn effect_catalog() -> Vec { "distort.freeDistort", "Free Distort…", DT, - "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}", + "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]]; placed gradients move and scale from the object's bounds to its distorted bounds)}", json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}), ), g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})), @@ -238,7 +238,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 1b496a9c5..c07045702 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -13,6 +13,21 @@ fn diag(b: Rect) -> f64 { /// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates), /// making at most `max` anchors ([`map_nonlinear_within`]). pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { + let c = free_distort_corners(b, p); + let (w, h) = (b.width().max(1e-9), b.height().max(1e-9)); + let f = |q: Point| { + let u = (q.x - b.x0) / w; + let v = (q.y - b.y0) / h; + let top = c[0].lerp(c[1], u); + let bot = c[3].lerp(c[2], u); + top.lerp(bot, v) + }; + map_nonlinear_within(path, diag(b) / 16.0, max, f) +} + +/// The four corners (top left, top right, bottom right, bottom left) Free Distort with params `p` +/// moves bounding box `b`'s corners to, in the document. +pub(crate) fn free_distort_corners(b: Rect, p: &Value) -> [Point; 4] { let mut c = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)]; if let Some(arr) = p.get("corners").and_then(Value::as_array) { for (i, v) in arr.iter().take(4).enumerate() { @@ -26,16 +41,7 @@ pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData } } let (w, h) = (b.width().max(1e-9), b.height().max(1e-9)); - let to_doc = |q: Point| Point::new(b.x0 + q.x * w, b.y0 + q.y * h); - let c = c.map(to_doc); - let f = |q: Point| { - let u = (q.x - b.x0) / w; - let v = (q.y - b.y0) / h; - let top = c[0].lerp(c[1], u); - let bot = c[3].lerp(c[2], u); - top.lerp(bot, v) - }; - map_nonlinear_within(path, diag(b) / 16.0, max, f) + c.map(|q| Point::new(b.x0 + q.x * w, b.y0 + q.y * h)) } /// Pucker & Bloat: anchors move toward (bloat) or away from (pucker) the centre, handles the diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index f6f007b5c..715143e1a 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -157,6 +157,118 @@ pub(crate) fn optimize_geometry_effects<'a>(effects: &[&'a Effect], style_safe: /// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that /// is more. pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData { + apply_geometry_carrying_within(effects, path, bounds, ctx, budget, None) +} + +/// [`apply_geometry_with`], and the [`Carry`] that moves the object's gradients along with the +/// effects that move the object as a whole. +pub fn apply_geometry_carrying(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> (PathData, Carry) { + let mut carry = Carry::default(); + let out = apply_geometry_carrying_within(effects, path, bounds, ctx, Budget::EFFECTS, Some(&mut carry)); + (out, carry) +} + +/// [`apply_geometry_carrying`] on kurbo paths. +pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> (BezPath, Carry) { + let (out, carry) = apply_geometry_carrying(effects, &PathData::from_bezpath(path), bounds, ctx); + (out.to_bezpath(), carry) +} + +/// How placed gradients follow geometry effects. The Transform effect without copies maps them as +/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves +/// and scales them from the object's bounds before the effect to its bounds after it. For the +/// other geometry effects it is empty. It does not change gradients fitted to the object. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Carry(Vec); + +#[derive(Clone, Copy, Debug, PartialEq)] +enum CarryStep { + Affine(vectorcraft_geom::Affine), + /// From box `from` to box `to` (strokes: their stroke-inflated boxes). + Boxes { + from: Rect, + to: Rect, + }, +} + +impl Carry { + /// Does effect `id` with merged params `p` on geometry with reference box `b` (`seed` for + /// Random) move placed gradients? Free Distort does unless it leaves every corner in place; + /// the Transform effect does without copies (with copies the original stays where it is, and + /// `crate::reshape` gives each copy its own gradients). + pub(crate) fn follows(id: &str, p: &Value, b: Rect, seed: u64) -> bool { + match id { + "distort.transform" => { + let (m, copies) = distort::transform_map(b, p, seed); + copies == 0 && m != vectorcraft_geom::Affine::IDENTITY && m.is_finite() + } + "distort.freeDistort" => { + let corners = distort::free_distort_corners(b, p); + let tol = 1e-9 * b.width().hypot(b.height()).max(1.0); + let still = + [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].iter().zip(&corners).all(|(a, c)| c.distance((*a).into()) <= tol); + !still && corners.iter().all(|c| c.x.is_finite() && c.y.is_finite()) + } + _ => false, + } + } + + /// How placed gradients follow effect `id` (as [`Self::follows`]) on an object whose geometric + /// bounds are `from` before the effect and `to` after it; empty when they stay in place. + pub(crate) fn of(id: &str, p: &Value, b: Rect, from: Option, to: Option, seed: u64) -> Carry { + if !Self::follows(id, p, b, seed) { + return Carry::default(); + } + let step = match id { + "distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)), + _ => match (from, to) { + (Some(from), Some(to)) if from != to && [to.x0, to.y0, to.x1, to.y1].iter().all(|v| v.is_finite()) => { + Some(CarryStep::Boxes { from, to }) + } + _ => None, + }, + }; + Carry(step.into_iter().collect()) + } + + /// Does it leave gradients where they are? + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Does it move any of the gradients of `ap`? Callers check first, so an appearance shared + /// with other objects is not copied for nothing. + pub fn moves(&self, ap: &Appearance) -> bool { + !self.0.is_empty() && ap.has_placed_gradient() + } + + /// Move the placed gradients of `ap` along, through each step in turn. + pub fn apply(&self, ap: &mut Appearance) { + if !self.moves(ap) { + return; + } + for s in &self.0 { + match *s { + CarryStep::Affine(m) => ap.transform_gradients(m), + CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to), + } + } + } + + pub(crate) fn push(&mut self, more: Carry) { + self.0.extend(more.0); + } +} + +/// [`apply_geometry_within`], adding to `carry` how gradients follow each effect. +pub(crate) fn apply_geometry_carrying_within( + effects: &[Effect], + path: &PathData, + bounds: Rect, + ctx: &GeomContext, + budget: Budget, + mut carry: Option<&mut Carry>, +) -> PathData { // `budget` raised to what `path` costs, measured once, and only for a path that has geometry // effects. let has_stroke = match (ctx.item, ctx.appearance) { @@ -185,6 +297,9 @@ pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: let p = merged_params(&e.id, &e.params); let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { + if let Some(c) = carry.as_deref_mut() { + c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed)); + } cur = next; } } diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index ce8c38077..a3f6859b8 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -198,7 +198,11 @@ fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Opt Some(area) => Some(reshaped(&area, n)), None => { let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes); - crate::reshape::reshape_leaves(&mut m, &fx); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx); + // The group's own fills and strokes follow the effects as its members do. + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } without_geometry_effects(&mut m); Some(m) } @@ -210,7 +214,11 @@ fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Opt let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); crate::reshape::outline_nested_text(&mut art, false); - crate::reshape::reshape_leaves(&mut art, &fx); + let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx); + // The art's own fills and strokes follow the effects as its members do. + if carry.moves(&art.appearance) { + carry.apply(&mut art.appearance); + } (art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate); art.appearance.effects = n.appearance.effects.clone(); without_geometry_effects(&mut art); @@ -230,8 +238,12 @@ fn reshaped(src: &Node, n: &Node) -> Node { let mut m = src.clone(); if let Some((path, rule)) = geometry(src) { let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); - let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n)); + let (g, carry) = crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n)); m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false }; + // Its gradients move with the outline, as the canvas paints them. + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } } without_geometry_effects(&mut m); m @@ -711,7 +723,12 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - crate::reshape::reshape_leaves(&mut m, &fx); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx); + // The group's own fills and strokes paint the members as a whole: their gradients follow + // the effects as the members' do. + if paint && carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } } m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); if paint { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index b67279bbb..a0cddc94d 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -248,7 +248,7 @@ fn hidden_effect_or_plain_group_is_untouched() { } #[test] -fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() { +fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_those_placed_on_its_paths() { if skip_without_craft_fonts() { return; } @@ -273,7 +273,181 @@ fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_o let members = art.children().unwrap(); let after = start(&members[1].children().unwrap()[0]); 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"); + assert!((start(&members[0]) - 30.0).abs() < 1e-6, "the square's gradient moves with it"); +} + +#[test] +fn a_groups_transform_moves_the_gradient_of_its_own_fill() { + 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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (15.0, 5.0).into(), aspect: 1.0, focal: None }; + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), sq(2, 5.0, Color::BLACK)]); + g.appearance + .items + .push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))))); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut starts = vec![]; + art.walk(&mut |c: &Node| { + if let Paint::Gradient(p) = c.appearance.fill_paint() { + starts.push(p.geom.unwrap().start.x); + } + }); + assert!(!starts.is_empty() && starts.iter().all(|s| (s - 30.0).abs() < 1e-6), "{starts:?}"); +} + +/// The art Pathfinder and compound shapes take (`shape_art`) paints as the canvas does: a path's +/// gradient and a group's own fill move with a Transform effect. +#[test] +fn shape_art_moves_gradients_with_a_transform_effect() { + 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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 40}), visible: true }; + let starts = |art: &Node| { + let mut out = vec![]; + art.walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let Paint::Gradient(p) = item.paint() { + out.push(p.geom.unwrap().start.x); + } + } + }); + out + }; + let mut path = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0)); + path.appearance.effects.push(moved()); + let got = starts(&shape_art(&path, None).unwrap()); + assert!(got.len() == 1 && (got[0] - 40.0).abs() < 1e-6, "the path's gradient: {got:?}"); + let mut g = Node::group(NodeId(10), vec![sq(2, 0.0, Color::BLACK)]); + g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed))); + g.appearance.effects.push(moved()); + let got = starts(&shape_art(&g, None).unwrap()); + assert!(!got.is_empty() && got.iter().all(|s| (s - 40.0).abs() < 1e-6), "the group's own fill: {got:?}"); +} + +/// A symbol instance's Transform effect moves the gradient placed on its art's own fill in the +/// art Pathfinder takes, as it moves its members. +#[test] +fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() { + 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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]); + art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed))); + let hook = move |_: &Node| Some(art.clone()); + let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + instance.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); + let out = shape_art(&instance, Some(&hook)).unwrap(); + let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient: {:?}", out.appearance) }; + assert!((g.geom.unwrap().start.x - 30.0).abs() < 1e-6, "{:?}", g.geom); + assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves"); +} + +#[test] +fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() { + 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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let ends = |e: Effect| { + let square = + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0))); + let mut g = Node::group(NodeId(10), vec![square, sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e); + let art = evaluate_container(None, &g).unwrap(); + match art.children().unwrap()[0].appearance.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)).unwrap(), + p => panic!("a gradient: {p:?}"), + } + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + // A quarter turn about the group's center (15, 5) takes (0, 5) to (15, 20) and (10, 5) to (15, 10). + let (s, e) = ends(Effect { id: "distort.transform".into(), params: serde_json::json!({"rotate": 90}), visible: true }); + assert!(near(s, Point::new(15.0, 20.0)) && near(e, Point::new(15.0, 10.0)), "{s:?} {e:?}"); + // Free Distort moving the group's top corners 10 pt right slants the square to 0..20 across: + // its gradient follows its own bounds (0..10 to 0..20), not the group's (0..30 to 0..40). + let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + let (s, e) = ends(Effect { id: "distort.freeDistort".into(), params: corners, visible: true }); + assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); +} + +/// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient +/// on its members: the Transform's map moves it, and Free Distort moves and scales it from the +/// nested group's bounds before the effect to its bounds after it. +#[test] +fn a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort() { + 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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let ends = |e: Effect| { + let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]); + inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e); + let art = evaluate_container(None, &g).unwrap(); + match art.children().unwrap()[0].appearance.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)).unwrap(), + p => panic!("a gradient: {p:?}"), + } + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + let (s, e) = ends(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); + assert!(near(s, Point::new(30.0, 5.0)) && near(e, Point::new(40.0, 5.0)), "{s:?} {e:?}"); + let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + let (s, e) = ends(Effect { id: "distort.freeDistort".into(), params: corners, visible: true }); + assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); +} + +/// An image or a symbol instance that a group's Transform effect moves takes the placed gradients +/// of its own fills along, as a path does, and each copy takes them as many times as it takes the +/// transform. +#[test] +fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::ImageObject; + 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::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let image = || { + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + })) + }; + let instance = || NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let starts = |kind: NodeKind, params: serde_json::Value| { + let mut object = Node::new(NodeId(1), kind); + object.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + let mut g = Node::group(NodeId(10), vec![Arc::new(object), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut out = vec![]; + art.walk(&mut |c: &Node| { + if let (NodeKind::Image(_) | NodeKind::SymbolInstance { .. }, Paint::Gradient(p)) = (&c.kind, c.appearance.fill_paint()) { + out.push(p.geom.unwrap().start.x); + } + }); + out + }; + for kind in [image as fn() -> NodeKind, instance] { + assert_eq!(starts(kind(), serde_json::json!({"moveH": 30})), [30.0], "{:?}", kind()); + assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [0.0, 30.0, 60.0], "{:?}", kind()); + } } #[test] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ccdb902d7..606d922a6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -98,7 +98,9 @@ mod params; pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects, raster_px_range}; pub(crate) use geometry::{Budget, apply_one}; -pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with}; +pub use geometry::{ + Carry, GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_bez_carrying, apply_geometry_carrying, apply_geometry_with, +}; #[cfg(test)] pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within}; pub use params::{ diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index b3acf07a5..4ea3e4610 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -11,6 +11,7 @@ //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). +use std::collections::HashSet; use std::sync::Arc; use vectorcraft_doc::live::Hooks; @@ -594,9 +595,15 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { reshape_leaves_within(art, fx, Budget::EFFECTS); } -/// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost. -pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) { - reshape_leaves_apart(art, fx, budget, false); +/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and +/// strokes) follow the effects ([`crate::Carry`]). +pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect]) -> crate::Carry { + reshape_leaves_within(art, fx, Budget::EFFECTS) +} + +/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost. +pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) -> crate::Carry { + reshape_leaves_apart(art, fx, budget, false) } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -616,10 +623,12 @@ fn hoist_copies(art: &mut Node) { } /// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects -/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. -fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) { +/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. Returns how +/// gradients that paint the art as a whole follow the effects. +fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) -> crate::Carry { + let mut whole = crate::Carry::default(); if fx.is_empty() { - return; + return whole; } let mut leaves_safe = true; for_each_leaf(art, &mut |leaf| { @@ -638,15 +647,32 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe); let fx = opt_fx.as_slice(); let seed = art.id.0; - // The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the - // pieces, from the outlines' bounds before the effects to their bounds after them. - let mut outlines = vec![]; - type_outline_bounds(art, &mut outlines); let mut used = art_cost(art); let max = budget.at_least(used); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + // Placed gradients follow a Transform effect and Free Distort ([`crate::Carry`]): under + // Free Distort (`boxes`) each path's move and scale from its bounds before the effect to + // its bounds after it. Under that and the other effects, the placed gradients of type's + // outlines in `art` (`outline_text`) move and scale with the outlines' bounds. + let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed); + let boxes = follows && e.id == "distort.freeDistort"; + let mut outlines = vec![]; + if !follows || boxes { + type_outline_bounds(art, &mut outlines); + } + let mut leaves = vec![]; + if boxes { + leaf_bounds(art, &mut leaves); + } + let mut groups = vec![]; + if follows { + group_marks(art, &mut vec![], &HashSet::new(), &mut groups); + } + // Whether a path kept its shape (past the budget), and the ids of those that did. + let mut kept = false; + let mut kept_ids = HashSet::new(); let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); @@ -668,8 +694,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b || leaf.appearance.has_placed_gradient() || paints_copies_apart(&leaf.appearance, rule)); if options.reach_appearance() { - let made = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); used = used.minus(before).plus(made); + if !moved { + kept = true; + kept_ids.insert(leaf.id); + } return; } if separate { @@ -678,19 +708,41 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; } } - let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { return }; + let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { + kept = true; + kept_ids.insert(leaf.id); + return; + }; used = used.minus(before).plus(Budget::of(&out)); + if follows && !boxes && leaf.appearance.has_placed_gradient() { + crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance); + } set_geometry(leaf, out, rule); }); + if boxes { + rebase_leaves(art, &mut leaves.into_iter()); + } if let Some((m, copies, options, _)) = transform && options.objects { transform_placed(art, m, copies); } + if outlines.iter().any(Option::is_some) { + rebase_type_outlines(art, &mut outlines.into_iter()); + } + // What paints the art as a whole, and the own fills and strokes of the groups inside it, + // follow their bounds under Free Distort (they stay when no path changed them). Each stays + // when a path under it kept its shape (past the budget). + if !groups.is_empty() { + let mut after = vec![]; + group_marks(art, &mut vec![], &kept_ids, &mut after); + carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); + } + if follows && !kept { + whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); + } } - if outlines.iter().any(Option::is_some) { - rebase_type_outlines(art, &mut outlines.into_iter()); - } + whole } /// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by @@ -704,8 +756,16 @@ const MAX_STROKE_SCALE: f64 = 1.0e6; /// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its /// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them /// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform -/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after. -fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, options: crate::distort::TransformOptions, budget: Budget) -> Budget { +/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after, +/// and `false` when the path kept its shape because the moved path would pass `budget`. +fn transform_leaf( + leaf: &mut Node, + path: &PathData, + m: Affine, + copies: usize, + options: crate::distort::TransformOptions, + budget: Budget, +) -> (Budget, bool) { let scaling = vectorcraft_doc::Scaling { strokes: options.strokes, effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), @@ -722,18 +782,18 @@ fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, op if options.patterns { vectorcraft_doc::pattern::transform_pattern_paints(leaf, m); } - return Budget::of(path); + return (Budget::of(path), true); } if copies == 0 { // A path scaled up is drawn with more lines; past the budget it keeps its shape. let moved = path.transformed(m); if !budget.fits(Budget::of(&moved)) { - return Budget::of(path); + return (Budget::of(path), false); } leaf.transform(m, scaling); - return Budget::of(&moved); + return (Budget::of(&moved), true); } - copy_leaf(leaf, path, m, copies, scaling, budget) + (copy_leaf(leaf, path, m, copies, scaling, budget), true) } /// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies` @@ -843,8 +903,10 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { let reserved = (0..later(i)).fold(Budget { anchors: 0, lines: 0 }, |r, _| r.plus(cost)); let limit = max.minus(reserved); let room = limit.minus(*used); + // The fill's or stroke's gradient follows the geometry effects before the Transform effect. + let mut carry = crate::Carry::default(); let g = if crate::has_geometry(before) && !path.is_empty() { - crate::geometry::apply_geometry_within(before, &path, bounds, &ctx.item(item), room) + crate::geometry::apply_geometry_carrying_within(before, &path, bounds, &ctx.item(item), room, Some(&mut carry)) } else { path.clone() }; @@ -857,8 +919,11 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut p = piece(vec![it]); + if carry.moves(&p.appearance) { + carry.apply(&mut p.appearance); + } set_geometry(&mut p, g.clone(), rule); - let made = transform_leaf(&mut p, &g, map, copies, options, room); + let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); transform_piece(&mut p, ctx.seed, limit, used); pieces.push(Arc::new(p)); @@ -902,7 +967,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let made = transform_leaf(leaf, &path, map, copies, options, room); + let (made, _) = transform_leaf(leaf, &path, map, copies, options, room); *used = used.minus(before).plus(made); return; } @@ -912,8 +977,13 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { AppearanceItem::Fill(_) => None, }; let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) }; - let out = crate::geometry::apply_geometry_within(std::slice::from_ref(&e), &path, bounds, &ctx, room); + let mut carry = crate::Carry::default(); + let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry)); *used = used.minus(before).plus(Budget::of(&out)); + // The fill's or stroke's gradient follows the effect as the object's does. + if carry.moves(&leaf.appearance) { + carry.apply(&mut leaf.appearance); + } set_geometry(leaf, out, rule); }); } @@ -957,11 +1027,128 @@ fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter>) { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.geometric_bounds()), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.extras().type_outlines => { + for c in children { + leaf_bounds(c, out); + } + } + _ => {} + } +} + +/// A group inside the art whose own fills and strokes [`carry_groups`] moves (groups of type's +/// outlines follow the outlines' bounds, [`rebase_type_outlines`]). +fn is_member_group(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.extras().type_outlines +} + +/// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a +/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and +/// whether a path below it kept its shape. +type GroupMark = (Vec, Option, bool); + +/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or +/// compound path below it is in `kept`, measured in one walk. Each group below `n` that +/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. +fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut Vec) -> (Option, bool) { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)), + NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => { + let layer = matches!(n.kind, NodeKind::Layer { .. }); + let skip = children.len().saturating_sub(n.editing_children().len()); + let (mut bounds, mut held) = (None, false); + for (i, c) in children.iter().enumerate() { + at.push(i); + let (b, h) = group_marks(c, at, kept, out); + if is_member_group(c) && c.appearance.has_placed_gradient() { + out.push((at.clone(), b, h)); + } + at.pop(); + held |= h; + let counts = if *clip { i == 0 } else { i >= skip && (c.visible || !layer) }; + if counts { + bounds = vectorcraft_geom::union_opt(bounds, b); + } + } + (bounds, held) + } + // Type's outlines: measured as a whole, with each outline that kept its shape counted. + NodeKind::Group { .. } | NodeKind::Layer { .. } => (n.geometric_bounds(), !kept.is_empty() && holds_kept(n, kept)), + _ => (n.geometric_bounds(), false), + } +} + +/// Is a path or compound path below `n` (`n` included) in `kept`? +fn holds_kept(n: &Node, kept: &HashSet) -> bool { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => kept.contains(&n.id), + _ => n.children().into_iter().flatten().any(|c| holds_kept(c, kept)), + } +} + +/// Move the placed gradients of the own fills and strokes of each group in `after` (the groups of +/// `before`, measured again once the paths changed) by what `carry` gives for its bounds before +/// and after. A group with a path below it that kept its shape keeps them in place. +fn carry_groups(art: &mut Node, before: Vec, after: Vec, carry: &dyn Fn(Option, Option) -> crate::Carry) { + for ((at, from, _), (at_after, to, held)) in before.into_iter().zip(after) { + if held || at != at_after { + continue; + } + let k = carry(from, to); + if k.is_empty() { + continue; + } + if let Some(n) = node_at(art, &at) + && is_member_group(n) + && k.moves(&n.appearance) + { + k.apply(&mut n.appearance); + } + } +} + +/// The node below `n` that the child indices `at` lead to. +fn node_at<'a>(n: &'a mut Node, at: &[usize]) -> Option<&'a mut Node> { + let Some((first, rest)) = at.split_first() else { return Some(n) }; + let c = n.children_mut()?.get_mut(*first)?; + node_at(Arc::make_mut(c), rest) +} + +/// Move and scale the placed gradients of each path under `n` that [`leaf_bounds`] lists from its +/// bounds in `before` (before its geometry changed) to its bounds now. +fn rebase_leaves(n: &mut Node, before: &mut std::vec::IntoIter>) { + if matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) { + if let Some(Some(from)) = before.next() + && let Some(to) = n.geometric_bounds() + && from != to + && n.appearance.has_placed_gradient() + { + follow_boxes(&mut n.appearance, from, to); + } + return; + } + if n.extras().type_outlines { + return; + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. }) { + rebase_leaves(Arc::make_mut(c), before); + } + } + } +} + /// Map the placed gradients of `ap` from box `from` to box `to` through the map that takes one box /// onto the other (strokes: their stroke-inflated boxes). The start and end go where /// [`Appearance::rebase_gradients`] puts them, and a radial's ellipse and a linear's isolines are /// mapped with them. -fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) { +pub(crate) fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) { for i in &mut ap.items { match i { AppearanceItem::Fill(f) => { @@ -1036,6 +1223,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at, _ => {} } + // The placed gradients of its own fills and strokes move with it, as a path's do. + if xf != Affine::IDENTITY && one.appearance.has_placed_gradient() { + one.appearance.transform_gradients(xf); + } one }; if copies == 0 { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ea3843f0e..0b3fc0b1c 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -407,6 +407,54 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() { assert_eq!(reshaped, 10); } +/// A path that keeps its shape because its Transform effect would pass the budget keeps the +/// gradients that paint the art as a whole in place too, also with Scale Strokes & Effects on. +#[test] +fn art_past_the_budget_keeps_its_own_gradients_in_place() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let id = NodeId(9); + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let circle = || Arc::new(Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + // The group's own fill, a gradient placed across the circle, after `carry` moves it. + let own_end = |carry: &crate::Carry| { + let mut ap = Appearance::default(); + ap.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + carry.apply(&mut ap); + match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + } + }; + for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] { + let grow = fx("distort.transform", params.clone()); + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS); + let end = own_end(&carry).unwrap(); + assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}"); + // No room beyond what the circle costs: scaled up, it would be drawn with more lines. + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + let b = art.geometric_bounds().unwrap(); + assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}"); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed"); + // A group inside the art whose square took the effect moves its own gradient all the same. + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut inner = Node::group(NodeId(2), vec![square]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + let mut art = Node::group(id, vec![circle(), Arc::new(inner)]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays"); + let inner = &art.children().unwrap()[1]; + let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") }; + assert!((g.geom.unwrap().end.x - 550.0).abs() < 1e-6, "{params}: the inner group's gradient: {:?}", g.geom); + } +} + /// #3578: a large Roughen draws each of its points as many lines; they stay within the budget, /// the Roughen built again with fewer points where they wouldn't. #[test] diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index d67b89a97..de056e4ec 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -407,8 +407,9 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { let mut expanded = false; for item in &l.appearance.items { // Items that paint nothing go; a gradient that can't be expanded stays. - let art = gradient_fill(item).and_then(|(f, g)| { - let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?; + let art = gradient_fill(item).and_then(|_| { + let (fl, path, bounds, moved) = fill_shape(l, item, &path, bounds)?; + let (f, g) = gradient_fill(&moved)?; gradient_art(d, &fl, &path, bounds, f, g, to) }); let Some(art) = art else { @@ -470,29 +471,39 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F /// path (a plain path then), and every geometry effect of its own removed. `None` when it has no /// visible geometry effect. fn reshaped(l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; if !has_geometry(&l.appearance.effects) { return None; } let (path, _) = node_path(l)?; - let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); + let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); let mut m = with_path(l, g); + // Its gradients move with the outline, as the canvas paints them. + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } m.appearance.effects.retain(|e| !is_geometry(&e.id)); Some(m) } /// 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)> { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry}; +/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they +/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along +/// with the outline. +fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> { + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry}; if !has_geometry(item.effects()) { - return Some((l.clone(), path.clone(), bounds)); + return Some((l.clone(), path.clone(), bounds, item.clone())); } // In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on. - let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item)); + let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext::of(l).item(item)); let b = outline.bounds()?; - Some((with_path(l, outline.clone()), outline, b)) + let mut one = Appearance { items: vec![item.clone()], ..Default::default() }; + if carry.moves(&one) { + carry.apply(&mut one); + } + let moved = one.items.pop().unwrap_or_else(|| item.clone()); + Some((with_path(l, outline.clone()), outline, b, moved)) } /// Path or compound path `l` with `path` as its path, a plain path. @@ -809,7 +820,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, & later -= usize::from(fill.is_some()); let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left }; let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| { - let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; + let (fl, _, bounds, _) = fill_shape(l, item, &path, bounds)?; let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else { full = true; return None; diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 59b8db890..4210a1d96 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -133,6 +133,37 @@ fn white_black() -> Value { json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}) } +#[test] +fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it() { + // The object's own effect, and the fill's (the first appearance item). + let slant = json!({"corners": [[0.25, 0.0], [1.25, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + for (effect, params, item) in [ + ("distort.transform", json!({"rotate": 90}), json!(null)), + ("distort.transform", json!({"rotate": 30, "scaleH": 60}), json!(null)), + ("distort.transform", json!({"rotate": 90}), json!(0)), + ("distort.freeDistort", slant.clone(), json!(null)), + ("distort.freeDistort", slant, json!(0)), + ] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false); + // Placed from a quarter of the way across to the right edge. + run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false})); + let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() { + vectorcraft_color::Paint::Gradient(g) => g.geom, + _ => None, + }); + assert!(placed.is_some(), "the fill's gradient is placed"); + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + // Fill alone: the gradient becomes strips inside the outline the effect makes. + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + // The gradient moves with the rectangle on the canvas, and the strips follow it. + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}"); + } +} + #[test] fn eight_strips_match_the_gradient_samples() { let mut s = session(); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 3e2692179..b31175fdb 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -33,6 +33,19 @@ pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath { apply(&n.appearance.effects, bp, &GeomContext::of(n)) } +/// [`apply`], and how gradients follow the effects ([`effects::Carry`]). +pub(super) fn apply_carrying(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> (BezPath, effects::Carry) { + if !effects::has_geometry(effects) || bp.elements().is_empty() { + return (bp.clone(), effects::Carry::default()); + } + effects::apply_geometry_bez_carrying(effects, bp, bp.bounding_box(), ctx) +} + +/// [`effected_path`], and how `n`'s gradients follow its effects. +pub(crate) fn effected_path_carrying(n: &Node, bp: &BezPath) -> (BezPath, effects::Carry) { + apply_carrying(&n.appearance.effects, bp, &GeomContext::of(n)) +} + pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] { match item { AppearanceItem::Fill(f) => &f.effects, diff --git a/crates/render/src/fx/entries.rs b/crates/render/src/fx/entries.rs index ec223173b..29f5724c3 100644 --- a/crates/render/src/fx/entries.rs +++ b/crates/render/src/fx/entries.rs @@ -360,6 +360,11 @@ pub(super) fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize { pub(crate) struct FxGeom { pub(crate) node: Arc, pub(super) g: Arc, - pub(super) items: Vec>>, + /// The node with its gradients moved along with its effects, when they move. + pub(super) carried: Option>, + pub(super) items: Vec>, pub(crate) stamp: u64, } + +/// One appearance item's own effected geometry, and how its gradient follows those effects. +pub(super) type ItemGeom = (Arc, Arc); diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index b544be8d1..a2ace3dbd 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -1,5 +1,5 @@ -use super::bounds::{apply, item_effects}; -use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, KeptImage, Made, Reads, feather_outline}; +use super::bounds::{apply_carrying, item_effects}; +use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, ItemGeom, KeptImage, Made, Reads, feather_outline}; #[cfg(test)] use super::raster::RASTERIZED; use super::raster::{ @@ -630,7 +630,7 @@ impl Renderer { g: &BezPath, rule: FillRule, gctx: &GeomContext, - mut cache: Option<&mut Vec>>>, + mut cache: Option<&mut Vec>>, ) { let bounds = g.bounding_box(); for (j, item) in n.appearance.items.iter().enumerate() { @@ -639,12 +639,23 @@ impl Renderer { } // The item's own geometry effects on `g`, from the cache when there is one. let slot = cache.as_deref_mut().and_then(|c| c.get_mut(j)); - let ig: Arc = match slot { - Some(Some(ig)) => ig.clone(), - Some(s) => s.insert(Arc::new(apply(item_effects(item), g, &gctx.item(item)))).clone(), - None => Arc::new(apply(item_effects(item), g, &gctx.item(item))), + let made = || { + let (ig, carry) = apply_carrying(item_effects(item), g, &gctx.item(item)); + (Arc::new(ig), Arc::new(carry)) + }; + let (ig, carry): ItemGeom = match slot { + Some(Some(e)) => e.clone(), + Some(s) => s.insert(made()).clone(), + None => made(), }; let ig = &*ig; + // The item with its gradient moved along with its own effects. + let carried = (!carry.is_empty() && matches!(item.paint(), vectorcraft_color::Paint::Gradient(_))).then(|| { + let mut ap = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() }; + carry.apply(&mut ap); + ap.items.pop() + }); + let item = carried.as_ref().and_then(Option::as_ref).unwrap_or(item); let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; let rfx = effects::raster_effects(item_effects(item)); if rfx.is_empty() { diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 6b7aa49f7..31ae6d102 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -1,5 +1,5 @@ -use super::bounds::{cull_bounds, effected_path}; -use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, Kept, Made, Reads, Shadow, ShadowEntry}; +use super::bounds::{cull_bounds, effected_path_carrying}; +use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, ItemGeom, Kept, Made, Reads, Shadow, ShadowEntry}; use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted}; use super::raster::{ DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, @@ -339,29 +339,39 @@ impl Renderer { let stamp = self.stamp; let e = match self.fx_geom.remove(&key) { Some(e) if Arc::ptr_eq(&e.node, &o) => e, - _ => FxGeom { g: Arc::new(effected_path(n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp }, + _ => { + let (g, carry) = effected_path_carrying(n, bp); + FxGeom { g: Arc::new(g), carried: carried(n, &carry), items: vec![None; n.appearance.items.len()], node: o, stamp } + } }; (key, FxGeom { stamp, ..e }) }); - let g = match &cached { - Some((_, e)) => e.g.clone(), - None => Arc::new(effected_path(n, bp)), + let (g, carried) = match &cached { + Some((_, e)) => (e.g.clone(), e.carried.clone()), + None => { + let (g, carry) = effected_path_carrying(n, bp); + (Arc::new(g), carried(n, &carry)) + } }; - self.draw_shape_fx_geom(ctx, f, n, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items)); + let paints = carried.as_deref().unwrap_or(n); + self.draw_shape_fx_geom(ctx, f, n, paints, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items)); if let Some((key, e)) = cached { self.fx_geom.insert(key, e); } } - /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry. + /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry: + /// `paints` is `n` with the fills and strokes it paints. + #[allow(clippy::too_many_arguments)] fn draw_shape_fx_geom( &mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, + paints: &Node, g: &BezPath, rule: FillRule, - items: Option<&mut Vec>>>, + items: Option<&mut Vec>>, ) { if g.elements().is_empty() { return; @@ -370,7 +380,7 @@ impl Renderer { self.hairline(ctx, f, g, [0, 0, 0, 255]); return; } - let gctx = GeomContext::of(n); + let gctx = GeomContext::of(paints); let rfx = effects::raster_effects(&n.appearance.effects); // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); @@ -380,7 +390,7 @@ impl Renderer { let reach = bounds.map_or(reach, |b| reach.union(b)); let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None }; let mut items = items; - self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut())); + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, paints, g, rule, &gctx, items.as_deref_mut())); ctx.set_transform(Affine::IDENTITY); } @@ -797,6 +807,16 @@ impl Renderer { } } +/// `n` with the gradients of its fills and strokes moved along with its effects by `carry`, or +/// `None` when they stay where they are. +fn carried(n: &Node, carry: &effects::Carry) -> Option> { + carry.moves(&n.appearance).then(|| { + let mut m = n.clone(); + carry.apply(&mut m.appearance); + Arc::new(m) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index b6baab074..173d91bb0 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -811,6 +811,52 @@ fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() { } } +#[test] +fn a_paths_gradient_moves_with_its_transform_and_free_distort() { + 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::new(GradientKind::Linear, stops)); + // Placed across the square from its left edge (red) to its right edge (blue). + let geom = GradientGeom { start: Point::new(10.0, 50.0), end: Point::new(40.0, 50.0), aspect: 1.0, focal: None }; + let draw = |paint: &GradientPaint, e: Option| { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint.clone())), Paint::None, 0.0)); + n.appearance.effects = e.into_iter().collect(); + render(&doc_with(Document::new(100.0, 100.0), n)) + }; + let placed = GradientPaint { geom: Some(geom), ..ramp.clone() }; + let rb = |img: &Rendered, x: u32, y: u32| { + let p = img.pixel(x, y); + (p[0], p[2]) + }; + let red = |(r, b): (u8, u8)| r > 200 && b < 60; + let blue = |(r, b): (u8, u8)| b > 200 && r < 60; + // Moved 40 pt right by Transform, and 30 pt (one width) right by a Free Distort that moves + // every corner. + let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]}); + for e in [fx("distort.transform", json!({"moveH": 40})), fx("distort.freeDistort", corners)] { + let img = draw(&placed, Some(e.clone())); + let moved = if e.id == "distort.transform" { 40 } else { 30 }; + let (left, right) = (11 + moved, 38 + moved); + assert!(red(rb(&img, left, 50)) && blue(rb(&img, right, 50)), "{}: {:?} {:?}", e.id, rb(&img, left, 50), rb(&img, right, 50)); + } + // Turned a quarter: the gradient runs from top to bottom (or bottom to top). + let turn = fx("distort.transform", json!({"rotate": 90})); + let img = draw(&placed, Some(turn)); + let (top, bottom) = (rb(&img, 25, 36), rb(&img, 25, 63)); + assert!((red(top) && blue(bottom)) || (blue(top) && red(bottom)), "{top:?} {bottom:?}"); + assert_eq!(rb(&img, 12, 50), rb(&img, 38, 50), "the same color across"); + // Scaled to half its width about its center: the gradient spans the narrower square. + let img = draw(&placed, Some(fx("distort.transform", json!({"scaleH": 50})))); + assert!(red(rb(&img, 18, 50)) && blue(rb(&img, 31, 50)), "{:?} {:?}", rb(&img, 18, 50), rb(&img, 31, 50)); + // The fill's own Transform moves its gradient too. + let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(placed.clone())), Paint::None, 0.0)); + n.appearance.items[0].effects_mut().push(fx("distort.transform", json!({"moveH": 40}))); + let img = render(&doc_with(Document::new(100.0, 100.0), n)); + assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50)); +} + #[test] fn darkness_subtracts_from_rgb_before_shadow_opacity() { for opacity in [50.0, 100.0] { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b6c003313..09f792d52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -543,6 +543,7 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. +- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged.