//! The colour mixer — twelve hue bands, each with hue, saturation and //! luminance. //! //! The control photographers mean by "per-colour adjustment": pick a colour //! range, then shift its hue, deepen or mute it, or lighten it, without //! touching the rest of the image. Thirty-six parameters in one operation. //! //! # Why bands overlap //! //! Each band has a centre hue and influences colours near it with a weight //! that falls smoothly to zero at its neighbours' centres. A hard assignment //! — "this pixel is orange, that one is yellow" — puts a visible seam through //! any gradient crossing a boundary, and skies and skin are exactly where //! that shows. Overlapping weights mean adjacent bands blend, and a colour //! halfway between two centres receives half of each. //! //! # Why the weights sum to one //! //! The falloff window is exactly the band spacing, so at any hue the twelve //! weights sum to one no matter where that hue falls — a partition of unity. //! That is what lets each band's gain be applied and added with no further //! scaling: setting every band's saturation to +100 gives the same result as //! setting the global saturation to +100 rather than something far stronger, //! and a band pushed on its own reaches its full documented travel. //! //! Dividing by the weight of the *adjusted* bands instead — which is what //! this used to do — breaks both halves of that. A single adjusted band //! divides by its own weight and cancels it, so the falloff disappears and //! the band acts at full strength right up to a hard edge; and with two bands //! adjusted, each one's share depends on what the other is set to, so turning //! up one colour's saturation quietly weakened its neighbour's hue shift. use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, }; use crate::operation::{Helper, Operation, Uniform}; use crate::ops::helpers; pub const ID: OpId = OpId("colour_mixer"); /// The twelve bands, in hue order starting at red. /// /// Twelve rather than Lightroom's eight: the extra bands fall between the /// primaries and secondaries, which is where skin (orange-to-red) and /// foliage (yellow-to-green) actually sit, and where eight bands force a /// compromise. pub struct Band { /// Stable id fragment, used to build parameter ids. pub key: &'static str, /// Centre hue in degrees. pub hue: f32, } pub static BANDS: [Band; 12] = [ Band { key: "red", hue: 0.0, }, Band { key: "orange", hue: 30.0, }, Band { key: "yellow", hue: 60.0, }, Band { key: "chartreuse", hue: 90.0, }, Band { key: "green", hue: 120.0, }, Band { key: "spring", hue: 150.0, }, Band { key: "cyan", hue: 180.0, }, Band { key: "azure", hue: 210.0, }, Band { key: "blue", hue: 240.0, }, Band { key: "violet", hue: 270.0, }, Band { key: "magenta", hue: 300.0, }, Band { key: "rose", hue: 330.0, }, ]; /// The three adjustments each band carries. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Channel { Hue, Saturation, Luminance, } impl Channel { pub const ALL: [Channel; 3] = [Channel::Hue, Channel::Saturation, Channel::Luminance]; pub const fn suffix(self) -> &'static str { match self { Channel::Hue => "hue", Channel::Saturation => "sat", Channel::Luminance => "lum", } } } // Parameter descriptors, one per band per channel. Written out rather than // looped because `concat!` needs literals: every id is built from its band's // key, and a runtime loop has no way to spell `orange_sat`. The macro keeps it // honest. // // **Every one of them is faceted**, and that is what makes the operation // legible in a panel. Thirty-six parameters presented as a flat list are // thirty-six sliders reading "Hue / Sat / Lum" twelve times with nothing // saying which band any row belongs to — the identity is right here in the // descriptor and used to be discarded on the way out. The facet carries it: // the channel as the aspect, the band as the subject, and the band's centre // hue so a frontend can identify the row by the colour it edits rather than // by a word. What a frontend *does* with 30° is its own business (ARCH // §4.3a); this only says the parameter acts on the band centred there. macro_rules! band_params { ($(($key:literal, $hue:literal)),* $(,)?) => { vec![ $( ParamDescriptor::amount( concat!($key, "_hue"), concat!("param.mixer.", $key, ".hue"), ) .faceted(Facet { aspect: LocalizedKey("param.channel.hue"), subject: LocalizedKey(concat!("band.", $key)), subject_hue: Some($hue), }), ParamDescriptor::amount( concat!($key, "_sat"), concat!("param.mixer.", $key, ".sat"), ) .faceted(Facet { aspect: LocalizedKey("param.channel.sat"), subject: LocalizedKey(concat!("band.", $key)), subject_hue: Some($hue), }), ParamDescriptor::amount( concat!($key, "_lum"), concat!("param.mixer.", $key, ".lum"), ) .faceted(Facet { aspect: LocalizedKey("param.channel.lum"), subject: LocalizedKey(concat!("band.", $key)), subject_hue: Some($hue), }), )* ] }; } // A fourth table parallel to `BANDS` and the three uniform-name tables, for // the same reason as those: `concat!` needs literals, so the keys and hues // cannot be read out of `BANDS` here. `facets_match_their_bands` below is // what keeps them from drifting. static DESCRIPTOR: LazyLock> = LazyLock::new(|| { Arc::new(OpDescriptor { attributes: vec![Attribute::Colour], id: ID, label: LocalizedKey("op.colour_mixer"), params: band_params![ ("red", 0.0), ("orange", 30.0), ("yellow", 60.0), ("chartreuse", 90.0), ("green", 120.0), ("spring", 150.0), ("cyan", 180.0), ("azure", 210.0), ("blue", 240.0), ("violet", 270.0), ("magenta", 300.0), ("rose", 330.0), ], }) }); static MIXER_HELPERS: &[Helper] = &[ helpers::LUMINANCE, Helper { name: "rgb_to_hcl", source: "\ // Hue (degrees), chroma, and the max channel, in one pass. // // Not a full HSL conversion: the mixer needs hue to weight the bands and // chroma to know how much colour there is to adjust, and computing lightness // separately from Rec. 709 luminance gives a better-behaved result than // HSL's (max+min)/2. fn rgb_to_hcl(c: vec3) -> vec3 { let hi = max(c.r, max(c.g, c.b)); let lo = min(c.r, min(c.g, c.b)); let chroma = hi - lo; var hue = 0.0; if (chroma > 0.00001) { if (hi == c.r) { // The only branch that can come out negative, hence the wrap. hue = 60.0 * (((c.g - c.b) / chroma) % 6.0); if (hue < 0.0) { hue = hue + 360.0; } } else if (hi == c.g) { hue = 60.0 * (((c.b - c.r) / chroma) + 2.0); } else { hue = 60.0 * (((c.r - c.g) / chroma) + 4.0); } } return vec3(hue, chroma, hi); }", }, Helper { name: "band_weight", source: "\ // How strongly a hue belongs to a band centred at `centre`. // // Cosine falloff over +/-30 degrees — the band spacing — so a band reaches // zero exactly at its neighbours' centres and every hue's twelve weights sum // to one. A narrower window would leave hues between bands weakly covered; a // wider one makes the weights sum to more than one, and then no adjustment // can be applied without scaling it by something that depends on which // *other* bands are set. fn band_weight(hue: f32, centre: f32) -> f32 { // Shortest angular distance, accounting for the wrap at 360. var d = abs(hue - centre); if (d > 180.0) { d = 360.0 - d; } if (d >= 30.0) { return 0.0; } // cos ramp: 1 at the centre, 0 at 30 degrees. return 0.5 + 0.5 * cos(d * 3.14159265 / 30.0); }", }, Helper { name: "hue_to_rgb_scale", source: "\ // Rebuild a colour after shifting its hue, preserving chroma and level. // // Reconstructing from HSV rather than rotating in RGB: an RGB rotation // matrix desaturates as it turns, which is visible as colours going pale // mid-shift. fn hue_to_rgb_scale(hue: f32, chroma: f32, hi: f32) -> vec3 { let h = fract(hue / 360.0) * 6.0; let x = chroma * (1.0 - abs((h % 2.0) - 1.0)); var rgb = vec3(0.0); if (h < 1.0) { rgb = vec3(chroma, x, 0.0); } else if (h < 2.0) { rgb = vec3(x, chroma, 0.0); } else if (h < 3.0) { rgb = vec3(0.0, chroma, x); } else if (h < 4.0) { rgb = vec3(0.0, x, chroma); } else if (h < 5.0) { rgb = vec3(x, 0.0, chroma); } else { rgb = vec3(chroma, 0.0, x); } return rgb + vec3(hi - chroma); }", }, ]; /// Twelve hue bands, each with hue, saturation and luminance. #[derive(Debug, Clone)] pub struct ColourMixer { /// `[band][channel]`, matching [`BANDS`] and [`Channel::ALL`]. values: [[f32; 3]; 12], } impl Default for ColourMixer { fn default() -> Self { Self { values: [[0.0; 3]; 12], } } } impl ColourMixer { pub fn new() -> Self { Self::default() } /// The parameter id for one band and channel. /// /// Ids are `"_"`, matching the descriptors above. fn index_of(id: ParamId) -> Option<(usize, usize)> { let (band, channel) = id.0.rsplit_once('_')?; let b = BANDS.iter().position(|x| x.key == band)?; let c = Channel::ALL.iter().position(|x| x.suffix() == channel)?; Some((b, c)) } /// Whether any band has a non-zero setting. fn any_set(&self) -> bool { self.values.iter().flatten().any(|v| *v != 0.0) } } impl Operation for ColourMixer { fn descriptor(&self) -> Arc { DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { match Self::index_of(id) { Some((b, c)) => self.values[b][c] = value, None => log::warn!("colour_mixer: unknown parameter {id}"), } } fn param(&self, id: ParamId) -> f32 { Self::index_of(id).map_or(0.0, |(b, c)| self.values[b][c]) } fn is_active(&self) -> bool { self.any_set() } fn wgsl_body(&self) -> String { // Only the bands the user actually touched contribute code. A single // adjusted band therefore costs one weight evaluation rather than // twelve — the composition property applied within an operation. let mut lines = String::from( "\ let hcl = rgb_to_hcl(c); let hue = hcl.x; let chroma = hcl.y; let hi = hcl.z; // Achromatic pixels have no hue to match, and adjusting them would tint // neutrals — the most visible way a mixer can go wrong. if (chroma > 0.0001) { var w_total = 0.0; var d_hue = 0.0; var d_sat = 0.0; var d_lum = 0.0; ", ); for (b, band) in BANDS.iter().enumerate() { let v = self.values[b]; if v.iter().all(|x| *x == 0.0) { continue; } let key = band.key; lines.push_str(&format!( "\n // {key}\n {{\n let w = band_weight(hue, {:.1});\n w_total = w_total + w;\n", band.hue )); if v[0] != 0.0 { lines.push_str(&format!(" d_hue = d_hue + w * {key}_hue;\n")); } if v[1] != 0.0 { lines.push_str(&format!(" d_sat = d_sat + w * {key}_sat;\n")); } if v[2] != 0.0 { lines.push_str(&format!(" d_lum = d_lum + w * {key}_lum;\n")); } lines.push_str(" }\n"); } lines.push_str( " // The deltas are used as accumulated: the twelve band weights sum to one // at every hue, so a weighted sum over the adjusted bands is already on // the right scale, and each band contributes independently of the others. // `w_total` only says whether any adjusted band reaches this pixel — // dividing by it would cancel the falloff and couple the bands together. if (w_total > 0.0001) { // Hue: up to 30 degrees at full travel. Enough to move foliage from // yellow-green to green, not enough to turn it blue by accident. let new_hue = hue + d_hue * 30.0; // Saturation scales chroma; luminance scales the whole colour. let new_chroma = clamp(chroma * (1.0 + d_sat), 0.0, hi); c = hue_to_rgb_scale(new_hue, new_chroma, hi); c = c * exp2(d_lum); } } c = max(c, vec3(0.0));", ); lines } fn uniforms(&self) -> Vec { // Only the bands that contributed code declare uniforms, and in the // same order the fragment references them. let mut out = Vec::new(); for b in 0..BANDS.len() { let v = self.values[b]; if v.iter().all(|x| *x == 0.0) { continue; } // Names must match those the fragment emitted. if v[0] != 0.0 { out.push(Uniform { name: HUE_NAMES[b], value: v[0] / 100.0, }); } if v[1] != 0.0 { out.push(Uniform { name: SAT_NAMES[b], value: v[1] / 100.0, }); } if v[2] != 0.0 { out.push(Uniform { name: LUM_NAMES[b], // Up to half a stop per band. value: v[2] / 100.0 * 0.5, }); } } out } fn helpers(&self) -> &'static [Helper] { MIXER_HELPERS } } // Uniform names must be `&'static str`, and they are built from the band // keys. Declared as tables rather than formatted at runtime, so the fragment // and the uniform list cannot disagree. static HUE_NAMES: [&str; 12] = [ "red_hue", "orange_hue", "yellow_hue", "chartreuse_hue", "green_hue", "spring_hue", "cyan_hue", "azure_hue", "blue_hue", "violet_hue", "magenta_hue", "rose_hue", ]; static SAT_NAMES: [&str; 12] = [ "red_sat", "orange_sat", "yellow_sat", "chartreuse_sat", "green_sat", "spring_sat", "cyan_sat", "azure_sat", "blue_sat", "violet_sat", "magenta_sat", "rose_sat", ]; static LUM_NAMES: [&str; 12] = [ "red_lum", "orange_lum", "yellow_lum", "chartreuse_lum", "green_lum", "spring_lum", "cyan_lum", "azure_lum", "blue_lum", "violet_lum", "magenta_lum", "rose_lum", ]; #[cfg(test)] mod tests { use super::*; #[test] fn there_are_twelve_bands_with_thirty_six_parameters() { assert_eq!(BANDS.len(), 12); assert_eq!(DESCRIPTOR.params.len(), 36); } #[test] fn bands_are_evenly_spaced_around_the_wheel() { // Uneven spacing would leave some hues weakly covered, since the // weight window is a fixed 60 degrees. for (i, band) in BANDS.iter().enumerate() { assert!( (band.hue - i as f32 * 30.0).abs() < 1e-6, "{} is at {}, expected {}", band.key, band.hue, i as f32 * 30.0 ); } } #[test] fn every_descriptor_id_resolves_to_a_band_and_channel() { // The link between the descriptor list and the value array. A // mismatch would make a slider silently adjust nothing. for p in &DESCRIPTOR.params { assert!( ColourMixer::index_of(p.id).is_some(), "{} does not map to a band", p.id ); } } #[test] fn every_band_and_channel_has_a_descriptor() { // The reverse direction: a band with no descriptor is unreachable // from the UI. for band in BANDS.iter() { for ch in Channel::ALL { let id = format!("{}_{}", band.key, ch.suffix()); assert!( DESCRIPTOR.params.iter().any(|p| p.id.0 == id), "{id} has no descriptor" ); } } } #[test] fn the_uniform_name_tables_match_the_band_keys() { // Three parallel tables and a band list; if they drift, the fragment // references a uniform that was never declared and the shader fails // to compile. for (i, band) in BANDS.iter().enumerate() { assert_eq!(HUE_NAMES[i], format!("{}_hue", band.key)); assert_eq!(SAT_NAMES[i], format!("{}_sat", band.key)); assert_eq!(LUM_NAMES[i], format!("{}_lum", band.key)); } } #[test] fn facets_match_their_bands() { // The macro's `(key, hue)` list is a fourth table parallel to `BANDS`, // and a hue mistyped there would put a row's swatch on a colour the // band does not act on — a control that lies about what it edits, // which is worse than one with no swatch at all. for p in &DESCRIPTOR.params { let facet = p.facet.expect("every mixer parameter is faceted"); let (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel"); let band = BANDS .iter() .find(|b| b.key == band_key) .expect("the id names a band"); assert_eq!( facet.subject.0, format!("band.{band_key}"), "{} is subject to the wrong band", p.id ); assert_eq!( facet.subject_hue, Some(band.hue), "{} claims a hue its band does not have", p.id ); } } #[test] fn each_channel_is_one_aspect_across_every_band() { // What lets a panel name the run once instead of twelve times: the // twelve hue parameters must agree they are the same control. Were // the aspect keyed per band, grouping by it would produce thirty-six // groups of one and nothing would have been gained. let mut per_aspect = std::collections::BTreeMap::new(); for p in &DESCRIPTOR.params { let facet = p.facet.expect("faceted"); *per_aspect.entry(facet.aspect.0).or_insert(0) += 1; } assert_eq!(per_aspect.len(), Channel::ALL.len()); for (aspect, count) in per_aspect { assert_eq!(count, BANDS.len(), "{aspect} does not cover every band"); } } #[test] fn a_fresh_mixer_is_inactive() { assert!(!ColourMixer::new().is_active()); } #[test] fn setting_any_band_activates_it() { let mut m = ColourMixer::new(); m.set_param(ParamId("blue_sat"), 40.0); assert!(m.is_active()); assert_eq!(m.param(ParamId("blue_sat")), 40.0); } #[test] fn only_adjusted_bands_reach_the_shader() { // The composition property applied within an operation: adjusting // one band must not cost twelve weight evaluations. let mut m = ColourMixer::new(); m.set_param(ParamId("blue_sat"), 40.0); let body = m.wgsl_body(); assert!(body.contains("blue_sat"), "the adjusted band must appear"); assert!(!body.contains("red_sat"), "untouched bands must not"); assert_eq!( body.matches("band_weight(").count(), 1, "one adjusted band means one weight evaluation" ); } #[test] fn only_adjusted_channels_within_a_band_reach_the_shader() { let mut m = ColourMixer::new(); m.set_param(ParamId("green_lum"), -25.0); let body = m.wgsl_body(); assert!(body.contains("green_lum")); assert!(!body.contains("green_hue")); assert!(!body.contains("green_sat")); } #[test] fn the_fragment_and_uniforms_agree_on_names() { // The failure this prevents is a compile error in generated code, // which is far harder to read than a failed assertion here. let mut m = ColourMixer::new(); m.set_param(ParamId("orange_hue"), 20.0); m.set_param(ParamId("orange_sat"), -30.0); m.set_param(ParamId("azure_lum"), 15.0); let body = m.wgsl_body(); for u in m.uniforms() { assert!( body.contains(u.name), "uniform {} is declared but never used", u.name ); } // And nothing referenced without being declared. let declared: Vec<&str> = m.uniforms().iter().map(|u| u.name).collect(); for band in BANDS.iter() { for ch in Channel::ALL { let name = format!("{}_{}", band.key, ch.suffix()); if body.contains(&name) { assert!( declared.contains(&name.as_str()), "{name} is used but not declared" ); } } } } #[test] fn achromatic_pixels_are_excluded() { // Adjusting a hue-less pixel would tint neutrals, which is the most // visible way a mixer misbehaves. let mut m = ColourMixer::new(); m.set_param(ParamId("red_sat"), 50.0); assert!(m.wgsl_body().contains("chroma > 0.0001")); } #[test] fn the_deltas_are_not_scaled_by_the_adjusted_bands_weight() { // The bug this closes: dividing each delta by the summed weight of // the bands that happen to be adjusted made the channels exclusive. // One band divided by its own weight, cancelling the falloff; two // bands split a fixed budget, so raising one colour's saturation cut // its neighbour's hue shift. `band_weights_sum_to_one` is why no // scaling is needed at all. let mut m = ColourMixer::new(); m.set_param(ParamId("red_hue"), 50.0); m.set_param(ParamId("orange_sat"), 50.0); let body = m.wgsl_body(); for delta in ["d_hue", "d_sat", "d_lum"] { assert!( !body.contains(&format!("{delta} / w_total")), "{delta} is scaled by the adjusted bands' weight" ); } // Still guarded, so a pixel no adjusted band reaches is left alone. assert!(body.contains("w_total > 0.0001")); } #[test] fn two_bands_are_two_shaders() { // The bug this closes: the shader cache was keyed on the set of // active operations, and this operation is active whichever band is // set. A red adjustment and a blue one therefore shared a compiled // pipeline — the first one to compile — and the second was rendered // with the first's code while its uniform was uploaded into the // first's slot. Every band but the one compiled first appeared to do // nothing, and moving its slider moved the other band's colour. // // Asserted here rather than only in `operation.rs` because this is // the operation that generates per-value code, and so the one whose // shaders must not collide. let compose_with = |id, v| { let mut m = ColourMixer::new(); m.set_param(ParamId(id), v); let ops: Vec> = vec![Box::new(m)]; crate::operation::compose(&ops) }; let red = compose_with("red_sat", 60.0); let blue = compose_with("blue_sat", 60.0); assert_ne!( red.source, blue.source, "two bands emit different code, which is the premise" ); assert_ne!( red.structure_hash, blue.structure_hash, "two bands must not share a compiled pipeline" ); // The same band at a different setting is the same shader, which is // what keeps a slider drag from recompiling once per frame. let red_harder = compose_with("red_sat", 90.0); assert_eq!(red.structure_hash, red_harder.structure_hash); assert_ne!(red.uniforms, red_harder.uniforms); } #[test] fn every_band_and_channel_is_its_own_shader() { // All thirty-six, because the collision above was not special to red // and blue: any two settings that generate different code and hash // alike put one of them on the other's pipeline. let mut seen = std::collections::HashMap::new(); for band in BANDS.iter() { for ch in Channel::ALL { let id = format!("{}_{}", band.key, ch.suffix()); let mut m = ColourMixer::new(); m.set_param(ParamId(Box::leak(id.clone().into_boxed_str())), 50.0); let ops: Vec> = vec![Box::new(m)]; let hash = crate::operation::compose(&ops).structure_hash; if let Some(other) = seen.insert(hash, id.clone()) { panic!("{id} and {other} share a pipeline"); } } } } #[test] fn band_weights_sum_to_one_at_every_hue() { // The property the shader relies on to add band deltas unscaled, and // the one that ties the falloff window to the band spacing: widen or // narrow `band_weight`'s window without moving the centres and this // fails, which is the point — the sum would no longer be one and // every adjustment would come out over- or under-strength. // // A mirror of the WGSL helper. It is nine lines, and the alternative // is asserting nothing about the arithmetic that matters most here. fn band_weight(hue: f32, centre: f32) -> f32 { let mut d = (hue - centre).abs(); if d > 180.0 { d = 360.0 - d; } if d >= 30.0 { return 0.0; } 0.5 + 0.5 * (d * std::f32::consts::PI / 30.0).cos() } for step in 0..3600 { let hue = step as f32 / 10.0; let sum: f32 = BANDS.iter().map(|b| band_weight(hue, b.hue)).sum(); assert!( (sum - 1.0).abs() < 1e-5, "weights at {hue} degrees sum to {sum}" ); } } #[test] fn the_falloff_window_is_the_band_spacing() { // The Rust mirror above only proves the sum for the window it copies; // this is what keeps the copy honest about the shader's own numbers. let src = MIXER_HELPERS .iter() .find(|h| h.name == "band_weight") .expect("the helper exists") .source; assert!(src.contains("d >= 30.0"), "the window is not +/-30 degrees"); assert!( src.contains("/ 30.0"), "the cos ramp is not over 30 degrees" ); } #[test] fn a_bands_channels_are_independent_of_its_neighbours() { // Two adjacent bands, one adjusted for hue and one for saturation. // Each must emit its own weighted term and nothing that mixes them. let mut m = ColourMixer::new(); m.set_param(ParamId("red_hue"), 50.0); m.set_param(ParamId("orange_sat"), 50.0); let body = m.wgsl_body(); assert!(body.contains("d_hue = d_hue + w * red_hue")); assert!(body.contains("d_sat = d_sat + w * orange_sat")); assert!(!body.contains("red_sat"), "red's saturation is untouched"); assert!(!body.contains("orange_hue"), "orange's hue is untouched"); } #[test] fn unknown_parameters_are_ignored() { let mut m = ColourMixer::new(); m.set_param(ParamId("puce_sat"), 50.0); m.set_param(ParamId("malformed"), 50.0); assert!(!m.is_active()); } #[test] fn luminance_travel_is_bounded_to_half_a_stop() { let mut m = ColourMixer::new(); m.set_param(ParamId("blue_lum"), 100.0); let v = m.uniforms()[0].value; assert!((v - 0.5).abs() < 1e-6, "got {v}"); } }