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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 04:27:01 +07:00
parent b8793eeaef
commit d8eff17b9f
26 changed files with 701 additions and 106 deletions

View File

@ -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<Point>,
}
/// 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<Affine> {
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<Point>) {
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::<GradientGeom>(&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:?}");
}
}

View File

@ -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);

View File

@ -196,6 +196,7 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
g.start = ga.start.lerp(gb.start, t as f64);
g.end = ga.end.lerp(gb.end, t as f64);
g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64;
g.focal = (ga.focal.is_some() || gb.focal.is_some()).then(|| ga.focal_point().lerp(gb.focal_point(), t as f64));
out.geom = Some(g);
} else {
out.geom = None;

View File

@ -200,7 +200,7 @@ fn linear(angle: f64, stops: &[(f32, &str, f32)]) -> 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)) }))
}

View File

@ -229,7 +229,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
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)));

View File

@ -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)

View File

@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<GradientPaint> {
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<Affine> {
(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<Rect>) -> Option<Paint> {
/// What `paint.setGradientGeom` sets, in document coordinates (None: kept).
struct GeomEdit {
vector: Option<(Point, Point)>,
aspect: Option<f64>,
/// Some(None) centres the focal point.
focal: Option<Option<Point>>,
}
impl GeomEdit {
fn parse(p: &Value) -> std::result::Result<Self, String> {
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<Rect>) -> Option<Paint> {
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<Value> {
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<Value> {
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(())

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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)
),

View File

@ -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;

View File

@ -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:?}");
}

View File

@ -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
}

View File

@ -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,

View File

@ -401,16 +401,17 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option<Gra
};
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
let (kind, geom, stops) = if *axial {
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0 };
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None };
(GradientKind::Linear, geom, keep.iter().map(|&i| stop_at(pts[i].0, pts[i].0)).collect::<Vec<_>>())
} 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)

View File

@ -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;

View File

@ -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);
}

View File

@ -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();

View File

@ -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<Point>) -> 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);
}

View File

@ -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!(
"<radialGradient id=\"{id}\" cx=\"{}\" cy=\"{}\" r=\"{}\" gradientUnits=\"userSpaceOnUse\">",
"<radialGradient id=\"{id}\" cx=\"{}\" cy=\"{}\" r=\"{}\"{} gradientUnits=\"userSpaceOnUse\">",
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!(
"<radialGradient id=\"{id}\" cx=\"0\" cy=\"0\" r=\"{}\" gradientTransform=\"{}\" gradientUnits=\"userSpaceOnUse\">",
"<radialGradient id=\"{id}\" cx=\"0\" cy=\"0\" r=\"{}\"{} gradientTransform=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(r),
geom.focal.map(|f| focal(m.inverse() * f)).unwrap_or_default(),
self.matrix(m)
),
"radialGradient",

View File

@ -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)
}

69
crates/svg/tests/focal.rs Normal file
View File

@ -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(&centred, &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##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<radialGradient id="g" cx="100" cy="100" r="50" fx="0" fy="100" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></radialGradient>
<rect width="200" height="200" fill="url(#g)"/></svg>"##;
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:?}");
}

View File

@ -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,

View File

@ -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)));

View File

@ -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");

View File

@ -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<f64> {
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<f32>,
},
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<Overlay>,
}
/// 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<Overlay> {
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<GradientStop>) -> (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);
}
}

View File

@ -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()));
}

View File

@ -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?,