mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
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:
co-authored by
Claude Opus 5.5
parent
37d9ecf0e9
commit
490fe943cd
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user