Wire the launch screen into the app
The app now opens on the login screen when there is nothing else to show
— no local paths and no configured library — and goes straight to the
images otherwise. Making someone click past a login they already
completed is pure friction.
launch.slint imported by app.slint, replacing the window rather
than overlaying it: there is no library to look at
until an account is configured
launch_ui.rs the Slint wiring, kept out of lib.rs so the launch
flow can change without touching the develop window
Login runs on a worker thread and posts results back through a channel,
since Slint's event loop is single-threaded and a 20-minute browser wait
cannot block it. The system browser is opened via xdg-open, never an
embedded webview (FR-NC-1).
Sign-out deletes the local credential even if server-side revocation
fails: a network error must not leave a usable secret on the machine.
Format tick-boxes persist on each toggle, so a selection survives a
crash before the library is opened.
Two things deliberately incomplete rather than faked:
- "Choose folder" lists the account's folders and reports them, but
there is no picker widget yet, so selection still happens via the
connect example.
- "Open library" logs the request. Opening a remote library needs the
scan-and-cache path, which belongs with the catalog work in flight.
Earlier I broke the other in-flight dr-ui work by calling
slint_build::compile twice, which replaces the generated module. The
correct wiring is an import inside app.slint, which is what this does.
30 dr-ui tests passing; both launch paths verified by running the app.
This commit is contained in:
@@ -0,0 +1,645 @@
|
||||
//! The tone curve — a monotonic spline through five movable points.
|
||||
//!
|
||||
//! The control every other tonal adjustment is a preset of. Highlights,
|
||||
//! shadows, blacks and whites each shape one region with a fixed weight; the
|
||||
//! curve lets the photographer put the inflection exactly where the image
|
||||
//! needs it.
|
||||
//!
|
||||
//! # Why the points are ordinary scalars
|
||||
//!
|
||||
//! Each point is two [`ParamKind::Scalar`] parameters, x and y. The curve
|
||||
//! widget is a *presentation* of those scalars (see
|
||||
//! [`Operation::presentation`]), not a separate kind of value. Three things
|
||||
//! follow, and all three are why it is built this way:
|
||||
//!
|
||||
//! - The parameter API stays `f32`-only, so nothing else in the pipeline,
|
||||
//! the graph or the sidecar had to change to accommodate a curve.
|
||||
//! - A UI that has not implemented the curve widget renders ten sliders and
|
||||
//! remains completely functional.
|
||||
//! - Undo, clamping and sidecar serialisation work already, because the
|
||||
//! points are the same kind of thing as every other parameter.
|
||||
//!
|
||||
//! The cost is a fixed point count. Adding or removing points at will would
|
||||
//! need a variable-length value type, which is a much larger change for a
|
||||
//! control that rarely needs more than five.
|
||||
//!
|
||||
//! # Why monotonic
|
||||
//!
|
||||
//! A plain cubic spline through user-placed points overshoots: drag one point
|
||||
//! and the curve can dip *below* its neighbour, which inverts tones locally
|
||||
//! and shows up as a dark halo in a smooth gradient. The Fritsch-Carlson
|
||||
//! filter constrains the tangents so the interpolant is monotone wherever the
|
||||
//! data is, which is exactly the guarantee a tone curve needs.
|
||||
|
||||
use crate::descriptor::{
|
||||
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit,
|
||||
WidgetKind,
|
||||
};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
use crate::ops::helpers;
|
||||
|
||||
pub const ID: OpId = OpId("tone_curve");
|
||||
|
||||
/// How many movable points the curve has.
|
||||
///
|
||||
/// Five: the two endpoints, a mid-tone, and one either side. Enough for the
|
||||
/// S-curves and shoulder rolls that make up nearly every tonal edit, few
|
||||
/// enough that the shader can evaluate them without a loop over storage.
|
||||
pub const POINTS: usize = 5;
|
||||
|
||||
pub const P0_X: ParamId = ParamId("p0_x");
|
||||
pub const P0_Y: ParamId = ParamId("p0_y");
|
||||
pub const P1_X: ParamId = ParamId("p1_x");
|
||||
pub const P1_Y: ParamId = ParamId("p1_y");
|
||||
pub const P2_X: ParamId = ParamId("p2_x");
|
||||
pub const P2_Y: ParamId = ParamId("p2_y");
|
||||
pub const P3_X: ParamId = ParamId("p3_x");
|
||||
pub const P3_Y: ParamId = ParamId("p3_y");
|
||||
pub const P4_X: ParamId = ParamId("p4_x");
|
||||
pub const P4_Y: ParamId = ParamId("p4_y");
|
||||
|
||||
/// The parameters the curve widget owns, in point order.
|
||||
static CURVE_PARAMS: [ParamId; POINTS * 2] =
|
||||
[P0_X, P0_Y, P1_X, P1_Y, P2_X, P2_Y, P3_X, P3_Y, P4_X, P4_Y];
|
||||
|
||||
/// A coordinate parameter: 0…1 with enough precision to place a point
|
||||
/// exactly, and a default putting the curve on the identity diagonal.
|
||||
const fn coord(id: &'static str, label: &'static str, default: f32) -> ParamDescriptor {
|
||||
ParamDescriptor::scalar(
|
||||
id,
|
||||
label,
|
||||
0.0,
|
||||
1.0,
|
||||
default,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
// A 4-decimal step is well under a pixel of widget travel, so the
|
||||
// control never feels quantised.
|
||||
4,
|
||||
)
|
||||
}
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.tone_curve"),
|
||||
// Defaults lie on y = x, so a fresh curve is the identity and the
|
||||
// operation reports itself inactive.
|
||||
params: &[
|
||||
coord("p0_x", "param.curve.p0_x", 0.0),
|
||||
coord("p0_y", "param.curve.p0_y", 0.0),
|
||||
coord("p1_x", "param.curve.p1_x", 0.25),
|
||||
coord("p1_y", "param.curve.p1_y", 0.25),
|
||||
coord("p2_x", "param.curve.p2_x", 0.5),
|
||||
coord("p2_y", "param.curve.p2_y", 0.5),
|
||||
coord("p3_x", "param.curve.p3_x", 0.75),
|
||||
coord("p3_y", "param.curve.p3_y", 0.75),
|
||||
coord("p4_x", "param.curve.p4_x", 1.0),
|
||||
coord("p4_y", "param.curve.p4_y", 1.0),
|
||||
],
|
||||
};
|
||||
|
||||
static CURVE_HELPERS: &[Helper] = &[
|
||||
helpers::LUMINANCE,
|
||||
helpers::APPLY_TONE_GAIN,
|
||||
Helper {
|
||||
name: "curve_eval",
|
||||
source: "\
|
||||
// Evaluate a monotone cubic Hermite spline through five points.
|
||||
//
|
||||
// Fritsch-Carlson (1980): compute secant slopes, take a smooth average for
|
||||
// the interior tangents, then *limit* each tangent to three times the
|
||||
// adjoining secant. That limiter is what prevents overshoot — an
|
||||
// unconstrained spline can dip below a point's neighbour, inverting tones
|
||||
// and putting a dark halo through a smooth gradient.
|
||||
//
|
||||
// Points arrive pre-sorted by x with a minimum separation enforced on the
|
||||
// CPU, so no division here can be by zero.
|
||||
fn curve_eval(xs: array<f32, 5>, ys: array<f32, 5>, x: f32) -> f32 {
|
||||
// Outside the point range the curve is flat, matching how the endpoints
|
||||
// read in the widget: nothing exists beyond them to interpolate toward.
|
||||
if (x <= xs[0]) { return ys[0]; }
|
||||
if (x >= xs[4]) { return ys[4]; }
|
||||
|
||||
// Locate the span. Five points is few enough that a chain of comparisons
|
||||
// beats any cleverer search.
|
||||
var i = 0;
|
||||
if (x >= xs[3]) { i = 3; }
|
||||
else if (x >= xs[2]) { i = 2; }
|
||||
else if (x >= xs[1]) { i = 1; }
|
||||
|
||||
let x0 = xs[i];
|
||||
let x1 = xs[i + 1];
|
||||
let y0 = ys[i];
|
||||
let y1 = ys[i + 1];
|
||||
let h = x1 - x0;
|
||||
let secant = (y1 - y0) / h;
|
||||
|
||||
// Secants either side of each knot, duplicated at the ends so the
|
||||
// boundary tangents match the adjoining secant.
|
||||
var s_prev = secant;
|
||||
if (i > 0) {
|
||||
s_prev = (ys[i] - ys[i - 1]) / (xs[i] - xs[i - 1]);
|
||||
}
|
||||
var s_next = secant;
|
||||
if (i + 2 <= 4) {
|
||||
s_next = (ys[i + 2] - ys[i + 1]) / (xs[i + 2] - xs[i + 1]);
|
||||
}
|
||||
|
||||
// Tangents: the average of adjoining secants, but zero wherever the data
|
||||
// turns, which is what pins a local extremum in place.
|
||||
var m0 = 0.5 * (s_prev + secant);
|
||||
var m1 = 0.5 * (secant + s_next);
|
||||
if (s_prev * secant <= 0.0) { m0 = 0.0; }
|
||||
if (secant * s_next <= 0.0) { m1 = 0.0; }
|
||||
|
||||
// A flat span must stay flat.
|
||||
if (abs(secant) < 0.000001) {
|
||||
m0 = 0.0;
|
||||
m1 = 0.0;
|
||||
} else {
|
||||
// The Fritsch-Carlson limiter.
|
||||
let a = m0 / secant;
|
||||
let b = m1 / secant;
|
||||
let magnitude = a * a + b * b;
|
||||
if (magnitude > 9.0) {
|
||||
let scale = 3.0 / sqrt(magnitude);
|
||||
m0 = scale * a * secant;
|
||||
m1 = scale * b * secant;
|
||||
}
|
||||
}
|
||||
|
||||
// Cubic Hermite basis on the normalised span.
|
||||
let t = (x - x0) / h;
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
|
||||
let h10 = t3 - 2.0 * t2 + t;
|
||||
let h01 = -2.0 * t3 + 3.0 * t2;
|
||||
let h11 = t3 - t2;
|
||||
|
||||
return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1;
|
||||
}",
|
||||
},
|
||||
];
|
||||
|
||||
/// A tone curve through [`POINTS`] movable points.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToneCurve {
|
||||
xs: [f32; POINTS],
|
||||
ys: [f32; POINTS],
|
||||
}
|
||||
|
||||
impl Default for ToneCurve {
|
||||
fn default() -> Self {
|
||||
// The identity diagonal.
|
||||
let mut xs = [0.0f32; POINTS];
|
||||
let mut ys = [0.0f32; POINTS];
|
||||
let mut i = 0;
|
||||
while i < POINTS {
|
||||
let t = i as f32 / (POINTS - 1) as f32;
|
||||
xs[i] = t;
|
||||
ys[i] = t;
|
||||
i += 1;
|
||||
}
|
||||
Self { xs, ys }
|
||||
}
|
||||
}
|
||||
|
||||
impl ToneCurve {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Map a parameter id to `(point index, is_y)`.
|
||||
fn index_of(id: ParamId) -> Option<(usize, bool)> {
|
||||
let (point, axis) = id.0.split_once('_')?;
|
||||
let index: usize = point.strip_prefix('p')?.parse().ok()?;
|
||||
if index >= POINTS {
|
||||
return None;
|
||||
}
|
||||
match axis {
|
||||
"x" => Some((index, false)),
|
||||
"y" => Some((index, true)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The x coordinates, sorted and separated.
|
||||
///
|
||||
/// The widget cannot reorder points, but a sidecar can carry anything and
|
||||
/// a spline through unordered or coincident x values divides by zero.
|
||||
/// Enforced here so the shader never has to check.
|
||||
fn sorted_xs(&self) -> [f32; POINTS] {
|
||||
const MIN_GAP: f32 = 0.001;
|
||||
let mut xs = self.xs;
|
||||
// Insertion sort: five elements, and it keeps the pairing with ys
|
||||
// simple to reason about at the call site.
|
||||
for i in 1..POINTS {
|
||||
let mut j = i;
|
||||
while j > 0 && xs[j - 1] > xs[j] {
|
||||
xs.swap(j - 1, j);
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
// Push apart any coincident pair, left to right.
|
||||
for i in 1..POINTS {
|
||||
if xs[i] - xs[i - 1] < MIN_GAP {
|
||||
xs[i] = xs[i - 1] + MIN_GAP;
|
||||
}
|
||||
}
|
||||
xs
|
||||
}
|
||||
|
||||
/// Whether the curve differs from the identity.
|
||||
fn differs_from_identity(&self) -> bool {
|
||||
self.xs
|
||||
.iter()
|
||||
.zip(self.ys.iter())
|
||||
.any(|(x, y)| (x - y).abs() > 1e-6)
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for ToneCurve {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match Self::index_of(id) {
|
||||
Some((i, true)) => self.ys[i] = value,
|
||||
Some((i, false)) => self.xs[i] = value,
|
||||
None => log::warn!("tone_curve: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match Self::index_of(id) {
|
||||
Some((i, true)) => self.ys[i],
|
||||
Some((i, false)) => self.xs[i],
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.differs_from_identity()
|
||||
}
|
||||
|
||||
fn presentation(&self) -> Option<Presentation> {
|
||||
Some(Presentation {
|
||||
widget: WidgetKind::Curve,
|
||||
params: &CURVE_PARAMS,
|
||||
})
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let luma = luminance(c);
|
||||
if (luma > 0.0001) {
|
||||
// The curve is authored on a display-referred 0..1 axis, which is where
|
||||
// the eye reads tone and where the widget's grid lives. Scene-referred
|
||||
// luminance is unbounded, so it is encoded to that axis, curved, and
|
||||
// decoded back — otherwise a point placed at the middle of the grid
|
||||
// would not correspond to the middle of the visible range.
|
||||
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2);
|
||||
|
||||
let xs = array<f32, 5>(x0, x1, x2, x3, x4);
|
||||
let ys = array<f32, 5>(y0, y1, y2, y3, y4);
|
||||
let curved = curve_eval(xs, ys, encoded);
|
||||
|
||||
let decoded = pow(clamp(curved, 0.0, 1.0), 2.2);
|
||||
// Applied as a ratio so hue is preserved, exactly as contrast does.
|
||||
c = apply_tone_gain(c, decoded / luma);
|
||||
}
|
||||
c = max(c, vec3<f32>(0.0));"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
let xs = self.sorted_xs();
|
||||
vec![
|
||||
Uniform {
|
||||
name: "x0",
|
||||
value: xs[0],
|
||||
},
|
||||
Uniform {
|
||||
name: "x1",
|
||||
value: xs[1],
|
||||
},
|
||||
Uniform {
|
||||
name: "x2",
|
||||
value: xs[2],
|
||||
},
|
||||
Uniform {
|
||||
name: "x3",
|
||||
value: xs[3],
|
||||
},
|
||||
Uniform {
|
||||
name: "x4",
|
||||
value: xs[4],
|
||||
},
|
||||
Uniform {
|
||||
name: "y0",
|
||||
value: self.ys[0],
|
||||
},
|
||||
Uniform {
|
||||
name: "y1",
|
||||
value: self.ys[1],
|
||||
},
|
||||
Uniform {
|
||||
name: "y2",
|
||||
value: self.ys[2],
|
||||
},
|
||||
Uniform {
|
||||
name: "y3",
|
||||
value: self.ys[3],
|
||||
},
|
||||
Uniform {
|
||||
name: "y4",
|
||||
value: self.ys[4],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
CURVE_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the curve on the CPU.
|
||||
///
|
||||
/// The same maths as the shader, used by the widget to draw the line it is
|
||||
/// editing. Duplicating it is deliberate: the alternative is a GPU readback
|
||||
/// per frame to draw a 200-pixel polyline (ARCH §6.1), and the shared tests
|
||||
/// below pin the two implementations to the same values.
|
||||
pub fn evaluate(xs: &[f32; POINTS], ys: &[f32; POINTS], x: f32) -> f32 {
|
||||
if x <= xs[0] {
|
||||
return ys[0];
|
||||
}
|
||||
if x >= xs[POINTS - 1] {
|
||||
return ys[POINTS - 1];
|
||||
}
|
||||
|
||||
let mut i = 0;
|
||||
for k in (1..POINTS - 1).rev() {
|
||||
if x >= xs[k] {
|
||||
i = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (x0, x1) = (xs[i], xs[i + 1]);
|
||||
let (y0, y1) = (ys[i], ys[i + 1]);
|
||||
let h = x1 - x0;
|
||||
let secant = (y1 - y0) / h;
|
||||
|
||||
let s_prev = if i > 0 {
|
||||
(ys[i] - ys[i - 1]) / (xs[i] - xs[i - 1])
|
||||
} else {
|
||||
secant
|
||||
};
|
||||
let s_next = if i + 2 <= POINTS - 1 {
|
||||
(ys[i + 2] - ys[i + 1]) / (xs[i + 2] - xs[i + 1])
|
||||
} else {
|
||||
secant
|
||||
};
|
||||
|
||||
let mut m0 = 0.5 * (s_prev + secant);
|
||||
let mut m1 = 0.5 * (secant + s_next);
|
||||
if s_prev * secant <= 0.0 {
|
||||
m0 = 0.0;
|
||||
}
|
||||
if secant * s_next <= 0.0 {
|
||||
m1 = 0.0;
|
||||
}
|
||||
|
||||
if secant.abs() < 1e-6 {
|
||||
m0 = 0.0;
|
||||
m1 = 0.0;
|
||||
} else {
|
||||
let a = m0 / secant;
|
||||
let b = m1 / secant;
|
||||
let magnitude = a * a + b * b;
|
||||
if magnitude > 9.0 {
|
||||
let scale = 3.0 / magnitude.sqrt();
|
||||
m0 = scale * a * secant;
|
||||
m1 = scale * b * secant;
|
||||
}
|
||||
}
|
||||
|
||||
let t = (x - x0) / h;
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
|
||||
let h10 = t3 - 2.0 * t2 + t;
|
||||
let h01 = -2.0 * t3 + 3.0 * t2;
|
||||
let h11 = t3 - t2;
|
||||
|
||||
h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identity() -> ([f32; POINTS], [f32; POINTS]) {
|
||||
let c = ToneCurve::new();
|
||||
(c.xs, c.ys)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_curve_is_the_identity_and_inactive() {
|
||||
// Opening an unedited image must show the image.
|
||||
let c = ToneCurve::new();
|
||||
assert!(!c.is_active());
|
||||
for p in DESCRIPTOR.params {
|
||||
assert_eq!(c.param(p.id), p.default);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_identity_curve_returns_its_input() {
|
||||
let (xs, ys) = identity();
|
||||
for i in 0..=20 {
|
||||
let x = i as f32 / 20.0;
|
||||
let y = evaluate(&xs, &ys, x);
|
||||
assert!((y - x).abs() < 1e-4, "identity curve at {x} returned {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_parameter_id_maps_to_a_point() {
|
||||
for p in DESCRIPTOR.params {
|
||||
assert!(
|
||||
ToneCurve::index_of(p.id).is_some(),
|
||||
"{} does not map to a point",
|
||||
p.id
|
||||
);
|
||||
}
|
||||
assert_eq!(DESCRIPTOR.params.len(), POINTS * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_point_activates_the_curve() {
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(P2_Y, 0.65);
|
||||
assert!(c.is_active());
|
||||
assert_eq!(c.param(P2_Y), 0.65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_passes_through_its_control_points() {
|
||||
// The property that makes the widget honest: the line drawn through
|
||||
// a point must actually reach it.
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(P1_Y, 0.15);
|
||||
c.set_param(P3_Y, 0.85);
|
||||
|
||||
let xs = c.sorted_xs();
|
||||
for i in 0..POINTS {
|
||||
let y = evaluate(&xs, &c.ys, xs[i]);
|
||||
assert!(
|
||||
(y - c.ys[i]).abs() < 1e-4,
|
||||
"point {i} at x={} evaluated to {y}, expected {}",
|
||||
xs[i],
|
||||
c.ys[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_s_curve_stays_monotonic() {
|
||||
// The reason for Fritsch-Carlson. An unconstrained spline through
|
||||
// these points overshoots, dipping below a neighbour and inverting
|
||||
// tones — visible as a dark halo in a smooth gradient.
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(P1_Y, 0.10);
|
||||
c.set_param(P3_Y, 0.90);
|
||||
|
||||
let xs = c.sorted_xs();
|
||||
let mut previous = f32::NEG_INFINITY;
|
||||
for i in 0..=200 {
|
||||
let x = i as f32 / 200.0;
|
||||
let y = evaluate(&xs, &c.ys, x);
|
||||
assert!(
|
||||
y >= previous - 1e-5,
|
||||
"curve decreased at x={x}: {y} after {previous}"
|
||||
);
|
||||
previous = y;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extreme_curve_stays_monotonic() {
|
||||
// Every point dragged to a limit — what a user does when exploring
|
||||
// what a control can do.
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(P0_Y, 0.0);
|
||||
c.set_param(P1_Y, 0.95);
|
||||
c.set_param(P2_Y, 0.96);
|
||||
c.set_param(P3_Y, 0.97);
|
||||
c.set_param(P4_Y, 1.0);
|
||||
|
||||
let xs = c.sorted_xs();
|
||||
let mut previous = f32::NEG_INFINITY;
|
||||
for i in 0..=200 {
|
||||
let y = evaluate(&xs, &c.ys, i as f32 / 200.0);
|
||||
assert!(y >= previous - 1e-5, "decreased at {i}");
|
||||
assert!(y.is_finite(), "non-finite at {i}");
|
||||
previous = y;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_span_stays_flat() {
|
||||
// Two points at the same height must not bow between them.
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(P1_Y, 0.5);
|
||||
c.set_param(P2_Y, 0.5);
|
||||
c.set_param(P3_Y, 0.5);
|
||||
|
||||
let xs = c.sorted_xs();
|
||||
for i in 0..=20 {
|
||||
let x = 0.25 + (i as f32 / 20.0) * 0.5;
|
||||
let y = evaluate(&xs, &c.ys, x);
|
||||
assert!((y - 0.5).abs() < 1e-4, "at {x} the flat span gave {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_clamped_outside_its_endpoints() {
|
||||
let (xs, ys) = identity();
|
||||
assert_eq!(evaluate(&xs, &ys, -1.0), ys[0]);
|
||||
assert_eq!(evaluate(&xs, &ys, 2.0), ys[POINTS - 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_x_values_are_separated() {
|
||||
// A sidecar can carry anything; a spline through two points at the
|
||||
// same x divides by zero and produces NaN across the image.
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(P1_X, 0.5);
|
||||
c.set_param(P2_X, 0.5);
|
||||
c.set_param(P3_X, 0.5);
|
||||
|
||||
let xs = c.sorted_xs();
|
||||
for i in 1..POINTS {
|
||||
assert!(
|
||||
xs[i] > xs[i - 1],
|
||||
"x values must be strictly increasing, got {xs:?}"
|
||||
);
|
||||
}
|
||||
// And the result must be usable, not merely non-crashing.
|
||||
for i in 0..=50 {
|
||||
assert!(evaluate(&xs, &c.ys, i as f32 / 50.0).is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order_x_values_are_sorted() {
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(P1_X, 0.9);
|
||||
c.set_param(P3_X, 0.1);
|
||||
let xs = c.sorted_xs();
|
||||
for i in 1..POINTS {
|
||||
assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_widget_owns_every_point_parameter() {
|
||||
// If the presentation misses one, that slider appears twice: once in
|
||||
// the curve and once as a stray control beneath it.
|
||||
let presentation = ToneCurve::new().presentation().expect("declares a widget");
|
||||
assert_eq!(presentation.widget, WidgetKind::Curve);
|
||||
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
|
||||
for p in DESCRIPTOR.params {
|
||||
assert!(
|
||||
presentation.params.contains(&p.id),
|
||||
"{} is not owned by the widget",
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fragment_reads_every_declared_uniform() {
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(P2_Y, 0.7);
|
||||
let body = c.wgsl_body();
|
||||
for u in c.uniforms() {
|
||||
assert!(
|
||||
body.contains(u.name),
|
||||
"uniform {} is declared but never read",
|
||||
u.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_parameters_are_ignored() {
|
||||
let mut c = ToneCurve::new();
|
||||
c.set_param(ParamId("p9_x"), 0.5);
|
||||
c.set_param(ParamId("nonsense"), 0.5);
|
||||
c.set_param(ParamId("p1_z"), 0.5);
|
||||
assert!(!c.is_active());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod aberration;
|
||||
pub mod colour;
|
||||
pub mod colour_mixer;
|
||||
pub mod contrast;
|
||||
pub mod curve;
|
||||
pub mod distortion;
|
||||
pub mod exposure;
|
||||
pub mod helpers;
|
||||
@@ -26,6 +27,7 @@ pub use aberration::Aberration;
|
||||
pub use colour::{Brilliance, Saturation, Vibrance};
|
||||
pub use colour_mixer::ColourMixer;
|
||||
pub use contrast::Contrast;
|
||||
pub use curve::ToneCurve;
|
||||
pub use distortion::Distortion;
|
||||
pub use exposure::Exposure;
|
||||
pub use tone::{BlacksWhites, HighlightsShadows};
|
||||
|
||||
Reference in New Issue
Block a user