Merge pull request #32 from storytold/pr/m3-freeform-gradients

M3: freeform gradients — model, rendering, commands, tool and panel (M3.81, M3.82)
This commit is contained in:
BFlat 2026-10-05 01:29:29 +07:00 committed by GitHub
commit cc514957f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2068 additions and 45 deletions

View File

@ -0,0 +1,534 @@
//! Freeform gradients: colour points placed anywhere on an object (and smooth lines through them),
//! blended across it.
//!
//! Each point paints a disc of pure colour (its spread, a fraction of half the larger side of the
//! painted box) and blends into its neighbours beyond it by inverse-distance weighting on the
//! distance from the disc's edge. A line is a Catmull-Rom curve through its points: every spot
//! on it carries the colour, opacity and spread interpolated between the two points it lies
//! between, and acts as a source along its length (each short piece of it weighted by its share
//! of the line). Where discs overlap the deeper one wins, so the field stays continuous.
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
use serde::{Deserialize, Serialize};
use crate::{Color, Gradient, GradientStop};
/// The spread a new point gets.
pub const DEFAULT_SPREAD: f32 = 0.0;
/// Line segments are tessellated into this many pieces for sampling.
const LINE_STEPS: usize = 16;
fn one() -> f32 {
1.0
}
fn default_spread() -> f32 {
DEFAULT_SPREAD
}
/// One colour point.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FreeformPoint {
/// Position in the paint's space (document coordinates; text space for type runs).
pub at: Point,
pub color: Color,
#[serde(default = "one")]
pub opacity: f32,
/// Radius (0..=1) of the disc of pure colour around the point, as a fraction of
/// [`spread_scale`].
#[serde(default = "default_spread")]
pub spread: f32,
}
impl FreeformPoint {
pub fn new(at: Point, color: Color) -> Self {
Self { at, color, opacity: 1.0, spread: DEFAULT_SPREAD }
}
}
/// How the Gradient tool adds points: free points, or points joined into lines.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FreeformMode {
#[default]
Points,
Lines,
}
impl FreeformMode {
/// Parse a mode name (`points`, `lines`; any case).
pub fn parse(s: &str) -> Option<Self> {
[FreeformMode::Points, FreeformMode::Lines].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
FreeformMode::Points => "Points",
FreeformMode::Lines => "Lines",
}
}
fn is_points(&self) -> bool {
*self == FreeformMode::Points
}
}
/// A freeform gradient: its colour points and the lines through them.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Freeform {
pub points: Vec<FreeformPoint>,
/// Each line is a smooth curve through `points` at these indices, in order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lines: Vec<Vec<usize>>,
/// Draw: how the Gradient tool adds points.
#[serde(default, skip_serializing_if = "FreeformMode::is_points")]
pub mode: FreeformMode,
}
/// The length spreads are fractions of: half the larger side of the painted box.
pub fn spread_scale(b: Rect) -> f64 {
b.width().abs().max(b.height().abs()) / 2.0
}
/// Where a point lies nearest on a line (see [`Freeform::nearest_on_lines`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LineHit {
pub line: usize,
/// The segment from the line's point `segment` to point `segment + 1`.
pub segment: usize,
/// Curve parameter along the segment, 0..=1.
pub t: f64,
pub point: Point,
pub distance: f64,
}
impl Freeform {
/// The points the first application places on an object with box `b`: a 2 × n grid (n = 2–4
/// along the longer side), each moved to the nearest spot `inside` the shape when its grid
/// position isn't. They take colours along `g`, in a loop around the grid.
pub fn auto(b: Rect, g: &Gradient, inside: &dyn Fn(Point) -> bool) -> Self {
let (w, h) = (b.width().max(1e-9), b.height().max(1e-9));
let long = ((2.0 * w.max(h) / w.min(h)).round() as usize).clamp(2, 4);
let (cols, rows) = if w >= h { (long, 2) } else { (2, long) };
let cell = |i: usize, n: usize| (i as f64 + 0.5) / n as f64;
// Row by row, every other row backwards: neighbours in the list are neighbours on the art.
let targets: Vec<Point> = (0..rows)
.flat_map(|j| (0..cols).map(move |k| if j % 2 == 0 { (k, j) } else { (cols - 1 - k, j) }))
.map(|(i, j)| Point::new(b.x0 + cell(i, cols) * w, b.y0 + cell(j, rows) * h))
.collect();
const GRID: usize = 24;
let candidates: Vec<Point> =
(0..GRID * GRID).map(|k| Point::new(b.x0 + cell(k % GRID, GRID) * w, b.y0 + cell(k / GRID, GRID) * h)).filter(|p| inside(*p)).collect();
let apart = w.min(h) * 0.15;
let mut placed: Vec<Point> = Vec::with_capacity(targets.len());
for t in &targets {
let free = |c: &&Point| placed.iter().all(|q| q.distance(**c) >= apart);
let at = if inside(*t) {
*t
} else {
candidates.iter().filter(free).min_by(|a, b| a.distance_squared(*t).total_cmp(&b.distance_squared(*t))).copied().unwrap_or(*t)
};
placed.push(at);
}
let mut f = Self { points: placed.into_iter().map(|at| FreeformPoint::new(at, Color::BLACK)).collect(), ..Default::default() };
f.recolor(g);
f
}
/// Colour the points along `g`, first to last.
pub fn recolor(&mut self, g: &Gradient) {
let n = self.points.len();
for (k, p) in self.points.iter_mut().enumerate() {
(p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32);
}
}
/// Gradient stops standing for the points (their colours evenly spaced, at least two): what
/// swatch chips, exports without freeform shading and re-seeding on other art use.
pub fn stops(&self) -> Vec<GradientStop> {
let n = self.points.len();
let stop = |offset: f32, p: &FreeformPoint| GradientStop { offset, color: p.color, opacity: p.opacity, midpoint: 0.5 };
match n {
0 => Gradient::default().stops,
1 => vec![stop(0.0, &self.points[0]), stop(1.0, &self.points[0])],
_ => self.points.iter().enumerate().map(|(i, p)| stop(i as f32 / (n - 1) as f32, p)).collect(),
}
}
/// Map every point through `f`.
pub fn map_points(&mut self, f: impl Fn(Point) -> Point) {
for p in &mut self.points {
p.at = f(p.at);
}
}
/// Map every point through `a`.
pub fn transform(&mut self, a: Affine) {
self.map_points(|p| a * p);
}
/// The cubic segments of line `line` (a Catmull-Rom curve through its points).
pub fn segments(&self, line: usize) -> Vec<CubicBez> {
let Some(ix) = self.lines.get(line) else { return vec![] };
// A line naming a missing point (a damaged file) draws nothing.
let Some(pts) = ix.iter().map(|i| self.points.get(*i).map(|p| p.at)).collect::<Option<Vec<Point>>>() else { return vec![] };
let n = pts.len();
(0..n.saturating_sub(1))
.map(|i| {
let (p0, p1, p2, p3) = (pts[i.saturating_sub(1)], pts[i], pts[i + 1], pts[(i + 2).min(n - 1)]);
CubicBez::new(p1, p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2)
})
.collect()
}
/// Line `line` as a path (for drawing it).
pub fn line_path(&self, line: usize) -> BezPath {
let mut bp = BezPath::new();
for (i, c) in self.segments(line).into_iter().enumerate() {
if i == 0 {
bp.move_to(c.p0);
}
bp.curve_to(c.p1, c.p2, c.p3);
}
bp
}
/// The spot on a line nearest `p`.
pub fn nearest_on_lines(&self, p: Point) -> Option<LineHit> {
let mut best: Option<LineHit> = None;
for line in 0..self.lines.len() {
for (segment, c) in self.segments(line).into_iter().enumerate() {
let n = c.nearest(p, 1e-6);
let distance = n.distance_sq.sqrt();
if best.is_none_or(|b| distance < b.distance) {
best = Some(LineHit { line, segment, t: n.t, point: c.eval(n.t), distance });
}
}
}
best
}
/// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`.
pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) {
let ([r, g, b], a) = self.field(scale).sample(p);
(Color::rgb(r, g, b), a)
}
/// The colour field, with colours converted and lines tessellated once (for sampling many
/// spots).
pub fn field(&self, scale: f64) -> Field {
let src = |p: &FreeformPoint| Source { at: p.at, rgb: p.color.to_rgb(), opacity: p.opacity, radius: p.spread.clamp(0.0, 1.0) as f64 * scale };
let points: Vec<Source> = self.points.iter().map(src).collect();
let mut pieces = vec![];
for (l, ix) in self.lines.iter().enumerate() {
// The line tessellated into a polyline of sources.
let mut v: Vec<Source> = vec![];
for (s, c) in self.segments(l).into_iter().enumerate() {
let (Some(a), Some(b)) = (ix.get(s).and_then(|i| points.get(*i)), ix.get(s + 1).and_then(|i| points.get(*i))) else { continue };
for k in (if s == 0 { 0 } else { 1 })..=LINE_STEPS {
let t = k as f64 / LINE_STEPS as f64;
v.push(Source { at: c.eval(t), ..a.lerp(b, t as f32) });
}
}
let len: f64 = v.windows(2).map(|w| w[0].at.distance(w[1].at)).sum();
let n = v.len().saturating_sub(1).max(1) as f64;
pieces.extend(v.windows(2).map(|w| Piece { a: w[0], b: w[1], share: if len > 0.0 { w[0].at.distance(w[1].at) / len } else { 1.0 / n } }));
}
Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) }
}
/// Add a point; returns its index.
pub fn add_point(&mut self, p: FreeformPoint) -> usize {
self.points.push(p);
self.points.len() - 1
}
/// Join point `to` to point `from`: extend the line that ends (or starts) at `from`, else start
/// a line from it. Returns the line's index (None when an index is out of range or equal).
pub fn connect(&mut self, from: usize, to: usize) -> Option<usize> {
if from == to || from >= self.points.len() || to >= self.points.len() {
return None;
}
if let Some(l) = self.lines.iter().position(|l| l.last() == Some(&from)) {
self.lines[l].push(to);
return Some(l);
}
if let Some(l) = self.lines.iter().position(|l| l.first() == Some(&from)) {
self.lines[l].insert(0, to);
return Some(l);
}
self.lines.push(vec![from, to]);
Some(self.lines.len() - 1)
}
/// Add a line through `points` (at least two, each a valid index, none twice in a row).
pub fn add_line(&mut self, points: Vec<usize>) -> Result<usize, String> {
if points.len() < 2 {
return Err("a line needs at least two points".into());
}
if let Some(i) = points.iter().find(|i| **i >= self.points.len()) {
return Err(format!("no point {i} (the gradient has {})", self.points.len()));
}
if points.windows(2).any(|w| w[0] == w[1]) {
return Err("a line can't run from a point to itself".into());
}
self.lines.push(points);
Ok(self.lines.len() - 1)
}
/// Remove point `i` (never the last one). Lines through it close up around it; lines left
/// with fewer than two points go.
pub fn remove_point(&mut self, i: usize) -> bool {
if i >= self.points.len() || self.points.len() <= 1 {
return false;
}
self.points.remove(i);
for l in &mut self.lines {
l.retain(|j| *j != i);
for j in l.iter_mut() {
if *j > i {
*j -= 1;
}
}
l.dedup();
}
self.lines.retain(|l| l.len() >= 2);
true
}
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour,
/// opacity and spread between the segment's ends. Returns the new point's index.
pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option<usize> {
let c = *self.segments(line).get(segment)?;
let l = &self.lines[line];
let (a, b) = (self.points[l[segment]], self.points[l[segment + 1]]);
let t = t.clamp(0.0, 1.0);
let u = t as f32;
let p = FreeformPoint {
at: c.eval(t),
color: a.color.lerp(&b.color, u),
opacity: a.opacity + (b.opacity - a.opacity) * u,
spread: a.spread + (b.spread - a.spread) * u,
};
let i = self.add_point(p);
self.lines[line].insert(segment + 1, i);
Some(i)
}
}
/// One colour source of a [`Field`].
#[derive(Clone, Copy, Debug)]
struct Source {
at: Point,
rgb: [f32; 3],
opacity: f32,
radius: f64,
}
impl Source {
fn lerp(&self, o: &Source, t: f32) -> Source {
let l = |a: f32, b: f32| a + (b - a) * t;
Source {
at: self.at.lerp(o.at, t as f64),
rgb: [l(self.rgb[0], o.rgb[0]), l(self.rgb[1], o.rgb[1]), l(self.rgb[2], o.rgb[2])],
opacity: l(self.opacity, o.opacity),
radius: self.radius + (o.radius - self.radius) * t as f64,
}
}
}
/// A freeform gradient ready to sample ([`Freeform::field`]).
#[derive(Clone, Debug)]
pub struct Field {
points: Vec<Source>,
/// The lines tessellated into short pieces.
pieces: Vec<Piece>,
eps: f64,
}
/// A piece of a tessellated line: a source at its spot nearest the sample, weighted by `share`
/// (its fraction of the line's length), so a whole line weighs as much as a point from afar and
/// the field stays continuous where the nearest spot on the line jumps.
#[derive(Clone, Copy, Debug)]
struct Piece {
a: Source,
b: Source,
share: f64,
}
impl Piece {
/// The distance from `p` and the source at the nearest spot.
fn nearest(&self, p: Point) -> (f64, Source) {
let v = self.b.at - self.a.at;
let l2 = v.hypot2();
let t = if l2 > 0.0 { ((p - self.a.at).dot(v) / l2).clamp(0.0, 1.0) } else { 0.0 };
let s = self.a.lerp(&self.b, t as f32);
(s.at.distance(p), s)
}
}
impl Field {
/// Display RGB and opacity at `p`.
pub fn sample(&self, p: Point) -> ([f32; 3], f32) {
// (weight sum, weighted rgba) inside discs, and blending by distance outside them.
let mut disc = (0.0f64, [0.0f64; 4]);
let mut far = (0.0f64, [0.0f64; 4]);
let mut add = |d: f64, s: &Source, share: f64| {
let (acc, w) = if d < s.radius || d <= self.eps {
// The deeper inside, the stronger: weights fade to nothing at the disc's edge.
(&mut disc, (s.radius - d + self.eps) * share)
} else {
let e = d - s.radius;
(&mut far, share / (e * e))
};
acc.0 += w;
for (k, v) in s.rgb.iter().chain(std::iter::once(&s.opacity)).enumerate() {
acc.1[k] += w * *v as f64;
}
};
for s in &self.points {
add(s.at.distance(p), s, 1.0);
}
for piece in &self.pieces {
let (d, s) = piece.nearest(p);
add(d, &s, piece.share);
}
let (w, acc) = if disc.0 > 0.0 { disc } else { far };
if !(w > 0.0 && w.is_finite()) {
return ([0.0; 3], 0.0);
}
let c = |k: usize| (acc[k] / w) as f32;
([c(0), c(1), c(2)], c(3))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn red() -> Color {
Color::rgb(1.0, 0.0, 0.0)
}
fn blue() -> Color {
Color::rgb(0.0, 0.0, 1.0)
}
fn two(spread: f32) -> Freeform {
Freeform {
points: vec![
FreeformPoint { spread, ..FreeformPoint::new(Point::new(0.0, 0.0), red()) },
FreeformPoint { spread, opacity: 0.5, ..FreeformPoint::new(Point::new(100.0, 0.0), blue()) },
],
..Default::default()
}
}
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
}
#[test]
fn the_sample_at_a_point_is_its_colour() {
let f = two(0.0);
let (c, o) = f.sample(Point::new(0.0, 0.0), 50.0);
assert!(close(c.to_rgb(), [1.0, 0.0, 0.0]) && (o - 1.0).abs() < 1e-6, "{c:?} {o}");
let (c, o) = f.sample(Point::new(100.0, 0.0), 50.0);
assert!(close(c.to_rgb(), [0.0, 0.0, 1.0]) && (o - 0.5).abs() < 1e-6);
// Half way between equal points: an even mix.
let (c, o) = f.sample(Point::new(50.0, 0.0), 50.0);
assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}");
}
#[test]
fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() {
// Scale 100: a 0.2 spread is a 20 pt disc.
let mut f = two(0.0);
f.points[0].spread = 0.2;
let at = |x: f64| f.sample(Point::new(x, 0.0), 100.0).0.to_rgb()[0];
assert!((at(15.0) - 1.0).abs() < 1e-6, "inside the disc: pure red");
assert!(at(30.0) < 1.0 && at(30.0) > at(50.0) && at(50.0) > at(70.0), "falls off: {} {} {}", at(30.0), at(50.0), at(70.0));
// Without the spread the same spot is bluer.
let plain = two(0.0).sample(Point::new(30.0, 0.0), 100.0).0.to_rgb()[0];
assert!(at(30.0) > plain);
// Overlapping discs blend continuously (no jump at the edge of the smaller one).
f.points[1].spread = 0.9;
let a = f.sample(Point::new(10.01, 0.0), 100.0).0.to_rgb()[0];
let b = f.sample(Point::new(9.99, 0.0), 100.0).0.to_rgb()[0];
assert!((a - b).abs() < 0.01, "{a} vs {b}");
}
#[test]
fn lines_carry_colour_between_their_points() {
let mut f = two(0.0);
f.points.push(FreeformPoint::new(Point::new(50.0, 100.0), Color::rgb(0.0, 1.0, 0.0)));
let off_line = f.sample(Point::new(50.0, 5.0), 100.0).0.to_rgb();
f.add_line(vec![0, 1]).unwrap();
let on_line = f.sample(Point::new(50.0, 5.0), 100.0).0.to_rgb();
// Near the line's middle the red-blue mix dominates the green point.
assert!(on_line[1] < off_line[1], "{on_line:?} vs {off_line:?}");
let hit = f.nearest_on_lines(Point::new(50.0, 7.0)).unwrap();
assert_eq!((hit.line, hit.segment), (0, 0));
assert!((hit.t - 0.5).abs() < 1e-3 && (hit.distance - 7.0).abs() < 1e-3, "{hit:?}");
}
#[test]
fn the_field_is_continuous_around_lines() {
// A bent line: where the nearest spot on it jumps the colour must not.
let mut f = two(0.0);
f.points.push(FreeformPoint::new(Point::new(50.0, 100.0), Color::rgb(0.0, 1.0, 0.0)));
f.points.push(FreeformPoint { spread: 0.1, ..FreeformPoint::new(Point::new(-50.0, 80.0), Color::WHITE) });
f.add_line(vec![0, 1, 2]).unwrap();
let field = f.field(100.0);
let at = |x: f64, y: f64| field.sample(Point::new(x, y)).0;
for i in 0..80 {
for j in 0..80 {
let (x, y) = (-60.0 + i as f64 * 2.5, -60.0 + j as f64 * 2.5);
let (a, b, c) = (at(x, y), at(x + 0.5, y), at(x, y + 0.5));
let step = (0..3).map(|k| (a[k] - b[k]).abs().max((a[k] - c[k]).abs())).fold(0.0, f32::max);
assert!(step < 0.06, "jump of {step} at ({x}, {y})");
}
}
}
#[test]
fn auto_places_four_or_more_points_inside_the_shape() {
let g = Gradient::default();
let b = Rect::new(0.0, 0.0, 200.0, 100.0);
// A triangle with its apex at the top middle.
let inside = |p: Point| p.y >= 0.0 && p.y <= 100.0 && (p.x - 100.0).abs() <= p.y;
let f = Freeform::auto(b, &g, &inside);
assert!(f.points.len() >= 4, "{}", f.points.len());
assert!(f.points.iter().all(|p| inside(p.at)), "{:?}", f.points);
// Colours run from the gradient's start to its end.
assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ffffff".into(), "#000000".into()));
// A long box gets more points along its length.
assert!(Freeform::auto(Rect::new(0.0, 0.0, 400.0, 100.0), &g, &|_| true).points.len() > 4);
}
#[test]
fn editing_keeps_lines_consistent() {
let mut f = two(0.0);
let c = f.add_point(FreeformPoint::new(Point::new(200.0, 0.0), red()));
assert_eq!(f.connect(0, 1), Some(0));
assert_eq!(f.connect(1, c), Some(0), "extends the line ending at 1");
assert_eq!(f.lines, vec![vec![0, 1, 2]]);
let mid = f.split_line(0, 0, 0.5).unwrap();
assert_eq!((mid, &f.lines[0]), (3, &vec![0, 3, 1, 2]));
let p = f.points[3];
assert!(p.at.y.abs() < 1e-9 && p.at.x > 0.0 && p.at.x < 100.0 && (p.opacity - 0.75).abs() < 1e-6, "on the curve, half way: {p:?}");
assert!(f.remove_point(1));
assert_eq!(f.lines, vec![vec![0, 2, 1]]);
assert!(f.add_line(vec![0, 9]).is_err() && f.add_line(vec![0]).is_err());
f.remove_point(0);
f.remove_point(0);
assert!(f.lines.is_empty() && f.points.len() == 1);
assert!(!f.remove_point(0), "the last point stays");
assert_eq!(f.stops().len(), 2);
}
#[test]
fn serde_defaults_and_mode() {
let f: Freeform = serde_json::from_str(r#"{"points":[{"at":{"x":1.0,"y":2.0},"color":{"model":"rgb","r":1.0,"g":0.0,"b":0.0}}]}"#).unwrap();
assert_eq!((f.points[0].opacity, f.points[0].spread, f.mode), (1.0, DEFAULT_SPREAD, FreeformMode::Points));
let s = serde_json::to_string(&f).unwrap();
assert!(!s.contains("\"lines\"") && !s.contains("\"mode\""), "{s}");
assert_eq!(FreeformMode::parse("LINES"), Some(FreeformMode::Lines));
}
}

View File

@ -1,16 +1,19 @@
//! Gradients: definitions (swatch-able) and their placement on an object.
use std::borrow::Cow;
use kurbo::{Affine, Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
use crate::Color;
use crate::freeform::Freeform;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientKind {
#[default]
Linear,
Radial,
/// Freeform gradients (points/lines) — rendered via a mesh approximation.
/// Freeform gradients: colour points and lines (see [`crate::freeform`]).
Freeform,
}
@ -284,12 +287,8 @@ impl GradientGeom {
/// relative to the box (along a side `from` has no extent on, their offset from its centre).
/// The aspect ratio is kept.
pub fn rebase(&mut self, from: Rect, to: Rect) {
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| {
if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 }
};
let map = |p: Point| Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1));
self.start = map(self.start);
self.end = map(self.end);
self.start = rebase_point(self.start, from, to);
self.end = rebase_point(self.end, from, to);
}
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
@ -316,6 +315,15 @@ impl GradientGeom {
}
}
/// `p` moved from box `from` to box `to`, keeping its position relative to the box (along a side
/// `from` has no extent on, its offset from the centre).
fn rebase_point(p: Point, from: Rect, to: Rect) -> Point {
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| {
if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 }
};
Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1))
}
/// A gradient applied to a fill or stroke.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientPaint {
@ -328,33 +336,79 @@ pub struct GradientPaint {
/// Name of the gradient swatch, if linked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swatch: Option<String>,
/// A freeform gradient's points, in the same space as `geom` (None: placed automatically on
/// the object's box each render, coloured along the stops).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freeform: Option<Freeform>,
}
impl GradientPaint {
pub fn new(gradient: Gradient) -> Self {
Self { gradient, geom: None, angle: 0.0, swatch: None }
Self { gradient, geom: None, angle: 0.0, swatch: None, freeform: None }
}
/// The freeform gradient drawn on box `bounds`: its own points, else the automatic ones
/// (fitted to the box: [`Freeform::auto`] without a shape to keep them inside).
pub fn freeform_on(&self, bounds: Rect) -> Cow<'_, Freeform> {
match &self.freeform {
Some(f) if !f.points.is_empty() => Cow::Borrowed(f),
f => Cow::Owned(Freeform {
mode: f.as_ref().map_or_else(Default::default, |f| f.mode),
..Freeform::auto(bounds, &self.gradient, &|_| true)
}),
}
}
/// Does it carry its own freeform points (rather than placing them on each render)?
pub fn has_freeform_points(&self) -> bool {
self.freeform.as_ref().is_some_and(|f| !f.points.is_empty())
}
/// Give a freeform gradient without points its automatic ones on `bounds`, kept `inside` the
/// shape (the Draw mode stays).
pub fn seed_freeform(&mut self, bounds: Rect, inside: &dyn Fn(Point) -> bool) {
if self.gradient.kind == GradientKind::Freeform && !self.has_freeform_points() {
let mode = self.freeform.as_ref().map_or_else(Default::default, |f| f.mode);
self.set_freeform(Freeform { mode, ..Freeform::auto(bounds, &self.gradient, inside) });
}
}
/// Set the freeform points, with the stops following their colours (what swatch chips,
/// exports without freeform shading and re-seeding on other art show).
pub fn set_freeform(&mut self, f: Freeform) {
self.gradient.stops = f.stops();
self.freeform = Some(f);
}
pub fn resolve(&self, bounds: Rect) -> GradientGeom {
self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle))
}
/// Is the gradient placed in its paint's space (its vector, and a freeform gradient's points),
/// rather than fitted to the object's box on each render?
pub fn is_placed(&self) -> bool {
self.geom.is_some() && (self.gradient.kind != GradientKind::Freeform || self.has_freeform_points())
}
/// Fix an unplaced gradient (geom None) to its fit on `bounds`, so it can follow transforms
/// that refitting wouldn't reproduce (rotation, shear, non-uniform scale, warps).
/// Freeform gradients get their automatic points.
pub fn pin(&mut self, bounds: Rect) {
if self.geom.is_none() {
self.geom = Some(self.resolve(bounds));
}
self.seed_freeform(bounds, &|_| true);
}
/// Move a placed gradient from box `from` to box `to` (see [`GradientGeom::rebase`]).
pub fn rebase(&mut self, from: Rect, to: Rect) {
if let Some(g) = &mut self.geom {
g.rebase(from, to);
}
if let Some(f) = &mut self.freeform {
f.map_points(|p| rebase_point(p, from, to));
}
}
/// Map a placed gradient through `a` (see [`GradientGeom::transform`]).
/// Map a placed gradient (and freeform points) through `a` (see [`GradientGeom::transform`]).
pub fn transform(&mut self, a: Affine) {
if let Some(g) = &mut self.geom {
g.transform(a, self.gradient.kind);
}
if let Some(f) = &mut self.freeform {
f.transform(a);
}
}
}

View File

@ -6,6 +6,7 @@
pub mod blend;
pub mod cms;
pub mod freeform;
pub mod gradient;
pub mod harmony;
pub mod libraries;
@ -13,6 +14,7 @@ pub mod palette_io;
pub mod swatch;
pub use blend::BlendMode;
pub use freeform::{Freeform, FreeformMode, FreeformPoint};
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
pub use libraries::SwatchLibrary;
pub use swatch::{Swatch, SwatchGroup, default_swatches};

View File

@ -590,6 +590,12 @@ impl Appearance {
{
*ga = *go;
}
if let (Some(fa), Some(fo)) = (&mut g.freeform, &o.freeform)
&& fa.points.len() == fo.points.len()
&& fa.points.iter().zip(&fo.points).all(|(p, q)| p.at.distance(q.at) <= 1e-6)
{
fa.points.iter_mut().zip(&fo.points).for_each(|(p, q)| p.at = q.at);
}
}
a == *other
}
@ -598,7 +604,7 @@ impl Appearance {
fn has_gradient(&self, placed: bool) -> bool {
self.items.iter().any(|i| match i {
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_some() == placed,
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.is_placed() == placed,
_ => false,
})
}
@ -650,13 +656,19 @@ impl Appearance {
}
}
/// Map placed gradients through a warp, given its local affine approximation at a point (taken
/// at each gradient's centre: the start of a radial, the middle of a linear vector).
/// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform
/// points each follow the warp at their own position.
pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) {
for g in self.gradients_mut() {
let freeform = g.freeform.take();
if let Some(geom) = g.geom {
let c = if g.gradient.kind == vectorcraft_color::GradientKind::Radial { geom.start } else { geom.start.midpoint(geom.end) };
g.transform(near(c));
}
g.freeform = freeform.map(|mut f| {
f.map_points(|p| near(p) * p);
f
});
}
}
}

View File

@ -592,6 +592,23 @@ impl Node {
}
Some((g, geom))
}
/// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates
/// (the automatic ones while unplaced), and the length its spreads are fractions of there
/// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits.
pub fn proxy_freeform(&self, stroke: bool, item: Option<usize>) -> Option<(vectorcraft_color::Freeform, f64)> {
let (paint, to_doc, b) = self.proxy_paint(stroke, item)?;
let vectorcraft_color::Paint::Gradient(g) = paint else { return None };
if g.gradient.kind != vectorcraft_color::GradientKind::Freeform {
return None;
}
let mut f = g.freeform_on(b).into_owned();
let mut scale = vectorcraft_color::freeform::spread_scale(b);
if to_doc != Affine::IDENTITY {
f.transform(to_doc);
scale *= to_doc.determinant().abs().sqrt();
}
Some((f, scale))
}
}
/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans

View File

@ -0,0 +1,332 @@
//! Freeform gradients: the points and lines of the freeform gradient behind the active proxy (the
//! Gradient panel's freeform section, the Color panel and the Gradient tool edit them through
//! these). Points are given in document coordinates; type runs keep them in text space.
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_geom::{Affine, Rect};
use super::appearance::{edit_items, edits_stroke, index_param, item_target};
use super::gradient::{StopOwner, item_paint_bounds, run_paint_mut};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"paint.freeform.addPoint",
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), opacity? 0..1 (or 0..100), spread? 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
has_doc,
add_point
),
cmd!(
"paint.freeform.setPoint",
"Edit Freeform Point",
[],
None,
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, opacity? 0..1 (or 0..100), spread? 0..1 (or 0..100 %), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} move or recolour a point of the freeform gradient → {index}",
has_doc,
set_point
),
cmd!(
"paint.freeform.deletePoint",
"Delete Freeform Point",
[],
None,
"{index? (default: the selected point), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
has_doc,
delete_point
),
cmd!(
"paint.freeform.addLine",
"Add Freeform Line",
[],
None,
"{points: [index, …] (at least two), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} join points with a smooth line through them, in order → {line}",
has_doc,
add_line
),
cmd!(
"paint.freeform.splitLine",
"Split Freeform Line",
[],
None,
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
has_doc,
split_line
),
cmd!(
"paint.freeform.selectPoint",
"Select Freeform Point",
[],
None,
"{index: point index | null to clear} select a point of the freeform gradient behind the active proxy (the first selected object's): the point the Gradient tool, the Gradient and Color panels and Delete act on → {index}",
has_doc,
select_point
),
cmd!(
query "paint.freeform.get",
"Freeform Gradient",
[],
None,
"{stroke?: bool (default: the active proxy)} the freeform gradient behind the proxy of the first selected object → {mode: points|lines, points: [{at: [x,y] (document coordinates), color, opacity, spread}], lines: [[index, …]], selected: index|null}",
has_doc,
get
),
]
}
const NOT_FREEFORM: &str = "the paint is not a freeform gradient (apply one with paint.editGradient {kind: \"freeform\"})";
impl Session {
/// The selected freeform point (`paint.freeform.selectPoint`), while the gradient it was
/// selected on is still the one behind the active proxy. Callers check it against the point
/// count (an undo can remove points).
pub fn selected_freeform_point(&self) -> Option<usize> {
self.freeform_point.filter(|(_, owner)| *owner == StopOwner::of(self)).map(|(i, _)| i)
}
/// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in
/// document coordinates, and the length its spreads are fractions of there (see
/// [`Node::proxy_freeform`]).
pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> {
let (n, item) = self.proxy_source(stroke)?;
n.proxy_freeform(stroke, item)
}
fn select_freeform_point(&mut self, i: Option<usize>) {
self.freeform_point = i.map(|i| (i, StopOwner::of(self)));
}
}
/// Where an edited paint lives: the map from the document to its space and the length its spreads
/// are fractions of.
#[derive(Clone, Copy)]
struct Space {
to_paint: Affine,
scale: f64,
}
/// The `opacity` / `spread` convention: 0..1, or a percentage above 1.
fn fraction(v: &Value, key: &str) -> std::result::Result<f32, String> {
let x = v.as_f64().ok_or_else(|| format!("`{key}` must be a number"))?;
Ok((if x > 1.0 { x / 100.0 } else { x }).clamp(0.0, 1.0) as f32)
}
/// Apply the `at` (mapped by `to_paint`), `color`, `opacity` and `spread` fields of `v` to `pt`.
fn point_fields(v: &Value, pt: &mut FreeformPoint, to_paint: Affine) -> std::result::Result<(), String> {
if v.get("at").is_some() {
pt.at = to_paint * point_param(v, "at").ok_or("`at` must be [x, y]")?;
}
if let Some(c) = v.get("color") {
pt.color = color_value(c).ok_or("`color` must be a colour")?;
}
if let Some(o) = v.get("opacity") {
pt.opacity = fraction(o, "opacity")?;
}
if let Some(s) = v.get("spread") {
pt.spread = fraction(s, "spread")?;
}
Ok(())
}
/// Parse the `freeform` param of a gradient paint (`{points: [{at, color, opacity?, spread?}],
/// lines?: [[index, …]], mode?}`; points in the paint's space). Lossless for
/// `vectorcraft_tools::params::freeform_json`.
pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String> {
let points = v.get("points").and_then(Value::as_array).ok_or("`freeform.points` must be an array")?;
let mut f = Freeform::default();
for (i, pv) in points.iter().enumerate() {
let (Some(at), Some(color)) = (point_param(pv, "at"), pv.get("color").and_then(color_value)) else {
return Err(format!("freeform point {i} needs `at` [x, y] and a valid `color`"));
};
let mut pt = FreeformPoint::new(at, color);
point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?;
f.points.push(pt);
}
for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() {
let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default();
f.add_line(ix)?;
}
if let Some(m) = str_param(v, "mode") {
f.mode = parse_mode(m)?;
}
Ok(f)
}
pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)"))
}
/// Run `f` on the freeform gradient behind the proxy of each target (the selection, or `ids`),
/// as one undo step; returns what it returned for the first. An unplaced gradient gets the
/// automatic points it shows first, and the stops follow the points' colours.
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
let item = item_target(s, p, cmd)?;
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "select an object with a freeform gradient"));
}
let mut first = None;
let mut apply = |paint: &mut Paint, b: Rect, to_doc: Affine| -> Result<()> {
let Paint::Gradient(g) = paint else { return Err(bad(cmd, NOT_FREEFORM)) };
if g.gradient.kind != GradientKind::Freeform {
return Err(bad(cmd, NOT_FREEFORM));
}
let to_paint = if to_doc == Affine::IDENTITY { to_doc } else { to_doc.inverse() };
let mut ff = g.freeform_on(b).into_owned();
let r = f(&mut ff, Space { to_paint, scale: spread_scale(b) }).map_err(|e| bad(cmd, e))?;
g.set_freeform(ff);
g.swatch = None;
first.get_or_insert(r);
Ok(())
};
edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| {
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
{
let (xf, lb) = (t.xf, t.local_bounds());
if xf.determinant().abs() < 1e-12 {
return Err(bad(cmd, "the type object is flattened to nothing"));
}
for r in &mut t.runs {
let (paint, b) = run_paint_mut(r, stroke, lb);
apply(paint, b, xf)?;
}
return Ok(());
}
let b = item_paint_bounds(n, index, !stroke).ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
let paint =
if stroke { n.appearance.stroke_at_mut(index).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(index).map(|l| &mut l.paint) };
apply(paint.ok_or_else(|| bad(cmd, NOT_FREEFORM))?, b, Affine::IDENTITY)
})?;
let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
// The edited gradient is the last one used (`paint.lastGradient`).
let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()));
if let Some(p) = shown {
s.remember_paint(&p);
}
Ok(r)
}
/// The `index` param, else the selected point.
fn point_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
match p.get("index") {
Some(v) => v.as_u64().map(|i| i as usize).ok_or_else(|| bad(cmd, "`index` must be a point index")),
None => s.selected_freeform_point().ok_or_else(|| bad(cmd, "no point selected (give `index`)")),
}
}
fn check_index(f: &Freeform, i: usize) -> std::result::Result<(), String> {
if i < f.points.len() { Ok(()) } else { Err(format!("no point {i} (the gradient has {})", f.points.len())) }
}
fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.addPoint";
let at = point_param(p, "at").ok_or_else(|| bad(C, "missing `at` [x, y]"))?;
let join = match p.get("line") {
None | Some(Value::Bool(false)) => None,
Some(Value::Bool(true)) => s.selected_freeform_point(),
Some(_) => return Err(bad(C, "`line` must be true or false")),
};
let index = edit(s, p, C, |f, sp| {
let q = sp.to_paint * at;
let (color, opacity) = f.sample(q, sp.scale);
let mut pt = FreeformPoint { opacity, ..FreeformPoint::new(q, color) };
point_fields(p, &mut pt, sp.to_paint)?;
let i = f.add_point(pt);
if let Some(from) = join {
f.connect(from, i);
}
Ok(i)
})?;
s.select_freeform_point(Some(index));
Ok(json!({ "index": index }))
}
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.setPoint";
let i = point_index(s, p, C)?;
edit(s, p, C, |f, sp| {
check_index(f, i)?;
point_fields(p, &mut f.points[i], sp.to_paint)
})?;
Ok(json!({ "index": i }))
}
fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.deletePoint";
let i = point_index(s, p, C)?;
edit(s, p, C, |f, _| {
check_index(f, i)?;
if f.remove_point(i) { Ok(()) } else { Err("a freeform gradient keeps at least one point".into()) }
})?;
// The points after it moved down one.
let sel = s.selected_freeform_point().and_then(|j| match j.cmp(&i) {
std::cmp::Ordering::Less => Some(j),
std::cmp::Ordering::Equal => None,
std::cmp::Ordering::Greater => Some(j - 1),
});
s.select_freeform_point(sel);
Ok(json!({ "index": i }))
}
fn add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.addLine";
let points: Vec<usize> = p
.get("points")
.and_then(Value::as_array)
.and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect())
.ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?;
Ok(json!({ "line": line }))
}
fn split_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.splitLine";
let line = index_param(p, "line", C)?;
let segment = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5);
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
s.select_freeform_point(Some(index));
Ok(json!({ "index": index }))
}
fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.selectPoint";
let index = match p.get("index") {
None => return Err(bad(C, "missing `index` (a point index, or null to clear)")),
Some(Value::Null) => None,
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
let (f, _) = s.proxy_freeform(!s.fill_active).ok_or_else(|| bad(C, NOT_FREEFORM))?;
check_index(&f, i).map_err(|e| bad(C, e))?;
Some(i)
}
};
s.select_freeform_point(index);
Ok(json!({ "index": index }))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.get";
let stroke = bool_or(p, "stroke", !s.fill_active);
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
let mut v = vectorcraft_tools::params::freeform_json(&f);
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

@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
@ -82,7 +82,7 @@ pub(crate) struct StopOwner {
}
impl StopOwner {
fn of(s: &Session) -> Self {
pub(crate) fn of(s: &Session) -> Self {
Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() }
}
}
@ -153,6 +153,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
(None, None) => {}
_ => return Err("give both `start` and `end` (or neither)".into()),
}
if let Some(f) = g.get("freeform") {
gp.freeform = Some(super::freeform::parse_freeform(f)?);
}
Ok(gp)
}
@ -164,10 +167,13 @@ fn aspect_param(p: &Value) -> Parsed<Option<f64>> {
}
/// `paint` without the placement it had on the art it came from (a swatch, or the default paint
/// for new art): a placed gradient keeps its angle and fits each object it lands on.
/// for new art): a placed gradient keeps its angle and fits each object it lands on (freeform
/// points are placed afresh, coloured along the stops, which follow the points' colours).
pub(crate) fn unplaced(paint: &Paint) -> Paint {
match paint {
Paint::Gradient(g) if g.geom.is_some() => Paint::Gradient(Box::new(GradientPaint { geom: None, ..(**g).clone() })),
Paint::Gradient(g) if g.geom.is_some() || g.freeform.is_some() => {
Paint::Gradient(Box::new(GradientPaint { geom: None, freeform: None, ..(**g).clone() }))
}
_ => paint.clone(),
}
}
@ -184,6 +190,8 @@ pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option<Rect>) -> Pai
let Paint::Gradient(g) = paint else { return paint.clone() };
let aspect = match g.geom {
Some(geom) if applies_swatch(p) => (geom.aspect != 1.0).then(|| json!(geom.aspect * 100.0)),
// Freeform points given with the paint stay where they were put.
_ if g.freeform.is_some() && !applies_swatch(p) => return paint.clone(),
None => p.get("gradient").and_then(|g| g.get("aspect")).cloned(),
Some(_) => return paint.clone(),
};
@ -196,17 +204,32 @@ pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option<Rect>) -> Pai
/// Apply the gradient edits in `p` to `paint` (pure; unit-tested).
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<Rect>) -> std::result::Result<Paint, String> {
apply_gradient_edit_in(paint, p, bounds, &|_| true)
}
/// [`apply_gradient_edit`] on a shape that contains the points `inside` does: a gradient turned
/// freeform gets its first points there.
pub(crate) fn apply_gradient_edit_in(
paint: &Paint,
p: &Value,
bounds: Option<Rect>,
inside: &dyn Fn(Point) -> bool,
) -> std::result::Result<Paint, String> {
let mut gp = match paint {
Paint::Gradient(g) => (**g).clone(),
_ => GradientPaint::new(Gradient::default()),
};
let mut switched = false;
if let Some(k) = str_param(p, "kind") {
let kind = parse_kind(k)?;
if kind != gp.gradient.kind {
gp.gradient.kind = kind;
gp.geom = None;
gp.freeform = None;
switched = true;
}
}
let recolor = p.get("stops").is_some() || bool_or(p, "reverse", false);
if let Some(stops) = p.get("stops") {
gp.gradient.stops = parse_stops(stops)?;
gp.swatch = None;
@ -246,6 +269,27 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<Rect>
g.aspect = asp;
}
}
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.
if let Some(b) = bounds {
gp.seed_freeform(b, if switched { inside } else { &|_| true });
}
if recolor
&& !switched
&& let Some(f) = &mut gp.freeform
{
f.recolor(&gp.gradient);
gp.gradient.stops = f.stops();
}
}
if let Some(m) = str_param(p, "mode") {
let mode = super::freeform::parse_mode(m)?;
if gp.gradient.kind != GradientKind::Freeform {
return Err("`mode` applies to freeform gradients".into());
}
gp.freeform.get_or_insert_with(Default::default).mode = mode;
}
Ok(Paint::Gradient(Box::new(gp)))
}
@ -258,6 +302,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
// Validate against the defaults first so bad params fail without touching the document.
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
@ -270,7 +315,13 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(());
}
let b = item_paint_bounds(n, index, !stroke);
let np = apply_gradient_edit(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b).map_err(|e| bad(C, e))?;
// 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: &dyn Fn(Point) -> bool = match &inside {
Some(f) => f,
None => &|_| true,
};
let np = apply_gradient_edit_in(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b, inside).map_err(|e| bad(C, e))?;
n.appearance.set_paint_at(index, !stroke, np);
Ok(())
})?;
@ -321,7 +372,7 @@ pub(crate) fn item_paint_bounds(n: &Node, index: Option<usize>, fill: bool) -> O
pub(crate) fn place_run_paint(paint: &Paint, p: &Value, xf: Affine, bounds: Rect) -> Paint {
let mut out = place_paint(paint, p, Some(bounds));
if let (Paint::Gradient(src), Paint::Gradient(g)) = (paint, &mut out)
&& src.geom.is_some()
&& (src.geom.is_some() || src.freeform.is_some())
&& !applies_swatch(p)
&& let Some(inv) = invert(xf)
{

View File

@ -15,6 +15,7 @@ mod edit;
mod effectcmd;
pub mod fileio;
mod fonts;
pub(crate) mod freeform;
pub(crate) mod gradient;
pub(crate) mod graph;
pub mod help;
@ -196,6 +197,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(paint::proxy_specs());
v.extend(overprint::specs());
v.extend(swatchlib::specs());
v.extend(freeform::specs());
v
})
}

View File

@ -1,7 +1,7 @@
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind};
use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target};
@ -102,7 +102,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"{stroke?: bool (default: the active proxy), ids?} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default",
has_doc,
|s, p| {
let paint = Paint::Gradient(Box::new(GradientPaint { geom: None, ..s.last_gradient.clone() }));
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
apply_to_proxy(s, p, paint)
}
),
@ -153,20 +153,28 @@ impl Session {
/// object's (a group's first painted object's; type: its first run's), else the defaults for
/// new art.
pub fn proxy_paints(&self) -> (Paint, Paint) {
let default = |stroke: bool| if stroke { self.paint.stroke.clone() } else { self.paint.fill.clone() };
let Some(first) = self.active().and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) else {
return (default(false), default(true));
};
let item = self.appearance_item();
let mut nodes = vec![];
painted(first, true, &mut nodes);
let show = |stroke: bool| match first.appearance.item_of_kind(item, !stroke) {
Some(_) => proxy_paint(first, stroke, item),
None => nodes.first().map_or_else(|| default(stroke), |n| proxy_paint(n, stroke, None)),
let show = |stroke: bool| match self.proxy_source(stroke) {
Some((n, item)) => proxy_paint(n, stroke, item),
None if stroke => self.paint.stroke.clone(),
None => self.paint.fill.clone(),
};
(show(false), show(true))
}
/// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item
/// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art.
pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option<usize>)> {
let st = self.active()?;
let first = st.doc.node(*st.selection.objects.first()?)?;
let item = self.appearance_item();
if first.appearance.item_of_kind(item, !stroke).is_some() {
return Some((first, item));
}
let mut nodes = vec![];
painted(first, true, &mut nodes);
nodes.first().map(|n| (*n, None))
}
pub(crate) fn remember_paint_now(&mut self, p: &Paint) {
match p {
Paint::Solid { color, .. } => {

View File

@ -464,6 +464,9 @@ pub struct Session {
pub eyedropper: EyedropperOptions,
/// User Defined and loaded swatch libraries (Window → Swatch Libraries); not saved.
pub swatch_libraries: cmd::swatchlib::Libraries,
/// The selected freeform gradient point (`paint.freeform.selectPoint`) and whose gradient it
/// was selected on: read it with [`Session::selected_freeform_point`].
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
}
impl Default for Session {
@ -497,6 +500,7 @@ impl Session {
pending_paint: None,
eyedropper: Default::default(),
swatch_libraries: Default::default(),
freeform_point: None,
}
}
@ -771,6 +775,8 @@ mod tests_effectedit;
#[cfg(test)]
mod tests_file;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_gradient;
#[cfg(test)]
mod tests_gradpanel;

View File

@ -0,0 +1,240 @@
//! Freeform gradients through commands: the first application (M3.81), transforms and warps, and
//! the point and line commands (M3.82).
use serde_json::{Value, json};
use vectorcraft_color::{Color, Freeform, GradientKind, GradientPaint, Paint};
use vectorcraft_geom::Point;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn shape(s: &mut Session, cmd: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute(cmd, &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> &vectorcraft_doc::Node {
s.doc().unwrap().doc.node(id).unwrap()
}
fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint {
match node(s, id).appearance.fill_paint() {
Paint::Gradient(g) => *g,
p => panic!("expected a gradient fill, got {p:?}"),
}
}
fn points(s: &Session, id: NodeId) -> Freeform {
fill_gradient(s, id).freeform.expect("placed freeform points")
}
/// A selected 100 × 100 rectangle at (100, 100) with a freeform fill.
fn freeform_rect() -> (Session, NodeId) {
let mut s = session();
let id = shape(&mut s, "shape.rectangle", 100.0, 100.0, 100.0, 100.0);
s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap();
(s, id)
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn close(a: Point, b: Point) -> bool {
a.distance(b) < 1e-6
}
// ---------- M3.81: the first application, transforms, warps and saving ----------
#[test]
fn the_first_application_places_points_inside_the_shape() {
let mut s = session();
// An ellipse: the box's corners are outside it.
let id = shape(&mut s, "shape.ellipse", 100.0, 100.0, 300.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap();
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));
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<_>>());
// Draw mode: freeform only.
s.execute("paint.editGradient", &json!({"mode": "lines"})).unwrap();
assert_eq!(points(&s, id).mode, vectorcraft_color::FreeformMode::Lines);
assert_eq!(points(&s, id).points, f.points, "the points stay");
s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap();
assert!(fill_gradient(&s, id).freeform.is_none(), "a linear gradient drops the points");
let e = s.execute("paint.editGradient", &json!({"mode": "lines"})).unwrap_err().to_string();
assert!(e.contains("freeform"), "{e}");
}
#[test]
fn new_stops_recolour_the_points() {
let (mut s, id) = freeform_rect();
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
s.execute("paint.editGradient", &json!({"stops": stops})).unwrap();
let f = points(&s, id);
assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ff0000".into(), "#0000ff".into()));
}
#[test]
fn transforms_and_warps_move_the_points() {
let (mut s, id) = freeform_rect();
let before = points(&s, id);
run(&mut s, "object.move", json!({"dx": 10, "dy": 5}));
let moved = points(&s, id);
assert!(before.points.iter().zip(&moved.points).all(|(a, b)| close(b.at, a.at + vectorcraft_geom::Vec2::new(10.0, 5.0))));
run(&mut s, "object.rotate", json!({"angle": 90, "origin": [160, 155]}));
let turned = points(&s, id);
// The y-down document turns (x, y) about the origin to (x', y') = (160 + (y − 155), 155 − (x − 160)).
for (a, b) in moved.points.iter().zip(&turned.points) {
let want = Point::new(160.0 + (a.at.y - 155.0), 155.0 - (a.at.x - 160.0));
assert!(close(b.at, want), "{:?} → {:?}, want {want:?}", a.at, b.at);
}
// A perspective distort maps each point exactly: a point on a corner stays on it.
let (mut s, id) = freeform_rect();
run(&mut s, "paint.freeform.addPoint", json!({"at": [200, 200]}));
run(&mut s, "object.distort", json!({"corners": [[100, 100], [200, 100], [260, 240], [100, 200]]}));
let f = points(&s, id);
assert!(close(f.points.last().unwrap().at, Point::new(260.0, 240.0)), "{:?}", f.points.last());
}
#[test]
fn freeform_paints_survive_saving_and_old_files_load() {
let (mut s, id) = freeform_rect();
run(&mut s, "paint.editGradient", json!({"mode": "lines"}));
run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1, 2]}));
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&s.doc().unwrap().doc).unwrap();
// A gradient saved before freeform points existed has none.
let old: GradientPaint = serde_json::from_value(json!({"gradient": {"kind": "Freeform", "stops": []}, "angle": 0.0})).unwrap();
assert!(old.freeform.is_none());
// The command params are lossless: setting the paint read back reproduces it.
let g = fill_gradient(&s, id);
let other = shape(&mut s, "shape.rectangle", 300.0, 300.0, 50.0, 50.0);
run(&mut s, "paint.setFill", json!({"gradient": vectorcraft_tools::params::gradient_params(&g), "ids": [other.0]}));
assert_eq!(fill_gradient(&s, other), g);
}
#[test]
fn reapplied_gradients_place_their_points_afresh() {
let (mut s, id) = freeform_rect();
run(&mut s, "paint.freeform.setPoint", json!({"index": 0, "color": "#ff0000"}));
// The last gradient (`.`) on other art: points inside it, coloured like the first object's.
let other = shape(&mut s, "shape.rectangle", 400.0, 300.0, 100.0, 100.0);
run(&mut s, "paint.lastGradient", json!({}));
let (a, (b, _)) = (points(&s, id), node(&s, other).proxy_freeform(false, None).unwrap());
assert!(b.points.iter().all(|p| p.at.x >= 400.0 && p.at.y >= 300.0), "{:?}", b.points);
assert_eq!(a.points.iter().map(|p| p.color.to_hex()).collect::<Vec<_>>(), b.points.iter().map(|p| p.color.to_hex()).collect::<Vec<_>>());
}
// ---------- M3.82: points and lines ----------
#[test]
fn add_set_and_delete_points() {
let (mut s, id) = freeform_rect();
let n = points(&s, id).points.len();
let field = points(&s, id);
let r = run(&mut s, "paint.freeform.addPoint", json!({"at": [150, 150]}));
assert_eq!(r["index"], json!(n));
assert_eq!(s.selected_freeform_point(), Some(n), "the new point is selected");
let p = points(&s, id).points[n];
// Without a colour it takes the gradient's colour there.
let (want, _) = field.sample(Point::new(150.0, 150.0), 50.0);
assert!(close(p.at, Point::new(150.0, 150.0)) && p.color.to_hex() == want.to_hex(), "{p:?} vs {want:?}");
// The selected point takes edits without an index; opacity and spread take 0..1 or percentages.
run(&mut s, "paint.freeform.setPoint", json!({"color": "#00ff00", "opacity": 50, "spread": 0.4, "at": [160, 170]}));
let p = points(&s, id).points[n];
assert_eq!((p.color.to_hex().as_str(), p.opacity, p.spread, p.at), ("#00ff00", 0.5, 0.4, Point::new(160.0, 170.0)));
assert_eq!(fill_gradient(&s, id).gradient.stops[n].color.to_hex(), "#00ff00", "the stops follow");
// One undo step per command.
run(&mut s, "edit.undo", json!({}));
assert_eq!(points(&s, id).points[n].color.to_hex(), want.to_hex());
run(&mut s, "edit.redo", json!({}));
run(&mut s, "paint.freeform.deletePoint", json!({}));
assert_eq!(points(&s, id).points.len(), n);
assert_eq!(s.selected_freeform_point(), None);
for i in (1..n).rev() {
run(&mut s, "paint.freeform.deletePoint", json!({"index": i}));
}
let e = s.execute("paint.freeform.deletePoint", &json!({"index": 0})).unwrap_err().to_string();
assert!(e.contains("at least one"), "{e}");
let e = s.execute("paint.freeform.setPoint", &json!({"index": 5, "color": "#000000"})).unwrap_err().to_string();
assert!(e.contains("no point 5"), "{e}");
}
#[test]
fn lines_join_split_and_follow_the_selection() {
let (mut s, id) = freeform_rect();
assert_eq!(run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]}))["line"], json!(0));
run(&mut s, "paint.freeform.selectPoint", json!({"index": 1}));
// Adding a point with `line` extends the line ending at the selected point.
let i = run(&mut s, "paint.freeform.addPoint", json!({"at": [190, 190], "line": true}))["index"].as_u64().unwrap() as usize;
assert_eq!(points(&s, id).lines, vec![vec![0, 1, i]]);
let j = run(&mut s, "paint.freeform.splitLine", json!({"line": 0, "segment": 1}))["index"].as_u64().unwrap() as usize;
assert_eq!((points(&s, id).lines[0].clone(), s.selected_freeform_point()), (vec![0, 1, j, i], Some(j)));
assert!(s.execute("paint.freeform.splitLine", &json!({"line": 0, "segment": 3})).is_err());
assert!(s.execute("paint.freeform.addLine", &json!({"points": [0, 99]})).is_err());
// The query reports points in document coordinates, the lines and the selection.
let g = run(&mut s, "paint.freeform.get", json!({}));
assert_eq!((g["lines"].clone(), g["selected"].clone(), g["mode"].clone()), (json!([[0, 1, j, i]]), json!(j), json!("points")));
assert_eq!(g["points"][i]["at"], json!([190.0, 190.0]));
}
#[test]
fn point_commands_need_a_freeform_gradient() {
let mut s = session();
shape(&mut s, "shape.rectangle", 0.0, 0.0, 50.0, 50.0);
for paint in [json!({"color": "#ff0000"}), json!({"gradient": {"kind": "radial"}})] {
run(&mut s, "paint.setFill", paint);
for (cmd, p) in [
("paint.freeform.addPoint", json!({"at": [10, 10]})),
("paint.freeform.setPoint", json!({"index": 0, "color": "#000000"})),
("paint.freeform.deletePoint", json!({"index": 0})),
("paint.freeform.addLine", json!({"points": [0, 1]})),
("paint.freeform.splitLine", json!({"line": 0, "segment": 0})),
("paint.freeform.selectPoint", json!({"index": 0})),
("paint.freeform.get", json!({})),
] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("not a freeform gradient"), "{cmd}: {e}");
}
}
// Nothing selected: nothing to edit.
run(&mut s, "select.none", json!({}));
assert!(s.execute("paint.freeform.addPoint", &json!({"at": [1, 1]})).is_err());
}
#[test]
fn selection_follows_the_object_and_the_proxy() {
let (mut s, id) = freeform_rect();
run(&mut s, "paint.freeform.selectPoint", json!({"index": 2}));
assert_eq!(s.selected_freeform_point(), Some(2));
run(&mut s, "paint.toggleActive", json!({}));
assert_eq!(s.selected_freeform_point(), None, "the Stroke proxy has its own paint");
run(&mut s, "paint.toggleActive", json!({}));
assert_eq!(s.selected_freeform_point(), Some(2));
run(&mut s, "select.none", json!({}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
assert_eq!(s.selected_freeform_point(), Some(2), "same object, same proxy");
assert!(s.execute("paint.freeform.selectPoint", &json!({"index": 99})).is_err());
}
#[test]
fn type_objects_keep_points_in_text_space() {
let mut s = session();
let r = run(&mut s, "text.create", json!({"x": 100, "y": 200, "text": "Freeform", "size": 40}));
let id = NodeId(r["id"].as_u64().unwrap());
run(&mut s, "paint.editGradient", json!({"kind": "freeform"}));
run(&mut s, "object.rotate", json!({"angle": 90, "origin": [100, 200]}));
let i = run(&mut s, "paint.freeform.addPoint", json!({"at": [90, 150], "color": "#ff00ff"}))["index"].as_u64().unwrap() as usize;
let (f, _) = node(&s, id).proxy_freeform(false, None).unwrap();
assert!(close(f.points[i].at, Point::new(90.0, 150.0)), "back in document coordinates: {:?}", f.points[i].at);
assert_eq!(f.points[i].color, Color::rgb(1.0, 0.0, 1.0));
}

View File

@ -82,6 +82,7 @@ impl Session {
gradient_stop: self.selected_stop(),
appearance_item: self.appearance_item(),
constrain_angle: self.prefs.constrain_angle,
freeform_point: self.selected_freeform_point(),
};
f(self.tool.as_mut(), &cx)
}

View File

@ -0,0 +1,105 @@
//! Freeform gradients in the renderer.
//!
//! vello has no freeform shading, so the colour field ([`vectorcraft_color::freeform`]) is
//! sampled on a grid of cells over the painted box and drawn as an image paint (bilinear, edges
//! padded): the fill or stroke being painted clips it. The grid is sized from the box's size on
//! screen (a cell per few device pixels, in power-of-two steps so zooming reuses it) and cached by
//! the gradient's content, so panning and redraws don't resample it.
use std::cell::RefCell;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, GradientPaint};
use vectorcraft_geom::{Affine, Point, Rect};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
/// Device pixels per grid cell.
const CELL_PX: f64 = 4.0;
/// Fewest and most cells along the box's longer side.
const MIN_CELLS: f64 = 8.0;
const MAX_CELLS: f64 = 256.0;
/// Grids kept before the cache starts over.
const MAX_GRIDS: usize = 128;
thread_local! {
static GRIDS: RefCell<HashMap<u64, Arc<Pixmap>>> = RefCell::new(HashMap::new());
}
fn hash_color(c: &Color, h: &mut impl Hasher) {
match *c {
Color::Rgb { r, g, b } => (0u8, r.to_bits(), g.to_bits(), b.to_bits()).hash(h),
Color::Cmyk { c, m, y, k } => (1u8, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()).hash(h),
Color::Gray { k } => (2u8, k.to_bits()).hash(h),
}
}
/// Cache key of the grid of `f` on box `b` at `cols` × `rows` (and the active colour settings,
/// which turn colours into display RGB).
fn key(f: &Freeform, b: Rect, cols: u16, rows: u16) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in &f.points {
(p.at.x.to_bits(), p.at.y.to_bits(), p.opacity.to_bits(), p.spread.to_bits()).hash(&mut h);
hash_color(&p.color, &mut h);
}
f.lines.hash(&mut h);
(b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h);
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize).hash(&mut h);
h.finish()
}
/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied).
pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16) -> Pixmap {
let field = f.field(spread_scale(b));
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
let data = (0..rows as usize * cols as usize)
.map(|k| {
let (i, j) = (k % cols as usize, k / cols as usize);
let ([r, g, bl], a) = field.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch));
let a = a.clamp(0.0, 1.0);
let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8;
vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 }
})
.collect();
Pixmap::from_parts(data, cols, rows)
}
/// Cells along a side `len` (document units) long whose box's longer side `side` gets `cells`.
fn cells_along(len: f64, side: f64, cells: f64) -> u16 {
(cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16
}
/// Set freeform gradient `g` on box `bounds` (in the paint's space, which the context's current
/// transform maps to pixels) as the context paint. Returns false for an empty box.
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect) -> bool {
let b = bounds.abs();
let side = b.width().max(b.height());
if !(side > 1e-9 && side.is_finite()) {
return false;
}
// A box without height (or width) gets a sliver of it, so the grid maps back invertibly.
let b = Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3)));
let device = side * ctx.transform().determinant().abs().sqrt();
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
let (cols, rows) = (cells_along(b.width(), side, cells), cells_along(b.height(), side, cells));
let f = g.freeform_on(b);
let k = key(&f, b, cols, rows);
let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| {
let pm = Arc::new(rasterize(&f, b, cols, rows));
GRIDS.with(|c| {
let mut c = c.borrow_mut();
if c.len() >= MAX_GRIDS {
c.clear();
}
c.insert(k, pm.clone());
});
pm
});
let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality: ImageQuality::Medium, alpha: 1.0 };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
ctx.set_paint_transform(Affine::translate(b.origin().to_vec2()) * Affine::scale_non_uniform(b.width() / cols as f64, b.height() / rows as f64));
true
}

View File

@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
mod brush_fx;
mod freeform;
mod fx;
mod group;
mod live;
@ -1161,6 +1162,8 @@ mod tests_clip;
#[cfg(test)]
mod tests_container;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_isolation;
#[cfg(test)]
mod tests_knockout;

View File

@ -19,6 +19,7 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Documen
ctx.set_paint(color(c, 1.0));
true
}
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds),
Paint::Gradient(g) => {
let geom = g.resolve(bounds);
let stops: Vec<ColorStop> = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect();

View File

@ -0,0 +1,96 @@
//! Freeform gradients: the rendered colour grid against the colour field.
use super::*;
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::{Point, shapes};
/// Red at the top left, blue at the bottom right, a half-transparent green at the top right.
fn freeform() -> Freeform {
let p = |x, y, c, opacity| FreeformPoint { opacity, spread: 0.1, ..FreeformPoint::new(Point::new(x, y), c) };
Freeform {
points: vec![
p(20.0, 20.0, Color::rgb(1.0, 0.0, 0.0), 1.0),
p(80.0, 80.0, Color::rgb(0.0, 0.0, 1.0), 1.0),
p(80.0, 20.0, Color::rgb(0.0, 1.0, 0.0), 0.5),
],
..Default::default()
}
}
fn paint(f: Option<Freeform>) -> Paint {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = f;
Paint::Gradient(Box::new(g))
}
/// A 100 × 100 document with `shape` (on 0..100) painted `fill`, at `opacity`.
fn doc(shape: vectorcraft_geom::PathData, fill: Paint, opacity: f32) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n = Node::path(id, shape, Appearance::basic(fill, Paint::None, 0.0));
n.opacity = opacity;
d.insert(Some(d.layers[0].id), 0, n).unwrap();
d
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The field's colour at `p` composited over white (8-bit).
fn expected(f: &Freeform, p: Point, opacity: f32) -> [u8; 4] {
let ([r, g, b], a) = f.field(spread_scale(Rect::new(0.0, 0.0, 100.0, 100.0))).sample(p);
let a = a * opacity;
let q = |v: f32| ((v * a + (1.0 - a)) * 255.0).round() as u8;
[q(r), q(g), q(b), 255]
}
fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
#[test]
fn the_rendered_grid_follows_the_colour_field() {
let f = freeform();
let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 1.0));
assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the red point's colour");
assert_eq!(r.pixel(80, 80), [0, 0, 255, 255], "the blue point's colour");
for (x, y) in [(50, 50), (35, 60), (65, 30), (10, 90), (95, 5), (50, 20)] {
let want = expected(&f, Point::new(x as f64 + 0.5, y as f64 + 0.5), 1.0);
assert!(near(r.pixel(x, y), want, 6), "({x}, {y}): {:?} vs {want:?}", r.pixel(x, y));
}
}
#[test]
fn the_grid_is_clipped_to_the_path_and_takes_the_object_opacity() {
let f = freeform();
let r = render(&doc(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 0.5));
assert_eq!(r.pixel(2, 2), [255, 255, 255, 255], "outside the ellipse");
let want = expected(&f, Point::new(50.5, 50.5), 0.5);
assert!(near(r.pixel(50, 50), want, 6), "{:?} vs {want:?}", r.pixel(50, 50));
}
#[test]
fn unplaced_freeform_gradients_seed_points_on_the_box() {
// Without points: the automatic ones, coloured white → black along the default stops.
let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(None), 1.0));
let auto = Freeform::auto(Rect::new(0.0, 0.0, 100.0, 100.0), &Gradient::default(), &|_| true);
let at = |i: usize| auto.points[i].at;
// (Pixel centres sit half a pixel off the points.)
assert!(near(r.pixel(at(0).x as u32, at(0).y as u32), [255, 255, 255, 255], 3));
assert!(near(r.pixel(at(3).x as u32, at(3).y as u32), [0, 0, 0, 255], 3));
}
#[test]
fn strokes_take_freeform_paint() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let line = shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0));
let n = Node::path(id, line, Appearance::basic(Paint::None, paint(Some(freeform())), 10.0));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the stroke's corner sits on the red point");
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255], "unfilled inside");
}

View File

@ -285,7 +285,13 @@ impl Importer {
.collect::<Vec<_>>()
};
let gp = |kind, stops, geom: GradientGeom| {
Paint::Gradient(Box::new(GradientPaint { gradient: Gradient { kind, stops }, geom: Some(geom), angle: geom.angle_deg(), swatch: None }))
Paint::Gradient(Box::new(GradientPaint {
gradient: Gradient { kind, stops },
geom: Some(geom),
angle: geom.angle_deg(),
swatch: None,
freeform: None,
}))
};
match p {
usvg::Paint::Color(c) => Paint::solid(Color::rgb8(c.red, c.green, c.blue)),

View File

@ -229,6 +229,7 @@ fn linear_gradient_roundtrip() {
geom: Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(90.0, 60.0), aspect: 1.0 }),
angle: 0.0,
swatch: None,
freeform: None,
};
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0));
let d = doc_with(vec![n]);
@ -260,6 +261,7 @@ fn radial_gradient_and_fit_roundtrip() {
geom: Some(GradientGeom { start: Point::new(150.0, 150.0), end: Point::new(150.0, 180.0), aspect: 0.5 }),
angle: 0.0,
swatch: None,
freeform: None,
};
let n2 = rect_node(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0));
let r = roundtrip(&doc_with(vec![n, n2]));

View File

@ -165,6 +165,9 @@ pub struct ToolContext<'a> {
pub appearance_item: Option<usize>,
/// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it.
pub constrain_angle: f64,
/// The selected freeform gradient point (`paint.freeform.selectPoint`), marked on the
/// freeform annotator.
pub freeform_point: Option<usize>,
}
impl ToolContext<'_> {
@ -349,6 +352,7 @@ pub(crate) mod testutil {
gradient_stop: None,
appearance_item: None,
constrain_angle: 0.0,
freeform_point: None,
}
}
}

View File

@ -2,7 +2,7 @@
//! paint read from the document and sent back through a command arrives unchanged.
use serde_json::{Value, json};
use vectorcraft_color::{Color, GradientPaint, GradientStop};
use vectorcraft_color::{Color, Freeform, FreeformPoint, GradientPaint, GradientStop};
/// A colour as a `color` param in its own model: `[r,g,b]`, `{c,m,y,k}` or `{gray}`.
pub fn color_json(c: &Color) -> Value {
@ -20,8 +20,23 @@ pub fn stops_json(stops: &[GradientStop]) -> Value {
)
}
/// A freeform point as its `paint.freeform.addPoint` / `freeform` param fields.
pub fn freeform_point_json(p: &FreeformPoint) -> Value {
json!({"at": [p.at.x, p.at.y], "color": color_json(&p.color), "opacity": p.opacity, "spread": p.spread})
}
/// A freeform gradient as the `freeform` param of a `gradient` paint: points, lines and Draw mode.
pub fn freeform_json(f: &Freeform) -> Value {
json!({
"points": f.points.iter().map(freeform_point_json).collect::<Vec<_>>(),
"lines": f.lines,
"mode": f.mode.label().to_lowercase(),
})
}
/// 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 (%).
/// opacity and midpoint), angle, the linked swatch and, once placed, the vector and aspect (%)
/// and the freeform points.
pub fn gradient_params(g: &GradientPaint) -> Value {
let mut v = json!({
"kind": g.gradient.kind.label().to_lowercase(),
@ -36,6 +51,9 @@ pub fn gradient_params(g: &GradientPaint) -> Value {
if let Some(s) = &g.swatch {
v["swatch"] = json!(s);
}
if let Some(f) = &g.freeform {
v["freeform"] = freeform_json(f);
}
v
}

View File

@ -7,6 +7,7 @@
mod artboard;
mod eyedropper;
mod free;
mod freeform;
mod gradient;
mod measure;
mod transform;

View File

@ -0,0 +1,264 @@
//! 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.
use serde_json::{Value, json};
use vectorcraft_color::freeform::LineHit;
use vectorcraft_color::{Freeform, FreeformMode};
use vectorcraft_doc::hit::hit_test;
use vectorcraft_geom::{Point, Shape};
use super::paint_owner;
use crate::{Action, Cursor, Overlay, ToolContext, ToolKey};
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;
/// 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 {
pub freeform: Freeform,
pub scale: f64,
}
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 })
})
}
/// The point under `p` (the topmost chip where chips overlap).
fn point_at(&self, cx: &ToolContext, p: Point) -> Option<usize> {
(0..self.freeform.points.len()).rev().find(|i| self.freeform.points[*i].at.distance(p) <= cx.tol(POINT_HIT))
}
/// The spot on a line under `p`.
fn line_at(&self, cx: &ToolContext, p: Point) -> Option<LineHit> {
self.freeform.nearest_on_lines(p).filter(|h| h.distance <= cx.tol(LINE_HIT))
}
/// The selected point, if it exists.
fn selected(&self, cx: &ToolContext) -> Option<usize> {
cx.freeform_point.filter(|i| *i < self.freeform.points.len())
}
pub fn overlays(&self, cx: &ToolContext) -> 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();
if let Some(i) = self.selected(cx) {
let pt = &f.points[i];
let r = pt.spread as f64 * self.scale;
if r > 0.0 {
let path = vectorcraft_geom::kurbo::Circle::new(pt.at, r).to_path(cx.tol(0.25));
o.push(Overlay::Path { path, color: LINE, width: 1.0, dashed: true });
}
}
o.extend(f.points.iter().enumerate().map(|(i, pt)| Overlay::Swatch {
p: pt.at,
color: pt.color.to_rgba8(pt.opacity),
selected: cx.freeform_point == Some(i),
}));
o
}
}
/// What a press on a freeform annotator does.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Grab {
/// A point: a drag moves it.
Point(usize),
/// A line: a click adds a point there.
Line(LineHit),
/// Inside selected art: a click adds a point there.
Add,
/// Elsewhere: a click leaves no point selected.
Clear,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Gesture {
grab: Grab,
at: Point,
began: bool,
}
impl Gesture {
/// Has the press become a drag (an interaction is open)?
pub fn began(&self) -> bool {
self.began
}
}
/// `params` aimed at the paint behind the active proxy.
fn on_proxy(cx: &ToolContext, mut params: Value) -> Value {
params["stroke"] = json!(!cx.fill_active);
params
}
fn select(i: Option<usize>) -> Action {
Action::Exec("paint.freeform.selectPoint".into(), json!({ "index": i }))
}
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) {
if cx.freeform_point != Some(i) {
out.push(select(Some(i)));
}
Grab::Point(i)
} 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))) {
Grab::Add
} else {
Grab::Clear
};
(out, Gesture { grab, at: p, began: false })
}
pub(super) fn drag(cx: &ToolContext, g: &mut Gesture, p: Point) -> Vec<Action> {
let Grab::Point(index) = g.grab else { return vec![] };
let mut out = vec![];
if !g.began {
if p.distance(g.at) < cx.tol(DRAG_START) {
return out;
}
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] }))));
out
}
pub(super) fn release(cx: &ToolContext, a: Option<&Annotator>, g: Gesture) -> Vec<Action> {
if g.began {
return vec![Action::Commit];
}
match g.grab {
Grab::Point(_) => vec![],
Grab::Line(h) => {
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 })))]
}
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
}
pub(super) fn key(cx: &ToolContext, a: &Annotator, key: ToolKey) -> Vec<Action> {
if !claims_key(cx, a, key) {
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;
}
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;
}
Cursor::Crosshair
}
#[cfg(test)]
mod tests {
use super::super::GradientTool;
use super::*;
use crate::testutil::*;
use crate::{PointerEvent, PointerKind, Tool};
use vectorcraft_color::{FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Document, Selection};
/// The test rectangle (100..200) with three points; `lines` mode joins new ones.
fn doc(mode: FreeformMode) -> (Document, Selection) {
let (mut d, id) = doc_with_rect();
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
let pt = |x, y| FreeformPoint { spread: 0.2, ..FreeformPoint::new(Point::new(x, y), vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)) };
g.freeform = Some(Freeform { points: vec![pt(120.0, 120.0), pt(180.0, 120.0), pt(150.0, 180.0)], lines: vec![vec![0, 1]], mode });
d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g)));
let mut s = Selection::default();
s.add(id);
(d, s)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
fn click(t: &mut GradientTool, cx: &ToolContext, x: f64, y: f64) -> Vec<Action> {
let mut a = t.pointer(cx, &ev(PointerKind::Down, x, y));
a.extend(t.pointer(cx, &ev(PointerKind::Up, x, y)));
a
}
#[test]
fn clicks_select_points_add_points_and_split_lines() {
let (d, s) = doc(FreeformMode::Points);
let p = paint();
let mut cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
assert_eq!(click(&mut t, &cx, 151.0, 181.0), vec![select(Some(2))]);
cx.freeform_point = Some(2);
// Inside the art: a point there (Points mode: not joined).
let a = click(&mut t, &cx, 140.0, 150.0);
assert_eq!(a, vec![Action::Exec("paint.freeform.addPoint".into(), json!({"at": [140.0, 150.0], "line": false, "stroke": false}))]);
// On the line: a point on it.
let a = click(&mut t, &cx, 150.0, 122.0);
let Action::Exec(c, v) = &a[0] else { panic!("{a:?}") };
assert_eq!((c.as_str(), &v["line"], &v["segment"]), ("paint.freeform.splitLine", &json!(0), &json!(0)));
// Off the art: the point selection clears.
assert_eq!(click(&mut t, &cx, 400.0, 400.0), vec![select(None)]);
// The overlays: a chip per point (the selected one ringed), the line and the spread.
let o = t.overlays(&cx);
assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Swatch { .. })).count(), 3);
assert!(o.iter().any(|o| matches!(o, Overlay::Swatch { selected: true, p, .. } if p == &Point::new(150.0, 180.0))));
assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count(), 1, "the spread circle");
}
#[test]
fn lines_mode_joins_new_points_and_drags_move_points() {
let (d, s) = doc(FreeformMode::Lines);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.freeform_point = Some(1);
let mut t = GradientTool::default();
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));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 130.0, 140.0));
assert_eq!(a[0], Action::Begin("Gradient".into()));
assert_eq!(a[1], Action::Preview("paint.freeform.setPoint".into(), json!({"index": 0, "at": [130.0, 140.0], "stroke": false})));
assert_eq!(t.cursor(&cx, Point::new(130.0, 140.0), Default::default()), Cursor::Move);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 130.0, 140.0)), vec![Action::Commit]);
// Delete removes the selected point.
assert!(t.claims_key(&cx, ToolKey::Delete));
assert_eq!(
t.key(&cx, ToolKey::Delete, Default::default()),
vec![Action::Exec("paint.freeform.deletePoint".into(), json!({"index": 1, "stroke": false}))]
);
}
}

View File

@ -13,6 +13,8 @@
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
//! 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.
//!
//! On a freeform gradient the tool edits its points instead (see `freeform`).
use serde_json::{Value, json};
use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint};
@ -21,6 +23,7 @@ use vectorcraft_doc::NodeId;
use vectorcraft_doc::hit::hit_test;
use vectorcraft_geom::{Affine, Point, Vec2, constrain_angle_from};
use super::freeform::{self, Annotator as FreeformAnnotator};
use super::paint_owner;
use crate::guides::snap_draw;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@ -49,8 +52,8 @@ const DRAG_START: f64 = 3.0;
const NUDGE: f32 = 0.01;
const NUDGE_BIG: f32 = 0.1;
/// The gradient annotator: the gradient behind the active proxy of the first selected object that
/// has one, placed in document coordinates.
/// The gradient annotator: the linear or radial gradient behind the active proxy of the first
/// selected object that has one, placed in document coordinates.
#[derive(Clone, Debug, PartialEq)]
pub struct Annotator {
pub geom: GradientGeom,
@ -75,7 +78,7 @@ impl Annotator {
pub fn of(cx: &ToolContext) -> Option<Self> {
cx.selection.objects.iter().find_map(|id| {
let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active, cx.appearance_item)?;
Some(Self { geom, gradient: g.gradient.clone() })
(g.gradient.kind != GradientKind::Freeform).then(|| Self { geom, gradient: g.gradient.clone() })
})
}
/// An annotator showing `stops` on `geom` (the state a drag started from).
@ -183,6 +186,8 @@ struct Gesture {
#[derive(Default)]
pub struct GradientTool {
gesture: Option<Gesture>,
/// A press on a freeform gradient's annotator.
free: Option<freeform::Gesture>,
/// Smart-guide feedback of the handle being dragged.
guides: Vec<Overlay>,
}
@ -381,10 +386,27 @@ impl Tool for GradientTool {
}
fn busy(&self) -> bool {
self.gesture.as_ref().is_some_and(|g| g.began)
self.gesture.as_ref().is_some_and(|g| g.began) || self.free.as_ref().is_some_and(freeform::Gesture::began)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
// A freeform gradient: its points instead of the bar.
match (ev.kind, &mut self.free) {
(PointerKind::Down, _) => {
if let Some(a) = FreeformAnnotator::of(cx) {
let (out, g) = freeform::press(cx, &a, ev.pos);
self.free = Some(g);
return out;
}
}
(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);
}
(PointerKind::DoubleClick, free) if free.is_some() || FreeformAnnotator::of(cx).is_some() => return vec![],
_ => {}
}
match ev.kind {
PointerKind::Down => self.press(cx, ev.pos, ev.mods),
PointerKind::Drag => self.drag(cx, ev.pos, ev.mods),
@ -400,6 +422,9 @@ 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);
}
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()))
}
@ -407,12 +432,16 @@ 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];
}
if self.busy() || !self.claims_key(cx, key) {
return vec![];
}
if let Some(a) = FreeformAnnotator::of(cx) {
return freeform::key(cx, &a, key);
}
let (Some(a), Some(i)) = (Annotator::of(cx), cx.gradient_stop) else { return vec![] };
let stops = &a.gradient.stops;
let (new, sel) = match key {
@ -433,6 +462,9 @@ impl Tool for GradientTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(a) = FreeformAnnotator::of(cx) {
return a.overlays(cx);
}
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.
@ -446,6 +478,9 @@ impl Tool for GradientTool {
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if let Some(a) = FreeformAnnotator::of(cx) {
return freeform::cursor(cx, &a, self.free.as_ref(), p);
}
if let Some(g) = &self.gesture {
return match (&g.grab, g.stop) {
(Grab::Stop { .. }, Some(None)) => Cursor::RemoveStop,

View File

@ -1,6 +1,7 @@
//! Color panel: Grayscale / RGB / HSB / CMYK / Web Safe RGB sliders with colour-gradient tracks,
//! value fields, hex field, None/Black/White chips, spectrum ramp and the Fill/Stroke proxy.
//! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop.
//! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop (on a
//! freeform gradient, its selected point).
//!
//! The mode follows the colour's own model (the last mode picked comes back for colours in its
//! model). Alt-clicking the spectrum or a chip paints the inactive proxy; Shift-clicking the
@ -9,7 +10,7 @@
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_color::{Color, GradientKind, Paint};
use super::{active_paint, color_json, live_run, pstate, set_pstate};
use crate::theme::Tokens;
@ -258,17 +259,19 @@ fn to32(c: &Color) -> Color32 {
super::c32(c)
}
/// What the panel is editing: the active proxy's solid colour, or a selected gradient stop.
/// What the panel is editing: the active proxy's solid colour, a selected gradient stop or a
/// selected freeform point.
enum Target {
Paint(Option<Color>),
Stop { paint: Paint, index: usize, color: Color },
Point { index: usize, color: Color },
}
impl Target {
fn color(&self) -> Option<Color> {
match self {
Target::Paint(c) => *c,
Target::Stop { color, .. } => Some(*color),
Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color),
}
}
}
@ -280,6 +283,12 @@ fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
}
let p = active_paint(app);
match &p {
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => {
// Like stops: without a selected point, the first.
let f = super::gradient::shown_points(g);
let index = app.session.selected_freeform_point().filter(|i| *i < f.points.len()).unwrap_or(0);
Target::Point { index, color: f.points.get(index).map_or(Color::BLACK, |p| p.color) }
}
Paint::Gradient(g) => {
let i = app.session.selected_stop().unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1));
let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK);
@ -301,6 +310,10 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
}
super::gradient::set_stops(app, &stops, None, phase);
}
Target::Point { index, .. } if !behind => {
let p = json!({"index": index, "color": color_json(&c), "stroke": !app.session.fill_active});
live_run(app, "Color", "paint.freeform.setPoint", p, phase);
}
_ => {
let cmd = super::proxy_cmd(app, behind);
// The panel's sliders work in the model picked there, even in a CMYK document.
@ -507,7 +520,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
] {
if menu_item(ui, label, recolor, false) {
match (&tgt, color) {
(Target::Stop { .. }, Some(c)) => apply(app, &tgt, f(&c), Live::Released, false),
(Target::Stop { .. } | Target::Point { .. }, Some(c)) => apply(app, &tgt, f(&c), Live::Released, false),
_ => {
app.run(cmd, json!({})).ok();
}

View File

@ -7,13 +7,17 @@
//! for the stop popover, and drag a diamond to move a midpoint (a selected diamond's midpoint
//! shows in Location). Dropping a colour swatch on the ramp adds a stop of that colour, or
//! recolours the stop it lands on. Hide Options leaves the thumbnail, the proxy and the slider.
//!
//! A freeform gradient shows its section instead of the angle, aspect and slider: the Draw
//! toggle (Points or Lines: how the Gradient tool adds points) and the selected point's colour
//! (edited in the Color panel), opacity and spread, with Delete Point.
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::gradient::{
MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors,
};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::NodeId;
use super::{active_paint, live_run, pstate, set_pstate};
@ -180,9 +184,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
});
let freeform = is_grad && kind == GradientKind::Freeform;
if hidden {
ui.add_space(6.0);
ramp(app, ui, &g.gradient, is_grad);
if !freeform {
ui.add_space(6.0);
ramp(app, ui, &g.gradient, is_grad);
}
return;
}
ui.add_space(4.0);
@ -196,6 +203,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0);
}
});
if freeform {
freeform_section(app, ui, &g);
return;
}
// The proxy beside the angle and aspect ratio fields.
ui.horizontal(|ui| {
super::proxy(app, ui, 36.0);
@ -231,6 +242,74 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// The freeform points shown: the gradient's own, else (unplaced) as many as it places, coloured
/// along the stops.
pub(crate) fn shown_points(g: &GradientPaint) -> std::borrow::Cow<'_, Freeform> {
g.freeform_on(vectorcraft_geom::Rect::new(0.0, 0.0, 1.0, 1.0))
}
/// Run a `paint.freeform.*` command on the paint behind the active proxy.
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);
}
/// The freeform section: the proxy beside the Draw toggle, then the selected point's colour,
/// opacity and spread, and Delete Point.
fn freeform_section(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint) {
let f = shown_points(g);
let mode = f.mode;
ui.horizontal(|ui| {
super::proxy(app, ui, 36.0);
ui.add_space(4.0);
widgets::dim_label(ui, "Draw:");
for (m, icon, tip) in [
(FreeformMode::Points, "circle", "Points: clicks add free points"),
(FreeformMode::Lines, "spline", "Lines: clicks add points joined by a line"),
] {
if widgets::icon_button(ui, icon, tip, mode == m, 24.0).clicked() && mode != m {
edit(app, json!({ "mode": m.label().to_lowercase() }), Live::Released);
}
}
});
ui.add_space(6.0);
let sel = app.session.selected_freeform_point().filter(|i| *i < f.points.len());
let point = sel.map(|i| f.points[i]);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Color:");
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
let paint = point.map_or(Paint::None, |p| Paint::solid(p.color));
widgets::swatch_tile(ui, r, &paint, false, resp.hovered());
resp.on_hover_text("Edit the selected point's colour in the Color panel");
if let Some(p) = point {
widgets::dim_label(ui, &p.color.to_hex().to_uppercase());
}
});
ui.horizontal(|ui| {
ui.add_enabled_ui(sel.is_some(), |ui| {
for (label, key, value) in [("Opacity:", "opacity", point.map(|p| p.opacity)), ("Spread:", "spread", point.map(|p| p.spread))] {
widgets::dim_label(ui, label);
// Blank while no point is selected.
let shown = value.map(|v| (v as f64 * 1000.0).round() / 10.0);
if let Some(v) = widgets::mixed_field(ui, ("freeform", key), shown, "%", 1, 54.0)
&& let Some(i) = sel
{
edit_point(app, "paint.freeform.setPoint", json!({ "index": i, key: v.clamp(0.0, 100.0) / 100.0 }));
}
}
});
let can_del = sel.is_some() && f.points.len() > 1;
if widgets::icon_button_enabled(ui, "trash-2", "Delete Point", false, can_del, 22.0).clicked()
&& let Some(i) = sel
{
edit_point(app, "paint.freeform.deletePoint", json!({ "index": i }));
}
});
if sel.is_none() {
widgets::dim_label(ui, "Click the art with the Gradient tool to add or select points.");
}
}
/// The gradient thumbnail (click: apply a gradient; drag: onto art) and its menu of gradient
/// swatches with Save to Swatches.
fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) {

View File

@ -380,6 +380,8 @@ mod tests_appearance;
#[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_stroke;
#[cfg(test)]
mod tests_strokeux;

View File

@ -0,0 +1,118 @@
//! Headless frames of the Gradient panel's freeform section and the Color panel on a freeform
//! point.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
use serde_json::{Value, json};
use vectorcraft_color::{Freeform, FreeformMode, Paint};
use vectorcraft_engine::Session;
use super::*;
/// A selected rectangle with a freeform fill.
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
run(&mut app, "file.new", json!({"width": 300, "height": 300}));
run(&mut app, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}));
run(&mut app, "paint.editGradient", json!({"kind": "freeform"}));
app
}
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn freeform(app: &VectorcraftApp) -> Freeform {
match current_paints(app).0 {
Paint::Gradient(g) => g.freeform.expect("placed points"),
p => panic!("expected a gradient fill, got {p:?}"),
}
}
/// One frame of `f` with `events`: the texts drawn and their centres.
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<Event>, f: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec<(String, Pos2)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let input = egui::RawInput { events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| f(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
fn at(texts: &[(String, Pos2)], s: &str) -> Pos2 {
texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("`{s}` not drawn: {texts:?}")).1
}
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2, f: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec<(String, Pos2)> {
let b = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
frame(ctx, app, vec![Event::PointerMoved(pos), b(true)], f);
frame(ctx, app, vec![b(false)], f);
frame(ctx, app, vec![], f)
}
/// Click the field drawn at `pos`, replace its text with `text` and press Enter.
fn type_into(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2, text: &str, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
click(ctx, app, pos, f);
let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers };
frame(ctx, app, vec![key(Key::A, Modifiers::COMMAND), Event::Text(text.into())], f);
frame(ctx, app, vec![key(Key::Enter, Modifiers::NONE)], f);
}
#[test]
fn the_freeform_section_replaces_the_slider_and_sets_the_draw_mode() {
let mut app = app();
let ctx = egui::Context::default();
let texts = frame(&ctx, &mut app, vec![], gradient::show);
assert!(texts.iter().any(|(t, _)| t == "Draw:" || t == "Spread:"), "{texts:?}");
assert!(!texts.iter().any(|(t, _)| t == "Location:"), "no stop fields: {texts:?}");
// The Draw toggle's Lines button sits right of the Points one.
let draw = at(&texts, "Draw:");
click(&ctx, &mut app, draw + vec2(60.0, 0.0), gradient::show);
assert_eq!(freeform(&app).mode, FreeformMode::Lines);
click(&ctx, &mut app, draw + vec2(32.0, 0.0), gradient::show);
assert_eq!(freeform(&app).mode, FreeformMode::Points);
}
#[test]
fn the_selected_points_opacity_spread_and_delete() {
let mut app = app();
let n = freeform(&app).points.len();
run(&mut app, "paint.freeform.selectPoint", json!({"index": 1}));
let ctx = egui::Context::default();
let texts = frame(&ctx, &mut app, vec![], gradient::show);
let hex = freeform(&app).points[1].color.to_hex().to_uppercase();
assert!(texts.iter().any(|(t, _)| *t == hex), "the point's colour: {texts:?}");
let spread = at(&texts, "0%");
type_into(&ctx, &mut app, spread, "40", gradient::show);
assert!((freeform(&app).points[1].spread - 0.4).abs() < 1e-6, "{:?}", freeform(&app).points[1]);
let opacity = at(&texts, "100%");
type_into(&ctx, &mut app, opacity, "50", gradient::show);
assert!((freeform(&app).points[1].opacity - 0.5).abs() < 1e-6);
// Delete Point sits right of the Spread field (54 px wide, text left-aligned in it).
let texts = frame(&ctx, &mut app, vec![], gradient::show);
let trash = at(&texts, "40%") + vec2(57.0, 0.0);
click(&ctx, &mut app, trash, gradient::show);
assert_eq!(freeform(&app).points.len(), n - 1, "Delete Point");
// Nothing selected: the hint.
let texts = frame(&ctx, &mut app, vec![], gradient::show);
assert!(texts.iter().any(|(t, _)| t.starts_with("Click the art")), "{texts:?}");
}
#[test]
fn the_color_panel_recolours_the_selected_point() {
let mut app = app();
run(&mut app, "paint.freeform.selectPoint", json!({"index": 2}));
let ctx = egui::Context::default();
let texts = frame(&ctx, &mut app, vec![], color::show);
let hex = freeform(&app).points[2].color.to_hex()[1..].to_uppercase();
let field = texts.iter().find(|(t, _)| t.eq_ignore_ascii_case(&hex)).unwrap_or_else(|| panic!("{hex} in {texts:?}")).1;
type_into(&ctx, &mut app, field, "00FF00", color::show);
assert_eq!(freeform(&app).points[2].color.to_hex(), "#00ff00");
assert!(matches!(current_paints(&app).0, Paint::Gradient(_)), "still a gradient");
}

View File

@ -975,10 +975,12 @@ pub struct SwatchRows {
impl PanelDrag {
/// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient
/// carries no placement: it fits the art it lands on.
/// carries no placement: it fits the art it lands on (freeform points are placed afresh there,
/// coloured like the dragged ones).
pub fn paint(mut paint: Paint) -> Self {
if let Paint::Gradient(g) = &mut paint {
g.geom = None;
g.freeform = None;
}
Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None }
}

View File

@ -155,6 +155,21 @@ delete it, hold Alt to copy it), dragging the end handle changes the vector. The
{"name":"press_key","arguments":{"key":"Delete"}}
```
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?,
line?}`, `setPoint {index?, …}`, `deletePoint {index?}`, `addLine {points}`, `splitLine {line, segment, t?}` and
`selectPoint {index|null}` edit them (`index` defaults to the selected point; each edit is one undo step). With the
Gradient tool on a freeform gradient a click on a point selects it (drag to move it), a click on a line adds a point
on it, a click elsewhere on the art adds a point (in Lines mode joined to the selected one), and Delete removes the
selected point:
```json
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"kind":"freeform","mode":"lines"}}}
{"name":"run_command","arguments":{"command":"paint.freeform.addPoint","params":{"at":[150,150],"color":"#ff3366","spread":20}}}
{"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":180,"y":170},{"kind":"up","x":180,"y":170}]}}
```
Raw protocol (for debugging):
```sh