more work
This commit is contained in:
parent
a517477da8
commit
b00b24739a
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
//! Photocraft colour management (placeholder while the crate is being written).
|
||||
#![forbid(unsafe_code)]
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue