diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index f423d69e7..7dda3ab54 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -931,8 +931,12 @@ fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histo /// 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"]; +/// The three region boundaries between those four regions, in graph order. +pub(crate) const PARAM_SPLITS: [&str; 3] = ["curve.splitShadows", "curve.splitMid", "curve.splitHighlights"]; /// Resolution the parametric curve is sampled at, matching `lightcraft_pipeline::finish`'s table. const PARAM_LUT: usize = 1024; +/// Radius (screen points) within which the pointer counts as being on a curve handle. +pub(crate) const CURVE_HIT: f32 = 8.0; /// Segments the graph's curve line is drawn with. pub(crate) const CURVE_SAMPLES: i32 = 96; @@ -948,13 +952,19 @@ pub(crate) const CURVE_SAMPLES: i32 = 96; /// 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 s = [(c.split_shadows / 100.0) as f32, (c.split_mid / 100.0) as f32, (c.split_highlights / 100.0) as f32]; + 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; @@ -974,7 +984,7 @@ impl ParametricCurve { prev = prev.max(y); lut[i] = prev; } - ParametricCurve { lut } + ParametricCurve { lut, splits, amounts } } /// The curve at `x` (0 = black, 1 = white), read off the table (so it never overshoots the ends). @@ -986,6 +996,233 @@ impl ParametricCurve { 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 level region `region`'s bump puts the curve at when its slider holds `amount` — where the + /// region's handle is drawn. This is the region's *own* bump (the shape Lightroom draws a handle + /// on), not the monotone curve through it: the bump is linear in the amount and one-to-one, so a + /// drag inverts it exactly and the handle stays under the pointer. + pub(crate) fn handle_level(&self, region: usize, amount: f64) -> f64 { + let x = self.region_x(region); + let bump = amount / 100.0 * 0.22 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x); + (x + bump).clamp(0.0, 1.0) + } + + /// The amount that puts region `region`'s handle at `level` — the inverse of [`Self::handle_level`]. + /// A drag that cannot reach the level asked for saturates at ±100 rather than jumping. + pub(crate) fn amount_at_level(&self, region: usize, level: f64) -> f64 { + let x = self.region_x(region); + let per_100 = 0.22 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x); + if per_100.abs() <= f64::EPSILON { + return 0.0; + } + (((level.clamp(0.0, 1.0) - x) / per_100) * 100.0).clamp(-100.0, 100.0).round() + } + + /// Where split `split` (0..3) sits on the input axis. + pub(crate) fn split_x(&self, split: usize) -> f64 { + self.splits.get(split).copied().unwrap_or(0.5) + } + + /// The graph position split `split` may be dragged to: inside its own window, a step away from + /// the splits either side of it so the four regions keep a width. + pub(crate) fn split_x_limited(&self, split: usize, x: f64) -> f64 { + let lo = if split == 0 { 0.0 } else { self.split_x(split - 1) + SPLIT_MIN_GAP }; + let hi = if split + 1 >= PARAM_SPLITS.len() { 1.0 } else { self.split_x(split + 1) - SPLIT_MIN_GAP }; + if lo > hi { (lo + hi) / 2.0 } else { x.clamp(lo, hi) } + } + + /// The value split `split`'s slider takes when its handle is dragged to graph position `x`: the + /// position read out in that slider's own range, the way its track reads. + pub(crate) fn split_value(&self, split: usize, x: f64) -> f64 { + let x = self.split_x_limited(split, x); + let id = PARAM_SPLITS.get(split).copied().unwrap_or(PARAM_SPLITS[0]); + controls::find(id).map_or(x * 100.0, |s| (s.min + x * (s.max - s.min)).round().clamp(s.min, s.max)) + } + + /// Screen position of a handle's own centre, the level a drag starts from. + fn handle_screen_start(&self, h: ParamHandle, r: Rect) -> Pos2 { + let (x, y) = match h { + ParamHandle::Region(region) => (self.region_x(region), self.handle_level(region, self.amounts[region])), + ParamHandle::Split(split) => (self.split_x(split), 0.0), + }; + pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()) + } +} + +/// 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 +} + +/// One graph drag of the parametric curve: begin the interaction, write the handle's slider on every +/// frame of the drag, end it on release. Every write goes through `develop.set`, so the slider under +/// the graph shows the value the handle holds — and the whole drag is one engine interaction, which +/// is one undo step (Ctrl+Z takes the gesture back, not the last frame of it). +fn param_handles(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) { + 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 drag_id = egui::Id::new("curve-param-handle-drag"); + let mut dragged: Option = ui.data(|dd| dd.get_temp::>(drag_id)).flatten(); + let hovered = if dragged.is_none() { resp.hover_pos().and_then(|q| param_handle_at(&model, r, q)) } else { None }; + let mut set: Option<(&'static str, f64)> = None; + // The handle is the one under the *press*, not under where the pointer is once the drag + // threshold has passed: that can be most of a handle's own width away, so hit-testing the + // current position would grab the wrong control (or none at all on a short, quick drag). + let press = ui.input(|i| i.pointer.press_origin()); + let grab = press.or(resp.interact_pointer_pos()); + if resp.drag_started() + && let Some(h) = grab.and_then(|q| param_handle_at(&model, r, q)) + { + let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"})); + dragged = Some(h); + } + if let Some(h) = dragged + && resp.dragged() + { + // The drag follows how far the pointer has moved, not where it is: the cursor it tracks is + // seeded at the handle's own centre (so grabbing a handle never makes its control jump by + // the distance between the pointer and that centre) and then advances by the pointer's + // motion. ⌥ = a tenth of the motion, as the point curve's drag does. + let cursor_id = egui::Id::new("curve-param-cursor"); + 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 = model.handle_screen_start(h, r); + // seeded at the handle's centre (so the grab itself moves nothing) and advanced by the + // pointer's motion from there + let advanced = ui.data(|d| d.get_temp::(cursor_id)).unwrap_or(start) + step; + ui.data_mut(|d| d.insert_temp(cursor_id, advanced)); + let level = ((r.bottom() - advanced.y) / r.height()).clamp(0.0, 1.0) as f64; + set = Some(match h { + ParamHandle::Region(region) => (h.control(), model.amount_at_level(region, level)), + ParamHandle::Split(split) => { + let x = ((advanced.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64; + (h.control(), model.split_value(split, x)) + } + }); + } + if let Some((id, v)) = set { + let _ = app.run("develop.set", json!({"control": id, "value": v})); + } + if commit_interaction(app, ui, dragged.is_some(), resp.drag_stopped()) { + dragged = None; + // the next drag seeds its cursor from its own handle, not from where this one finished + ui.data_mut(|d| d.remove::(egui::Id::new("curve-param-cursor"))); + } + 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.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + // the handles: a region's on its own bump, a split's a notch on the bottom edge + let p = ui.painter(); + for region in 0..PARAM_AMOUNTS.len() { + let at = to_screen(model.region_x(region), model.handle_level(region, model.amounts[region])); + let on = dragged == Some(ParamHandle::Region(region)) || hovered == Some(ParamHandle::Region(region)); + p.circle_filled(at, if on { 5.5 } else { 4.0 }, Color32::from_gray(230)); + p.circle_stroke(at, if on { 5.5 } else { 4.0 }, Stroke::new(1.0, t.canvas)); + } + for split in 0..PARAM_SPLITS.len() { + let at = to_screen(model.split_x(split), 0.0); + let on = dragged == Some(ParamHandle::Split(split)) || hovered == Some(ParamHandle::Split(split)); + let (w, h) = if on { (2.5, 9.0) } else { (1.5, 6.0) }; + p.rect_filled(Rect::from_min_max(pos2(at.x - w / 2.0, at.y - h), pos2(at.x + w / 2.0, at.y + h)), 1.0, Color32::from_gray(190)); + } + // what the handle in hand (or under the pointer) holds, as its slider below reads it + if let Some(h) = dragged.or(hovered) { + let label = controls::get(d, h.control()).map(|v| { + let name = controls::find(h.control()).map_or("", |s| s.label); + if v > 0.0 { format!("{name} {v:+.0}") } else { format!("{name} {v:.0}") } + }); + if let Some(label) = label { + p.text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, label, t.font(11.0), t.text_dim); + } + } +} + +/// Keeps a split clear of its neighbours on the graph. +const SPLIT_MIN_GAP: f64 = 0.01; + +/// A region handle of the parametric graph: which of the four regions, or which of the three splits +/// between them. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum ParamHandle { + /// Region `0..4` (0 = the darkest), grabbed by its slider's amount. + Region(usize), + /// Split `0..3`, grabbed on the graph's bottom edge. + Split(usize), +} + +impl ParamHandle { + /// The control this handle writes. + pub(crate) fn control(self) -> &'static str { + match self { + ParamHandle::Region(r) => PARAM_AMOUNTS.get(r).copied().unwrap_or(PARAM_AMOUNTS[0]), + ParamHandle::Split(s) => PARAM_SPLITS.get(s).copied().unwrap_or(PARAM_SPLITS[0]), + } + } +} + +/// The handle under `q`, if any is within [`CURVE_HIT`]. +/// +/// A split's notch is looked for first. It lives on the graph's bottom edge, where the curve itself +/// passes on the dark end of a shallow line, so on equal terms a region handle would always win and +/// the notch could never be grabbed. +fn param_handle_at(model: &ParametricCurve, r: Rect, q: Pos2) -> Option { + let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); + let near = |at: Pos2| at.distance(q) <= CURVE_HIT; + for split in 0..PARAM_SPLITS.len() { + if near(to_screen(model.split_x(split), 0.0)) { + return Some(ParamHandle::Split(split)); + } + } + let closest = (0..PARAM_AMOUNTS.len()) + .map(|region| (region, to_screen(model.region_x(region), model.handle_level(region, model.amounts[region])).distance(q))) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + (closest.1 <= CURVE_HIT).then_some(ParamHandle::Region(closest.0)) } fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { @@ -1072,6 +1309,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let pts: Vec = (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_handles(app, ui, r, &resp, d); curve_footer(app, ui, d); for c in PARAM_AMOUNTS { control(app, ui, d, c, true); @@ -1205,9 +1443,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); diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index 7131c1ade..e918d8d60 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -395,3 +395,176 @@ fn one_graph_drag_that_moves_several_regions_is_one_undo_step() { exec(&mut h, "edit.undo", json!({})); assert_eq!(develop(&h).curve, before, "undo takes back the whole gesture at once"); } + +/// The screen position of a region's handle: its region's window centre, at the level the region's +/// own bump puts the curve, so a drag starts on the dot the graph draws. +fn region_handle(r: [f32; 4], c: &lightcraft_develop::ToneCurve, region: usize) -> [f32; 2] { + let model = ParametricCurve::new(c); + let x = model.region_x(region); + let y = model.handle_level(region, model.amounts[region]); + [r[0] + x as f32 * r[2], r[1] + (1.0 - y as f32) * r[3]] +} + +/// The screen position of a split's notch, on the graph's bottom edge. +fn split_handle(r: [f32; 4], c: &lightcraft_develop::ToneCurve, split: usize) -> [f32; 2] { + let model = ParametricCurve::new(c); + [r[0] + model.split_x(split) as f32 * r[2], r[1] + r[3]] +} + +/// The point to press to grab a split's notch: its x, a few points up from the graph's bottom edge +/// (the notch is drawn on the edge, and a press exactly on the widget's border falls outside egui's +/// hit rect). Well inside [`CURVE_HIT`] of the notch. +fn split_grab(r: [f32; 4], c: &lightcraft_develop::ToneCurve, split: usize) -> [f32; 2] { + let [x, y] = split_handle(r, c, split); + [x, y - 3.0] +} + +/// Dragging a region's handle on the graph bends that region and moves its slider with it, live: +/// the value under the pointer while the drag is in flight, and the same value when it lands. +#[test] +fn dragging_a_region_handle_moves_its_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(); + let [x, y] = region_handle(r, &c, 2); + let down = 0.08 * r[3]; + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y + down, "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 handle 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 is redrawn bent, with the handle where the drag left it + let graph = curve_graph_rect(&mut h); + let model = ParametricCurve::new(&after); + let painted = painted_param_curve(&h, graph)(model.region_x(2)); + let want = model.handle_level(2, after.lights); + assert!((painted - want).abs() < 0.03, "the handle sits on the redrawn curve: painted {painted}, handle {want}"); + // 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"); +} + +/// Dragging up bends the region the other way, so the gesture is not just "downwards works". +#[test] +fn dragging_a_region_handle_up_raises_its_slider() { + let mut h = curve_open("parametric"); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + let [x, y] = region_handle(r, &c, 1); + 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 handle up: {}", after.darks); + assert_eq!((after.shadows, after.lights, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}"); + // the drawn curve really did move up where that region lives + assert!(ParametricCurve::new(&after).at(0.375) > ParametricCurve::new(&c).at(0.375), "the curve bends up at the Darks region"); +} + +/// Dragging a split's notch moves the region boundary — the same split slider the pipeline reads — +/// leaving the four region amounts alone, in one undo step. +#[test] +fn dragging_a_split_moves_the_region_boundary() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40})); + h.step(); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + let undo_before = h.app.session.undo.len(); + let [x, y] = split_grab(r, &c, 1); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 0.1 * r[2], "toY": y, "steps": 8}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + assert!(after.split_mid > c.split_mid, "the Midtones split follows the notch right: {} → {}", c.split_mid, after.split_mid); + assert_eq!((after.shadows, after.darks, after.lights, after.highlights), (0.0, 0.0, 40.0, 0.0), "the region amounts stay put"); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step for the drag"); + // dragged far right it stops inside the split's own range, without crossing the next split + let [x, y] = split_grab(r, &after, 1); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 0.9 * r[2], "toY": y, "steps": 8}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let far = develop(&h).curve; + assert!((20.0..=80.0).contains(&far.split_mid), "the split stays in its slider's range: {}", far.split_mid); + let model = ParametricCurve::new(&far); + assert!(model.split_x(1) < model.split_x(2), "and stays left of the split after it: {far:?}"); +} + +/// A drag that starts away from every handle 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_a_handle_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 from the curve and from both handle rows + 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"); +} + +/// The graph shows the value under the pointer while the drag is in flight, so the slider and the +/// handle are seen to agree mid-gesture, not only once it lands. +#[test] +fn the_value_under_the_pointer_reads_out_during_the_drag() { + 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 [x, y] = region_handle(r, &c, 2); + // press on the handle and hover the same place: the readout names the control it will write + let q = h.request("ui.move", json!({"x": x, "y": y}), T); + assert_eq!(q["ok"], true, "{q}"); + h.step(); + assert!(h.painted_text().iter().any(|t| t.contains("Lights")), "the handle names the slider it writes: {:?}", h.painted_text()); +} + +/// A region's handle level and its slider amount are inverses, so a drag puts the handle exactly +/// where the pointer is; a level the region cannot reach saturates rather than jumping. +#[test] +fn a_region_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 { + for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] { + let level = model.handle_level(region, amount); + let back = model.amount_at_level(region, level); + assert!((back - amount).abs() <= 1.0, "region {region}: {amount} → level {level} → {back}"); + } + } + assert_eq!(model.amount_at_level(3, 1.0), 100.0, "a level above the region's reach saturates high"); + assert_eq!(model.amount_at_level(3, 0.0), -100.0, "and one below saturates low"); + // a split reads out in its own slider's range (10..=70 here), not the graph's 0..=100 … + assert_eq!(model.split_value(0, 0.0), 10.0, "the Shadows split bottoms out at its slider's low end"); + // … and its neighbours are what actually bound it, closer than that range's own ends + assert!(model.split_value(0, 1.0) <= 70.0 && model.split_value(0, 1.0) > 10.0, "kept inside its range"); + assert!(model.split_x(1) > model.split_x(0) && model.split_x(2) > model.split_x(1), "and the splits stay in order"); + assert!(model.split_value(2, 1.0) <= 90.0, "the Highlights split stays in its own range too"); +} + +/// A dragged split never crosses its neighbours, however far the pointer goes. +#[test] +fn a_dragged_split_stays_between_its_neighbours() { + let model = ParametricCurve::new(&lightcraft_develop::ToneCurve::default()); + assert!((model.split_x_limited(1, 0.0) - 0.26).abs() < 1e-9, "Midtones stops just right of the split before it"); + assert!((model.split_x_limited(1, 1.0) - 0.74).abs() < 1e-9, "and just left of the split after it"); + assert!(model.split_x_limited(0, -1.0).abs() < 1e-9, "the first split can reach black"); + assert!((model.split_x_limited(2, 2.0) - 1.0).abs() < 1e-9, "the last can reach white"); +}