Files
lightcraft/crates/develop/src/controls.rs
T

591 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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};
use crate::settings::DevelopSettings;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Section {
Light,
Curve,
Color,
Mixer,
BwMix,
Grading,
Effects,
Vignette,
Grain,
Detail,
Optics,
Geometry,
Profile,
Calibration,
PointColor,
RedEye,
}
impl Section {
pub fn label(self) -> &'static str {
match self {
Section::Light => "Light",
Section::Curve => "Tone Curve",
Section::Color => "Color",
Section::Mixer => "Color Mixer",
Section::BwMix => "B&W Mixer",
Section::Grading => "Color Grading",
Section::Effects => "Effects",
Section::Vignette => "Vignette",
Section::Grain => "Grain",
Section::Detail => "Detail",
Section::Optics => "Optics",
Section::Geometry => "Geometry",
Section::Profile => "Profile",
Section::Calibration => "Calibration",
Section::PointColor => "Point Color",
Section::RedEye => "Red Eye",
}
}
}
/// How the slider track is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Track {
/// Plain grey track, fill from the minimum.
Plain,
/// Grey track, zero in the middle.
Centered,
/// Blue → yellow (white balance temperature).
Temp,
/// Green → magenta (tint).
Tint,
/// A two-colour gradient (sRGB hex strings).
Gradient { from: &'static str, to: &'static str },
/// Hue shift for a mixer band: gradient from the neighbouring hues through the band colour.
Hue { band: u8 },
/// Saturation for a band: grey → band colour.
Sat { band: u8 },
/// Luminance for a band: dark → light band colour.
Lum { band: u8 },
/// Full hue rainbow.
Rainbow,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct ControlSpec {
pub id: &'static str,
pub label: &'static str,
pub section: Section,
pub min: f64,
pub max: f64,
pub default: f64,
pub step: f64,
/// Decimal places shown.
pub decimals: u8,
pub track: Track,
}
impl ControlSpec {
pub fn clamp(&self, v: f64) -> f64 {
if v.is_finite() { v.clamp(self.min, self.max) } else { self.default }
}
pub fn format(&self, v: f64) -> String {
let s = format!("{:.*}", self.decimals as usize, v);
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
}
/// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin
/// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and
/// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the
/// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays
/// linear.
pub fn position(&self, v: f64) -> f64 {
let v = self.clamp(v);
let p = match self.kelvin_scale() {
Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right,
Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min),
None => (v - self.min) / (self.max - self.min).max(1e-9),
};
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
}
/// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps.
pub fn value_at(&self, p: f64) -> f64 {
let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 };
let v = match self.kelvin_scale() {
Some(_) if p >= 1.0 => self.max,
Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0),
Some((centre, _)) => self.min + 2.0 * p * (centre - self.min),
None => self.min + p * (self.max - self.min),
};
self.clamp(v)
}
/// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]);
/// `None` for every other control, or a range the centre isn't inside.
fn kelvin_scale(&self) -> Option<(f64, f64)> {
let centre = KELVIN_CENTRE;
(self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln()))
}
}
/// The Kelvin temperature at the middle of its track, as in Lightroom.
const KELVIN_CENTRE: f64 = 7000.0;
/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope
/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it.
const KELVIN_KNEE: f64 = 1000.0;
macro_rules! controls {
($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {
/// All numeric develop controls, in panel order.
pub static CONTROLS: &[ControlSpec] = &[
$( ControlSpec { id: $id, label: $label, section: Section::$section, min: $min as f64, max: $max as f64, default: $def as f64, step: $step as f64, decimals: $dec, track: $track }, )*
];
/// Read a control value by id.
pub fn get(s: &DevelopSettings, id: &str) -> Option<f64> {
match id {
$( $id => Some(s.$($field).+ as f64), )*
_ => indexed_get(s, id),
}
}
/// Write a control value by id (clamped to the spec range). Returns false for unknown ids.
pub fn set(s: &mut DevelopSettings, id: &str, v: f64) -> bool {
let Some(spec) = find(id) else { return false };
let v = spec.clamp(v);
match id {
$( $id => { s.$($field).+ = v as _; true } )*
_ => indexed_set(s, id, v),
}
}
};
}
use Track::*;
controls! {
"profile.amount" => profile.amount, "Amount", Profile, 0, 200, 100, 1, 0, Plain;
"wb.temp" => wb.temp, "Temp", Color, 2000, 50000, 6500, 50, 0, Temp;
"wb.tint" => wb.tint, "Tint", Color, -150, 150, 0, 1, 0, Tint;
"light.exposure" => light.exposure, "Exposure", Light, -5, 5, 0, 0.01, 2, Centered;
"light.contrast" => light.contrast, "Contrast", Light, -100, 100, 0, 1, 0, Centered;
"light.highlights" => light.highlights, "Highlights", Light, -100, 100, 0, 1, 0, Centered;
"light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered;
"light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered;
"light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered;
"hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain;
"hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered;
"hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", Light, -100, 100, 0, 1, 0, Centered;
"curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered;
"curve.shadows" => curve.shadows, "Shadows", Curve, -100, 100, 0, 1, 0, Centered;
"curve.splitShadows" => curve.split_shadows, "Shadows split", Curve, 10, 70, 25, 1, 0, Plain;
"curve.splitMid" => curve.split_mid, "Midtones split", Curve, 20, 80, 50, 1, 0, Plain;
"curve.splitHighlights" => curve.split_highlights, "Highlights split", Curve, 30, 90, 75, 1, 0, Plain;
"curve.refineSaturation" => curve.refine_saturation, "Refine Saturation", Curve, 0, 100, 100, 1, 0, Gradient { from: "#7a7a7a", to: "#d85a3a" };
"color.vibrance" => color.vibrance, "Vibrance", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#d8406a" };
"color.saturation" => color.saturation, "Saturation", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e04a3a" };
"mixer.red.hue" => mixer.red.hue, "Red Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 0 };
"mixer.red.sat" => mixer.red.sat, "Red Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 0 };
"mixer.red.lum" => mixer.red.lum, "Red Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 0 };
"mixer.orange.hue" => mixer.orange.hue, "Orange Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 1 };
"mixer.orange.sat" => mixer.orange.sat, "Orange Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 1 };
"mixer.orange.lum" => mixer.orange.lum, "Orange Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 1 };
"mixer.yellow.hue" => mixer.yellow.hue, "Yellow Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 2 };
"mixer.yellow.sat" => mixer.yellow.sat, "Yellow Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 2 };
"mixer.yellow.lum" => mixer.yellow.lum, "Yellow Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 2 };
"mixer.green.hue" => mixer.green.hue, "Green Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 3 };
"mixer.green.sat" => mixer.green.sat, "Green Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 3 };
"mixer.green.lum" => mixer.green.lum, "Green Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 3 };
"mixer.aqua.hue" => mixer.aqua.hue, "Aqua Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 4 };
"mixer.aqua.sat" => mixer.aqua.sat, "Aqua Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 4 };
"mixer.aqua.lum" => mixer.aqua.lum, "Aqua Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 4 };
"mixer.blue.hue" => mixer.blue.hue, "Blue Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 5 };
"mixer.blue.sat" => mixer.blue.sat, "Blue Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 5 };
"mixer.blue.lum" => mixer.blue.lum, "Blue Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 5 };
"mixer.purple.hue" => mixer.purple.hue, "Purple Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 6 };
"mixer.purple.sat" => mixer.purple.sat, "Purple Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 6 };
"mixer.purple.lum" => mixer.purple.lum, "Purple Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 6 };
"mixer.magenta.hue" => mixer.magenta.hue, "Magenta Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 7 };
"mixer.magenta.sat" => mixer.magenta.sat, "Magenta Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 7 };
"mixer.magenta.lum" => mixer.magenta.lum, "Magenta Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 7 };
"bw.red" => bw_mix.red, "Red", BwMix, -100, 100, 0, 1, 0, Lum { band: 0 };
"bw.orange" => bw_mix.orange, "Orange", BwMix, -100, 100, 0, 1, 0, Lum { band: 1 };
"bw.yellow" => bw_mix.yellow, "Yellow", BwMix, -100, 100, 0, 1, 0, Lum { band: 2 };
"bw.green" => bw_mix.green, "Green", BwMix, -100, 100, 0, 1, 0, Lum { band: 3 };
"bw.aqua" => bw_mix.aqua, "Aqua", BwMix, -100, 100, 0, 1, 0, Lum { band: 4 };
"bw.blue" => bw_mix.blue, "Blue", BwMix, -100, 100, 0, 1, 0, Lum { band: 5 };
"bw.purple" => bw_mix.purple, "Purple", BwMix, -100, 100, 0, 1, 0, Lum { band: 6 };
"bw.magenta" => bw_mix.magenta, "Magenta", BwMix, -100, 100, 0, 1, 0, Lum { band: 7 };
"grading.shadows.hue" => grading.shadows.hue, "Shadows Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
"grading.shadows.sat" => grading.shadows.sat, "Shadows Saturation", Grading, 0, 100, 0, 1, 0, Plain;
"grading.shadows.lum" => grading.shadows.lum, "Shadows Luminance", Grading, -100, 100, 0, 1, 0, Centered;
"grading.midtones.hue" => grading.midtones.hue, "Midtones Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
"grading.midtones.sat" => grading.midtones.sat, "Midtones Saturation", Grading, 0, 100, 0, 1, 0, Plain;
"grading.midtones.lum" => grading.midtones.lum, "Midtones Luminance", Grading, -100, 100, 0, 1, 0, Centered;
"grading.highlights.hue" => grading.highlights.hue, "Highlights Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
"grading.highlights.sat" => grading.highlights.sat, "Highlights Saturation", Grading, 0, 100, 0, 1, 0, Plain;
"grading.highlights.lum" => grading.highlights.lum, "Highlights Luminance", Grading, -100, 100, 0, 1, 0, Centered;
"grading.global.hue" => grading.global.hue, "Global Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
"grading.global.sat" => grading.global.sat, "Global Saturation", Grading, 0, 100, 0, 1, 0, Plain;
"grading.global.lum" => grading.global.lum, "Global Luminance", Grading, -100, 100, 0, 1, 0, Centered;
"grading.blending" => grading.blending, "Blending", Grading, 0, 100, 50, 1, 0, Plain;
"grading.balance" => grading.balance, "Balance", Grading, -100, 100, 0, 1, 0, Centered;
"effects.texture" => effects.texture, "Texture", Effects, -100, 100, 0, 1, 0, Centered;
"effects.clarity" => effects.clarity, "Clarity", Effects, -100, 100, 0, 1, 0, Centered;
"effects.dehaze" => effects.dehaze, "Dehaze", Effects, -100, 100, 0, 1, 0, Centered;
"vignette.amount" => vignette.amount, "Vignette", Vignette, -100, 100, 0, 1, 0, Gradient { from: "#101010", to: "#f0f0f0" };
"vignette.midpoint" => vignette.midpoint, "Midpoint", Vignette, 0, 100, 50, 1, 0, Plain;
"vignette.roundness" => vignette.roundness, "Roundness", Vignette, -100, 100, 0, 1, 0, Centered;
"vignette.feather" => vignette.feather, "Feather", Vignette, 0, 100, 50, 1, 0, Plain;
"vignette.highlights" => vignette.highlights, "Highlights", Vignette, 0, 100, 0, 1, 0, Plain;
"grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain;
"grain.size" => grain.size, "Size", Grain, 0, 100, 25, 1, 0, Plain;
"grain.roughness" => grain.roughness, "Roughness", Grain, 0, 100, 50, 1, 0, Plain;
"enhance.denoise" => enhance.denoise, "Amount", Detail, 0, 100, 0, 1, 0, Plain;
"detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain;
"detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain;
"detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain;
"detail.sharpenMasking" => detail.sharpen_masking, "Masking", Detail, 0, 100, 0, 1, 0, Plain;
"detail.nrLuminance" => detail.nr_luminance, "Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain;
"detail.nrDetail" => detail.nr_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain;
"detail.nrContrast" => detail.nr_contrast, "Contrast", Detail, 0, 100, 0, 1, 0, Plain;
"detail.nrColor" => detail.nr_color, "Color Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain;
"detail.nrColorDetail" => detail.nr_color_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain;
"detail.nrColorSmoothness" => detail.nr_color_smoothness, "Smoothness", Detail, 0, 100, 50, 1, 0, Plain;
"optics.distortion" => optics.distortion, "Distortion", Optics, -100, 100, 0, 1, 0, Centered;
"optics.vignetting" => optics.vignetting, "Vignetting", Optics, -100, 100, 0, 1, 0, Centered;
"optics.vignettingMidpoint" => optics.vignetting_midpoint, "Midpoint", Optics, 0, 100, 50, 1, 0, Plain;
"optics.profileDistortion" => optics.profile_distortion, "Profile Distortion", Optics, 0, 200, 100, 1, 0, Plain;
"optics.profileVignetting" => optics.profile_vignetting, "Profile Vignetting", Optics, 0, 200, 100, 1, 0, Plain;
"optics.defringePurple" => optics.defringe_purple_amount, "Purple Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#a040d0" };
"optics.defringePurpleHueLo" => optics.defringe_purple_hue_lo, "Purple Hue Low", Optics, 0, 90, 30, 1, 0, Gradient { from: "#6040e0", to: "#d040b0" };
"optics.defringePurpleHueHi" => optics.defringe_purple_hue_hi, "Purple Hue High", Optics, 10, 100, 70, 1, 0, Gradient { from: "#6040e0", to: "#d040b0" };
"optics.defringeGreen" => optics.defringe_green_amount, "Green Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40c040" };
"optics.defringeGreenHueLo" => optics.defringe_green_hue_lo, "Green Hue Low", Optics, 0, 90, 40, 1, 0, Gradient { from: "#c0d030", to: "#30c0b0" };
"optics.defringeGreenHueHi" => optics.defringe_green_hue_hi, "Green Hue High", Optics, 10, 100, 60, 1, 0, Gradient { from: "#c0d030", to: "#30c0b0" };
"optics.caRed" => optics.ca_red, "Red/Cyan Fringe", Optics, -100, 100, 0, 1, 0, Gradient { from: "#30c0c0", to: "#e04040" };
"optics.caBlue" => optics.ca_blue, "Blue/Yellow Fringe", Optics, -100, 100, 0, 1, 0, Gradient { from: "#d0c030", to: "#4060e0" };
"geometry.vertical" => geometry.vertical, "Vertical", Geometry, -100, 100, 0, 1, 0, Centered;
"geometry.horizontal" => geometry.horizontal, "Horizontal", Geometry, -100, 100, 0, 1, 0, Centered;
"geometry.rotate" => geometry.rotate, "Rotate", Geometry, -10, 10, 0, 0.1, 1, Centered;
"geometry.aspect" => geometry.aspect, "Aspect", Geometry, -100, 100, 0, 1, 0, Centered;
"geometry.scale" => geometry.scale, "Scale", Geometry, 50, 150, 100, 1, 0, Plain;
"geometry.offsetX" => geometry.offset_x, "Offset X", Geometry, -100, 100, 0, 0.1, 1, Centered;
"geometry.offsetY" => geometry.offset_y, "Offset Y", Geometry, -100, 100, 0, 0.1, 1, Centered;
"calibration.shadowsTint" => calibration.shadows_tint, "Shadows Tint", Calibration, -100, 100, 0, 1, 0, Tint;
"calibration.redHue" => calibration.red_hue, "Red Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#e0306a", to: "#e08a30" };
"calibration.redSat" => calibration.red_sat, "Red Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e03a3a" };
"calibration.greenHue" => calibration.green_hue, "Green Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#b0c030", to: "#30c08a" };
"calibration.greenSat" => calibration.green_sat, "Green Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40b050" };
"calibration.blueHue" => calibration.blue_hue, "Blue Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#30a0d0", to: "#8040d0" };
"calibration.blueSat" => calibration.blue_sat, "Blue Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#3a60e0" };
"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" },
},
ControlSpec {
id: "redEye.pupilSize",
label: "Pupil Size",
section: Section::RedEye,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Plain,
},
ControlSpec {
id: "redEye.darken",
label: "Darken",
section: Section::RedEye,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Gradient { from: "#d0d0d0", to: "#101010" },
},
];
/// `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,
})
}
"redEye" => {
let e = s.red_eye.get_mut(i)?;
Some(match field {
"pupilSize" => &mut e.pupil_size,
"darken" => &mut e.darken,
_ => 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(), red_eye: s.red_eye.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> {
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(),
"redEye" => s.red_eye.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> {
CONTROLS.iter().filter(move |c| c.section == section)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_control_reads_its_default() {
let s = DevelopSettings::default();
for c in CONTROLS {
let v = get(&s, c.id).unwrap_or_else(|| panic!("no getter for {}", c.id));
assert_eq!(v, c.default, "{}", c.id);
assert!(c.min <= c.default && c.default <= c.max, "{}", c.id);
}
}
#[test]
fn ids_unique_and_settable() {
let mut seen = std::collections::HashSet::new();
let mut s = DevelopSettings::default();
for c in CONTROLS {
assert!(seen.insert(c.id), "duplicate {}", c.id);
assert!(set(&mut s, c.id, c.max + 1000.0));
assert_eq!(get(&s, c.id), Some(c.max), "{}", c.id);
assert!(set(&mut s, c.id, f64::NAN));
assert_eq!(get(&s, c.id), Some(c.default));
}
assert!(!set(&mut s, "nope", 1.0));
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();
assert_eq!(e.format(0.5), "+0.50");
assert_eq!(e.format(-1.0), "-1.00");
assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500");
}
/// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against
/// the linear Tint slider; the model is within 2 % of them); every other control stays linear.
#[test]
fn the_kelvin_track_is_lightrooms_and_others_stay_linear() {
let temp = find("wb.temp").unwrap();
for (k, lightroom, model) in [
(2000.0, 0.0, 0.0),
(3650.0, 0.167, 0.165),
(5400.0, 0.333, 0.34),
(7000.0, 0.5, 0.5),
(15000.0, 0.79, 0.79),
(20000.0, 0.853, 0.849),
(30000.0, 0.90, 0.92),
(50000.0, 1.0, 1.0),
] {
let p = temp.position(k);
assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}");
assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}");
}
// every 50 K step survives value → position → value, and the track never runs backwards
let mut last = -1.0;
for k in (2000..=50000).step_by(50).map(f64::from) {
let p = temp.position(k);
assert!(p > last, "{k} K");
last = p;
let back = (temp.value_at(p) / temp.step).round() * temp.step;
assert_eq!(back, k, "{k} K → {p} → {back}");
}
assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0));
assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default)));
let exposure = find("light.exposure").unwrap();
for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] {
assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}");
}
// the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale
let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp };
assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0));
// a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero
let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp };
assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0));
}
}