LR-EDIT-COLOR-POINTCOLOR: Point Color — sampled colours with shift, variance, range and visualize

Up to 8 OkLCh samples (pointColor.pick samples the colour after the mixer on a proxy render);
per sample hue/saturation/luminance shift, variance (compress/expand the spread around the
sample) and an overall + per-axis range with soft edges, applied after the colour mixer in
ColorOps and its WGSL twin (equivalence case). Indexed control specs
(pointColor.<i>.hueShift …) so develop.set/controls/MCP address each sample. Visualize range
is a render overlay (RenderRequest::overlay) drawn after either renderer: the sample's own
edit is left out and colours outside the range turn grey. Point Color flyout in the Color
section with swatches, eyedropper, sliders, visualize and delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 10:18:28 -04:00
co-authored by Claude Opus 5.5
parent 37d9ecf0e9
commit 490fe943cd
23 changed files with 890 additions and 12 deletions
+186 -3
View File
@@ -1,6 +1,9 @@
//! Control specs: one table describes every numeric develop slider — id, label, section, range,
//! default, step, display precision and track style. The UI builds panels from it, MCP exposes it as
//! a schema, and `get`/`set` address settings by id (e.g. `light.exposure`, `mixer.red.hue`).
//!
//! Settings that come in lists (Point Color samples) are *indexed* controls: one template spec in
//! [`INDEXED`] (`pointColor.hueShift`) addresses every element as `pointColor.<i>.hueShift`.
use serde::{Deserialize, Serialize};
@@ -23,6 +26,7 @@ pub enum Section {
Geometry,
Profile,
Calibration,
PointColor,
}
impl Section {
@@ -42,6 +46,7 @@ impl Section {
Section::Geometry => "Geometry",
Section::Profile => "Profile",
Section::Calibration => "Calibration",
Section::PointColor => "Point Color",
}
}
}
@@ -105,7 +110,7 @@ macro_rules! controls {
pub fn get(s: &DevelopSettings, id: &str) -> Option<f64> {
match id {
$( $id => Some(s.$($field).+ as f64), )*
_ => None,
_ => indexed_get(s, id),
}
}
@@ -115,7 +120,7 @@ macro_rules! controls {
let v = spec.clamp(v);
match id {
$( $id => { s.$($field).+ = v as _; true } )*
_ => false,
_ => indexed_set(s, id, v),
}
}
};
@@ -240,8 +245,166 @@ controls! {
"crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered;
}
/// Template specs of the indexed controls (`<family>.<field>`; addressed as `<family>.<i>.<field>`).
pub static INDEXED: &[ControlSpec] = &[
ControlSpec {
id: "pointColor.hueShift",
label: "Hue",
section: Section::PointColor,
min: -100.0,
max: 100.0,
default: 0.0,
step: 1.0,
decimals: 0,
track: Rainbow,
},
ControlSpec {
id: "pointColor.satShift",
label: "Saturation",
section: Section::PointColor,
min: -100.0,
max: 100.0,
default: 0.0,
step: 1.0,
decimals: 0,
track: Gradient { from: "#7a7a7a", to: "#e04a3a" },
},
ControlSpec {
id: "pointColor.lumShift",
label: "Luminance",
section: Section::PointColor,
min: -100.0,
max: 100.0,
default: 0.0,
step: 1.0,
decimals: 0,
track: Gradient { from: "#202020", to: "#f0f0f0" },
},
ControlSpec {
id: "pointColor.variance",
label: "Variance",
section: Section::PointColor,
min: -100.0,
max: 100.0,
default: 0.0,
step: 1.0,
decimals: 0,
track: Centered,
},
ControlSpec {
id: "pointColor.range",
label: "Range",
section: Section::PointColor,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Plain,
},
ControlSpec {
id: "pointColor.hueRange",
label: "Hue Range",
section: Section::PointColor,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Rainbow,
},
ControlSpec {
id: "pointColor.satRange",
label: "Saturation Range",
section: Section::PointColor,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Gradient { from: "#7a7a7a", to: "#e04a3a" },
},
ControlSpec {
id: "pointColor.lumRange",
label: "Luminance Range",
section: Section::PointColor,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Gradient { from: "#202020", to: "#f0f0f0" },
},
];
/// `pointColor.3.hueShift` → (`pointColor`, 3, `hueShift`).
fn split_indexed(id: &str) -> Option<(&str, usize, &str)> {
let mut it = id.splitn(3, '.');
let (fam, i, field) = (it.next()?, it.next()?, it.next()?);
Some((fam, i.parse().ok()?, field))
}
fn indexed_field<'a>(s: &'a mut DevelopSettings, id: &str) -> Option<&'a mut f64> {
let (fam, i, field) = split_indexed(id)?;
match fam {
"pointColor" => {
let p = s.point_colors.get_mut(i)?;
Some(match field {
"hueShift" => &mut p.hue_shift,
"satShift" => &mut p.sat_shift,
"lumShift" => &mut p.lum_shift,
"variance" => &mut p.variance,
"range" => &mut p.range,
"hueRange" => &mut p.hue_range,
"satRange" => &mut p.sat_range,
"lumRange" => &mut p.lum_range,
_ => return None,
})
}
_ => None,
}
}
fn indexed_get(s: &DevelopSettings, id: &str) -> Option<f64> {
// (a clone keeps one accessor for get and set; these lists are tiny)
let mut c = DevelopSettings { point_colors: s.point_colors.clone(), ..DevelopSettings::default() };
indexed_field(&mut c, id).map(|v| *v)
}
fn indexed_set(s: &mut DevelopSettings, id: &str, v: f64) -> bool {
match indexed_field(s, id) {
Some(f) => {
*f = v;
true
}
None => false,
}
}
/// The spec of a control id, including indexed ids (`pointColor.0.hueShift` → the
/// `pointColor.hueShift` template).
pub fn find(id: &str) -> Option<&'static ControlSpec> {
CONTROLS.iter().find(|c| c.id == id)
if let Some(c) = CONTROLS.iter().find(|c| c.id == id) {
return Some(c);
}
let (fam, _, field) = split_indexed(id)?;
INDEXED.iter().find(|c| c.id.strip_prefix(fam).and_then(|r| r.strip_prefix('.')) == Some(field))
}
/// Every indexed control that exists in `s` (id, template spec), e.g. `pointColor.0.hueShift`.
pub fn indexed_instances(s: &DevelopSettings) -> Vec<(String, &'static ControlSpec)> {
let mut out = Vec::new();
for spec in INDEXED {
let Some((fam, field)) = spec.id.split_once('.') else { continue };
let n = match fam {
"pointColor" => s.point_colors.len(),
_ => 0,
};
for i in 0..n {
out.push((format!("{fam}.{i}.{field}"), spec));
}
}
out
}
pub fn in_section(section: Section) -> impl Iterator<Item = &'static ControlSpec> {
@@ -277,6 +440,26 @@ mod tests {
assert_eq!(get(&s, "nope"), None);
}
#[test]
fn indexed_controls_address_list_elements() {
let mut s = DevelopSettings::default();
assert_eq!(get(&s, "pointColor.0.hueShift"), None);
assert!(!set(&mut s, "pointColor.0.hueShift", 10.0), "no sample yet");
s.point_colors.push(Default::default());
s.point_colors.push(Default::default());
assert!(set(&mut s, "pointColor.1.hueShift", 250.0));
assert_eq!(s.point_colors[1].hue_shift, 100.0, "clamped by the template spec");
assert_eq!(get(&s, "pointColor.1.range"), Some(50.0));
assert_eq!(find("pointColor.7.lumRange").map(|c| c.id), Some("pointColor.lumRange"));
assert!(find("pointColor.x.lumRange").is_none() && find("pointColor.0.nope").is_none());
let ids: Vec<String> = indexed_instances(&s).into_iter().map(|(id, _)| id).collect();
assert_eq!(ids.len(), 2 * INDEXED.iter().filter(|c| c.id.starts_with("pointColor.")).count());
for (id, spec) in indexed_instances(&s) {
assert!(get(&s, &id).is_some(), "{id}");
assert!(spec.min <= spec.default && spec.default <= spec.max);
}
}
#[test]
fn formatting() {
let e = find("light.exposure").unwrap();
+1 -1
View File
@@ -101,7 +101,7 @@ impl SettingsGroup {
SettingsGroup::Light => &["light"],
SettingsGroup::ToneCurve => &["curve"],
SettingsGroup::Color => &["color"],
SettingsGroup::ColorMixer => &["mixer", "bw_mix"],
SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"],
SettingsGroup::ColorGrading => &["grading"],
SettingsGroup::Effects => &["effects"],
SettingsGroup::Vignette => &["vignette"],
+60
View File
@@ -17,6 +17,8 @@ pub struct DevelopSettings {
pub curve: ToneCurve,
pub color: ColorAdj,
pub mixer: Mixer,
/// Point Color: up to [`MAX_POINT_COLORS`] sampled colours, each with its own adjustment.
pub point_colors: Vec<PointColor>,
pub bw_mix: BwMix,
pub grading: ColorGrading,
pub effects: Effects,
@@ -49,6 +51,7 @@ impl Default for DevelopSettings {
curve: ToneCurve::default(),
color: ColorAdj::default(),
mixer: Mixer::default(),
point_colors: Vec::new(),
bw_mix: BwMix::default(),
grading: ColorGrading::default(),
effects: Effects::default(),
@@ -263,6 +266,63 @@ impl Mixer {
}
}
/// Most Point Color samples per photo (Lightroom's limit).
pub const MAX_POINT_COLORS: usize = 8;
/// One Point Color sample: a colour picked on the image (OkLCh, at the stage where Point Color
/// applies: after the colour mixer) and the adjustment of the colours around it. The range is a
/// box in OkLCh around the sample, widened by `range` (overall) and the per-axis widths, with a
/// soft edge.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PointColor {
/// Sampled OkLab lightness (0..1).
pub lum: f64,
/// Sampled OkLCh chroma (0..~0.37).
pub chroma: f64,
/// Sampled OkLCh hue, degrees.
pub hue: f64,
/// Hue shift −100..100 (±~29° at the sample).
pub hue_shift: f64,
/// Saturation shift −100..100 (chroma scale).
pub sat_shift: f64,
/// Luminance shift −100..100.
pub lum_shift: f64,
/// −100 compresses the colour spread within the range towards the sample (evens out e.g.
/// skin), +100 expands it.
pub variance: f64,
/// Overall range 0..100 (scales all three widths).
pub range: f64,
pub hue_range: f64,
pub sat_range: f64,
pub lum_range: f64,
}
impl Default for PointColor {
fn default() -> Self {
Self {
lum: 0.5,
chroma: 0.1,
hue: 0.0,
hue_shift: 0.0,
sat_shift: 0.0,
lum_shift: 0.0,
variance: 0.0,
range: 50.0,
hue_range: 50.0,
sat_range: 50.0,
lum_range: 50.0,
}
}
}
impl PointColor {
/// True when the sample changes nothing.
pub fn is_neutral(&self) -> bool {
self.hue_shift == 0.0 && self.sat_shift == 0.0 && self.lum_shift == 0.0 && self.variance == 0.0
}
}
/// B&W mix (used when treatment = Bw): per-band luminance contribution −100..100.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]