Merge PR #1956 (with maintainer follow-up)

# Conflicts:
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-24 10:20:14 -04:00
7 changed files with 745 additions and 43 deletions
+238
View File
@@ -0,0 +1,238 @@
//! The parametric tone curve model shared by the pipeline renderer and the UI curve editor.
//!
//! Lightroom-style tone curve parametric model: four regions (shadows, darks, lights, highlights)
//! separated by three split points. Each region contributes a cosine-windowed bump, summed and
//! forced monotone.
use lightcraft_color::spline::Lut1;
use lightcraft_develop::ToneCurve;
/// The four shape controls of the parametric curve, in graph order: region 0 is the darkest.
pub const PARAM_AMOUNTS: [&str; 4] = ["curve.shadows", "curve.darks", "curve.lights", "curve.highlights"];
/// Resolution the parametric curve is sampled at, matching the renderer's LUT.
pub const PARAM_LUT: usize = 1024;
/// The parametric (region) curve of a [`ToneCurve`]: the four region amounts, each a cosine bump
/// over its window between two splits, summed and then forced monotone.
///
/// This is the renderer's curve, not an approximation of it — both `curve_luts` in the pipeline
/// and the curve editor in the UI use this struct so they can never disagree.
#[derive(Clone, Debug)]
pub struct ParametricCurve {
/// The curve, sampled and already forced monotone.
lut: [f32; PARAM_LUT],
/// The three region boundaries as fractions of the graph (0..1).
pub splits: [f64; 3],
/// The four region amounts, in graph order (0 = shadows, 3 = highlights).
pub amounts: [f64; 4],
}
impl ParametricCurve {
pub fn new(c: &ToneCurve) -> ParametricCurve {
let s0 = if c.split_shadows.is_nan() { 0.25 } else { (c.split_shadows / 100.0).clamp(0.0, 1.0) as f32 };
let s1 = if c.split_mid.is_nan() { 0.50_f32.max(s0) } else { (c.split_mid / 100.0).clamp(s0 as f64, 1.0) as f32 };
let s2 = if c.split_highlights.is_nan() { 0.75_f32.max(s1) } else { (c.split_highlights / 100.0).clamp(s1 as f64, 1.0) as f32 };
let clean_amt = |a: f64| if a.is_nan() { 0.0 } else { a.clamp(-100.0, 100.0) };
let (a0, a1, a2, a3) = (clean_amt(c.shadows), clean_amt(c.darks), clean_amt(c.lights), clean_amt(c.highlights));
let splits = [s0 as f64, s1 as f64, s2 as f64];
let amounts = [a0, a1, a2, a3];
let regions = [(0.0, s0, a0), (s0, s1, a1), (s1, s2, a2), (s2, 1.0, a3)];
let mut lut = [0.0_f32; PARAM_LUT];
let mut prev = 0.0_f32;
for (i, entry) in lut.iter_mut().enumerate() {
let x = i as f32 / (PARAM_LUT - 1) as f32;
let mut d = 0.0_f32;
for (a, b, amt) in regions {
if amt == 0.0 {
continue;
}
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
if half <= f32::EPSILON {
continue;
}
let t = ((x - ctr) / half).clamp(-1.0, 1.0);
d += (amt / 100.0) as f32 * 0.22 * (0.5 + 0.5 * (t * std::f32::consts::PI).cos());
}
let y = (x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0);
prev = prev.max(y);
*entry = prev;
}
ParametricCurve { lut, splits, amounts }
}
/// Convert the evaluated parametric curve into a 1024-entry [`Lut1`].
pub fn to_lut(&self) -> Lut1 {
Lut1 { v: self.lut.to_vec() }
}
/// The curve at `x` (0 = black, 1 = white), read off the table (so it never overshoots the ends).
pub fn at(&self, x: f64) -> f64 {
let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) as f32 };
let at = x * (PARAM_LUT - 1) as f32;
let i = at.floor().clamp(0.0, (PARAM_LUT - 1) as f32) as usize;
let cur = self.lut.get(i).copied().unwrap_or(0.0);
let next = self.lut.get(i + 1).copied().unwrap_or(cur);
let f = at - i as f32;
(cur + (next - cur) * f) as f64
}
/// Where region `region`'s handle sits on the input axis: the middle of its own window, so a
/// handle never has to cross a split to be grabbed.
pub fn region_x(&self, region: usize) -> f64 {
let (a, b) = self.region_window(region);
(a + b) / 2.0
}
/// The window (two splits, as fractions of the graph) region `region` owns.
pub fn region_window(&self, region: usize) -> (f64, f64) {
let s0 = self.split_x(0);
let s1 = self.split_x(1);
let s2 = self.split_x(2);
match region {
0 => (0.0, s0),
1 => (s0, s1),
2 => (s1, s2),
_ => (s2, 1.0),
}
}
/// The half-width of region `region`'s own bump, as fraction of the graph.
pub fn region_half(&self, region: usize) -> f64 {
let (a, b) = self.region_window(region);
(b - a) * 0.75 + 0.05
}
/// The cosine window region `region` applies at `x` (1 at its centre, 0 at the window edges).
pub fn region_window_at(&self, region: usize, x: f64) -> f64 {
let ctr = self.region_x(region);
let half = self.region_half(region);
if half <= f64::EPSILON {
return 0.0;
}
let t = ((x - ctr) / half).clamp(-1.0, 1.0);
0.5 + 0.5 * (t * std::f64::consts::PI).cos()
}
/// The region that owns graph position `x`: the one whose own window contains it. The four
/// windows tile 0..1, so this is always defined and a drag anywhere on the curve moves exactly
/// one slider — the region under the pointer.
pub fn region_at(&self, x: f64) -> usize {
let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) };
let s0 = self.split_x(0);
let s1 = self.split_x(1);
let s2 = self.split_x(2);
if x <= s0 {
0
} else if x <= s1 {
1
} else if x <= s2 {
2
} else {
3
}
}
/// How much output level region `region`'s bump gains at `x` per unit of its slider: the bump is
/// linear in the amount, so this is a constant for a given `x`. A drag converts the pointer's
/// vertical motion into a slider change through it.
pub fn level_per_unit(&self, region: usize, x: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
0.22 / 100.0 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x)
}
/// The baseline level at `x` from the identity diagonal plus the contributions of all regions
/// *except* `region`. Used when inverting the model for `region` while holding the other three
/// regions fixed.
pub fn base_level_without(&self, region: usize, x: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
let mut d = 0.0;
for i in 0..4 {
if i == region {
continue;
}
let amt = self.amounts.get(i).copied().unwrap_or(0.0);
if amt == 0.0 {
continue;
}
d += (amt / 100.0) * 0.22 * self.region_window_at(i, x);
}
x + d * 4.0 * x * (1.0 - x)
}
/// The curve level region `region` sits at when its slider holds `amount`, at graph position `x`,
/// holding the other regions fixed at their current settings.
pub fn handle_level_at(&self, region: usize, amount: f64, x: f64) -> f64 {
(self.base_level_without(region, x) + amount * self.level_per_unit(region, x)).clamp(0.0, 1.0)
}
/// The amount that puts region `region`'s curve at `level` at `x`, with the other three
/// regions held fixed — the inverse of [`Self::handle_level_at`]. A level the region cannot
/// reach saturates rather than jumping.
pub fn amount_at_level(&self, region: usize, level: f64, x: f64) -> f64 {
let per_unit = self.level_per_unit(region, x);
if per_unit.abs() <= f64::EPSILON {
return self.amounts.get(region).copied().unwrap_or(0.0);
}
let base = self.base_level_without(region, x);
((level.clamp(0.0, 1.0) - base) / per_unit).clamp(-100.0, 100.0).round()
}
/// The graph position split `split` (0..=2) sits at, 0..1.
pub fn split_x(&self, split: usize) -> f64 {
self.splits.get(split).copied().unwrap_or(0.5)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_finite_or_inverted_splits_do_not_panic_or_poison_curve() {
let c = ToneCurve {
split_shadows: 80.0,
split_mid: 20.0,
split_highlights: f64::NAN,
shadows: f64::NAN,
lights: f64::INFINITY,
..Default::default()
};
let model = ParametricCurve::new(&c);
assert!(model.at(0.5).is_finite());
assert!(model.at(f64::NAN).is_finite());
assert!(model.region_at(f64::NAN) < 4);
assert!((model.splits[0] - 0.8).abs() < 1e-6);
assert!((model.splits[1] - 0.8).abs() < 1e-6);
assert!((model.splits[2] - 0.8).abs() < 1e-6);
}
#[test]
fn horizontal_inversion_at_x_0_51_with_darks_60_preserves_zero_lights() {
let c = ToneCurve { darks: 60.0, lights: 0.0, ..Default::default() };
let model = ParametricCurve::new(&c);
assert_eq!(model.region_at(0.51), 2, "x=0.51 is inside the Lights region");
let level = model.at(0.51);
assert!((level - 0.5619).abs() < 0.001, "extracted model at 0.51 is ~0.5619: {level}");
// With other regions held fixed, inverting at level ~0.5619 must return Lights = 0, NOT 45!
let lights = model.amount_at_level(2, level, 0.51);
assert_eq!(lights, 0.0, "zero vertical delta preserves Lights=0: {lights}");
}
#[test]
fn a_curve_level_and_its_amount_are_inverses_with_other_regions_present() {
let c = ToneCurve { shadows: 20.0, darks: -30.0, lights: 40.0, highlights: -10.0, ..Default::default() };
let model = ParametricCurve::new(&c);
for region in 0..4 {
let centre = model.region_x(region);
let (_, upper) = model.region_window(region);
for x in [centre, centre + (upper - centre) * 0.5] {
for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] {
let level = model.handle_level_at(region, amount, x);
let back = model.amount_at_level(region, level, x);
assert!((back - amount).abs() <= 1.0, "region {region} at {x}: {amount} -> {level} -> {back}");
}
}
}
}
}
+9 -20
View File
@@ -29,31 +29,20 @@ fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> {
return None;
}
const N: usize = 1024;
let (s1, s2, s3) = ((c.split_shadows / 100.0) as f32, (c.split_mid / 100.0) as f32, (c.split_highlights / 100.0) as f32);
let regions = [(0.0, s1, c.shadows), (s1, s2, c.darks), (s2, s3, c.lights), (s3, 1.0, c.highlights)];
let mut base = Lut1::from_fn(N, |x| {
let mut d = 0.0;
for (a, b, amt) in regions {
if amt == 0.0 {
continue;
}
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
let t = ((x - ctr) / half).clamp(-1.0, 1.0);
let win = 0.5 + 0.5 * (t * std::f32::consts::PI).cos();
d += (amt / 100.0) as f32 * 0.22 * win;
}
(x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0)
});
// keep monotone
for i in 1..N {
base.v[i] = base.v[i].max(base.v[i - 1]);
}
let mut base = crate::curve::ParametricCurve::new(c).to_lut();
let to_pts = |p: &[Point]| p.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>();
if master {
base = MonotoneCurve::new(&to_pts(&c.master)).to_lut(N).compose(&base);
}
let per = [&c.red, &c.green, &c.blue];
Some(std::array::from_fn(|i| if chans[i] { MonotoneCurve::new(&to_pts(per[i])).to_lut(N).compose(&base) } else { base.clone() }))
Some(std::array::from_fn(|i| {
if chans.get(i).copied().unwrap_or(false) {
let pts = per.get(i).map(|&p| to_pts(p)).unwrap_or_default();
MonotoneCurve::new(&pts).to_lut(N).compose(&base)
} else {
base.clone()
}
}))
}
/// Vignette (post-crop) parameters.
+2
View File
@@ -27,6 +27,7 @@ pub mod auto;
pub mod blemish;
pub mod colorops;
pub mod cull;
pub mod curve;
pub mod dust;
pub mod finish;
pub mod geometry;
@@ -44,6 +45,7 @@ pub mod transform;
pub mod upright;
pub mod visualize;
pub use curve::{PARAM_AMOUNTS, PARAM_LUT, ParametricCurve};
pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof, ProofLut, proof_profile, register_proof_profile};
pub use visualize::{MaskView, Overlay};
+122 -22
View File
@@ -6,6 +6,7 @@ use egui::{Align2, Color32, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2};
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls};
use lightcraft_geom::Point;
use lightcraft_pipeline::{PARAM_AMOUNTS, ParametricCurve};
use lightcraft_raster::Histogram;
use serde_json::{Value, json};
@@ -937,6 +938,115 @@ fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histo
p.add(mesh);
}
/// Segments the graph's curve line is drawn with.
pub(crate) const CURVE_SAMPLES: i32 = 96;
/// Close a graph drag's `develop` interaction, as one undo step.
///
/// It is closed when the drag ends *or* the pointer comes up without egui reporting `drag_stopped`
/// for this widget (released outside the window, over a popup, or on a frame the widget does not
/// run). A dangling interaction would make every later edit a silent preview — Undo would then
/// appear to do nothing at all. Returns whether the drag is over, so the caller clears its state.
fn commit_interaction(app: &mut LightcraftApp, ui: &egui::Ui, dragging: bool, stopped: bool) -> bool {
let released = ui.input(|i| i.pointer.any_released()) || !ui.input(|i| i.pointer.primary_down());
if !dragging || !(stopped || released) {
return false;
}
let _ = app.run("develop.endInteraction", json!({}));
true
}
/// How close (screen points) to the curve a press counts as grabbing it.
const PARAM_GRAB: f32 = 14.0;
/// The parametric curve's own drag: the graph shows the curve and nothing else, and dragging the
/// line bends the region the pointer is over, with that region's slider following on the same frame.
///
/// The whole gesture runs inside one `develop` interaction, so it is one undo step (Ctrl+Z takes the
/// gesture back, not the last frame of it), and every write goes through `develop.set`, so the
/// slider, the curve and what is drawn are the same state rather than three things kept in step.
fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) {
if r.width() <= 1.0 || r.height() <= 1.0 {
return;
}
let t = Tokens::get(ui.ctx());
let model = ParametricCurve::new(&d.curve);
let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height());
let from_screen_x = |q: Pos2| ((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64;
// the graph x of a point, and how far it is from the drawn curve (in screen points)
let near_curve = |q: Pos2| {
let x = from_screen_x(q);
to_screen(x, model.at(x)).distance(q)
};
let drag_id = egui::Id::new("curve-param-drag");
let cursor_id = egui::Id::new("curve-param-cursor");
// (region, x, initial_amount, start_y): the region being dragged, the graph x the drag is anchored
// at, the region's starting amount, and the starting screen Y position.
let mut dragged: Option<(usize, f64, f64, f32)> = ui.data(|dd| dd.get_temp::<Option<(usize, f64, f64, f32)>>(drag_id)).flatten();
let hovered = dragged.is_none() && resp.hover_pos().is_some_and(|q| near_curve(q) <= PARAM_GRAB);
let mut set: Option<(&'static str, f64)> = None;
// the press, not where the pointer is once the drag threshold has passed: a press slightly off
// the line is still a grab, and the pointer has usually left that spot by the time it starts
let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos());
if resp.drag_started()
&& let Some(q) = press
&& near_curve(q) <= PARAM_GRAB
{
let x = from_screen_x(q);
let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"}));
let region = model.region_at(x);
let initial_amount = model.amounts.get(region).copied().unwrap_or(0.0);
let start_pos = to_screen(x, model.at(x));
dragged = Some((region, x, initial_amount, start_pos.y));
ui.data_mut(|dd| dd.insert_temp(cursor_id, start_pos));
}
if let Some((region, x, initial_amount, start_y)) = dragged
&& resp.dragged()
{
// Follow the pointer's motion from where the curve was grabbed, so the press itself moves
// nothing and the line tracks the hand. ⌥ = a tenth of the motion, as the point curve does.
let alt = ui.input(|i| i.modifiers.alt);
let dd = ui.input(|i| i.pointer.delta());
let step = if alt { vec2(dd.x * 0.1, dd.y * 0.1) } else { vec2(dd.x, dd.y) };
let cursor = ui.data(|d| d.get_temp::<Pos2>(cursor_id)).unwrap_or(pos2(0.0, start_y)) + step;
ui.data_mut(|d| d.insert_temp(cursor_id, cursor));
let delta_y = start_y - cursor.y;
let delta_level = (delta_y / r.height()) as f64;
let per_unit = model.level_per_unit(region, x);
let new_amount =
if per_unit.abs() > f64::EPSILON { (initial_amount + delta_level / per_unit).clamp(-100.0, 100.0).round() } else { initial_amount };
if let Some(&id) = PARAM_AMOUNTS.get(region) {
set = Some((id, new_amount));
}
}
if let Some((id, v)) = set {
let _ = app.run("develop.set", json!({"control": id, "value": v}));
}
let dropped = resp.drag_stopped();
if commit_interaction(app, ui, dragged.is_some(), dropped) {
dragged = None;
ui.data_mut(|d| {
d.remove::<Pos2>(cursor_id);
d.remove::<Option<(usize, f64, f64, f32)>>(drag_id);
});
}
ui.data_mut(|dd| dd.insert_temp(drag_id, dragged));
if dragged.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
// the region under the pointer (or the one in hand), named as its slider below reads it
let shown = dragged.map(|(region, ..)| region).or_else(|| hovered.then(|| resp.hover_pos()).flatten().map(|q| model.region_at(from_screen_x(q))));
if let Some(region) = shown
&& let Some(&id) = PARAM_AMOUNTS.get(region)
&& let Some(v) = set.and_then(|(s_id, v)| (s_id == id).then_some(v)).or_else(|| controls::get(d, id))
{
let name = controls::find(id).map_or("", |s| s.label);
ui.painter().text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, format!("{name} {v:.0}"), t.font(11.0), t.text_dim);
}
}
fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) {
let t = Tokens::get(ui.ctx());
// channel selector
@@ -1013,29 +1123,17 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height());
let from_screen = |q: Pos2| (((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64, ((r.bottom() - q.y) / r.height()).clamp(0.0, 1.0) as f64);
if ch == "parametric" {
// draw the effective parametric curve by sampling the pipeline's region curve approximation
// The Effective curve: the model the renderer builds from the four region sliders, sampled
// here and rebuilt every frame. The graph holds no state of its own, so a slider moved by
// hand, a Reset or an Undo redraws it from the settings in the same frame it changes.
let c = &d.curve;
let regions = [
(0.0, c.split_shadows / 100.0, c.shadows),
(c.split_shadows / 100.0, c.split_mid / 100.0, c.darks),
(c.split_mid / 100.0, c.split_highlights / 100.0, c.lights),
(c.split_highlights / 100.0, 1.0, c.highlights),
];
let pts: Vec<Pos2> = (0..=64)
.map(|i| {
let x = i as f64 / 64.0;
let mut dd = 0.0;
for (a, b, amt) in regions {
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
let tt = ((x - ctr) / half).clamp(-1.0, 1.0);
dd += amt / 100.0 * 0.22 * (0.5 + 0.5 * (tt * std::f64::consts::PI).cos());
}
to_screen(x, (x + dd * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0))
})
.collect();
let model = ParametricCurve::new(c);
let pts: Vec<Pos2> =
(0..=CURVE_SAMPLES).map(|i| to_screen(i as f64 / CURVE_SAMPLES as f64, model.at(i as f64 / CURVE_SAMPLES as f64))).collect();
p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220))));
param_drag(app, ui, r, &resp, d);
curve_footer(app, ui, d);
for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] {
for &c in PARAM_AMOUNTS.iter().rev() {
control(app, ui, d, c, true);
}
control(app, ui, d, "curve.refineSaturation", true);
@@ -1167,9 +1265,11 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect();
let _ = app.run("develop.curve", json!({"channel": ch, "points": arr}));
}
if resp.drag_stopped() {
// the drag is over when egui says so, or when the pointer comes up without it saying so (a
// release outside the window would otherwise leave the interaction open, and every later edit a
// silent preview that Undo cannot take back)
if commit_interaction(app, ui, dragging.is_some(), resp.drag_stopped()) {
dragging = None;
let _ = app.run("develop.endInteraction", json!({}));
}
ui.data_mut(|dd| dd.insert_temp(drag_id, dragging));
curve_footer(app, ui, d);
+368
View File
@@ -6,6 +6,7 @@ use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
use lightcraft_pipeline::ParametricCurve;
const T: Duration = Duration::from_secs(20);
@@ -221,3 +222,370 @@ fn curve_points_from_an_agent_are_stored_in_order() {
let c = pts(&develop(&h).curve.green);
assert!(c[1].0 < c[2].0, "dragged up to its neighbour, not past it: {c:?}");
}
/// The settings the parametric graph is drawn from: two neighbouring regions pushed opposite ways,
/// where the raw sum of their bumps folds back on itself before the renderer's running maximum
/// straightens it out.
fn folded_regions() -> lightcraft_develop::ToneCurve {
lightcraft_develop::ToneCurve { shadows: 90.0, darks: -80.0, lights: -70.0, highlights: 60.0, ..Default::default() }
}
/// The bare sum of the four region bumps is not monotone, so the drawn curve needs the renderer's
/// running maximum: this is the shape that made the graph disagree with the sliders.
#[test]
fn the_parametric_model_is_monotone_where_the_bare_sum_of_bumps_is_not() {
let c = folded_regions();
let raw = |x: f64| {
let s = [0.0, c.split_shadows / 100.0, c.split_mid / 100.0, c.split_highlights / 100.0, 1.0];
let amounts = [c.shadows, c.darks, c.lights, c.highlights];
let mut d = 0.0;
for i in 0..4 {
let (a, b) = (s[i], s[i + 1]);
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
let t = ((x - ctr) / half).clamp(-1.0, 1.0);
d += amounts[i] / 100.0 * 0.22 * (0.5 + 0.5 * (t * std::f64::consts::PI).cos());
}
x + d * 4.0 * x * (1.0 - x)
};
assert!((0..=64).any(|i| raw(i as f64 / 64.0) > raw((i + 1) as f64 / 64.0)), "the shape folds back, or it proves nothing");
let model = ParametricCurve::new(&c);
let mut prev = f64::MIN;
for i in 0..=256 {
let y = model.at(i as f64 / 256.0);
assert!(y >= prev - 1e-12, "the model never folds back, at {i}: {y} < {prev}");
prev = y;
}
}
/// What the graph *paints* is the model the renderer computes from the sliders. They used to be two
/// copies of the formula and the panel's copy was missing the renderer's monotone pass, so the drawn
/// line could fold back while the renderer straightened it: the graph and the sliders below it
/// disagreed. This reads the painted line itself, not the function behind it.
#[test]
fn the_parametric_graph_paints_the_model_the_renderer_uses() {
let mut h = curve_open("parametric");
let c = folded_regions();
for (control, value) in [("curve.shadows", c.shadows), ("curve.darks", c.darks), ("curve.lights", c.lights), ("curve.highlights", c.highlights)] {
exec(&mut h, "develop.set", json!({"control": control, "value": value}));
}
h.step();
h.step();
let settings = develop(&h).curve;
assert_eq!(settings, c, "the sliders hold the folded shape");
let graph = curve_graph_rect(&mut h);
let painted = painted_param_curve(&h, graph);
let model = ParametricCurve::new(&c);
let mut checked = 0;
for i in 0..=24 {
let x = i as f64 / 24.0;
let want = model.at(x);
let got = painted(x);
assert!((got - want).abs() < 0.02, "the painted curve is the renderer's at {x}: painted {got}, model {want}");
checked += 1;
}
assert_eq!(checked, 25);
// and the shape really does fold back before the monotone pass, so this proves something
assert!(model.at(0.6) > model.at(0.3), "the region bumps still bend the curve");
}
/// The graph rect as `[left, top, width, height]` plus a reader for the painted curve's height at a
/// graph fraction (0 = the bottom of the graph, 1 = the top).
fn curve_graph_rect(h: &mut Headless) -> [f32; 4] {
let r = h.request("ui.widgets", json!({"filter": "curve"}), T);
let w = r["result"].as_array().unwrap().iter().find(|w| w["id"] == "curve").cloned().unwrap_or_else(|| panic!("curve widget on screen: {r}"));
let v = w["rect"].as_array().unwrap();
std::array::from_fn(|i| v[i].as_f64().unwrap() as f32)
}
/// A reader for the height of the painted parametric curve at a graph fraction. The curve is the
/// wide grey path the graph draws; the histogram is a mesh and the grid lines are single segments.
fn painted_param_curve(h: &Headless, r: [f32; 4]) -> impl Fn(f64) -> f64 {
let points = h
.view
.painted_shapes()
.into_iter()
.find_map(|s| match s {
egui::Shape::Path(p) if p.points.len() > 32 && (p.stroke.width - 2.0).abs() < 0.1 => Some(p.points),
_ => None,
})
.unwrap_or_else(|| panic!("the parametric curve line is painted"));
move |x: f64| {
let want_x = r[0] + x as f32 * r[2];
let q = points
.iter()
.min_by(|a, b| (a.x - want_x).abs().total_cmp(&(b.x - want_x).abs()))
.copied()
.unwrap_or_else(|| panic!("the curve has points"));
((r[1] + r[3] - q.y) / r[3]).clamp(0.0, 1.0) as f64
}
}
/// The tone the parametric graph paints at `x`, read by value from the painted line. Stepped first
/// so the panel has drawn the state the command left behind.
fn painted_tone(h: &mut Headless, x: f64) -> f64 {
h.step();
h.step();
let r = curve_graph_rect(h);
painted_param_curve(h, r)(x)
}
/// Where the midtones sit on a linear curve: a point well inside the Lights region, so lifting
/// Lights bends the line there.
const MID: f64 = 0.625;
/// Reset restores the four sliders *and* puts the graph back to the straight diagonal, as one state.
#[test]
fn reset_clears_the_sliders_and_the_graph_together() {
let mut h = curve_open("parametric");
let straight = painted_tone(&mut h, MID);
assert!((straight - MID).abs() < 0.02, "a fresh parametric curve is the diagonal: {straight}");
exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60}));
let bent = painted_tone(&mut h, MID);
assert!(bent > MID + 0.02, "the graph follows the slider up: {bent}");
let undo_before = h.app.session.undo.len();
click_widget(&mut h, "button:curveReset", 1);
let c = develop(&h).curve;
assert_eq!(c, lightcraft_develop::ToneCurve::default(), "Reset puts every slider and split back: {c:?}");
let flat = painted_tone(&mut h, MID);
assert!((flat - MID).abs() < 0.02, "and the graph is the diagonal again: {flat}");
assert_eq!(h.app.session.undo.len(), undo_before + 1, "Reset is one undo step");
}
/// Undo after a Reset brings back both the slider and the graph: the state the graph draws is the
/// state Undo restores, so the two can never be left showing different things.
#[test]
fn undo_restores_the_slider_and_the_graph_together() {
let mut h = curve_open("parametric");
exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60}));
let bent = painted_tone(&mut h, MID);
assert!(bent > MID + 0.02, "the graph is bent: {bent}");
click_widget(&mut h, "button:curveReset", 1);
assert_eq!(develop(&h).curve, lightcraft_develop::ToneCurve::default(), "reset first");
exec(&mut h, "edit.undo", json!({}));
let c = develop(&h).curve;
assert_eq!(c.lights, 60.0, "Undo brings the slider back: {c:?}");
let again = painted_tone(&mut h, MID);
assert!(again > MID + 0.02, "and the graph is bent with it, not left flat: {again}");
// redo puts the diagonal back, and the graph follows that too
exec(&mut h, "edit.redo", json!({}));
assert_eq!(develop(&h).curve, lightcraft_develop::ToneCurve::default(), "redo restores the reset");
let flat = painted_tone(&mut h, MID);
assert!((flat - MID).abs() < 0.02, "the graph is flat with the sliders: {flat}");
}
/// One graph drag that bends several regions at once is one undo step: Ctrl+Z takes back the whole
/// gesture, not the last slider it happened to touch.
#[test]
fn one_graph_drag_that_moves_several_regions_is_one_undo_step() {
let mut h = curve_open("parametric");
exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40}));
exec(&mut h, "develop.set", json!({"control": "curve.darks", "value": -40}));
let before = develop(&h).curve;
let undo_before = h.app.session.undo.len();
// drag the Lights slider's own track, the way a graph handle writes it: begin, live updates, end
exec(&mut h, "develop.beginInteraction", json!({"label": "Parametric Curve"}));
for v in [45, 55, 70, 85] {
exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": v}));
}
exec(&mut h, "develop.endInteraction", json!({}));
let after = develop(&h).curve;
assert_eq!(after.lights, 85.0, "the drag ended at its last value: {after:?}");
assert_ne!(after, before, "the drag changed the state");
assert_eq!(h.app.session.undo.len(), undo_before + 1, "the whole drag is one step, not one per frame");
exec(&mut h, "edit.undo", json!({}));
assert_eq!(develop(&h).curve, before, "undo takes back the whole gesture at once");
}
/// The screen point where a drag should grab the curve: graph fraction `fx`, on the line itself.
fn on_curve(r: [f32; 4], c: &lightcraft_develop::ToneCurve, fx: f32) -> [f32; 2] {
let y = ParametricCurve::new(c).at(fx as f64);
[r[0] + fx * r[2], r[1] + (1.0 - y as f32) * r[3]]
}
/// Dragging the curve itself bends the region under the pointer and moves that region's slider with
/// it, live. The graph draws no handles, so the only thing to grab is the line.
#[test]
fn dragging_the_curve_moves_the_region_slider() {
let mut h = curve_open("parametric");
exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60}));
h.step();
let r = curve_graph_rect(&mut h);
let c = develop(&h).curve;
let undo_before = h.app.session.undo.len();
// grab the line inside the Lights window (x = 0.625) and pull it down
let [x, y] = on_curve(r, &c, 0.625);
let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y + 0.08 * r[3], "steps": 8}), T);
assert_eq!(d["ok"], true, "{d}");
h.step();
let after = develop(&h).curve;
assert!(after.lights < 60.0, "the Lights slider follows the line down: {}", after.lights);
assert!(after.lights > 0.0, "and lands on a value, not at the bottom: {}", after.lights);
assert_eq!((after.shadows, after.darks, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}");
assert_eq!(after.split_shadows, c.split_shadows, "and the splits are untouched");
assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step for the whole drag");
// the graph really is redrawn below where the line was
let graph = curve_graph_rect(&mut h);
let painted = painted_param_curve(&h, graph)(0.625);
let before_level = ParametricCurve::new(&c).at(0.625);
assert!(painted < before_level - 0.02, "the painted line followed the drag down: {painted} vs before {before_level}");
// and Undo takes the whole gesture back to where it started
exec(&mut h, "edit.undo", json!({}));
assert_eq!(develop(&h).curve, c, "undo restores the value the drag started from");
}
/// A horizontal drag on the curve (zero vertical motion) changes no region amount, even when
/// neighbouring regions contribute heavily to the curve at the grabbed position (AlexChambers report:
/// Darks=60, Lights=0 grabbed at x=0.51 used to jump Lights to 45 with zero vertical motion).
#[test]
fn a_horizontal_drag_with_zero_vertical_motion_changes_nothing() {
let mut h = curve_open("parametric");
exec(&mut h, "develop.set", json!({"control": "curve.darks", "value": 60}));
h.step();
let before = develop(&h).curve;
assert_eq!(before.darks, 60.0);
assert_eq!(before.lights, 0.0);
let r = curve_graph_rect(&mut h);
let [x, y] = on_curve(r, &before, 0.51);
// Drag purely horizontally from x=0.51: dx = 20 px, dy = 0
let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 20.0, "toY": y, "steps": 6}), T);
assert_eq!(d["ok"], true, "{d}");
h.step();
let after = develop(&h).curve;
assert_eq!(after.lights, 0.0, "horizontal motion preserves Lights=0: {}", after.lights);
assert_eq!(after.darks, 60.0, "Darks is preserved: {}", after.darks);
assert_eq!(after, before, "horizontal drag changes nothing: {after:?}");
}
/// Dragging up bends the region the other way, so the gesture is not just "downwards works".
#[test]
fn dragging_the_curve_up_raises_the_region_slider() {
let mut h = curve_open("parametric");
let r = curve_graph_rect(&mut h);
let c = develop(&h).curve;
// x = 0.375 is inside the Darks window
let [x, y] = on_curve(r, &c, 0.375);
let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y - 0.08 * r[3], "steps": 8}), T);
assert_eq!(d["ok"], true, "{d}");
h.step();
let after = develop(&h).curve;
assert!(after.darks > 0.0, "the Darks slider follows the line up: {}", after.darks);
assert_eq!((after.shadows, after.lights, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}");
assert!(ParametricCurve::new(&after).at(0.375) > ParametricCurve::new(&c).at(0.375), "the curve is drawn bent up at the Darks region");
}
/// Which region a drag moves is decided by where on the line it is grabbed: the same drag gesture
/// four times over grabs four different sliders.
#[test]
fn the_region_dragged_is_the_one_under_the_pointer() {
for (fx, control, region) in
[(0.125_f32, "curve.shadows", 0usize), (0.375, "curve.darks", 1), (0.625, "curve.lights", 2), (0.875, "curve.highlights", 3)]
{
let mut h = curve_open("parametric");
let r = curve_graph_rect(&mut h);
let c = develop(&h).curve;
assert_eq!(ParametricCurve::new(&c).region_at(fx as f64), region, "x = {fx} belongs to {control}");
let [x, y] = on_curve(r, &c, fx);
let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y - 0.06 * r[3], "steps": 6}), T);
assert_eq!(d["ok"], true, "{d}");
h.step();
let after = develop(&h).curve;
let moved = after.shadows + after.darks + after.lights + after.highlights;
let want = match region {
0 => after.shadows,
1 => after.darks,
2 => after.lights,
_ => after.highlights,
};
assert!(want > 0.0, "grabbing at {fx} raises {control}: {after:?}");
assert!((moved - want).abs() < 1e-9, "and nothing else moved: {after:?}");
}
}
/// A drag that starts away from the line is not a graph gesture at all: nothing moves and no undo
/// step is made (the graph is not a "click anywhere to bend" surface).
#[test]
fn a_parametric_drag_off_the_line_changes_nothing() {
let mut h = curve_open("parametric");
exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40}));
h.step();
let before = develop(&h).curve;
let undo = h.app.session.undo.len();
let r = curve_graph_rect(&mut h);
// a corner of the graph, far above the line
let d = h.request(
"ui.drag",
json!({"x": r[0] + 0.06 * r[2], "y": r[1] + 0.06 * r[3], "toX": r[0] + 0.2 * r[2], "toY": r[1] + 0.3 * r[3], "steps": 6}),
T,
);
assert_eq!(d["ok"], true, "{d}");
h.step();
assert_eq!(develop(&h).curve, before, "no control moved");
assert_eq!(h.app.session.undo.len(), undo, "and no undo step was made");
}
/// Hovering the line names the region a press there would move (and offers the grab cursor), so the
/// graph says which slider it is about to change before the drag starts.
#[test]
fn hovering_the_line_names_the_region_it_would_move() {
let mut h = curve_open("parametric");
exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60}));
h.step();
let r = curve_graph_rect(&mut h);
let [x, y] = on_curve(r, &develop(&h).curve, 0.625);
let q = h.request("ui.move", json!({"x": x, "y": y}), T);
assert_eq!(q["ok"], true, "{q}");
h.step();
assert_eq!(h.last_cursor, egui::CursorIcon::Grab, "the line offers a grab: {:?}", h.last_cursor);
// the slider rows also paint "Lights", so compare with a frame away from the line
let lights = |h: &Headless| h.painted_text().iter().filter(|t| t.contains("Lights")).count();
let hovered = lights(&h);
// well above the line there is no grab and no name
let q = h.request("ui.move", json!({"x": x, "y": r[1] + 0.06 * r[3]}), T);
assert_eq!(q["ok"], true, "{q}");
h.step();
assert_eq!(h.last_cursor, egui::CursorIcon::Default, "away from the line there is nothing to grab: {:?}", h.last_cursor);
assert!(hovered > lights(&h), "hovering names the slider it would move: {hovered} vs {} away", lights(&h));
}
/// A point's level on the region's curve and its slider amount are inverses, so a drag puts the line
/// exactly where the hand is, at whatever point on the graph it was grabbed. A level the region
/// cannot reach saturates rather than jumping.
#[test]
fn a_curve_level_and_its_amount_are_inverses() {
let c = lightcraft_develop::ToneCurve { shadows: 20.0, lights: 40.0, ..Default::default() };
let model = ParametricCurve::new(&c);
for region in 0..4 {
let centre = model.region_x(region);
let (_, upper) = model.region_window(region);
// the region's window centre, and a point off-centre inside the same window
for x in [centre, centre + (upper - centre) * 0.5] {
for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] {
let level = model.handle_level_at(region, amount, x);
let back = model.amount_at_level(region, level, x);
assert!((back - amount).abs() <= 1.0, "region {region} at {x}: {amount} → {level} → {back}");
}
}
}
let centre = model.region_x(3);
assert_eq!(model.amount_at_level(3, 1.0, centre), 100.0, "a level above the region's reach saturates high");
assert_eq!(model.amount_at_level(3, 0.0, centre), -100.0, "and one below saturates low");
}
/// The four regions tile the graph: every position belongs to exactly one of them, in order, so a
/// drag anywhere on the line moves exactly one slider and the one the pointer is over.
#[test]
fn the_four_regions_tile_the_graph() {
let model = ParametricCurve::new(&lightcraft_develop::ToneCurve::default());
assert_eq!(model.region_at(0.0), 0, "black is the darkest region");
assert_eq!(model.region_at(1.0), 3, "white is the lightest");
assert_eq!(model.region_at(0.2), 0, "below the first split");
assert_eq!(model.region_at(0.3), 1, "between the first two splits");
assert_eq!(model.region_at(0.6), 2);
assert_eq!(model.region_at(0.8), 3);
let mut prev = model.region_at(0.0);
for i in 0..=100 {
let r = model.region_at(i as f64 / 100.0);
assert!(r >= prev, "the regions never go backwards: {r} after {prev}");
prev = r;
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) |
| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | |
| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | |
| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | |
| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*`, `crates/pipeline/src/curve.rs` | the graph draws the renderer's own curve model; dragging the line moves the region under the pointer (one undo step) |
| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON |
| LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | |
| LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | |
+5
View File
@@ -14,6 +14,11 @@
sideways moves that control — the zone you pressed stays locked while the pointer crosses the others, Shift drags a
tenth as far, and the whole drag is one undo step. Double-clicking a zone puts its control back to its default.
### Drag the parametric Tone Curve
- In the Tone Curve's parametric mode the graph is now drawn from the renderer's own curve, so it always matches the
photo, and the line can be dragged: pressing it picks the region under the pointer (Highlights, Lights, Darks or
Shadows) and moving up or down changes that slider, as one undo step. Hovering the line names the region first.
## 2026-10-23
### Tab leaves the name field in New Album, New Smart Album and the name prompts