mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
Tone Curve: the parametric graph is a clean curve you drag
The graph now shows the Effective curve and nothing else, as the parametric tab should: the four region handles and three split notches are gone. You grab the line itself, anywhere, and drag it up or down. Which slider moves is decided by where on the line you grabbed — the region whose window that input level falls in — and that slider follows on the same frame, so the line, the handle-free graph and the sliders are one state. The mapping is the renderer's own: a point's curve level against the region's slider is a straight line, so the drag inverts it exactly and the line tracks the hand instead of drifting away from it. The four regions tile the graph, so any grab moves exactly one slider. ⌥ is still a tenth of the motion, ⌥ or not the grab itself moves nothing, and a press away from the line is not a gesture (no value change, no undo step). The whole gesture is still one `develop` interaction, so it is one undo step. Note: `ui.data` stores and loads one type, so the drag state is kept as `Option<(region, x)>` and loaded with `.flatten()` — storing one shape and loading another silently returns `None` every frame, which is what made the first attempt drop the drag after a single frame. Tests rewritten for the line: a drag down and up, which region each quarter of the graph belongs to, the level/amount inverse at off-centre points, the region tiling, hovering naming the region, and one undo step per gesture. Co-Authored-By: deepseek-flash <noreply@deepseek.com>
This commit is contained in:
committed by
Paul
co-authored by
deepseek-flash
parent
3c3df53ca1
commit
7b6d88ce14
@@ -931,12 +931,8 @@ 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;
|
||||
|
||||
@@ -1028,56 +1024,52 @@ impl ParametricCurve {
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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) / per_100) * 100.0).clamp(-100.0, 100.0).round()
|
||||
((level.clamp(0.0, 1.0) - x.clamp(0.0, 1.0)) / per_unit).clamp(-100.0, 100.0).round()
|
||||
}
|
||||
|
||||
/// Where split `split` (0..3) sits on the input axis.
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -1095,136 +1087,86 @@ fn commit_interaction(app: &mut LightcraftApp, ui: &egui::Ui, dragging: bool, st
|
||||
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) {
|
||||
/// 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) {
|
||||
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<ParamHandle> = ui.data(|dd| dd.get_temp::<Option<ParamHandle>>(drag_id)).flatten();
|
||||
let hovered = if dragged.is_none() { resp.hover_pos().and_then(|q| param_handle_at(&model, r, q)) } else { None };
|
||||
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");
|
||||
// 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();
|
||||
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 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());
|
||||
// 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(h) = grab.and_then(|q| param_handle_at(&model, r, q))
|
||||
&& let Some(q) = press
|
||||
&& near_curve(q) <= PARAM_GRAB
|
||||
{
|
||||
let x = from_screen_x(q);
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"}));
|
||||
dragged = Some(h);
|
||||
dragged = Some((model.region_at(x), x));
|
||||
ui.data_mut(|dd| dd.insert_temp(cursor_id, to_screen(x, model.at(x))));
|
||||
}
|
||||
if let Some(h) = dragged
|
||||
if let Some((region, x)) = 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");
|
||||
// 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 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::<Pos2>(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))
|
||||
}
|
||||
});
|
||||
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;
|
||||
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)));
|
||||
}
|
||||
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()) {
|
||||
let dropped = resp.drag_stopped();
|
||||
if commit_interaction(app, ui, dragged.is_some(), dropped) {
|
||||
dragged = None;
|
||||
// the next drag seeds its cursor from its own handle, not from where this one finished
|
||||
ui.data_mut(|d| d.remove::<Pos2>(egui::Id::new("curve-param-cursor")));
|
||||
ui.data_mut(|d| {
|
||||
d.remove::<Pos2>(cursor_id);
|
||||
d.remove::<Option<(usize, f64)>>(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.is_some() {
|
||||
} else if hovered {
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<ParamHandle> {
|
||||
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) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// channel selector
|
||||
@@ -1309,7 +1251,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
||||
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_handles(app, ui, r, &resp, d);
|
||||
param_drag(app, ui, r, &resp, d);
|
||||
curve_footer(app, ui, d);
|
||||
for c in PARAM_AMOUNTS {
|
||||
control(app, ui, d, c, true);
|
||||
|
||||
@@ -396,56 +396,37 @@ fn one_graph_drag_that_moves_several_regions_is_one_undo_step() {
|
||||
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 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]]
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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_a_region_handle_moves_its_slider() {
|
||||
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();
|
||||
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);
|
||||
// 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 handle down: {}", after.lights);
|
||||
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 is redrawn bent, with the handle where the drag left it
|
||||
// the graph really is redrawn below where the line was
|
||||
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}");
|
||||
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}");
|
||||
// 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");
|
||||
@@ -453,61 +434,60 @@ fn dragging_a_region_handle_moves_its_slider() {
|
||||
|
||||
/// 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() {
|
||||
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;
|
||||
let [x, y] = region_handle(r, &c, 1);
|
||||
// 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 handle up: {}", after.darks);
|
||||
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:?}");
|
||||
// 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");
|
||||
assert!(ParametricCurve::new(&after).at(0.375) > ParametricCurve::new(&c).at(0.375), "the curve is drawn bent 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.
|
||||
/// 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 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:?}");
|
||||
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 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).
|
||||
/// 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_a_handle_changes_nothing() {
|
||||
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 from the curve and from both handle rows
|
||||
// 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}),
|
||||
@@ -519,52 +499,65 @@ fn a_parametric_drag_off_a_handle_changes_nothing() {
|
||||
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.
|
||||
/// 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 the_value_under_the_pointer_reads_out_during_the_drag() {
|
||||
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 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 [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!(h.painted_text().iter().any(|t| t.contains("Lights")), "the handle names the slider it writes: {:?}", h.painted_text());
|
||||
assert_eq!(h.last_cursor, egui::CursorIcon::Grab, "the line offers a grab: {:?}", h.last_cursor);
|
||||
assert!(h.painted_text().iter().any(|t| t.contains("Lights")), "and names the slider it would move: {:?}", h.painted_text());
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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_region_level_and_its_amount_are_inverses() {
|
||||
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 {
|
||||
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}");
|
||||
let centre = model.region_x(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 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
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");
|
||||
}
|
||||
|
||||
/// A dragged split never crosses its neighbours, however far the pointer goes.
|
||||
/// 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 a_dragged_split_stays_between_its_neighbours() {
|
||||
fn the_four_regions_tile_the_graph() {
|
||||
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");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user