diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs new file mode 100644 index 000000000..96ea9c395 --- /dev/null +++ b/crates/color/src/freeform.rs @@ -0,0 +1,534 @@ +//! Freeform gradients: colour points placed anywhere on an object (and smooth lines through them), +//! blended across it. +//! +//! Each point paints a disc of pure colour (its spread, a fraction of half the larger side of the +//! painted box) and blends into its neighbours beyond it by inverse-distance weighting on the +//! distance from the disc's edge. A line is a Catmull-Rom curve through its points: every spot +//! on it carries the colour, opacity and spread interpolated between the two points it lies +//! between, and acts as a source along its length (each short piece of it weighted by its share +//! of the line). Where discs overlap the deeper one wins, so the field stays continuous. + +use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect}; +use serde::{Deserialize, Serialize}; + +use crate::{Color, Gradient, GradientStop}; + +/// The spread a new point gets. +pub const DEFAULT_SPREAD: f32 = 0.0; +/// Line segments are tessellated into this many pieces for sampling. +const LINE_STEPS: usize = 16; + +fn one() -> f32 { + 1.0 +} +fn default_spread() -> f32 { + DEFAULT_SPREAD +} + +/// One colour point. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct FreeformPoint { + /// Position in the paint's space (document coordinates; text space for type runs). + pub at: Point, + pub color: Color, + #[serde(default = "one")] + pub opacity: f32, + /// Radius (0..=1) of the disc of pure colour around the point, as a fraction of + /// [`spread_scale`]. + #[serde(default = "default_spread")] + pub spread: f32, +} + +impl FreeformPoint { + pub fn new(at: Point, color: Color) -> Self { + Self { at, color, opacity: 1.0, spread: DEFAULT_SPREAD } + } +} + +/// How the Gradient tool adds points: free points, or points joined into lines. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FreeformMode { + #[default] + Points, + Lines, +} + +impl FreeformMode { + /// Parse a mode name (`points`, `lines`; any case). + pub fn parse(s: &str) -> Option { + [FreeformMode::Points, FreeformMode::Lines].into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + FreeformMode::Points => "Points", + FreeformMode::Lines => "Lines", + } + } + fn is_points(&self) -> bool { + *self == FreeformMode::Points + } +} + +/// A freeform gradient: its colour points and the lines through them. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Freeform { + pub points: Vec, + /// Each line is a smooth curve through `points` at these indices, in order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lines: Vec>, + /// Draw: how the Gradient tool adds points. + #[serde(default, skip_serializing_if = "FreeformMode::is_points")] + pub mode: FreeformMode, +} + +/// The length spreads are fractions of: half the larger side of the painted box. +pub fn spread_scale(b: Rect) -> f64 { + b.width().abs().max(b.height().abs()) / 2.0 +} + +/// Where a point lies nearest on a line (see [`Freeform::nearest_on_lines`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LineHit { + pub line: usize, + /// The segment from the line's point `segment` to point `segment + 1`. + pub segment: usize, + /// Curve parameter along the segment, 0..=1. + pub t: f64, + pub point: Point, + pub distance: f64, +} + +impl Freeform { + /// The points the first application places on an object with box `b`: a 2 × n grid (n = 2–4 + /// along the longer side), each moved to the nearest spot `inside` the shape when its grid + /// position isn't. They take colours along `g`, in a loop around the grid. + pub fn auto(b: Rect, g: &Gradient, inside: &dyn Fn(Point) -> bool) -> Self { + let (w, h) = (b.width().max(1e-9), b.height().max(1e-9)); + let long = ((2.0 * w.max(h) / w.min(h)).round() as usize).clamp(2, 4); + let (cols, rows) = if w >= h { (long, 2) } else { (2, long) }; + let cell = |i: usize, n: usize| (i as f64 + 0.5) / n as f64; + // Row by row, every other row backwards: neighbours in the list are neighbours on the art. + let targets: Vec = (0..rows) + .flat_map(|j| (0..cols).map(move |k| if j % 2 == 0 { (k, j) } else { (cols - 1 - k, j) })) + .map(|(i, j)| Point::new(b.x0 + cell(i, cols) * w, b.y0 + cell(j, rows) * h)) + .collect(); + const GRID: usize = 24; + let candidates: Vec = + (0..GRID * GRID).map(|k| Point::new(b.x0 + cell(k % GRID, GRID) * w, b.y0 + cell(k / GRID, GRID) * h)).filter(|p| inside(*p)).collect(); + let apart = w.min(h) * 0.15; + let mut placed: Vec = Vec::with_capacity(targets.len()); + for t in &targets { + let free = |c: &&Point| placed.iter().all(|q| q.distance(**c) >= apart); + let at = if inside(*t) { + *t + } else { + candidates.iter().filter(free).min_by(|a, b| a.distance_squared(*t).total_cmp(&b.distance_squared(*t))).copied().unwrap_or(*t) + }; + placed.push(at); + } + let mut f = Self { points: placed.into_iter().map(|at| FreeformPoint::new(at, Color::BLACK)).collect(), ..Default::default() }; + f.recolor(g); + f + } + + /// Colour the points along `g`, first to last. + pub fn recolor(&mut self, g: &Gradient) { + let n = self.points.len(); + for (k, p) in self.points.iter_mut().enumerate() { + (p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32); + } + } + + /// Gradient stops standing for the points (their colours evenly spaced, at least two): what + /// swatch chips, exports without freeform shading and re-seeding on other art use. + pub fn stops(&self) -> Vec { + let n = self.points.len(); + let stop = |offset: f32, p: &FreeformPoint| GradientStop { offset, color: p.color, opacity: p.opacity, midpoint: 0.5 }; + match n { + 0 => Gradient::default().stops, + 1 => vec![stop(0.0, &self.points[0]), stop(1.0, &self.points[0])], + _ => self.points.iter().enumerate().map(|(i, p)| stop(i as f32 / (n - 1) as f32, p)).collect(), + } + } + + /// Map every point through `f`. + pub fn map_points(&mut self, f: impl Fn(Point) -> Point) { + for p in &mut self.points { + p.at = f(p.at); + } + } + + /// Map every point through `a`. + pub fn transform(&mut self, a: Affine) { + self.map_points(|p| a * p); + } + + /// The cubic segments of line `line` (a Catmull-Rom curve through its points). + pub fn segments(&self, line: usize) -> Vec { + let Some(ix) = self.lines.get(line) else { return vec![] }; + // A line naming a missing point (a damaged file) draws nothing. + let Some(pts) = ix.iter().map(|i| self.points.get(*i).map(|p| p.at)).collect::>>() else { return vec![] }; + let n = pts.len(); + (0..n.saturating_sub(1)) + .map(|i| { + let (p0, p1, p2, p3) = (pts[i.saturating_sub(1)], pts[i], pts[i + 1], pts[(i + 2).min(n - 1)]); + CubicBez::new(p1, p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2) + }) + .collect() + } + + /// Line `line` as a path (for drawing it). + pub fn line_path(&self, line: usize) -> BezPath { + let mut bp = BezPath::new(); + for (i, c) in self.segments(line).into_iter().enumerate() { + if i == 0 { + bp.move_to(c.p0); + } + bp.curve_to(c.p1, c.p2, c.p3); + } + bp + } + + /// The spot on a line nearest `p`. + pub fn nearest_on_lines(&self, p: Point) -> Option { + let mut best: Option = None; + for line in 0..self.lines.len() { + for (segment, c) in self.segments(line).into_iter().enumerate() { + let n = c.nearest(p, 1e-6); + let distance = n.distance_sq.sqrt(); + if best.is_none_or(|b| distance < b.distance) { + best = Some(LineHit { line, segment, t: n.t, point: c.eval(n.t), distance }); + } + } + } + best + } + + /// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`. + pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) { + let ([r, g, b], a) = self.field(scale).sample(p); + (Color::rgb(r, g, b), a) + } + + /// The colour field, with colours converted and lines tessellated once (for sampling many + /// spots). + pub fn field(&self, scale: f64) -> Field { + let src = |p: &FreeformPoint| Source { at: p.at, rgb: p.color.to_rgb(), opacity: p.opacity, radius: p.spread.clamp(0.0, 1.0) as f64 * scale }; + let points: Vec = self.points.iter().map(src).collect(); + let mut pieces = vec![]; + for (l, ix) in self.lines.iter().enumerate() { + // The line tessellated into a polyline of sources. + let mut v: Vec = vec![]; + for (s, c) in self.segments(l).into_iter().enumerate() { + let (Some(a), Some(b)) = (ix.get(s).and_then(|i| points.get(*i)), ix.get(s + 1).and_then(|i| points.get(*i))) else { continue }; + for k in (if s == 0 { 0 } else { 1 })..=LINE_STEPS { + let t = k as f64 / LINE_STEPS as f64; + v.push(Source { at: c.eval(t), ..a.lerp(b, t as f32) }); + } + } + let len: f64 = v.windows(2).map(|w| w[0].at.distance(w[1].at)).sum(); + let n = v.len().saturating_sub(1).max(1) as f64; + pieces.extend(v.windows(2).map(|w| Piece { a: w[0], b: w[1], share: if len > 0.0 { w[0].at.distance(w[1].at) / len } else { 1.0 / n } })); + } + Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) } + } + + /// Add a point; returns its index. + pub fn add_point(&mut self, p: FreeformPoint) -> usize { + self.points.push(p); + self.points.len() - 1 + } + + /// Join point `to` to point `from`: extend the line that ends (or starts) at `from`, else start + /// a line from it. Returns the line's index (None when an index is out of range or equal). + pub fn connect(&mut self, from: usize, to: usize) -> Option { + if from == to || from >= self.points.len() || to >= self.points.len() { + return None; + } + if let Some(l) = self.lines.iter().position(|l| l.last() == Some(&from)) { + self.lines[l].push(to); + return Some(l); + } + if let Some(l) = self.lines.iter().position(|l| l.first() == Some(&from)) { + self.lines[l].insert(0, to); + return Some(l); + } + self.lines.push(vec![from, to]); + Some(self.lines.len() - 1) + } + + /// Add a line through `points` (at least two, each a valid index, none twice in a row). + pub fn add_line(&mut self, points: Vec) -> Result { + if points.len() < 2 { + return Err("a line needs at least two points".into()); + } + if let Some(i) = points.iter().find(|i| **i >= self.points.len()) { + return Err(format!("no point {i} (the gradient has {})", self.points.len())); + } + if points.windows(2).any(|w| w[0] == w[1]) { + return Err("a line can't run from a point to itself".into()); + } + self.lines.push(points); + Ok(self.lines.len() - 1) + } + + /// Remove point `i` (never the last one). Lines through it close up around it; lines left + /// with fewer than two points go. + pub fn remove_point(&mut self, i: usize) -> bool { + if i >= self.points.len() || self.points.len() <= 1 { + return false; + } + self.points.remove(i); + for l in &mut self.lines { + l.retain(|j| *j != i); + for j in l.iter_mut() { + if *j > i { + *j -= 1; + } + } + l.dedup(); + } + self.lines.retain(|l| l.len() >= 2); + true + } + + /// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour, + /// opacity and spread between the segment's ends. Returns the new point's index. + pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option { + let c = *self.segments(line).get(segment)?; + let l = &self.lines[line]; + let (a, b) = (self.points[l[segment]], self.points[l[segment + 1]]); + let t = t.clamp(0.0, 1.0); + let u = t as f32; + let p = FreeformPoint { + at: c.eval(t), + color: a.color.lerp(&b.color, u), + opacity: a.opacity + (b.opacity - a.opacity) * u, + spread: a.spread + (b.spread - a.spread) * u, + }; + let i = self.add_point(p); + self.lines[line].insert(segment + 1, i); + Some(i) + } +} + +/// One colour source of a [`Field`]. +#[derive(Clone, Copy, Debug)] +struct Source { + at: Point, + rgb: [f32; 3], + opacity: f32, + radius: f64, +} + +impl Source { + fn lerp(&self, o: &Source, t: f32) -> Source { + let l = |a: f32, b: f32| a + (b - a) * t; + Source { + at: self.at.lerp(o.at, t as f64), + rgb: [l(self.rgb[0], o.rgb[0]), l(self.rgb[1], o.rgb[1]), l(self.rgb[2], o.rgb[2])], + opacity: l(self.opacity, o.opacity), + radius: self.radius + (o.radius - self.radius) * t as f64, + } + } +} + +/// A freeform gradient ready to sample ([`Freeform::field`]). +#[derive(Clone, Debug)] +pub struct Field { + points: Vec, + /// The lines tessellated into short pieces. + pieces: Vec, + eps: f64, +} + +/// A piece of a tessellated line: a source at its spot nearest the sample, weighted by `share` +/// (its fraction of the line's length), so a whole line weighs as much as a point from afar and +/// the field stays continuous where the nearest spot on the line jumps. +#[derive(Clone, Copy, Debug)] +struct Piece { + a: Source, + b: Source, + share: f64, +} + +impl Piece { + /// The distance from `p` and the source at the nearest spot. + fn nearest(&self, p: Point) -> (f64, Source) { + let v = self.b.at - self.a.at; + let l2 = v.hypot2(); + let t = if l2 > 0.0 { ((p - self.a.at).dot(v) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let s = self.a.lerp(&self.b, t as f32); + (s.at.distance(p), s) + } +} + +impl Field { + /// Display RGB and opacity at `p`. + pub fn sample(&self, p: Point) -> ([f32; 3], f32) { + // (weight sum, weighted rgba) inside discs, and blending by distance outside them. + let mut disc = (0.0f64, [0.0f64; 4]); + let mut far = (0.0f64, [0.0f64; 4]); + let mut add = |d: f64, s: &Source, share: f64| { + let (acc, w) = if d < s.radius || d <= self.eps { + // The deeper inside, the stronger: weights fade to nothing at the disc's edge. + (&mut disc, (s.radius - d + self.eps) * share) + } else { + let e = d - s.radius; + (&mut far, share / (e * e)) + }; + acc.0 += w; + for (k, v) in s.rgb.iter().chain(std::iter::once(&s.opacity)).enumerate() { + acc.1[k] += w * *v as f64; + } + }; + for s in &self.points { + add(s.at.distance(p), s, 1.0); + } + for piece in &self.pieces { + let (d, s) = piece.nearest(p); + add(d, &s, piece.share); + } + let (w, acc) = if disc.0 > 0.0 { disc } else { far }; + if !(w > 0.0 && w.is_finite()) { + return ([0.0; 3], 0.0); + } + let c = |k: usize| (acc[k] / w) as f32; + ([c(0), c(1), c(2)], c(3)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn red() -> Color { + Color::rgb(1.0, 0.0, 0.0) + } + fn blue() -> Color { + Color::rgb(0.0, 0.0, 1.0) + } + + fn two(spread: f32) -> Freeform { + Freeform { + points: vec![ + FreeformPoint { spread, ..FreeformPoint::new(Point::new(0.0, 0.0), red()) }, + FreeformPoint { spread, opacity: 0.5, ..FreeformPoint::new(Point::new(100.0, 0.0), blue()) }, + ], + ..Default::default() + } + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) + } + + #[test] + fn the_sample_at_a_point_is_its_colour() { + let f = two(0.0); + let (c, o) = f.sample(Point::new(0.0, 0.0), 50.0); + assert!(close(c.to_rgb(), [1.0, 0.0, 0.0]) && (o - 1.0).abs() < 1e-6, "{c:?} {o}"); + let (c, o) = f.sample(Point::new(100.0, 0.0), 50.0); + assert!(close(c.to_rgb(), [0.0, 0.0, 1.0]) && (o - 0.5).abs() < 1e-6); + // Half way between equal points: an even mix. + let (c, o) = f.sample(Point::new(50.0, 0.0), 50.0); + assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}"); + } + + #[test] + fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() { + // Scale 100: a 0.2 spread is a 20 pt disc. + let mut f = two(0.0); + f.points[0].spread = 0.2; + let at = |x: f64| f.sample(Point::new(x, 0.0), 100.0).0.to_rgb()[0]; + assert!((at(15.0) - 1.0).abs() < 1e-6, "inside the disc: pure red"); + assert!(at(30.0) < 1.0 && at(30.0) > at(50.0) && at(50.0) > at(70.0), "falls off: {} {} {}", at(30.0), at(50.0), at(70.0)); + // Without the spread the same spot is bluer. + let plain = two(0.0).sample(Point::new(30.0, 0.0), 100.0).0.to_rgb()[0]; + assert!(at(30.0) > plain); + // Overlapping discs blend continuously (no jump at the edge of the smaller one). + f.points[1].spread = 0.9; + let a = f.sample(Point::new(10.01, 0.0), 100.0).0.to_rgb()[0]; + let b = f.sample(Point::new(9.99, 0.0), 100.0).0.to_rgb()[0]; + assert!((a - b).abs() < 0.01, "{a} vs {b}"); + } + + #[test] + fn lines_carry_colour_between_their_points() { + let mut f = two(0.0); + f.points.push(FreeformPoint::new(Point::new(50.0, 100.0), Color::rgb(0.0, 1.0, 0.0))); + let off_line = f.sample(Point::new(50.0, 5.0), 100.0).0.to_rgb(); + f.add_line(vec![0, 1]).unwrap(); + let on_line = f.sample(Point::new(50.0, 5.0), 100.0).0.to_rgb(); + // Near the line's middle the red-blue mix dominates the green point. + assert!(on_line[1] < off_line[1], "{on_line:?} vs {off_line:?}"); + let hit = f.nearest_on_lines(Point::new(50.0, 7.0)).unwrap(); + assert_eq!((hit.line, hit.segment), (0, 0)); + assert!((hit.t - 0.5).abs() < 1e-3 && (hit.distance - 7.0).abs() < 1e-3, "{hit:?}"); + } + + #[test] + fn the_field_is_continuous_around_lines() { + // A bent line: where the nearest spot on it jumps the colour must not. + let mut f = two(0.0); + f.points.push(FreeformPoint::new(Point::new(50.0, 100.0), Color::rgb(0.0, 1.0, 0.0))); + f.points.push(FreeformPoint { spread: 0.1, ..FreeformPoint::new(Point::new(-50.0, 80.0), Color::WHITE) }); + f.add_line(vec![0, 1, 2]).unwrap(); + let field = f.field(100.0); + let at = |x: f64, y: f64| field.sample(Point::new(x, y)).0; + for i in 0..80 { + for j in 0..80 { + let (x, y) = (-60.0 + i as f64 * 2.5, -60.0 + j as f64 * 2.5); + let (a, b, c) = (at(x, y), at(x + 0.5, y), at(x, y + 0.5)); + let step = (0..3).map(|k| (a[k] - b[k]).abs().max((a[k] - c[k]).abs())).fold(0.0, f32::max); + assert!(step < 0.06, "jump of {step} at ({x}, {y})"); + } + } + } + + #[test] + fn auto_places_four_or_more_points_inside_the_shape() { + let g = Gradient::default(); + let b = Rect::new(0.0, 0.0, 200.0, 100.0); + // A triangle with its apex at the top middle. + let inside = |p: Point| p.y >= 0.0 && p.y <= 100.0 && (p.x - 100.0).abs() <= p.y; + let f = Freeform::auto(b, &g, &inside); + assert!(f.points.len() >= 4, "{}", f.points.len()); + assert!(f.points.iter().all(|p| inside(p.at)), "{:?}", f.points); + // Colours run from the gradient's start to its end. + assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ffffff".into(), "#000000".into())); + // A long box gets more points along its length. + assert!(Freeform::auto(Rect::new(0.0, 0.0, 400.0, 100.0), &g, &|_| true).points.len() > 4); + } + + #[test] + fn editing_keeps_lines_consistent() { + let mut f = two(0.0); + let c = f.add_point(FreeformPoint::new(Point::new(200.0, 0.0), red())); + assert_eq!(f.connect(0, 1), Some(0)); + assert_eq!(f.connect(1, c), Some(0), "extends the line ending at 1"); + assert_eq!(f.lines, vec![vec![0, 1, 2]]); + let mid = f.split_line(0, 0, 0.5).unwrap(); + assert_eq!((mid, &f.lines[0]), (3, &vec![0, 3, 1, 2])); + let p = f.points[3]; + assert!(p.at.y.abs() < 1e-9 && p.at.x > 0.0 && p.at.x < 100.0 && (p.opacity - 0.75).abs() < 1e-6, "on the curve, half way: {p:?}"); + assert!(f.remove_point(1)); + assert_eq!(f.lines, vec![vec![0, 2, 1]]); + assert!(f.add_line(vec![0, 9]).is_err() && f.add_line(vec![0]).is_err()); + f.remove_point(0); + f.remove_point(0); + assert!(f.lines.is_empty() && f.points.len() == 1); + assert!(!f.remove_point(0), "the last point stays"); + assert_eq!(f.stops().len(), 2); + } + + #[test] + fn serde_defaults_and_mode() { + let f: Freeform = serde_json::from_str(r#"{"points":[{"at":{"x":1.0,"y":2.0},"color":{"model":"rgb","r":1.0,"g":0.0,"b":0.0}}]}"#).unwrap(); + assert_eq!((f.points[0].opacity, f.points[0].spread, f.mode), (1.0, DEFAULT_SPREAD, FreeformMode::Points)); + let s = serde_json::to_string(&f).unwrap(); + assert!(!s.contains("\"lines\"") && !s.contains("\"mode\""), "{s}"); + assert_eq!(FreeformMode::parse("LINES"), Some(FreeformMode::Lines)); + } +} diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 4fec89887..57921796a 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -1,16 +1,19 @@ //! Gradients: definitions (swatch-able) and their placement on an object. +use std::borrow::Cow; + use kurbo::{Affine, Point, Rect, Vec2}; use serde::{Deserialize, Serialize}; use crate::Color; +use crate::freeform::Freeform; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum GradientKind { #[default] Linear, Radial, - /// Freeform gradients (points/lines) — rendered via a mesh approximation. + /// Freeform gradients: colour points and lines (see [`crate::freeform`]). Freeform, } @@ -284,12 +287,8 @@ impl GradientGeom { /// relative to the box (along a side `from` has no extent on, their offset from its centre). /// The aspect ratio is kept. pub fn rebase(&mut self, from: Rect, to: Rect) { - let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| { - if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 } - }; - let map = |p: Point| Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1)); - self.start = map(self.start); - self.end = map(self.end); + self.start = rebase_point(self.start, from, to); + self.end = rebase_point(self.end, from, to); } /// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection @@ -316,6 +315,15 @@ impl GradientGeom { } } +/// `p` moved from box `from` to box `to`, keeping its position relative to the box (along a side +/// `from` has no extent on, its offset from the centre). +fn rebase_point(p: Point, from: Rect, to: Rect) -> Point { + let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| { + if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 } + }; + Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1)) +} + /// A gradient applied to a fill or stroke. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GradientPaint { @@ -328,33 +336,79 @@ pub struct GradientPaint { /// Name of the gradient swatch, if linked. #[serde(default, skip_serializing_if = "Option::is_none")] pub swatch: Option, + /// A freeform gradient's points, in the same space as `geom` (None: placed automatically on + /// the object's box each render, coloured along the stops). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub freeform: Option, } impl GradientPaint { pub fn new(gradient: Gradient) -> Self { - Self { gradient, geom: None, angle: 0.0, swatch: None } + Self { gradient, geom: None, angle: 0.0, swatch: None, freeform: None } + } + /// The freeform gradient drawn on box `bounds`: its own points, else the automatic ones + /// (fitted to the box: [`Freeform::auto`] without a shape to keep them inside). + pub fn freeform_on(&self, bounds: Rect) -> Cow<'_, Freeform> { + match &self.freeform { + Some(f) if !f.points.is_empty() => Cow::Borrowed(f), + f => Cow::Owned(Freeform { + mode: f.as_ref().map_or_else(Default::default, |f| f.mode), + ..Freeform::auto(bounds, &self.gradient, &|_| true) + }), + } + } + /// Does it carry its own freeform points (rather than placing them on each render)? + pub fn has_freeform_points(&self) -> bool { + self.freeform.as_ref().is_some_and(|f| !f.points.is_empty()) + } + /// Give a freeform gradient without points its automatic ones on `bounds`, kept `inside` the + /// shape (the Draw mode stays). + pub fn seed_freeform(&mut self, bounds: Rect, inside: &dyn Fn(Point) -> bool) { + if self.gradient.kind == GradientKind::Freeform && !self.has_freeform_points() { + let mode = self.freeform.as_ref().map_or_else(Default::default, |f| f.mode); + self.set_freeform(Freeform { mode, ..Freeform::auto(bounds, &self.gradient, inside) }); + } + } + /// Set the freeform points, with the stops following their colours (what swatch chips, + /// exports without freeform shading and re-seeding on other art show). + pub fn set_freeform(&mut self, f: Freeform) { + self.gradient.stops = f.stops(); + self.freeform = Some(f); } pub fn resolve(&self, bounds: Rect) -> GradientGeom { self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle)) } + /// Is the gradient placed in its paint's space (its vector, and a freeform gradient's points), + /// rather than fitted to the object's box on each render? + pub fn is_placed(&self) -> bool { + self.geom.is_some() && (self.gradient.kind != GradientKind::Freeform || self.has_freeform_points()) + } /// Fix an unplaced gradient (geom None) to its fit on `bounds`, so it can follow transforms /// that refitting wouldn't reproduce (rotation, shear, non-uniform scale, warps). + /// Freeform gradients get their automatic points. pub fn pin(&mut self, bounds: Rect) { if self.geom.is_none() { self.geom = Some(self.resolve(bounds)); } + self.seed_freeform(bounds, &|_| true); } /// Move a placed gradient from box `from` to box `to` (see [`GradientGeom::rebase`]). pub fn rebase(&mut self, from: Rect, to: Rect) { if let Some(g) = &mut self.geom { g.rebase(from, to); } + if let Some(f) = &mut self.freeform { + f.map_points(|p| rebase_point(p, from, to)); + } } - /// Map a placed gradient through `a` (see [`GradientGeom::transform`]). + /// Map a placed gradient (and freeform points) through `a` (see [`GradientGeom::transform`]). pub fn transform(&mut self, a: Affine) { if let Some(g) = &mut self.geom { g.transform(a, self.gradient.kind); } + if let Some(f) = &mut self.freeform { + f.transform(a); + } } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 816a87897..e84a96507 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -6,6 +6,7 @@ pub mod blend; pub mod cms; +pub mod freeform; pub mod gradient; pub mod harmony; pub mod libraries; @@ -13,6 +14,7 @@ pub mod palette_io; pub mod swatch; pub use blend::BlendMode; +pub use freeform::{Freeform, FreeformMode, FreeformPoint}; pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; pub use libraries::SwatchLibrary; pub use swatch::{Swatch, SwatchGroup, default_swatches}; diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 32f44069d..0475830e5 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -590,6 +590,12 @@ impl Appearance { { *ga = *go; } + if let (Some(fa), Some(fo)) = (&mut g.freeform, &o.freeform) + && fa.points.len() == fo.points.len() + && fa.points.iter().zip(&fo.points).all(|(p, q)| p.at.distance(q.at) <= 1e-6) + { + fa.points.iter_mut().zip(&fo.points).for_each(|(p, q)| p.at = q.at); + } } a == *other } @@ -598,7 +604,7 @@ impl Appearance { fn has_gradient(&self, placed: bool) -> bool { self.items.iter().any(|i| match i { AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) - | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_some() == placed, + | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.is_placed() == placed, _ => false, }) } @@ -650,13 +656,19 @@ impl Appearance { } } /// Map placed gradients through a warp, given its local affine approximation at a point (taken - /// at each gradient's centre: the start of a radial, the middle of a linear vector). + /// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform + /// points each follow the warp at their own position. pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) { for g in self.gradients_mut() { + let freeform = g.freeform.take(); if let Some(geom) = g.geom { let c = if g.gradient.kind == vectorcraft_color::GradientKind::Radial { geom.start } else { geom.start.midpoint(geom.end) }; g.transform(near(c)); } + g.freeform = freeform.map(|mut f| { + f.map_points(|p| near(p) * p); + f + }); } } } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 28601a5ce..7cadd5d90 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -592,6 +592,23 @@ impl Node { } Some((g, geom)) } + /// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates + /// (the automatic ones while unplaced), and the length its spreads are fractions of there + /// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits. + pub fn proxy_freeform(&self, stroke: bool, item: Option) -> Option<(vectorcraft_color::Freeform, f64)> { + let (paint, to_doc, b) = self.proxy_paint(stroke, item)?; + let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; + if g.gradient.kind != vectorcraft_color::GradientKind::Freeform { + return None; + } + let mut f = g.freeform_on(b).into_owned(); + let mut scale = vectorcraft_color::freeform::spread_scale(b); + if to_doc != Affine::IDENTITY { + f.transform(to_doc); + scale *= to_doc.determinant().abs().sqrt(); + } + Some((f, scale)) + } } /// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs new file mode 100644 index 000000000..966324965 --- /dev/null +++ b/crates/engine/src/cmd/freeform.rs @@ -0,0 +1,67 @@ +//! Freeform gradients: parsing their points (the `freeform` param of a gradient paint) and the +//! shape test their first points are placed with. + +use serde_json::Value; +use vectorcraft_color::{Freeform, FreeformMode, FreeformPoint}; +use vectorcraft_doc::Node; +use vectorcraft_geom::Affine; + +use super::*; + +/// The `opacity` / `spread` convention: 0..1, or a percentage above 1. +fn fraction(v: &Value, key: &str) -> std::result::Result { + let x = v.as_f64().ok_or_else(|| format!("`{key}` must be a number"))?; + Ok((if x > 1.0 { x / 100.0 } else { x }).clamp(0.0, 1.0) as f32) +} + +/// Apply the `at` (mapped by `to_paint`), `color`, `opacity` and `spread` fields of `v` to `pt`. +fn point_fields(v: &Value, pt: &mut FreeformPoint, to_paint: Affine) -> std::result::Result<(), String> { + if v.get("at").is_some() { + pt.at = to_paint * point_param(v, "at").ok_or("`at` must be [x, y]")?; + } + if let Some(c) = v.get("color") { + pt.color = color_value(c).ok_or("`color` must be a colour")?; + } + if let Some(o) = v.get("opacity") { + pt.opacity = fraction(o, "opacity")?; + } + if let Some(s) = v.get("spread") { + pt.spread = fraction(s, "spread")?; + } + Ok(()) +} + +/// Parse the `freeform` param of a gradient paint (`{points: [{at, color, opacity?, spread?}], +/// lines?: [[index, …]], mode?}`; points in the paint's space). Lossless for +/// `vectorcraft_tools::params::freeform_json`. +pub(crate) fn parse_freeform(v: &Value) -> std::result::Result { + let points = v.get("points").and_then(Value::as_array).ok_or("`freeform.points` must be an array")?; + let mut f = Freeform::default(); + for (i, pv) in points.iter().enumerate() { + let (Some(at), Some(color)) = (point_param(pv, "at"), pv.get("color").and_then(color_value)) else { + return Err(format!("freeform point {i} needs `at` [x, y] and a valid `color`")); + }; + let mut pt = FreeformPoint::new(at, color); + point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?; + f.points.push(pt); + } + for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() { + let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default(); + f.add_line(ix)?; + } + if let Some(m) = str_param(v, "mode") { + f.mode = parse_mode(m)?; + } + Ok(f) +} + +pub(crate) fn parse_mode(m: &str) -> std::result::Result { + FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)")) +} + +/// Whether `n`'s shape contains `p` (its filled regions; true for objects without them, such as +/// type, whose boxes stand in). +pub(crate) fn inside_fn(n: &Node) -> impl Fn(vectorcraft_geom::Point) -> bool + use<> { + let shapes = n.clip_shapes(None); + move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)) +} diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 0ce713bdd..4bfa98a41 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -153,6 +153,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { (None, None) => {} _ => return Err("give both `start` and `end` (or neither)".into()), } + if let Some(f) = g.get("freeform") { + gp.freeform = Some(super::freeform::parse_freeform(f)?); + } Ok(gp) } @@ -164,10 +167,13 @@ fn aspect_param(p: &Value) -> Parsed> { } /// `paint` without the placement it had on the art it came from (a swatch, or the default paint -/// for new art): a placed gradient keeps its angle and fits each object it lands on. +/// for new art): a placed gradient keeps its angle and fits each object it lands on (freeform +/// points are placed afresh, coloured along the stops, which follow the points' colours). pub(crate) fn unplaced(paint: &Paint) -> Paint { match paint { - Paint::Gradient(g) if g.geom.is_some() => Paint::Gradient(Box::new(GradientPaint { geom: None, ..(**g).clone() })), + Paint::Gradient(g) if g.geom.is_some() || g.freeform.is_some() => { + Paint::Gradient(Box::new(GradientPaint { geom: None, freeform: None, ..(**g).clone() })) + } _ => paint.clone(), } } @@ -184,6 +190,8 @@ pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Pai let Paint::Gradient(g) = paint else { return paint.clone() }; let aspect = match g.geom { Some(geom) if applies_swatch(p) => (geom.aspect != 1.0).then(|| json!(geom.aspect * 100.0)), + // Freeform points given with the paint stay where they were put. + _ if g.freeform.is_some() && !applies_swatch(p) => return paint.clone(), None => p.get("gradient").and_then(|g| g.get("aspect")).cloned(), Some(_) => return paint.clone(), }; @@ -196,17 +204,32 @@ pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Pai /// Apply the gradient edits in `p` to `paint` (pure; unit-tested). pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { + apply_gradient_edit_in(paint, p, bounds, &|_| true) +} + +/// [`apply_gradient_edit`] on a shape that contains the points `inside` does: a gradient turned +/// freeform gets its first points there. +pub(crate) fn apply_gradient_edit_in( + paint: &Paint, + p: &Value, + bounds: Option, + inside: &dyn Fn(Point) -> bool, +) -> std::result::Result { let mut gp = match paint { Paint::Gradient(g) => (**g).clone(), _ => GradientPaint::new(Gradient::default()), }; + let mut switched = false; if let Some(k) = str_param(p, "kind") { let kind = parse_kind(k)?; if kind != gp.gradient.kind { gp.gradient.kind = kind; gp.geom = None; + gp.freeform = None; + switched = true; } } + let recolor = p.get("stops").is_some() || bool_or(p, "reverse", false); if let Some(stops) = p.get("stops") { gp.gradient.stops = parse_stops(stops)?; gp.swatch = None; @@ -246,6 +269,27 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option g.aspect = asp; } } + if gp.gradient.kind == GradientKind::Freeform { + // Points already shown (automatic ones fitted to the box) stay put; a fresh freeform + // gradient gets its points inside the shape. + if let Some(b) = bounds { + gp.seed_freeform(b, if switched { inside } else { &|_| true }); + } + if recolor + && !switched + && let Some(f) = &mut gp.freeform + { + f.recolor(&gp.gradient); + gp.gradient.stops = f.stops(); + } + } + if let Some(m) = str_param(p, "mode") { + let mode = super::freeform::parse_mode(m)?; + if gp.gradient.kind != GradientKind::Freeform { + return Err("`mode` applies to freeform gradients".into()); + } + gp.freeform.get_or_insert_with(Default::default).mode = mode; + } Ok(Paint::Gradient(Box::new(gp))) } @@ -258,6 +302,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { // Validate against the defaults first so bad params fail without touching the document. let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }; let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?; + let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind @@ -270,7 +315,13 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { return Ok(()); } let b = item_paint_bounds(n, index, !stroke); - let np = apply_gradient_edit(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b).map_err(|e| bad(C, e))?; + // Only a gradient turning freeform needs the shape (its first points go inside it). + let inside = (freeform && b.is_some()).then(|| super::freeform::inside_fn(n)); + let inside: &dyn Fn(Point) -> bool = match &inside { + Some(f) => f, + None => &|_| true, + }; + let np = apply_gradient_edit_in(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b, inside).map_err(|e| bad(C, e))?; n.appearance.set_paint_at(index, !stroke, np); Ok(()) })?; @@ -321,7 +372,7 @@ pub(crate) fn item_paint_bounds(n: &Node, index: Option, fill: bool) -> O pub(crate) fn place_run_paint(paint: &Paint, p: &Value, xf: Affine, bounds: Rect) -> Paint { let mut out = place_paint(paint, p, Some(bounds)); if let (Paint::Gradient(src), Paint::Gradient(g)) = (paint, &mut out) - && src.geom.is_some() + && (src.geom.is_some() || src.freeform.is_some()) && !applies_swatch(p) && let Some(inv) = invert(xf) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0b2c998bd..8c1d4faf8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,6 +15,7 @@ mod edit; mod effectcmd; pub mod fileio; mod fonts; +pub(crate) mod freeform; pub(crate) mod gradient; pub(crate) mod graph; pub mod help; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0e9fa29f4..1a497b77f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -771,6 +771,8 @@ mod tests_effectedit; #[cfg(test)] mod tests_file; #[cfg(test)] +mod tests_freeform; +#[cfg(test)] mod tests_gradient; #[cfg(test)] mod tests_gradpanel; diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests_freeform.rs new file mode 100644 index 000000000..d331b39b1 --- /dev/null +++ b/crates/engine/src/tests_freeform.rs @@ -0,0 +1,126 @@ +//! Freeform gradients through commands: the first application, transforms and warps (M3.81). + +use serde_json::{Value, json}; +use vectorcraft_color::{Freeform, GradientKind, GradientPaint, Paint}; +use vectorcraft_geom::Point; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn shape(s: &mut Session, cmd: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute(cmd, &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> &vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap() +} + +fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint { + match node(s, id).appearance.fill_paint() { + Paint::Gradient(g) => *g, + p => panic!("expected a gradient fill, got {p:?}"), + } +} + +fn points(s: &Session, id: NodeId) -> Freeform { + fill_gradient(s, id).freeform.expect("placed freeform points") +} + +/// A selected 100 × 100 rectangle at (100, 100) with a freeform fill. +fn freeform_rect() -> (Session, NodeId) { + let mut s = session(); + let id = shape(&mut s, "shape.rectangle", 100.0, 100.0, 100.0, 100.0); + s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap(); + (s, id) +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-6 +} + +// ---------- M3.81: the first application, transforms, warps and saving ---------- + +#[test] +fn the_first_application_places_points_inside_the_shape() { + let mut s = session(); + // An ellipse: the box's corners are outside it. + let id = shape(&mut s, "shape.ellipse", 100.0, 100.0, 300.0, 100.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!(g.gradient.kind, GradientKind::Freeform); + let f = g.freeform.as_ref().unwrap(); + let inside = cmd::freeform::inside_fn(node(&s, id)); + assert!(f.points.len() >= 4 && f.points.iter().all(|p| inside(p.at)), "{:?}", f.points); + // The stops follow the points' colours (swatch chips and exports show them). + assert_eq!(g.gradient.stops.iter().map(|st| st.color).collect::>(), f.points.iter().map(|p| p.color).collect::>()); + // Draw mode: freeform only. + s.execute("paint.editGradient", &json!({"mode": "lines"})).unwrap(); + assert_eq!(points(&s, id).mode, vectorcraft_color::FreeformMode::Lines); + assert_eq!(points(&s, id).points, f.points, "the points stay"); + s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap(); + assert!(fill_gradient(&s, id).freeform.is_none(), "a linear gradient drops the points"); + let e = s.execute("paint.editGradient", &json!({"mode": "lines"})).unwrap_err().to_string(); + assert!(e.contains("freeform"), "{e}"); +} + +#[test] +fn new_stops_recolour_the_points() { + let (mut s, id) = freeform_rect(); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + s.execute("paint.editGradient", &json!({"stops": stops})).unwrap(); + let f = points(&s, id); + assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ff0000".into(), "#0000ff".into())); +} + +#[test] +fn transforms_and_warps_move_the_points() { + let (mut s, id) = freeform_rect(); + let before = points(&s, id); + run(&mut s, "object.move", json!({"dx": 10, "dy": 5})); + let moved = points(&s, id); + assert!(before.points.iter().zip(&moved.points).all(|(a, b)| close(b.at, a.at + vectorcraft_geom::Vec2::new(10.0, 5.0)))); + run(&mut s, "object.rotate", json!({"angle": 90, "origin": [160, 155]})); + let turned = points(&s, id); + // The y-down document turns (x, y) about the origin to (x', y') = (160 + (y − 155), 155 − (x − 160)). + for (a, b) in moved.points.iter().zip(&turned.points) { + let want = Point::new(160.0 + (a.at.y - 155.0), 155.0 - (a.at.x - 160.0)); + assert!(close(b.at, want), "{:?} → {:?}, want {want:?}", a.at, b.at); + } + // A perspective distort maps each point exactly: a point on a corner stays on it. + let (mut s, id) = freeform_rect(); + let corner = json!({"kind": "freeform", "freeform": {"points": [{"at": [200, 200], "color": "#ff0000"}]}}); + run(&mut s, "paint.setFill", json!({ "gradient": corner })); + run(&mut s, "object.distort", json!({"corners": [[100, 100], [200, 100], [260, 240], [100, 200]]})); + let f = points(&s, id); + assert!(close(f.points.last().unwrap().at, Point::new(260.0, 240.0)), "{:?}", f.points.last()); +} + +#[test] +fn freeform_paints_survive_saving_and_old_files_load() { + let (mut s, id) = freeform_rect(); + run(&mut s, "paint.editGradient", json!({"mode": "lines"})); + let mut g = fill_gradient(&s, id); + g.freeform.as_mut().unwrap().add_line(vec![0, 1, 2]).unwrap(); + run(&mut s, "paint.setFill", json!({"gradient": vectorcraft_tools::params::gradient_params(&g)})); + assert_eq!(points(&s, id).lines, vec![vec![0, 1, 2]]); + vectorcraft_testkit::invariants::check_native_roundtrip_exact(&s.doc().unwrap().doc).unwrap(); + // A gradient saved before freeform points existed has none. + let old: GradientPaint = serde_json::from_value(json!({"gradient": {"kind": "Freeform", "stops": []}, "angle": 0.0})).unwrap(); + assert!(old.freeform.is_none()); + // The command params are lossless: setting the paint read back reproduces it. + let g = fill_gradient(&s, id); + let other = shape(&mut s, "shape.rectangle", 300.0, 300.0, 50.0, 50.0); + run(&mut s, "paint.setFill", json!({"gradient": vectorcraft_tools::params::gradient_params(&g), "ids": [other.0]})); + assert_eq!(fill_gradient(&s, other), g); +} diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs new file mode 100644 index 000000000..2a150d31e --- /dev/null +++ b/crates/render/src/freeform.rs @@ -0,0 +1,105 @@ +//! Freeform gradients in the renderer. +//! +//! vello has no freeform shading, so the colour field ([`vectorcraft_color::freeform`]) is +//! sampled on a grid of cells over the painted box and drawn as an image paint (bilinear, edges +//! padded): the fill or stroke being painted clips it. The grid is sized from the box's size on +//! screen (a cell per few device pixels, in power-of-two steps so zooming reuses it) and cached by +//! the gradient's content, so panning and redraws don't resample it. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use vectorcraft_color::freeform::spread_scale; +use vectorcraft_color::{Color, Freeform, GradientPaint}; +use vectorcraft_geom::{Affine, Point, Rect}; +use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; +use vello_cpu::{Pixmap, RenderContext}; + +/// Device pixels per grid cell. +const CELL_PX: f64 = 4.0; +/// Fewest and most cells along the box's longer side. +const MIN_CELLS: f64 = 8.0; +const MAX_CELLS: f64 = 256.0; +/// Grids kept before the cache starts over. +const MAX_GRIDS: usize = 128; + +thread_local! { + static GRIDS: RefCell>> = RefCell::new(HashMap::new()); +} + +fn hash_color(c: &Color, h: &mut impl Hasher) { + match *c { + Color::Rgb { r, g, b } => (0u8, r.to_bits(), g.to_bits(), b.to_bits()).hash(h), + Color::Cmyk { c, m, y, k } => (1u8, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()).hash(h), + Color::Gray { k } => (2u8, k.to_bits()).hash(h), + } +} + +/// Cache key of the grid of `f` on box `b` at `cols` × `rows` (and the active colour settings, +/// which turn colours into display RGB). +fn key(f: &Freeform, b: Rect, cols: u16, rows: u16) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for p in &f.points { + (p.at.x.to_bits(), p.at.y.to_bits(), p.opacity.to_bits(), p.spread.to_bits()).hash(&mut h); + hash_color(&p.color, &mut h); + } + f.lines.hash(&mut h); + (b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h); + (Arc::as_ptr(&vectorcraft_color::cms::active()) as usize).hash(&mut h); + h.finish() +} + +/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied). +pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16) -> Pixmap { + let field = f.field(spread_scale(b)); + let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); + let data = (0..rows as usize * cols as usize) + .map(|k| { + let (i, j) = (k % cols as usize, k / cols as usize); + let ([r, g, bl], a) = field.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)); + let a = a.clamp(0.0, 1.0); + let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8; + vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 } + }) + .collect(); + Pixmap::from_parts(data, cols, rows) +} + +/// Cells along a side `len` (document units) long whose box's longer side `side` gets `cells`. +fn cells_along(len: f64, side: f64, cells: f64) -> u16 { + (cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16 +} + +/// Set freeform gradient `g` on box `bounds` (in the paint's space, which the context's current +/// transform maps to pixels) as the context paint. Returns false for an empty box. +pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect) -> bool { + let b = bounds.abs(); + let side = b.width().max(b.height()); + if !(side > 1e-9 && side.is_finite()) { + return false; + } + // A box without height (or width) gets a sliver of it, so the grid maps back invertibly. + let b = Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3))); + let device = side * ctx.transform().determinant().abs().sqrt(); + let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS); + let (cols, rows) = (cells_along(b.width(), side, cells), cells_along(b.height(), side, cells)); + let f = g.freeform_on(b); + let k = key(&f, b, cols, rows); + let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| { + let pm = Arc::new(rasterize(&f, b, cols, rows)); + GRIDS.with(|c| { + let mut c = c.borrow_mut(); + if c.len() >= MAX_GRIDS { + c.clear(); + } + c.insert(k, pm.clone()); + }); + pm + }); + let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality: ImageQuality::Medium, alpha: 1.0 }; + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler }); + ctx.set_paint_transform(Affine::translate(b.origin().to_vec2()) * Affine::scale_non_uniform(b.width() / cols as f64, b.height() / rows as f64)); + true +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1fbb639d1..049863533 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod brush_fx; +mod freeform; mod fx; mod group; mod live; @@ -1161,6 +1162,8 @@ mod tests_clip; #[cfg(test)] mod tests_container; #[cfg(test)] +mod tests_freeform; +#[cfg(test)] mod tests_isolation; #[cfg(test)] mod tests_knockout; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index c33e89c53..731dde26f 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -19,6 +19,7 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Documen ctx.set_paint(color(c, 1.0)); true } + Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds), Paint::Gradient(g) => { let geom = g.resolve(bounds); let stops: Vec = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect(); diff --git a/crates/render/src/tests_freeform.rs b/crates/render/src/tests_freeform.rs new file mode 100644 index 000000000..621e04f87 --- /dev/null +++ b/crates/render/src/tests_freeform.rs @@ -0,0 +1,96 @@ +//! Freeform gradients: the rendered colour grid against the colour field. + +use super::*; +use vectorcraft_color::freeform::spread_scale; +use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::{Point, shapes}; + +/// Red at the top left, blue at the bottom right, a half-transparent green at the top right. +fn freeform() -> Freeform { + let p = |x, y, c, opacity| FreeformPoint { opacity, spread: 0.1, ..FreeformPoint::new(Point::new(x, y), c) }; + Freeform { + points: vec![ + p(20.0, 20.0, Color::rgb(1.0, 0.0, 0.0), 1.0), + p(80.0, 80.0, Color::rgb(0.0, 0.0, 1.0), 1.0), + p(80.0, 20.0, Color::rgb(0.0, 1.0, 0.0), 0.5), + ], + ..Default::default() + } +} + +fn paint(f: Option) -> Paint { + let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() }); + g.freeform = f; + Paint::Gradient(Box::new(g)) +} + +/// A 100 × 100 document with `shape` (on 0..100) painted `fill`, at `opacity`. +fn doc(shape: vectorcraft_geom::PathData, fill: Paint, opacity: f32) -> Document { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let mut n = Node::path(id, shape, Appearance::basic(fill, Paint::None, 0.0)); + n.opacity = opacity; + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + d +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +/// The field's colour at `p` composited over white (8-bit). +fn expected(f: &Freeform, p: Point, opacity: f32) -> [u8; 4] { + let ([r, g, b], a) = f.field(spread_scale(Rect::new(0.0, 0.0, 100.0, 100.0))).sample(p); + let a = a * opacity; + let q = |v: f32| ((v * a + (1.0 - a)) * 255.0).round() as u8; + [q(r), q(g), q(b), 255] +} + +fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool { + a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol) +} + +#[test] +fn the_rendered_grid_follows_the_colour_field() { + let f = freeform(); + let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 1.0)); + assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the red point's colour"); + assert_eq!(r.pixel(80, 80), [0, 0, 255, 255], "the blue point's colour"); + for (x, y) in [(50, 50), (35, 60), (65, 30), (10, 90), (95, 5), (50, 20)] { + let want = expected(&f, Point::new(x as f64 + 0.5, y as f64 + 0.5), 1.0); + assert!(near(r.pixel(x, y), want, 6), "({x}, {y}): {:?} vs {want:?}", r.pixel(x, y)); + } +} + +#[test] +fn the_grid_is_clipped_to_the_path_and_takes_the_object_opacity() { + let f = freeform(); + let r = render(&doc(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 0.5)); + assert_eq!(r.pixel(2, 2), [255, 255, 255, 255], "outside the ellipse"); + let want = expected(&f, Point::new(50.5, 50.5), 0.5); + assert!(near(r.pixel(50, 50), want, 6), "{:?} vs {want:?}", r.pixel(50, 50)); +} + +#[test] +fn unplaced_freeform_gradients_seed_points_on_the_box() { + // Without points: the automatic ones, coloured white → black along the default stops. + let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(None), 1.0)); + let auto = Freeform::auto(Rect::new(0.0, 0.0, 100.0, 100.0), &Gradient::default(), &|_| true); + let at = |i: usize| auto.points[i].at; + // (Pixel centres sit half a pixel off the points.) + assert!(near(r.pixel(at(0).x as u32, at(0).y as u32), [255, 255, 255, 255], 3)); + assert!(near(r.pixel(at(3).x as u32, at(3).y as u32), [0, 0, 0, 255], 3)); +} + +#[test] +fn strokes_take_freeform_paint() { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let line = shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)); + let n = Node::path(id, line, Appearance::basic(Paint::None, paint(Some(freeform())), 10.0)); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let r = render(&d); + assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the stroke's corner sits on the red point"); + assert_eq!(r.pixel(50, 50), [255, 255, 255, 255], "unfilled inside"); +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 92e0d2f3b..ba79008bd 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -285,7 +285,13 @@ impl Importer { .collect::>() }; let gp = |kind, stops, geom: GradientGeom| { - Paint::Gradient(Box::new(GradientPaint { gradient: Gradient { kind, stops }, geom: Some(geom), angle: geom.angle_deg(), swatch: None })) + Paint::Gradient(Box::new(GradientPaint { + gradient: Gradient { kind, stops }, + geom: Some(geom), + angle: geom.angle_deg(), + swatch: None, + freeform: None, + })) }; match p { usvg::Paint::Color(c) => Paint::solid(Color::rgb8(c.red, c.green, c.blue)), diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index c8b822239..15b89d4fc 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -229,6 +229,7 @@ fn linear_gradient_roundtrip() { geom: Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(90.0, 60.0), aspect: 1.0 }), angle: 0.0, swatch: None, + freeform: None, }; let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0)); let d = doc_with(vec![n]); @@ -260,6 +261,7 @@ fn radial_gradient_and_fit_roundtrip() { geom: Some(GradientGeom { start: Point::new(150.0, 150.0), end: Point::new(150.0, 180.0), aspect: 0.5 }), angle: 0.0, swatch: None, + freeform: None, }; let n2 = rect_node(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0)); let r = roundtrip(&doc_with(vec![n, n2])); diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index faee9a183..2403d1fdf 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -2,7 +2,7 @@ //! paint read from the document and sent back through a command arrives unchanged. use serde_json::{Value, json}; -use vectorcraft_color::{Color, GradientPaint, GradientStop}; +use vectorcraft_color::{Color, Freeform, FreeformPoint, GradientPaint, GradientStop}; /// A colour as a `color` param in its own model: `[r,g,b]`, `{c,m,y,k}` or `{gray}`. pub fn color_json(c: &Color) -> Value { @@ -20,8 +20,23 @@ pub fn stops_json(stops: &[GradientStop]) -> Value { ) } +/// A freeform point as its `paint.freeform.addPoint` / `freeform` param fields. +pub fn freeform_point_json(p: &FreeformPoint) -> Value { + json!({"at": [p.at.x, p.at.y], "color": color_json(&p.color), "opacity": p.opacity, "spread": p.spread}) +} + +/// A freeform gradient as the `freeform` param of a `gradient` paint: points, lines and Draw mode. +pub fn freeform_json(f: &Freeform) -> Value { + json!({ + "points": f.points.iter().map(freeform_point_json).collect::>(), + "lines": f.lines, + "mode": f.mode.label().to_lowercase(), + }) +} + /// A gradient paint as the `gradient` param of `paint.setFill` / `swatch.new`: kind, stops (with -/// opacity and midpoint), angle, the linked swatch and, once placed, the vector and aspect (%). +/// opacity and midpoint), angle, the linked swatch and, once placed, the vector and aspect (%) +/// and the freeform points. pub fn gradient_params(g: &GradientPaint) -> Value { let mut v = json!({ "kind": g.gradient.kind.label().to_lowercase(), @@ -36,6 +51,9 @@ pub fn gradient_params(g: &GradientPaint) -> Value { if let Some(s) = &g.swatch { v["swatch"] = json!(s); } + if let Some(f) = &g.freeform { + v["freeform"] = freeform_json(f); + } v }