From d8eff17b9f28c062a603cb07ebfcbd9452c61e30 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 04:27:01 +0700 Subject: [PATCH] M3.98: radial annotator extent ellipse, aspect handle and off-centre focal point Radial gradients show a dashed extent ellipse: its handle across the bar sets the aspect ratio, the ellipse elsewhere rotates it, and the dot inside the centre ring drags the focal point (GradientGeom.focal) for an off-centre radial. paint.setGradientGeom takes aspect (%) and focal ([x,y] | null), and start/end may be left out; the focal point keeps its place in the ellipse as the vector, aspect and object transform. It renders as a two-point radial, exports and imports as SVG fx/fy and PDF two-point radial shadings, expands to off-centre rings, and travels in gradient params and the native format. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 162 ++++++++++++++++++++++++--- crates/doc/src/appearance.rs | 3 +- crates/doc/src/live.rs | 1 + crates/doc/src/style_libs.rs | 2 +- crates/engine/src/cmd/create.rs | 2 +- crates/engine/src/cmd/expand.rs | 16 ++- crates/engine/src/cmd/gradient.rs | 76 +++++++++---- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_focal.rs | 120 ++++++++++++++++++++ crates/engine/src/tests_gradient.rs | 2 +- crates/pdf/src/export.rs | 13 +-- crates/pdf/src/import.rs | 15 +-- crates/pdf/src/lib.rs | 2 + crates/pdf/src/tests_focal.rs | 57 ++++++++++ crates/render/src/paint.rs | 21 ++-- crates/render/tests/radial_focal.rs | 56 +++++++++ crates/svg/src/export.rs | 10 +- crates/svg/src/import.rs | 16 ++- crates/svg/tests/focal.rs | 69 ++++++++++++ crates/svg/tests/roundtrip.rs | 4 +- crates/tools/src/params.rs | 9 +- crates/tools/src/xform/eyedropper.rs | 4 +- crates/tools/src/xform/gradient.rs | 130 ++++++++++++++++++--- crates/ui-egui/src/canvas.rs | 2 +- docs/mcp.md | 11 ++ 26 files changed, 701 insertions(+), 106 deletions(-) create mode 100644 crates/engine/src/tests_focal.rs create mode 100644 crates/pdf/src/tests_focal.rs create mode 100644 crates/render/tests/radial_focal.rs create mode 100644 crates/svg/tests/focal.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 8e2ee33b..46537300 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -236,8 +236,15 @@ pub struct GradientGeom { /// Radial aspect ratio (height / width), 1 = circle. #[serde(default = "one64")] pub aspect: f64, + /// A radial gradient's focal point, where its first stop sits (an off-centre radial), in the + /// same space; inside the extent ellipse. None: the centre (`start`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focal: Option, } +/// How far out a focal point may sit, as a fraction of the extent ellipse. +const FOCAL_MAX: f64 = 0.99; + fn one64() -> f64 { 1.0 } @@ -250,11 +257,11 @@ impl GradientGeom { let a = angle_deg.to_radians(); let d = Vec2::new(a.cos(), -a.sin()); match kind { - GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0 }, + GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None }, _ => { // 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 } + Self { start: c - d * half, end: c + d * half, aspect: 1.0, focal: None } } } } @@ -266,6 +273,8 @@ impl GradientGeom { /// (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) { + // The focal point is a point like any other. + self.focal = self.focal.map(|f| a * f); 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); @@ -312,21 +321,76 @@ impl GradientGeom { pub fn rebase(&mut self, from: Rect, to: Rect) { self.start = rebase_point(self.start, from, to); self.end = rebase_point(self.end, from, to); + self.focal = self.focal.map(|f| rebase_point(f, from, to)); } /// 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. + /// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial, + /// measured from the focal point (each level is the ellipse scaled about the line from the + /// focal point to the centre). pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { let u = self.end - self.start; - let l2 = u.hypot2(); - if l2 < 1e-18 { + if u.hypot2() < 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, + if kind != GradientKind::Radial { + return (p - self.start).dot(u) / u.hypot2(); + } + let Some(to_unit) = self.unit_frame().map(|m| m.inverse()) else { return 0.0 }; + let q = (to_unit * p).to_vec2(); + let Some(f) = self.focal.map(|f| (to_unit * f).to_vec2()) else { return q.hypot() }; + // The level t through q: |q - f(1 - t)| = t, the root at t >= 0 (|f| < 1). + let d = q - f; + let a = f.hypot2() - 1.0; + if a.abs() < 1e-12 { + return q.hypot(); + } + let b = d.dot(f); + (-b - (b * b - a * d.hypot2()).max(0.0).sqrt()) / a + } + + /// The map from the unit circle onto a radial's extent ellipse: (1, 0) to the end, (0, 1) to + /// `aspect` × the radius across the vector. None when the ellipse is flat (no inverse). + pub fn unit_frame(&self) -> Option { + let u = self.end - self.start; + (u.hypot2() * self.aspect.abs() > 1e-18).then(|| Affine::new([u.x, u.y, -u.y * self.aspect, u.x * self.aspect, self.start.x, self.start.y])) + } + + /// The map that squashes a radial gradient's circle (centre `start`, radius the vector's + /// length) into its extent ellipse: by `aspect` across the vector, about the centre. + pub fn radial_squash(&self) -> Affine { + let angle = (self.end - self.start).atan2(); + let c = self.start.to_vec2(); + Affine::translate(c) + * Affine::rotate(angle) + * Affine::scale_non_uniform(1.0, self.aspect.max(1e-3)) + * Affine::rotate(-angle) + * Affine::translate(-c) + } + + /// Where a radial gradient's first stop sits: its focal point, else its centre. + pub fn focal_point(&self) -> Point { + self.focal.unwrap_or(self.start) + } + + /// Set the focal point, pulled inside the extent ellipse; one on the centre (or a gradient + /// with no extent) has none. + pub fn set_focal(&mut self, f: Option) { + self.focal = f.and_then(|f| { + let m = self.unit_frame()?; + let q = (m.inverse() * f).to_vec2(); + let r = q.hypot(); + (r > 1e-6).then(|| if r > FOCAL_MAX { m * (q * (FOCAL_MAX / r)).to_point() } else { f }) + }); + } + + /// After a change of the vector or aspect from `before`, put the focal point back where it + /// was relative to the extent ellipse. + pub fn keep_focal_from(&mut self, before: &GradientGeom) { + let rel = before.focal.zip(before.unit_frame()).map(|(f, m)| m.inverse() * f); + self.focal = None; + if let (Some(q), Some(m)) = (rel, self.unit_frame()) { + self.set_focal(Some(m * q)); } } pub fn angle_deg(&self) -> f64 { @@ -481,7 +545,7 @@ mod tests { } fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom { - GradientGeom { start: start.into(), end: end.into(), aspect: 1.0 } + GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None } } #[test] @@ -503,12 +567,12 @@ mod tests { #[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 }; + let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None }; 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 }; + let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None }; 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:?}"); } @@ -517,7 +581,7 @@ mod tests { 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 g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None }; 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)] { @@ -532,7 +596,7 @@ mod tests { 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 }; + let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None }; 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); @@ -611,11 +675,11 @@ mod tests { #[test] fn rebase_keeps_relative_positions() { - let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5 }; + let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None }; g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0)); assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}"); // A box without height (a horizontal line) keeps the offset from its centre line. - let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0 }; + let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None }; h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0)); assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}"); // Unplaced paints have nothing to move. @@ -634,4 +698,68 @@ mod tests { p.pin(Rect::new(0.0, 0.0, 1.0, 1.0)); assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); } + + /// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre. + fn off_centre() -> GradientGeom { + let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None }; + g.set_focal(Some(Point::new(160.0, 200.0))); + g + } + + #[test] + fn focal_round_trips_through_serde_and_defaults_to_none() { + let g = off_centre(); + let s = serde_json::to_string(&g).unwrap(); + assert!(s.contains("\"focal\""), "{s}"); + assert_eq!(serde_json::from_str::(&s).unwrap(), g); + // Placements saved before focal points existed load centred, and centred ones write none. + let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap(); + assert_eq!((old.focal, old.aspect), (None, 1.0)); + assert!(!serde_json::to_string(&old).unwrap().contains("focal")); + } + + #[test] + fn focal_follows_transforms_and_rebases() { + let mut g = off_centre(); + let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5); + let f = a * g.focal.unwrap(); + g.transform(a, GradientKind::Radial); + assert!(close(g.focal.unwrap(), f), "{g:?}"); + // Still at the same parameter: the first stop. + assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9); + let mut h = off_centre(); + h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}"); + } + + #[test] + fn param_runs_from_the_focal_point_to_the_ellipse() { + let g = off_centre(); + let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y)); + assert!(t(160.0, 200.0).abs() < 1e-9); + // The extent circle is the last stop all round. + for a in [0.0f64, 1.0, 2.5, 4.0] { + let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin()); + assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}"); + } + // The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point + // (0.4 of the radius off), with a radius of 2/7. + assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0)); + assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier"); + assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5); + } + + #[test] + fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() { + let mut g = off_centre(); + g.set_focal(Some(Point::new(500.0, 200.0))); + assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}"); + g.set_focal(Some(Point::new(200.0, 200.0))); + assert_eq!(g.focal, None, "on the centre: centred"); + // A new vector and aspect keep it in its place in the ellipse. + let before = off_centre(); + let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None }; + h.keep_focal_from(&before); + assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}"); + } } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 386f5604..5b8ae41e 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -779,6 +779,7 @@ impl Appearance { && ga.start.distance(go.start) <= 1e-6 && ga.end.distance(go.end) <= 1e-6 && (ga.aspect - go.aspect).abs() <= 1e-6 + && ga.focal_point().distance(go.focal_point()) <= 1e-6 { *ga = *go; } @@ -971,7 +972,7 @@ mod tests { use vectorcraft_geom::{Point, Rect}; let placed = |x: f64| { let mut g = GradientPaint::new(Gradient::default()); - g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0 }); + g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None }); Paint::Gradient(Box::new(g)) }; let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index d3c82c40..92d7ee01 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -196,6 +196,7 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option Paint { /// A radial gradient placed in the unit box (each object gets it at the same place relative to its /// bounds): centred at `(x, y)` with `radius`. fn radial_at(x: f64, y: f64, radius: f64, stops: &[(f32, &str, f32)]) -> Paint { - let geom = GradientGeom { start: Point::new(x, y), end: Point::new(x + radius, y), aspect: 1.0 }; + let geom = GradientGeom { start: Point::new(x, y), end: Point::new(x + radius, y), aspect: 1.0, focal: None }; Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(gradient(GradientKind::Radial, stops)) })) } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index d58a7698..7c0e3f01 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -229,7 +229,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) }; let radial = |centre: Point, radius: f64, stops: Vec| { let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops }); - g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0 }); + g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None }); Paint::Gradient(Box::new(g)) }; let circle = |centre: Point, radius: f64| shapes::ellipse(Rect::from_center_size(centre, (radius * 2.0, radius * 2.0))); diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index f6d0622e..8899a618 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -314,10 +314,11 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32 /// A linear or radial gradient's geometry over an object's box: its start, the vector to its /// end, the vector across it (radial: the ellipse's other axis), and the parameter ranges that /// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial: -/// `0..t1`). +/// `0..t1`, each level's ellipse centred on the way from the focal point to the start). struct Frame { radial: bool, start: Point, + focal: Point, along: Vec2, across: Vec2, t0: f64, @@ -344,13 +345,17 @@ impl Frame { } // Keep a sliver of span so the art maps back invertibly. t1 = t1.max(t0 + 1e-6); - Self { radial, start: geom.start, along, across, t0, t1, s0, s1 } + Self { radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 } } /// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t` /// and angle `s` (radians). fn at(&self, t: f64, s: f64) -> Point { - if self.radial { self.start + (self.along * s.cos() + self.across * s.sin()) * t } else { self.start + self.along * t + self.across * s } + if self.radial { + self.focal + (self.start - self.focal + self.along * s.cos() + self.across * s.sin()) * t + } else { + self.start + self.along * t + self.across * s + } } /// A gradient mesh covering the box: columns (linear) or rings (radial) where the colour @@ -371,7 +376,7 @@ impl Frame { /// `n` solid shapes covering the box in paint order, each coloured as the gradient in the /// middle of its band: for a linear gradient, rectangles from each band's start to the far - /// end, for a radial one, concentric ellipses (largest first). Each paints over the ones + /// end, for a radial one, nested ellipses (largest first). Each paints over the ones /// below except its band, so no seam shows between bands. With translucent stops each shape /// is just its band, so nothing doubles up. fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { @@ -380,7 +385,8 @@ impl Frame { let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); if self.radial { let ellipse = |t: f64| { - let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, self.start.x, self.start.y]); + let c = self.focal + (self.start - self.focal) * t; + let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, c.x, c.y]); shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m) }; return (0..n) diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 6a95c02a..3f584634 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start: [x,y], end: [x,y] (document coordinates), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind)} set the gradient vector (solid paints become the default gradient; type objects set it on their runs, in text space; the aspect ratio is kept)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial: the extent ellipse's height / width; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", has_doc, set_gradient_geom ), @@ -181,16 +181,12 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { let mut gp = GradientPaint::new(Gradient { kind, stops }); gp.angle = f64_or(g, "angle", 0.0); gp.swatch = str_param(g, "swatch").map(str::to_string); - let aspect = aspect_param(g)?; - let point = |k: &str| g.get(k).map(|_| point_param(g, k).ok_or_else(|| format!("`{k}` must be [x, y]"))).transpose(); - match (point("start")?, point("end")?) { - (Some(start), Some(end)) => { - let geom = GradientGeom { start, end, aspect: aspect.unwrap_or(1.0) }; - gp.angle = geom.angle_deg(); - gp.geom = Some(geom); - } - (None, None) => {} - _ => return Err("give both `start` and `end` (or neither)".into()), + let geom = GeomEdit::parse(g)?; + if let Some((start, end)) = geom.vector { + let mut g = GradientGeom { start, end, aspect: geom.aspect.unwrap_or(1.0), focal: None }; + g.set_focal(geom.focal.flatten()); + gp.angle = g.angle_deg(); + gp.geom = Some(g); } if let Some(f) = g.get("freeform") { gp.freeform = Some(super::freeform::parse_freeform(f)?); @@ -282,6 +278,8 @@ pub(crate) fn apply_gradient_edit_in( gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 }; } } + // Angle and aspect edits keep the focal point in its place in the extent ellipse. + let before = gp.geom; if let Some(a) = p.get("angle").and_then(Value::as_f64) { let a = ((a + 180.0).rem_euclid(360.0)) - 180.0; gp.angle = a; @@ -308,6 +306,9 @@ pub(crate) fn apply_gradient_edit_in( g.aspect = asp; } } + if let (Some(b), Some(g)) = (before, &mut gp.geom) { + g.keep_focal_from(&b); + } if gp.gradient.kind == GradientKind::Freeform { // Points already shown (automatic ones fitted to the box) stay put; a fresh freeform // gradient gets its points inside the shape. @@ -428,10 +429,35 @@ fn invert(a: Affine) -> Option { (a.determinant().abs() > 1e-12).then(|| a.inverse()) } -/// `cur` as a gradient whose vector runs from `start` to `end` in the document. `to_doc` maps the -/// paint's space (text space for type runs) to the document and `bounds` (in that space) fits an -/// unplaced gradient, so the aspect ratio the document shows carries over. -fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: Option) -> Option { +/// What `paint.setGradientGeom` sets, in document coordinates (None: kept). +struct GeomEdit { + vector: Option<(Point, Point)>, + aspect: Option, + /// Some(None) centres the focal point. + focal: Option>, +} + +impl GeomEdit { + fn parse(p: &Value) -> std::result::Result { + let point = |k: &str| p.get(k).map(|_| point_param(p, k).ok_or_else(|| format!("`{k}` must be [x, y]"))).transpose(); + let vector = match (point("start")?, point("end")?) { + (Some(s), Some(e)) => Some((s, e)), + (None, None) => None, + _ => return Err("give both `start` and `end` [x, y] (or neither)".into()), + }; + let focal = match p.get("focal") { + None => None, + Some(Value::Null) => Some(None), + Some(_) => Some(point("focal")?), + }; + Ok(Self { vector, aspect: aspect_param(p)?, focal }) + } +} + +/// `cur` with the vector, aspect and focal point of `e` (given in the document). `to_doc` maps +/// the paint's space (text space for type runs) to the document and `bounds` (in that space) fits +/// an unplaced gradient, so what the document shows carries over where `e` keeps it. +fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option) -> Option { let from_doc = invert(to_doc)?; let mut gp = match cur { Paint::Gradient(g) => (**g).clone(), @@ -444,8 +470,17 @@ fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: O } g }; - let aspect = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map_or(1.0, |g| map(g, to_doc).aspect); - let geom = map(GradientGeom { start, end, aspect }, from_doc); + let shown = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map(|g| map(g, to_doc)); + let (start, end) = e.vector.or_else(|| shown.map(|g| (g.start, g.end)))?; + let aspect = e.aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0); + let mut geom = GradientGeom { start, end, aspect, focal: None }; + match (e.focal, shown) { + _ if kind != GradientKind::Radial => {} + (Some(f), _) => geom.set_focal(f), + (None, Some(b)) => geom.keep_focal_from(&b), + (None, None) => {} + } + let geom = map(geom, from_doc); gp.angle = geom.angle_deg(); gp.geom = Some(geom); Some(Paint::Gradient(Box::new(gp))) @@ -453,8 +488,7 @@ fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: O fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.setGradientGeom"; - let start = point_param(p, "start").ok_or_else(|| bad(C, "missing start [x,y]"))?; - let end = point_param(p, "end").ok_or_else(|| bad(C, "missing end [x,y]"))?; + let edit = GeomEdit::parse(p).map_err(|e| bad(C, e))?; // `index` is an alias of `item`. let mut p = p.clone(); if let (None, Some(i)) = (p.get("item"), p.get("index").cloned()) { @@ -481,14 +515,14 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { run_stroke_weight(&mut r.style); } let (paint, b) = run_paint_mut(r, stroke, lb); - if let Some(np) = vector_paint(paint, start, end, xf, Some(b)) { + if let Some(np) = vector_paint(paint, &edit, xf, Some(b)) { *paint = np; } } return Ok(()); } let b = item_paint_bounds(n, index, !stroke); - if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), start, end, Affine::IDENTITY, b) { + if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), &edit, Affine::IDENTITY, b) { n.appearance.set_paint_at(index, !stroke, np); } Ok(()) diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index cc31449e..747d849d 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c061eccc..4177870b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -875,6 +875,8 @@ mod tests_flatpreview; #[cfg(test)] mod tests_flatten; #[cfg(test)] +mod tests_focal; +#[cfg(test)] mod tests_freeform; #[cfg(test)] mod tests_gradient; diff --git a/crates/engine/src/tests_focal.rs b/crates/engine/src/tests_focal.rs new file mode 100644 index 00000000..63d7f52d --- /dev/null +++ b/crates/engine/src/tests_focal.rs @@ -0,0 +1,120 @@ +//! Radial gradients' extent and focal point (M3.98): `paint.setGradientGeom {aspect, focal}`, +//! focal points through transforms, params and the native format, and the radial annotator. + +use serde_json::json; +use vectorcraft_color::{GradientGeom, Paint}; +use vectorcraft_geom::Point; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +/// A 200 pt square at (100, 100) with a radial fill centred on it, radius 100. +fn radial() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "start": [200, 200], "end": [300, 200]}})).unwrap(); + (s, id) +} + +fn geom(s: &Session, id: NodeId) -> GradientGeom { + match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() { + Paint::Gradient(g) => g.geom.expect("placed"), + p => panic!("not a gradient: {p:?}"), + } +} + +fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-6 +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn set_gradient_geom_sets_aspect_and_focal_in_one_step() { + let (mut s, id) = radial(); + let n = undo_len(&s); + s.execute("paint.setGradientGeom", &json!({"focal": [160, 210], "aspect": 50})).unwrap(); + let g = geom(&s, id); + assert_eq!((g.start, g.end, g.aspect, g.focal), (Point::new(200.0, 200.0), Point::new(300.0, 200.0), 0.5, Some(Point::new(160.0, 210.0)))); + assert_eq!(undo_len(&s), n + 1); + // Out of the extent ellipse it is pulled in; null centres it. + s.execute("paint.setGradientGeom", &json!({"focal": [200, 400]})).unwrap(); + assert!(close(geom(&s, id).focal.unwrap(), Point::new(200.0, 249.5)), "{:?}", geom(&s, id)); + s.execute("paint.setGradientGeom", &json!({"focal": null})).unwrap(); + assert_eq!(geom(&s, id).focal, None); + // One end without the other is an error; linear gradients have no focal point. + assert!(s.execute("paint.setGradientGeom", &json!({"end": [0, 0]})).is_err()); + s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"start": [100, 200], "end": [300, 200], "focal": [150, 200]})).unwrap(); + assert_eq!(geom(&s, id).focal, None); +} + +#[test] +fn the_focal_point_keeps_its_place_as_the_gradient_moves_and_reshapes() { + let (mut s, id) = radial(); + s.execute("paint.setGradientGeom", &json!({"focal": [160, 210]})).unwrap(); + // A new vector: moved 10 right and turned to point down. + s.execute("paint.setGradientGeom", &json!({"start": [210, 200], "end": [210, 300]})).unwrap(); + assert!(close(geom(&s, id).focal.unwrap(), Point::new(200.0, 160.0)), "{:?}", geom(&s, id)); + // The aspect ratio (Gradient panel) squashes it with the ellipse. + s.execute("paint.editGradient", &json!({"aspect": 50})).unwrap(); + assert!(close(geom(&s, id).focal.unwrap(), Point::new(205.0, 160.0)), "{:?}", geom(&s, id)); + // Transforming the object maps it. + s.execute("object.move", &json!({"dx": 5, "dy": 7})).unwrap(); + assert!(close(geom(&s, id).focal.unwrap(), Point::new(210.0, 167.0)), "{:?}", geom(&s, id)); + s.execute("object.transform", &json!({"matrix": [0, 1, -1, 0, 0, 0]})).unwrap(); + assert!(close(geom(&s, id).focal.unwrap(), Point::new(-167.0, 210.0)), "{:?}", geom(&s, id)); +} + +#[test] +fn the_focal_point_round_trips_through_params_and_the_native_format() { + let (mut s, id) = radial(); + s.execute("paint.setGradientGeom", &json!({"focal": [170, 190], "aspect": 80})).unwrap(); + let want = geom(&s, id); + // Gradient params (what swatches and agents read) carry it, and give it back. + let Paint::Gradient(g) = s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() else { unreachable!() }; + let params = vectorcraft_tools::params::gradient_params(&g); + assert_eq!(params["focal"], json!([170.0, 190.0])); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!(geom(&s, id), want); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(g) = back.node(id).unwrap().appearance.fill_paint() else { panic!("gradient lost") }; + assert_eq!(g.geom, Some(want)); +} + +#[test] +fn the_radial_annotator_drags_the_focal_dot_the_aspect_handle_and_the_ring() { + let (mut s, id) = radial(); + let v = ViewInfo::default(); + s.select_tool("gradient", v).unwrap(); + let drag = |s: &mut Session, pts: &[(f64, f64)]| { + let n = undo_len(s); + for (i, (x, y)) in pts.iter().enumerate() { + let kind = [PointerKind::Down, PointerKind::Drag][i.min(1)]; + s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(Mods::default()), v).unwrap(); + } + let (x, y) = pts[pts.len() - 1]; + s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap(); + assert_eq!(undo_len(s), n + 1, "one undo step"); + }; + // The focal dot sits on the centre: dragging it moves only the focal point. + drag(&mut s, &[(200.0, 200.0), (180.0, 195.0), (170.0, 190.0)]); + let g = geom(&s, id); + assert_eq!((g.start, g.focal), (Point::new(200.0, 200.0), Some(Point::new(170.0, 190.0)))); + // Back onto the centre, it centres. + drag(&mut s, &[(170.0, 190.0), (190.0, 195.0), (200.5, 200.5)]); + assert_eq!(geom(&s, id).focal, None); + // The handle across the bar (100 pt above the centre) sets the aspect ratio. + drag(&mut s, &[(200.0, 100.0), (200.0, 120.0), (200.0, 150.0)]); + assert!((geom(&s, id).aspect - 0.5).abs() < 1e-9, "{:?}", geom(&s, id)); + // The ellipse elsewhere rotates it, keeping its radius: from its bottom round to its left + // turns the vector a quarter clockwise, to point down. + drag(&mut s, &[(200.0, 250.0), (170.0, 240.0), (150.0, 200.0)]); + let g = geom(&s, id); + assert!(close(g.end, Point::new(200.0, 300.0)) && (g.aspect - 0.5).abs() < 1e-9, "{g:?}"); +} diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index ce660736..6db651f4 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -41,7 +41,7 @@ fn three_stops() -> GradientPaint { stop(1.0, Color::gray(0.25), 0.75, 0.5), ], }); - g.geom = Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(110.0, 70.0), aspect: 0.5 }); + g.geom = Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(110.0, 70.0), aspect: 0.5, focal: None }); g.angle = g.geom.unwrap().angle_deg(); g } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index c60b4f39..c3a4e4e8 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -458,17 +458,14 @@ impl Exporter<'_> { } GradientKind::Radial => { let r = geom.length().max(1e-6) as f32; - let angle = (geom.end - geom.start).atan2(); - let t = Affine::translate(geom.start.to_vec2()) - * Affine::rotate(angle) - * Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3)) - * Affine::rotate(-angle) - * Affine::translate(-geom.start.to_vec2()); + let t = geom.radial_squash(); let (cx, cy) = (geom.start.x as f32, geom.start.y as f32); + // An off-centre focal point: a two-point radial shading from it. + let f = t.inverse() * geom.focal_point(); Some( RadialGradient { - fx: cx, - fy: cy, + fx: f.x as f32, + fy: f.y as f32, fr: 0.0, cx, cy, diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index c962e7a6..acc53250 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -401,16 +401,17 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option>()) } else { let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6)); - let centre = c(coords[3], coords[4]); - let k = m.as_coeffs(); - let ex = Vec2::new(k[0], k[1]) * r1 as f64; - let ey = Vec2::new(k[2], k[3]) * r1 as f64; - let aspect = if ex.hypot() > 1e-9 { ey.hypot() / ex.hypot() } else { 1.0 }; - let geom = GradientGeom { start: centre, end: centre + ex, aspect }; + // The end circle, mapped (as an ellipse) into the page; a start circle off its centre + // gives the focal point. + let p = |x: f32, y: f32| Point::new(x as f64, y as f64); + let centre = p(coords[3], coords[4]); + let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None }; + geom.set_focal(Some(p(coords[0], coords[1]))); + geom.transform(m, GradientKind::Radial); // Offsets are relative to the outer radius; an inner radius shifts them outwards. let stops = keep.iter().map(|&i| stop_at(pts[i].0, (r0 + (r1 - r0) * pts[i].0) / r1)).collect(); (GradientKind::Radial, geom, stops) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 34e8a4c4..bcdae790 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -117,6 +117,8 @@ mod tests_cmykblend; #[cfg(test)] mod tests_dashalign; #[cfg(test)] +mod tests_focal; +#[cfg(test)] mod tests_fx; #[cfg(test)] mod tests_stroke; diff --git a/crates/pdf/src/tests_focal.rs b/crates/pdf/src/tests_focal.rs new file mode 100644 index 00000000..09754508 --- /dev/null +++ b/crates/pdf/src/tests_focal.rs @@ -0,0 +1,57 @@ +//! Radial gradients' focal points as two-point radial shadings, out and back in. + +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{Appearance, Document, Node}; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_testkit::raster::{assert_similar, render_artboard}; + +use crate::*; + +/// A page-sized square filled with a radial on `start`→`end` at `aspect`, focal point `focal`. +fn doc(start: Point, end: Point, aspect: f64, focal: Point) -> Document { + let mut geom = GradientGeom { start, end, aspect, focal: None }; + geom.set_focal(Some(focal)); + let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() }); + gp.geom = Some(geom); + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), ap)).unwrap(); + d +} + +fn geom(d: &Document) -> GradientGeom { + let mut out = None; + d.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + out = g.geom; + } + }); + out.expect("a gradient fill") +} + +#[test] +fn a_focal_point_round_trips_as_a_two_point_radial_shading() { + for (end, aspect) in [(Point::new(180.0, 100.0), 1.0), (Point::new(100.0, 40.0), 0.5)] { + let d = doc(Point::new(100.0, 100.0), end, aspect, Point::new(80.0, 110.0)); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let g = geom(&back); + let f = g.focal.expect("focal kept"); + assert!(f.distance(Point::new(80.0, 110.0)) < 0.05 && g.start.distance(Point::new(100.0, 100.0)) < 0.05, "{g:?}"); + // The same ellipse (its axes may come back swapped, with the aspect inverted). + let want = geom(&d); + for p in [Point::new(30.0, 100.0), Point::new(100.0, 170.0), Point::new(140.0, 60.0), Point::new(90.0, 125.0)] { + let (a, b) = (g.param_at(GradientKind::Radial, p), want.param_at(GradientKind::Radial, p)); + assert!((a - b).abs() < 1e-3, "{p:?}: {a} vs {b}: {g:?}"); + } + // And it looks the same. + assert_similar(&render_artboard(&back), &render_artboard(&d), 3.0, 0.01); + } +} + +#[test] +fn a_centred_radial_imports_without_a_focal_point() { + let d = doc(Point::new(100.0, 100.0), Point::new(180.0, 100.0), 1.0, Point::new(100.0, 100.0)); + assert_eq!(geom(&import(&export(&d, &PdfOptions::default()).unwrap()).unwrap()).focal, None); +} diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index cff53dc6..e99adfae 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -1,7 +1,7 @@ //! Paint conversion (solid, gradients, patterns) and image decoding. use vectorcraft_color::{GradientKind, Paint}; -use vectorcraft_geom::{Affine, Rect, Vec2}; +use vectorcraft_geom::{Rect, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko::{self, ColorStop}; @@ -26,16 +26,15 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr let grad = match g.gradient.kind { GradientKind::Radial => { let r = geom.length().max(1e-6) as f32; - let angle = (geom.end - geom.start).atan2(); - // Aspect ratio squashes the circle along the perpendicular of the gradient vector. - ctx.set_paint_transform( - Affine::translate(geom.start.to_vec2()) - * Affine::rotate(angle) - * Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3)) - * Affine::rotate(-angle) - * Affine::translate(-geom.start.to_vec2()), - ); - peniko::Gradient::new_radial(geom.start, r).with_stops(stops.as_slice()) + // The aspect ratio squashes the circle across the gradient vector. + let squash = geom.radial_squash(); + ctx.set_paint_transform(squash); + let g = match geom.focal { + // An off-centre focal point: a two-point radial from it to the extent circle. + Some(f) => peniko::Gradient::new_two_point_radial(squash.inverse() * f, 0.0, geom.start, r), + None => peniko::Gradient::new_radial(geom.start, r), + }; + g.with_stops(stops.as_slice()) } _ => { ctx.reset_paint_transform(); diff --git a/crates/render/tests/radial_focal.rs b/crates/render/tests/radial_focal.rs new file mode 100644 index 00000000..a609d088 --- /dev/null +++ b/crates/render/tests/radial_focal.rs @@ -0,0 +1,56 @@ +//! Off-centre (focal) radial gradients: the pixels follow the model's gradient parameter, with +//! and without an aspect ratio, and differ from the centred gradient. + +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{Appearance, Document}; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_testkit::raster::{Image, render_view}; + +/// A 100 pt square filled with a white-to-black radial on `geom`. +fn radial(geom: GradientGeom) -> Document { + let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() }); + gp.geom = Some(geom); + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0), |_| {}); + b.build() +} + +/// A circle of radius 40 at (50, 50) with its focal point at `focal`. +fn off_centre(focal: Option) -> GradientGeom { + let mut g = GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(90.0, 50.0), aspect: 1.0, focal: None }; + g.set_focal(focal); + g +} + +/// Every 7th pixel's red matches white→black at the model's parameter there (pixel centres). +fn assert_follows_model(img: &Image, geom: &GradientGeom) { + for y in (3..100).step_by(7) { + for x in (3..100).step_by(7) { + let t = geom.param_at(GradientKind::Radial, Point::new(x as f64 + 0.5, y as f64 + 0.5)).clamp(0.0, 1.0); + let want = ((1.0 - t) * 255.0).round() as u8; + let got = img.over_white(x, y)[0]; + assert!(got.abs_diff(want) <= 6, "({x},{y}) t={t:.3}: got {got}, want {want}"); + } + } +} + +#[test] +fn an_off_centre_focal_point_renders_as_its_model() { + let geom = off_centre(Some(Point::new(30.0, 40.0))); + let img = render_view(&radial(geom), 100, 100); + assert_follows_model(&img, &geom); + // White at the focal point, and the light side faces it. + assert!(img.over_white(30, 40)[0] > 245, "{:?}", img.over_white(30, 40)); + let centred = render_view(&radial(off_centre(None)), 100, 100); + assert!(img.over_white(35, 50)[0] > centred.over_white(35, 50)[0] + 20); + assert!(img.over_white(65, 50)[0] + 20 < centred.over_white(65, 50)[0]); +} + +#[test] +fn a_focal_point_on_a_squashed_rotated_ellipse_renders_as_its_model() { + let mut geom = GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(80.0, 75.0), aspect: 0.5, focal: None }; + geom.set_focal(Some(Point::new(60.0, 50.0))); + assert!(geom.focal.is_some()); + assert_follows_model(&render_view(&radial(geom), 100, 100), &geom); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index c9ccabab..8c7e2e6f 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -377,14 +377,17 @@ impl Writer<'_> { GradientKind::Radial => { let id = self.fresh_id("radial-gradient"); let r = (e - s).hypot(); + // An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space. + let focal = |f: Point| format!(" fx=\"{}\" fy=\"{}\"", self.num(f.x), self.num(f.y)); if (geom.aspect - 1.0).abs() < 1e-9 { ( id.clone(), format!( - "", + "", self.num(s.x), self.num(s.y), - self.num(r) + self.num(r), + geom.focal.map(focal).unwrap_or_default() ), "radialGradient", ) @@ -394,8 +397,9 @@ impl Writer<'_> { ( id.clone(), format!( - "", + "", self.num(r), + geom.focal.map(|f| focal(m.inverse() * f)).unwrap_or_default(), self.matrix(m) ), "radialGradient", diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 627701d8..1e146722 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -406,8 +406,12 @@ impl Importer { if lg.spread_method() != usvg::SpreadMethod::Pad { self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id())); } - 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 }; + 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, + focal: None, + }; geom.transform(m * aff(lg.transform()), GradientKind::Linear); gp(GradientKind::Linear, stops(lg), geom) } @@ -415,11 +419,13 @@ impl Importer { if rg.spread_method() != usvg::SpreadMethod::Pad { self.warn(format!("gradient '{}': spreadMethod approximated as pad", rg.id())); } - if (rg.fx() - rg.cx()).abs() > 1e-4 || (rg.fy() - rg.cy()).abs() > 1e-4 { - self.warn(format!("gradient '{}': focal point ignored", rg.id())); + if rg.fr().get() > 1e-4 { + self.warn(format!("gradient '{}': focal radius ignored", rg.id())); } let c = Point::new(rg.cx() as f64, rg.cy() as f64); - let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0 }; + let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0, focal: None }; + // A focal point outside the circle is pulled inside it. + geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64))); geom.transform(m * aff(rg.transform()), GradientKind::Radial); gp(GradientKind::Radial, stops(rg), geom) } diff --git a/crates/svg/tests/focal.rs b/crates/svg/tests/focal.rs new file mode 100644 index 00000000..4d76faf4 --- /dev/null +++ b/crates/svg/tests/focal.rs @@ -0,0 +1,69 @@ +//! Radial gradients' focal points as `fx`/`fy`, out and back in. + +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{Appearance, Document, Node}; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export, import}; + +/// One square filled with a radial on `start`→`end` at `aspect`, its focal point at `focal`. +fn doc(start: Point, end: Point, aspect: f64, focal: Point) -> Document { + let mut geom = GradientGeom { start, end, aspect, focal: None }; + geom.set_focal(Some(focal)); + let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() }); + gp.geom = Some(geom); + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), ap)).unwrap(); + d +} + +fn geom(d: &Document) -> GradientGeom { + let mut out = None; + d.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + out = g.geom; + } + }); + out.expect("a gradient fill") +} + +fn close(a: Point, b: Point) -> bool { + a.distance(b) < 0.01 +} + +#[test] +fn a_circle_writes_its_focal_point_as_fx_fy_and_reads_it_back() { + let d = doc(Point::new(100.0, 100.0), Point::new(180.0, 100.0), 1.0, Point::new(70.0, 110.0)); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains("cx=\"100\" cy=\"100\" r=\"80\" fx=\"70\" fy=\"110\""), "{svg}"); + let g = geom(&import(&svg).unwrap()); + assert!(close(g.start, Point::new(100.0, 100.0)) && close(g.focal.expect("focal kept"), Point::new(70.0, 110.0)), "{g:?}"); + // A centred radial writes none. + let centred = doc(Point::new(100.0, 100.0), Point::new(180.0, 100.0), 1.0, Point::new(100.0, 100.0)); + let svg = export(¢red, &ExportOptions::default()); + assert!(!svg.contains("fx="), "{svg}"); + assert_eq!(geom(&import(&svg).unwrap()).focal, None); +} + +#[test] +fn an_ellipse_writes_its_focal_point_in_the_gradient_space() { + let d = doc(Point::new(100.0, 100.0), Point::new(100.0, 40.0), 0.5, Point::new(110.0, 120.0)); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains("fx=") && svg.contains("gradientTransform"), "{svg}"); + let g = geom(&import(&svg).unwrap()); + assert!(close(g.focal.expect("focal kept"), Point::new(110.0, 120.0)), "{g:?}"); + assert!((g.aspect - 0.5).abs() < 1e-3, "{g:?}"); +} + +#[test] +fn imported_focal_points_outside_the_circle_are_pulled_in() { + let svg = r##" + + + "##; + let g = geom(&import(svg).unwrap()); + let f = g.focal.expect("focal kept"); + assert!(f.y == 100.0 && f.x > 50.0 && f.x < 51.0, "{g:?}"); +} diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 1a3339d5..907808c0 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -226,7 +226,7 @@ fn linear_gradient_roundtrip() { g.stops[1].opacity = 0.5; let gp = GradientPaint { gradient: g, - geom: Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(90.0, 60.0), aspect: 1.0 }), + geom: Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(90.0, 60.0), aspect: 1.0, focal: None }), angle: 0.0, swatch: None, freeform: None, @@ -258,7 +258,7 @@ fn radial_gradient_and_fit_roundtrip() { // Elliptical radial via gradientTransform. let gp = GradientPaint { gradient: g, - geom: Some(GradientGeom { start: Point::new(150.0, 150.0), end: Point::new(150.0, 180.0), aspect: 0.5 }), + geom: Some(GradientGeom { start: Point::new(150.0, 150.0), end: Point::new(150.0, 180.0), aspect: 0.5, focal: None }), angle: 0.0, swatch: None, freeform: None, diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index 4481aa74..46e920a6 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -47,8 +47,8 @@ pub fn freeform_json(f: &Freeform) -> Value { } /// A gradient paint as the `gradient` param of `paint.setFill` / `swatch.new`: kind, stops (with -/// opacity and midpoint), angle, the linked swatch and, once placed, the vector and aspect (%) -/// and the freeform points. +/// opacity and midpoint), angle, the linked swatch and, once placed, the vector, aspect (%) and +/// focal point, and the freeform points. pub fn gradient_params(g: &GradientPaint) -> Value { let mut v = json!({ "kind": g.gradient.kind.label().to_lowercase(), @@ -59,6 +59,9 @@ pub fn gradient_params(g: &GradientPaint) -> Value { v["start"] = json!([geom.start.x, geom.start.y]); v["end"] = json!([geom.end.x, geom.end.y]); v["aspect"] = json!(geom.aspect * 100.0); + if let Some(f) = geom.focal { + v["focal"] = json!([f.x, f.y]); + } } if let Some(s) = &g.swatch { v["swatch"] = json!(s); @@ -82,7 +85,7 @@ mod tests { assert_eq!(v["kind"], "radial"); assert!(v.get("start").is_none() && v.get("aspect").is_none()); assert_eq!(v["stops"][0]["midpoint"], json!(0.5)); - g.geom = Some(GradientGeom { start: Point::new(1.0, 2.0), end: Point::new(3.0, 4.0), aspect: 0.5 }); + g.geom = Some(GradientGeom { start: Point::new(1.0, 2.0), end: Point::new(3.0, 4.0), aspect: 0.5, focal: None }); g.swatch = Some("Sky".into()); let v = gradient_params(&g); assert_eq!((v["start"].clone(), v["end"].clone(), v["aspect"].clone()), (json!([1.0, 2.0]), json!([3.0, 4.0]), json!(50.0))); diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 217a28a5..71f171b9 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -229,7 +229,7 @@ mod tests { let mut t = TextObject::point(Point::ZERO, "Gradient", CharStyle { size: 20.0, ..Default::default() }); t.xf = vectorcraft_geom::Affine::translate((100.0, 100.0)) * vectorcraft_geom::Affine::rotate(std::f64::consts::FRAC_PI_2); let mut g = GradientPaint::new(Gradient::default()); - g.geom = Some(GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 1.0 }); + g.geom = Some(GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 1.0, focal: None }); t.runs[0].style.fill = Paint::Gradient(Box::new(g)); d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); let (s, p) = (Selection::default(), paint()); @@ -246,7 +246,7 @@ mod tests { ..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 }); + g.geom = Some(vectorcraft_color::GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 0.5, focal: None }); 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"); diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 1ff94247..8d2a5c32 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -14,6 +14,12 @@ //! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or //! Backspace removes it (never below two stops) and the arrow keys nudge it. //! +//! A radial gradient also shows its extent: a dashed ellipse around the centre (the start, a +//! ring). Drag the dot on the ellipse across the bar to change the aspect ratio and the ellipse +//! elsewhere to rotate it; the end handle sets the radius. The dot inside the centre ring is the +//! focal point (where the first stop sits): drag it to make the gradient off-centre, back onto +//! the centre to centre it. +//! //! On a freeform gradient the tool edits its points instead (see `freeform`). use serde_json::{Value, json}; @@ -21,7 +27,7 @@ use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; use vectorcraft_doc::NodeId; use vectorcraft_doc::hit::hit_test; -use vectorcraft_geom::{Affine, Point, Vec2, constrain_angle_from}; +use vectorcraft_geom::{Affine, Point, Shape, Vec2, constrain_angle_from}; use super::freeform::{self, Annotator as FreeformAnnotator}; use super::paint_owner; @@ -44,6 +50,10 @@ const MID_GAP: f64 = 7.0; const MID_HIT: f64 = 5.0; /// The bar (where a click adds a stop) reaches this far above it, and down through the stop row. const BAR_HIT: f64 = 4.0; +/// A radial's focal dot, and the centre ring around it (the start handle). +const FOCAL_HIT: f64 = 3.0; +const CENTRE: f64 = 4.5; +const CENTRE_HIT: f64 = 8.0; /// A stop dragged farther than this from the bar is deleted on release. const OFF_BAR: f64 = 24.0; /// A press becomes a drag after this much movement. @@ -68,8 +78,12 @@ pub enum Part { Mid(usize), Start, End, - /// Just past the end: rotates the vector about the start. + /// Just past the end (or a radial's extent ellipse): rotates the vector about the start. Rotate, + /// A radial's focal point. + Focal, + /// The handle on a radial's extent ellipse, across the bar: the aspect ratio. + Aspect, /// The bar at an offset (0..1): a click adds a stop there. Bar(f32), } @@ -85,6 +99,20 @@ impl Annotator { fn with(geom: GradientGeom, stops: &[GradientStop]) -> Self { Self { geom, gradient: Gradient { kind: GradientKind::Linear, stops: stops.to_vec() } } } + fn radial(&self) -> bool { + self.gradient.kind == GradientKind::Radial + } + /// A radial's aspect handle: on the extent ellipse, across the bar from the stops. + pub fn aspect_point(&self) -> Point { + self.geom.start - self.normal() * (self.vector().hypot() * self.geom.aspect) + } + /// How far `p` is from a radial's extent ellipse (approximately, along the ray from the + /// centre). + fn off_ellipse(&self, p: Point) -> Option { + let m = self.geom.unit_frame()?; + let rho = (m.inverse() * p).to_vec2().hypot(); + (rho > 1e-12).then(|| p.distance(self.geom.start) * (1.0 - 1.0 / rho).abs()) + } fn vector(&self) -> Vec2 { self.geom.end - self.geom.start } @@ -131,9 +159,16 @@ impl Annotator { if near(Some(self.geom.end), HANDLE) { return Some(Part::End); } - if near(Some(self.geom.start), HANDLE) { + let radial = self.radial(); + if radial && near(Some(self.geom.focal_point()), FOCAL_HIT) { + return Some(Part::Focal); + } + if near(Some(self.geom.start), if radial { CENTRE_HIT } else { HANDLE }) { return Some(Part::Start); } + if radial && near(Some(self.aspect_point()), HANDLE) { + return Some(Part::Aspect); + } let (t, d) = self.project(p); if t > 1.0 && near(Some(self.geom.end), ROTATE) { return Some(Part::Rotate); @@ -142,6 +177,9 @@ impl Annotator { if self.vector().hypot() > 1e-12 && (0.0..=1.0).contains(&t) && across.contains(&d) { return Some(Part::Bar(t as f32)); } + if radial && self.off_ellipse(p).is_some_and(|d| d <= cx.tol(BAR_HIT)) { + return Some(Part::Rotate); + } None } } @@ -157,7 +195,13 @@ enum Grab { bar: Option, }, End(GradientGeom), - Rotate(GradientGeom), + /// Rotates the vector, by the pointer's turn about the start from `from` (radians). + Rotate { + geom: GradientGeom, + from: f64, + }, + Focal(GradientGeom), + Aspect(GradientGeom), Stop { index: usize, from: Annotator, @@ -194,16 +238,27 @@ pub struct GradientTool { guides: Vec, } -/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop. +/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop; a +/// radial's extent ellipse with its aspect handle, centre ring and focal dot. fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { let (s, e) = (a.geom.start, a.geom.end); let shade = Vec2::new(0.0, cx.tol(1.0)); - let mut o = vec![ + let mut o = vec![]; + if let Some(m) = a.geom.unit_frame().filter(|_| a.radial()) { + // Dark dashes over a light line: readable on any art. + let path = vectorcraft_geom::kurbo::Ellipse::from_affine(m).to_path(cx.tol(0.25)); + o.push(Overlay::Path { path: path.clone(), color: LIGHT, width: 1.0, dashed: false }); + o.push(Overlay::Path { path, color: BAR, width: 1.0, dashed: true }); + o.push(Overlay::Handle { p: a.aspect_point(), color: BAR }); + let ring = vectorcraft_geom::kurbo::Circle::new(s, cx.tol(CENTRE)).to_path(cx.tol(0.25)); + o.push(Overlay::Path { path: ring, color: BAR, width: 1.5, dashed: false }); + } + o.extend([ Overlay::Line { a: s, b: e, color: BAR, dashed: false }, Overlay::Line { a: s + shade, b: e + shade, color: LIGHT, dashed: false }, - Overlay::Handle { p: s, color: BAR }, + Overlay::Handle { p: if a.radial() { a.geom.focal_point() } else { s }, color: BAR }, Overlay::Anchor { p: e, color: BAR, filled: true, size: 7.0 }, - ]; + ]); let len = a.vector().hypot(); if len < 1e-12 { return o; @@ -262,7 +317,9 @@ impl GradientTool { Some((a, Part::Start)) => Grab::Move { geom: a.geom, bar: None }, Some((a, Part::Bar(t))) => Grab::Move { geom: a.geom, bar: Some(t) }, Some((a, Part::End)) => Grab::End(a.geom), - Some((a, Part::Rotate)) => Grab::Rotate(a.geom), + Some((a, Part::Rotate)) => Grab::Rotate { geom: a.geom, from: (p - a.geom.start).atan2() - a.vector().atan2() }, + Some((a, Part::Focal)) => Grab::Focal(a.geom), + Some((a, Part::Aspect)) => Grab::Aspect(a.geom), None => { if cx.selection.is_empty() { let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out }; @@ -312,12 +369,27 @@ impl GradientTool { ("paint.setGradientGeom", Self::geom_params(cx, start, start + (geom.end - geom.start))) } Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, place(geom.start, p))), - Grab::Rotate(geom) => { - let dir = if mods.shift { constrain(p - geom.start) } else { p - geom.start }; + Grab::Rotate { geom, from } => { + let dir = (Affine::rotate(-from) * (p - geom.start).to_point()).to_vec2(); + let dir = if mods.shift { constrain(dir) } else { dir }; let len = dir.hypot(); let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) }; ("paint.setGradientGeom", Self::geom_params(cx, geom.start, end)) } + Grab::Focal(geom) => { + // Dropped back on the centre it centres. + let f = geom.focal_point() + (p - g.at); + let mut params = Self::geom_params(cx, geom.start, geom.end); + params["focal"] = if f.distance(geom.start) <= cx.tol(FOCAL_HIT) { Value::Null } else { json!([f.x, f.y]) }; + ("paint.setGradientGeom", params) + } + Grab::Aspect(geom) => { + // The pointer's distance across the bar, as a fraction of the radius. + let across = Annotator::with(*geom, &[]).project(p).1.abs(); + let mut params = Self::geom_params(cx, geom.start, geom.end); + params["aspect"] = json!(across / geom.length().max(1e-12) * 100.0); + ("paint.setGradientGeom", params) + } Grab::Stop { index, from, copy } => { let (t, d) = from.project(p); let t = t.clamp(0.0, 1.0) as f32; @@ -475,7 +547,7 @@ impl Tool for GradientTool { (Some(a), _) => annotator_overlays(cx, &a), // A vector drawn on a paint that isn't a gradient yet. (None, Some((s, e))) => { - annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops)) + annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0, focal: None }, &Gradient::default().stops)) } _ => vec![], }; @@ -490,7 +562,7 @@ impl Tool for GradientTool { if let Some(g) = &self.gesture { return match (&g.grab, g.stop) { (Grab::Stop { .. }, Some(None)) => Cursor::RemoveStop, - (Grab::Rotate(_), _) => Cursor::Rotate, + (Grab::Rotate { .. }, _) => Cursor::Rotate, (Grab::Art, _) => Cursor::Crosshair, _ => Cursor::Move, }; @@ -530,7 +602,7 @@ mod tests { fn graded(stops: Vec) -> (Document, Selection) { let (mut d, id) = doc_with_rect(); let mut g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); - g.geom = Some(GradientGeom { start: Point::new(100.0, 150.0), end: Point::new(200.0, 150.0), aspect: 1.0 }); + g.geom = Some(GradientGeom { start: Point::new(100.0, 150.0), end: Point::new(200.0, 150.0), aspect: 1.0, focal: None }); d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g))); let mut s = Selection::default(); s.add(id); @@ -670,7 +742,7 @@ mod tests { t.pointer(&cx2, &ev(PointerKind::Up, Point::new(150.0, 130.0))); // Constrain Angle 30: Shift gives 30 and 75 degrees (and no snapping). cx.constrain_angle = 30.0; - let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0 }.angle_deg(); + let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0, focal: None }.angle_deg(); for (to, want) in [(Point::new(190.0, 100.0), 30.0), (Point::new(125.0, 60.0), 75.0)] { t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); let v = preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, to)))).clone(); @@ -805,7 +877,7 @@ mod tests { #[test] fn annotator_follows_the_active_appearance_item() { let (mut d, id) = doc_with_rect(); - let geom = GradientGeom { start: Point::new(100.0, 120.0), end: Point::new(200.0, 120.0), aspect: 1.0 }; + let geom = GradientGeom { start: Point::new(100.0, 120.0), end: Point::new(200.0, 120.0), aspect: 1.0, focal: None }; let mut gp = GradientPaint::new(Default::default()); gp.geom = Some(geom); d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().paint = Paint::Gradient(Box::new(gp)); @@ -826,4 +898,30 @@ mod tests { let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 180.0))); assert_eq!(exec(&a[0]).1["item"], 1); } + + #[test] + fn a_radial_shows_its_extent_ellipse_aspect_handle_and_focal_dot() { + let (mut d, s) = graded(two()); + let n = d.node_mut(s.objects[0]).unwrap(); + let Paint::Gradient(mut g) = n.appearance.fill_paint() else { unreachable!() }; + g.gradient.kind = GradientKind::Radial; + let geom = g.geom.as_mut().unwrap(); + geom.aspect = 0.5; + geom.set_focal(Some(Point::new(120.0, 145.0))); + n.appearance.set_fill(Paint::Gradient(g)); + let p = paint(); + let cx = cx(&d, &s, &p); + let o = GradientTool::default().overlays(&cx); + assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count(), 1, "the extent ellipse"); + // The aspect handle 50 pt above the centre, the focal dot where the focal point is. + assert!( + o.contains(&Overlay::Handle { p: Point::new(100.0, 100.0), color: BAR }) + && o.contains(&Overlay::Handle { p: Point::new(120.0, 145.0), color: BAR }) + ); + let a = Annotator::of(&cx).unwrap(); + let hit = |x: f64, y: f64| a.hit(&cx, Point::new(x, y)); + assert_eq!((hit(120.0, 145.0), hit(104.0, 150.0), hit(100.0, 100.0)), (Some(Part::Focal), Some(Part::Start), Some(Part::Aspect))); + assert_eq!((hit(100.0, 200.0), hit(0.0, 150.0), hit(100.0, 220.0)), (Some(Part::Rotate), Some(Part::Rotate), None)); + assert_eq!(GradientTool::default().cursor(&cx, Point::new(100.0, 200.0), Mods::default()), Cursor::Rotate); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5406a673..e4aeb5c9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1208,7 +1208,7 @@ mod tests { assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); // A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on. let mut g = GradientPaint::new(Default::default()); - g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0 }); + g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }); drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0)); assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none())); } diff --git a/docs/mcp.md b/docs/mcp.md index 585f228a..77018faf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -155,6 +155,17 @@ delete it, hold Alt to copy it), dragging the end handle changes the vector. The {"name":"press_key","arguments":{"key":"Delete"}} ``` +A radial gradient's annotator also draws its extent: a dashed ellipse around the centre (the start, drawn as a ring) +with a dot on it across the bar (drag it to change the aspect ratio); dragging the ellipse elsewhere rotates it. +The dot inside the centre ring is the focal point, where the first stop sits: drag it for an off-centre radial, back +onto the centre to centre it. `paint.setGradientGeom` sets the same things directly (`aspect` in %, `focal` in +document coordinates or `null`; `start`/`end` may be left out), and they export as SVG `fx`/`fy` and PDF two-point +radial shadings: + +```json +{"name":"run_command","arguments":{"command":"paint.setGradientGeom","params":{"aspect":60,"focal":[130,140]}}} +``` + Freeform gradients: `paint.editGradient {kind: "freeform"}` places four or more colour points inside each selected object (coloured along the stops; `mode: "points"|"lines"` is the Draw toggle). `paint.freeform.get` lists the points (document coordinates), lines and the selected point; `paint.freeform.addPoint {at, color?, opacity?, spread?,