diff --git a/Cargo.lock b/Cargo.lock index 3f4968bb..df43933e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3299,6 +3299,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "photocraft-cms" +version = "0.1.0" +dependencies = [ + "moxcms", + "rayon", +] + [[package]] name = "photocraft-codecs" version = "0.1.0" diff --git a/crates/cms/Cargo.toml b/crates/cms/Cargo.toml new file mode 100644 index 00000000..389f7976 --- /dev/null +++ b/crates/cms/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "photocraft-cms" +description = "Pure-Rust ICC v2/v4 colour management: profile parsing and writing, transforms, rendering intents, black point compensation" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + +[dev-dependencies] +moxcms = "0.8" diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs new file mode 100644 index 00000000..c974563e --- /dev/null +++ b/crates/cms/src/curve.rs @@ -0,0 +1,336 @@ +//! One-dimensional tone curves (ICC `curv` and `para` tag types) and their inverses. + +/// A tone reproduction curve. +/// +/// Gamma and parametric curves are evaluated analytically, including outside `[0, 1]` (negative +/// inputs mirror, values above 1 extend the formula), so unbounded float transforms keep HDR +/// values. Sampled tables clamp their input to `[0, 1]`. +#[derive(Clone, Debug, PartialEq)] +pub enum Curve { + Identity, + /// `y = x^g`. + Gamma(f64), + /// ICC `parametricCurveType` function `kind` (0–4) with parameters `[g, a, b, c, d, e, f]` + /// (unused ones are 0). + Parametric { kind: u16, p: [f64; 7] }, + /// Samples at evenly spaced inputs over `[0, 1]`, normalised outputs. + Table(Vec), +} + +/// The sRGB transfer function (IEC 61966-2.1) as ICC parametric type 3. +pub fn srgb_trc() -> Curve { + Curve::Parametric { kind: 3, p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0] } +} + +impl Curve { + /// Number of parameters stored for a parametric function type. + pub fn param_count(kind: u16) -> usize { + match kind { + 0 => 1, + 1 => 3, + 2 => 4, + 3 => 5, + _ => 7, + } + } + + pub fn is_identity(&self) -> bool { + match self { + Curve::Identity => true, + Curve::Gamma(g) => (*g - 1.0).abs() < 1e-9, + Curve::Parametric { kind: 0, p } => (p[0] - 1.0).abs() < 1e-9, + Curve::Table(t) => { + let n = t.len(); + n < 2 || t.iter().enumerate().all(|(i, v)| (*v as f64 - i as f64 / (n - 1) as f64).abs() < 1.0 / 65535.0) + } + _ => false, + } + } + + /// Evaluates the curve at `x` (f64 reference path). + pub fn eval64(&self, x: f64) -> f64 { + match self { + Curve::Identity => x, + Curve::Gamma(g) => mirror(x, |v| v.powf(*g)), + Curve::Parametric { kind, p } => para(*kind, p, x), + Curve::Table(t) => table(t, x as f32) as f64, + } + } + + #[inline] + pub fn eval(&self, x: f32) -> f32 { + match self { + Curve::Identity => x, + Curve::Table(t) => table(t, x), + _ => self.eval64(x as f64) as f32, + } + } + + /// Evaluates the inverse curve at `y`. + pub fn eval_inverse64(&self, y: f64) -> f64 { + match self { + Curve::Identity => y, + Curve::Gamma(g) => { + if g.abs() < 1e-12 { + y + } else { + mirror(y, |v| v.powf(1.0 / g)) + } + } + Curve::Parametric { kind, p } => para_inverse(*kind, p, y).unwrap_or_else(|| numeric_inverse(|x| para(*kind, p, x), y)), + Curve::Table(t) => table_inverse(t, y as f32) as f64, + } + } + + #[inline] + pub fn eval_inverse(&self, y: f32) -> f32 { + match self { + Curve::Identity => y, + Curve::Table(t) => table_inverse(t, y), + _ => self.eval_inverse64(y as f64) as f32, + } + } + + /// Samples `n` evenly spaced points over `[0, 1]`. + pub fn sample(&self, n: usize) -> Vec { + (0..n).map(|i| self.eval64(i as f64 / (n - 1) as f64) as f32).collect() + } + + /// `true` when the curve never decreases over `[0, 1]`. + pub fn is_monotonic(&self) -> bool { + let s = self.sample(1024); + s.windows(2).all(|w| w[1] >= w[0] - 1e-6) || s.windows(2).all(|w| w[1] <= w[0] + 1e-6) + } +} + +#[inline] +fn mirror(x: f64, f: impl Fn(f64) -> f64) -> f64 { + if x < 0.0 { -f(-x) } else { f(x) } +} + +fn pw(base: f64, g: f64) -> f64 { + if base <= 0.0 { 0.0 } else { base.powf(g) } +} + +fn para(kind: u16, p: &[f64; 7], x: f64) -> f64 { + let [g, a, b, c, d, e, f] = *p; + match kind { + 0 => mirror(x, |v| v.powf(g)), + 1 => { + if a != 0.0 && x >= -b / a { + pw(a * x + b, g) + } else { + 0.0 + } + } + 2 => { + if a != 0.0 && x >= -b / a { + pw(a * x + b, g) + c + } else { + c + } + } + 3 => { + if x < 0.0 && b == 0.0 && d == 0.0 { + // Pure gamma written as type 3: mirror negatives. + -para(kind, p, -x) + } else if x >= d { + pw(a * x + b, g) + } else { + c * x + } + } + _ => { + if x >= d { + pw(a * x + b, g) + e + } else { + c * x + f + } + } + } +} + +fn para_inverse(kind: u16, p: &[f64; 7], y: f64) -> Option { + let [g, a, b, c, d, e, f] = *p; + if g.abs() < 1e-12 { + return None; + } + let root = |v: f64| if v <= 0.0 { 0.0 } else { v.powf(1.0 / g) }; + Some(match kind { + 0 => mirror(y, |v| v.powf(1.0 / g)), + 1 => { + if a == 0.0 { + return None; + } + if y <= 0.0 { -b / a } else { (root(y) - b) / a } + } + 2 => { + if a == 0.0 { + return None; + } + if y <= c { -b / a } else { (root(y - c) - b) / a } + } + 3 => { + if a == 0.0 { + return None; + } + if y < 0.0 && b == 0.0 && d == 0.0 { + return para_inverse(kind, p, -y).map(|v| -v); + } + let yd = pw(a * d + b, g); + if y >= yd { + (root(y) - b) / a + } else if c != 0.0 { + y / c + } else { + 0.0 + } + } + _ => { + if a == 0.0 { + return None; + } + let yd = pw(a * d + b, g) + e; + if y >= yd { + (root(y - e) - b) / a + } else if c != 0.0 { + (y - f) / c + } else { + 0.0 + } + } + }) +} + +/// Bisection inverse of an increasing function on `[0, 1]`. +fn numeric_inverse(f: impl Fn(f64) -> f64, y: f64) -> f64 { + let (f0, f1) = (f(0.0), f(1.0)); + let increasing = f1 >= f0; + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + let v = f(mid); + if (v < y) == increasing { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) +} + +#[inline] +fn table(t: &[f32], x: f32) -> f32 { + let n = t.len(); + match n { + 0 => x, + 1 => t[0], + _ => { + let pos = x.clamp(0.0, 1.0) * (n - 1) as f32; + let i = (pos as usize).min(n - 2); + let f = pos - i as f32; + t[i] + (t[i + 1] - t[i]) * f + } + } +} + +/// Exact inverse of a piecewise-linear table (binary search), for increasing or decreasing tables. +fn table_inverse(t: &[f32], y: f32) -> f32 { + let n = t.len(); + if n < 2 { + return y; + } + let increasing = t[n - 1] >= t[0]; + let (lo_v, hi_v) = if increasing { (t[0], t[n - 1]) } else { (t[n - 1], t[0]) }; + if y <= lo_v { + // Flat start: choose the last index still at the lowest value. + return if increasing { edge_index(t, true) } else { edge_index(t, false) }; + } + if y >= hi_v { + return if increasing { edge_index(t, false) } else { edge_index(t, true) }; + } + let (mut lo, mut hi) = (0usize, n - 1); + while hi - lo > 1 { + let mid = (lo + hi) / 2; + if (t[mid] < y) == increasing { + lo = mid; + } else { + hi = mid; + } + } + let (a, b) = (t[lo], t[hi]); + let f = if (b - a).abs() > 1e-12 { (y - a) / (b - a) } else { 0.5 }; + (lo as f32 + f) / (n - 1) as f32 +} + +/// For a flat run at the table start (`start = true`) the last index of the run, or for a flat +/// run at the end the first index of it, as a normalised input. +fn edge_index(t: &[f32], start: bool) -> f32 { + let n = t.len(); + if start { + let v = t[0]; + let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1); + i as f32 / (n - 1) as f32 + } else { + let v = t[n - 1]; + let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1); + i as f32 / (n - 1) as f32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn srgb_matches_formula() { + let c = srgb_trc(); + for i in 0..=255 { + let v = i as f64 / 255.0; + let want = if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + assert!((c.eval64(v) - want).abs() < 1e-12); + assert!((c.eval_inverse64(want) - v).abs() < 1e-9); + } + } + + #[test] + fn parametric_inverses() { + let curves = [ + Curve::Parametric { kind: 0, p: [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }, + Curve::Parametric { kind: 1, p: [2.0, 0.9, 0.1, 0.0, 0.0, 0.0, 0.0] }, + Curve::Parametric { kind: 2, p: [2.0, 0.9, 0.1, 0.05, 0.0, 0.0, 0.0] }, + srgb_trc(), + Curve::Parametric { kind: 4, p: [2.4, 0.9, 0.05, 0.08, 0.04, 0.01, 0.002] }, + Curve::Gamma(1.8), + ]; + for c in &curves { + for i in 1..100 { + let x = i as f64 / 100.0; + let y = c.eval64(x); + assert!((c.eval_inverse64(y) - x).abs() < 1e-6, "{c:?} at {x}"); + } + } + } + + #[test] + fn table_inverse_exact_on_knots_and_between() { + let t = Curve::Gamma(2.2).sample(1024); + let c = Curve::Table(t); + for i in 0..=100 { + let x = i as f32 / 100.0; + let back = c.eval_inverse(c.eval(x)); + assert!((back - x).abs() < 1e-4, "{x} -> {back}"); + } + let dec = Curve::Table(vec![1.0, 0.5, 0.0]); + assert!((dec.eval_inverse(0.25) - 0.75).abs() < 1e-6); + } + + #[test] + fn hdr_and_negative_extension() { + let c = srgb_trc(); + assert!(c.eval64(2.0) > 1.0); + assert!((c.eval_inverse64(c.eval64(2.0)) - 2.0).abs() < 1e-9); + let g = Curve::Gamma(2.2); + assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12); + } +} diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs new file mode 100644 index 00000000..4c1a0daf --- /dev/null +++ b/crates/cms/src/lib.rs @@ -0,0 +1,2 @@ +//! Photocraft colour management (placeholder while the crate is being written). +#![forbid(unsafe_code)] diff --git a/crates/cms/src/math.rs b/crates/cms/src/math.rs new file mode 100644 index 00000000..e9e63939 --- /dev/null +++ b/crates/cms/src/math.rs @@ -0,0 +1,207 @@ +//! Small colour-science helpers: 3×3 matrices, CIE XYZ ↔ L*a*b*, chromatic adaptation. +//! +//! All formulas are from public standards: CIE 15 (L*a*b*), ICC.1:2010 Annex A (PCS, D50), +//! and the Bradford cone response matrix published by Lam (1985). + +/// The ICC profile connection space illuminant (D50), as encoded in s15Fixed16 in every +/// ICC header: X = 0.9642, Y = 1.0, Z = 0.8249. +pub const D50: [f64; 3] = [0.9642, 1.0, 0.8249]; + +/// CIE D65 white point as XYZ (Y = 1), from the chromaticity (0.3127, 0.3290) used by sRGB, +/// Display P3 and Adobe RGB-compatible spaces. +pub const D65_XY: [f64; 2] = [0.3127, 0.3290]; + +/// Row-major 3×3 matrix. +pub type Mat3 = [[f64; 3]; 3]; + +pub const IDENTITY: Mat3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +pub fn mul(a: &Mat3, b: &Mat3) -> Mat3 { + let mut o = [[0.0; 3]; 3]; + for (i, row) in o.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]; + } + } + o +} + +pub fn apply(m: &Mat3, v: [f64; 3]) -> [f64; 3] { + [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ] +} + +pub fn det(m: &Mat3) -> f64 { + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) +} + +/// Inverse, or `None` when (nearly) singular. +pub fn invert(m: &Mat3) -> Option { + let d = det(m); + if !d.is_finite() || d.abs() < 1e-12 { + return None; + } + let inv = 1.0 / d; + Some([ + [ + (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv, + (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv, + (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv, + ], + [ + (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv, + (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv, + (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv, + ], + [ + (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv, + (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv, + (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv, + ], + ]) +} + +/// XYZ (Y = 1) of a chromaticity `(x, y)`. +pub fn xy_to_xyz(xy: [f64; 2]) -> [f64; 3] { + let [x, y] = xy; + [x / y, 1.0, (1.0 - x - y) / y] +} + +/// Bradford cone response matrix (Lam 1985). +const BRADFORD: Mat3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]; + +/// Bradford chromatic adaptation matrix from white `src` to white `dst` (both XYZ). +pub fn bradford(src: [f64; 3], dst: [f64; 3]) -> Mat3 { + let inv = invert(&BRADFORD).expect("Bradford matrix is invertible"); + let s = apply(&BRADFORD, src); + let d = apply(&BRADFORD, dst); + let scale = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]]; + mul(&inv, &mul(&scale, &BRADFORD)) +} + +/// RGB → XYZ matrix (relative to `white`, Y = 1) for primaries given as chromaticities. +pub fn rgb_to_xyz_matrix(r: [f64; 2], g: [f64; 2], b: [f64; 2], white: [f64; 3]) -> Mat3 { + let (xr, xg, xb) = (xy_to_xyz(r), xy_to_xyz(g), xy_to_xyz(b)); + let m = [[xr[0], xg[0], xb[0]], [xr[1], xg[1], xb[1]], [xr[2], xg[2], xb[2]]]; + let s = apply(&invert(&m).expect("primaries are independent"), white); + [ + [m[0][0] * s[0], m[0][1] * s[1], m[0][2] * s[2]], + [m[1][0] * s[0], m[1][1] * s[1], m[1][2] * s[2]], + [m[2][0] * s[0], m[2][1] * s[1], m[2][2] * s[2]], + ] +} + +const EPS: f64 = 216.0 / 24389.0; // (6/29)^3 +const KAPPA: f64 = 24389.0 / 27.0; // (29/3)^3 + +#[inline] +fn lab_f(t: f64) -> f64 { + if t > EPS { t.cbrt() } else { (KAPPA * t + 16.0) / 116.0 } +} + +#[inline] +fn lab_finv(f: f64) -> f64 { + let f3 = f * f * f; + if f3 > EPS { f3 } else { (116.0 * f - 16.0) / KAPPA } +} + +/// CIE XYZ → L*a*b* relative to `white` (CIE 15). +pub fn xyz_to_lab(xyz: [f64; 3], white: [f64; 3]) -> [f64; 3] { + let fx = lab_f(xyz[0] / white[0]); + let fy = lab_f(xyz[1] / white[1]); + let fz = lab_f(xyz[2] / white[2]); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// CIE L*a*b* → XYZ relative to `white`. +pub fn lab_to_xyz(lab: [f64; 3], white: [f64; 3]) -> [f64; 3] { + let fy = (lab[0] + 16.0) / 116.0; + let fx = fy + lab[1] / 500.0; + let fz = fy - lab[2] / 200.0; + [lab_finv(fx) * white[0], lab_finv(fy) * white[1], lab_finv(fz) * white[2]] +} + +/// f32 versions for the hot paths (D50 PCS only). +#[inline] +pub fn xyz_to_lab_f32(xyz: [f32; 3]) -> [f32; 3] { + const W: [f32; 3] = [0.9642, 1.0, 0.8249]; + const E: f32 = 216.0 / 24389.0; + const K: f32 = 24389.0 / 27.0; + let f = |t: f32| if t > E { t.cbrt() } else { (K * t + 16.0) / 116.0 }; + let fx = f(xyz[0] / W[0]); + let fy = f(xyz[1] / W[1]); + let fz = f(xyz[2] / W[2]); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +#[inline] +pub fn lab_to_xyz_f32(lab: [f32; 3]) -> [f32; 3] { + const W: [f32; 3] = [0.9642, 1.0, 0.8249]; + const E: f32 = 216.0 / 24389.0; + const K: f32 = 24389.0 / 27.0; + let fy = (lab[0] + 16.0) / 116.0; + let fx = fy + lab[1] / 500.0; + let fz = fy - lab[2] / 200.0; + let finv = |f: f32| { + let f3 = f * f * f; + if f3 > E { f3 } else { (116.0 * f - 16.0) / K } + }; + [finv(fx) * W[0], finv(fy) * W[1], finv(fz) * W[2]] +} + +/// CIE 1976 colour difference. +pub fn delta_e76(a: [f64; 3], b: [f64; 3]) -> f64 { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() +} + +/// Quantizes to ICC s15Fixed16Number and back (what a profile can actually store). +pub fn s15f16_round(v: f64) -> f64 { + (v * 65536.0).round() / 65536.0 +} + +/// The D50 illuminant exactly as an ICC header stores it (s15Fixed16). +pub fn d50_quantized() -> [f64; 3] { + [s15f16_round(D50[0]), s15f16_round(D50[1]), s15f16_round(D50[2])] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lab_roundtrip() { + for xyz in [[0.2, 0.3, 0.1], [0.9642, 1.0, 0.8249], [0.001, 0.002, 0.0005]] { + let back = lab_to_xyz(xyz_to_lab(xyz, D50), D50); + for i in 0..3 { + assert!((back[i] - xyz[i]).abs() < 1e-12); + } + } + let w = xyz_to_lab(D50, D50); + assert!((w[0] - 100.0).abs() < 1e-12 && w[1].abs() < 1e-12 && w[2].abs() < 1e-12); + } + + #[test] + fn bradford_maps_white() { + let d65 = xy_to_xyz(D65_XY); + let m = bradford(d65, D50); + let w = apply(&m, d65); + for i in 0..3 { + assert!((w[i] - D50[i]).abs() < 1e-12); + } + } + + #[test] + fn invert_roundtrip() { + let m = [[0.4, 0.3, 0.2], [0.2, 0.7, 0.1], [0.0, 0.1, 0.8]]; + let p = mul(&m, &invert(&m).unwrap()); + for (i, row) in p.iter().enumerate() { + for (j, v) in row.iter().enumerate() { + assert!((v - if i == j { 1.0 } else { 0.0 }).abs() < 1e-12); + } + } + } +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index a3675e55..12b85857 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -500,12 +500,109 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight) -> (Map, Map, bool) { (apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour), outer) } +/// Effect maps derived from a layer's shape (its alpha), computed once over the layer's whole +/// region and cropped per render tile. They depend only on the shape, the effect settings and the +/// global light, never on the backdrop, so they can be cached across tiles and edits. +pub struct FxMaps { + rect: Rect, + shape: Map, + /// Per enabled effect (in `items` order): the maps it paints through. + per: Vec>, + outer_bevel: Vec, + din: Option>, + dout: Option>, + /// Shape layers: distance outside the vector outline (from local coverage). + vdout: Option>, +} + +impl FxMaps { + /// Approximate heap size (for cache budgeting). + pub fn bytes(&self) -> usize { + let n = self.shape.v.len(); + let maps: usize = self.per.iter().map(Vec::len).sum(); + (1 + maps + usize::from(self.din.is_some()) + usize::from(self.dout.is_some()) + usize::from(self.vdout.is_some())) * n * 4 + } + + fn crop_vec(&self, v: &[f32], to: Rect, fill: f32) -> Vec { + let (w, h) = (to.width() as usize, to.height() as usize); + let mut out = vec![fill; w * h]; + let r = self.rect; + let inter = r.intersect(&to); + if inter.is_empty() { + return out; + } + let sw = r.width() as usize; + for y in inter.y0..inter.y1 { + let s0 = (y - r.y0) as usize * sw + (inter.x0 - r.x0) as usize; + let d0 = (y - to.y0) as usize * w + (inter.x0 - to.x0) as usize; + let n = inter.width() as usize; + out[d0..d0 + n].copy_from_slice(&v[s0..s0 + n]); + } + out + } + + fn crop(&self, m: &Map, to: Rect, fill: f32) -> Map { + Map { w: to.width() as usize, h: to.height() as usize, v: self.crop_vec(&m.v, to, fill) } + } +} + +fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map { + let (dx, dy) = offset(s.angle, s.distance); + let mut a = shape.shifted(dx, dy, 0.0); + let mut b = shape.shifted(-dx, -dy, 0.0); + blur(&mut a, s.size); + blur(&mut b, s.size); + let mut m = Map { w: shape.w, h: shape.h, v: a.v.iter().zip(&b.v).map(|(x, y)| (x - y).abs()).collect() }; + if s.invert { + m = m.map(|v| 1.0 - v); + } + let mut m = apply_contour(m, &s.contour); + for (v, a) in m.v.iter_mut().zip(&shape.v) { + *v *= a; + } + m +} + +/// Build every effect map for `layer` from its alpha `shape` over `rect`. +pub fn build_maps(layer: &Layer, shape: Vec, rect: Rect, light: &GlobalLight) -> FxMaps { + let (w, h) = (rect.width() as usize, rect.height() as usize); + let shape = Map { w, h, v: shape }; + let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect(); + let mut outer_bevel = vec![false; items.len()]; + let per: Vec> = items + .iter() + .enumerate() + .map(|(i, e)| match e { + Effect::DropShadow(s) => vec![shadow_map(&shape, s, light, false)], + Effect::InnerShadow(s) => vec![shadow_map(&shape, s, light, true)], + Effect::OuterGlow(g) => vec![glow_map(&shape, g, false)], + Effect::InnerGlow(g) => vec![glow_map(&shape, g, true)], + Effect::Satin(s) => vec![satin_map(&shape, s)], + Effect::BevelEmboss(b) => { + let (hi, sh, outer) = bevel_maps(&shape, b, light); + outer_bevel[i] = outer; + vec![hi, sh] + } + _ => Vec::new(), + }) + .collect(); + let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_))); + let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_)); + let (din, dout) = if has_stroke { (Some(dist_inside(&shape)), Some(dist_outside(&shape))) } else { (None, None) }; + let vdout = (has_stroke && vector_shape).then(|| dist_outside(&local_coverage(&shape))); + FxMaps { rect, shape, per, outer_bevel, din, dout, vdout } +} + +/// Far outside any shape (distance fill for cropped distance fields). +const FAR: f32 = 1.0e9; + /// Composites `content` (the layer's own pixels over `big`, alpha already /// masked, clipped layers applied) plus its effects into `backdrop`. -pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, light: &GlobalLight, layer_bounds: Rect) { +pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect) { let big = content.rect; let (w, h) = (big.width() as usize, big.height() as usize); let shape = Map { w, h, v: content.px.iter().map(|p| p[3]).collect() }; + let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0); // Layer bounds (gradients aligned with the layer use the whole layer, // independent of the render rect). let sb = layer_bounds; @@ -521,11 +618,11 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect(); let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_)); // Multiple instances: the first listed is on top, so paint in reverse. - let rev = || items.iter().rev().copied(); + let rev = || items.iter().copied().enumerate().rev(); - for e in rev() { + for (i, e) in rev() { if let Effect::DropShadow(s) = e { - let mut m = shadow_map(&shape, s, light, false); + let mut m = fx(i, 0); if s.knocks_out { for (v, a) in m.v.iter_mut().zip(&shape.v) { *v *= 1.0 - a; @@ -534,9 +631,9 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity); } } - for e in rev() { + for (i, e) in rev() { if let Effect::OuterGlow(g) = e { - let m = glow_map(&shape, g, false); + let m = fx(i, 0); paint_fx(&mut work, &m, &g.paint, sb, big, g.common.blend, g.common.opacity); } } @@ -545,43 +642,31 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu let fill = layer.fill_opacity; let mut lay = Buffer { rect: big, px: content.px.iter().map(|p| [p[0], p[1], p[2], p[3] * fill]).collect() }; let full = shape.clone(); - for e in rev() { + for (_, e) in rev() { if let Effect::GradientOverlay { common, gradient, .. } = e { paint_fx(&mut lay, &full, &FxPaint::Gradient(gradient.clone()), sb, big, common.blend, common.opacity) } } - for e in rev() { + for (_, e) in rev() { if let Effect::ColorOverlay { common, color } = e { paint_color(&mut lay, &full, rgb(color), common.blend, common.opacity); } } - for e in rev() { + for (i, e) in rev() { if let Effect::Satin(s) = e { - let (dx, dy) = offset(s.angle, s.distance); - let mut a = shape.shifted(dx, dy, 0.0); - let mut b = shape.shifted(-dx, -dy, 0.0); - blur(&mut a, s.size); - blur(&mut b, s.size); - let mut m = Map { w, h, v: a.v.iter().zip(&b.v).map(|(x, y)| (x - y).abs()).collect() }; - if s.invert { - m = m.map(|v| 1.0 - v); - } - let mut m = apply_contour(m, &s.contour); - for (v, a) in m.v.iter_mut().zip(&shape.v) { - *v *= a; - } + let m = fx(i, 0); paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity); } } - for e in rev() { + for (i, e) in rev() { if let Effect::InnerGlow(g) = e { - let m = glow_map(&shape, g, true); + let m = fx(i, 0); paint_fx(&mut lay, &m, &g.paint, sb, big, g.common.blend, g.common.opacity); } } - for e in rev() { + for (i, e) in rev() { if let Effect::InnerShadow(s) = e { - let m = shadow_map(&shape, s, light, true); + let m = fx(i, 0); paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity); } } @@ -589,14 +674,16 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu // (top) instance is processed first; inside parts are painted over it, // bottom instance first. let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect(); - let (din, dout) = if strokes.is_empty() { (Vec::new(), Vec::new()) } else { (dist_inside(&shape), dist_outside(&shape)) }; + let (din, dout) = match (&maps.din, &maps.dout) { + (Some(a), Some(b)) if !strokes.is_empty() => (maps.crop_vec(a, big, 0.0), maps.crop_vec(b, big, FAR)), + _ => (Vec::new(), Vec::new()), + }; let widths = |st: &photocraft_doc::StrokeFx| match st.position { StrokePosition::Outside => (0.0, st.size), StrokePosition::Inside => (st.size, 0.0), StrokePosition::Center => (st.size / 2.0, st.size / 2.0), }; - let vcov = if vector_shape && !strokes.is_empty() { Some(local_coverage(&shape)) } else { None }; - let vdout = vcov.as_ref().map(dist_outside); + let vdout = maps.vdout.as_ref().filter(|_| vector_shape && !strokes.is_empty()).map(|v| maps.crop_vec(v, big, FAR)); for st in strokes.iter().copied() { let (_, out_w) = widths(st); if out_w <= 0.0 { @@ -631,9 +718,9 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu } paint_fx(&mut lay, &m, &st.paint, sb, big, st.common.blend, st.common.opacity); } - for e in rev() { + for (i, e) in rev() { if let Effect::BevelEmboss(b) = e { - let (hi, sh, outer) = bevel_maps(&shape, b, light); + let (hi, sh, outer) = (fx(i, 0), fx(i, 1), maps.outer_bevel[i]); let dst = if outer { &mut work } else { &mut lay }; paint_color(dst, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity); paint_color(dst, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5f7a66c9..09b194f8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -455,7 +455,8 @@ fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: for c in clipped.iter().filter(|c| c.visible) { composite_atop(c, &mut content, cx); } - effects::composite_with_effects(layer, &content, backdrop, &cx.light, layer_bounds(layer, cx.canvas)); + let maps = effect_maps(layer, cx); + effects::composite_with_effects(layer, &content, backdrop, &maps, layer_bounds(layer, cx.canvas)); return; } if let Some((mut content, stroke)) = shape_parts(layer, clipped, rect, cx) { @@ -497,6 +498,121 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option Some((f, s)) } +// --------------------------------------------------------------------------------------------- +// Layer-effect map cache + +/// Order-independent identity of a layer's pixels, masks, effects and (for groups) children. +/// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the +/// layer so those tiles (and their addresses) stay alive while the entry exists. +fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { + use std::hash::{Hash, Hasher}; + fn surface_fp(s: &Surface) -> u64 { + s.tiles().fold(s.tile_count() as u64, |acc, (c, t)| { + let mut x = (std::sync::Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 40) ^ ((c.ty as u32 as u64) << 8); + x = x.wrapping_mul(0x9e37_79b9_7f4a_7c15); + acc.wrapping_add(x ^ (x >> 29)) + }) + } + layer.id.0.hash(h); + layer.visible.hash(h); + layer.opacity.to_bits().hash(h); + layer.fill_opacity.to_bits().hash(h); + match &layer.content { + LayerContent::Group(g) => { + for c in &g.children { + layer_identity(c, h); + } + } + LayerContent::Fill(f) => format!("{f:?}").hash(h), + LayerContent::Adjustment(a) => format!("{a:?}").hash(h), + _ => layer.surface().map_or(0, surface_fp).hash(h), + } + if let Some(m) = &layer.mask { + (surface_fp(&m.surface), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h); + } + if let Some(vm) = &layer.vector_mask { + format!("{vm:?}").hash(h); + } + format!("{:?}", layer.effects).hash(h); + h.write_u8(0xfe); +} + +struct FxEntry { + maps: std::sync::Arc, + _pin: Layer, + bytes: usize, +} + +/// Global cache of effect maps (bounded by bytes). Tiles rendered in parallel share one build per +/// layer state via a per-key `OnceLock`. +type FxSlot = std::sync::Arc>; +struct FxCache { + map: std::collections::HashMap, + order: std::collections::VecDeque, + bytes: usize, +} + +const FX_CACHE_BUDGET: usize = 768 << 20; + +fn fx_cache() -> &'static std::sync::Mutex { + static C: std::sync::OnceLock> = std::sync::OnceLock::new(); + C.get_or_init(|| std::sync::Mutex::new(FxCache { map: Default::default(), order: Default::default(), bytes: 0 })) +} + +/// The layer's effect maps over its whole region (layer bounds grown by the effect reach, within +/// the canvas grown likewise), built once per layer state and shared by every tile. +fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc { + use std::hash::{Hash, Hasher}; + let m = effects::margin(layer); + let region = layer_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m)); + if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() { + let shape = render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_default(); + return std::sync::Arc::new(effects::build_maps(layer, shape, region, &cx.light)); + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + layer_identity(layer, &mut h); + (region.x0, region.y0, region.x1, region.y1).hash(&mut h); + (cx.light.angle.to_bits(), cx.light.altitude.to_bits()).hash(&mut h); + let key = h.finish(); + let slot = { + let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(s) = c.map.get(&key) { + s.clone() + } else { + let s: FxSlot = Default::default(); + c.map.insert(key, s.clone()); + c.order.push_back(key); + s + } + }; + let entry = slot.get_or_init(|| { + let shape: Vec = if region.is_empty() { + Vec::new() + } else { + render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize]) + }; + let maps = effects::build_maps(layer, shape, region, &cx.light); + let bytes = maps.bytes(); + // Counted exactly once, when the entry is built. + fx_cache().lock().unwrap_or_else(|e| e.into_inner()).bytes += bytes; + FxEntry { maps: std::sync::Arc::new(maps), _pin: layer.clone(), bytes } + }); + let maps = entry.maps.clone(); + // Evict the oldest entries over budget (never the one just used). + let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner()); + while c.bytes > FX_CACHE_BUDGET && c.order.len() > 1 { + let Some(old) = c.order.pop_front() else { break }; + if old == key { + c.order.push_back(old); + continue; + } + if let Some(s) = c.map.remove(&old) { + c.bytes = c.bytes.saturating_sub(s.get().map_or(0, |e| e.bytes)); + } + } + maps +} + /// Composite `layer` onto `base` restricted to the base's alpha (clipping mask semantics). fn composite_atop(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let rect = base.rect; @@ -520,7 +636,8 @@ fn composite_atop(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let big = rect.inflate(effects::margin(layer)); let Some(content) = render_content(layer, big, cx) else { return }; let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; - effects::composite_with_effects(layer, &content, &mut opaque, &cx.light, layer_bounds(layer, cx.canvas)); + let maps = effect_maps(layer, cx); + effects::composite_with_effects(layer, &content, &mut opaque, &maps, layer_bounds(layer, cx.canvas)); for (p, o) in base.px.iter_mut().zip(&opaque.px) { if p[3] > 0.0 { *p = [o[0], o[1], o[2], p[3]]; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d039e5c8..c3807099 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -629,3 +629,28 @@ fn vector_mask_combines_with_pixel_mask() { assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0])); assert!(close4(px(&d, 6, 1), [1.0; 4])); } + +#[test] +fn effect_maps_are_cached_and_invalidated_by_pixel_changes() { + let mut doc = doc_white(64, 64); + let mut l = solid_layer("fx", Rect::new(16, 16, 48, 48), [1.0, 0.0, 0.0, 1.0]); + l.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + doc.layers.push(l); + let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::Srgb, light: doc.global_light }; + let a = effect_maps(&doc.layers[1], &cx); + let b = effect_maps(&doc.layers[1], &cx); + assert!(std::sync::Arc::ptr_eq(&a, &b), "second request hits the cache"); + let first = flatten(&doc); + // Editing the layer's pixels changes its tiles, so the maps are rebuilt. + doc.layers[1].surface_mut().unwrap().fill_rect(Rect::new(8, 8, 20, 20), &[0.0, 0.0, 1.0, 1.0]); + let c = effect_maps(&doc.layers[1], &cx); + assert!(!std::sync::Arc::ptr_eq(&a, &c), "pixel edit invalidates"); + // Cached rendering equals a fresh build (tiled and full renders agree too). + let again = flatten(&doc); + let region = doc.bounds(); + let tiled = render_tiled(&doc, region, 16); + for (p, q) in again.px.iter().zip(&tiled.px) { + assert!(close4(*p, *q)); + } + assert_ne!(first.px, again.px); +} diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index 85c5fb4a..2abea770 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -88,10 +88,10 @@ fn main() { } } for rec in file.layers() { - if let Some(b) = rec.block(b"lfx2").or(rec.block(b"lmfx")) { - if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..]) { - walk(&vd.descriptor, 1); - } + if let Some(b) = rec.block(b"lfx2").or(rec.block(b"lmfx")) + && let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..]) + { + walk(&vd.descriptor, 1); } } } diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs new file mode 100644 index 00000000..a4a63fb6 --- /dev/null +++ b/crates/paint/src/brush.rs @@ -0,0 +1,431 @@ +//! The brush model: tip shape plus Photoshop-style dynamics sections, all plain serde data. +//! +//! Every section is optional (an `enabled` flag, or a neutral default), so a partial JSON object +//! such as `{"size": 40, "scattering": {"enabled": true, "scatter": {"jitter": 2.0}}}` deserialises +//! onto the defaults. Field names are camelCase in JSON, matching the command parameters. +//! +//! Units: sizes are pixels, angles are degrees, every percentage is a 0..1 fraction (scatter and +//! texture scale may exceed 1, like Photoshop's 1000 % scatter). + +use photocraft_color::BlendMode; +use serde::{Deserialize, Serialize}; + +use crate::tile::GrayTile; + +/// What drives a dynamic parameter (Photoshop's "Control" pop-ups). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Control { + #[default] + Off, + /// Ramp over `fadeSteps` spacing steps (from full to the minimum). + Fade, + PenPressure, + PenTilt, + StylusWheel, + /// Barrel rotation of the stylus. + Rotation, + /// Angle only: the direction of the first stroke segment. + InitialDirection, + /// Angle only: the current stroke direction. + Direction, +} + +/// One dynamic parameter: random jitter plus a controller with a floor. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Dynamic { + /// 0..1 random variation per dab (for scatter: the amount, may exceed 1). + pub jitter: f32, + pub control: Control, + /// Steps for [`Control::Fade`]. + pub fade_steps: u32, + /// 0..1 floor the control/jitter may reduce the value to (minimum diameter, roundness, depth…). + pub minimum: f32, +} + +impl Default for Dynamic { + fn default() -> Self { + Self { jitter: 0.0, control: Control::Off, fade_steps: 25, minimum: 0.0 } + } +} + +impl Dynamic { + pub const fn controlled(control: Control) -> Self { + Self { jitter: 0.0, control, fade_steps: 25, minimum: 0.0 } + } + pub const fn jitter(jitter: f32) -> Self { + Self { jitter, control: Control::Off, fade_steps: 25, minimum: 0.0 } + } + pub fn is_active(&self) -> bool { + self.jitter > 0.0 || self.control != Control::Off + } +} + +/// The brush tip bitmap source. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TipShape { + /// Computed round/elliptical tip (hardness, angle, roundness). + #[default] + Round, + /// Sampled tip: grayscale bitmap where 1 = full paint. Scaled so its larger side equals `size`. + Sampled(GrayTile), +} + +/// Shape Dynamics. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ShapeDynamics { + pub enabled: bool, + /// Size jitter / control; `minimum` is Photoshop's Minimum Diameter. + pub size: Dynamic, + /// Angle jitter (fraction of ±180°) / control. + pub angle: Dynamic, + /// Roundness jitter / control; `minimum` is Minimum Roundness. + pub roundness: Dynamic, + pub flip_x_jitter: bool, + pub flip_y_jitter: bool, +} + +/// Scattering. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Scattering { + pub enabled: bool, + /// `jitter` = scatter amount (1.0 = 100 %: up to one radius of displacement); control scales it. + pub scatter: Dynamic, + /// Scatter radially instead of only perpendicular to the stroke. + pub both_axes: bool, + /// Dabs per spacing step (1..16). + pub count: u32, + /// Count jitter / control (reduces the count towards 1). + pub count_jitter: Dynamic, +} + +impl Default for Scattering { + fn default() -> Self { + Self { enabled: false, scatter: Dynamic::default(), both_axes: false, count: 1, count_jitter: Dynamic::default() } + } +} + +/// How a grayscale mask (texture or dual tip) combines with the brush coverage. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MaskMode { + #[default] + Multiply, + Subtract, + Darken, + Overlay, + ColorDodge, + ColorBurn, + LinearBurn, + HardMix, + LinearHeight, + Height, +} + +/// Procedural pattern styles (generated deterministically; no bundled pattern files). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PatternStyle { + /// Smooth value noise. + #[default] + Noise, + /// Woven canvas. + Canvas, + /// Fibrous paper tooth. + Paper, + /// Halftone-like dots. + Dots, +} + +/// A texture pattern: a procedural generator or an explicit grayscale tile (tiled infinitely). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Pattern { + Procedural { style: PatternStyle, size: u32, seed: u32 }, + Tile(GrayTile), +} + +impl Default for Pattern { + fn default() -> Self { + Pattern::Procedural { style: PatternStyle::Paper, size: 128, seed: 1 } + } +} + +/// Texture. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Texture { + pub enabled: bool, + pub pattern: Pattern, + pub invert: bool, + /// Pattern scale (1 = 100 %). + pub scale: f32, + /// -1..1 (Photoshop's -150..150 mapped to ±1). + pub brightness: f32, + /// -1..1. + pub contrast: f32, + /// Apply the texture to every dab (enables depth jitter) instead of once to the stroke. + pub each_tip: bool, + pub mode: MaskMode, + /// 0..1. + pub depth: f32, + /// Depth jitter / control (per tip only); `minimum` is Minimum Depth. + pub depth_jitter: Dynamic, +} + +impl Default for Texture { + fn default() -> Self { + Self { + enabled: false, + pattern: Pattern::default(), + invert: false, + scale: 1.0, + brightness: 0.0, + contrast: 0.0, + each_tip: false, + mode: MaskMode::Multiply, + depth: 1.0, + depth_jitter: Dynamic::default(), + } + } +} + +/// Dual Brush: a second tip stroked along the same path; the stroke is masked by it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DualBrush { + pub enabled: bool, + pub mode: MaskMode, + pub tip: TipShape, + pub size: f32, + pub hardness: f32, + pub roundness: f32, + pub angle: f32, + pub spacing: f32, + pub scatter: f32, + pub both_axes: bool, + pub count: u32, + pub flip: bool, +} + +impl Default for DualBrush { + fn default() -> Self { + Self { + enabled: false, + mode: MaskMode::Multiply, + tip: TipShape::Round, + size: 20.0, + hardness: 1.0, + roundness: 1.0, + angle: 0.0, + spacing: 0.25, + scatter: 0.0, + both_axes: false, + count: 1, + flip: false, + } + } +} + +/// Color Dynamics. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ColorDynamics { + pub enabled: bool, + /// Vary every dab (true) or once per stroke (false). + pub per_tip: bool, + /// Foreground/background mix jitter / control. + pub fg_bg: Dynamic, + /// 0..1 fraction of ±half the hue wheel. + pub hue_jitter: f32, + pub saturation_jitter: f32, + pub brightness_jitter: f32, + /// -1 (fully desaturated) .. 1 (fully saturated). + pub purity: f32, +} + +impl Default for ColorDynamics { + fn default() -> Self { + Self { enabled: false, per_tip: true, fg_bg: Dynamic::default(), hue_jitter: 0.0, saturation_jitter: 0.0, brightness_jitter: 0.0, purity: 0.0 } + } +} + +/// Transfer: per-dab opacity ceiling and flow. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Transfer { + pub enabled: bool, + pub opacity: Dynamic, + pub flow: Dynamic, +} + +/// Brush Pose: stylus values used instead of (override) or for missing device data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Pose { + pub enabled: bool, + /// Degrees, -90..90 (W3C Pointer Events convention). + pub tilt_x: f32, + pub tilt_y: f32, + /// Degrees 0..360. + pub rotation: f32, + /// 0..1. + pub pressure: f32, + pub override_tilt: bool, + pub override_rotation: bool, + pub override_pressure: bool, +} + +impl Default for Pose { + fn default() -> Self { + Self { enabled: false, tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, pressure: 1.0, override_tilt: false, override_rotation: false, override_pressure: false } + } +} + +/// Stroke smoothing (Photoshop's Smoothing % and its options). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Smoothing { + /// 0..1. + pub amount: f32, + /// Lazy-mouse: paint only when the pointer pulls a string of `amount × 100 px` taut. + pub pulled_string: bool, + /// Keep catching up with the pointer while it pauses (uses point timestamps). + pub catch_up: bool, + /// Finish the stroke at the last pointer position. + pub catch_up_on_end: bool, + /// Divide the string length by the view zoom (the command's `zoom` param). + pub adjust_for_zoom: bool, +} + +impl Default for Smoothing { + fn default() -> Self { + Self { amount: 0.0, pulled_string: false, catch_up: true, catch_up_on_end: true, adjust_for_zoom: true } + } +} + +/// A complete brush: tip shape, dynamics, and stroke-level options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BrushSettings { + /// Diameter in pixels. + pub size: f32, + /// 0 (soft) ..1 (hard). Computed tips only. + pub hardness: f32, + /// Spacing between dabs as a fraction of the diameter. + pub spacing: f32, + /// Maximum coverage for the whole stroke. + pub opacity: f32, + /// Per-dab coverage. + pub flow: f32, + /// Shortcut for Shape Dynamics › Size › Pen Pressure (the options-bar button). + #[serde(alias = "pressure_size")] + pub pressure_size: bool, + /// Shortcut for Transfer › Opacity › Pen Pressure. + #[serde(alias = "pressure_opacity")] + pub pressure_opacity: bool, + /// Straight RGBA colour (display RGB). Commands fill it from the foreground colour. + pub color: [f32; 4], + /// Background colour for Color Dynamics' foreground/background jitter. + pub background: [f32; 4], + /// Erase instead of paint. + pub erase: bool, + /// Brush blend mode (Normal, Multiply, Dissolve…). + pub mode: BlendMode, + // --- Brush Tip Shape --- + pub tip: TipShape, + /// Degrees, counter-clockwise. + pub angle: f32, + /// 0..1 (1 = circle). + pub roundness: f32, + pub flip_x: bool, + pub flip_y: bool, + /// Pencil: no anti-aliasing (coverage is 0 or 1). + pub aliased: bool, + // --- dynamics sections --- + pub shape_dynamics: ShapeDynamics, + pub scattering: Scattering, + pub texture: Texture, + pub dual_brush: DualBrush, + pub color_dynamics: ColorDynamics, + pub transfer: Transfer, + pub pose: Pose, + /// Add grain to the soft parts of the tip. + pub noise: bool, + /// Watercolour look: lighter interior, darker rims; overlapping dabs don't build up. + pub wet_edges: bool, + /// Airbrush: keep depositing dabs over time while the pointer lingers. + pub build_up: bool, + /// Build-up rate in dabs per second. + pub build_up_rate: f32, + pub smoothing: Smoothing, + /// Keep the current texture (pattern + scale) when switching to another textured preset. + pub protect_texture: bool, + /// Random seed for jitters (commands derive it from the stroke when not given), so strokes replay. + pub seed: u64, +} + +impl Default for BrushSettings { + fn default() -> Self { + Self { + size: 20.0, + hardness: 1.0, + spacing: 0.1, + opacity: 1.0, + flow: 1.0, + pressure_size: true, + pressure_opacity: false, + color: [0.0, 0.0, 0.0, 1.0], + background: [1.0, 1.0, 1.0, 1.0], + erase: false, + mode: BlendMode::Normal, + tip: TipShape::Round, + angle: 0.0, + roundness: 1.0, + flip_x: false, + flip_y: false, + aliased: false, + shape_dynamics: ShapeDynamics::default(), + scattering: Scattering::default(), + texture: Texture::default(), + dual_brush: DualBrush::default(), + color_dynamics: ColorDynamics::default(), + transfer: Transfer::default(), + pose: Pose::default(), + noise: false, + wet_edges: false, + build_up: false, + build_up_rate: 20.0, + smoothing: Smoothing::default(), + protect_texture: false, + seed: 0, + } + } +} + +impl BrushSettings { + /// Apply Protect Texture: if `current` protects its texture, a newly chosen brush with a texture + /// keeps `current`'s pattern and scale. + pub fn with_protected_texture(mut self, current: &BrushSettings) -> Self { + if current.protect_texture && current.texture.enabled && self.texture.enabled { + self.texture.pattern = current.texture.pattern.clone(); + self.texture.scale = current.texture.scale; + self.protect_texture = true; + } + self + } +} + +/// A named brush preset (session tool state; serde round-trips). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct BrushPreset { + pub name: String, + pub brush: BrushSettings, + /// Shipped with Photocraft (built-ins can be deleted from a session but are regenerated on reset). + #[serde(default)] + pub builtin: bool, +} diff --git a/crates/paint/src/procedural.rs b/crates/paint/src/procedural.rs new file mode 100644 index 00000000..a76e93aa --- /dev/null +++ b/crates/paint/src/procedural.rs @@ -0,0 +1,156 @@ +//! Procedural (generated, deterministic) texture patterns and sampled tips for the built-in presets. +//! Everything here is original: seeded value noise and simple geometry, no bundled brush files. + +use crate::brush::PatternStyle; +use crate::rng::hash2; +use crate::tile::{GrayTile, PatternImage}; + +#[inline] +fn smooth(t: f32) -> f32 { + t * t * (3.0 - 2.0 * t) +} + +/// Tileable value noise with `period` lattice cells over the tile. +fn value_noise(x: f32, y: f32, period: i32, salt: u64) -> f32 { + let (x0, y0) = (x.floor(), y.floor()); + let (tx, ty) = (smooth(x - x0), smooth(y - y0)); + let (xi, yi) = (x0 as i32, y0 as i32); + let h = |i: i32, j: i32| hash2(i.rem_euclid(period), j.rem_euclid(period), salt); + let a = h(xi, yi) + (h(xi + 1, yi) - h(xi, yi)) * tx; + let b = h(xi, yi + 1) + (h(xi + 1, yi + 1) - h(xi, yi + 1)) * tx; + a + (b - a) * ty +} + +/// Tileable fractal noise in 0..1 (`cells` lattice cells across at the base octave). +fn fbm(x: f32, y: f32, size: f32, cells: i32, octaves: u32, salt: u64) -> f32 { + let (mut sum, mut amp, mut norm, mut c) = (0.0, 1.0, 0.0, cells); + for o in 0..octaves { + sum += value_noise(x / size * c as f32, y / size * c as f32, c, salt.wrapping_add(o as u64 * 7919)) * amp; + norm += amp; + amp *= 0.5; + c *= 2; + } + sum / norm +} + +/// Generate a seamless pattern tile. +pub fn pattern(style: PatternStyle, size: u32, seed: u32) -> PatternImage { + let n = size.clamp(8, 1024) as usize; + let s = n as f32; + let salt = u64::from(seed) * 0x1000_0000_1B3 + 17; + let mut data = vec![0.0f32; n * n]; + for y in 0..n { + for x in 0..n { + let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); + let v = match style { + PatternStyle::Noise => fbm(fx, fy, s, 8, 4, salt), + PatternStyle::Paper => { + let base = fbm(fx, fy, s, 16, 4, salt); + let grain = hash2(x as i32, y as i32, salt ^ 0x55); + ((base - 0.5) * 2.2 + 0.5 + (grain - 0.5) * 0.35).clamp(0.0, 1.0) + } + PatternStyle::Canvas => { + // Over/under weave: threads every 8 px, alternating which direction is on top. + let period = 8.0; + let (cx, cy) = ((fx / period).floor() as i32, (fy / period).floor() as i32); + let (u, v) = ((fx / period).fract(), (fy / period).fract()); + let warp = (std::f32::consts::PI * u).sin(); + let weft = (std::f32::consts::PI * v).sin(); + let top = if (cx + cy) % 2 == 0 { warp } else { weft }; + (0.25 + 0.65 * top + (hash2(x as i32, y as i32, salt) - 0.5) * 0.15).clamp(0.0, 1.0) + } + PatternStyle::Dots => { + let period = 8.0; + let (u, v) = ((fx / period).fract() - 0.5, (fy / period).fract() - 0.5); + let d = (u * u + v * v).sqrt(); + 1.0 - smooth(((0.35 - d) / 0.1 + 0.5).clamp(0.0, 1.0)) + } + }; + data[y * n + x] = v; + } + } + PatternImage { width: n, height: n, data } +} + +/// A chalk/charcoal tip: a disc with a ragged edge and grainy interior. +pub fn chalk_tip(n: u32, seed: u32) -> GrayTile { + let salt = u64::from(seed) + 101; + let c = n as f32 / 2.0; + GrayTile::from_fn(n, n, |x, y| { + let (dx, dy) = (x as f32 + 0.5 - c, y as f32 + 0.5 - c); + let r = (dx * dx + dy * dy).sqrt() / c; + let ang = dy.atan2(dx); + let edge = 0.8 + 0.15 * value_noise((ang + std::f32::consts::PI) * 3.0, 0.5, 19, salt); + let inside = ((edge - r) * 8.0).clamp(0.0, 1.0); + let grain = hash2(x as i32, y as i32, salt); + inside * if grain < 0.2 { 0.2 } else { 0.7 + 0.3 * grain } + }) +} + +/// A spatter tip: a cluster of round droplets of different sizes. +pub fn spatter_tip(n: u32, seed: u32, drops: u32) -> GrayTile { + let salt = u64::from(seed) + 202; + let s = n as f32; + let blobs: Vec<(f32, f32, f32)> = (0..drops) + .map(|i| { + let a = hash2(i as i32, 0, salt) * std::f32::consts::TAU; + let d = hash2(i as i32, 1, salt).sqrt() * 0.38 * s; + let r = (0.03 + 0.09 * hash2(i as i32, 2, salt).powi(2)) * s; + (s / 2.0 + a.cos() * d, s / 2.0 + a.sin() * d, r) + }) + .collect(); + GrayTile::from_fn(n, n, |x, y| { + let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); + blobs.iter().fold(0.0f32, |acc, &(bx, by, r)| { + let d = ((px - bx).powi(2) + (py - by).powi(2)).sqrt(); + acc.max((r + 0.5 - d).clamp(0.0, 1.0)) + }) + }) +} + +/// A bristle tip: parallel hair streaks of varying density inside an ellipse. +pub fn bristle_tip(n: u32, seed: u32) -> GrayTile { + let salt = u64::from(seed) + 303; + let c = n as f32 / 2.0; + GrayTile::from_fn(n, n, |x, y| { + let (dx, dy) = ((x as f32 + 0.5 - c) / c, (y as f32 + 0.5 - c) / c); + let r = (dx * dx + (dy * 1.8).powi(2)).sqrt(); + if r > 1.0 { + return 0.0; + } + let hair = hash2(0, (y as f32 / 1.5) as i32, salt); + let density = if hair < 0.25 { 0.0 } else { 0.5 + 0.5 * hair }; + density * (1.0 - r.powi(4)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn patterns_are_seamless_and_in_range() { + for st in [PatternStyle::Noise, PatternStyle::Paper, PatternStyle::Canvas, PatternStyle::Dots] { + let p = pattern(st, 64, 3); + assert!(p.data.iter().all(|v| (0.0..=1.0).contains(v)), "{st:?}"); + let var = p.data.iter().map(|v| (v - 0.5).abs()).sum::() / p.data.len() as f32; + assert!(var > 0.02, "{st:?} is flat"); + // Deterministic. + assert_eq!(p.data, pattern(st, 64, 3).data); + } + // Noise tiles: left and right columns are neighbours. + let p = pattern(PatternStyle::Noise, 64, 1); + let seam: f32 = (0..64).map(|y| (p.data[y * 64] - p.data[y * 64 + 63]).abs()).sum::() / 64.0; + assert!(seam < 0.08, "{seam}"); + } + + #[test] + fn tips_have_paint() { + for t in [chalk_tip(48, 1), spatter_tip(48, 1, 12), bristle_tip(48, 1)] { + assert!(t.is_valid()); + let sum: f32 = t.to_f32().iter().sum(); + assert!(sum > 48.0, "{sum}"); + assert_eq!(t.get(0, 0), 0.0); + } + } +} diff --git a/crates/paint/src/rng.rs b/crates/paint/src/rng.rs new file mode 100644 index 00000000..9d053653 --- /dev/null +++ b/crates/paint/src/rng.rs @@ -0,0 +1,82 @@ +//! Counter-based deterministic randomness. +//! +//! Every random value is a pure hash of `(seed, index, stream)`, so a dab's jitter depends only on +//! the stroke seed and the dab's position in the stroke — never on how the points were chunked, +//! on thread scheduling or on earlier draws. That is what makes journaled strokes replay exactly. + +/// SplitMix64 finaliser. +#[inline] +pub fn mix64(mut z: u64) -> u64 { + z = z.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// Uniform in [0, 1). +#[inline] +pub fn rand01(seed: u64, index: u64, stream: u64) -> f32 { + let h = mix64(seed ^ mix64(index.wrapping_mul(0xD1B5_4A32_D192_ED03) ^ stream.wrapping_mul(0x8CB9_2BA7_2F3D_8DD7))); + (h >> 40) as f32 / (1u64 << 24) as f32 +} + +/// Uniform in [-1, 1). +#[inline] +pub fn rand_signed(seed: u64, index: u64, stream: u64) -> f32 { + rand01(seed, index, stream) * 2.0 - 1.0 +} + +/// Hash of integer document coordinates to [0, 1) (noise, dissolve). +#[inline] +pub fn hash2(x: i32, y: i32, salt: u64) -> f32 { + let k = ((x as u32 as u64) << 32) | (y as u32 as u64); + (mix64(k ^ salt.wrapping_mul(0xA24B_AED4_963E_E407)) >> 40) as f32 / (1u64 << 24) as f32 +} + +/// Streams: one per random quantity so they are independent. +pub mod stream { + pub const SIZE: u64 = 1; + pub const ANGLE: u64 = 2; + pub const ROUNDNESS: u64 = 3; + pub const FLIP_X: u64 = 4; + pub const FLIP_Y: u64 = 5; + pub const SCATTER_X: u64 = 6; + pub const SCATTER_Y: u64 = 7; + pub const COUNT: u64 = 8; + pub const OPACITY: u64 = 9; + pub const FLOW: u64 = 10; + pub const FG_BG: u64 = 11; + pub const HUE: u64 = 12; + pub const SAT: u64 = 13; + pub const BRIGHT: u64 = 14; + pub const DEPTH: u64 = 15; + pub const DUAL_X: u64 = 16; + pub const DUAL_Y: u64 = 17; + pub const DUAL_FLIP: u64 = 18; +} + +/// Seed derived from arbitrary bytes (FNV-1a then mixed), for commands without an explicit seed. +pub fn seed_from_bytes(b: &[u8]) -> u64 { + let mut h: u64 = 0xCBF2_9CE4_8422_2325; + for &x in b { + h ^= u64::from(x); + h = h.wrapping_mul(0x0000_0100_0000_01B3); + } + mix64(h) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_and_uniformish() { + assert_eq!(rand01(7, 3, 1), rand01(7, 3, 1)); + assert_ne!(rand01(7, 3, 1), rand01(8, 3, 1)); + assert_ne!(rand01(7, 3, 1), rand01(7, 3, 2)); + let n = 10000; + let mean: f32 = (0..n).map(|i| rand01(1, i, 0)).sum::() / n as f32; + assert!((mean - 0.5).abs() < 0.02, "{mean}"); + assert!((0..n).all(|i| (0.0..1.0).contains(&rand01(2, i, 5)))); + } +} diff --git a/crates/paint/src/tile.rs b/crates/paint/src/tile.rs new file mode 100644 index 00000000..b7b75022 --- /dev/null +++ b/crates/paint/src/tile.rs @@ -0,0 +1,261 @@ +//! Grayscale bitmaps for sampled tips and texture patterns, with a compact serde form. +//! +//! Values are 16-bit (0..65535 ↔ 0..1) so presets stay small; JSON carries them as base64 of the +//! little-endian samples (`"data": "…"`), and also accepts a plain array of 0..1 floats. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A grayscale bitmap, row-major, 1 = full. +#[derive(Clone, Debug, PartialEq)] +pub struct GrayTile { + pub width: u32, + pub height: u32, + pub data: Vec, +} + +impl GrayTile { + /// Build from a function returning 0..1 values. + pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> f32) -> Self { + let mut data = Vec::with_capacity(width as usize * height as usize); + for y in 0..height { + for x in 0..width { + data.push(q16(f(x, y))); + } + } + Self { width, height, data } + } + /// Build from 0..1 floats (`width × height`). + pub fn from_f32(width: u32, height: u32, v: &[f32]) -> Self { + assert_eq!(v.len(), width as usize * height as usize, "GrayTile size mismatch"); + Self { width, height, data: v.iter().map(|&x| q16(x)).collect() } + } + #[inline] + pub fn get(&self, x: u32, y: u32) -> f32 { + f32::from(self.data[(y * self.width + x) as usize]) / 65535.0 + } + pub fn to_f32(&self) -> Vec { + self.data.iter().map(|&v| f32::from(v) / 65535.0).collect() + } + pub fn is_valid(&self) -> bool { + self.width > 0 && self.height > 0 && self.data.len() == self.width as usize * self.height as usize + } +} + +#[inline] +fn q16(v: f32) -> u16 { + (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 +} + +// ---------- serde ---------- + +#[derive(Serialize)] +struct GrayTileOut<'a> { + width: u32, + height: u32, + data: &'a str, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum DataIn { + B64(String), + Floats(Vec), +} + +#[derive(Deserialize)] +struct GrayTileIn { + width: u32, + height: u32, + data: DataIn, +} + +impl Serialize for GrayTile { + fn serialize(&self, s: S) -> Result { + let mut bytes = Vec::with_capacity(self.data.len() * 2); + for v in &self.data { + bytes.extend_from_slice(&v.to_le_bytes()); + } + let enc = b64_encode(&bytes); + GrayTileOut { width: self.width, height: self.height, data: &enc }.serialize(s) + } +} + +impl<'de> Deserialize<'de> for GrayTile { + fn deserialize>(d: D) -> Result { + use serde::de::Error; + let t = GrayTileIn::deserialize(d)?; + let n = t.width as usize * t.height as usize; + let data: Vec = match t.data { + DataIn::B64(s) => { + let b = b64_decode(&s).ok_or_else(|| D::Error::custom("invalid base64 in tile data"))?; + b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() + } + DataIn::Floats(v) => v.iter().map(|&x| q16(x)).collect(), + }; + if n == 0 || data.len() != n { + return Err(D::Error::custom(format!("tile data has {} samples, expected {}×{}", data.len(), t.width, t.height))); + } + Ok(GrayTile { width: t.width, height: t.height, data }) + } +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn b64_encode(b: &[u8]) -> String { + let mut out = String::with_capacity(b.len().div_ceil(3) * 4); + for c in b.chunks(3) { + let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0)); + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); + } + out +} + +fn b64_decode(s: &str) -> Option> { + let val = |c: u8| -> Option { + Some(match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + } as u32) + }; + let s: Vec = s.bytes().filter(|c| !c.is_ascii_whitespace()).collect(); + if s.len() % 4 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 4 * 3); + for c in s.chunks(4) { + let pad = c.iter().rev().take_while(|&&x| x == b'=').count(); + let mut n = 0u32; + for (i, &x) in c.iter().enumerate() { + n = (n << 6) | if i >= 4 - pad { 0 } else { val(x)? }; + } + out.push((n >> 16) as u8); + if pad < 2 { + out.push((n >> 8) as u8); + } + if pad < 1 { + out.push(n as u8); + } + } + Some(out) +} + +// ---------- sampling ---------- + +/// A float mip pyramid of a [`GrayTile`] for sampling at any scale without aliasing. +#[derive(Clone, Debug)] +pub struct Mips { + /// (width, height, values), level 0 = full resolution. + pub levels: Vec<(usize, usize, Vec)>, +} + +impl Mips { + pub fn new(t: &GrayTile) -> Self { + let mut levels = vec![(t.width as usize, t.height as usize, t.to_f32())]; + loop { + let (w, h, v) = levels.last().expect("level"); + if *w <= 1 && *h <= 1 { + break; + } + let (nw, nh) = ((*w).div_ceil(2), (*h).div_ceil(2)); + let mut nv = vec![0.0f32; nw * nh]; + for y in 0..nh { + for x in 0..nw { + let mut s = 0.0; + let mut c = 0.0; + for dy in 0..2 { + for dx in 0..2 { + let (sx, sy) = (x * 2 + dx, y * 2 + dy); + if sx < *w && sy < *h { + s += v[sy * w + sx]; + c += 1.0; + } + } + } + nv[y * nw + x] = s / c; + } + } + levels.push((nw, nh, nv)); + } + Self { levels } + } + + /// Mip level for drawing the tile at `px` pixels across its larger side. + pub fn level_for(&self, px: f32) -> usize { + let (w, h, _) = &self.levels[0]; + let ratio = (*w.max(h) as f32 / px.max(1.0)).max(1.0); + (ratio.log2().floor() as usize).min(self.levels.len() - 1) + } + + /// Bilinear sample at normalised `u, v` in 0..1 (outside = 0). + #[inline] + pub fn sample_clamped(&self, level: usize, u: f32, v: f32) -> f32 { + let (w, h, d) = &self.levels[level]; + let fx = u * *w as f32 - 0.5; + let fy = v * *h as f32 - 0.5; + let (x0, y0) = (fx.floor(), fy.floor()); + let (tx, ty) = (fx - x0, fy - y0); + let (x0, y0) = (x0 as i64, y0 as i64); + let get = |x: i64, y: i64| if x < 0 || y < 0 || x >= *w as i64 || y >= *h as i64 { 0.0 } else { d[y as usize * w + x as usize] }; + let a = get(x0, y0) + (get(x0 + 1, y0) - get(x0, y0)) * tx; + let b = get(x0, y0 + 1) + (get(x0 + 1, y0 + 1) - get(x0, y0 + 1)) * tx; + a + (b - a) * ty + } +} + +/// A tiling float pattern (texture), sampled with wrap-around. +#[derive(Clone, Debug)] +pub struct PatternImage { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +impl PatternImage { + #[inline] + pub fn sample_wrap(&self, x: f32, y: f32) -> f32 { + let xi = (x.floor() as i64).rem_euclid(self.width as i64) as usize; + let yi = (y.floor() as i64).rem_euclid(self.height as i64) as usize; + self.data[yi * self.width + xi] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_round_trip() { + for n in 0..10 { + let b: Vec = (0..n).map(|i| (i * 37 + 5) as u8).collect(); + assert_eq!(b64_decode(&b64_encode(&b)).unwrap(), b); + } + } + + #[test] + fn tile_serde_round_trip_and_float_form() { + let t = GrayTile::from_fn(5, 3, |x, y| (x + y) as f32 / 6.0); + let j = serde_json::to_string(&t).unwrap(); + let back: GrayTile = serde_json::from_str(&j).unwrap(); + assert_eq!(back, t); + let f: GrayTile = serde_json::from_str(r#"{"width":2,"height":1,"data":[0.0,1.0]}"#).unwrap(); + assert_eq!(f.data, vec![0, 65535]); + assert!(serde_json::from_str::(r#"{"width":2,"height":2,"data":[0.0,1.0]}"#).is_err()); + } + + #[test] + fn mips_average() { + let t = GrayTile::from_fn(4, 4, |x, _| if x < 2 { 1.0 } else { 0.0 }); + let m = Mips::new(&t); + assert_eq!(m.levels.len(), 3); + assert!((m.levels[2].2[0] - 0.5).abs() < 1e-3); + assert_eq!(m.level_for(4.0), 0); + assert_eq!(m.level_for(1.0), 2); + } +}