diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 0ff1cdac0..4819727f9 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -210,6 +210,177 @@ 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", + ]; + // 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(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_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"); +} + +/// 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 = 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); + 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 07106ffc8..26c193b62 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -111,8 +111,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; 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/geometry.rs b/crates/effects/src/geometry.rs index 57a10f66e..d561c90b9 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..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}; @@ -378,6 +414,118 @@ 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 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}; + 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()))); + 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()) + }; + 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(), 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(); + 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 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} {:?} (stroke: {}): {got:?}, want {s:?} {e:?} ({from:?} to {to:?})", + c.id, + stroke.is_some() + ); + checked.push((c.id, stroke.is_some())); + } + }); + checked.sort(); + checked.dedup(); + 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}"); + } +} + /// 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 7fabc0490..ca39bc6cf 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -658,12 +658,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); @@ -734,8 +735,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 that its bounds measure 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 that its bounds measure kept its shape (past the budget). let mut after = vec![]; let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; if !groups.is_empty() { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 1afa679aa..90c215bf0 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 fb0279ede..84f196a8b 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -32,3 +32,14 @@ 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: 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); + 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 6c5c05572..644120d34 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -164,6 +164,48 @@ 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]); + // 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}"); + } +} + #[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..e6054b722 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -857,6 +857,94 @@ 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() + }; + 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 { + 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}"); + } + // 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] 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 15d010756..2bc71b6e9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2440,7 +2440,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). 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 80b07c159..8994723f3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -551,6 +551,7 @@ 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; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. 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`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; 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.