From 394bbb37c014e25f0ac838e219451d1208e9185f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 07:23:49 -0800 Subject: [PATCH] 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.