Merge pull request #32 from storytold/pr/m3-freeform-gradients
M3: freeform gradients — model, rendering, commands, tool and panel (M3.81, M3.82)
This commit is contained in:
commit
cc514957f5
|
|
@ -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,332 @@
|
|||
//! Freeform gradients: the points and lines of the freeform gradient behind the active proxy (the
|
||||
//! Gradient panel's freeform section, the Color panel and the Gradient tool edit them through
|
||||
//! these). Points are given in document coordinates; type runs keep them in text space.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::freeform::spread_scale;
|
||||
use vectorcraft_color::{Freeform, FreeformMode, FreeformPoint, GradientKind, Paint};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::appearance::{edit_items, edits_stroke, index_param, item_target};
|
||||
use super::gradient::{StopOwner, item_paint_bounds, run_paint_mut};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"paint.freeform.addPoint",
|
||||
"Add Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), opacity? 0..1 (or 0..100), spread? 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
|
||||
has_doc,
|
||||
add_point
|
||||
),
|
||||
cmd!(
|
||||
"paint.freeform.setPoint",
|
||||
"Edit Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, opacity? 0..1 (or 0..100), spread? 0..1 (or 0..100 %), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} move or recolour a point of the freeform gradient → {index}",
|
||||
has_doc,
|
||||
set_point
|
||||
),
|
||||
cmd!(
|
||||
"paint.freeform.deletePoint",
|
||||
"Delete Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{index? (default: the selected point), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
|
||||
has_doc,
|
||||
delete_point
|
||||
),
|
||||
cmd!(
|
||||
"paint.freeform.addLine",
|
||||
"Add Freeform Line",
|
||||
[],
|
||||
None,
|
||||
"{points: [index, …] (at least two), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} join points with a smooth line through them, in order → {line}",
|
||||
has_doc,
|
||||
add_line
|
||||
),
|
||||
cmd!(
|
||||
"paint.freeform.splitLine",
|
||||
"Split Freeform Line",
|
||||
[],
|
||||
None,
|
||||
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
|
||||
has_doc,
|
||||
split_line
|
||||
),
|
||||
cmd!(
|
||||
"paint.freeform.selectPoint",
|
||||
"Select Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{index: point index | null to clear} select a point of the freeform gradient behind the active proxy (the first selected object's): the point the Gradient tool, the Gradient and Color panels and Delete act on → {index}",
|
||||
has_doc,
|
||||
select_point
|
||||
),
|
||||
cmd!(
|
||||
query "paint.freeform.get",
|
||||
"Freeform Gradient",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy)} the freeform gradient behind the proxy of the first selected object → {mode: points|lines, points: [{at: [x,y] (document coordinates), color, opacity, spread}], lines: [[index, …]], selected: index|null}",
|
||||
has_doc,
|
||||
get
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
const NOT_FREEFORM: &str = "the paint is not a freeform gradient (apply one with paint.editGradient {kind: \"freeform\"})";
|
||||
|
||||
impl Session {
|
||||
/// The selected freeform point (`paint.freeform.selectPoint`), while the gradient it was
|
||||
/// selected on is still the one behind the active proxy. Callers check it against the point
|
||||
/// count (an undo can remove points).
|
||||
pub fn selected_freeform_point(&self) -> Option<usize> {
|
||||
self.freeform_point.filter(|(_, owner)| *owner == StopOwner::of(self)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in
|
||||
/// document coordinates, and the length its spreads are fractions of there (see
|
||||
/// [`Node::proxy_freeform`]).
|
||||
pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> {
|
||||
let (n, item) = self.proxy_source(stroke)?;
|
||||
n.proxy_freeform(stroke, item)
|
||||
}
|
||||
|
||||
fn select_freeform_point(&mut self, i: Option<usize>) {
|
||||
self.freeform_point = i.map(|i| (i, StopOwner::of(self)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an edited paint lives: the map from the document to its space and the length its spreads
|
||||
/// are fractions of.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Space {
|
||||
to_paint: Affine,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
/// The `opacity` / `spread` convention: 0..1, or a percentage above 1.
|
||||
fn fraction(v: &Value, key: &str) -> std::result::Result<f32, String> {
|
||||
let x = v.as_f64().ok_or_else(|| format!("`{key}` must be a number"))?;
|
||||
Ok((if x > 1.0 { x / 100.0 } else { x }).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// Apply the `at` (mapped by `to_paint`), `color`, `opacity` and `spread` fields of `v` to `pt`.
|
||||
fn point_fields(v: &Value, pt: &mut FreeformPoint, to_paint: Affine) -> std::result::Result<(), String> {
|
||||
if v.get("at").is_some() {
|
||||
pt.at = to_paint * point_param(v, "at").ok_or("`at` must be [x, y]")?;
|
||||
}
|
||||
if let Some(c) = v.get("color") {
|
||||
pt.color = color_value(c).ok_or("`color` must be a colour")?;
|
||||
}
|
||||
if let Some(o) = v.get("opacity") {
|
||||
pt.opacity = fraction(o, "opacity")?;
|
||||
}
|
||||
if let Some(s) = v.get("spread") {
|
||||
pt.spread = fraction(s, "spread")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse the `freeform` param of a gradient paint (`{points: [{at, color, opacity?, spread?}],
|
||||
/// lines?: [[index, …]], mode?}`; points in the paint's space). Lossless for
|
||||
/// `vectorcraft_tools::params::freeform_json`.
|
||||
pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String> {
|
||||
let points = v.get("points").and_then(Value::as_array).ok_or("`freeform.points` must be an array")?;
|
||||
let mut f = Freeform::default();
|
||||
for (i, pv) in points.iter().enumerate() {
|
||||
let (Some(at), Some(color)) = (point_param(pv, "at"), pv.get("color").and_then(color_value)) else {
|
||||
return Err(format!("freeform point {i} needs `at` [x, y] and a valid `color`"));
|
||||
};
|
||||
let mut pt = FreeformPoint::new(at, color);
|
||||
point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?;
|
||||
f.points.push(pt);
|
||||
}
|
||||
for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() {
|
||||
let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default();
|
||||
f.add_line(ix)?;
|
||||
}
|
||||
if let Some(m) = str_param(v, "mode") {
|
||||
f.mode = parse_mode(m)?;
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
|
||||
FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)"))
|
||||
}
|
||||
|
||||
/// Run `f` on the freeform gradient behind the proxy of each target (the selection, or `ids`),
|
||||
/// as one undo step; returns what it returned for the first. An unplaced gradient gets the
|
||||
/// automatic points it shows first, and the stops follow the points' colours.
|
||||
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
|
||||
let item = item_target(s, p, cmd)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select an object with a freeform gradient"));
|
||||
}
|
||||
let mut first = None;
|
||||
let mut apply = |paint: &mut Paint, b: Rect, to_doc: Affine| -> Result<()> {
|
||||
let Paint::Gradient(g) = paint else { return Err(bad(cmd, NOT_FREEFORM)) };
|
||||
if g.gradient.kind != GradientKind::Freeform {
|
||||
return Err(bad(cmd, NOT_FREEFORM));
|
||||
}
|
||||
let to_paint = if to_doc == Affine::IDENTITY { to_doc } else { to_doc.inverse() };
|
||||
let mut ff = g.freeform_on(b).into_owned();
|
||||
let r = f(&mut ff, Space { to_paint, scale: spread_scale(b) }).map_err(|e| bad(cmd, e))?;
|
||||
g.set_freeform(ff);
|
||||
g.swatch = None;
|
||||
first.get_or_insert(r);
|
||||
Ok(())
|
||||
};
|
||||
edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
let (xf, lb) = (t.xf, t.local_bounds());
|
||||
if xf.determinant().abs() < 1e-12 {
|
||||
return Err(bad(cmd, "the type object is flattened to nothing"));
|
||||
}
|
||||
for r in &mut t.runs {
|
||||
let (paint, b) = run_paint_mut(r, stroke, lb);
|
||||
apply(paint, b, xf)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let b = item_paint_bounds(n, index, !stroke).ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
|
||||
let paint =
|
||||
if stroke { n.appearance.stroke_at_mut(index).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(index).map(|l| &mut l.paint) };
|
||||
apply(paint.ok_or_else(|| bad(cmd, NOT_FREEFORM))?, b, Affine::IDENTITY)
|
||||
})?;
|
||||
let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
|
||||
// The edited gradient is the last one used (`paint.lastGradient`).
|
||||
let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()));
|
||||
if let Some(p) = shown {
|
||||
s.remember_paint(&p);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The `index` param, else the selected point.
|
||||
fn point_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
match p.get("index") {
|
||||
Some(v) => v.as_u64().map(|i| i as usize).ok_or_else(|| bad(cmd, "`index` must be a point index")),
|
||||
None => s.selected_freeform_point().ok_or_else(|| bad(cmd, "no point selected (give `index`)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_index(f: &Freeform, i: usize) -> std::result::Result<(), String> {
|
||||
if i < f.points.len() { Ok(()) } else { Err(format!("no point {i} (the gradient has {})", f.points.len())) }
|
||||
}
|
||||
|
||||
fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.addPoint";
|
||||
let at = point_param(p, "at").ok_or_else(|| bad(C, "missing `at` [x, y]"))?;
|
||||
let join = match p.get("line") {
|
||||
None | Some(Value::Bool(false)) => None,
|
||||
Some(Value::Bool(true)) => s.selected_freeform_point(),
|
||||
Some(_) => return Err(bad(C, "`line` must be true or false")),
|
||||
};
|
||||
let index = edit(s, p, C, |f, sp| {
|
||||
let q = sp.to_paint * at;
|
||||
let (color, opacity) = f.sample(q, sp.scale);
|
||||
let mut pt = FreeformPoint { opacity, ..FreeformPoint::new(q, color) };
|
||||
point_fields(p, &mut pt, sp.to_paint)?;
|
||||
let i = f.add_point(pt);
|
||||
if let Some(from) = join {
|
||||
f.connect(from, i);
|
||||
}
|
||||
Ok(i)
|
||||
})?;
|
||||
s.select_freeform_point(Some(index));
|
||||
Ok(json!({ "index": index }))
|
||||
}
|
||||
|
||||
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.setPoint";
|
||||
let i = point_index(s, p, C)?;
|
||||
edit(s, p, C, |f, sp| {
|
||||
check_index(f, i)?;
|
||||
point_fields(p, &mut f.points[i], sp.to_paint)
|
||||
})?;
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.deletePoint";
|
||||
let i = point_index(s, p, C)?;
|
||||
edit(s, p, C, |f, _| {
|
||||
check_index(f, i)?;
|
||||
if f.remove_point(i) { Ok(()) } else { Err("a freeform gradient keeps at least one point".into()) }
|
||||
})?;
|
||||
// The points after it moved down one.
|
||||
let sel = s.selected_freeform_point().and_then(|j| match j.cmp(&i) {
|
||||
std::cmp::Ordering::Less => Some(j),
|
||||
std::cmp::Ordering::Equal => None,
|
||||
std::cmp::Ordering::Greater => Some(j - 1),
|
||||
});
|
||||
s.select_freeform_point(sel);
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
fn add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.addLine";
|
||||
let points: Vec<usize> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect())
|
||||
.ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
|
||||
let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?;
|
||||
Ok(json!({ "line": line }))
|
||||
}
|
||||
|
||||
fn split_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.splitLine";
|
||||
let line = index_param(p, "line", C)?;
|
||||
let segment = index_param(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5);
|
||||
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
|
||||
s.select_freeform_point(Some(index));
|
||||
Ok(json!({ "index": index }))
|
||||
}
|
||||
|
||||
fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.selectPoint";
|
||||
let index = match p.get("index") {
|
||||
None => return Err(bad(C, "missing `index` (a point index, or null to clear)")),
|
||||
Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
|
||||
let (f, _) = s.proxy_freeform(!s.fill_active).ok_or_else(|| bad(C, NOT_FREEFORM))?;
|
||||
check_index(&f, i).map_err(|e| bad(C, e))?;
|
||||
Some(i)
|
||||
}
|
||||
};
|
||||
s.select_freeform_point(index);
|
||||
Ok(json!({ "index": index }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.get";
|
||||
let stroke = bool_or(p, "stroke", !s.fill_active);
|
||||
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
|
||||
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
|
||||
let mut v = vectorcraft_tools::params::freeform_json(&f);
|
||||
v["selected"] = json!(selected);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Whether `n`'s shape contains `p` (its filled regions; true for objects without them, such as
|
||||
/// type, whose boxes stand in).
|
||||
pub(crate) fn inside_fn(n: &Node) -> impl Fn(vectorcraft_geom::Point) -> bool + use<> {
|
||||
let shapes = n.clip_shapes(None);
|
||||
move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
|
|
@ -82,7 +82,7 @@ pub(crate) struct StopOwner {
|
|||
}
|
||||
|
||||
impl StopOwner {
|
||||
fn of(s: &Session) -> Self {
|
||||
pub(crate) fn of(s: &Session) -> Self {
|
||||
Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() }
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
|||
(None, None) => {}
|
||||
_ => return Err("give both `start` and `end` (or neither)".into()),
|
||||
}
|
||||
if let Some(f) = g.get("freeform") {
|
||||
gp.freeform = Some(super::freeform::parse_freeform(f)?);
|
||||
}
|
||||
Ok(gp)
|
||||
}
|
||||
|
||||
|
|
@ -164,10 +167,13 @@ fn aspect_param(p: &Value) -> Parsed<Option<f64>> {
|
|||
}
|
||||
|
||||
/// `paint` without the placement it had on the art it came from (a swatch, or the default paint
|
||||
/// for new art): a placed gradient keeps its angle and fits each object it lands on.
|
||||
/// for new art): a placed gradient keeps its angle and fits each object it lands on (freeform
|
||||
/// points are placed afresh, coloured along the stops, which follow the points' colours).
|
||||
pub(crate) fn unplaced(paint: &Paint) -> Paint {
|
||||
match paint {
|
||||
Paint::Gradient(g) if g.geom.is_some() => Paint::Gradient(Box::new(GradientPaint { geom: None, ..(**g).clone() })),
|
||||
Paint::Gradient(g) if g.geom.is_some() || g.freeform.is_some() => {
|
||||
Paint::Gradient(Box::new(GradientPaint { geom: None, freeform: None, ..(**g).clone() }))
|
||||
}
|
||||
_ => paint.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -184,6 +190,8 @@ pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option<Rect>) -> Pai
|
|||
let Paint::Gradient(g) = paint else { return paint.clone() };
|
||||
let aspect = match g.geom {
|
||||
Some(geom) if applies_swatch(p) => (geom.aspect != 1.0).then(|| json!(geom.aspect * 100.0)),
|
||||
// Freeform points given with the paint stay where they were put.
|
||||
_ if g.freeform.is_some() && !applies_swatch(p) => return paint.clone(),
|
||||
None => p.get("gradient").and_then(|g| g.get("aspect")).cloned(),
|
||||
Some(_) => return paint.clone(),
|
||||
};
|
||||
|
|
@ -196,17 +204,32 @@ pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option<Rect>) -> Pai
|
|||
|
||||
/// Apply the gradient edits in `p` to `paint` (pure; unit-tested).
|
||||
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<Rect>) -> std::result::Result<Paint, String> {
|
||||
apply_gradient_edit_in(paint, p, bounds, &|_| true)
|
||||
}
|
||||
|
||||
/// [`apply_gradient_edit`] on a shape that contains the points `inside` does: a gradient turned
|
||||
/// freeform gets its first points there.
|
||||
pub(crate) fn apply_gradient_edit_in(
|
||||
paint: &Paint,
|
||||
p: &Value,
|
||||
bounds: Option<Rect>,
|
||||
inside: &dyn Fn(Point) -> bool,
|
||||
) -> std::result::Result<Paint, String> {
|
||||
let mut gp = match paint {
|
||||
Paint::Gradient(g) => (**g).clone(),
|
||||
_ => GradientPaint::new(Gradient::default()),
|
||||
};
|
||||
let mut switched = false;
|
||||
if let Some(k) = str_param(p, "kind") {
|
||||
let kind = parse_kind(k)?;
|
||||
if kind != gp.gradient.kind {
|
||||
gp.gradient.kind = kind;
|
||||
gp.geom = None;
|
||||
gp.freeform = None;
|
||||
switched = true;
|
||||
}
|
||||
}
|
||||
let recolor = p.get("stops").is_some() || bool_or(p, "reverse", false);
|
||||
if let Some(stops) = p.get("stops") {
|
||||
gp.gradient.stops = parse_stops(stops)?;
|
||||
gp.swatch = None;
|
||||
|
|
@ -246,6 +269,27 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<Rect>
|
|||
g.aspect = asp;
|
||||
}
|
||||
}
|
||||
if gp.gradient.kind == GradientKind::Freeform {
|
||||
// Points already shown (automatic ones fitted to the box) stay put; a fresh freeform
|
||||
// gradient gets its points inside the shape.
|
||||
if let Some(b) = bounds {
|
||||
gp.seed_freeform(b, if switched { inside } else { &|_| true });
|
||||
}
|
||||
if recolor
|
||||
&& !switched
|
||||
&& let Some(f) = &mut gp.freeform
|
||||
{
|
||||
f.recolor(&gp.gradient);
|
||||
gp.gradient.stops = f.stops();
|
||||
}
|
||||
}
|
||||
if let Some(m) = str_param(p, "mode") {
|
||||
let mode = super::freeform::parse_mode(m)?;
|
||||
if gp.gradient.kind != GradientKind::Freeform {
|
||||
return Err("`mode` applies to freeform gradients".into());
|
||||
}
|
||||
gp.freeform.get_or_insert_with(Default::default).mode = mode;
|
||||
}
|
||||
Ok(Paint::Gradient(Box::new(gp)))
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +302,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
// Validate against the defaults first so bad params fail without touching the document.
|
||||
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
|
||||
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
|
||||
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
|
||||
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
|
|
@ -270,7 +315,13 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Ok(());
|
||||
}
|
||||
let b = item_paint_bounds(n, index, !stroke);
|
||||
let np = apply_gradient_edit(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b).map_err(|e| bad(C, e))?;
|
||||
// Only a gradient turning freeform needs the shape (its first points go inside it).
|
||||
let inside = (freeform && b.is_some()).then(|| super::freeform::inside_fn(n));
|
||||
let inside: &dyn Fn(Point) -> bool = match &inside {
|
||||
Some(f) => f,
|
||||
None => &|_| true,
|
||||
};
|
||||
let np = apply_gradient_edit_in(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b, inside).map_err(|e| bad(C, e))?;
|
||||
n.appearance.set_paint_at(index, !stroke, np);
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -321,7 +372,7 @@ pub(crate) fn item_paint_bounds(n: &Node, index: Option<usize>, fill: bool) -> O
|
|||
pub(crate) fn place_run_paint(paint: &Paint, p: &Value, xf: Affine, bounds: Rect) -> Paint {
|
||||
let mut out = place_paint(paint, p, Some(bounds));
|
||||
if let (Paint::Gradient(src), Paint::Gradient(g)) = (paint, &mut out)
|
||||
&& src.geom.is_some()
|
||||
&& (src.geom.is_some() || src.freeform.is_some())
|
||||
&& !applies_swatch(p)
|
||||
&& let Some(inv) = invert(xf)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ mod edit;
|
|||
mod effectcmd;
|
||||
pub mod fileio;
|
||||
mod fonts;
|
||||
pub(crate) mod freeform;
|
||||
pub(crate) mod gradient;
|
||||
pub(crate) mod graph;
|
||||
pub mod help;
|
||||
|
|
@ -196,6 +197,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(paint::proxy_specs());
|
||||
v.extend(overprint::specs());
|
||||
v.extend(swatchlib::specs());
|
||||
v.extend(freeform::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind};
|
||||
|
||||
use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target};
|
||||
|
|
@ -102,7 +102,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
|||
"{stroke?: bool (default: the active proxy), ids?} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint { geom: None, ..s.last_gradient.clone() }));
|
||||
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
|
||||
apply_to_proxy(s, p, paint)
|
||||
}
|
||||
),
|
||||
|
|
@ -153,20 +153,28 @@ impl Session {
|
|||
/// object's (a group's first painted object's; type: its first run's), else the defaults for
|
||||
/// new art.
|
||||
pub fn proxy_paints(&self) -> (Paint, Paint) {
|
||||
let default = |stroke: bool| if stroke { self.paint.stroke.clone() } else { self.paint.fill.clone() };
|
||||
let Some(first) = self.active().and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) else {
|
||||
return (default(false), default(true));
|
||||
};
|
||||
let item = self.appearance_item();
|
||||
let mut nodes = vec![];
|
||||
painted(first, true, &mut nodes);
|
||||
let show = |stroke: bool| match first.appearance.item_of_kind(item, !stroke) {
|
||||
Some(_) => proxy_paint(first, stroke, item),
|
||||
None => nodes.first().map_or_else(|| default(stroke), |n| proxy_paint(n, stroke, None)),
|
||||
let show = |stroke: bool| match self.proxy_source(stroke) {
|
||||
Some((n, item)) => proxy_paint(n, stroke, item),
|
||||
None if stroke => self.paint.stroke.clone(),
|
||||
None => self.paint.fill.clone(),
|
||||
};
|
||||
(show(false), show(true))
|
||||
}
|
||||
|
||||
/// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item
|
||||
/// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art.
|
||||
pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option<usize>)> {
|
||||
let st = self.active()?;
|
||||
let first = st.doc.node(*st.selection.objects.first()?)?;
|
||||
let item = self.appearance_item();
|
||||
if first.appearance.item_of_kind(item, !stroke).is_some() {
|
||||
return Some((first, item));
|
||||
}
|
||||
let mut nodes = vec![];
|
||||
painted(first, true, &mut nodes);
|
||||
nodes.first().map(|n| (*n, None))
|
||||
}
|
||||
|
||||
pub(crate) fn remember_paint_now(&mut self, p: &Paint) {
|
||||
match p {
|
||||
Paint::Solid { color, .. } => {
|
||||
|
|
|
|||
|
|
@ -464,6 +464,9 @@ pub struct Session {
|
|||
pub eyedropper: EyedropperOptions,
|
||||
/// User Defined and loaded swatch libraries (Window → Swatch Libraries); not saved.
|
||||
pub swatch_libraries: cmd::swatchlib::Libraries,
|
||||
/// The selected freeform gradient point (`paint.freeform.selectPoint`) and whose gradient it
|
||||
/// was selected on: read it with [`Session::selected_freeform_point`].
|
||||
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -497,6 +500,7 @@ impl Session {
|
|||
pending_paint: None,
|
||||
eyedropper: Default::default(),
|
||||
swatch_libraries: Default::default(),
|
||||
freeform_point: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -771,6 +775,8 @@ mod tests_effectedit;
|
|||
#[cfg(test)]
|
||||
mod tests_file;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
#[cfg(test)]
|
||||
mod tests_gradpanel;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
//! Freeform gradients through commands: the first application (M3.81), transforms and warps, and
|
||||
//! the point and line commands (M3.82).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Freeform, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session, cmd: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
let r = s.execute(cmd, &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> &vectorcraft_doc::Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap()
|
||||
}
|
||||
|
||||
fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint {
|
||||
match node(s, id).appearance.fill_paint() {
|
||||
Paint::Gradient(g) => *g,
|
||||
p => panic!("expected a gradient fill, got {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn points(s: &Session, id: NodeId) -> Freeform {
|
||||
fill_gradient(s, id).freeform.expect("placed freeform points")
|
||||
}
|
||||
|
||||
/// A selected 100 × 100 rectangle at (100, 100) with a freeform fill.
|
||||
fn freeform_rect() -> (Session, NodeId) {
|
||||
let mut s = session();
|
||||
let id = shape(&mut s, "shape.rectangle", 100.0, 100.0, 100.0, 100.0);
|
||||
s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-6
|
||||
}
|
||||
|
||||
// ---------- M3.81: the first application, transforms, warps and saving ----------
|
||||
|
||||
#[test]
|
||||
fn the_first_application_places_points_inside_the_shape() {
|
||||
let mut s = session();
|
||||
// An ellipse: the box's corners are outside it.
|
||||
let id = shape(&mut s, "shape.ellipse", 100.0, 100.0, 300.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap();
|
||||
let g = fill_gradient(&s, id);
|
||||
assert_eq!(g.gradient.kind, GradientKind::Freeform);
|
||||
let f = g.freeform.as_ref().unwrap();
|
||||
let inside = cmd::freeform::inside_fn(node(&s, id));
|
||||
assert!(f.points.len() >= 4 && f.points.iter().all(|p| inside(p.at)), "{:?}", f.points);
|
||||
// The stops follow the points' colours (swatch chips and exports show them).
|
||||
assert_eq!(g.gradient.stops.iter().map(|st| st.color).collect::<Vec<_>>(), f.points.iter().map(|p| p.color).collect::<Vec<_>>());
|
||||
// Draw mode: freeform only.
|
||||
s.execute("paint.editGradient", &json!({"mode": "lines"})).unwrap();
|
||||
assert_eq!(points(&s, id).mode, vectorcraft_color::FreeformMode::Lines);
|
||||
assert_eq!(points(&s, id).points, f.points, "the points stay");
|
||||
s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap();
|
||||
assert!(fill_gradient(&s, id).freeform.is_none(), "a linear gradient drops the points");
|
||||
let e = s.execute("paint.editGradient", &json!({"mode": "lines"})).unwrap_err().to_string();
|
||||
assert!(e.contains("freeform"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_stops_recolour_the_points() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
s.execute("paint.editGradient", &json!({"stops": stops})).unwrap();
|
||||
let f = points(&s, id);
|
||||
assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ff0000".into(), "#0000ff".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_and_warps_move_the_points() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
let before = points(&s, id);
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 5}));
|
||||
let moved = points(&s, id);
|
||||
assert!(before.points.iter().zip(&moved.points).all(|(a, b)| close(b.at, a.at + vectorcraft_geom::Vec2::new(10.0, 5.0))));
|
||||
run(&mut s, "object.rotate", json!({"angle": 90, "origin": [160, 155]}));
|
||||
let turned = points(&s, id);
|
||||
// The y-down document turns (x, y) about the origin to (x', y') = (160 + (y − 155), 155 − (x − 160)).
|
||||
for (a, b) in moved.points.iter().zip(&turned.points) {
|
||||
let want = Point::new(160.0 + (a.at.y - 155.0), 155.0 - (a.at.x - 160.0));
|
||||
assert!(close(b.at, want), "{:?} → {:?}, want {want:?}", a.at, b.at);
|
||||
}
|
||||
// A perspective distort maps each point exactly: a point on a corner stays on it.
|
||||
let (mut s, id) = freeform_rect();
|
||||
run(&mut s, "paint.freeform.addPoint", json!({"at": [200, 200]}));
|
||||
run(&mut s, "object.distort", json!({"corners": [[100, 100], [200, 100], [260, 240], [100, 200]]}));
|
||||
let f = points(&s, id);
|
||||
assert!(close(f.points.last().unwrap().at, Point::new(260.0, 240.0)), "{:?}", f.points.last());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_paints_survive_saving_and_old_files_load() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
run(&mut s, "paint.editGradient", json!({"mode": "lines"}));
|
||||
run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1, 2]}));
|
||||
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&s.doc().unwrap().doc).unwrap();
|
||||
// A gradient saved before freeform points existed has none.
|
||||
let old: GradientPaint = serde_json::from_value(json!({"gradient": {"kind": "Freeform", "stops": []}, "angle": 0.0})).unwrap();
|
||||
assert!(old.freeform.is_none());
|
||||
// The command params are lossless: setting the paint read back reproduces it.
|
||||
let g = fill_gradient(&s, id);
|
||||
let other = shape(&mut s, "shape.rectangle", 300.0, 300.0, 50.0, 50.0);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": vectorcraft_tools::params::gradient_params(&g), "ids": [other.0]}));
|
||||
assert_eq!(fill_gradient(&s, other), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reapplied_gradients_place_their_points_afresh() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
run(&mut s, "paint.freeform.setPoint", json!({"index": 0, "color": "#ff0000"}));
|
||||
// The last gradient (`.`) on other art: points inside it, coloured like the first object's.
|
||||
let other = shape(&mut s, "shape.rectangle", 400.0, 300.0, 100.0, 100.0);
|
||||
run(&mut s, "paint.lastGradient", json!({}));
|
||||
let (a, (b, _)) = (points(&s, id), node(&s, other).proxy_freeform(false, None).unwrap());
|
||||
assert!(b.points.iter().all(|p| p.at.x >= 400.0 && p.at.y >= 300.0), "{:?}", b.points);
|
||||
assert_eq!(a.points.iter().map(|p| p.color.to_hex()).collect::<Vec<_>>(), b.points.iter().map(|p| p.color.to_hex()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
// ---------- M3.82: points and lines ----------
|
||||
|
||||
#[test]
|
||||
fn add_set_and_delete_points() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
let n = points(&s, id).points.len();
|
||||
let field = points(&s, id);
|
||||
let r = run(&mut s, "paint.freeform.addPoint", json!({"at": [150, 150]}));
|
||||
assert_eq!(r["index"], json!(n));
|
||||
assert_eq!(s.selected_freeform_point(), Some(n), "the new point is selected");
|
||||
let p = points(&s, id).points[n];
|
||||
// Without a colour it takes the gradient's colour there.
|
||||
let (want, _) = field.sample(Point::new(150.0, 150.0), 50.0);
|
||||
assert!(close(p.at, Point::new(150.0, 150.0)) && p.color.to_hex() == want.to_hex(), "{p:?} vs {want:?}");
|
||||
// The selected point takes edits without an index; opacity and spread take 0..1 or percentages.
|
||||
run(&mut s, "paint.freeform.setPoint", json!({"color": "#00ff00", "opacity": 50, "spread": 0.4, "at": [160, 170]}));
|
||||
let p = points(&s, id).points[n];
|
||||
assert_eq!((p.color.to_hex().as_str(), p.opacity, p.spread, p.at), ("#00ff00", 0.5, 0.4, Point::new(160.0, 170.0)));
|
||||
assert_eq!(fill_gradient(&s, id).gradient.stops[n].color.to_hex(), "#00ff00", "the stops follow");
|
||||
// One undo step per command.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(points(&s, id).points[n].color.to_hex(), want.to_hex());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "paint.freeform.deletePoint", json!({}));
|
||||
assert_eq!(points(&s, id).points.len(), n);
|
||||
assert_eq!(s.selected_freeform_point(), None);
|
||||
for i in (1..n).rev() {
|
||||
run(&mut s, "paint.freeform.deletePoint", json!({"index": i}));
|
||||
}
|
||||
let e = s.execute("paint.freeform.deletePoint", &json!({"index": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("at least one"), "{e}");
|
||||
let e = s.execute("paint.freeform.setPoint", &json!({"index": 5, "color": "#000000"})).unwrap_err().to_string();
|
||||
assert!(e.contains("no point 5"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_join_split_and_follow_the_selection() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
assert_eq!(run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]}))["line"], json!(0));
|
||||
run(&mut s, "paint.freeform.selectPoint", json!({"index": 1}));
|
||||
// Adding a point with `line` extends the line ending at the selected point.
|
||||
let i = run(&mut s, "paint.freeform.addPoint", json!({"at": [190, 190], "line": true}))["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!(points(&s, id).lines, vec![vec![0, 1, i]]);
|
||||
let j = run(&mut s, "paint.freeform.splitLine", json!({"line": 0, "segment": 1}))["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!((points(&s, id).lines[0].clone(), s.selected_freeform_point()), (vec![0, 1, j, i], Some(j)));
|
||||
assert!(s.execute("paint.freeform.splitLine", &json!({"line": 0, "segment": 3})).is_err());
|
||||
assert!(s.execute("paint.freeform.addLine", &json!({"points": [0, 99]})).is_err());
|
||||
// The query reports points in document coordinates, the lines and the selection.
|
||||
let g = run(&mut s, "paint.freeform.get", json!({}));
|
||||
assert_eq!((g["lines"].clone(), g["selected"].clone(), g["mode"].clone()), (json!([[0, 1, j, i]]), json!(j), json!("points")));
|
||||
assert_eq!(g["points"][i]["at"], json!([190.0, 190.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_commands_need_a_freeform_gradient() {
|
||||
let mut s = session();
|
||||
shape(&mut s, "shape.rectangle", 0.0, 0.0, 50.0, 50.0);
|
||||
for paint in [json!({"color": "#ff0000"}), json!({"gradient": {"kind": "radial"}})] {
|
||||
run(&mut s, "paint.setFill", paint);
|
||||
for (cmd, p) in [
|
||||
("paint.freeform.addPoint", json!({"at": [10, 10]})),
|
||||
("paint.freeform.setPoint", json!({"index": 0, "color": "#000000"})),
|
||||
("paint.freeform.deletePoint", json!({"index": 0})),
|
||||
("paint.freeform.addLine", json!({"points": [0, 1]})),
|
||||
("paint.freeform.splitLine", json!({"line": 0, "segment": 0})),
|
||||
("paint.freeform.selectPoint", json!({"index": 0})),
|
||||
("paint.freeform.get", json!({})),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("not a freeform gradient"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
// Nothing selected: nothing to edit.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("paint.freeform.addPoint", &json!({"at": [1, 1]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_follows_the_object_and_the_proxy() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
run(&mut s, "paint.freeform.selectPoint", json!({"index": 2}));
|
||||
assert_eq!(s.selected_freeform_point(), Some(2));
|
||||
run(&mut s, "paint.toggleActive", json!({}));
|
||||
assert_eq!(s.selected_freeform_point(), None, "the Stroke proxy has its own paint");
|
||||
run(&mut s, "paint.toggleActive", json!({}));
|
||||
assert_eq!(s.selected_freeform_point(), Some(2));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
assert_eq!(s.selected_freeform_point(), Some(2), "same object, same proxy");
|
||||
assert!(s.execute("paint.freeform.selectPoint", &json!({"index": 99})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_objects_keep_points_in_text_space() {
|
||||
let mut s = session();
|
||||
let r = run(&mut s, "text.create", json!({"x": 100, "y": 200, "text": "Freeform", "size": 40}));
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
run(&mut s, "paint.editGradient", json!({"kind": "freeform"}));
|
||||
run(&mut s, "object.rotate", json!({"angle": 90, "origin": [100, 200]}));
|
||||
let i = run(&mut s, "paint.freeform.addPoint", json!({"at": [90, 150], "color": "#ff00ff"}))["index"].as_u64().unwrap() as usize;
|
||||
let (f, _) = node(&s, id).proxy_freeform(false, None).unwrap();
|
||||
assert!(close(f.points[i].at, Point::new(90.0, 150.0)), "back in document coordinates: {:?}", f.points[i].at);
|
||||
assert_eq!(f.points[i].color, Color::rgb(1.0, 0.0, 1.0));
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ impl Session {
|
|||
gradient_stop: self.selected_stop(),
|
||||
appearance_item: self.appearance_item(),
|
||||
constrain_angle: self.prefs.constrain_angle,
|
||||
freeform_point: self.selected_freeform_point(),
|
||||
};
|
||||
f(self.tool.as_mut(), &cx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ pub struct ToolContext<'a> {
|
|||
pub appearance_item: Option<usize>,
|
||||
/// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it.
|
||||
pub constrain_angle: f64,
|
||||
/// The selected freeform gradient point (`paint.freeform.selectPoint`), marked on the
|
||||
/// freeform annotator.
|
||||
pub freeform_point: Option<usize>,
|
||||
}
|
||||
|
||||
impl ToolContext<'_> {
|
||||
|
|
@ -349,6 +352,7 @@ pub(crate) mod testutil {
|
|||
gradient_stop: None,
|
||||
appearance_item: None,
|
||||
constrain_angle: 0.0,
|
||||
freeform_point: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
mod artboard;
|
||||
mod eyedropper;
|
||||
mod free;
|
||||
mod freeform;
|
||||
mod gradient;
|
||||
mod measure;
|
||||
mod transform;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
//! The Gradient tool on a freeform gradient.
|
||||
//!
|
||||
//! The annotator shows each point as a colour chip, the lines through them and, around the
|
||||
//! selected point, its spread. Click a point to select it (`paint.freeform.selectPoint`; the
|
||||
//! Gradient and Color panels edit the selected point) and drag it to move it. Click inside the
|
||||
//! selected art to add a point there, coloured as the gradient is there; in Lines mode (the
|
||||
//! Gradient panel's Draw toggle) the new point joins the selected one by a line. Click a line to add
|
||||
//! a point on it. Delete or Backspace removes the selected point.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::freeform::LineHit;
|
||||
use vectorcraft_color::{Freeform, FreeformMode};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_geom::{Point, Shape};
|
||||
|
||||
use super::paint_owner;
|
||||
use crate::{Action, Cursor, Overlay, ToolContext, ToolKey};
|
||||
|
||||
const LINE: [u8; 3] = [0x20, 0x20, 0x20];
|
||||
/// Hit radius of a point's chip and of a line, screen pixels.
|
||||
const POINT_HIT: f64 = 7.0;
|
||||
const LINE_HIT: f64 = 4.0;
|
||||
/// A press becomes a drag after this much movement, screen pixels.
|
||||
const DRAG_START: f64 = 3.0;
|
||||
|
||||
/// The freeform gradient behind the active proxy of the first selected object that has one: its
|
||||
/// points in document coordinates and the length spreads are fractions of there.
|
||||
pub(super) struct Annotator {
|
||||
pub freeform: Freeform,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Annotator {
|
||||
pub fn of(cx: &ToolContext) -> Option<Self> {
|
||||
cx.selection.objects.iter().find_map(|id| {
|
||||
let (freeform, scale) = cx.doc.node(*id)?.proxy_freeform(!cx.fill_active, cx.appearance_item)?;
|
||||
Some(Self { freeform, scale })
|
||||
})
|
||||
}
|
||||
|
||||
/// The point under `p` (the topmost chip where chips overlap).
|
||||
fn point_at(&self, cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
(0..self.freeform.points.len()).rev().find(|i| self.freeform.points[*i].at.distance(p) <= cx.tol(POINT_HIT))
|
||||
}
|
||||
|
||||
/// The spot on a line under `p`.
|
||||
fn line_at(&self, cx: &ToolContext, p: Point) -> Option<LineHit> {
|
||||
self.freeform.nearest_on_lines(p).filter(|h| h.distance <= cx.tol(LINE_HIT))
|
||||
}
|
||||
|
||||
/// The selected point, if it exists.
|
||||
fn selected(&self, cx: &ToolContext) -> Option<usize> {
|
||||
cx.freeform_point.filter(|i| *i < self.freeform.points.len())
|
||||
}
|
||||
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let f = &self.freeform;
|
||||
let mut o: Vec<Overlay> =
|
||||
(0..f.lines.len()).map(|l| Overlay::Path { path: f.line_path(l), color: LINE, width: 1.0, dashed: false }).collect();
|
||||
if let Some(i) = self.selected(cx) {
|
||||
let pt = &f.points[i];
|
||||
let r = pt.spread as f64 * self.scale;
|
||||
if r > 0.0 {
|
||||
let path = vectorcraft_geom::kurbo::Circle::new(pt.at, r).to_path(cx.tol(0.25));
|
||||
o.push(Overlay::Path { path, color: LINE, width: 1.0, dashed: true });
|
||||
}
|
||||
}
|
||||
o.extend(f.points.iter().enumerate().map(|(i, pt)| Overlay::Swatch {
|
||||
p: pt.at,
|
||||
color: pt.color.to_rgba8(pt.opacity),
|
||||
selected: cx.freeform_point == Some(i),
|
||||
}));
|
||||
o
|
||||
}
|
||||
}
|
||||
|
||||
/// What a press on a freeform annotator does.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Grab {
|
||||
/// A point: a drag moves it.
|
||||
Point(usize),
|
||||
/// A line: a click adds a point there.
|
||||
Line(LineHit),
|
||||
/// Inside selected art: a click adds a point there.
|
||||
Add,
|
||||
/// Elsewhere: a click leaves no point selected.
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Gesture {
|
||||
grab: Grab,
|
||||
at: Point,
|
||||
began: bool,
|
||||
}
|
||||
|
||||
impl Gesture {
|
||||
/// Has the press become a drag (an interaction is open)?
|
||||
pub fn began(&self) -> bool {
|
||||
self.began
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` aimed at the paint behind the active proxy.
|
||||
fn on_proxy(cx: &ToolContext, mut params: Value) -> Value {
|
||||
params["stroke"] = json!(!cx.fill_active);
|
||||
params
|
||||
}
|
||||
|
||||
fn select(i: Option<usize>) -> Action {
|
||||
Action::Exec("paint.freeform.selectPoint".into(), json!({ "index": i }))
|
||||
}
|
||||
|
||||
pub(super) fn press(cx: &ToolContext, a: &Annotator, p: Point) -> (Vec<Action>, Gesture) {
|
||||
let mut out = vec![];
|
||||
let grab = if let Some(i) = a.point_at(cx, p) {
|
||||
if cx.freeform_point != Some(i) {
|
||||
out.push(select(Some(i)));
|
||||
}
|
||||
Grab::Point(i)
|
||||
} else if let Some(h) = a.line_at(cx, p) {
|
||||
Grab::Line(h)
|
||||
} else if hit_test(cx.doc, p, cx.hit_options()).is_some_and(|h| cx.selection.objects.contains(&paint_owner(cx.doc, h.leaf))) {
|
||||
Grab::Add
|
||||
} else {
|
||||
Grab::Clear
|
||||
};
|
||||
(out, Gesture { grab, at: p, began: false })
|
||||
}
|
||||
|
||||
pub(super) fn drag(cx: &ToolContext, g: &mut Gesture, p: Point) -> Vec<Action> {
|
||||
let Grab::Point(index) = g.grab else { return vec![] };
|
||||
let mut out = vec![];
|
||||
if !g.began {
|
||||
if p.distance(g.at) < cx.tol(DRAG_START) {
|
||||
return out;
|
||||
}
|
||||
g.began = true;
|
||||
out.push(Action::Begin("Gradient".into()));
|
||||
}
|
||||
out.push(Action::Preview("paint.freeform.setPoint".into(), on_proxy(cx, json!({ "index": index, "at": [p.x, p.y] }))));
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn release(cx: &ToolContext, a: Option<&Annotator>, g: Gesture) -> Vec<Action> {
|
||||
if g.began {
|
||||
return vec![Action::Commit];
|
||||
}
|
||||
match g.grab {
|
||||
Grab::Point(_) => vec![],
|
||||
Grab::Line(h) => {
|
||||
vec![Action::Exec("paint.freeform.splitLine".into(), on_proxy(cx, json!({ "line": h.line, "segment": h.segment, "t": h.t })))]
|
||||
}
|
||||
Grab::Add => {
|
||||
let lines = a.is_some_and(|a| a.freeform.mode == FreeformMode::Lines);
|
||||
vec![Action::Exec("paint.freeform.addPoint".into(), on_proxy(cx, json!({ "at": [g.at.x, g.at.y], "line": lines })))]
|
||||
}
|
||||
Grab::Clear if cx.freeform_point.is_some() => vec![select(None)],
|
||||
Grab::Clear => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete / Backspace remove the selected point (never the last one).
|
||||
pub(super) fn claims_key(cx: &ToolContext, a: &Annotator, key: ToolKey) -> bool {
|
||||
matches!(key, ToolKey::Delete | ToolKey::Backspace) && a.selected(cx).is_some() && a.freeform.points.len() > 1
|
||||
}
|
||||
|
||||
pub(super) fn key(cx: &ToolContext, a: &Annotator, key: ToolKey) -> Vec<Action> {
|
||||
if !claims_key(cx, a, key) {
|
||||
return vec![];
|
||||
}
|
||||
vec![Action::Exec("paint.freeform.deletePoint".into(), on_proxy(cx, json!({ "index": a.selected(cx) })))]
|
||||
}
|
||||
|
||||
pub(super) fn cursor(cx: &ToolContext, a: &Annotator, g: Option<&Gesture>, p: Point) -> Cursor {
|
||||
if g.is_some_and(|g| g.began) || a.point_at(cx, p).is_some() {
|
||||
return Cursor::Move;
|
||||
}
|
||||
if a.line_at(cx, p).is_some()
|
||||
|| hit_test(cx.doc, p, cx.hit_options()).is_some_and(|h| cx.selection.objects.contains(&paint_owner(cx.doc, h.leaf)))
|
||||
{
|
||||
return Cursor::AddStop;
|
||||
}
|
||||
Cursor::Crosshair
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::GradientTool;
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{PointerEvent, PointerKind, Tool};
|
||||
use vectorcraft_color::{FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Document, Selection};
|
||||
|
||||
/// The test rectangle (100..200) with three points; `lines` mode joins new ones.
|
||||
fn doc(mode: FreeformMode) -> (Document, Selection) {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
|
||||
let pt = |x, y| FreeformPoint { spread: 0.2, ..FreeformPoint::new(Point::new(x, y), vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)) };
|
||||
g.freeform = Some(Freeform { points: vec![pt(120.0, 120.0), pt(180.0, 120.0), pt(150.0, 180.0)], lines: vec![vec![0, 1]], mode });
|
||||
d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g)));
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
(d, s)
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
fn click(t: &mut GradientTool, cx: &ToolContext, x: f64, y: f64) -> Vec<Action> {
|
||||
let mut a = t.pointer(cx, &ev(PointerKind::Down, x, y));
|
||||
a.extend(t.pointer(cx, &ev(PointerKind::Up, x, y)));
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_select_points_add_points_and_split_lines() {
|
||||
let (d, s) = doc(FreeformMode::Points);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
let mut t = GradientTool::default();
|
||||
assert_eq!(click(&mut t, &cx, 151.0, 181.0), vec![select(Some(2))]);
|
||||
cx.freeform_point = Some(2);
|
||||
// Inside the art: a point there (Points mode: not joined).
|
||||
let a = click(&mut t, &cx, 140.0, 150.0);
|
||||
assert_eq!(a, vec![Action::Exec("paint.freeform.addPoint".into(), json!({"at": [140.0, 150.0], "line": false, "stroke": false}))]);
|
||||
// On the line: a point on it.
|
||||
let a = click(&mut t, &cx, 150.0, 122.0);
|
||||
let Action::Exec(c, v) = &a[0] else { panic!("{a:?}") };
|
||||
assert_eq!((c.as_str(), &v["line"], &v["segment"]), ("paint.freeform.splitLine", &json!(0), &json!(0)));
|
||||
// Off the art: the point selection clears.
|
||||
assert_eq!(click(&mut t, &cx, 400.0, 400.0), vec![select(None)]);
|
||||
// The overlays: a chip per point (the selected one ringed), the line and the spread.
|
||||
let o = t.overlays(&cx);
|
||||
assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Swatch { .. })).count(), 3);
|
||||
assert!(o.iter().any(|o| matches!(o, Overlay::Swatch { selected: true, p, .. } if p == &Point::new(150.0, 180.0))));
|
||||
assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count(), 1, "the spread circle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_mode_joins_new_points_and_drags_move_points() {
|
||||
let (d, s) = doc(FreeformMode::Lines);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.freeform_point = Some(1);
|
||||
let mut t = GradientTool::default();
|
||||
let a = click(&mut t, &cx, 190.0, 190.0);
|
||||
assert_eq!(a, vec![Action::Exec("paint.freeform.addPoint".into(), json!({"at": [190.0, 190.0], "line": true, "stroke": false}))]);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 120.0, 121.0));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 130.0, 140.0));
|
||||
assert_eq!(a[0], Action::Begin("Gradient".into()));
|
||||
assert_eq!(a[1], Action::Preview("paint.freeform.setPoint".into(), json!({"index": 0, "at": [130.0, 140.0], "stroke": false})));
|
||||
assert_eq!(t.cursor(&cx, Point::new(130.0, 140.0), Default::default()), Cursor::Move);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 130.0, 140.0)), vec![Action::Commit]);
|
||||
// Delete removes the selected point.
|
||||
assert!(t.claims_key(&cx, ToolKey::Delete));
|
||||
assert_eq!(
|
||||
t.key(&cx, ToolKey::Delete, Default::default()),
|
||||
vec![Action::Exec("paint.freeform.deletePoint".into(), json!({"index": 1, "stroke": false}))]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
|
||||
//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or
|
||||
//! Backspace removes it (never below two stops) and the arrow keys nudge it.
|
||||
//!
|
||||
//! On a freeform gradient the tool edits its points instead (see `freeform`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint};
|
||||
|
|
@ -21,6 +23,7 @@ use vectorcraft_doc::NodeId;
|
|||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2, constrain_angle_from};
|
||||
|
||||
use super::freeform::{self, Annotator as FreeformAnnotator};
|
||||
use super::paint_owner;
|
||||
use crate::guides::snap_draw;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
|
@ -49,8 +52,8 @@ const DRAG_START: f64 = 3.0;
|
|||
const NUDGE: f32 = 0.01;
|
||||
const NUDGE_BIG: f32 = 0.1;
|
||||
|
||||
/// The gradient annotator: the gradient behind the active proxy of the first selected object that
|
||||
/// has one, placed in document coordinates.
|
||||
/// The gradient annotator: the linear or radial gradient behind the active proxy of the first
|
||||
/// selected object that has one, placed in document coordinates.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Annotator {
|
||||
pub geom: GradientGeom,
|
||||
|
|
@ -75,7 +78,7 @@ impl Annotator {
|
|||
pub fn of(cx: &ToolContext) -> Option<Self> {
|
||||
cx.selection.objects.iter().find_map(|id| {
|
||||
let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active, cx.appearance_item)?;
|
||||
Some(Self { geom, gradient: g.gradient.clone() })
|
||||
(g.gradient.kind != GradientKind::Freeform).then(|| Self { geom, gradient: g.gradient.clone() })
|
||||
})
|
||||
}
|
||||
/// An annotator showing `stops` on `geom` (the state a drag started from).
|
||||
|
|
@ -183,6 +186,8 @@ struct Gesture {
|
|||
#[derive(Default)]
|
||||
pub struct GradientTool {
|
||||
gesture: Option<Gesture>,
|
||||
/// A press on a freeform gradient's annotator.
|
||||
free: Option<freeform::Gesture>,
|
||||
/// Smart-guide feedback of the handle being dragged.
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
|
@ -381,10 +386,27 @@ impl Tool for GradientTool {
|
|||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.gesture.as_ref().is_some_and(|g| g.began)
|
||||
self.gesture.as_ref().is_some_and(|g| g.began) || self.free.as_ref().is_some_and(freeform::Gesture::began)
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
// A freeform gradient: its points instead of the bar.
|
||||
match (ev.kind, &mut self.free) {
|
||||
(PointerKind::Down, _) => {
|
||||
if let Some(a) = FreeformAnnotator::of(cx) {
|
||||
let (out, g) = freeform::press(cx, &a, ev.pos);
|
||||
self.free = Some(g);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
(PointerKind::Drag, Some(g)) => return freeform::drag(cx, g, ev.pos),
|
||||
(PointerKind::Up, Some(_)) => {
|
||||
let g = self.free.take().expect("checked");
|
||||
return freeform::release(cx, FreeformAnnotator::of(cx).as_ref(), g);
|
||||
}
|
||||
(PointerKind::DoubleClick, free) if free.is_some() || FreeformAnnotator::of(cx).is_some() => return vec![],
|
||||
_ => {}
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Down => self.press(cx, ev.pos, ev.mods),
|
||||
PointerKind::Drag => self.drag(cx, ev.pos, ev.mods),
|
||||
|
|
@ -400,6 +422,9 @@ impl Tool for GradientTool {
|
|||
}
|
||||
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
if let Some(a) = FreeformAnnotator::of(cx) {
|
||||
return freeform::claims_key(cx, &a, key);
|
||||
}
|
||||
matches!(key, ToolKey::Delete | ToolKey::Backspace | ToolKey::Left | ToolKey::Right)
|
||||
&& cx.gradient_stop.is_some_and(|i| Annotator::of(cx).is_some_and(|a| i < a.gradient.stops.len()))
|
||||
}
|
||||
|
|
@ -407,12 +432,16 @@ impl Tool for GradientTool {
|
|||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.busy() {
|
||||
self.gesture = None;
|
||||
self.free = None;
|
||||
self.guides.clear();
|
||||
return vec![Action::Cancel];
|
||||
}
|
||||
if self.busy() || !self.claims_key(cx, key) {
|
||||
return vec![];
|
||||
}
|
||||
if let Some(a) = FreeformAnnotator::of(cx) {
|
||||
return freeform::key(cx, &a, key);
|
||||
}
|
||||
let (Some(a), Some(i)) = (Annotator::of(cx), cx.gradient_stop) else { return vec![] };
|
||||
let stops = &a.gradient.stops;
|
||||
let (new, sel) = match key {
|
||||
|
|
@ -433,6 +462,9 @@ impl Tool for GradientTool {
|
|||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if let Some(a) = FreeformAnnotator::of(cx) {
|
||||
return a.overlays(cx);
|
||||
}
|
||||
let mut o = match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
|
||||
(Some(a), _) => annotator_overlays(cx, &a),
|
||||
// A vector drawn on a paint that isn't a gradient yet.
|
||||
|
|
@ -446,6 +478,9 @@ impl Tool for GradientTool {
|
|||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if let Some(a) = FreeformAnnotator::of(cx) {
|
||||
return freeform::cursor(cx, &a, self.free.as_ref(), p);
|
||||
}
|
||||
if let Some(g) = &self.gesture {
|
||||
return match (&g.grab, g.stop) {
|
||||
(Grab::Stop { .. }, Some(None)) => Cursor::RemoveStop,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Color panel: Grayscale / RGB / HSB / CMYK / Web Safe RGB sliders with colour-gradient tracks,
|
||||
//! value fields, hex field, None/Black/White chips, spectrum ramp and the Fill/Stroke proxy.
|
||||
//! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop.
|
||||
//! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop (on a
|
||||
//! freeform gradient, its selected point).
|
||||
//!
|
||||
//! The mode follows the colour's own model (the last mode picked comes back for colours in its
|
||||
//! model). Alt-clicking the spectrum or a chip paints the inactive proxy; Shift-clicking the
|
||||
|
|
@ -9,7 +10,7 @@
|
|||
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_color::{Color, GradientKind, Paint};
|
||||
|
||||
use super::{active_paint, color_json, live_run, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
|
|
@ -258,17 +259,19 @@ fn to32(c: &Color) -> Color32 {
|
|||
super::c32(c)
|
||||
}
|
||||
|
||||
/// What the panel is editing: the active proxy's solid colour, or a selected gradient stop.
|
||||
/// What the panel is editing: the active proxy's solid colour, a selected gradient stop or a
|
||||
/// selected freeform point.
|
||||
enum Target {
|
||||
Paint(Option<Color>),
|
||||
Stop { paint: Paint, index: usize, color: Color },
|
||||
Point { index: usize, color: Color },
|
||||
}
|
||||
|
||||
impl Target {
|
||||
fn color(&self) -> Option<Color> {
|
||||
match self {
|
||||
Target::Paint(c) => *c,
|
||||
Target::Stop { color, .. } => Some(*color),
|
||||
Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -280,6 +283,12 @@ fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
|
|||
}
|
||||
let p = active_paint(app);
|
||||
match &p {
|
||||
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => {
|
||||
// Like stops: without a selected point, the first.
|
||||
let f = super::gradient::shown_points(g);
|
||||
let index = app.session.selected_freeform_point().filter(|i| *i < f.points.len()).unwrap_or(0);
|
||||
Target::Point { index, color: f.points.get(index).map_or(Color::BLACK, |p| p.color) }
|
||||
}
|
||||
Paint::Gradient(g) => {
|
||||
let i = app.session.selected_stop().unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1));
|
||||
let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK);
|
||||
|
|
@ -301,6 +310,10 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
|
|||
}
|
||||
super::gradient::set_stops(app, &stops, None, phase);
|
||||
}
|
||||
Target::Point { index, .. } if !behind => {
|
||||
let p = json!({"index": index, "color": color_json(&c), "stroke": !app.session.fill_active});
|
||||
live_run(app, "Color", "paint.freeform.setPoint", p, phase);
|
||||
}
|
||||
_ => {
|
||||
let cmd = super::proxy_cmd(app, behind);
|
||||
// The panel's sliders work in the model picked there, even in a CMYK document.
|
||||
|
|
@ -507,7 +520,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
] {
|
||||
if menu_item(ui, label, recolor, false) {
|
||||
match (&tgt, color) {
|
||||
(Target::Stop { .. }, Some(c)) => apply(app, &tgt, f(&c), Live::Released, false),
|
||||
(Target::Stop { .. } | Target::Point { .. }, Some(c)) => apply(app, &tgt, f(&c), Live::Released, false),
|
||||
_ => {
|
||||
app.run(cmd, json!({})).ok();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,17 @@
|
|||
//! for the stop popover, and drag a diamond to move a midpoint (a selected diamond's midpoint
|
||||
//! shows in Location). Dropping a colour swatch on the ramp adds a stop of that colour, or
|
||||
//! recolours the stop it lands on. Hide Options leaves the thumbnail, the proxy and the slider.
|
||||
//!
|
||||
//! A freeform gradient shows its section instead of the angle, aspect and slider: the Draw
|
||||
//! toggle (Points or Lines: how the Gradient tool adds points) and the selected point's colour
|
||||
//! (edited in the Color panel), opacity and spread, with Delete Point.
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::gradient::{
|
||||
MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors,
|
||||
};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
||||
use super::{active_paint, live_run, pstate, set_pstate};
|
||||
|
|
@ -180,9 +184,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
});
|
||||
let freeform = is_grad && kind == GradientKind::Freeform;
|
||||
if hidden {
|
||||
ui.add_space(6.0);
|
||||
ramp(app, ui, &g.gradient, is_grad);
|
||||
if !freeform {
|
||||
ui.add_space(6.0);
|
||||
ramp(app, ui, &g.gradient, is_grad);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
|
|
@ -196,6 +203,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0);
|
||||
}
|
||||
});
|
||||
if freeform {
|
||||
freeform_section(app, ui, &g);
|
||||
return;
|
||||
}
|
||||
// The proxy beside the angle and aspect ratio fields.
|
||||
ui.horizontal(|ui| {
|
||||
super::proxy(app, ui, 36.0);
|
||||
|
|
@ -231,6 +242,74 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The freeform points shown: the gradient's own, else (unplaced) as many as it places, coloured
|
||||
/// along the stops.
|
||||
pub(crate) fn shown_points(g: &GradientPaint) -> std::borrow::Cow<'_, Freeform> {
|
||||
g.freeform_on(vectorcraft_geom::Rect::new(0.0, 0.0, 1.0, 1.0))
|
||||
}
|
||||
|
||||
/// Run a `paint.freeform.*` command on the paint behind the active proxy.
|
||||
fn edit_point(app: &mut VectorcraftApp, cmd: &str, mut params: Value) {
|
||||
params["stroke"] = json!(!app.session.fill_active);
|
||||
live_run(app, "Gradient", cmd, params, Live::Released);
|
||||
}
|
||||
|
||||
/// The freeform section: the proxy beside the Draw toggle, then the selected point's colour,
|
||||
/// opacity and spread, and Delete Point.
|
||||
fn freeform_section(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint) {
|
||||
let f = shown_points(g);
|
||||
let mode = f.mode;
|
||||
ui.horizontal(|ui| {
|
||||
super::proxy(app, ui, 36.0);
|
||||
ui.add_space(4.0);
|
||||
widgets::dim_label(ui, "Draw:");
|
||||
for (m, icon, tip) in [
|
||||
(FreeformMode::Points, "circle", "Points: clicks add free points"),
|
||||
(FreeformMode::Lines, "spline", "Lines: clicks add points joined by a line"),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tip, mode == m, 24.0).clicked() && mode != m {
|
||||
edit(app, json!({ "mode": m.label().to_lowercase() }), Live::Released);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let sel = app.session.selected_freeform_point().filter(|i| *i < f.points.len());
|
||||
let point = sel.map(|i| f.points[i]);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, "Color:");
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
let paint = point.map_or(Paint::None, |p| Paint::solid(p.color));
|
||||
widgets::swatch_tile(ui, r, &paint, false, resp.hovered());
|
||||
resp.on_hover_text("Edit the selected point's colour in the Color panel");
|
||||
if let Some(p) = point {
|
||||
widgets::dim_label(ui, &p.color.to_hex().to_uppercase());
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(sel.is_some(), |ui| {
|
||||
for (label, key, value) in [("Opacity:", "opacity", point.map(|p| p.opacity)), ("Spread:", "spread", point.map(|p| p.spread))] {
|
||||
widgets::dim_label(ui, label);
|
||||
// Blank while no point is selected.
|
||||
let shown = value.map(|v| (v as f64 * 1000.0).round() / 10.0);
|
||||
if let Some(v) = widgets::mixed_field(ui, ("freeform", key), shown, "%", 1, 54.0)
|
||||
&& let Some(i) = sel
|
||||
{
|
||||
edit_point(app, "paint.freeform.setPoint", json!({ "index": i, key: v.clamp(0.0, 100.0) / 100.0 }));
|
||||
}
|
||||
}
|
||||
});
|
||||
let can_del = sel.is_some() && f.points.len() > 1;
|
||||
if widgets::icon_button_enabled(ui, "trash-2", "Delete Point", false, can_del, 22.0).clicked()
|
||||
&& let Some(i) = sel
|
||||
{
|
||||
edit_point(app, "paint.freeform.deletePoint", json!({ "index": i }));
|
||||
}
|
||||
});
|
||||
if sel.is_none() {
|
||||
widgets::dim_label(ui, "Click the art with the Gradient tool to add or select points.");
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient thumbnail (click: apply a gradient; drag: onto art) and its menu of gradient
|
||||
/// swatches with Save to Swatches.
|
||||
fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) {
|
||||
|
|
|
|||
|
|
@ -380,6 +380,8 @@ mod tests_appearance;
|
|||
#[cfg(test)]
|
||||
mod tests_effectedit;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
//! Headless frames of the Gradient panel's freeform section and the Color panel on a freeform
|
||||
//! point.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Freeform, FreeformMode, Paint};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A selected rectangle with a freeform fill.
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 300, "height": 300}));
|
||||
run(&mut app, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}));
|
||||
run(&mut app, "paint.editGradient", json!({"kind": "freeform"}));
|
||||
app
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn freeform(app: &VectorcraftApp) -> Freeform {
|
||||
match current_paints(app).0 {
|
||||
Paint::Gradient(g) => g.freeform.expect("placed points"),
|
||||
p => panic!("expected a gradient fill, got {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of `f` with `events`: the texts drawn and their centres.
|
||||
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<Event>, f: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec<(String, Pos2)> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let input = egui::RawInput { events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| f(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
fn at(texts: &[(String, Pos2)], s: &str) -> Pos2 {
|
||||
texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("`{s}` not drawn: {texts:?}")).1
|
||||
}
|
||||
|
||||
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2, f: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec<(String, Pos2)> {
|
||||
let b = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
frame(ctx, app, vec![Event::PointerMoved(pos), b(true)], f);
|
||||
frame(ctx, app, vec![b(false)], f);
|
||||
frame(ctx, app, vec![], f)
|
||||
}
|
||||
|
||||
/// Click the field drawn at `pos`, replace its text with `text` and press Enter.
|
||||
fn type_into(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2, text: &str, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
click(ctx, app, pos, f);
|
||||
let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
frame(ctx, app, vec![key(Key::A, Modifiers::COMMAND), Event::Text(text.into())], f);
|
||||
frame(ctx, app, vec![key(Key::Enter, Modifiers::NONE)], f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_freeform_section_replaces_the_slider_and_sets_the_draw_mode() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
let texts = frame(&ctx, &mut app, vec![], gradient::show);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Draw:" || t == "Spread:"), "{texts:?}");
|
||||
assert!(!texts.iter().any(|(t, _)| t == "Location:"), "no stop fields: {texts:?}");
|
||||
// The Draw toggle's Lines button sits right of the Points one.
|
||||
let draw = at(&texts, "Draw:");
|
||||
click(&ctx, &mut app, draw + vec2(60.0, 0.0), gradient::show);
|
||||
assert_eq!(freeform(&app).mode, FreeformMode::Lines);
|
||||
click(&ctx, &mut app, draw + vec2(32.0, 0.0), gradient::show);
|
||||
assert_eq!(freeform(&app).mode, FreeformMode::Points);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selected_points_opacity_spread_and_delete() {
|
||||
let mut app = app();
|
||||
let n = freeform(&app).points.len();
|
||||
run(&mut app, "paint.freeform.selectPoint", json!({"index": 1}));
|
||||
let ctx = egui::Context::default();
|
||||
let texts = frame(&ctx, &mut app, vec![], gradient::show);
|
||||
let hex = freeform(&app).points[1].color.to_hex().to_uppercase();
|
||||
assert!(texts.iter().any(|(t, _)| *t == hex), "the point's colour: {texts:?}");
|
||||
let spread = at(&texts, "0%");
|
||||
type_into(&ctx, &mut app, spread, "40", gradient::show);
|
||||
assert!((freeform(&app).points[1].spread - 0.4).abs() < 1e-6, "{:?}", freeform(&app).points[1]);
|
||||
let opacity = at(&texts, "100%");
|
||||
type_into(&ctx, &mut app, opacity, "50", gradient::show);
|
||||
assert!((freeform(&app).points[1].opacity - 0.5).abs() < 1e-6);
|
||||
// Delete Point sits right of the Spread field (54 px wide, text left-aligned in it).
|
||||
let texts = frame(&ctx, &mut app, vec![], gradient::show);
|
||||
let trash = at(&texts, "40%") + vec2(57.0, 0.0);
|
||||
click(&ctx, &mut app, trash, gradient::show);
|
||||
assert_eq!(freeform(&app).points.len(), n - 1, "Delete Point");
|
||||
// Nothing selected: the hint.
|
||||
let texts = frame(&ctx, &mut app, vec![], gradient::show);
|
||||
assert!(texts.iter().any(|(t, _)| t.starts_with("Click the art")), "{texts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_color_panel_recolours_the_selected_point() {
|
||||
let mut app = app();
|
||||
run(&mut app, "paint.freeform.selectPoint", json!({"index": 2}));
|
||||
let ctx = egui::Context::default();
|
||||
let texts = frame(&ctx, &mut app, vec![], color::show);
|
||||
let hex = freeform(&app).points[2].color.to_hex()[1..].to_uppercase();
|
||||
let field = texts.iter().find(|(t, _)| t.eq_ignore_ascii_case(&hex)).unwrap_or_else(|| panic!("{hex} in {texts:?}")).1;
|
||||
type_into(&ctx, &mut app, field, "00FF00", color::show);
|
||||
assert_eq!(freeform(&app).points[2].color.to_hex(), "#00ff00");
|
||||
assert!(matches!(current_paints(&app).0, Paint::Gradient(_)), "still a gradient");
|
||||
}
|
||||
|
|
@ -975,10 +975,12 @@ pub struct SwatchRows {
|
|||
|
||||
impl PanelDrag {
|
||||
/// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient
|
||||
/// carries no placement: it fits the art it lands on.
|
||||
/// carries no placement: it fits the art it lands on (freeform points are placed afresh there,
|
||||
/// coloured like the dragged ones).
|
||||
pub fn paint(mut paint: Paint) -> Self {
|
||||
if let Paint::Gradient(g) = &mut paint {
|
||||
g.geom = None;
|
||||
g.freeform = None;
|
||||
}
|
||||
Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None }
|
||||
}
|
||||
|
|
|
|||
15
docs/mcp.md
15
docs/mcp.md
|
|
@ -155,6 +155,21 @@ delete it, hold Alt to copy it), dragging the end handle changes the vector. The
|
|||
{"name":"press_key","arguments":{"key":"Delete"}}
|
||||
```
|
||||
|
||||
Freeform gradients: `paint.editGradient {kind: "freeform"}` places four or more colour points inside each selected
|
||||
object (coloured along the stops; `mode: "points"|"lines"` is the Draw toggle). `paint.freeform.get` lists the points
|
||||
(document coordinates), lines and the selected point; `paint.freeform.addPoint {at, color?, opacity?, spread?,
|
||||
line?}`, `setPoint {index?, …}`, `deletePoint {index?}`, `addLine {points}`, `splitLine {line, segment, t?}` and
|
||||
`selectPoint {index|null}` edit them (`index` defaults to the selected point; each edit is one undo step). With the
|
||||
Gradient tool on a freeform gradient a click on a point selects it (drag to move it), a click on a line adds a point
|
||||
on it, a click elsewhere on the art adds a point (in Lines mode joined to the selected one), and Delete removes the
|
||||
selected point:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"kind":"freeform","mode":"lines"}}}
|
||||
{"name":"run_command","arguments":{"command":"paint.freeform.addPoint","params":{"at":[150,150],"color":"#ff3366","spread":20}}}
|
||||
{"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":180,"y":170},{"kind":"up","x":180,"y":170}]}}
|
||||
```
|
||||
|
||||
Raw protocol (for debugging):
|
||||
|
||||
```sh
|
||||
|
|
|
|||
Loading…
Reference in New Issue