From 142366d9eeaabd06f4eefce3cf3a343b46752e83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 20:43:10 -0800 Subject: [PATCH 1/5] 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 8835d769f..70c23ade8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -97,7 +97,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 8bccf2c36..662aa9824 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -32,6 +32,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 3f13d1b3e..5ab6a090b 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::{ @@ -624,7 +624,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() { @@ -633,12 +633,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 fd8d68a98..7722a0a86 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, @@ -314,29 +314,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; @@ -345,7 +355,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(); @@ -355,7 +365,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); } @@ -772,6 +782,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 7bf38edc6..cdb4efaa1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -538,6 +538,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. From c43e4a388edc04eb5e737878acff1bae70337166 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 23:24:09 -0800 Subject: [PATCH 2/5] Tests: placed gradient tests check the whole gradient after Transform and Free Distort The baked Transform and Free Distort tests now compare each placed gradient's start, end, aspect and focal point with the expected values within a tolerance, and the Transform test adds a turn with a scale. --- crates/effects/src/bake/tests.rs | 97 ++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 0484f9cbe..0ff1cdac0 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -17,6 +17,20 @@ fn fx(id: &str, params: serde_json::Value) -> Effect { Effect { id: id.into(), params, visible: true } } +/// Asserts that placed gradient `got` has `want`'s start, end, aspect and focal point, within 1e-6. +fn assert_geom_near(got: vectorcraft_color::GradientGeom, want: vectorcraft_color::GradientGeom, what: &str) { + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + let focal = match (got.focal, want.focal) { + (None, None) => true, + (Some(a), Some(b)) => near(a, b), + _ => false, + }; + assert!( + near(got.start, want.start) && near(got.end, want.end) && (got.aspect - want.aspect).abs() < 1e-6 && focal, + "{what}: got {got:?}, want {want:?}" + ); +} + fn doc_with(n: Node) -> Document { let mut d = Document::new(400.0, 400.0); let l = d.layers[0].id; @@ -52,30 +66,43 @@ fn a_baked_transform_moves_a_placed_gradient_with_the_path() { 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, + let placed_geom = |n: &Node| match n.appearance.fill_paint() { + Paint::Gradient(g) => g.geom.unwrap(), p => panic!("a gradient: {p:?}"), }; + // A move 30 pt right, and a turn of 90° with the width halved about the center (50, 50): the + // square becomes 100 pt wide and 50 pt tall, and the gradient runs from the bottom up. + let moves = [ + (json!({"moveH": 30.0}), Point::new(30.0, 0.0), GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(130.0, 50.0), ..geom }), + ( + json!({"rotate": 90.0, "scaleH": 50.0}), + Point::new(0.0, 25.0), + GradientGeom { start: Point::new(50.0, 75.0), end: Point::new(50.0, 25.0), ..geom }, + ), + ]; // 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}))); + for (params, corner, want) in moves { + 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", params.clone()); + 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 }; + let b = fill.geometric_bounds().unwrap(); + assert!(b.origin().distance(corner) < 1e-6, "{params}, split {split}, own {own}: {b:?}"); + assert_geom_near(placed_geom(fill), want, &format!("{params}, split {split}, own {own}")); } - 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)); } } @@ -98,17 +125,21 @@ fn a_strokes_own_transform_and_free_distort_move_its_placed_gradient() { 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![]; + let mut geoms = 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); + geoms.push(g.geom.unwrap()); } } }); - assert!(!starts.is_empty() && starts.iter().all(|s| (s - moved).abs() < 1e-6), "{}: {starts:?}", e.id); + assert!(!geoms.is_empty(), "{}: the stroke keeps its gradient", e.id); + let want = GradientGeom { start: Point::new(moved, 50.0), end: Point::new(100.0 + moved, 50.0), ..geom }; + for g in geoms { + assert_geom_near(g, want, &e.id); + } } } @@ -127,13 +158,18 @@ fn a_fills_gradient_follows_the_effects_around_its_transform_with_options() { *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![]; + let mut found = 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)); + found.push((c.geometric_bounds().unwrap(), g.geom.unwrap())); } }); - assert!(!starts.is_empty() && starts.iter().all(|(x0, s)| (x0 - 30.0).abs() < 1e-6 && (s - 30.0).abs() < 1e-6), "{fill_fx:?}: {starts:?}"); + assert!(!found.is_empty(), "{fill_fx:?}: the fill keeps its gradient"); + let want = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(130.0, 50.0), ..geom }; + for (b, g) in found { + assert!(b.origin().distance(Point::new(30.0, 0.0)) < 1e-6, "{fill_fx:?}: {b:?}"); + assert_geom_near(g, want, &format!("{fill_fx:?}")); + } } } @@ -143,7 +179,7 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo 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 radial = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(60.0, 30.0), aspect: 0.5, focal: Some(Point::new(45.0, 32.0)) }; 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))); @@ -165,12 +201,11 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo 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. + // A radial gradient's center, focal point and ellipse take the same stretch: the radius along + // the vector grows from 20 to 30 pt and the 10 pt radius across it stays. 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); + let want = GradientGeom { start: Point::new(60.0, 30.0), end: Point::new(90.0, 30.0), aspect: 1.0 / 3.0, focal: Some(Point::new(67.5, 32.0)) }; + assert_geom_near(g.geom.unwrap(), want, "radial"); // A fitted gradient stays fitted, and fits the distorted outline. assert_eq!(baked(&fitted, distort()).1.geom, None); } From 394bbb37c014e25f0ac838e219451d1208e9185f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 07:23:49 -0800 Subject: [PATCH 3/5] Effects: placed gradients move and scale with the Warp effects A gradient placed with the Gradient tool stayed where it was when the object's Warp effect changed the outline, so the warped shape no longer matched its gradient. Under each Warp style the placed gradients of a path now move and scale from the path's bounds before the warp to its bounds after it, as they do under Free Distort. This applies to an object's own Warp and to a fill's or stroke's own (a stroke uses its stroke-inflated bounds), on the canvas and in Object > Expand of the fill. In a group with a Warp effect, each path's and compound path's gradient follows its own bounds, and the own fills of the group and of the groups inside it follow those groups' bounds. A warp with no bend and no distortion leaves gradients exactly where they are, and gradients fitted to the object are unchanged. This changes how existing documents draw: a placed gradient on art with a Warp effect now follows the warped outline instead of staying in place. How gradients follow the warp is VectorCraft's own rule. What the Warp effects do to the object is described in https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html --- crates/effects/src/bake/tests.rs | 139 +++++++++++++++++++++++++++ crates/effects/src/catalog.rs | 3 +- crates/effects/src/geometry.rs | 21 ++-- crates/effects/src/group/tests.rs | 98 +++++++++++++++++++ crates/effects/src/reshape.rs | 15 +-- crates/effects/src/warp.rs | 9 ++ crates/engine/src/tests/objexpand.rs | 38 ++++++++ crates/render/src/tests/objectfx.rs | 31 ++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 10 files changed, 342 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 0ff1cdac0..1829f3834 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -210,6 +210,145 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo assert_eq!(baked(&fitted, distort()).1.geom, None); } +#[test] +fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_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())); + // A ramp across the square and one down it, so both axes of the box map show. + let across = GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; + let down = GradientGeom { start: Point::new(60.0, 10.0), end: Point::new(60.0, 75.0), aspect: 1.0, focal: None }; + let placed = |geom| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp(GradientKind::Linear) })); + let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear))); + let square = Rect::new(0.0, 0.0, 100.0, 100.0); + let baked = |fill: &Paint, e: Effect| { + let mut n = Node::path(NodeId(50), shapes::rectangle(square), 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:?}"), + } + }; + // Where the box map from the square to `to` takes `p`. + let follow = |p: Point, to: Rect| Point::new(to.x0 + p.x / 100.0 * to.width(), to.y0 + p.y / 100.0 * to.height()); + let styles = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + let mut moved = 0; + for style in styles { + for params in + [json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})] + { + for linear in [across, down] { + let what = format!("warp.{style} {params} {linear:?}"); + let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone())); + let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear }; + assert_geom_near(g.geom.unwrap(), want, &what); + if b != square { + moved += 1; + } + } + let what = format!("warp.{style} {params}"); + // A fitted gradient stays fitted, and fits the warped outline. + assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}"); + } + } + assert!(moved >= 80, "most of the warps change the bounds: {moved}"); + // With no bend and no distortion the gradient stays where it is. + let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0}))); + assert_geom_near(g.geom.unwrap(), across, "no bend"); +} + +/// A warp with no distortion and no bend (or a bend Arc takes as none) leaves a placed gradient +/// exactly where it was, even where warping the outline moves its bounds by a rounding error. +#[test] +fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() { + 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 geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut drift = 0; + let cases = ["arc", "bulge", "flag", "wave", "rise", "fisheye", "inflate", "squeeze", "twist"].map(|s| (s, 0.0)); + for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() { + let k = k as f64; + let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71); + let mut n = Node::path(NodeId(50), shapes::ellipse(oval), Appearance::basic(placed.clone(), Paint::None, 0.0)); + n.appearance.effects.push(fx(&format!("warp.{style}"), json!({ "bend": bend }))); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + drift += usize::from(m.geometric_bounds() != shapes::ellipse(oval).bounds()); + let Paint::Gradient(g) = m.appearance.fill_paint() else { panic!("warp.{style} {bend}: a gradient") }; + assert_eq!(g.geom, Some(geom), "warp.{style} {bend}"); + } + assert!(drift > 0, "the warped outlines' bounds move by a rounding error"); + // Another style with as small a bend still moves the outline, and the gradient follows it. + let mut n = Node::path(NodeId(50), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(placed, Paint::None, 0.0)); + n.appearance.effects.push(fx("warp.rise", json!({"bend": 1e-5}))); + let d = bake_document(&doc_with(n)).unwrap(); + let Paint::Gradient(g) = d.node(NodeId(50)).unwrap().appearance.fill_paint() else { panic!("warp.rise: a gradient") }; + assert_ne!(g.geom, Some(geom), "warp.rise 1e-5"); +} + +/// A Warp effect on a fill or a stroke itself moves and scales its placed gradient from the +/// path's bounds before the warp to its bounds after it (a stroke: its stroke-inflated bounds). +#[test] +fn a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_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 = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: Point::new(20.0, 60.0), end: Point::new(80.0, 60.0), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let square = Rect::new(0.0, 0.0, 100.0, 100.0); + let width = 4.0; + // Where the box map from `from` to `to` takes `p`. + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + let pad = |r: Rect| r.inflate(width / 2.0, width / 2.0); + for style in ["arc", "rise", "inflate"] { + for stroke in [false, true] { + let what = format!("warp.{style} on the {}", if stroke { "stroke" } else { "fill" }); + let mut ap = + if stroke { Appearance::basic(Paint::None, placed.clone(), width) } else { Appearance::basic(placed.clone(), Paint::None, 0.0) }; + let Some(item) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_)) == stroke) else { panic!("{what}: the item") }; + item.effects_mut().push(fx(&format!("warp.{style}"), json!({"bend": 40.0}))); + let n = Node::path(NodeId(50), shapes::rectangle(square), ap); + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let Paint::Gradient(g) = item.paint() { + found.push((c.geometric_bounds().unwrap(), g.geom.unwrap())); + } + } + }); + assert!(!found.is_empty(), "{what}: the gradient is kept"); + for (b, g) in found { + assert_ne!(b, square, "{what}: the warp changes the bounds"); + let (from, to) = if stroke { (pad(square), pad(b)) } else { (square, b) }; + let want = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom }; + assert_geom_near(g, want, &what); + } + } + } +} + #[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 a014807a7..cad3cd2ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -109,8 +109,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = - "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; placed gradients move and scale from each path's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 715143e1a..3b8d42d7f 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -175,9 +175,10 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R } /// 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. +/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort and +/// the Warp effects move and scale 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); @@ -193,9 +194,10 @@ enum CarryStep { 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). + /// Random) move placed gradients? Free Distort does unless it leaves every corner in place, a + /// Warp effect unless it has no bend and no distortion, and 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" => { @@ -209,10 +211,17 @@ impl Carry { [(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()) } + _ if id.starts_with("warp.") => crate::warp::bends(id, p), _ => false, } } + /// Does effect `id` move placed gradients from each path's bounds before it to its bounds + /// after it, rather than by one map for the whole object? + pub(crate) fn by_bounds(id: &str) -> bool { + id == "distort.freeDistort" || id.starts_with("warp.") + } + /// 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 { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index a0cddc94d..064c64438 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -378,6 +378,104 @@ fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); } +/// A group's Warp effect moves and scales the gradient placed on each of its paths from that path's +/// bounds before the warp to its bounds after it, not by the group's bounds. +#[test] +fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_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 = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = |x: f64| GradientGeom { start: (x + 2.0, 3.0).into(), end: (x + 8.0, 3.0).into(), aspect: 1.0, focal: None }; + let placed = |x: f64| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom(x)), ..ramp.clone() })); + let square = |id: u64, x: f64| { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(placed(x), Paint::None, 0.0))) + }; + for style in ["arc", "bulge", "rise", "twist"] { + let mut g = Node::group(NodeId(10), vec![square(1, 0.0), square(2, 20.0)]); + g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut moved = false; + for (i, x) in [0.0, 20.0].into_iter().enumerate() { + let m = &art.children().unwrap()[i]; + let b = m.geometric_bounds().unwrap(); + let follow = |p: Point| Point::new(b.x0 + (p.x - x) / 10.0 * b.width(), b.y0 + p.y / 10.0 * b.height()); + let Paint::Gradient(gp) = m.appearance.fill_paint() else { panic!("a gradient") }; + let got = gp.geom.unwrap(); + let want = geom(x); + assert!( + got.start.distance(follow(want.start)) < 1e-6 && got.end.distance(follow(want.end)) < 1e-6, + "warp.{style} path {i}: {got:?} in {b:?}" + ); + moved |= got.start.distance(want.start) > 0.1; + } + assert!(moved, "warp.{style} moves a gradient"); + } +} + +/// Under a group's Warp effect, the group's own placed gradient follows the group's bounds, a +/// nested group's own placed gradient follows the nested group's bounds, and a compound path's +/// placed gradient follows the compound path's bounds, each from before the warp to after it. +#[test] +fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_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 = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let own_fill = |n: &mut Node| n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + for style in ["arc", "bulge", "rise", "flag"] { + let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]); + own_fill(&mut inner); + let ring = vec![ + Arc::new(Node::path(NodeId(6), shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 14.0)), Appearance::default())), + Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())), + ]; + let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd }); + compound.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]); + own_fill(&mut g); + let mut before = std::collections::HashMap::new(); + g.walk(&mut |c: &Node| { + before.insert(c.id, c.geometric_bounds().unwrap()); + }); + g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + // The bounds after the warp of the group, the nested group and the compound path (the paths + // a group's own fill paints carry the group's id). + let mut after = std::collections::HashMap::new(); + art.walk(&mut |c: &Node| { + if matches!(c.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) { + after.entry(c.id).or_insert_with(|| c.geometric_bounds().unwrap()); + } + }); + let mut checked = vec![]; + art.walk(&mut |c: &Node| { + if ![NodeId(3), NodeId(5), NodeId(10)].contains(&c.id) { + return; + } + for item in &c.appearance.items { + let Paint::Gradient(gp) = item.paint() else { continue }; + let (from, to) = (before[&c.id], after[&c.id]); + assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id); + let got = gp.geom.unwrap(); + let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to)); + assert!( + got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, + "warp.{style} {:?}: {got:?}, want {s:?} {e:?} ({from:?} to {to:?})", + c.id + ); + checked.push(c.id); + } + }); + checked.sort(); + checked.dedup(); + assert_eq!(checked, vec![NodeId(3), NodeId(5), NodeId(10)], "warp.{style}"); + } +} + /// 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. diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 4ea3e4610..02464988c 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -652,12 +652,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b 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. + // Placed gradients follow a Transform effect, Free Distort and the Warp effects + // ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from + // its bounds before the effect to its bounds after it. Under those 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 boxes = follows && crate::Carry::by_bounds(&e.id); let mut outlines = vec![]; if !follows || boxes { type_outline_bounds(art, &mut outlines); @@ -731,8 +732,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b 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). + // follow their bounds under Free Distort and Warp (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); diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index fb0279ede..8c887c0ff 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -32,3 +32,12 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - let piece = b.width().hypot(b.height()) / 24.0; map_nonlinear_within(path, piece, max, f) } + +/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with +/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none. +pub(crate) fn bends(id: &str, p: &Value) -> bool { + let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; + let bend = share("bend", 50.0); + let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 }; + bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0 +} diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 4210a1d96..657e0dd00 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -164,6 +164,44 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_ } } +#[test] +fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { + // The object's own effect, and the fill's (the first appearance item). + for (effect, params, item) in [ + ("warp.arc", json!({"bend": 40}), json!(null)), + ("warp.bulge", json!({"bend": 40, "orientation": "vertical"}), json!(null)), + ("warp.inflate", json!({"bend": 40}), 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 warp makes. + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}"); + // The ramp's midpoint, 115 pt before the warp, moves and scales with the warped bounds. + let doc = &s.doc().unwrap().doc; + let b = doc.node(doc.layers[0].id).and_then(|l| l.geometric_bounds()).unwrap(); + let want = b.x0 + (115.0 - 40.0) / 120.0 * b.width(); + assert!((want - 115.0).abs() > 2.0, "{effect} {params}: the warp moves the midpoint ({b:?})"); + let red = |x: usize| f64::from(after.pixels[(100 * 200 + x) * 4]); + let half = (b.x0.ceil() as usize + 1..b.x1.floor() as usize - 1) + .find(|&x| red(x) >= 127.5 && red(x + 1) < 127.5) + .map(|x| x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1))) + .unwrap(); + assert!((half - want).abs() < 1.0, "{effect} {params} on item {item}: the midpoint is at {half}, want {want}"); + } +} + #[test] fn eight_strips_match_the_gradient_samples() { let mut s = session(); diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 173d91bb0..304ae2160 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -857,6 +857,37 @@ fn a_paths_gradient_moves_with_its_transform_and_free_distort() { assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50)); } +#[test] +fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() { + 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)); + let draw = |paint: &GradientPaint, e: Effect| { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 35.0, 60.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 = vec![e]; + render(&doc_with(Document::new(100.0, 100.0), n)) + }; + // Placed from the square's left edge to its right edge, the gradient is drawn as one fitted to + // the square is: after a warp both run from the warped outline's left edge to its right edge. + let placed = GradientPaint { + geom: Some(GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(60.0, 50.0), aspect: 1.0, focal: None }), + ..ramp.clone() + }; + for (style, params) in [("arc", json!({"bend": 50})), ("fisheye", json!({"bend": 60})), ("rise", json!({"bend": 40, "horizontal": 50}))] { + let e = fx(&format!("warp.{style}"), params); + let (got, want) = (draw(&placed, e.clone()), draw(&ramp, e)); + let mut worst = 0; + for y in 0..100 { + for x in 0..100 { + let (a, b) = (got.pixel(x, y), want.pixel(x, y)); + worst = worst.max((0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0)); + } + } + assert!(worst <= 3, "warp.{style}: worst channel difference {worst}"); + } +} + #[test] fn darkness_subtracts_from_rgb_before_shadow_opacity() { for opacity in [50.0, 100.0] { diff --git a/docs/mcp.md b/docs/mcp.md index d3fe607e7..4d31a69c4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2283,7 +2283,8 @@ counts as a value left out except for `recolor.apply`'s `model` and a `map` row' Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a -Transform or Warp moves or bends the whole group). Their Contents row (type: Characters) is a slot in the stack: +Transform or Warp moves or bends the whole group; under a Warp, the gradient placed on each path inside moves and scales +from that path's bounds to its warped bounds). Their Contents row (type: Characters) is a slot in the stack: fills and strokes above it paint over the members (characters), those below under them; new items go above it (on an image, below it). `appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdb4efaa1..4ab6ae9c6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -539,6 +539,7 @@ since 2026-10-01. - **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. +- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles, a gradient placed with the Gradient tool moves and scales from the path's bounds before the warp to its bounds after it, as under Free Distort. This holds for an object's own Warp and for a fill's or stroke's own (a stroke uses its stroke-inflated bounds), on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradient follows its own bounds, a nested group's own fills follow the nested group's bounds, and the group's own fills follow the group's bounds. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_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. From 00ac0ac5aeffb82a046110e076ddf34e1f0db4aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 09:27:55 -0800 Subject: [PATCH 4/5] Tests: placed gradients under Warp on strokes, symbol art and compound shapes Covers the other places a Warp now moves placed gradients: a group's and a nested group's own strokes and a compound path's stroke (with the stroke-inflated bounds), the art of a symbol instance and the area of a compound shape with a Warp, and a stroke's own Warp on the canvas. A new test checks that Arc's no-bend threshold for gradients matches the one the warp uses, and the no-bend test now covers all fifteen styles. The render test's Fisheye case, which left the square's bounds as they were, is replaced by a vertical Bulge, and each style there is checked to move the outline's left or right edge. The Object > Expand test checks that it measures the ramp and not an edge of the outline. The params text and docs/mcp.md now state which bounds each gradient follows, and that a warp moves them with a bend or a distortion. --- crates/effects/src/bake/tests.rs | 2 +- crates/effects/src/catalog.rs | 2 +- crates/effects/src/group/tests.rs | 68 ++++++++++++++++++++++++---- crates/effects/src/tests.rs | 18 ++++++++ crates/effects/src/warp.rs | 4 +- crates/engine/src/tests/objexpand.rs | 12 +++-- crates/render/src/tests/objectfx.rs | 61 ++++++++++++++++++++++++- docs/mcp.md | 6 ++- docs/target-app-parity.md | 2 +- 9 files changed, 154 insertions(+), 21 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 1829f3834..e95fc29b7 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -284,7 +284,7 @@ fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() { let geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None }; let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); let mut drift = 0; - let cases = ["arc", "bulge", "flag", "wave", "rise", "fisheye", "inflate", "squeeze", "twist"].map(|s| (s, 0.0)); + let cases = crate::WARP_STYLES.map(|(s, _)| (s, 0.0)); for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() { let k = k as f64; let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index cad3cd2ed..c5a82cb79 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -109,7 +109,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = "{bend: % (-100..100, 50; placed gradients move and scale from each path's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 064c64438..9c7e2a31a 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -349,6 +349,42 @@ fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() { assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves"); } +/// Under a Warp effect, the gradient placed on a symbol instance's art's own fill follows the art's +/// bounds, and the gradient placed on a compound shape follows its area's bounds, each from before +/// the warp to after it, in the art Pathfinder takes. +#[test] +fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp() { + 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: (2.0, 3.0).into(), end: (9.0, 3.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); + let follow = |p: Point, from: Rect, to: Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + let check = |out: &Node, from: Rect, what: &str| { + let to = out.geometric_bounds().unwrap(); + assert_ne!(from, to, "{what}: the warp changes the bounds"); + let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("{what}: a gradient: {:?}", out.appearance) }; + let got = g.geom.unwrap(); + let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to)); + assert!(got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, "{what}: {got:?}, want {s:?} {e:?}"); + }; + for style in ["arc", "rise", "bulge"] { + let warp = || Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true }; + 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.clone()))); + 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(warp()); + check(&shape_art(&instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance")); + let mut shape = compound_shape(ShapeMode::Add); + shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + shape.appearance.effects.push(warp()); + check(&shape_art(&shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape")); + } +} + #[test] fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; @@ -412,9 +448,10 @@ fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds() } } -/// Under a group's Warp effect, the group's own placed gradient follows the group's bounds, a -/// nested group's own placed gradient follows the nested group's bounds, and a compound path's -/// placed gradient follows the compound path's bounds, each from before the warp to after it. +/// Under a group's Warp effect, the group's own placed gradients follow the group's bounds, a +/// nested group's own placed gradients follow the nested group's bounds, and a compound path's +/// placed gradients follow the compound path's bounds, each from before the warp to after it (a +/// stroke's: those bounds inflated by the stroke). #[test] fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; @@ -422,7 +459,10 @@ fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None }; let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })); - let own_fill = |n: &mut Node| n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + let own_fill = |n: &mut Node| { + n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone()))); + n.appearance.items.push(AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(placed.clone(), 3.0))); + }; let follow = |p: Point, from: Rect, to: Rect| { Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) }; @@ -434,7 +474,7 @@ fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())), ]; let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd }); - compound.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); + compound.appearance = Appearance::basic(placed.clone(), placed.clone(), 2.0).into(); let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]); own_fill(&mut g); let mut before = std::collections::HashMap::new(); @@ -460,19 +500,29 @@ fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by let Paint::Gradient(gp) = item.paint() else { continue }; let (from, to) = (before[&c.id], after[&c.id]); assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id); + let stroke = match item { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }; + let (from, to) = match stroke { + Some(s) => (s.paint_bounds(from), s.paint_bounds(to)), + None => (from, to), + }; let got = gp.geom.unwrap(); let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to)); assert!( got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, - "warp.{style} {:?}: {got:?}, want {s:?} {e:?} ({from:?} to {to:?})", - c.id + "warp.{style} {:?} (stroke: {}): {got:?}, want {s:?} {e:?} ({from:?} to {to:?})", + c.id, + stroke.is_some() ); - checked.push(c.id); + checked.push((c.id, stroke.is_some())); } }); checked.sort(); checked.dedup(); - assert_eq!(checked, vec![NodeId(3), NodeId(5), NodeId(10)], "warp.{style}"); + let want: Vec<_> = [NodeId(3), NodeId(5), NodeId(10)].into_iter().flat_map(|id| [(id, false), (id, true)]).collect(); + assert_eq!(checked, want, "warp.{style}"); } } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 0b3fc0b1c..623fff27a 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -180,6 +180,24 @@ fn warp_zero_bend_is_identity_ish() { } } +/// `warp::bends` says a warp moves placed gradients exactly when the warp moves the outline: +/// Arc leaves the outline as it is under a bend of 0.0001 % and moves it above that, and the other +/// styles move it at any bend. +#[test] +fn warp_bends_when_the_warp_moves_the_outline() { + // A star: Fisheye moves nothing on the edge of the box, where an ellipse or a rectangle lies. + let oval = shapes::star(Point::new(51.7, 34.3), 40.9, 17.3, 5, 7.0); + for (s, _) in WARP_STYLES { + let id = format!("warp.{s}"); + let flat = run(&id, json!({"bend": 0}), &oval); + for bend in [0.0, 1e-5, -1e-5, 0.9e-4, -0.9e-4, 1.1e-4, -1.1e-4] { + let p = json!({ "bend": bend }); + let moved = run(&id, p.clone(), &oval) != flat; + assert_eq!(crate::warp::bends(&id, &p), moved, "{id} at {bend} %"); + } + } +} + #[test] fn warp_arc_bends_the_top_edge() { let out = run("warp.arc", json!({"bend": 50}), &square()); diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 8c887c0ff..84f196a8b 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -34,7 +34,9 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - } /// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with -/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none. +/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the +/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline` +/// tests both sides of it). pub(crate) fn bends(id: &str, p: &Value) -> bool { let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; let bend = share("bend", 50.0); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 657e0dd00..57b5aeeeb 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -194,10 +194,14 @@ fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { let want = b.x0 + (115.0 - 40.0) / 120.0 * b.width(); assert!((want - 115.0).abs() > 2.0, "{effect} {params}: the warp moves the midpoint ({b:?})"); let red = |x: usize| f64::from(after.pixels[(100 * 200 + x) * 4]); - let half = (b.x0.ceil() as usize + 1..b.x1.floor() as usize - 1) - .find(|&x| red(x) >= 127.5 && red(x + 1) < 127.5) - .map(|x| x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1))) - .unwrap(); + // White turns dark once on row 100, and gradually, so the crossing is the ramp's midpoint + // and not an edge of the outline. + let crossings: Vec = + (b.x0.ceil() as usize + 3..b.x1.floor() as usize - 4).filter(|&x| red(x) >= 127.5 && red(x + 1) < 127.5).collect(); + assert_eq!(crossings.len(), 1, "{effect} {params}: white turns dark once on row 100: {crossings:?}"); + let x = crossings[0]; + assert!(red(x - 2) - red(x + 3) < 100.0, "{effect} {params}: the ramp, not an edge: {} to {}", red(x - 2), red(x + 3)); + let half = x as f64 + 0.5 + (red(x) - 127.5) / (red(x) - red(x + 1)); assert!((half - want).abs() < 1.0, "{effect} {params} on item {item}: the midpoint is at {half}, want {want}"); } } diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 304ae2160..e6054b722 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -874,9 +874,20 @@ fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() { geom: Some(GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(60.0, 50.0), aspect: 1.0, focal: None }), ..ramp.clone() }; - for (style, params) in [("arc", json!({"bend": 50})), ("fisheye", json!({"bend": 60})), ("rise", json!({"bend": 40, "horizontal": 50}))] { - let e = fx(&format!("warp.{style}"), params); + let styles = [ + ("arc", json!({"bend": 50})), + ("bulge", json!({"bend": 60, "orientation": "vertical"})), + ("rise", json!({"bend": 40, "vertical": 50, "orientation": "vertical"})), + ]; + for (style, params) in styles.clone() { + let e = fx(&format!("warp.{style}"), params.clone()); let (got, want) = (draw(&placed, e.clone()), draw(&ramp, e)); + // The warp moves the outline's left or right edge, so a gradient left in place would not + // run from edge to edge. + let square = vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0); + let path = shapes::rectangle(square); + let b = vectorcraft_effects::apply_geometry(&[fx(&format!("warp.{style}"), params.clone())], &path, square).bounds().unwrap(); + assert!((b.x0 - 30.0).abs() > 0.5 || (b.x1 - 60.0).abs() > 0.5, "warp.{style}: the outline spans {b:?}"); let mut worst = 0; for y in 0..100 { for x in 0..100 { @@ -886,6 +897,52 @@ fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() { } assert!(worst <= 3, "warp.{style}: worst channel difference {worst}"); } + // A stroke's own Warp: the canvas draws its gradient moved and scaled from the stroke-inflated + // bounds before the warp to those after it, as on the warped path with that gradient. + let geom = GradientGeom { start: Point::new(35.0, 50.0), end: Point::new(55.0, 50.0), aspect: 1.0, focal: None }; + let stroked = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp.clone() })); + let square = vectorcraft_geom::Rect::new(30.0, 35.0, 60.0, 65.0); + let follow = |p: Point, from: vectorcraft_geom::Rect, to: vectorcraft_geom::Rect| { + Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()) + }; + for (style, params) in styles { + let mut n = Node::path(NodeId(0), shapes::rectangle(square), Appearance::basic(Paint::None, stroked.clone(), 6.0)); + let Some(AppearanceItem::Stroke(layer)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned() else { + panic!("a stroke") + }; + let Some(item) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") }; + item.effects_mut().push(fx(&format!("warp.{style}"), params)); + let d = doc_with(Document::new(100.0, 100.0), n); + let live = render(&d); + let baked = vectorcraft_effects::bake_document(&d).unwrap(); + let mut warped = None; + for l in &baked.layers { + l.walk(&mut |c: &Node| { + if let (Some(path), true) = (c.path_data(), c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))) { + warped.get_or_insert_with(|| path.clone()); + } + }); + } + let path = warped.unwrap(); + let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); + assert_ne!(b, square, "warp.{style}: the warp changes the bounds"); + let (from, to) = (layer.paint_bounds(square), layer.paint_bounds(b)); + let moved = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom }; + let want_node = Node::path( + NodeId(0), + path, + Appearance::basic(Paint::None, Paint::Gradient(Box::new(GradientPaint { geom: Some(moved), ..ramp.clone() })), 6.0), + ); + let want = render(&doc_with(Document::new(100.0, 100.0), want_node)); + let mut worst = 0; + for y in 0..100 { + for x in 0..100 { + let (a, b) = (live.pixel(x, y), want.pixel(x, y)); + worst = worst.max((0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0)); + } + } + assert!(worst <= 3, "warp.{style} on the stroke: worst channel difference {worst}"); + } } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 4d31a69c4..163f272ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2283,8 +2283,10 @@ counts as a value left out except for `recolor.apply`'s `model` and a `map` row' Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a -Transform or Warp moves or bends the whole group; under a Warp, the gradient placed on each path inside moves and scales -from that path's bounds to its warped bounds). Their Contents row (type: Characters) is a slot in the stack: +Transform or Warp moves or bends the whole group). Under a group's Warp, each placed gradient moves and scales from +bounds before the warp to bounds after it: a path's or compound path's gradients use its own bounds, a nested group's +own fills and strokes use the nested group's bounds, and the group's own fills and strokes use the group's bounds; a +stroke uses those bounds inflated by the stroke. Their Contents row (type: Characters) is a slot in the stack: fills and strokes above it paint over the members (characters), those below under them; new items go above it (on an image, below it). `appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4ab6ae9c6..6f2af0228 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -537,9 +537,9 @@ since 2026-10-01. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **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. +- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **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. -- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles, a gradient placed with the Gradient tool moves and scales from the path's bounds before the warp to its bounds after it, as under Free Distort. This holds for an object's own Warp and for a fill's or stroke's own (a stroke uses its stroke-inflated bounds), on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradient follows its own bounds, a nested group's own fills follow the nested group's bounds, and the group's own fills follow the group's bounds. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_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. From 5c17cfd038fa550b4e637fc863c530f250787c7f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:20:47 -0800 Subject: [PATCH 5/5] Tests: warped placed gradients check each warp's bounds and movement The Warp gradient test now compares the baked bounds with the square's outline mapped through warp_point, for every style and parameter set. It requires the gradient to move exactly when those bounds move, and pins 39 of the 45 cases as moving the bounds. --- crates/effects/src/bake/tests.rs | 42 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index e95fc29b7..4819727f9 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -250,26 +250,58 @@ fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() { "squeeze", "twist", ]; + // The square's warped bounds, from `warp_point` on 401 points along each side: the square's + // corners are (-1, -1) and (1, 1) in the warp's box coordinates, and a vertical warp swaps + // the axes. + let warped_bounds = |style: &str, p: &serde_json::Value| { + let Some(style) = crate::WarpStyle::from_id(style) else { panic!("warp.{style}: a style") }; + let share = |key: &str, default: f64| p.get(key).and_then(serde_json::Value::as_f64).unwrap_or(default) / 100.0; + let vertical = p.get("orientation").and_then(serde_json::Value::as_str) == Some("vertical"); + let (bend, dh, dv) = (share("bend", 50.0), share("horizontal", 0.0), share("vertical", 0.0)); + let mut r: Option = None; + for k in 0..=400 { + let s = f64::from(k) / 200.0 - 1.0; + for (x, y) in [(s, -1.0), (s, 1.0), (-1.0, s), (1.0, s)] { + let (x2, y2) = if vertical { + let (a, b) = crate::warp_point(style, bend, dh, dv, 1.0, y, x); + (b, a) + } else { + crate::warp_point(style, bend, dh, dv, 1.0, x, y) + }; + let q = Point::new(50.0 + x2 * 50.0, 50.0 + y2 * 50.0); + r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + } + r.unwrap_or(square) + }; + let near = + |a: Rect, b: Rect, tol: f64| (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol; let mut moved = 0; for style in styles { for params in [json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})] { + // A warp that bends the square's sides inward keeps its bounds (within 0.5 pt), and the + // gradient stays with them. + let expected = warped_bounds(style, ¶ms); + let moves = !near(expected, square, 0.5); + moved += usize::from(moves); for linear in [across, down] { let what = format!("warp.{style} {params} {linear:?}"); let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone())); + assert!(near(b, expected, 0.1), "{what}: bounds {b:?}, want {expected:?}"); + let geom = g.geom.unwrap(); + let shifted = geom.start.distance(linear.start).max(geom.end.distance(linear.end)); + assert_eq!(shifted > 0.1, moves, "{what}: the gradient moves {shifted} with bounds {b:?}"); let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear }; - assert_geom_near(g.geom.unwrap(), want, &what); - if b != square { - moved += 1; - } + assert_geom_near(geom, want, &what); } let what = format!("warp.{style} {params}"); // A fitted gradient stays fitted, and fits the warped outline. assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}"); } } - assert!(moved >= 80, "most of the warps change the bounds: {moved}"); + assert_eq!(moved, 39, "the warps that move the bounds, of 45"); // With no bend and no distortion the gradient stays where it is. let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0}))); assert_geom_near(g.geom.unwrap(), across, "no bend");