Tone Curve: expose ParametricCurve from pipeline and preserve other regions on drag

- Expose ParametricCurve from lightcraft-pipeline and use it in both
  curve_luts and the UI curve editor, eliminating duplicate implementations
  and ensuring render and graph never drift.
- Invert the parametric model while holding the other three regions fixed,
  converting vertical pointer motion relative to the grabbed region's initial
  amount so a horizontal drag with zero vertical travel leaves all sliders
  untouched even with heavy overlapping neighbouring bumps (fixes AlexChambers
  report: Darks=60, Lights=0 grabbed at x=0.51).
- Replace all raw table indexing with .get() per never-crash rules.
- Add unit and headless integration tests verifying that horizontal drag
  with other regions non-zero changes nothing.

Co-Authored-By: deepseek-flash <noreply@deepseek.com>
This commit is contained in:
Paul
2026-10-24 09:34:24 +02:00
co-authored by deepseek-flash
parent 7b6d88ce14
commit 5eebb9c2f9
5 changed files with 281 additions and 183 deletions
+213
View File
@@ -0,0 +1,213 @@
//! 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, s1, s2) = ((c.split_shadows / 100.0) as f32, (c.split_mid / 100.0) as f32, (c.split_highlights / 100.0) as f32);
let (a0, a1, a2, a3) = (c.shadows, c.darks, c.lights, 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);
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 = 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 = 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..3) 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 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};
+28 -159
View File
@@ -4,8 +4,9 @@
use egui::epaint::{Mesh, Vertex};
use egui::{Align2, Color32, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2};
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{ControlSpec, DevelopSettings, Section, ToneCurve, Track, WbMode, controls};
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};
@@ -929,149 +930,9 @@ fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histo
p.add(mesh);
}
/// The four shape controls of the parametric curve, in graph order: region 0 is the darkest.
pub(crate) const PARAM_AMOUNTS: [&str; 4] = ["curve.shadows", "curve.darks", "curve.lights", "curve.highlights"];
/// Resolution the parametric curve is sampled at, matching `lightcraft_pipeline::finish`'s table.
const PARAM_LUT: usize = 1024;
/// Segments the graph's curve line is drawn with.
pub(crate) const CURVE_SAMPLES: i32 = 96;
/// 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 — `lightcraft_pipeline::finish::curve_luts`
/// builds the same shape (its own table, whose `x` is the encoded level the curve sees). The graph
/// draws this curve, so the line and the sliders can never disagree: both come from the settings,
/// recomputed every frame, with no state of their own.
///
/// The running maximum is what makes the curve monotone. Two neighbouring regions pushed opposite
/// ways can otherwise fold it back on itself, and a fold is exactly what a drawn line used to show
/// while the renderer straightened it out.
pub(crate) struct ParametricCurve {
/// The curve, sampled and already forced monotone.
lut: [f32; PARAM_LUT],
/// The three region boundaries as fractions of the graph.
pub(crate) splits: [f64; 3],
/// The four region amounts, in graph order (0 = the darkest).
pub(crate) amounts: [f64; 4],
}
impl ParametricCurve {
pub(crate) fn new(c: &ToneCurve) -> ParametricCurve {
let splits = [c.split_shadows / 100.0, c.split_mid / 100.0, c.split_highlights / 100.0];
let amounts = [c.shadows, c.darks, c.lights, c.highlights];
let s = [splits[0] as f32, splits[1] as f32, splits[2] as f32];
let regions = [(0.0, s[0], amounts[0]), (s[0], s[1], amounts[1]), (s[1], s[2], amounts[2]), (s[2], 1.0, amounts[3])];
let mut lut = [0.0_f32; PARAM_LUT];
let mut prev = 0.0_f32;
for i in 0..PARAM_LUT {
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);
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);
// the running maximum: the renderer's own monotone pass
prev = prev.max(y);
lut[i] = prev;
}
ParametricCurve { lut, splits, amounts }
}
/// The curve at `x` (0 = black, 1 = white), read off the table (so it never overshoots the ends).
pub(crate) fn at(&self, x: f64) -> f64 {
let x = 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 next = self.lut.get(i + 1).copied().unwrap_or(self.lut[i]);
let f = at - i as f32;
(self.lut[i] + (next - self.lut[i]) * 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(crate) 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.
fn region_window(&self, region: usize) -> (f64, f64) {
let s = [self.splits[0], self.splits[1], self.splits[2]];
match region {
0 => (0.0, s[0]),
1 => (s[0], s[1]),
2 => (s[1], s[2]),
_ => (s[2], 1.0),
}
}
/// The half-width of region `region`'s own bump, as fraction of the graph.
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).
fn region_window_at(&self, region: usize, x: f64) -> f64 {
let ctr = self.region_x(region);
let t = ((x - ctr) / self.region_half(region)).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(crate) fn region_at(&self, x: f64) -> usize {
let x = x.clamp(0.0, 1.0);
// the bands are black..split0, split0..split1, split1..split2, split2..white
let (s0, s1, s2) = (self.split_x(0), self.split_x(1), 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(crate) 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 curve level region `region` sits at when its slider holds `amount`, at graph position `x`
/// — where the curve is at the point a drag grabbed, which is what that drag starts from.
pub(crate) fn handle_level_at(&self, region: usize, amount: f64, x: f64) -> f64 {
(x.clamp(0.0, 1.0) + amount * self.level_per_unit(region, x)).clamp(0.0, 1.0)
}
/// The amount that puts region `region`'s curve at `level` at `x` — the inverse of
/// [`Self::handle_level_at`]. A level the region cannot reach saturates rather than jumping.
pub(crate) 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 0.0;
}
((level.clamp(0.0, 1.0) - x.clamp(0.0, 1.0)) / per_unit).clamp(-100.0, 100.0).round()
}
/// The graph position split `split` (0..3) sits at, 0..1.
pub(crate) fn split_x(&self, split: usize) -> f64 {
self.splits.get(split).copied().unwrap_or(0.5)
}
}
/// 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`
@@ -1108,9 +969,9 @@ fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::
};
let drag_id = egui::Id::new("curve-param-drag");
let cursor_id = egui::Id::new("curve-param-cursor");
// the region being dragged, and the graph x the drag is anchored at (stored as an `Option` so a
// stale value can be cleared, as the point-curve editor's drag state is)
let mut dragged: Option<(usize, f64)> = ui.data(|dd| dd.get_temp::<Option<(usize, f64)>>(drag_id)).flatten();
// (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
@@ -1122,10 +983,13 @@ fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::
{
let x = from_screen_x(q);
let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"}));
dragged = Some((model.region_at(x), x));
ui.data_mut(|dd| dd.insert_temp(cursor_id, to_screen(x, model.at(x))));
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)) = dragged
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
@@ -1133,11 +997,16 @@ fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::
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 start = to_screen(x, model.handle_level_at(region, model.amounts[region], x));
let cursor = ui.data(|d| d.get_temp::<Pos2>(cursor_id)).unwrap_or(start) + step;
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 level = ((r.bottom() - cursor.y) / r.height()).clamp(0.0, 1.0) as f64;
set = Some((PARAM_AMOUNTS[region], model.amount_at_level(region, level, x)));
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}));
@@ -1147,7 +1016,7 @@ fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::
dragged = None;
ui.data_mut(|d| {
d.remove::<Pos2>(cursor_id);
d.remove::<Option<(usize, f64)>>(drag_id);
d.remove::<Option<(usize, f64, f64, f32)>>(drag_id);
});
}
ui.data_mut(|dd| dd.insert_temp(drag_id, dragged));
@@ -1157,13 +1026,13 @@ fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::
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 id = PARAM_AMOUNTS[region];
if let Some(v) = 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);
}
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) = 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);
}
}
+29 -4
View File
@@ -5,8 +5,8 @@ use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::panels::edit::ParametricCurve;
use crate::{LightcraftApp, Services};
use lightcraft_pipeline::ParametricCurve;
const T: Duration = Duration::from_secs(20);
@@ -247,7 +247,7 @@ fn the_parametric_model_is_monotone_where_the_bare_sum_of_bumps_is_not() {
}
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");
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 {
@@ -426,12 +426,36 @@ fn dragging_the_curve_moves_the_region_slider() {
// 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);
assert!(painted < c.lights / 100.0 + 0.625, "the painted line followed the drag down: {painted}");
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() {
@@ -529,8 +553,9 @@ fn a_curve_level_and_its_amount_are_inverses() {
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 + (model.split_x(region) - centre) * 0.5] {
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);