From 8b7fb306dcd24b187f9fecad8c068e323fe6142a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:25:29 -0400 Subject: [PATCH] LR-EDIT-LIGHT-CURVE-TAT: targeted adjustment tool for the tone curve and the colour mixer develop.targeted {target: curve|hue|sat|lum, x, y, delta, channel?}: samples the photo at the point on a proxy render with the target stage and everything after it neutralized, then nudges the parametric curve region (by the sampled tone and the splits), moves/adds a point on the master/RGB point curve, or adjusts the colour-mixer (or B&W mix) bands weighted by their share of the sampled OkLCh hue. UI: target buttons in the curve editor and the colour mixer; a vertical drag on the loupe sends one step per unit inside one interaction (one undo step). New original vector icon (Target) in icons.rs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/color.rs | 104 +++++++++++++++++++++++++++- crates/engine/src/tests_color.rs | 29 ++++++++ crates/ui-egui/src/icons.rs | 9 +++ crates/ui-egui/src/panels/detail.rs | 41 +++++++++++ crates/ui-egui/src/panels/edit.rs | 32 +++++++++ 5 files changed, 213 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/color.rs b/crates/engine/src/cmd/color.rs index fbf551cb7..65a9a709e 100644 --- a/crates/engine/src/cmd/color.rs +++ b/crates/engine/src/cmd/color.rs @@ -1,13 +1,16 @@ -//! Commands that sample the developed image: Point Color samples and the targeted adjustment tool. +//! Commands that sample the developed image: Point Color samples and the targeted adjustment tool +//! (`develop.targeted`: a vertical drag on the photo raises/lowers the tone-curve region or the +//! colour-mixer bands under the pointer; the UI sends one call per drag step inside an interaction). //! //! Sampling renders a small proxy with every stage *after* the sampled one neutralized, so the //! picked colour is the one the adjustment will see (e.g. Point Color samples after the colour //! mixer, before vibrance, grading, vignette and curves). use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; +use lightcraft_color::spline::MonotoneCurve; use lightcraft_color::transfer::srgb_to_linear; use lightcraft_color::{REC2020, SRGB}; -use lightcraft_develop::{DevelopSettings, MAX_POINT_COLORS, PointColor, Treatment}; +use lightcraft_develop::{DevelopSettings, MAX_POINT_COLORS, MIXER_BANDS, PointColor, ToneCurve, Treatment, controls}; use lightcraft_geom::Point; use serde_json::{Value, json}; @@ -64,6 +67,94 @@ pub(crate) fn after_mixer_neutral(d: &mut DevelopSettings) { d.curve = Default::default(); } +/// The targeted adjustment tool: the controls (and their new values) a vertical drag of `delta` +/// at `(x, y)` changes, computed on `d` (applied by the caller). +fn targeted(s: &mut Session, p: &Value) -> Result { + let c = "develop.targeted"; + let (x, y, delta) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?, f64_req(p, "delta", c)?); + let target = p.get("target").and_then(Value::as_str).unwrap_or("curve").to_string(); + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + let mut changed = serde_json::Map::new(); + fn nudge(changed: &mut serde_json::Map, d: &mut DevelopSettings, ctl: &str, by: f64) { + let v = controls::get(d, ctl).unwrap_or(0.0) + by; + controls::set(d, ctl, v); + changed.insert(ctl.to_string(), json!(controls::get(d, ctl))); + } + let label; + match target.as_str() { + "curve" => { + // the encoded value the tone curve sees there (curves and grain neutralized) + let e = probe(s, c, x, y, |d| { + d.curve = ToneCurve { refine_saturation: d.curve.refine_saturation, ..Default::default() }; + d.grain.amount = 0.0; + })?; + let channel = p.get("channel").and_then(Value::as_str).unwrap_or("parametric"); + label = "Tone Curve"; + if channel == "parametric" { + let v = (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]) as f64 * 100.0; + let cv = &d.curve; + let region = if v < cv.split_shadows { + "curve.shadows" + } else if v < cv.split_mid { + "curve.darks" + } else if v < cv.split_highlights { + "curve.lights" + } else { + "curve.highlights" + }; + nudge(&mut changed, &mut d, region, delta); + } else { + // point curve: move the point at the sampled input up/down (or add one there) + let (pts, v) = match channel { + "red" => (&mut d.curve.red, e[0]), + "green" => (&mut d.curve.green, e[1]), + "blue" => (&mut d.curve.blue, e[2]), + _ => (&mut d.curve.master, 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]), + }; + let v = v as f64; + if pts.is_empty() { + *pts = vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)]; + } + let curve = MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::>()); + let dy = delta / 255.0; + match pts.iter().position(|q| (q.x - v).abs() < 0.03) { + Some(i) => pts[i].y = (pts[i].y + dy).clamp(0.0, 1.0), + None => { + let i = pts.iter().position(|q| q.x > v).unwrap_or(pts.len()); + pts.insert(i, Point::new(v, (curve.eval(v) + dy).clamp(0.0, 1.0))); + } + } + changed.insert(format!("curve.{channel}"), json!(pts.iter().map(|q| [q.x, q.y]).collect::>())); + } + } + "hue" | "sat" | "lum" => { + // the hue the colour mixer sees there (the mixer and everything after it neutralized) + let e = probe(s, c, x, y, |d| { + d.mixer = Default::default(); + d.bw_mix = Default::default(); + after_mixer_neutral(d); + })?; + let [_, _, h] = encoded_to_oklch(e); + let w = lightcraft_pipeline::colorops::band_weights(h); + let top = w.iter().cloned().fold(0.0f32, f32::max).max(1e-6); + let bw = d.treatment == Treatment::Bw || d.profile.id == "lc.mono"; + label = if bw { "B&W Mix" } else { "Color Mixer" }; + for (i, band) in MIXER_BANDS.iter().enumerate() { + let k = (w[i] / top) as f64; + if k < 0.05 { + continue; + } + let ctl = if bw { format!("bw.{band}") } else { format!("mixer.{band}.{target}") }; + nudge(&mut changed, &mut d, &ctl, delta * k); + } + } + _ => return Err(bad(c, "target must be curve, hue, sat or lum")), + } + s.set_develop(id, d, label)?; + Ok(Value::Object(changed)) +} + fn index(p: &Value, c: &str) -> Result { Ok(f64_req(p, "index", c)? as usize) } @@ -93,6 +184,15 @@ pub fn specs() -> Vec { Ok(json!({"index": i, "lum": sample.lum, "chroma": sample.chroma, "hue": sample.hue})) } ), + cmd!( + "develop.targeted", + "Targeted Adjustment", + [], + None, + "{target: curve|hue|sat|lum, x, y (normalized image coords), delta (slider units; point curves: output levels 0..255), channel?: parametric|master|red|green|blue (curve)} — adjusts the curve region / colour-mixer bands under the point; returns the changed controls", + has_active, + targeted + ), cmd!("pointColor.delete", "Delete Point Color Sample", [], None, "{index}", has_active, |s, p| { let c = "pointColor.delete"; let i = index(p, c)?; diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests_color.rs index a6b8eb2a7..5cc58869e 100644 --- a/crates/engine/src/tests_color.rs +++ b/crates/engine/src/tests_color.rs @@ -52,3 +52,32 @@ fn point_color_pick_and_adjust() { let d = active_dev(&s); assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d); } + +#[test] +fn targeted_adjustment_on_curve_and_mixer() { + let mut s = demo(); + // the bright sky lies in the upper regions of the parametric curve + let r = s.execute("develop.targeted", &json!({"target": "curve", "x": 0.5, "y": 0.35, "delta": 20})).unwrap(); + let (k, v) = r.as_object().unwrap().iter().next().map(|(k, v)| (k.clone(), v.clone())).unwrap(); + assert!(k == "curve.lights" || k == "curve.highlights", "{r}"); + assert_eq!(v, 20.0); + assert_eq!(lightcraft_develop::controls::get(&active_dev(&s), &k), Some(20.0)); + // the dark trees in the lower ones + let r = s.execute("develop.targeted", &json!({"target": "curve", "x": 0.4, "y": 0.62, "delta": -10})).unwrap(); + assert!(r.get("curve.shadows").is_some() || r.get("curve.darks").is_some(), "{r}"); + // point curve: a point appears at the sampled input, raised by delta levels + s.execute("develop.targeted", &json!({"target": "curve", "channel": "master", "x": 0.5, "y": 0.35, "delta": 12.75})).unwrap(); + let m = active_dev(&s).curve.master; + assert_eq!(m.len(), 3); + assert!((m[1].y - m[1].x - 0.05).abs() < 1e-6, "{m:?}"); + // a second step moves the same point + s.execute("develop.targeted", &json!({"target": "curve", "channel": "master", "x": 0.5, "y": 0.35, "delta": 12.75})).unwrap(); + assert_eq!(active_dev(&s).curve.master.len(), 3); + // colour mixer: the purple sky's bands lose saturation, the dominant one by the full delta + let r = s.execute("develop.targeted", &json!({"target": "sat", "x": 0.5, "y": 0.1, "delta": -30})).unwrap(); + let o = r.as_object().unwrap(); + assert!(!o.is_empty() && o.keys().all(|k| k.starts_with("mixer.") && k.ends_with(".sat")), "{r}"); + assert!(o.values().any(|v| v.as_f64() == Some(-30.0)), "{r}"); + assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0)); + assert!(s.execute("develop.targeted", &json!({"target": "nope", "x": 0.5, "y": 0.5, "delta": 1})).is_err()); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a8656c6f9..10426bc2e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -66,6 +66,8 @@ pub enum Icon { Video, Heart, Edited, + /// Targeted adjustment: a ring with a centre dot and up/down arrows (drag vertically). + Target, } struct Pen<'a> { @@ -402,5 +404,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.circle(10.0, 10.0, 7.0); pen.line(&[(7.0, 13.0), (13.0, 7.0)]); } + Target => { + pen.circle(8.0, 10.0, 5.0); + pen.dot(8.0, 10.0, 1.3); + pen.line(&[(15.5, 3.5), (15.5, 16.5)]); + pen.line(&[(13.5, 5.5), (15.5, 3.5), (17.5, 5.5)]); + pen.line(&[(13.5, 14.5), (15.5, 16.5), (17.5, 14.5)]); + } } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ff9426334..3833bc2b9 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -44,6 +44,11 @@ pub enum Gesture { Guide { a: Point, }, + /// Targeted adjustment drag from `at` (normalized); `acc` = vertical travel not yet applied. + Targeted { + at: Point, + acc: f32, + }, } /// Screen ↔ normalized-image mapping for the displayed frame. @@ -200,6 +205,38 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); } +/// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or +/// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all +/// in one interaction = one undo step). +fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, target: &str) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + if resp.drag_started() + && let Some(q) = resp.interact_pointer_pos() + { + let _ = app.run("develop.beginInteraction", json!({"label": "Targeted Adjustment"})); + app.gesture = Some(Gesture::Targeted { at: map.norm(q), acc: 0.0 }); + } + if resp.dragged() + && let Some(Gesture::Targeted { at, acc }) = &mut app.gesture + { + *acc -= resp.drag_delta().y * 0.5; + let step = acc.trunc(); + if step != 0.0 { + *acc -= step; + let at = *at; + let mut p = json!({"target": target, "x": at.x, "y": at.y, "delta": step}); + if target == "curve" && app.ui.curve_channel != "parametric" { + p["channel"] = json!(app.ui.curve_channel); + } + let _ = app.run("develop.targeted", p); + } + } + if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::Targeted { .. })) { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } +} + /// The diagnostic overlay the loupe shows (Point Color's visualized range). fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { use lightcraft_pipeline::Overlay; @@ -244,6 +281,10 @@ fn general_interaction( } return; } + if let Some(target) = app.ui.tool.strip_prefix("tat:").map(str::to_string) { + targeted_drag(app, ui, resp, map, &target); + return; + } if app.ui.tool == "pointColor" { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 57fd35ceb..2b0190c45 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -422,6 +422,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev app.ui.curve_channel = ch.into(); } } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + tat_button(app, ui, "tat:curve", "Targeted adjustment: drag up/down on the photo to adjust the curve there"); + }); }); }); let ch = app.ui.curve_channel.clone(); @@ -568,6 +571,15 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let _ = id; } +/// Toggle for a targeted-adjustment tool (`tool` = `tat:`). +fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) { + let active = app.ui.tool == tool; + let id = tool.replace(':', "-"); + if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() { + app.ui.tool = if active { String::new() } else { tool.to_string() }; + } +} + // ------------------------------------------------------------------------------ colour mixer fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { @@ -591,6 +603,26 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { } }); }); + // targeted adjustment: pick the attribute, then drag on the photo + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); + paint(ui.painter(), r, Icon::Target, t.icon); + let items: &[(&str, &str)] = + if crate::is_bw(d) { &[("lum", "B&W Mix")] } else { &[("hue", "Hue"), ("sat", "Saturation"), ("lum", "Luminance")] }; + for (k, label) in items { + let tool = format!("tat:{k}"); + let active = app.ui.tool == tool; + if text_button(ui, &format!("tatMixer-{k}"), label, active) + .on_hover_text("Targeted adjustment: drag up/down on the photo to adjust the colours there") + .clicked() + { + app.ui.tool = if active { String::new() } else { tool }; + } + } + }); + }); let b = bands[sel]; if crate::is_bw(d) { control(app, ui, d, &format!("bw.{b}"), true);