From 81f86a9acc569123bdaa08e86d33452a03687cbe Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:31:54 +0700 Subject: [PATCH] =?UTF-8?q?M3.15:=20gradients=20follow=20every=20transform?= =?UTF-8?q?=20=E2=80=94=20rotate,=20reflect,=20shear,=20non-uniform=20scal?= =?UTF-8?q?e=20and=20Free=20Distort/warps=20pin=20an=20unplaced=20gradient?= =?UTF-8?q?=20to=20its=20fit=20first=20(fills=20on=20the=20bounds,=20strok?= =?UTF-8?q?es=20on=20the=20stroke-inflated=20box;=20moves=20and=20uniform?= =?UTF-8?q?=20scales=20keep=20refitting);=20linear=20vectors=20keep=20thei?= =?UTF-8?q?r=20isolines=20and=20radial=20gradients=20map=20their=20ellipse?= =?UTF-8?q?=20(principal=20axes=20give=20the=20new=20end=20and=20aspect);?= =?UTF-8?q?=20the=20radial=20angle=20places=20the=20vector=20before=20the?= =?UTF-8?q?=20gradient=20is=20placed;=20the=20eyedropper=20samples=20radia?= =?UTF-8?q?ls=20honouring=20aspect;=20SVG=20import=20shares=20the=20mappin?= =?UTF-8?q?g;=20warped=20text,=20images=20and=20symbols=20no=20longer=20ma?= =?UTF-8?q?p=20their=20gradients=20twice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 164 +++++++++++++++++++++++++-- crates/doc/src/appearance.rs | 48 ++++++++ crates/doc/src/node.rs | 55 ++++++--- crates/engine/src/cmd/xform.rs | 25 ++-- crates/engine/src/tests_gradient.rs | 43 +++++++ crates/svg/src/export.rs | 2 +- crates/svg/src/import.rs | 30 ++--- crates/tools/src/distort.rs | 50 ++++---- crates/tools/src/xform/eyedropper.rs | 24 ++-- 9 files changed, 343 insertions(+), 98 deletions(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index dd45c9bfc..46ef04407 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -131,27 +131,83 @@ fn one64() -> f64 { } impl GradientGeom { - /// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`, - /// radial is centred with radius = half the larger dimension. + /// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`, + /// radial is centred with radius = half the larger dimension and its vector at `angle_deg`. pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self { let c = b.center(); + let a = angle_deg.to_radians(); + let d = Vec2::new(a.cos(), -a.sin()); match kind { - GradientKind::Radial => { - let r = b.width().max(b.height()) / 2.0; - Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 } - } + GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0 }, _ => { - let a = angle_deg.to_radians(); - let d = Vec2::new(a.cos(), -a.sin()); // Project the box corners onto the direction to cover the whole box. let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; Self { start: c - d * half, end: c + d * half, aspect: 1.0 } } } } - pub fn transform(&mut self, a: Affine) { - self.start = a * self.start; - self.end = a * self.end; + + /// Map the placement through `a` so the gradient follows its object exactly. + /// + /// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the + /// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse + /// (the radius along the vector, `aspect` × the radius across it); its principal axes give the + /// new end (on the axis nearest the mapped vector) and aspect. + pub fn transform(&mut self, a: Affine, kind: GradientKind) { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let lin = |v: Vec2| Vec2::new(m0 * v.x + m2 * v.y, m1 * v.x + m3 * v.y); + let perp = |v: Vec2| Vec2::new(-v.y, v.x); + let start = a * self.start; + let u = self.end - self.start; + if u.hypot2() < 1e-18 { + self.start = start; + self.end = start; + return; + } + if kind != GradientKind::Radial { + let far = a * self.end; + let nrm = perp(lin(perp(u))); + self.start = start; + self.end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far }; + return; + } + let (lu, lw) = (lin(u), lin(perp(u) * self.aspect)); + // Eigen-decomposition of M·Mᵀ with M = [lu lw]: the mapped ellipse's axes and squared radii. + let p = lu.x * lu.x + lw.x * lw.x; + let q = lu.y * lu.y + lw.y * lw.y; + let r = lu.x * lu.y + lw.x * lw.y; + let disc = ((p - q).powi(2) + 4.0 * r * r).sqrt(); + let s1 = ((p + q + disc) / 2.0).max(0.0).sqrt(); + let s2 = ((p + q - disc) / 2.0).max(0.0).sqrt(); + let (axis, len, other) = if disc <= 1e-12 * (p + q) { + // A circle: every direction is an axis, so keep the mapped vector's. + (lu / lu.hypot().max(1e-300), s1, s2) + } else { + let th = 0.5 * (2.0 * r).atan2(p - q); + let e1 = Vec2::new(th.cos(), th.sin()); + let e2 = perp(e1); + if e1.dot(lu).abs() >= e2.dot(lu).abs() { (e1, s1, s2) } else { (e2, s2, s1) } + }; + let axis = if axis.dot(lu) < 0.0 { -axis } else { axis }; + self.start = start; + self.end = start + axis * len; + self.aspect = if len > 1e-12 { other / len } else { 1.0 }; + } + + /// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection + /// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial. + pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { + let u = self.end - self.start; + let l2 = u.hypot2(); + if l2 < 1e-18 { + return 0.0; + } + let d = p - self.start; + let along = d.dot(u) / l2; + match kind { + GradientKind::Radial => along.hypot(d.cross(u) / (l2 * self.aspect.max(1e-9))), + _ => along, + } } pub fn angle_deg(&self) -> f64 { let v = self.end - self.start; @@ -183,6 +239,19 @@ impl GradientPaint { pub fn resolve(&self, bounds: Rect) -> GradientGeom { self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle)) } + /// Fix an unplaced gradient (geom None) to its fit on `bounds`, so it can follow transforms + /// that refitting wouldn't reproduce (rotation, shear, non-uniform scale, warps). + pub fn pin(&mut self, bounds: Rect) { + if self.geom.is_none() { + self.geom = Some(self.resolve(bounds)); + } + } + /// Map a placed gradient through `a` (see [`GradientGeom::transform`]). + pub fn transform(&mut self, a: Affine) { + if let Some(g) = &mut self.geom { + g.transform(a, self.gradient.kind); + } + } } #[cfg(test)] @@ -225,4 +294,77 @@ mod tests { assert_eq!(g.stops[0].color.to_hex(), "#000000"); assert_eq!(g.stops[0].offset, 0.0); } + + fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 + } + + fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom { + GradientGeom { start: start.into(), end: end.into(), aspect: 1.0 } + } + + #[test] + fn rotate_90_gives_vertical_vector() { + let mut g = linear((0.0, 0.0), (100.0, 0.0)); + g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}"); + assert!((g.angle_deg() + 90.0).abs() < 1e-9); + } + + #[test] + fn reflect_swaps_start_and_end() { + let mut g = linear((0.0, 25.0), (100.0, 25.0)); + // Reflect across the vertical axis through x = 50. + let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0)); + g.transform(flip, GradientKind::Linear); + assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}"); + } + + #[test] + fn non_uniform_scale_turns_a_circle_into_an_ellipse() { + let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0 }; + g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); + assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}"); + assert!((g.aspect - 0.5).abs() < 1e-9); + // A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long. + let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0 }; + v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); + assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}"); + } + + #[test] + fn samples_follow_shear_and_rotation() { + let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4); + for kind in [GradientKind::Linear, GradientKind::Radial] { + let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6 }; + let mut m = g; + m.transform(shear, kind); + for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] { + let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p)); + assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}"); + } + } + } + + #[test] + fn radial_fit_uses_the_angle_and_param_honours_aspect() { + let b = Rect::new(0.0, 0.0, 100.0, 60.0); + let g = GradientGeom::fit(GradientKind::Radial, b, 90.0); + assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}"); + let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5 }; + assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12); + assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12); + assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12); + } + + #[test] + fn pin_fixes_the_fit_once() { + let mut p = GradientPaint::new(Gradient::default()); + p.angle = 90.0; + let b = Rect::new(0.0, 0.0, 100.0, 50.0); + p.pin(b); + assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); + p.pin(Rect::new(0.0, 0.0, 1.0, 1.0)); + assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); + } } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index fe029fe19..ca7738976 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -321,6 +321,54 @@ impl Appearance { } } } + /// Gradient paints of the fills and strokes. + fn gradients_mut(&mut self) -> impl Iterator { + self.items.iter_mut().filter_map(|i| match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) + | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&mut **g), + _ => None, + }) + } + /// Does a fill or stroke carry a gradient that is fitted to the bounds on each render? + pub fn has_unplaced_gradient(&self) -> bool { + self.items.iter().any(|i| match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) + | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_none(), + _ => false, + }) + } + /// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit + /// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce. + pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) { + for i in &mut self.items { + match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => g.pin(bounds), + AppearanceItem::Stroke(s) => { + let b = s.paint_bounds(bounds); + if let Paint::Gradient(g) = &mut s.paint { + g.pin(b); + } + } + _ => {} + } + } + } + /// Map placed gradients through `a`. + pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) { + for g in self.gradients_mut() { + g.transform(a); + } + } + /// Map placed gradients through a warp, given its local affine approximation at a point (taken + /// at each gradient's centre: the start of a radial, the middle of a linear vector). + pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) { + for g in self.gradients_mut() { + if let Some(geom) = g.geom { + let c = if g.gradient.kind == vectorcraft_color::GradientKind::Radial { geom.start } else { geom.start.midpoint(geom.end) }; + g.transform(near(c)); + } + } + } } #[cfg(test)] diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index d85a7929b..8c3b98e99 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -448,13 +448,17 @@ impl Node { /// Apply an affine transform to the geometry (and gradients) of this node and its descendants. /// `scale_strokes` also scales stroke weights by the transform's mean scale. pub fn transform(&mut self, a: Affine, scale_strokes: bool) { + // Refitting an unplaced gradient only reproduces moves and uniform scales. + if !keeps_gradient_fit(a) { + self.pin_gradients(); + } if scale_strokes { let det = a.determinant().abs().sqrt(); if (det - 1.0).abs() > 1e-9 { self.appearance.scale_strokes(det); } } - transform_paints(&mut self.appearance, a); + self.appearance.transform_gradients(a); match &mut self.kind { NodeKind::Path { path, live, .. } => { path.transform(a); @@ -516,24 +520,24 @@ impl Node { self.walk(&mut |_| n += 1); n } -} - -fn transform_paints(ap: &mut Appearance, a: Affine) { - use crate::appearance::AppearanceItem; - use vectorcraft_color::Paint; - for it in &mut ap.items { - let p = match it { - AppearanceItem::Fill(f) => &mut f.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Gradient(g) = p - && let Some(geom) = &mut g.geom + /// Fix this node's unplaced gradients to their fit on its current bounds (before a transform + /// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed. + pub fn pin_gradients(&mut self) { + if self.appearance.has_unplaced_gradient() + && let Some(b) = self.geometric_bounds() { - geom.transform(a); + self.appearance.pin_gradients(b); } } } +/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? +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 +} + #[cfg(test)] mod tests { use super::*; @@ -555,6 +559,29 @@ mod tests { assert_eq!(child.appearance.stroke_width(), 2.0); } + #[test] + fn unplaced_gradients_pin_only_when_a_refit_would_differ() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; + let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0)); + let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) { + (Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom), + _ => unreachable!(), + }; + let mut n = fresh(); + n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true); + assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting"); + let mut n = fresh(); + n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); + let (f, s) = geoms(&n); + // Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical. + let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); + want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + let f = f.unwrap(); + assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}"); + assert!((s.unwrap().length() - 102.0).abs() < 1e-9); + } + #[test] fn display_names() { let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index a0d452618..90e5892c5 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::edit::selected_roots; @@ -91,23 +90,10 @@ impl Projective { } } -fn warp_paints(ap: &mut Appearance, pr: &Projective) { - for it in &mut ap.items { - let p = match it { - vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, - vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Gradient(g) = p - && let Some(geom) = &mut g.geom - { - geom.start = pr.apply(geom.start); - geom.end = pr.apply(geom.end); - } - } -} - +/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine +/// approximation at their centre. fn warp_node(n: &mut Node, pr: &Projective) { - warp_paints(&mut n.appearance, pr); + n.pin_gradients(); match &mut n.kind { NodeKind::Path { path, live, .. } => { *live = None; @@ -125,11 +111,14 @@ fn warp_node(n: &mut Node, pr: &Projective) { } } _ => { + // No editable points: the affine approximation (which maps the gradients too). if let Some(b) = n.geometric_bounds() { n.transform(pr.affine_near(b.center()), false); } + return; } } + n.appearance.warp_gradients(&|p| pr.affine_near(p)); } fn distort(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 00951b98c..750b93af0 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -131,3 +131,46 @@ fn geometry_and_aspect_in_one_call() { s.execute("appearance.setItem", &q).unwrap(); assert_eq!(fill_gradient(&s, id).gradient, g.gradient); } + +// ---------- M3.15: gradients follow every transform ---------- + +fn fill_geom(s: &Session, id: NodeId) -> GradientGeom { + fill_gradient(s, id).geom.expect("placed") +} + +#[test] +fn rotate_reflect_and_scale_carry_unplaced_gradients() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + s.execute("paint.setFill", &json!({"gradient": {}})).unwrap(); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert!(fill_gradient(&s, id).geom.is_none(), "a move keeps refitting"); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + let g = fill_geom(&s, id); + assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}"); + s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + let r = fill_geom(&s, id); + // The vertical vector is centred on the object, so the reflection swaps its ends. + assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}"); + + // A circle's radial gradient becomes an ellipse under a non-uniform scale. + let c = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(); + let c = NodeId(c["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let e = fill_geom(&s, c); + assert!((e.aspect - 0.5).abs() < 1e-9 && (e.length() - 100.0).abs() < 1e-9, "{e:?}"); +} + +#[test] +fn free_distort_maps_gradients_once() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {}})).unwrap(); + // An affine distort (a sheared parallelogram) maps the gradient exactly like object.transform. + s.execute("object.distort", &json!({"corners": [[20, 0], [120, 0], [100, 100], [0, 100]]})).unwrap(); + let g = fill_geom(&s, id); + let mut want = GradientGeom::fit(GradientKind::Linear, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + want.transform(vectorcraft_geom::Affine::new([1.0, 0.0, -0.2, 1.0, 20.0, 0.0]), GradientKind::Linear); + assert!(g.start.distance(want.start) < 1e-6 && g.end.distance(want.end) < 1e-6, "{g:?} vs {want:?}"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ba71ddca8..96947f11b 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -322,7 +322,7 @@ impl Writer<'_> { fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { let mut geom = g.resolve(bounds); - geom.transform(self.xf); + geom.transform(self.xf, g.gradient.kind); let (s, e) = (geom.start, geom.end); let head = match g.gradient.kind { GradientKind::Radial => { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 8bc4dcee6..b647db4a1 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -70,11 +70,6 @@ fn aff(t: usvg::Transform) -> Affine { Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64]) } -/// Linear part of `m` applied to `v`. -fn lin(m: Affine, v: Vec2) -> Vec2 { - (m * v.to_point()) - (m * Point::ZERO) -} - fn is_plain(g: &usvg::Group) -> bool { g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal } @@ -277,17 +272,10 @@ impl Importer { if lg.spread_method() != usvg::SpreadMethod::Pad { self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id())); } - let t = m * aff(lg.transform()); - let (p1, p2) = (Point::new(lg.x1() as f64, lg.y1() as f64), Point::new(lg.x2() as f64, lg.y2() as f64)); - let start = t * p1; - let far = t * p2; - // Isolines (perpendicular to the vector in gradient space) stay parallel under an - // affine map; the document gradient vector is the normal to their image. - let dir = p2 - p1; - let iso = lin(t, Vec2::new(-dir.y, dir.x)); - let nrm = Vec2::new(-iso.y, iso.x); - let end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far }; - gp(GradientKind::Linear, stops(lg), GradientGeom { start, end, aspect: 1.0 }) + let mut geom = + GradientGeom { start: Point::new(lg.x1() as f64, lg.y1() as f64), end: Point::new(lg.x2() as f64, lg.y2() as f64), aspect: 1.0 }; + geom.transform(m * aff(lg.transform()), GradientKind::Linear); + gp(GradientKind::Linear, stops(lg), geom) } usvg::Paint::RadialGradient(rg) => { if rg.spread_method() != usvg::SpreadMethod::Pad { @@ -296,14 +284,10 @@ impl Importer { if (rg.fx() - rg.cx()).abs() > 1e-4 || (rg.fy() - rg.cy()).abs() > 1e-4 { self.warn(format!("gradient '{}': focal point ignored", rg.id())); } - let t = m * aff(rg.transform()); - let r = rg.r().get() as f64; let c = Point::new(rg.cx() as f64, rg.cy() as f64); - let vx = lin(t, Vec2::new(r, 0.0)); - let vy = lin(t, Vec2::new(0.0, r)); - let start = t * c; - let aspect = if vx.hypot() > 1e-12 { vy.hypot() / vx.hypot() } else { 1.0 }; - gp(GradientKind::Radial, stops(rg), GradientGeom { start, end: start + vx, aspect }) + let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0 }; + geom.transform(m * aff(rg.transform()), GradientKind::Radial); + gp(GradientKind::Radial, stops(rg), geom) } usvg::Paint::Pattern(pt) => { self.warn(format!("pattern '{}' not supported; painted as none", pt.id())); diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index ab22cdba0..0eb1d57c5 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -19,8 +19,7 @@ mod width; use std::sync::Arc; -use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind}; +use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Affine, Point, Vec2}; use crate::Tool; @@ -42,11 +41,15 @@ pub fn create(id: &str) -> Option> { }) } -/// Apply a point map to every anchor and handle of `n` and its descendants. Gradient vectors are -/// mapped too; objects without editable points (text, images, symbols, meshes, live objects) get -/// the map's affine approximation at their centre. Live shapes become plain paths. +/// Apply a point map to every anchor and handle of `n` and its descendants. Gradients (pinned +/// first if unplaced) follow the map's affine approximation at their centre; objects without +/// editable points (text, images, symbols, meshes, live objects) get the approximation at their +/// centre. Live shapes become plain paths. pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { - warp_paints(&mut n.appearance, f); + let bounds = n.geometric_bounds(); + // Finite-difference step: 5% of the object (at least half a point). + let eps = bounds.map_or(0.5, |b| (b.width().max(b.height()) * 0.05).max(0.5)); + n.pin_gradients(); match &mut n.kind { NodeKind::Path { path, live, .. } => { *live = None; @@ -67,27 +70,13 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { } } _ => { - if let Some(b) = n.geometric_bounds() { - let a = affine_near(f, b.center(), (b.width().max(b.height()) * 0.05).max(0.5)); - n.transform(a, false); + if let Some(b) = bounds { + n.transform(affine_near(f, b.center(), eps), false); } + return; } } -} - -fn warp_paints(ap: &mut Appearance, f: &dyn Fn(Point) -> Point) { - for it in &mut ap.items { - let p = match it { - AppearanceItem::Fill(fl) => &mut fl.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Gradient(g) = p - && let Some(geom) = &mut g.geom - { - geom.start = f(geom.start); - geom.end = f(geom.end); - } - } + n.appearance.warp_gradients(&|p| affine_near(f, p, eps)); } /// The affine map that best matches `f` near `p` (finite differences with step `eps`). @@ -136,6 +125,8 @@ pub(crate) fn ellipse_path(c: Point, rx: f64, ry: f64, angle_deg: f64) -> vector #[cfg(test)] mod tests { use super::*; + use vectorcraft_color::{Gradient, GradientPaint, Paint}; + use vectorcraft_doc::Appearance; use vectorcraft_geom::{PathData, Rect, shapes}; #[test] @@ -170,6 +161,17 @@ mod tests { let _ = PathData::default(); } + #[test] + fn warp_pins_and_maps_gradients_once() { + let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut n = + Node::path(vectorcraft_doc::NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(grad, Paint::None, 0.0)); + warp_node_with(&mut n, &|p| Point::new(p.x * 2.0, p.y + 1.0)); + let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!() }; + let geom = g.geom.expect("pinned before the warp"); + assert!(geom.start.distance(Point::new(0.0, 6.0)) < 1e-9 && geom.end.distance(Point::new(20.0, 6.0)) < 1e-9, "{geom:?}"); + } + #[test] fn affine_near_recovers_affine_maps() { let a = Affine::new([2.0, 0.5, -0.3, 1.5, 10.0, -4.0]); diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 8c4bca488..9307953e7 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -14,17 +14,14 @@ use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; pub struct EyedropperTool; -/// The colour of `paint` at `p` (gradients are sampled along their vector). +/// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring +/// their aspect ratio). pub fn paint_color_at(paint: &Paint, bounds: Option, p: Point) -> Option { match paint { Paint::Solid { color, .. } => Some(*color), Paint::Gradient(g) => { - let geom = g.resolve(bounds?); - let v = geom.end - geom.start; - let t = match g.gradient.kind { - vectorcraft_color::GradientKind::Radial => (p - geom.start).hypot() / v.hypot().max(1e-9), - _ => (p - geom.start).dot(v) / v.hypot2().max(1e-9), - }; + let geom = g.geom.or_else(|| bounds.map(|b| g.resolve(b)))?; + let t = geom.param_at(g.gradient.kind, p); Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0) } _ => None, @@ -98,4 +95,17 @@ mod tests { assert_eq!(paint_color_at(&paint, Some(r), Point::new(0.0, 5.0)).unwrap().to_hex(), "#ffffff"); assert_eq!(paint_color_at(&paint, Some(r), Point::new(100.0, 5.0)).unwrap().to_hex(), "#000000"); } + + #[test] + fn radial_sample_honours_aspect() { + let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient { + kind: vectorcraft_color::GradientKind::Radial, + ..Default::default() + }); + // Radius 100 along x, 50 across: 50 pt above the centre is already the outer (black) edge. + g.geom = Some(vectorcraft_color::GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 0.5 }); + let paint = Paint::Gradient(Box::new(g)); + assert_eq!(paint_color_at(&paint, None, Point::new(0.0, -50.0)).unwrap().to_hex(), "#000000"); + assert_eq!(paint_color_at(&paint, None, Point::new(50.0, 0.0)).unwrap().to_hex(), "#808080"); + } }