From 28774071118790ddd42e4df92d116847993666e3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:17:41 +0700 Subject: [PATCH 1/2] M3.81: freeform gradients: colour points with spread, smooth lines, first points placed inside the shape Freeform gradients carry their own points (colour, opacity, spread) and lines (Catmull-Rom curves through points), blended by inverse-distance weighting from each disc of pure colour. paint.editGradient {kind: "freeform", mode?} places 4+ points inside the shape, coloured along the stops (which follow the points' colours). The renderer samples the field on a cached colour grid drawn as an image paint clipped by the fill or stroke. Moves, transforms, box rebases and warps move the points; old files load unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/freeform.rs | 534 ++++++++++++++++++++++++++++ crates/color/src/gradient.rs | 72 +++- crates/color/src/lib.rs | 2 + crates/doc/src/appearance.rs | 16 +- crates/doc/src/node.rs | 17 + crates/engine/src/cmd/freeform.rs | 67 ++++ crates/engine/src/cmd/gradient.rs | 61 +++- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/lib.rs | 2 + crates/engine/src/tests_freeform.rs | 126 +++++++ crates/render/src/freeform.rs | 105 ++++++ crates/render/src/lib.rs | 3 + crates/render/src/paint.rs | 1 + crates/render/src/tests_freeform.rs | 96 +++++ crates/svg/src/import.rs | 8 +- crates/svg/tests/roundtrip.rs | 2 + crates/tools/src/params.rs | 22 +- 17 files changed, 1116 insertions(+), 19 deletions(-) create mode 100644 crates/color/src/freeform.rs create mode 100644 crates/engine/src/cmd/freeform.rs create mode 100644 crates/engine/src/tests_freeform.rs create mode 100644 crates/render/src/freeform.rs create mode 100644 crates/render/src/tests_freeform.rs diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs new file mode 100644 index 000000000..96ea9c395 --- /dev/null +++ b/crates/color/src/freeform.rs @@ -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 { + [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, + /// Each line is a smooth curve through `points` at these indices, in order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lines: Vec>, + /// 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 = (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 = + (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 = 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 { + 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 { + 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::>>() 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 { + let mut best: Option = 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 = 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 = 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 { + 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) -> Result { + 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 { + 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, + /// The lines tessellated into short pieces. + pieces: Vec, + 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)); + } +} diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 4fec89887..57921796a 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -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, + /// 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, } 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); + } } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 816a87897..e84a96507 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -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}; diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 32f44069d..0475830e5 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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 + }); } } } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 28601a5ce..7cadd5d90 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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) -> 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 diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs new file mode 100644 index 000000000..966324965 --- /dev/null +++ b/crates/engine/src/cmd/freeform.rs @@ -0,0 +1,67 @@ +//! Freeform gradients: parsing their points (the `freeform` param of a gradient paint) and the +//! shape test their first points are placed with. + +use serde_json::Value; +use vectorcraft_color::{Freeform, FreeformMode, FreeformPoint}; +use vectorcraft_doc::Node; +use vectorcraft_geom::Affine; + +use super::*; + +/// The `opacity` / `spread` convention: 0..1, or a percentage above 1. +fn fraction(v: &Value, key: &str) -> std::result::Result { + 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 { + 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::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)")) +} + +/// 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)) +} diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 0ce713bdd..4bfa98a41 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "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 ), @@ -153,6 +153,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { (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> { } /// `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) -> 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) -> Pai /// Apply the gradient edits in `p` to `paint` (pure; unit-tested). pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { + 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, + inside: &dyn Fn(Point) -> bool, +) -> std::result::Result { 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 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 { // 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 { 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, 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) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0b2c998bd..8c1d4faf8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0e9fa29f4..1a497b77f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -771,6 +771,8 @@ mod tests_effectedit; #[cfg(test)] mod tests_file; #[cfg(test)] +mod tests_freeform; +#[cfg(test)] mod tests_gradient; #[cfg(test)] mod tests_gradpanel; diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests_freeform.rs new file mode 100644 index 000000000..d331b39b1 --- /dev/null +++ b/crates/engine/src/tests_freeform.rs @@ -0,0 +1,126 @@ +//! Freeform gradients through commands: the first application, transforms and warps (M3.81). + +use serde_json::{Value, json}; +use vectorcraft_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::>(), f.points.iter().map(|p| p.color).collect::>()); + // 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(); + let corner = json!({"kind": "freeform", "freeform": {"points": [{"at": [200, 200], "color": "#ff0000"}]}}); + run(&mut s, "paint.setFill", json!({ "gradient": corner })); + 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"})); + let mut g = fill_gradient(&s, id); + g.freeform.as_mut().unwrap().add_line(vec![0, 1, 2]).unwrap(); + run(&mut s, "paint.setFill", json!({"gradient": vectorcraft_tools::params::gradient_params(&g)})); + assert_eq!(points(&s, id).lines, vec![vec![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); +} diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs new file mode 100644 index 000000000..2a150d31e --- /dev/null +++ b/crates/render/src/freeform.rs @@ -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>> = 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 +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1fbb639d1..049863533 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index c33e89c53..731dde26f 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -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 = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect(); diff --git a/crates/render/src/tests_freeform.rs b/crates/render/src/tests_freeform.rs new file mode 100644 index 000000000..621e04f87 --- /dev/null +++ b/crates/render/src/tests_freeform.rs @@ -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) -> 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"); +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 92e0d2f3b..ba79008bd 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -285,7 +285,13 @@ impl Importer { .collect::>() }; 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)), diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index c8b822239..15b89d4fc 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -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])); diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index faee9a183..2403d1fdf 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -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::>(), + "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 } From ed308d61f7ded5cb33815763a4262c42f760f900 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:42:44 +0700 Subject: [PATCH 2/2] M3.82: freeform point and line commands, Gradient tool and Gradient panel freeform section paint.freeform.addPoint/setPoint/deletePoint/addLine/splitLine/selectPoint and the paint.freeform.get query edit the freeform gradient behind the active proxy (one undo step each; errors on other paints). The session keeps the selected point. The Gradient tool on a freeform gradient selects and drags points, adds points on the art (joined in Lines mode) and on lines, and deletes the selected point. The Gradient panel shows the Draw toggle and the selected point's colour, opacity, spread and Delete Point; the Color panel recolours the selected point. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/freeform.rs | 277 +++++++++++++++++++- crates/engine/src/cmd/gradient.rs | 2 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/paint.rs | 32 ++- crates/engine/src/lib.rs | 4 + crates/engine/src/tests_freeform.rs | 130 ++++++++- crates/engine/src/tooling.rs | 1 + crates/tools/src/lib.rs | 4 + crates/tools/src/xform.rs | 1 + crates/tools/src/xform/freeform.rs | 264 +++++++++++++++++++ crates/tools/src/xform/gradient.rs | 43 ++- crates/ui-egui/src/panels/color.rs | 23 +- crates/ui-egui/src/panels/gradient.rs | 85 +++++- crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/tests_freeform.rs | 118 +++++++++ crates/ui-egui/src/widgets.rs | 4 +- docs/mcp.md | 15 ++ 17 files changed, 966 insertions(+), 40 deletions(-) create mode 100644 crates/tools/src/xform/freeform.rs create mode 100644 crates/ui-egui/src/panels/tests_freeform.rs diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 966324965..7c99e8240 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -1,13 +1,116 @@ -//! Freeform gradients: parsing their points (the `freeform` param of a gradient paint) and the -//! shape test their first points are placed with. +//! 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; -use vectorcraft_color::{Freeform, FreeformMode, FreeformPoint}; -use vectorcraft_doc::Node; -use vectorcraft_geom::Affine; +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 { + 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 { + 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) { + 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 { let x = v.as_f64().ok_or_else(|| format!("`{key}` must be a number"))?; @@ -59,6 +162,168 @@ pub(crate) fn parse_mode(m: &str) -> std::result::Result { 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(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + const C: &str = "paint.freeform.addLine"; + let points: Vec = 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 { + 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 { + 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 { + 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<> { diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 4bfa98a41..b42474ef9 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -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() } } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8c1d4faf8..f997e181e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -197,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 }) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 3763c20f2..85268d36c 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -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 { "{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)> { + 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, .. } => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1a497b77f..fd59c012b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, } } diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests_freeform.rs index d331b39b1..cc01aada3 100644 --- a/crates/engine/src/tests_freeform.rs +++ b/crates/engine/src/tests_freeform.rs @@ -1,7 +1,8 @@ -//! Freeform gradients through commands: the first application, transforms and warps (M3.81). +//! 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::{Freeform, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{Color, Freeform, GradientKind, GradientPaint, Paint}; use vectorcraft_geom::Point; use super::*; @@ -99,8 +100,7 @@ fn transforms_and_warps_move_the_points() { } // A perspective distort maps each point exactly: a point on a corner stays on it. let (mut s, id) = freeform_rect(); - let corner = json!({"kind": "freeform", "freeform": {"points": [{"at": [200, 200], "color": "#ff0000"}]}}); - run(&mut s, "paint.setFill", json!({ "gradient": corner })); + 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()); @@ -110,10 +110,7 @@ fn transforms_and_warps_move_the_points() { fn freeform_paints_survive_saving_and_old_files_load() { let (mut s, id) = freeform_rect(); run(&mut s, "paint.editGradient", json!({"mode": "lines"})); - let mut g = fill_gradient(&s, id); - g.freeform.as_mut().unwrap().add_line(vec![0, 1, 2]).unwrap(); - run(&mut s, "paint.setFill", json!({"gradient": vectorcraft_tools::params::gradient_params(&g)})); - assert_eq!(points(&s, id).lines, vec![vec![0, 1, 2]]); + 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(); @@ -124,3 +121,120 @@ fn freeform_paints_survive_saving_and_old_files_load() { 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::>(), b.points.iter().map(|p| p.color.to_hex()).collect::>()); +} + +// ---------- 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)); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 54129edc9..7bf354164 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -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) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index b26b9289e..ebfa48082 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -165,6 +165,9 @@ pub struct ToolContext<'a> { pub appearance_item: Option, /// 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, } impl ToolContext<'_> { @@ -349,6 +352,7 @@ pub(crate) mod testutil { gradient_stop: None, appearance_item: None, constrain_angle: 0.0, + freeform_point: None, } } } diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index b978116e3..6c52ae151 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -7,6 +7,7 @@ mod artboard; mod eyedropper; mod free; +mod freeform; mod gradient; mod measure; mod transform; diff --git a/crates/tools/src/xform/freeform.rs b/crates/tools/src/xform/freeform.rs new file mode 100644 index 000000000..5ecff9d37 --- /dev/null +++ b/crates/tools/src/xform/freeform.rs @@ -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 { + 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 { + (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 { + 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 { + cx.freeform_point.filter(|i| *i < self.freeform.points.len()) + } + + pub fn overlays(&self, cx: &ToolContext) -> Vec { + let f = &self.freeform; + let mut o: Vec = + (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) -> Action { + Action::Exec("paint.freeform.selectPoint".into(), json!({ "index": i })) +} + +pub(super) fn press(cx: &ToolContext, a: &Annotator, p: Point) -> (Vec, 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 { + 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 { + 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 { + 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 { + 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}))] + ); + } +} diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index f854374de..b1996922d 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -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 { 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, + /// A press on a freeform gradient's annotator. + free: Option, /// Smart-guide feedback of the handle being dragged. guides: Vec, } @@ -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 { + // 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 { 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 { + 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, diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index d997c004e..0c2f3bb90 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -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), Stop { paint: Paint, index: usize, color: Color }, + Point { index: usize, color: Color }, } impl Target { fn color(&self) -> Option { 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(); } diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 84b1ed2e7..99bd38a03 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -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) { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 343b8efd2..0593cce89 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/tests_freeform.rs b/crates/ui-egui/src/panels/tests_freeform.rs new file mode 100644 index 000000000..945cd854a --- /dev/null +++ b/crates/ui-egui/src/panels/tests_freeform.rs @@ -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, 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"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8c76851d4..34b93f1f5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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 } } diff --git a/docs/mcp.md b/docs/mcp.md index 1896f9445..65c17038b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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