From a5ea32b3869e3b67a43355c113113232b97fa9dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 07:31:27 -0800 Subject: [PATCH 1/2] Effects: fitted gradients turn with the Transform effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gradient fitted to the object refit to the outline after the object's Transform effect rotated, reflected or scaled it unevenly, so the gradient kept running left to right while the shape turned under it. Object › Transform already turns such a gradient. A Transform effect that a refit would not reproduce now fixes the gradient to its fit on the geometry the effect takes, and then maps it with that effect and the effects after it, as a placed gradient. Under moves and even scales it draws as a refit does, and an effect that scales the object to nothing across leaves it fitted. This applies to an object's own effects, a fill's or stroke's own effects, and a group's: in a group, 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 the fit on their own object. Object › Expand of the fill draws the same. This changes how existing documents draw: art with a fitted gradient and a Transform effect that rotates, reflects or scales it unevenly now shows the gradient turned with the art. How gradients follow the effect is VectorCraft's own rule. --- crates/doc/src/node.rs | 1 + crates/doc/src/node/ops.rs | 2 +- crates/effects/src/bake/tests.rs | 119 +++++++++++++++++++++++++++ crates/effects/src/catalog.rs | 2 +- crates/effects/src/geometry.rs | 56 +++++++++---- crates/effects/src/group/tests.rs | 64 ++++++++++++++ crates/effects/src/reshape.rs | 16 +++- crates/engine/src/tests/objexpand.rs | 27 ++++++ crates/render/src/tests/objectfx.rs | 35 ++++++++ docs/target-app-parity.md | 1 + 10 files changed, 303 insertions(+), 20 deletions(-) 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. From 83f1333d1c779223254f70407c848ae5507f23b9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:14:48 -0800 Subject: [PATCH 2/2] Effects: a Transform effect that flattens the object leaves a fitted gradient fitted with its options on With Scale Strokes & Effects or Transform Patterns on, the path and each copy are moved by Node::transform, which fixed a fitted gradient to its fit before a scale of 0 flattened it to one color. Scaling::keep_fit_when_flat keeps the gradient fitted under a map that flattens the object (node::flattens, the gate fixes_fit already used), and the Transform effect's path transforms set it. docs/target-app-parity.md also says that the Gradient tool still shows a fitted gradient's vector at its unturned fit. --- crates/doc/src/node.rs | 2 +- crates/doc/src/node/live_shape.rs | 4 +++ crates/doc/src/node/ops.rs | 15 ++++++++-- crates/doc/src/node/tests.rs | 3 +- crates/effects/src/bake/tests.rs | 40 ++++++++++++++++++--------- crates/effects/src/geometry.rs | 6 ++-- crates/effects/src/reshape.rs | 5 +++- crates/eps/src/import/native/build.rs | 3 +- docs/target-app-parity.md | 2 +- 9 files changed, 56 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 550ac3770..774e82439 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -31,7 +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; +pub use ops::{flattens, keeps_gradient_fit}; use wire::NodeWire; pub use wire::{MAX_READ_NODES, with_node_budget}; diff --git a/crates/doc/src/node/live_shape.rs b/crates/doc/src/node/live_shape.rs index 93f2022e0..9ddc4e1bb 100644 --- a/crates/doc/src/node/live_shape.rs +++ b/crates/doc/src/node/live_shape.rs @@ -329,6 +329,10 @@ pub struct Scaling { /// Pattern fills and strokes move with the art (Transform Patterns; General › Transform /// Pattern Tiles is its default): their tiles transform too, instead of staying put. pub patterns: bool, + /// Gradients fitted to the object stay fitted under a transform that flattens it to a line or + /// a point (no area left), as a Transform effect leaves them, instead of being fixed to their + /// fit and flattened to one color with the object. + pub keep_fit_when_flat: bool, } impl Scaling { diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index bc1f72e74..f908e50a1 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -431,7 +431,7 @@ impl Node { // Type and symbols in perspective keep looking the same, moved by `a`. self.transform_projection(a); // Refitting an unplaced gradient only reproduces moves and uniform scales. - if !keeps_gradient_fit(a) { + if !keeps_gradient_fit(a) && !(sc.keep_fit_when_flat && flattens(a)) { self.pin_gradients(); } let k = Scaling::factor(a); @@ -770,13 +770,24 @@ impl Node { } } -/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? +/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? The +/// terms are compared within 1e-12 of the scale (at least of 1). A scale of 0 or less, a turn, a +/// reflection and an uneven scale give false, and so does a map that flattens the object +/// ([`flattens`]), which callers that keep a fitted gradient there check apart. 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 } +/// Does `a` flatten what it maps to a line or a point (its determinant 0, within 1e-12 of the +/// squares of its terms), or is it not finite? +pub fn flattens(a: Affine) -> bool { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let norm = m0 * m0 + m1 * m1 + m2 * m2 + m3 * m3; + !a.is_finite() || a.determinant().abs() <= 1e-12 * norm +} + impl Node { /// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true /// everywhere for objects without them, such as type, whose boxes stand in). diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 43a8ac97b..335eb6312 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -221,7 +221,8 @@ fn assert_round_corners(n: &Node, back: Affine, r: f64) { } } -const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; +const KEEP_CORNERS: Scaling = + Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false, keep_fit_when_flat: false }; #[test] fn uneven_scales_keep_live_corners_circular() { diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index d42199a68..6643d91cb 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -313,20 +313,34 @@ 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); - } + // With Scale Strokes & Effects or Transform Patterns on, and for each copy, as without them. + for (options, strokes) in [ + (json!({}), 1), + (json!({"strokes": true}), 1), + (json!({"patterns": true}), 1), + (json!({"copies": 2}), 3), + (json!({"strokes": true, "copies": 2}), 3), + (json!({"patterns": true, "copies": 2}), 3), + ] { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted.clone(), 4.0)); + let mut p = json!({"scaleH": 0.0, "rotate": 30.0}); + if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) { + p.extend(o.clone()); } - }); - assert_eq!(found, [None]); + n.appearance.effects = vec![fx("distort.transform", p)]; + 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, vec![None; strokes], "{options}"); + } } #[test] diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 0d51e1d2b..ed5de8daf 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -188,11 +188,9 @@ 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. +/// would flatten it to one color ([`vectorcraft_doc::node::flattens`]). 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) + !vectorcraft_doc::node::flattens(m) && !vectorcraft_doc::node::keeps_gradient_fit(m) } #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 5ab50dff9..441981f77 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -701,7 +701,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; } if separate { - let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room); + let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; + let made = copy_leaf(leaf, &path, m, copies, scaling, room); used = used.minus(before).plus(made); return; } @@ -771,6 +772,8 @@ fn transform_leaf( strokes: options.strokes, effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), patterns: options.patterns, + // A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does. + keep_fit_when_flat: true, ..Default::default() }; if !options.objects { diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 57d961181..39c751bfc 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -22,7 +22,8 @@ pub(super) enum Page { /// How art moved from a page to its place in the layers' document transforms: its pattern tiles /// move with it. -pub(super) const PAGE_MOVE: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true }; +pub(super) const PAGE_MOVE: Scaling = + Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true, keep_fit_when_flat: false }; /// The page (`visible`, the file read as its page) and the place of its area in the layers' /// document. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5082cb6f4..7679101f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -549,7 +549,7 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. -- **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. +- **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, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. 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. The Gradient tool still shows a fitted gradient's vector at its unturned fit. 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. - **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.