readme, misc

This commit is contained in:
Brandon Thomas 2026-09-30 11:37:50 -04:00
parent b00b24739a
commit 14c5c1bc51
4 changed files with 1063 additions and 1 deletions

View File

@ -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.

248
crates/cms/src/clut.rs Normal file
View File

@ -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<usize>,
/// Normalised node values, `outputs` per node.
pub data: Vec<f32>,
strides: Vec<usize>,
}
impl Clut {
/// Builds a table from node data. Panics if `data` has the wrong length.
pub fn new(grid: Vec<usize>, outputs: usize, data: Vec<f32>) -> 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<usize>, 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);
}
}

232
crates/cms/src/pipeline.rs Normal file
View File

@ -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<Curve>),
/// Per-channel inverse curves.
InvCurves(Vec<Curve>),
/// `out[r] = Σ m[r][c]·in[c] + offset[r]`, `rows × cols`.
Matrix { rows: usize, cols: usize, m: Vec<f64>, offset: Vec<f64> },
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<Stage>,
}
impl Pipeline {
pub fn new(inputs: usize, stages: Vec<Stage>) -> 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<Item = Stage>) {
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<Stage> = 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::<f64>() + ob[r];
}
out.pop();
out.push(Stage::Matrix { rows: 3, cols: 3, m, offset: off });
continue;
}
out.push(s);
}
self.stages = out;
}
}

View File

@ -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<StrokePoint>,
last_input: Option<StrokePoint>,
}
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<StrokePoint>) {
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<StrokePoint>) {
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<StrokePoint> {
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<StrokePoint>,
/// Path distance from `last` to the next step.
next_at: f64,
/// First point, emitted lazily once the initial direction is known.
pending_first: Option<StrokePoint>,
initial_dir: Option<f32>,
dir: f32,
step: u64,
/// Airbrush interval in ms (None = off).
interval: Option<f64>,
time_acc: f64,
}
impl PathWalker {
pub fn new(build_up_interval_ms: Option<f64>) -> 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<Dab>) {
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<Dab>) {
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<PathWalker>,
builder: DabBuilder,
dual: Option<DualBuilder>,
scratch: Vec<StrokePoint>,
}
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<Dab>, dual: &mut Vec<Dab>) {
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<Dab>, dual: &mut Vec<Dab>) {
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<Dab>, dual: &mut Vec<Dab>) {
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);
}
}
}
}