diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs new file mode 100644 index 00000000..96ea9c39 --- /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 4fec8988..57921796 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 816a8789..e84a9650 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 32f44069..0475830e 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 28601a5c..7cadd5d9 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 00000000..7c99e824 --- /dev/null +++ b/crates/engine/src/cmd/freeform.rs @@ -0,0 +1,332 @@ +//! Freeform gradients: the points and lines of the freeform gradient behind the active proxy (the +//! Gradient panel's freeform section, the Color panel and the Gradient tool edit them through +//! these). Points are given in document coordinates; type runs keep them in text space. + +use serde_json::{Value, json}; +use vectorcraft_color::freeform::spread_scale; +use vectorcraft_color::{Freeform, FreeformMode, FreeformPoint, GradientKind, Paint}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; + +use super::appearance::{edit_items, edits_stroke, index_param, item_target}; +use super::gradient::{StopOwner, item_paint_bounds, run_paint_mut}; +use super::*; + +pub fn specs() -> Vec { + 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"))?; + 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)")) +} + +/// 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<> { + 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 0ce713bd..b42474ef 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 ), @@ -82,7 +82,7 @@ pub(crate) struct StopOwner { } impl StopOwner { - fn of(s: &Session) -> Self { + pub(crate) fn of(s: &Session) -> Self { Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() } } } @@ -153,6 +153,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { (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 0b2c998b..f997e181 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; @@ -196,6 +197,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(paint::proxy_specs()); v.extend(overprint::specs()); v.extend(swatchlib::specs()); + v.extend(freeform::specs()); v }) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 3763c20f..85268d36 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 0e9fa29f..fd59c012 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, } } @@ -771,6 +775,8 @@ mod tests_effectedit; #[cfg(test)] mod tests_file; #[cfg(test)] +mod tests_freeform; +#[cfg(test)] mod tests_gradient; #[cfg(test)] mod tests_gradpanel; diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests_freeform.rs new file mode 100644 index 00000000..cc01aada --- /dev/null +++ b/crates/engine/src/tests_freeform.rs @@ -0,0 +1,240 @@ +//! Freeform gradients through commands: the first application (M3.81), transforms and warps, and +//! the point and line commands (M3.82). + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Freeform, GradientKind, GradientPaint, Paint}; +use vectorcraft_geom::Point; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn shape(s: &mut Session, cmd: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute(cmd, &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> &vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap() +} + +fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint { + match node(s, id).appearance.fill_paint() { + Paint::Gradient(g) => *g, + p => panic!("expected a gradient fill, got {p:?}"), + } +} + +fn points(s: &Session, id: NodeId) -> Freeform { + fill_gradient(s, id).freeform.expect("placed freeform points") +} + +/// A selected 100 × 100 rectangle at (100, 100) with a freeform fill. +fn freeform_rect() -> (Session, NodeId) { + let mut s = session(); + let id = shape(&mut s, "shape.rectangle", 100.0, 100.0, 100.0, 100.0); + s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap(); + (s, id) +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-6 +} + +// ---------- M3.81: the first application, transforms, warps and saving ---------- + +#[test] +fn the_first_application_places_points_inside_the_shape() { + let mut s = session(); + // An ellipse: the box's corners are outside it. + let id = shape(&mut s, "shape.ellipse", 100.0, 100.0, 300.0, 100.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!(g.gradient.kind, GradientKind::Freeform); + let f = g.freeform.as_ref().unwrap(); + let inside = cmd::freeform::inside_fn(node(&s, id)); + assert!(f.points.len() >= 4 && f.points.iter().all(|p| inside(p.at)), "{:?}", f.points); + // The stops follow the points' colours (swatch chips and exports show them). + assert_eq!(g.gradient.stops.iter().map(|st| st.color).collect::>(), 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(); + run(&mut s, "paint.freeform.addPoint", json!({"at": [200, 200]})); + run(&mut s, "object.distort", json!({"corners": [[100, 100], [200, 100], [260, 240], [100, 200]]})); + let f = points(&s, id); + assert!(close(f.points.last().unwrap().at, Point::new(260.0, 240.0)), "{:?}", f.points.last()); +} + +#[test] +fn freeform_paints_survive_saving_and_old_files_load() { + let (mut s, id) = freeform_rect(); + run(&mut s, "paint.editGradient", json!({"mode": "lines"})); + run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1, 2]})); + vectorcraft_testkit::invariants::check_native_roundtrip_exact(&s.doc().unwrap().doc).unwrap(); + // A gradient saved before freeform points existed has none. + let old: GradientPaint = serde_json::from_value(json!({"gradient": {"kind": "Freeform", "stops": []}, "angle": 0.0})).unwrap(); + assert!(old.freeform.is_none()); + // The command params are lossless: setting the paint read back reproduces it. + let g = fill_gradient(&s, id); + let other = shape(&mut s, "shape.rectangle", 300.0, 300.0, 50.0, 50.0); + run(&mut s, "paint.setFill", json!({"gradient": vectorcraft_tools::params::gradient_params(&g), "ids": [other.0]})); + assert_eq!(fill_gradient(&s, other), g); +} + +#[test] +fn reapplied_gradients_place_their_points_afresh() { + let (mut s, id) = freeform_rect(); + run(&mut s, "paint.freeform.setPoint", json!({"index": 0, "color": "#ff0000"})); + // The last gradient (`.`) on other art: points inside it, coloured like the first object's. + let other = shape(&mut s, "shape.rectangle", 400.0, 300.0, 100.0, 100.0); + run(&mut s, "paint.lastGradient", json!({})); + let (a, (b, _)) = (points(&s, id), node(&s, other).proxy_freeform(false, None).unwrap()); + assert!(b.points.iter().all(|p| p.at.x >= 400.0 && p.at.y >= 300.0), "{:?}", b.points); + assert_eq!(a.points.iter().map(|p| p.color.to_hex()).collect::>(), 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 54129edc..7bf35416 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/render/src/freeform.rs b/crates/render/src/freeform.rs new file mode 100644 index 00000000..2a150d31 --- /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 1fbb639d..04986353 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 c33e89c5..731dde26 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 00000000..621e04f8 --- /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 92e0d2f3..ba79008b 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 c8b82223..15b89d4f 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/lib.rs b/crates/tools/src/lib.rs index b26b9289..ebfa4808 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/params.rs b/crates/tools/src/params.rs index faee9a18..2403d1fd 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 } diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index b978116e..6c52ae15 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 00000000..5ecff9d3 --- /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 f854374d..b1996922 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 d997c004..0c2f3bb9 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 84b1ed2e..99bd38a0 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 343b8efd..0593cce8 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 00000000..945cd854 --- /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 8c76851d..34b93f1f 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 1896f944..65c17038 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