diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 3dd07351e..550ac3770 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -31,6 +31,7 @@ pub use ids::{LAYER_COLORS, LayerColor, NodeId}; pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget}; pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; +pub use ops::keeps_gradient_fit; use wire::NodeWire; pub use wire::{MAX_READ_NODES, with_node_budget}; diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 2b2bef296..c15b1d05e 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -768,7 +768,7 @@ impl Node { } /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? -fn keeps_gradient_fit(a: Affine) -> bool { +pub fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); let eps = 1e-12 * m0.abs().max(1.0); m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 0ff1cdac0..d42199a68 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -210,6 +210,125 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo assert_eq!(baked(&fitted, distort()).1.geom, None); } +/// A gradient fitted to the object turns, reflects and stretches with a Transform effect that a +/// refit would not reproduce: it is fixed to its fit on the geometry the effect takes, then mapped. +/// Moves and even scales leave it fitted. +#[test] +fn a_fitted_gradient_turns_reflects_and_stretches_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 fitted = |kind| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops.clone())))); + let geom = |start: (f64, f64), end: (f64, f64)| GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None }; + // The geometry of the baked fill (`own`: the Transform effect is the fill's own effect). + let baked = |kind, effects: &[Effect], own: bool| { + let mut ap = Appearance::basic(fitted(kind), Paint::None, 0.0); + if own { + *ap.items[0].effects_mut() = effects.to_vec(); + } + 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 = effects.to_vec(); + } + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + if let Paint::Gradient(g) = c.appearance.fill_paint() { + found.push(g.geom); + } + }); + assert_eq!(found.len(), 1, "{effects:?}, own {own}"); + found[0] + }; + let t = |params| fx("distort.transform", params); + let offset = fx("path.offsetPath", json!({"offset": 10.0, "joins": "miter"})); + let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]}); + let linear = GradientKind::Linear; + let cases = [ + // A quarter turn about the center: the left to right ramp runs from the bottom up. + (linear, vec![t(json!({"rotate": 90.0}))], geom((50.0, 100.0), (50.0, 0.0))), + // Reflected left to right: the ramp runs from right to left. + (linear, vec![t(json!({"reflectX": true}))], geom((100.0, 50.0), (0.0, 50.0))), + // With Scale Strokes & Effects on, the same. + (linear, vec![t(json!({"rotate": 90.0, "strokes": true}))], geom((50.0, 100.0), (50.0, 0.0))), + // Offset Path grows the square to -10..110 first: the turn takes the fit across 120 pt. + (linear, vec![offset.clone(), t(json!({"rotate": 90.0}))], geom((50.0, 110.0), (50.0, -10.0))), + // Stretched to twice the width: the circle of radius 50 about the center becomes an + // ellipse 100 pt across and 50 pt tall. + (GradientKind::Radial, vec![t(json!({"scaleH": 200.0}))], GradientGeom { aspect: 0.5, ..geom((50.0, 50.0), (150.0, 50.0)) }), + // Turned, then moved one width right by Free Distort: the fixed gradient moves with it. + (linear, vec![t(json!({"rotate": 90.0})), fx("distort.freeDistort", corners)], geom((150.0, 100.0), (150.0, 0.0))), + ]; + for (kind, effects, want) in cases { + for own in [false, true] { + let what = format!("{kind:?} {effects:?}, own {own}"); + let got = baked(kind, &effects, own).unwrap_or_else(|| panic!("{what}: fixed to its fit")); + assert_geom_near(got, want, &what); + } + } + // A move and an even scale are what a refit gives: the gradient stays fitted. + for effects in [vec![t(json!({"moveH": 30.0}))], vec![t(json!({"scaleH": 150.0, "scaleV": 150.0})), offset]] { + for own in [false, true] { + assert_eq!(baked(linear, &effects, own), None, "{effects:?}, own {own}"); + } + } + // A stroke's gradient is fixed to its fit on the box grown by half its weight (4 pt), -2..102, + // with Scale Strokes & Effects off or on. + let stroked = |effects: &[Effect], own: bool| { + let mut ap = Appearance::basic(Paint::None, fitted(linear), 4.0); + let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") }; + if own { + *stroke.effects_mut() = effects.to_vec(); + } + 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 = effects.to_vec(); + } + 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 AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + found.push(g.geom); + } + } + }); + assert_eq!(found.len(), 1, "{effects:?}, own {own}"); + found[0] + }; + for strokes in [false, true] { + for own in [false, true] { + let what = format!("stroke, strokes {strokes}, own {own}"); + let got = stroked(&[t(json!({"rotate": 90.0, "strokes": strokes}))], own).unwrap_or_else(|| panic!("{what}: fixed to its fit")); + assert_geom_near(got, geom((50.0, 102.0), (50.0, -2.0)), &what); + } + } +} + +/// A Transform effect that scales the geometry to nothing across leaves a fitted gradient fitted: +/// mapping it with the effect would flatten it to one color. +#[test] +fn a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted, 4.0)); + n.appearance.effects = vec![fx("distort.transform", json!({"scaleH": 0.0, "rotate": 30.0}))]; + 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 AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + found.push(g.geom); + } + } + }); + assert_eq!(found, [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 a014807a7..8a34fc0d7 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -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; 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)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), 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/geometry.rs b/crates/effects/src/geometry.rs index 715143e1a..b9799ef93 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -174,21 +174,34 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R (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. +/// How gradients follow geometry effects. The Transform effect without copies maps placed +/// gradients 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. A gradient fitted to the object fits the +/// geometry each effect makes, until a Transform effect turns, reflects or scales it unevenly +/// (a map after which a refit would not match the mapped gradient): it is fixed to its fit on the +/// geometry that effect takes, and then follows that effect and the ones after it as a placed +/// gradient does. #[derive(Clone, Debug, Default, PartialEq)] pub struct Carry(Vec); +/// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit +/// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map +/// that flattens the geometry to a line (a scale of 0) leaves the gradient fitted, since mapping it +/// would flatten it to one color. +pub(crate) fn fixes_fit(m: vectorcraft_geom::Affine) -> bool { + let [a, b, c, d, _, _] = m.as_coeffs(); + let norm = a * a + b * b + c * c + d * d; + m.is_finite() && m.determinant().abs() > 1e-12 * norm && !vectorcraft_doc::node::keeps_gradient_fit(m) +} + #[derive(Clone, Copy, Debug, PartialEq)] enum CarryStep { - Affine(vectorcraft_geom::Affine), + /// Map `m`; `from` is the geometric bounds of the geometry it takes, which a fitted gradient + /// fits before `m` moves it (none: fitted gradients stay fitted). + Affine { m: vectorcraft_geom::Affine, from: Option }, /// From box `from` to box `to` (strokes: their stroke-inflated boxes). - Boxes { - from: Rect, - to: Rect, - }, + Boxes { from: Rect, to: Rect }, } impl Carry { @@ -220,7 +233,7 @@ impl Carry { return Carry::default(); } let step = match id { - "distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)), + "distort.transform" => Some(CarryStep::Affine { m: distort::transform_map(b, p, seed).0, from }), _ => 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 }) @@ -239,17 +252,32 @@ impl Carry { /// 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() + !self.0.is_empty() && (ap.has_placed_gradient() || (self.pin().is_some() && ap.has_unplaced_gradient())) } - /// Move the placed gradients of `ap` along, through each step in turn. + /// The first step that fixes gradients fitted to the object: a map a refit would not + /// reproduce, on geometry whose bounds are known. + fn pin(&self) -> Option { + self.0.iter().position(|s| matches!(s, CarryStep::Affine { m, from: Some(_) } if fixes_fit(*m))) + } + + /// Move the gradients of `ap` along. Placed gradients take every step in turn; fitted ones + /// are fixed to their fit at the first step [`Self::pin`] finds and take it and the steps + /// after it. pub fn apply(&self, ap: &mut Appearance) { if !self.moves(ap) { return; } - for s in &self.0 { + // The steps move placed gradients only; fitted ones become placed at the pin. + let pin = self.pin(); + for (k, s) in self.0.iter().enumerate() { + if Some(k) == pin + && let CarryStep::Affine { from: Some(b), .. } = *s + { + ap.pin_gradients(b); + } match *s { - CarryStep::Affine(m) => ap.transform_gradients(m), + CarryStep::Affine { m, .. } => ap.transform_gradients(m), CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to), } } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index a0cddc94d..fa02ff004 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -450,6 +450,70 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() { } } +/// A group's Transform effect that turns its art turns the gradients fitted to its paths, to the +/// group's own fill, to a nested group's own fill and to an image's and a symbol instance's own +/// fills: each is fixed to its fit on its own object first. A move leaves the members' gradients +/// fitted. +#[test] +fn a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill() { + use vectorcraft_color::{Gradient, 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 fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + 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(fitted.clone(), Paint::None, 0.0))) + }; + let mut image = Node::new( + NodeId(4), + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::translate((30.0, 0.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + image.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into(); + // A symbol instance whose symbol is not in a document is measured as the 20 pt square about + // its origin: here 22.5..27.5 across and 2.5..7.5 down. + let at = vectorcraft_geom::Affine::translate((25.0, 5.0)) * vectorcraft_geom::Affine::scale(0.25); + let mut instance = Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: at }); + instance.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into(); + // Ends of each gradient by object id; none for a gradient still fitted. + let ends = |params: serde_json::Value| { + let mut inner = Node::group(NodeId(5), vec![square(2, 10.0)]); + inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone()))); + let mut g = Node::group(NodeId(10), vec![square(1, 0.0), Arc::new(inner), Arc::new(image.clone()), Arc::new(instance.clone())]); + g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone()))); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut out = std::collections::BTreeMap::new(); + art.walk(&mut |c: &Node| { + if let Paint::Gradient(p) = c.appearance.fill_paint() { + out.insert(c.id.0, p.geom.map(|g| (g.start, g.end))); + } + }); + out + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + // A quarter turn about the group's center (20, 5): (x, 5) goes to (20, 5 + 20 - x), so a ramp + // from (x0, 5) to (x1, 5) runs from (20, 25 - x0) up to (20, 25 - x1). + let turned = ends(serde_json::json!({"rotate": 90})); + let want = [(1, 0.0, 10.0), (2, 10.0, 20.0), (5, 10.0, 20.0), (4, 30.0, 40.0), (6, 22.5, 27.5), (10, 0.0, 40.0)]; + for (id, x0, x1) in want { + let got = turned.get(&id).copied().flatten(); + let ok = got.is_some_and(|(s, e)| near(s, Point::new(20.0, 25.0 - x0)) && near(e, Point::new(20.0, 25.0 - x1))); + assert!(ok, "object {id}: {got:?} in {turned:?}"); + } + // The group's own fill is fixed to its fit on the art it paints, which the move took to 30..70. + let moved = ends(serde_json::json!({"moveH": 30})); + let group = moved.get(&10).copied().flatten(); + assert!(group.is_some_and(|(s, e)| near(s, Point::new(30.0, 5.0)) && near(e, Point::new(70.0, 5.0))), "{moved:?}"); + assert!(moved.len() == 6 && [1, 2, 4, 5, 6].iter().all(|id| moved[id].is_none()), "{moved:?}"); +} + #[test] fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 4ea3e4610..9382fc5b5 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -714,8 +714,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b 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); + if follows && !boxes { + // A gradient fitted to the path fits the path's own bounds. + let carry = crate::Carry::of(&e.id, ¶ms, bounds, path.bounds(), None, seed); + if carry.moves(&leaf.appearance) { + carry.apply(&mut leaf.appearance); + } } set_geometry(leaf, out, rule); }); @@ -1048,7 +1052,7 @@ fn is_member_group(n: &Node) -> bool { } /// 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 +/// 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); @@ -1065,7 +1069,7 @@ fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut 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() { + if is_member_group(c) && (c.appearance.has_placed_gradient() || c.appearance.has_unplaced_gradient()) { out.push((at.clone(), b, h)); } at.pop(); @@ -1218,6 +1222,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { let src = (**c).clone(); let placed = |xf: Affine| { let mut one = src.clone(); + // Gradients fitted to it are fixed to their fit first when a refit would not match. + if crate::geometry::fixes_fit(xf) { + one.pin_gradients(); + } match &mut one.kind { NodeKind::Image(im) => im.xf = xf * im.xf, NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at, diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 4210a1d96..64e495b5e 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -164,6 +164,33 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_ } } +/// A gradient fitted to the object turns with a Transform effect on the canvas, and Object › Expand +/// of the fill makes strips that draw the same. +#[test] +fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() { + // The gray level at (x, y). + let gray = |r: &vectorcraft_render::Rendered, x: usize, y: usize| r.pixels[(y * 200 + x) * 4] as i32; + // The object's own effect, and the fill's (the first appearance item). + for (params, item) in + [(json!({"rotate": 90}), json!(null)), (json!({"rotate": 90}), json!(0)), (json!({"rotate": 30, "scaleH": 60}), json!(null))] + { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(60.0, 60.0, 140.0, 140.0), white_black(), false); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + if params["rotate"] == 90 { + // The white to black ramp runs up or down the turned square, not across it. + let (top, bottom) = (gray(&before, 100, 70), gray(&before, 100, 130)); + let (left, right) = (gray(&before, 70, 100), gray(&before, 130, 100)); + assert!((top - bottom).abs() > 100 && (left - right).abs() < 20, "{params} on item {item}: {top} {bottom} {left} {right}"); + } + 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, "{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/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 173d91bb0..316f2b19b 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -857,6 +857,41 @@ 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)); } +/// The canvas turns and reflects a gradient fitted to the square with its Transform effect, on the +/// object and as the fill's own effect. +#[test] +fn a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas() { + 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 fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let draw = |e: Effect, own: bool| { + 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(fitted.clone(), Paint::None, 0.0)); + if own { + n.appearance.items[0].effects_mut().push(e); + } else { + n.appearance.effects.push(e); + } + render(&doc_with(Document::new(100.0, 100.0), n)) + }; + 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; + for own in [false, true] { + // A quarter turn: the left (red) to right (blue) ramp runs from the bottom up. + let img = draw(fx("distort.transform", json!({"rotate": 90})), own); + let (top, bottom) = (rb(&img, 25, 36), rb(&img, 25, 63)); + assert!(blue(top) && red(bottom), "own {own}: {top:?} {bottom:?}"); + assert_eq!(rb(&img, 12, 50), rb(&img, 38, 50), "own {own}: the same color across"); + // Reflected left to right: blue on the left, red on the right. + let img = draw(fx("distort.transform", json!({"reflectX": true})), own); + let (left, right) = (rb(&img, 11, 50), rb(&img, 38, 50)); + assert!(blue(left) && red(right), "own {own}: {left:?} {right:?}"); + } +} + #[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 cdb4efaa1..78f040f84 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. +- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. How gradients follow the effect is VectorCraft's own rule; what the 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_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_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.