From 14c5c1bc51844355b719c363dbe0a525e4a9a1f1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 11:37:50 -0400 Subject: [PATCH] readme, misc --- README.md | 2 +- crates/cms/src/clut.rs | 248 +++++++++++++++ crates/cms/src/pipeline.rs | 232 ++++++++++++++ crates/paint/src/dynamics.rs | 582 +++++++++++++++++++++++++++++++++++ 4 files changed, 1063 insertions(+), 1 deletion(-) create mode 100644 crates/cms/src/clut.rs create mode 100644 crates/cms/src/pipeline.rs create mode 100644 crates/paint/src/dynamics.rs diff --git a/README.md b/README.md index 6f1b552d..8189dc27 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ cargo test --workspace Start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/). -## The artcraft suite +## Crafting Apps Open-source, clean-room, pure-Rust creative apps that share the same conventions: native on macOS, Windows and Linux, in the browser via WebAssembly, and fully drivable by agents. diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs new file mode 100644 index 00000000..0606f1d1 --- /dev/null +++ b/crates/cms/src/clut.rs @@ -0,0 +1,248 @@ +//! Multi-dimensional colour lookup tables (ICC CLUTs) with tetrahedral interpolation. +//! +//! Grid layout follows ICC.1: the first input channel varies slowest, output values of one +//! node are stored together. 3-input tables use tetrahedral interpolation (Sakamoto 1980 / +//! Kasson et al. 1995); 4-input tables interpolate linearly along the first input between +//! two tetrahedral evaluations; other input counts use multilinear interpolation. + +/// Maximum number of input or output channels of a CLUT. +pub const MAX_CHANNELS: usize = 15; + +#[derive(Clone, Debug, PartialEq)] +pub struct Clut { + pub inputs: usize, + pub outputs: usize, + /// Grid points per input dimension (each ≥ 2, or 1 for a degenerate dimension). + pub grid: Vec, + /// Normalised node values, `outputs` per node. + pub data: Vec, + strides: Vec, +} + +impl Clut { + /// Builds a table from node data. Panics if `data` has the wrong length. + pub fn new(grid: Vec, outputs: usize, data: Vec) -> Self { + let inputs = grid.len(); + assert!((1..=MAX_CHANNELS).contains(&inputs) && (1..=MAX_CHANNELS).contains(&outputs), "bad CLUT dimensions"); + let mut strides = vec![0usize; inputs]; + let mut s = outputs; + for i in (0..inputs).rev() { + strides[i] = s; + s *= grid[i].max(1); + } + assert_eq!(data.len(), s, "CLUT data length"); + Clut { inputs, outputs, grid, data, strides } + } + + /// Number of nodes. + pub fn nodes(&self) -> usize { + self.data.len() / self.outputs + } + + /// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`). + pub fn sample(grid: Vec, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Self { + let inputs = grid.len(); + let total: usize = grid.iter().product(); + let mut data = vec![0.0f32; total * outputs]; + let mut idx = vec![0usize; inputs]; + let mut inp = vec![0.0f32; inputs]; + for node in 0..total { + let mut rem = node; + for d in (0..inputs).rev() { + idx[d] = rem % grid[d]; + rem /= grid[d]; + } + for d in 0..inputs { + inp[d] = if grid[d] > 1 { idx[d] as f32 / (grid[d] - 1) as f32 } else { 0.0 }; + } + f(&inp, &mut data[node * outputs..(node + 1) * outputs]); + } + Clut::new(grid, outputs, data) + } + + #[inline] + fn locate(&self, d: usize, v: f32) -> (usize, f32, usize) { + let n = self.grid[d]; + if n < 2 { + return (0, 0.0, 0); + } + let p = v.clamp(0.0, 1.0) * (n - 1) as f32; + let i = (p as usize).min(n - 2); + (i * self.strides[d], p - i as f32, self.strides[d]) + } + + /// Interpolates at `input` (`inputs` values) into `out` (`outputs` values). + #[inline] + pub fn eval(&self, input: &[f32], out: &mut [f32]) { + match self.inputs { + 1 => self.eval1(input[0], out), + 3 => { + let (b0, r0, s0) = self.locate(0, input[0]); + let (b1, r1, s1) = self.locate(1, input[1]); + let (b2, r2, s2) = self.locate(2, input[2]); + self.tetra(b0 + b1 + b2, [s0, s1, s2], [r0, r1, r2], out); + } + 4 => { + let (b0, r0, s0) = self.locate(0, input[0]); + let (b1, r1, s1) = self.locate(1, input[1]); + let (b2, r2, s2) = self.locate(2, input[2]); + let (b3, r3, s3) = self.locate(3, input[3]); + let base = b0 + b1 + b2 + b3; + let mut lo = [0.0f32; MAX_CHANNELS]; + self.tetra(base, [s1, s2, s3], [r1, r2, r3], &mut lo[..self.outputs]); + if r0 > 0.0 && s0 > 0 { + let mut hi = [0.0f32; MAX_CHANNELS]; + self.tetra(base + s0, [s1, s2, s3], [r1, r2, r3], &mut hi[..self.outputs]); + for (o, (l, h)) in out.iter_mut().zip(lo.iter().zip(&hi)) { + *o = l + (h - l) * r0; + } + } else { + out[..self.outputs].copy_from_slice(&lo[..self.outputs]); + } + } + _ => self.eval_multilinear(input, out), + } + } + + fn eval1(&self, v: f32, out: &mut [f32]) { + let (b, r, s) = self.locate(0, v); + for (k, o) in out.iter_mut().enumerate().take(self.outputs) { + let a = self.data[b + k]; + let c = if s > 0 { self.data[b + s + k] } else { a }; + *o = a + (c - a) * r; + } + } + + /// Tetrahedral interpolation inside one cube of three dimensions. + #[inline] + fn tetra(&self, base: usize, s: [usize; 3], r: [f32; 3], out: &mut [f32]) { + let d = &self.data; + let [rx, ry, rz] = r; + let [sx, sy, sz] = s; + let c000 = base; + let c100 = base + sx; + let c010 = base + sy; + let c001 = base + sz; + let c110 = base + sx + sy; + let c101 = base + sx + sz; + let c011 = base + sy + sz; + let c111 = base + sx + sy + sz; + // Pick the tetrahedron containing (rx, ry, rz) and the path through the cube corners. + let (p1, p2, p3, w1, w2, w3) = if rx >= ry { + if ry >= rz { + (c100, c110, c111, rx, ry, rz) + } else if rx >= rz { + (c100, c101, c111, rx, rz, ry) + } else { + (c001, c101, c111, rz, rx, ry) + } + } else if rx >= rz { + (c010, c110, c111, ry, rx, rz) + } else if ry >= rz { + (c010, c011, c111, ry, rz, rx) + } else { + (c001, c011, c111, rz, ry, rx) + }; + for (k, o) in out.iter_mut().enumerate().take(self.outputs) { + let v0 = d[c000 + k]; + let v1 = d[p1 + k]; + let v2 = d[p2 + k]; + let v3 = d[p3 + k]; + *o = v0 + (v1 - v0) * w1 + (v2 - v1) * w2 + (v3 - v2) * w3; + } + } + + /// Trilinear interpolation (3 inputs), kept as a reference for accuracy comparisons. + pub fn eval_trilinear(&self, input: &[f32], out: &mut [f32]) { + assert_eq!(self.inputs, 3); + self.eval_multilinear(input, out); + } + + /// Multilinear interpolation over all 2^n cube corners. + pub fn eval_multilinear(&self, input: &[f32], out: &mut [f32]) { + let n = self.inputs; + let mut base = 0usize; + let mut r = [0.0f32; MAX_CHANNELS]; + let mut s = [0usize; MAX_CHANNELS]; + for d in 0..n { + let (b, rr, ss) = self.locate(d, input[d]); + base += b; + r[d] = rr; + s[d] = ss; + } + let o = self.outputs; + out[..o].iter_mut().for_each(|v| *v = 0.0); + for corner in 0..(1usize << n) { + let mut w = 1.0f32; + let mut off = base; + for d in 0..n { + if corner & (1 << d) != 0 { + w *= r[d]; + off += s[d]; + } else { + w *= 1.0 - r[d]; + } + } + if w == 0.0 { + continue; + } + for k in 0..o { + out[k] += w * self.data[off + k]; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn f3(p: &[f32], o: &mut [f32]) { + o[0] = p[0] * 0.5 + p[1] * 0.3 + p[2] * 0.2; + o[1] = p[0] * p[1]; + o[2] = (p[2] * 3.0).sin() * 0.5 + 0.5; + } + + #[test] + fn exact_on_nodes_and_linear_functions() { + let c = Clut::sample(vec![5, 5, 5], 3, f3); + let mut o = [0.0; 3]; + c.eval(&[0.25, 0.5, 0.75], &mut o); + let mut w = [0.0; 3]; + f3(&[0.25, 0.5, 0.75], &mut w); + for i in 0..3 { + assert!((o[i] - w[i]).abs() < 1e-6); + } + // Linear function is reproduced exactly anywhere. + c.eval(&[0.13, 0.77, 0.41], &mut o); + assert!((o[0] - (0.13 * 0.5 + 0.77 * 0.3 + 0.41 * 0.2)).abs() < 1e-6); + } + + #[test] + fn four_d_and_multilinear_agree_on_linear() { + let f = |p: &[f32], o: &mut [f32]| o[0] = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3]; + let c = Clut::sample(vec![3, 4, 5, 6], 1, f); + let mut a = [0.0]; + let mut b = [0.0]; + for p in [[0.1f32, 0.9, 0.3, 0.7], [1.0, 0.0, 0.5, 0.5], [0.33, 0.66, 0.99, 0.01]] { + c.eval(&p, &mut a); + c.eval_multilinear(&p, &mut b); + let want = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3]; + assert!((a[0] - want).abs() < 1e-5 && (b[0] - want).abs() < 1e-5); + } + } + + #[test] + fn one_and_two_d() { + let c = Clut::sample(vec![3], 2, |p, o| { + o[0] = p[0]; + o[1] = 1.0 - p[0]; + }); + let mut o = [0.0; 2]; + c.eval(&[0.25], &mut o); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.75).abs() < 1e-6); + let c = Clut::sample(vec![2, 2], 1, |p, o| o[0] = p[0] + p[1]); + c.eval(&[0.5, 0.25], &mut o); + assert!((o[0] - 0.75).abs() < 1e-6); + } +} diff --git a/crates/cms/src/pipeline.rs b/crates/cms/src/pipeline.rs new file mode 100644 index 00000000..d425f7d2 --- /dev/null +++ b/crates/cms/src/pipeline.rs @@ -0,0 +1,232 @@ +//! Float evaluation pipelines: a list of stages mapping normalised device values to the +//! profile connection space (or back). This is the exact reference path; [`crate::Transform`] +//! builds faster lookup tables from it. + +use crate::clut::Clut; +use crate::curve::Curve; +use crate::math; + +/// Largest channel count flowing through a pipeline. +pub const MAX: usize = 16; + +/// One processing element. +#[derive(Clone, Debug, PartialEq)] +pub enum Stage { + /// Per-channel curves. + Curves(Vec), + /// Per-channel inverse curves. + InvCurves(Vec), + /// `out[r] = Σ m[r][c]·in[c] + offset[r]`, `rows × cols`. + Matrix { rows: usize, cols: usize, m: Vec, offset: Vec }, + Clut(Clut), + /// Float CIE Lab (L 0–100) → XYZ (D50, Y = 1). + LabToXyz, + XyzToLab, + /// ICC v4 / 8-bit Lab encoding (normalised) → float Lab, and back. + DecodeLabV4, + EncodeLabV4, + /// ICC v2 legacy 16-bit Lab encoding (used by `lut16Type`) → float Lab, and back. + DecodeLabV2, + EncodeLabV2, + /// u1Fixed15 XYZ encoding (normalised over 0..=0xFFFF) → XYZ, and back. + DecodeXyz, + EncodeXyz, + /// Clamp every channel to `[0, 1]`. + Clamp01, +} + +const XYZ_SCALE: f32 = 65535.0 / 32768.0; +const V2_L: f32 = 100.0 * 65535.0 / 65280.0; +const V2_AB: f32 = 255.0 * 65535.0 / 65280.0; + +impl Stage { + /// Output channel count given `n` inputs. + pub fn outputs(&self, n: usize) -> usize { + match self { + Stage::Matrix { rows, .. } => *rows, + Stage::Clut(c) => c.outputs, + _ => n, + } + } + + pub fn matrix3(m: &math::Mat3) -> Stage { + Stage::Matrix { rows: 3, cols: 3, m: m.iter().flatten().copied().collect(), offset: vec![0.0; 3] } + } + + #[inline] + fn run(&self, v: &mut [f32; MAX], n: usize) -> usize { + match self { + Stage::Curves(cs) => { + for (x, c) in v.iter_mut().zip(cs) { + *x = c.eval(*x); + } + n + } + Stage::InvCurves(cs) => { + for (x, c) in v.iter_mut().zip(cs) { + *x = c.eval_inverse(*x); + } + n + } + Stage::Matrix { rows, cols, m, offset } => { + let mut o = [0.0f32; MAX]; + for r in 0..*rows { + let mut s = offset[r]; + for c in 0..*cols { + s += m[r * cols + c] * v[c] as f64; + } + o[r] = s as f32; + } + v[..*rows].copy_from_slice(&o[..*rows]); + *rows + } + Stage::Clut(c) => { + let mut o = [0.0f32; MAX]; + let mut inp = [0.0f32; MAX]; + inp[..n].copy_from_slice(&v[..n]); + c.eval(&inp[..c.inputs], &mut o[..c.outputs]); + v[..c.outputs].copy_from_slice(&o[..c.outputs]); + c.outputs + } + Stage::LabToXyz => { + let x = math::lab_to_xyz_f32([v[0], v[1], v[2]]); + v[..3].copy_from_slice(&x); + 3 + } + Stage::XyzToLab => { + let l = math::xyz_to_lab_f32([v[0].max(0.0), v[1].max(0.0), v[2].max(0.0)]); + v[..3].copy_from_slice(&l); + 3 + } + Stage::DecodeLabV4 => { + v[0] *= 100.0; + v[1] = v[1] * 255.0 - 128.0; + v[2] = v[2] * 255.0 - 128.0; + 3 + } + Stage::EncodeLabV4 => { + v[0] = (v[0] / 100.0).clamp(0.0, 1.0); + v[1] = ((v[1] + 128.0) / 255.0).clamp(0.0, 1.0); + v[2] = ((v[2] + 128.0) / 255.0).clamp(0.0, 1.0); + 3 + } + Stage::DecodeLabV2 => { + v[0] *= V2_L; + v[1] = v[1] * V2_AB - 128.0; + v[2] = v[2] * V2_AB - 128.0; + 3 + } + Stage::EncodeLabV2 => { + v[0] = (v[0] / V2_L).clamp(0.0, 1.0); + v[1] = ((v[1] + 128.0) / V2_AB).clamp(0.0, 1.0); + v[2] = ((v[2] + 128.0) / V2_AB).clamp(0.0, 1.0); + 3 + } + Stage::DecodeXyz => { + for x in &mut v[..3] { + *x *= XYZ_SCALE; + } + 3 + } + Stage::EncodeXyz => { + for x in &mut v[..3] { + *x = (*x / XYZ_SCALE).clamp(0.0, 1.0); + } + 3 + } + Stage::Clamp01 => { + for x in &mut v[..n] { + *x = x.clamp(0.0, 1.0); + } + n + } + } + } +} + +/// A sequence of stages with fixed input and output channel counts. +#[derive(Clone, Debug, PartialEq)] +pub struct Pipeline { + pub inputs: usize, + pub outputs: usize, + pub stages: Vec, +} + +impl Pipeline { + pub fn new(inputs: usize, stages: Vec) -> Self { + let outputs = stages.iter().fold(inputs, |n, s| s.outputs(n)); + Pipeline { inputs, outputs, stages } + } + + /// Appends stages. + pub fn extend(&mut self, stages: impl IntoIterator) { + for s in stages { + self.outputs = s.outputs(self.outputs); + self.stages.push(s); + } + } + + /// Evaluates one pixel: `input` has `inputs` values, `out` receives `outputs` values. + #[inline] + pub fn eval(&self, input: &[f32], out: &mut [f32]) { + let mut v = [0.0f32; MAX]; + v[..self.inputs].copy_from_slice(&input[..self.inputs]); + let mut n = self.inputs; + for s in &self.stages { + n = s.run(&mut v, n); + } + out[..self.outputs].copy_from_slice(&v[..self.outputs]); + } + + /// Removes no-op stages and cancels adjacent inverse pairs (Lab↔XYZ, encode/decode). + pub fn optimize(&mut self) { + let mut out: Vec = Vec::with_capacity(self.stages.len()); + for s in self.stages.drain(..) { + let noop = match &s { + Stage::Curves(c) | Stage::InvCurves(c) => c.iter().all(Curve::is_identity), + Stage::Matrix { rows: 3, cols: 3, m, offset } => { + offset.iter().all(|o| o.abs() < 1e-12) + && m.iter().enumerate().all(|(i, v)| (v - if i % 4 == 0 { 1.0 } else { 0.0 }).abs() < 1e-12) + } + _ => false, + }; + if noop { + continue; + } + let cancels = matches!( + (out.last(), &s), + (Some(Stage::LabToXyz), Stage::XyzToLab) + | (Some(Stage::XyzToLab), Stage::LabToXyz) + | (Some(Stage::EncodeLabV4), Stage::DecodeLabV4) + | (Some(Stage::EncodeLabV2), Stage::DecodeLabV2) + | (Some(Stage::EncodeXyz), Stage::DecodeXyz) + ); + // Decode→encode of the same encoding is only exact inside the gamut of the encoding, + // which always holds for data coming out of a table, so those pairs cancel too. + let cancels_dec = matches!( + (out.last(), &s), + (Some(Stage::DecodeLabV4), Stage::EncodeLabV4) | (Some(Stage::DecodeLabV2), Stage::EncodeLabV2) | (Some(Stage::DecodeXyz), Stage::EncodeXyz) + ); + if cancels || cancels_dec { + out.pop(); + continue; + } + // Merge consecutive 3×3 matrices. + if let (Some(Stage::Matrix { rows: 3, cols: 3, m: a, offset: oa }), Stage::Matrix { rows: 3, cols: 3, m: b, offset: ob }) = (out.last(), &s) { + let mut m = vec![0.0; 9]; + let mut off = vec![0.0; 3]; + for r in 0..3 { + for c in 0..3 { + m[r * 3 + c] = (0..3).map(|k| b[r * 3 + k] * a[k * 3 + c]).sum(); + } + off[r] = (0..3).map(|k| b[r * 3 + k] * oa[k]).sum::() + ob[r]; + } + out.pop(); + out.push(Stage::Matrix { rows: 3, cols: 3, m, offset: off }); + continue; + } + out.push(s); + } + self.stages = out; + } +} diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs new file mode 100644 index 00000000..b04589da --- /dev/null +++ b/crates/paint/src/dynamics.rs @@ -0,0 +1,582 @@ +//! From input points to dabs: smoothing, brush pose, path walking (spacing, airbrush build-up) and +//! the per-dab dynamics (shape, scattering, transfer, colour, texture depth). +//! +//! Everything is incremental (feed points in any chunking, get the same dabs) and deterministic: +//! jitter comes from [`crate::rng`] keyed by the stroke seed and the step/dab index. + +use photocraft_geom::Point; + +use crate::brush::{BrushSettings, Control, Dynamic, Smoothing}; +use crate::rng::{rand_signed, rand01, stream}; +use crate::{Dab, StrokePoint}; + +/// Maximum pulled-string length at 100 % smoothing, in screen pixels. +pub const MAX_STRING_PX: f64 = 100.0; + +#[inline] +fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint { + let ff = f as f32; + StrokePoint { + x: a.x + (b.x - a.x) * f, + y: a.y + (b.y - a.y) * f, + pressure: a.pressure + (b.pressure - a.pressure) * ff, + tilt_x: a.tilt_x + (b.tilt_x - a.tilt_x) * ff, + tilt_y: a.tilt_y + (b.tilt_y - a.tilt_y) * ff, + rotation: a.rotation + (b.rotation - a.rotation) * ff, + wheel: a.wheel + (b.wheel - a.wheel) * ff, + time: a.time + (b.time - a.time) * f, + } +} + +// --------------------------------------------------------------------------- +// Smoothing +// --------------------------------------------------------------------------- + +/// Incremental stroke smoothing: exponential (with optional time-based catch-up) or pulled string. +#[derive(Clone, Debug)] +pub struct Smoother { + cfg: Smoothing, + zoom: f64, + pos: Option, + last_input: Option, +} + +impl Smoother { + /// `zoom` is the view zoom (only used with `adjust_for_zoom`). + pub fn new(cfg: &Smoothing, zoom: f32) -> Self { + Self { cfg: cfg.clone(), zoom: f64::from(zoom.max(0.01)), pos: None, last_input: None } + } + + fn active(&self) -> bool { + self.cfg.amount > 0.0 + } + + /// Feed one input point; returns the brush positions to paint through. + pub fn push(&mut self, p: StrokePoint, out: &mut Vec) { + let prev_input = self.last_input.replace(p); + if !self.active() { + out.push(p); + return; + } + let Some(pos) = self.pos else { + self.pos = Some(p); + out.push(p); + return; + }; + let amount = f64::from(self.cfg.amount.clamp(0.0, 1.0)); + if self.cfg.pulled_string { + let mut len = amount * MAX_STRING_PX; + if self.cfg.adjust_for_zoom { + len /= self.zoom; + } + let (dx, dy) = (p.x - pos.x, p.y - pos.y); + let d = (dx * dx + dy * dy).sqrt(); + if d > len { + let k = (d - len) / d; + let np = StrokePoint { x: pos.x + dx * k, y: pos.y + dy * k, ..p }; + self.pos = Some(np); + out.push(np); + } + return; + } + // Exponential: each iteration moves `a` of the way towards the pointer. + let a = 1.0 - amount.min(0.95); + let iters = match prev_input { + Some(q) if self.cfg.catch_up && p.time > q.time => ((p.time - q.time) / 16.0).round().clamp(1.0, 64.0) as usize, + _ => 1, + }; + let mut cur = pos; + for _ in 0..iters { + cur = StrokePoint { x: cur.x + (p.x - cur.x) * a, y: cur.y + (p.y - cur.y) * a, ..p }; + out.push(cur); + } + self.pos = Some(cur); + } + + /// End of stroke: with Catch-Up On Stroke End the brush finishes at the last pointer position. + pub fn finish(&mut self, out: &mut Vec) { + if !self.active() || !self.cfg.catch_up_on_end { + return; + } + if let (Some(pos), Some(last)) = (self.pos, self.last_input) + && (pos.x != last.x || pos.y != last.y) + { + self.pos = Some(last); + out.push(last); + } + } +} + +/// Smooth a whole point list with the brush's smoothing settings. +pub fn smooth_points(cfg: &Smoothing, zoom: f32, pts: &[StrokePoint]) -> Vec { + let mut s = Smoother::new(cfg, zoom); + let mut out = Vec::with_capacity(pts.len()); + for p in pts { + s.push(*p, &mut out); + } + s.finish(&mut out); + out +} + +// --------------------------------------------------------------------------- +// Path walking +// --------------------------------------------------------------------------- + +/// One spacing (or airbrush time) step along the path. +#[derive(Clone, Copy, Debug)] +pub struct StepInput { + pub point: StrokePoint, + /// Visual (counter-clockwise, y-up) direction of travel in radians. + pub direction: f32, + pub initial_direction: f32, + /// Sequential step number (0 = first). + pub step: u64, +} + +/// Walks a polyline and emits steps every `step_len(point)` pixels, plus airbrush steps in time. +#[derive(Clone, Debug)] +pub struct PathWalker { + last: Option, + /// Path distance from `last` to the next step. + next_at: f64, + /// First point, emitted lazily once the initial direction is known. + pending_first: Option, + initial_dir: Option, + dir: f32, + step: u64, + /// Airbrush interval in ms (None = off). + interval: Option, + time_acc: f64, +} + +impl PathWalker { + pub fn new(build_up_interval_ms: Option) -> Self { + Self { last: None, next_at: 0.0, pending_first: None, initial_dir: None, dir: 0.0, step: 0, interval: build_up_interval_ms, time_acc: 0.0 } + } + + fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) { + let s = StepInput { point, direction: self.dir, initial_direction: self.initial_dir.unwrap_or(self.dir), step: self.step }; + self.step += 1; + out(s); + } + + pub fn push(&mut self, p: StrokePoint, step_len: &impl Fn(&StrokePoint) -> f64, out: &mut impl FnMut(StepInput)) { + let Some(a) = self.last else { + self.last = Some(p); + self.pending_first = Some(p); + self.next_at = step_len(&p); + return; + }; + let (dx, dy) = (p.x - a.x, p.y - a.y); + let len = (dx * dx + dy * dy).sqrt(); + if len > 1e-9 { + self.dir = (-dy).atan2(dx) as f32; + if self.initial_dir.is_none() { + self.initial_dir = Some(self.dir); + } + } + if let Some(f) = self.pending_first.take() { + self.emit(f, out); + } + if len > 1e-9 { + while self.next_at <= len + 1e-9 { + let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0)); + self.emit(q, out); + self.next_at += step_len(&q).max(0.25); + } + self.next_at -= len; + } + if let Some(iv) = self.interval { + let dt = p.time - a.time; + if dt > 0.0 { + self.time_acc += dt; + while self.time_acc >= iv { + self.time_acc -= iv; + let f = ((dt - self.time_acc) / dt).clamp(0.0, 1.0); + self.emit(lerp_pt(&a, &p, f), out); + } + } + } + self.last = Some(p); + } + + pub fn finish(&mut self, out: &mut impl FnMut(StepInput)) { + if let Some(f) = self.pending_first.take() { + self.emit(f, out); + } + } +} + +// --------------------------------------------------------------------------- +// Dynamics +// --------------------------------------------------------------------------- + +/// Scalar control value in 0..1. +pub fn control_value(c: Control, fade_steps: u32, s: &StepInput) -> f32 { + let p = &s.point; + match c { + Control::Off | Control::InitialDirection | Control::Direction => 1.0, + Control::Fade => 1.0 - (s.step as f32 / fade_steps.max(1) as f32).min(1.0), + Control::PenPressure => p.pressure.clamp(0.0, 1.0), + // Upright pen = 1, flat pen = 0 (a mouse reports no tilt, so paints at full value). + Control::PenTilt => 1.0 - (p.tilt_x.hypot(p.tilt_y) / 90.0).clamp(0.0, 1.0), + Control::StylusWheel => p.wheel.clamp(0.0, 1.0), + Control::Rotation => (p.rotation.rem_euclid(360.0)) / 360.0, + } +} + +/// Angle contributed by a control, in degrees (counter-clockwise). +pub fn control_angle(c: Control, fade_steps: u32, s: &StepInput) -> f32 { + let p = &s.point; + match c { + Control::Off => 0.0, + Control::Fade => 360.0 * (s.step as f32 / fade_steps.max(1) as f32).min(1.0), + Control::PenPressure => 360.0 * p.pressure.clamp(0.0, 1.0), + Control::PenTilt => { + if p.tilt_x == 0.0 && p.tilt_y == 0.0 { + 0.0 + } else { + (-p.tilt_y).atan2(p.tilt_x).to_degrees() + } + } + Control::StylusWheel => 360.0 * p.wheel.clamp(0.0, 1.0), + Control::Rotation => p.rotation, + Control::InitialDirection => s.initial_direction.to_degrees(), + Control::Direction => s.direction.to_degrees(), + } +} + +/// A 0..1 multiplier from a dynamic: control lerps from `minimum` to 1, jitter reduces randomly, +/// never below `minimum`. +fn scalar(d: &Dynamic, s: &StepInput, r: f32) -> f32 { + if !d.is_active() { + return 1.0; + } + let min = d.minimum.clamp(0.0, 1.0); + let cv = control_value(d.control, d.fade_steps, s); + let v = (min + (1.0 - min) * cv) * (1.0 - d.jitter.clamp(0.0, 1.0) * r); + v.max(min) +} + +fn rgb_to_hsv(c: [f32; 3]) -> [f32; 3] { + let mx = c[0].max(c[1]).max(c[2]); + let mn = c[0].min(c[1]).min(c[2]); + let d = mx - mn; + let h = if d <= 0.0 { + 0.0 + } else if mx == c[0] { + ((c[1] - c[2]) / d).rem_euclid(6.0) + } else if mx == c[1] { + (c[2] - c[0]) / d + 2.0 + } else { + (c[0] - c[1]) / d + 4.0 + } / 6.0; + [h, if mx > 0.0 { d / mx } else { 0.0 }, mx] +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { + let h6 = h.rem_euclid(1.0) * 6.0; + let i = h6.floor(); + let f = h6 - i; + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + match i as i32 { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + _ => [v, p, q], + } +} + +/// Colour Dynamics for one dab (or once per stroke, with `index = 0`). +pub fn dynamic_color(b: &BrushSettings, s: &StepInput, seed: u64, index: u64) -> [f32; 4] { + let cd = &b.color_dynamics; + let (fg, bg) = (b.color, b.background); + let r = |st: u64| rand01(seed, index, st); + let base = if cd.fg_bg.control != Control::Off { 1.0 - control_value(cd.fg_bg.control, cd.fg_bg.fade_steps, s) } else { 0.0 }; + let t = (base + (1.0 - base) * cd.fg_bg.jitter.clamp(0.0, 1.0) * r(stream::FG_BG)).clamp(0.0, 1.0); + let rgb = [fg[0] + (bg[0] - fg[0]) * t, fg[1] + (bg[1] - fg[1]) * t, fg[2] + (bg[2] - fg[2]) * t]; + let [mut h, mut sat, mut v] = rgb_to_hsv(rgb); + h += rand_signed(seed, index, stream::HUE) * cd.hue_jitter.clamp(0.0, 1.0) * 0.5; + sat = (sat + rand_signed(seed, index, stream::SAT) * cd.saturation_jitter.clamp(0.0, 1.0)).clamp(0.0, 1.0); + let pu = cd.purity.clamp(-1.0, 1.0); + sat = if pu >= 0.0 { sat + (1.0 - sat) * pu } else { sat * (1.0 + pu) }; + v = (v + rand_signed(seed, index, stream::BRIGHT) * cd.brightness_jitter.clamp(0.0, 1.0)).clamp(0.0, 1.0); + let o = hsv_to_rgb(h, sat, v); + [o[0], o[1], o[2], fg[3] + (bg[3] - fg[3]) * t] +} + +/// Base (pre-jitter) diameter at a step: size × size control (pressure etc.). Drives spacing. +pub fn base_diameter(b: &BrushSettings, s: &StepInput) -> f32 { + let sd = &b.shape_dynamics; + let mut f = 1.0; + if sd.enabled && sd.size.control != Control::Off { + let min = sd.size.minimum.clamp(0.0, 1.0); + f = min + (1.0 - min) * control_value(sd.size.control, sd.size.fade_steps, s); + } else if b.pressure_size { + f = s.point.pressure.clamp(0.0, 1.0); + } + b.size * f +} + +/// Builds the primary dabs of a step (Shape Dynamics, Scattering, Transfer, Color, Texture depth). +#[derive(Clone, Debug)] +pub struct DabBuilder { + pub brush: BrushSettings, + pub seed: u64, + /// Per-stroke colour (Color Dynamics with per-tip off), computed at the first step. + stroke_color: Option<[f32; 4]>, + next_index: u64, +} + +impl DabBuilder { + pub fn new(brush: &BrushSettings) -> Self { + Self { brush: brush.clone(), seed: brush.seed, stroke_color: None, next_index: 0 } + } + + pub fn step_len(&self, p: &StrokePoint) -> f64 { + let s = StepInput { point: *p, direction: 0.0, initial_direction: 0.0, step: 0 }; + let d = base_diameter(&self.brush, &s).max(1.0); + f64::from(d * self.brush.spacing.max(0.01)).max(0.5) + } + + pub fn build(&mut self, s: &StepInput, out: &mut Vec) { + let b = &self.brush; + let seed = self.seed; + let sd = &b.shape_dynamics; + let sc = &b.scattering; + let count = if sc.enabled { + let n = sc.count.clamp(1, 16) as f32; + let k = scalar(&sc.count_jitter, s, rand01(seed, s.step, stream::COUNT)); + ((n * k).round() as u32).clamp(1, 16) + } else { + 1 + }; + let base_d = base_diameter(b, s); + for _ in 0..count { + let i = self.next_index; + self.next_index += 1; + let r = |st: u64| rand01(seed, i, st); + // Shape. + let mut diameter = base_d; + let mut angle = b.angle; + let mut roundness = b.roundness.clamp(0.01, 1.0); + let (mut fx, mut fy) = (b.flip_x, b.flip_y); + if sd.enabled { + if sd.size.jitter > 0.0 { + let min = sd.size.minimum.clamp(0.0, 1.0); + let f = (1.0 - sd.size.jitter.clamp(0.0, 1.0) * r(stream::SIZE)).max(min); + diameter *= f; + } + angle += control_angle(sd.angle.control, sd.angle.fade_steps, s) + rand_signed(seed, i, stream::ANGLE) * sd.angle.jitter.clamp(0.0, 1.0) * 180.0; + roundness = (roundness * scalar(&sd.roundness, s, r(stream::ROUNDNESS))).clamp(0.01, 1.0); + if sd.flip_x_jitter && r(stream::FLIP_X) < 0.5 { + fx = !fx; + } + if sd.flip_y_jitter && r(stream::FLIP_Y) < 0.5 { + fy = !fy; + } + } + let radius = (diameter / 2.0).max(0.5); + // Scatter. + let mut c = Point::new(s.point.x, s.point.y); + if sc.enabled && sc.scatter.jitter > 0.0 { + let cv = control_value(sc.scatter.control, sc.scatter.fade_steps, s); + let amt = f64::from(sc.scatter.jitter * cv * radius); + let (ox, oy) = (rand_signed(seed, i, stream::SCATTER_X) as f64 * amt, rand_signed(seed, i, stream::SCATTER_Y) as f64 * amt); + if sc.both_axes { + c.x += ox; + c.y += oy; + } else { + // Perpendicular to the direction of travel (y-down image space). + let (dx, dy) = (f64::from(s.direction.cos()), -f64::from(s.direction.sin())); + c.x += -dy * ox; + c.y += dx * ox; + } + } + // Transfer. + let tr = &b.transfer; + let (mut opacity, mut flow) = (1.0, b.flow); + if tr.enabled { + opacity = scalar(&tr.opacity, s, r(stream::OPACITY)); + flow *= scalar(&tr.flow, s, r(stream::FLOW)); + } + if b.pressure_opacity && !(tr.enabled && tr.opacity.control != Control::Off) { + opacity *= s.point.pressure.clamp(0.0, 1.0); + } + // Colour. + let color = if b.color_dynamics.enabled { + if b.color_dynamics.per_tip { + dynamic_color(b, s, seed, i) + } else { + *self.stroke_color.get_or_insert_with(|| dynamic_color(b, s, seed, 0)) + } + } else { + b.color + }; + // Texture depth (per tip). + let tx = &b.texture; + let depth = if tx.enabled && tx.each_tip { tx.depth.clamp(0.0, 1.0) * scalar(&tx.depth_jitter, s, r(stream::DEPTH)) } else { tx.depth.clamp(0.0, 1.0) }; + out.push(Dab { + center: c, + radius, + alpha: flow.clamp(0.0, 1.0), + angle: angle.to_radians(), + roundness, + flip_x: fx, + flip_y: fy, + opacity: opacity.clamp(0.0, 1.0), + color, + depth, + index: i, + }); + } + } +} + +/// Builds Dual Brush dabs (the second tip: size, spacing, scatter, count, flip). +#[derive(Clone, Debug)] +pub struct DualBuilder { + pub brush: BrushSettings, + next_index: u64, +} + +impl DualBuilder { + pub fn new(brush: &BrushSettings) -> Self { + Self { brush: brush.clone(), next_index: 0 } + } + pub fn step_len(&self, _p: &StrokePoint) -> f64 { + let d = &self.brush.dual_brush; + f64::from(d.size.max(1.0) * d.spacing.max(0.01)).max(0.5) + } + pub fn build(&mut self, s: &StepInput, out: &mut Vec) { + let d = &self.brush.dual_brush; + let seed = self.brush.seed ^ 0xD0A1; + for _ in 0..d.count.clamp(1, 16) { + let i = self.next_index; + self.next_index += 1; + let radius = (d.size / 2.0).max(0.5); + let amt = f64::from(d.scatter * radius); + let (ox, oy) = (rand_signed(seed, i, stream::DUAL_X) as f64 * amt, rand_signed(seed, i, stream::DUAL_Y) as f64 * amt); + let mut c = Point::new(s.point.x, s.point.y); + if d.both_axes { + c.x += ox; + c.y += oy; + } else { + let (dx, dy) = (f64::from(s.direction.cos()), -f64::from(s.direction.sin())); + c.x += -dy * ox; + c.y += dx * ox; + } + let flip = d.flip && rand01(seed, i, stream::DUAL_FLIP) < 0.5; + out.push(Dab { + center: c, + radius, + alpha: 1.0, + angle: d.angle.to_radians(), + roundness: d.roundness.clamp(0.01, 1.0), + flip_x: flip, + flip_y: false, + opacity: 1.0, + color: [0.0, 0.0, 0.0, 1.0], + depth: 1.0, + index: i, + }); + } + } +} + +/// Apply Brush Pose to an input point. +pub fn apply_pose(b: &BrushSettings, mut p: StrokePoint) -> StrokePoint { + let pose = &b.pose; + if pose.enabled { + if pose.override_tilt { + p.tilt_x = pose.tilt_x; + p.tilt_y = pose.tilt_y; + } + if pose.override_rotation { + p.rotation = pose.rotation; + } + if pose.override_pressure { + p.pressure = pose.pressure; + } + } + p +} + +/// Incremental dab generation for a whole brush (primary and optional dual dabs). +#[derive(Clone, Debug)] +pub struct DabGenerator { + smoother: Smoother, + walker: PathWalker, + dual_walker: Option, + builder: DabBuilder, + dual: Option, + scratch: Vec, +} + +impl DabGenerator { + pub fn new(brush: &BrushSettings, zoom: f32) -> Self { + let interval = brush.build_up.then(|| 1000.0 / f64::from(brush.build_up_rate.clamp(0.1, 1000.0))); + let dual_on = brush.dual_brush.enabled; + Self { + smoother: Smoother::new(&brush.smoothing, zoom), + walker: PathWalker::new(interval), + dual_walker: dual_on.then(|| PathWalker::new(None)), + builder: DabBuilder::new(brush), + dual: dual_on.then(|| DualBuilder::new(brush)), + scratch: Vec::new(), + } + } + + fn walk(&mut self, pts: &[StrokePoint], dabs: &mut Vec, dual: &mut Vec) { + for p in pts { + let p = apply_pose(&self.builder.brush, *p); + let b = &self.builder; + let len = |q: &StrokePoint| b.step_len(q); + let mut steps = Vec::new(); + self.walker.push(p, &len, &mut |s| steps.push(s)); + for s in &steps { + self.builder.build(s, dabs); + } + if let (Some(w), Some(db)) = (self.dual_walker.as_mut(), self.dual.as_mut()) { + let mut steps = Vec::new(); + let len = |q: &StrokePoint| db.step_len(q); + w.push(p, &len, &mut |s| steps.push(s)); + for s in &steps { + db.build(s, dual); + } + } + } + } + + /// Feed input points; appends the new primary and dual dabs. + pub fn push(&mut self, pts: &[StrokePoint], dabs: &mut Vec, dual: &mut Vec) { + let mut sm = std::mem::take(&mut self.scratch); + sm.clear(); + for p in pts { + self.smoother.push(*p, &mut sm); + } + self.walk(&sm, dabs, dual); + self.scratch = sm; + } + + /// End of stroke. + pub fn finish(&mut self, dabs: &mut Vec, dual: &mut Vec) { + let mut sm = Vec::new(); + self.smoother.finish(&mut sm); + self.walk(&sm, dabs, dual); + let mut steps = Vec::new(); + self.walker.finish(&mut |s| steps.push(s)); + for s in &steps { + self.builder.build(s, dabs); + } + if let (Some(w), Some(db)) = (self.dual_walker.as_mut(), self.dual.as_mut()) { + let mut steps = Vec::new(); + w.finish(&mut |s| steps.push(s)); + for s in &steps { + db.build(s, dual); + } + } + } +}