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