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:43:43 +07:00
co-authored by Claude Opus 5.5
parent b8793eeaef
commit d8eff17b9f
26 changed files with 701 additions and 106 deletions
+1 -1
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)));
+11 -5
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)
+55 -21
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(())
+1 -1
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)
),
+2
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;
+120
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:?}");
}
+1 -1
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
}