more work

This commit is contained in:
Brandon Thomas 2026-09-30 11:36:20 -04:00
parent a517477da8
commit b00b24739a
13 changed files with 1767 additions and 37 deletions

8
Cargo.lock generated
View File

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

18
crates/cms/Cargo.toml Normal file
View File

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

336
crates/cms/src/curve.rs Normal file
View File

@ -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<f32>),
}
/// 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<f32> {
(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<f64> {
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);
}
}

2
crates/cms/src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
//! Photocraft colour management (placeholder while the crate is being written).
#![forbid(unsafe_code)]

207
crates/cms/src/math.rs Normal file
View File

@ -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<Mat3> {
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);
}
}
}
}

View File

@ -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<Vec<Map>>,
outer_bevel: Vec<bool>,
din: Option<Vec<f32>>,
dout: Option<Vec<f32>>,
/// Shape layers: distance outside the vector outline (from local coverage).
vdout: Option<Vec<f32>>,
}
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<f32> {
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<f32>, 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<Vec<Map>> = 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);

View File

@ -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<effects::FxMaps>,
_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<std::sync::OnceLock<FxEntry>>;
struct FxCache {
map: std::collections::HashMap<u64, FxSlot>,
order: std::collections::VecDeque<u64>,
bytes: usize,
}
const FX_CACHE_BUDGET: usize = 768 << 20;
fn fx_cache() -> &'static std::sync::Mutex<FxCache> {
static C: std::sync::OnceLock<std::sync::Mutex<FxCache>> = 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<effects::FxMaps> {
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<f32> = 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]];

View File

@ -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);
}

View File

@ -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);
}
}
}

431
crates/paint/src/brush.rs Normal file
View File

@ -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,
}

View File

@ -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::<f32>() / 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::<f32>() / 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);
}
}
}

82
crates/paint/src/rng.rs Normal file
View File

@ -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::<f32>() / 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))));
}
}

261
crates/paint/src/tile.rs Normal file
View File

@ -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<u16>,
}
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<f32> {
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<f32>),
}
#[derive(Deserialize)]
struct GrayTileIn {
width: u32,
height: u32,
data: DataIn,
}
impl Serialize for GrayTile {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
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: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error;
let t = GrayTileIn::deserialize(d)?;
let n = t.width as usize * t.height as usize;
let data: Vec<u16> = 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<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
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<u8> = 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<f32>)>,
}
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<f32>,
}
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<u8> = (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::<GrayTile>(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);
}
}