Merge pull request #59 from storytold/pr/m3-freeform-canvas-focal

M3: freeform editing on the canvas; radial extent, aspect and focal point (M3.97, M3.98)
This commit is contained in:
BFlat 2026-10-05 04:48:55 +07:00 committed by GitHub
commit 96a1a02436
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1234 additions and 200 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

@ -1075,6 +1075,15 @@ fn keeps_gradient_fit(a: Affine) -> bool {
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
}
impl Node {
/// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true
/// everywhere for objects without them, such as type, whose boxes stand in).
pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> {
let shapes = self.clip_shapes(None);
move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p))
}
}
#[cfg(test)]
mod tests {
use super::*;

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

@ -5,7 +5,7 @@
use serde_json::{Value, json};
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Freeform, FreeformMode, FreeformPoint, GradientKind, Paint};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{Affine, Rect};
use super::appearance::{edit_items, edits_stroke, index_param, item_target};
@ -323,10 +323,3 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
v["selected"] = json!(selected);
Ok(v)
}
/// Whether `n`'s shape contains `p` (its filled regions; true for objects without them, such as
/// type, whose boxes stand in).
pub(crate) fn inside_fn(n: &Node) -> impl Fn(vectorcraft_geom::Point) -> bool + use<> {
let shapes = n.clip_shapes(None);
move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p))
}

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.
@ -357,7 +358,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
}
let b = item_paint_bounds(n, index, !stroke);
// Only a gradient turning freeform needs the shape (its first points go inside it).
let inside = (freeform && b.is_some()).then(|| super::freeform::inside_fn(n));
let inside = (freeform && b.is_some()).then(|| n.contains_fn());
let inside: &dyn Fn(Point) -> bool = match &inside {
Some(f) => f,
None => &|_| true,
@ -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

@ -61,7 +61,7 @@ fn the_first_application_places_points_inside_the_shape() {
let g = fill_gradient(&s, id);
assert_eq!(g.gradient.kind, GradientKind::Freeform);
let f = g.freeform.as_ref().unwrap();
let inside = cmd::freeform::inside_fn(node(&s, id));
let inside = node(&s, id).contains_fn();
assert!(f.points.len() >= 4 && f.points.iter().all(|p| inside(p.at)), "{:?}", f.points);
// The stops follow the points' colours (swatch chips and exports show them).
assert_eq!(g.gradient.stops.iter().map(|st| st.color).collect::<Vec<_>>(), f.points.iter().map(|p| p.color).collect::<Vec<_>>());

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

@ -32,4 +32,6 @@ mod tests;
#[cfg(test)]
mod tests_fileio;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_gradient;

View File

@ -0,0 +1,58 @@
//! The Gradient tool on a freeform gradient through MCP: `pointer_gesture` adds, moves and
//! removes points, drags the spread and draws lines; `press_key` Escape ends a line.
use serde_json::{Value, json};
use crate::{Backend, Headless, call_tool};
fn ok(h: &mut Headless, name: &str, args: Value) -> Value {
let r = call_tool(h, name, &args);
assert!(!r.is_error, "{name} {args}: {r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null)
}
fn freeform(h: &mut Headless) -> Value {
ok(h, "run_command", json!({"command": "paint.freeform.get"}))
}
fn click(h: &mut Headless, x: f64, y: f64) -> Value {
ok(h, "pointer_gesture", json!({"tool": "gradient", "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]}))
}
fn drag(h: &mut Headless, from: [f64; 2], to: [f64; 2]) {
let mid = [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0];
let ev = |k: &str, p: [f64; 2]| json!({"kind": k, "x": p[0], "y": p[1]});
ok(h, "pointer_gesture", json!({"events": [ev("down", from), ev("drag", mid), ev("drag", to), ev("up", to)]}));
}
#[test]
fn freeform_points_and_lines_on_the_canvas() {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap();
ok(&mut h, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100}));
ok(&mut h, "run_command", json!({"command": "paint.editGradient", "params": {"kind": "freeform", "mode": "lines"}}));
let n = freeform(&mut h)["points"].as_array().unwrap().len();
// Lines mode: three clicks draw one line through three new points.
for (x, y) in [(110.0, 190.0), (150.0, 170.0), (190.0, 190.0)] {
click(&mut h, x, y);
}
let f = freeform(&mut h);
assert_eq!(f["lines"], json!([[n, n + 1, n + 2]]), "{f}");
// Escape ends it: the next click starts afresh.
let r = ok(&mut h, "press_key", json!({"key": "Escape"}));
assert_eq!(r["handledBy"], "tool", "{r}");
click(&mut h, 150.0, 120.0);
assert_eq!(freeform(&mut h)["lines"].as_array().unwrap().len(), 1);
// Dragging the selected point's spread handle (16 px right of it at 100 %) spreads it.
drag(&mut h, [166.0, 120.0], [176.0, 120.0]);
let f = freeform(&mut h);
let spread = f["points"][n + 3]["spread"].as_f64().unwrap();
assert!((spread - 0.2).abs() < 1e-6, "10 pt of half the 100 pt box: {f}");
// A point dragged out of the rectangle goes.
drag(&mut h, [150.0, 120.0], [40.0, 40.0]);
assert_eq!(freeform(&mut h)["points"].as_array().unwrap().len(), n + 3);
// Double-clicking a point asks for its popover.
let dbl = ok(&mut h, "pointer_gesture", json!({"events": [{"kind": "doubleclick", "x": 150, "y": 170}]}));
assert_eq!(dbl["requests"][0]["dialog"], "gradientStop", "{dbl}");
assert_eq!(freeform(&mut h)["selected"], json!(n + 1));
}

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

@ -1,17 +1,22 @@
//! The Gradient tool on a freeform gradient.
//!
//! The annotator shows each point as a colour chip, the lines through them and, around the
//! selected point, its spread. Click a point to select it (`paint.freeform.selectPoint`; the
//! Gradient and Color panels edit the selected point) and drag it to move it. Click inside the
//! selected art to add a point there, coloured as the gradient is there; in Lines mode (the
//! Gradient panel's Draw toggle) the new point joins the selected one by a line. Click a line to add
//! a point on it. Delete or Backspace removes the selected point.
//! selected point, its spread: a dashed ring with a handle. Click a point to select it
//! (`paint.freeform.selectPoint`; the Gradient and Color panels edit the selected point), drag it
//! to move it, drag it out of the object to remove it, and double-click it for its popover. Drag
//! the ring or its handle to change the spread. Click a line to add a point on it.
//!
//! Points mode (the Gradient panel's Draw toggle): a click inside the selected art adds a point
//! there, coloured as the gradient is there. Lines mode: successive clicks draw a smooth line
//! through the points they add, until Escape (or a click off the art) ends it; clicking an
//! existing point first starts the line from it. Delete or Backspace removes the selected point.
use serde_json::{Value, json};
use vectorcraft_color::freeform::LineHit;
use vectorcraft_color::{Freeform, FreeformMode};
use vectorcraft_color::{Color, Freeform, FreeformMode, FreeformPoint};
use vectorcraft_doc::NodeId;
use vectorcraft_doc::hit::hit_test;
use vectorcraft_geom::{Point, Shape};
use vectorcraft_geom::{Point, Shape, Vec2};
use super::paint_owner;
use crate::{Action, Cursor, Overlay, ToolContext, ToolKey};
@ -20,12 +25,19 @@ const LINE: [u8; 3] = [0x20, 0x20, 0x20];
/// Hit radius of a point's chip and of a line, screen pixels.
const POINT_HIT: f64 = 7.0;
const LINE_HIT: f64 = 4.0;
/// Hit distance of the spread ring and its handle, screen pixels.
const RING_HIT: f64 = 4.0;
/// The spread handle sits at least this far from its point (so a spread of 0 can be dragged
/// out), screen pixels.
const RING_MIN: f64 = 16.0;
/// A press becomes a drag after this much movement, screen pixels.
const DRAG_START: f64 = 3.0;
/// The freeform gradient behind the active proxy of the first selected object that has one: its
/// points in document coordinates and the length spreads are fractions of there.
pub(super) struct Annotator {
/// The object it paints.
pub id: NodeId,
pub freeform: Freeform,
pub scale: f64,
}
@ -34,7 +46,7 @@ impl Annotator {
pub fn of(cx: &ToolContext) -> Option<Self> {
cx.selection.objects.iter().find_map(|id| {
let (freeform, scale) = cx.doc.node(*id)?.proxy_freeform(!cx.fill_active, cx.appearance_item)?;
Some(Self { freeform, scale })
Some(Self { id: *id, freeform, scale })
})
}
@ -53,17 +65,42 @@ impl Annotator {
cx.freeform_point.filter(|i| *i < self.freeform.points.len())
}
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
/// Point `i`'s spread radius (document units) and where its spread handle sits: on the ring,
/// to the right of the point (at least [`RING_MIN`] pixels out).
fn ring(&self, cx: &ToolContext, i: usize) -> (f64, Point) {
let pt = &self.freeform.points[i];
let r = pt.spread as f64 * self.scale;
(r, pt.at + Vec2::new(r.max(cx.tol(RING_MIN)), 0.0))
}
/// The selected point when `p` is on its spread ring or handle.
fn ring_at(&self, cx: &ToolContext, p: Point) -> Option<usize> {
let i = self.selected(cx)?;
let (r, handle) = self.ring(cx, i);
let on_ring = r > 0.0 && (self.freeform.points[i].at.distance(p) - r).abs() <= cx.tol(RING_HIT);
(on_ring || handle.distance(p) <= cx.tol(RING_HIT)).then_some(i)
}
/// Is a line being drawn from the selected point (Lines mode)?
fn line_open(&self, cx: &ToolContext, lines: &Lines) -> Option<usize> {
self.selected(cx).filter(|_| lines.open && self.freeform.mode == FreeformMode::Lines)
}
pub fn overlays(&self, cx: &ToolContext, lines: &Lines) -> Vec<Overlay> {
let f = &self.freeform;
let mut o: Vec<Overlay> =
(0..f.lines.len()).map(|l| Overlay::Path { path: f.line_path(l), color: LINE, width: 1.0, dashed: false }).collect();
// The line being drawn, on to the pointer.
if let (Some(from), Some(hover)) = (self.line_open(cx, lines), lines.hover) {
o.push(Overlay::Path { path: rubber_band(f, from, hover), color: LINE, width: 1.0, dashed: true });
}
if let Some(i) = self.selected(cx) {
let pt = &f.points[i];
let r = pt.spread as f64 * self.scale;
let (r, handle) = self.ring(cx, i);
if r > 0.0 {
let path = vectorcraft_geom::kurbo::Circle::new(pt.at, r).to_path(cx.tol(0.25));
let path = vectorcraft_geom::kurbo::Circle::new(f.points[i].at, r).to_path(cx.tol(0.25));
o.push(Overlay::Path { path, color: LINE, width: 1.0, dashed: true });
}
o.push(Overlay::Handle { p: handle, color: LINE });
}
o.extend(f.points.iter().enumerate().map(|(i, pt)| Overlay::Swatch {
p: pt.at,
@ -74,11 +111,35 @@ impl Annotator {
}
}
/// The segment a click at `to` would add to the line drawn from point `from`.
fn rubber_band(f: &Freeform, from: usize, to: Point) -> vectorcraft_geom::BezPath {
let mut f = f.clone();
let i = f.add_point(FreeformPoint::new(to, Color::BLACK));
let mut bp = vectorcraft_geom::BezPath::new();
if let Some(c) = f.connect(from, i).and_then(|l| f.segments(l).into_iter().find(|c| c.p0 == to || c.p3 == to)) {
bp.move_to(c.p0);
bp.curve_to(c.p1, c.p2, c.p3);
}
bp
}
/// The Gradient tool's freeform state between gestures: the line Lines mode is drawing.
#[derive(Debug, Default)]
pub(super) struct Lines {
/// A line is being drawn from the selected point: the next click on the art extends it.
open: bool,
/// Where the pointer hovers over the art (the line's rubber band runs there).
hover: Option<Point>,
}
/// What a press on a freeform annotator does.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Grab {
/// A point: a drag moves it.
enum Grab {
/// A point: a drag moves it, out of the object removes it.
Point(usize),
/// The selected point's spread ring: a drag resizes it from radius `r` (the press `d` from
/// the point at `at`).
Spread { index: usize, at: Point, r: f64, d: f64, scale: f64 },
/// A line: a click adds a point there.
Line(LineHit),
/// Inside selected art: a click adds a point there.
@ -90,8 +151,14 @@ pub(super) enum Grab {
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Gesture {
grab: Grab,
/// The object whose gradient it edits and its point count at the press.
id: NodeId,
points: usize,
at: Point,
began: bool,
/// A dragged point is out of the object (releasing removes it); `left`: it has been.
out: bool,
left: bool,
}
impl Gesture {
@ -111,6 +178,11 @@ fn select(i: Option<usize>) -> Action {
Action::Exec("paint.freeform.selectPoint".into(), json!({ "index": i }))
}
/// Is `p` on selected art (where a click adds a point)?
fn on_art(cx: &ToolContext, p: Point) -> bool {
hit_test(cx.doc, p, cx.hit_options()).is_some_and(|h| cx.selection.objects.contains(&paint_owner(cx.doc, h.leaf)))
}
pub(super) fn press(cx: &ToolContext, a: &Annotator, p: Point) -> (Vec<Action>, Gesture) {
let mut out = vec![];
let grab = if let Some(i) = a.point_at(cx, p) {
@ -118,18 +190,23 @@ pub(super) fn press(cx: &ToolContext, a: &Annotator, p: Point) -> (Vec<Action>,
out.push(select(Some(i)));
}
Grab::Point(i)
} else if let Some(index) = a.ring_at(cx, p) {
let at = a.freeform.points[index].at;
Grab::Spread { index, at, r: a.ring(cx, index).0, d: at.distance(p), scale: a.scale }
} else if let Some(h) = a.line_at(cx, p) {
Grab::Line(h)
} else if hit_test(cx.doc, p, cx.hit_options()).is_some_and(|h| cx.selection.objects.contains(&paint_owner(cx.doc, h.leaf))) {
} else if on_art(cx, p) {
Grab::Add
} else {
Grab::Clear
};
(out, Gesture { grab, at: p, began: false })
(out, Gesture { grab, id: a.id, points: a.freeform.points.len(), at: p, began: false, out: false, left: false })
}
pub(super) fn drag(cx: &ToolContext, g: &mut Gesture, p: Point) -> Vec<Action> {
let Grab::Point(index) = g.grab else { return vec![] };
if !matches!(g.grab, Grab::Point(_) | Grab::Spread { .. }) {
return vec![];
}
let mut out = vec![];
if !g.began {
if p.distance(g.at) < cx.tol(DRAG_START) {
@ -138,50 +215,124 @@ pub(super) fn drag(cx: &ToolContext, g: &mut Gesture, p: Point) -> Vec<Action> {
g.began = true;
out.push(Action::Begin("Gradient".into()));
}
out.push(Action::Preview("paint.freeform.setPoint".into(), on_proxy(cx, json!({ "index": index, "at": [p.x, p.y] }))));
let (cmd, params) = match g.grab {
Grab::Point(index) => {
// Out of the object the point goes (never the last one).
g.out = g.points > 1 && cx.doc.node(g.id).is_some_and(|n| !n.contains_fn()(p));
g.left |= g.out;
if g.out {
("paint.freeform.deletePoint", json!({ "index": index }))
} else {
("paint.freeform.setPoint", json!({ "index": index, "at": [p.x, p.y] }))
}
}
Grab::Spread { index, at, r, d, scale } => {
let spread = if scale > 0.0 { ((r + at.distance(p) - d) / scale).clamp(0.0, 1.0) } else { 0.0 };
("paint.freeform.setPoint", json!({ "index": index, "spread": spread }))
}
_ => unreachable!("checked above"),
};
out.push(Action::Preview(cmd.into(), on_proxy(cx, params)));
out
}
pub(super) fn release(cx: &ToolContext, a: Option<&Annotator>, g: Gesture) -> Vec<Action> {
pub(super) fn release(cx: &ToolContext, a: Option<&Annotator>, g: Gesture, lines: &mut Lines) -> Vec<Action> {
let mode = a.map_or(FreeformMode::Points, |a| a.freeform.mode);
if g.began {
return vec![Action::Commit];
let mut out = vec![Action::Commit];
// A point dragged out and back was deselected by the removal its drag previewed.
if let Grab::Point(i) = g.grab
&& g.left
&& !g.out
{
out.push(select(Some(i)));
}
lines.open &= !g.out;
return out;
}
match g.grab {
Grab::Point(_) => vec![],
// In Lines mode a line can start from an existing point.
Grab::Point(_) => {
lines.open = mode == FreeformMode::Lines;
vec![]
}
Grab::Spread { .. } => vec![],
Grab::Line(h) => {
lines.open = false;
vec![Action::Exec("paint.freeform.splitLine".into(), on_proxy(cx, json!({ "line": h.line, "segment": h.segment, "t": h.t })))]
}
Grab::Add => {
let lines = a.is_some_and(|a| a.freeform.mode == FreeformMode::Lines);
vec![Action::Exec("paint.freeform.addPoint".into(), on_proxy(cx, json!({ "at": [g.at.x, g.at.y], "line": lines })))]
let join = a.is_some_and(|a| a.line_open(cx, lines).is_some());
lines.open = mode == FreeformMode::Lines;
vec![Action::Exec("paint.freeform.addPoint".into(), on_proxy(cx, json!({ "at": [g.at.x, g.at.y], "line": join })))]
}
Grab::Clear => {
lines.open = false;
if cx.freeform_point.is_some() { vec![select(None)] } else { vec![] }
}
Grab::Clear if cx.freeform_point.is_some() => vec![select(None)],
Grab::Clear => vec![],
}
}
/// Delete / Backspace remove the selected point (never the last one).
pub(super) fn claims_key(cx: &ToolContext, a: &Annotator, key: ToolKey) -> bool {
matches!(key, ToolKey::Delete | ToolKey::Backspace) && a.selected(cx).is_some() && a.freeform.points.len() > 1
/// Escape during a drag: the point a drag-out deselected is selected again.
pub(super) fn cancel(g: &Gesture) -> Vec<Action> {
match g.grab {
Grab::Point(i) if g.out => vec![Action::Cancel, select(Some(i))],
_ => vec![Action::Cancel],
}
}
pub(super) fn key(cx: &ToolContext, a: &Annotator, key: ToolKey) -> Vec<Action> {
if !claims_key(cx, a, key) {
/// Double-clicking a point opens its popover beside it.
pub(super) fn double_click(cx: &ToolContext, a: &Annotator, p: Point) -> Vec<Action> {
let Some(i) = a.point_at(cx, p) else { return vec![] };
let at = a.freeform.points[i].at;
let mut out = if cx.freeform_point != Some(i) { vec![select(Some(i))] } else { vec![] };
out.push(Action::Dialog("gradientStop".into(), json!({ "index": i, "x": at.x, "y": at.y })));
out
}
/// Pointer moves: Lines mode's rubber band follows the pointer over the art (where a click
/// extends the line).
pub(super) fn hover(cx: &ToolContext, lines: &mut Lines, p: Point) {
lines.hover = (lines.open && on_art(cx, p)).then_some(p);
}
/// Delete / Backspace remove the selected point (never the last one); Escape ends the line being
/// drawn.
pub(super) fn claims_key(cx: &ToolContext, a: &Annotator, lines: &Lines, key: ToolKey) -> bool {
match key {
ToolKey::Delete | ToolKey::Backspace => a.selected(cx).is_some() && a.freeform.points.len() > 1,
ToolKey::Escape => a.line_open(cx, lines).is_some(),
_ => false,
}
}
pub(super) fn key(cx: &ToolContext, a: &Annotator, lines: &mut Lines, key: ToolKey) -> Vec<Action> {
if !claims_key(cx, a, lines, key) {
return vec![];
}
if key == ToolKey::Escape {
lines.open = false;
return vec![];
}
vec![Action::Exec("paint.freeform.deletePoint".into(), on_proxy(cx, json!({ "index": a.selected(cx) })))]
}
pub(super) fn cursor(cx: &ToolContext, a: &Annotator, g: Option<&Gesture>, p: Point) -> Cursor {
if g.is_some_and(|g| g.began) || a.point_at(cx, p).is_some() {
return Cursor::Move;
match g.filter(|g| g.began).map(|g| (&g.grab, g.out)) {
Some((Grab::Point(_), true)) => return Cursor::RemoveStop,
Some((Grab::Spread { .. }, _)) => return Cursor::ResizeH,
Some(_) => return Cursor::Move,
None => {}
}
if a.line_at(cx, p).is_some()
|| hit_test(cx.doc, p, cx.hit_options()).is_some_and(|h| cx.selection.objects.contains(&paint_owner(cx.doc, h.leaf)))
{
return Cursor::AddStop;
if a.point_at(cx, p).is_some() {
Cursor::Move
} else if a.ring_at(cx, p).is_some() {
Cursor::ResizeH
} else if a.line_at(cx, p).is_some() || on_art(cx, p) {
Cursor::AddStop
} else {
Cursor::Crosshair
}
Cursor::Crosshair
}
#[cfg(test)]
@ -246,6 +397,8 @@ mod tests {
let mut cx = cx(&d, &s, &p);
cx.freeform_point = Some(1);
let mut t = GradientTool::default();
// Clicking the selected point starts a line from it; a click on the art extends it.
assert!(click(&mut t, &cx, 180.0, 120.0).is_empty());
let a = click(&mut t, &cx, 190.0, 190.0);
assert_eq!(a, vec![Action::Exec("paint.freeform.addPoint".into(), json!({"at": [190.0, 190.0], "line": true, "stroke": false}))]);
t.pointer(&cx, &ev(PointerKind::Down, 120.0, 121.0));
@ -261,4 +414,109 @@ mod tests {
vec![Action::Exec("paint.freeform.deletePoint".into(), json!({"index": 1, "stroke": false}))]
);
}
#[test]
fn successive_clicks_draw_a_line_until_escape() {
let (d, s) = doc(FreeformMode::Lines);
let p = paint();
let mut cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
let add = |x: f64, y: f64, line: bool| Action::Exec("paint.freeform.addPoint".into(), json!({"at": [x, y], "line": line, "stroke": false}));
// The first click starts a line; the engine selects each new point, which the next joins.
assert_eq!(click(&mut t, &cx, 140.0, 150.0), vec![add(140.0, 150.0, false)]);
// (The engine selects the new point; any existing one stands in for it here.)
cx.freeform_point = Some(2);
assert!(t.claims_key(&cx, ToolKey::Escape));
// The rubber band follows the pointer from the line's end.
t.pointer(&cx, &ev(PointerKind::Move, 170.0, 160.0));
let dashed = |t: &GradientTool, cx: &ToolContext| t.overlays(cx).iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count();
assert_eq!(dashed(&t, &cx), 2, "the rubber band and the selected point's spread ring");
// Off the art (where a click ends the line) it goes.
t.pointer(&cx, &ev(PointerKind::Move, 400.0, 400.0));
assert_eq!(dashed(&t, &cx), 1);
assert_eq!(click(&mut t, &cx, 170.0, 160.0), vec![add(170.0, 160.0, true)]);
// Escape ends the line: the next click starts another.
assert!(t.key(&cx, ToolKey::Escape, Default::default()).is_empty());
assert!(!t.claims_key(&cx, ToolKey::Escape));
assert_eq!(dashed(&t, &cx), 1);
assert_eq!(click(&mut t, &cx, 130.0, 170.0), vec![add(130.0, 170.0, false)]);
// Points mode never joins.
let (d, s) = doc(FreeformMode::Points);
let mut cx = crate::testutil::cx(&d, &s, &p);
cx.freeform_point = Some(0);
let mut t = GradientTool::default();
click(&mut t, &cx, 120.0, 120.0);
assert_eq!(click(&mut t, &cx, 140.0, 150.0), vec![add(140.0, 150.0, false)]);
assert!(!t.claims_key(&cx, ToolKey::Escape));
}
#[test]
fn a_point_dragged_out_of_the_object_is_removed() {
let (d, s) = doc(FreeformMode::Points);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.freeform_point = Some(0);
let mut t = GradientTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0));
assert_eq!(a[1], Action::Preview("paint.freeform.deletePoint".into(), json!({"index": 0, "stroke": false})));
assert_eq!(t.cursor(&cx, Point::new(60.0, 60.0), Default::default()), Cursor::RemoveStop);
// Back inside it moves again.
let a = t.pointer(&cx, &ev(PointerKind::Drag, 110.0, 110.0));
assert_eq!(a[0], Action::Preview("paint.freeform.setPoint".into(), json!({"index": 0, "at": [110.0, 110.0], "stroke": false})));
// Released there it is selected again (the removal previewed on the way deselected it).
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 110.0, 110.0)), vec![Action::Commit, select(Some(0))]);
t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0));
t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 60.0, 60.0)), vec![Action::Commit]);
// Escape mid-drag restores the point and its selection.
t.pointer(&cx, &ev(PointerKind::Down, 120.0, 120.0));
t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0));
assert_eq!(t.key(&cx, ToolKey::Escape, Default::default()), vec![Action::Cancel, select(Some(0))]);
}
#[test]
fn the_spread_ring_drags_the_spread() {
let (d, s) = doc(FreeformMode::Points);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.freeform_point = Some(2);
let mut t = GradientTool::default();
// Spread 0.2 of half the 100 pt box: a 10 pt ring around (150, 180), its handle on it.
assert_eq!(t.cursor(&cx, Point::new(150.0, 170.0), Default::default()), Cursor::ResizeH);
assert!(t.overlays(&cx).contains(&Overlay::Handle { p: Point::new(166.0, 180.0), color: LINE }), "at least 16 px out");
assert!(t.pointer(&cx, &ev(PointerKind::Down, 160.0, 180.0)).is_empty());
let spread = |a: &[Action]| match a.last() {
Some(Action::Preview(c, v)) if c == "paint.freeform.setPoint" && v["index"] == 2 => (v["spread"].as_f64().unwrap() * 1e4).round() / 1e4,
a => panic!("{a:?}"),
};
assert_eq!(spread(&t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 180.0))), 0.4);
// Dragged in, it shrinks with the pointer's distance.
assert_eq!(spread(&t.pointer(&cx, &ev(PointerKind::Drag, 145.0, 180.0))), 0.1);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 145.0, 180.0)), vec![Action::Commit]);
// Grabbed by the handle (16 px out), it reaches no spread before the pointer reaches the point.
t.pointer(&cx, &ev(PointerKind::Down, 166.0, 180.0));
assert_eq!(spread(&t.pointer(&cx, &ev(PointerKind::Drag, 152.0, 180.0))), 0.0);
t.pointer(&cx, &ev(PointerKind::Up, 152.0, 180.0));
// The handle of a point without spread drags one out.
let mut d0 = d.clone();
let n = d0.node_mut(s.objects[0]).unwrap();
let Paint::Gradient(mut g) = n.appearance.fill_paint() else { unreachable!() };
g.freeform.as_mut().unwrap().points[2].spread = 0.0;
n.appearance.set_fill(Paint::Gradient(g));
let mut cx = crate::testutil::cx(&d0, &s, &p);
cx.freeform_point = Some(2);
t.pointer(&cx, &ev(PointerKind::Down, 166.0, 180.0));
assert_eq!(spread(&t.pointer(&cx, &ev(PointerKind::Drag, 176.0, 180.0))), 0.2);
}
#[test]
fn double_clicking_a_point_opens_its_popover() {
let (d, s) = doc(FreeformMode::Points);
let p = paint();
let cx = cx(&d, &s, &p);
let a = GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, 181.0, 119.0));
assert_eq!(a, vec![select(Some(1)), Action::Dialog("gradientStop".into(), json!({"index": 1, "x": 180.0, "y": 120.0}))]);
assert!(GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, 140.0, 150.0)).is_empty());
}
}

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,
@ -188,20 +232,33 @@ pub struct GradientTool {
gesture: Option<Gesture>,
/// A press on a freeform gradient's annotator.
free: Option<freeform::Gesture>,
/// The line Lines mode is drawing on a freeform gradient.
lines: freeform::Lines,
/// Smart-guide feedback of the handle being dragged.
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;
@ -260,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 };
@ -310,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;
@ -402,9 +476,14 @@ impl Tool for GradientTool {
(PointerKind::Drag, Some(g)) => return freeform::drag(cx, g, ev.pos),
(PointerKind::Up, Some(_)) => {
let g = self.free.take().expect("checked");
return freeform::release(cx, FreeformAnnotator::of(cx).as_ref(), g);
return freeform::release(cx, FreeformAnnotator::of(cx).as_ref(), g, &mut self.lines);
}
(PointerKind::Move, None) => freeform::hover(cx, &mut self.lines, ev.pos),
(PointerKind::DoubleClick, _) => {
if let Some(a) = FreeformAnnotator::of(cx) {
return freeform::double_click(cx, &a, ev.pos);
}
}
(PointerKind::DoubleClick, free) if free.is_some() || FreeformAnnotator::of(cx).is_some() => return vec![],
_ => {}
}
match ev.kind {
@ -423,7 +502,7 @@ impl Tool for GradientTool {
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
if let Some(a) = FreeformAnnotator::of(cx) {
return freeform::claims_key(cx, &a, key);
return freeform::claims_key(cx, &a, &self.lines, key);
}
matches!(key, ToolKey::Delete | ToolKey::Backspace | ToolKey::Left | ToolKey::Right)
&& cx.gradient_stop.is_some_and(|i| Annotator::of(cx).is_some_and(|a| i < a.gradient.stops.len()))
@ -432,15 +511,14 @@ impl Tool for GradientTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.busy() {
self.gesture = None;
self.free = None;
self.guides.clear();
return vec![Action::Cancel];
return self.free.take().map_or_else(|| vec![Action::Cancel], |g| freeform::cancel(&g));
}
if self.busy() || !self.claims_key(cx, key) {
return vec![];
}
if let Some(a) = FreeformAnnotator::of(cx) {
return freeform::key(cx, &a, key);
return freeform::key(cx, &a, &mut self.lines, key);
}
let (Some(a), Some(i)) = (Annotator::of(cx), cx.gradient_stop) else { return vec![] };
let stops = &a.gradient.stops;
@ -463,13 +541,13 @@ impl Tool for GradientTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(a) = FreeformAnnotator::of(cx) {
return a.overlays(cx);
return a.overlays(cx, &self.lines);
}
let mut o = match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
(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![],
};
@ -484,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,
};
@ -524,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);
@ -664,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();
@ -799,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));
@ -820,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

@ -2,34 +2,64 @@
//! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls,
//! or a document swatch, which stays linked when it is a global or spot colour), opacity and
//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and
//! closes on Escape or a click elsewhere.
//! closes on Escape or a click elsewhere. On a freeform gradient it edits the selected point
//! (`paint.freeform.selectPoint`) instead: its colour, opacity and spread, through
//! `paint.freeform.setPoint`.
//!
//! Fields: `index`, `x`, `y` (the chip, document coordinates) or `screen` ([x, y], screen points,
//! from the panel), and `tab` (`color` or `swatches`).
//! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm.
//! Agents can set `color` (hex), `opacity` and `location` (a point: `spread`) as percentages,
//! and confirm.
use egui::{Sense, vec2};
use serde_json::{Value, json};
use vectorcraft_color::gradient::move_stop;
use vectorcraft_color::{Color, GradientStop, Paint};
use vectorcraft_color::{Color, FreeformPoint, GradientKind, GradientStop, Paint};
use vectorcraft_geom::Point;
use vectorcraft_tools::params::color_json;
use super::{DialogResult, DialogSpec};
use crate::VectorcraftApp;
use crate::canvas::Xf;
use crate::panels::active_paint;
use crate::panels::gradient::set_stops;
use crate::panels::gradient::{edit_point, set_stops, shown_points};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets::{self, Live};
pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm);
/// The stops of the gradient behind the active proxy and the selected stop's index.
fn selected(app: &VectorcraftApp) -> Option<(Vec<GradientStop>, usize)> {
/// What the popover edits: the selected stop of the gradient behind the active proxy (with all
/// its stops), or the selected point of a freeform gradient.
enum Target {
Stop(Vec<GradientStop>, usize),
Point(FreeformPoint, usize),
}
impl Target {
fn color(&self) -> Color {
match self {
Target::Stop(stops, i) => stops[*i].color,
Target::Point(p, _) => p.color,
}
}
fn opacity(&self) -> f32 {
match self {
Target::Stop(stops, i) => stops[*i].opacity,
Target::Point(p, _) => p.opacity,
}
}
}
fn selected(app: &VectorcraftApp) -> Option<Target> {
let Paint::Gradient(g) = active_paint(app) else { return None };
if g.gradient.kind == GradientKind::Freeform {
let f = shown_points(&g);
let i = app.session.selected_freeform_point().filter(|i| *i < f.points.len())?;
return Some(Target::Point(f.points[i], i));
}
let i = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len())?;
Some((g.gradient.stops, i))
Some(Target::Stop(g.gradient.stops, i))
}
/// `stops` with stop `i` given `color`, linked to `link` (a global swatch and tint) or unlinked.
@ -47,8 +77,8 @@ fn swatch_colors(app: &VectorcraftApp) -> Vec<(String, Color)> {
fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(d) = app.ui.dialog.clone() else { return };
// The stop went away (undo, another object selected): nothing left to edit.
let Some((stops, i)) = selected(app) else {
// The stop or point went away (undo, another object selected): nothing left to edit.
let Some(target) = selected(app) else {
app.ui.dialog = None;
return;
};
@ -78,24 +108,24 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
});
ui.add_space(6.0);
if swatches {
swatch_grid(app, ui, &stops, i);
swatch_grid(app, ui, &target);
} else {
// The Color panel edits the selected stop of the active gradient.
// The Color panel edits the selected stop (or point) of the active gradient.
crate::panels::color::show(app, ui);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let s = &stops[i];
widgets::dim_label(ui, "Opacity:");
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) {
let mut v2 = stops.clone();
v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
set_stops(app, &v2, Some(i), Live::Released);
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", target.opacity() as f64 * 100.0, "%", 0, 54.0) {
apply(app, &target, &json!({ "opacity": v }));
}
widgets::dim_label(ui, "Location:");
if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) {
let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32);
set_stops(app, &v2, Some(ni), Live::Released);
let (label, key, value) = match &target {
Target::Stop(stops, i) => ("Location:", "location", stops[*i].offset),
Target::Point(p, _) => ("Spread:", "spread", p.spread),
};
widgets::dim_label(ui, label);
if let Some(v) = widgets::plain_field(ui, ("stop-pop", key), value as f64 * 100.0, "%", 1, 54.0) {
apply(app, &target, &json!({ key: v }));
}
});
});
@ -111,45 +141,80 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
/// Document swatches as tiles: a click gives the stop that colour.
fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientStop], i: usize) {
/// Document swatches as tiles: a click gives the stop (or point) that colour.
fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, target: &Target) {
let colors = swatch_colors(app);
if colors.is_empty() {
widgets::dim_label(ui, "The document has no colour swatches.");
return;
}
let current = target.color();
let mut pick = None;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
for (name, c) in &colors {
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
widgets::swatch_tile(ui, r, &Paint::solid(*c), stops[i].color == *c, resp.hovered());
widgets::swatch_tile(ui, r, &Paint::solid(*c), current == *c, resp.hovered());
if resp.on_hover_text(name).clicked() {
pick = Some((name.clone(), *c));
}
}
});
// A global or spot colour (or a tint of one) stays linked.
if let Some((name, c)) = pick {
let link = crate::panels::gradient::dropped_link(app, &json!({ "swatch": name }));
set_stops(app, &recolor(stops.to_vec(), i, c, link), Some(i), Live::Released);
let Some((name, c)) = pick else { return };
match target {
// A global or spot colour (or a tint of one) stays linked.
Target::Stop(stops, i) => {
let link = crate::panels::gradient::dropped_link(app, &json!({ "swatch": name }));
set_stops(app, &recolor(stops.clone(), *i, c, link), Some(*i), Live::Released);
}
Target::Point(_, i) => edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": color_json(&c) })),
}
}
/// OK (agents): apply the `color`, `opacity` and `location` fields that are set, then close.
/// Apply the `color` (hex), `opacity` and `location` or `spread` (percentages) set in `f` to the
/// target as one edit; returns the stop's (or point's) index after it.
fn apply(app: &mut VectorcraftApp, target: &Target, f: &Value) -> usize {
let pct = |k: &str| f.get(k).and_then(Value::as_f64).map(|v| (v / 100.0).clamp(0.0, 1.0));
let color = f.get("color").and_then(Value::as_str).and_then(crate::panels::color::parse_hex);
match target {
Target::Stop(stops, i) => {
let (mut stops, mut i) = (stops.clone(), *i);
if let Some(c) = color {
stops = recolor(stops, i, c, None);
}
if let Some(o) = pct("opacity") {
stops[i].opacity = o as f32;
}
if let Some(l) = f.get("location").and_then(Value::as_f64) {
(stops, i) = move_stop(&stops, i, (l / 100.0) as f32);
}
set_stops(app, &stops, Some(i), Live::Released);
i
}
Target::Point(_, i) => {
let mut p = json!({ "index": i });
if let Some(c) = color {
p["color"] = color_json(&c);
}
for k in ["opacity", "spread"] {
if let Some(v) = pct(k) {
p[k] = json!(v);
}
}
edit_point(app, "paint.freeform.setPoint", p);
*i
}
}
}
/// OK (agents): apply the `color`, `opacity` and `location` (a point: `spread`) fields that are
/// set, then close.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let (mut stops, mut i) = selected(app).ok_or("no gradient stop is selected")?;
let target = selected(app).ok_or("no gradient stop or freeform point is selected")?;
if let Some(hex) = d.fields.get("color").and_then(Value::as_str) {
let c = crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?;
stops = recolor(stops, i, c, None);
crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?;
}
if d.fields.contains_key("opacity") {
stops[i].opacity = (d.f64("opacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32;
}
if d.fields.contains_key("location") {
(stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32);
}
set_stops(app, &stops, Some(i), Live::Released);
let i = apply(app, &target, &Value::Object(d.fields.clone()));
app.ui.dialog = None;
Ok(json!({ "index": i }))
}
@ -175,7 +240,10 @@ mod tests {
}
fn stops(app: &VectorcraftApp) -> Vec<GradientStop> {
selected(app).unwrap().0
match selected(app) {
Some(Target::Stop(stops, _)) => stops,
_ => panic!("no stop selected"),
}
}
/// One headless frame of the dialog layer and the shortcuts.
@ -266,4 +334,33 @@ mod tests {
frame(&mut app, vec![key(egui::Key::ArrowLeft)]);
assert!((stops(&app)[1].offset - (before - 0.01)).abs() < 1e-6);
}
#[test]
fn on_a_freeform_gradient_it_edits_the_selected_point() {
let mut app = app();
app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap();
app.run("paint.freeform.addPoint", json!({"at": [150, 150], "color": "#0000ff"})).unwrap();
let point = |app: &VectorcraftApp| match selected(app) {
Some(Target::Point(p, i)) => (p, i),
_ => panic!("no point selected"),
};
let (p, i) = point(&app);
// Double-clicking the point opens the popover beside it.
let view = app.view_info();
crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, mods: Default::default(), pressure: 1.0 }, view);
let d = app.ui.dialog.clone().expect("the popover opened");
assert_eq!((d.kind.as_str(), d.fields["index"].clone()), ("gradientStop", json!(i)));
frame(&mut app, vec![]);
app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("swatches"));
frame(&mut app, vec![]);
assert!(app.ui.dialog.is_some(), "both tabs draw");
// Agents set its colour, opacity and spread.
for (k, v) in [("color", json!("#ff0000")), ("opacity", json!(40)), ("spread", json!(25))] {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
assert_eq!(super::super::confirm(&mut app).unwrap(), json!({"index": i}));
let (p, _) = point(&app);
assert_eq!((p.color.to_hex(), p.opacity, p.spread), ("#ff0000".to_string(), 0.4, 0.25));
assert!(app.ui.dialog.is_none());
}
}

View File

@ -262,7 +262,7 @@ pub(crate) fn shown_points(g: &GradientPaint) -> std::borrow::Cow<'_, Freeform>
}
/// Run a `paint.freeform.*` command on the paint behind the active proxy.
fn edit_point(app: &mut VectorcraftApp, cmd: &str, mut params: Value) {
pub(crate) fn edit_point(app: &mut VectorcraftApp, cmd: &str, mut params: Value) {
params["stroke"] = json!(!app.session.fill_active);
live_run(app, "Gradient", cmd, params, Live::Released);
}

View File

@ -52,6 +52,10 @@ swatch, a Fill/Stroke proxy or the panel's gradient thumbnail onto art runs `pai
active proxy) with the paint's params and the object's `ids`; a colour dropped on the panel's ramp adds or recolours
a stop.
Freeform gradients: double-clicking a point with the Gradient tool opens the same `gradientStop` popover on the selected
point (`paint.freeform.selectPoint`); its fields are `color` (hex), `opacity` and `spread` (percentages), applied by
`ui.dialog.confirm` through `paint.freeform.setPoint`. `ui.key` Escape ends the line Lines mode is drawing.
Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gradient` it opens the Gradient panel;
for `eyedropper` it opens Eyedropper Options, an `eyedropperOptions` dialog (fields `sampleSize` 1/3/5, `pickUp` and
`apply`, the attribute trees of `eyedropper.setOptions`) whose `ui.dialog.confirm` runs `eyedropper.setOptions` (what

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?,
@ -170,6 +181,18 @@ selected point:
{"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":180,"y":170},{"kind":"up","x":180,"y":170}]}}
```
On the canvas the selected point also shows its spread as a dashed ring with a handle 16 px (or the spread, if larger)
to its right: drag the ring or handle to change the spread. Dragging a point out of the object removes it, and
double-clicking one opens its popover (dialog `gradientStop`, which on a freeform gradient edits the selected point:
set `color`, `opacity` and `spread` and confirm). In Lines mode successive clicks on the art draw one smooth line
through the points they add; a click on an existing point first continues the line from it, and `press_key` Escape
(or a click off the art) ends it:
```json
{"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":110,"y":190},{"kind":"up","x":110,"y":190},{"kind":"down","x":150,"y":170},{"kind":"up","x":150,"y":170}]}}
{"name":"press_key","arguments":{"key":"Escape"}}
```
Raw protocol (for debugging):
```sh