diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs new file mode 100644 index 000000000..691c17d96 --- /dev/null +++ b/crates/develop/src/controls.rs @@ -0,0 +1,271 @@ +//! 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`). + +use serde::Serialize; + +use crate::settings::DevelopSettings; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Section { + Light, + Curve, + Color, + Mixer, + BwMix, + Grading, + Effects, + Vignette, + Grain, + Detail, + Optics, + Geometry, + Profile, +} + +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", + } + } +} + +/// 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 } + } +} + +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 { + match id { + $( $id => Some(s.$($field).+ as f64), )* + _ => None, + } + } + + /// 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 } )* + _ => false, + } + } + }; +} + +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; + "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; + "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; + "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.defringeGreen" => optics.defringe_green_amount, "Green Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40c040" }; + "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; + "crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered; +} + +pub fn find(id: &str) -> Option<&'static ControlSpec> { + CONTROLS.iter().find(|c| c.id == id) +} + +pub fn in_section(section: Section) -> impl Iterator { + 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 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"); + } +} diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs new file mode 100644 index 000000000..69888e527 --- /dev/null +++ b/crates/develop/src/lib.rs @@ -0,0 +1,166 @@ +//! The non-destructive edit model of LightCraft. +//! +//! [`DevelopSettings`] is the complete description of a photo's look: pure, serializable data. +//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history +//! are operations on it. Numeric sliders are addressed by id through [`controls`]. +#![forbid(unsafe_code)] + +pub mod controls; +pub mod presets; +pub mod settings; + +pub use controls::{CONTROLS, ControlSpec, Section, Track}; +pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups}; +pub use settings::*; + +use serde_json::Value; + +impl DevelopSettings { + /// Settings for a freshly imported raw file (Lightroom applies capture sharpening and colour + /// noise reduction by default to raw files). + pub fn for_raw(as_shot_temp: f64, as_shot_tint: f64) -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.wb.temp = as_shot_temp; + s.wb.tint = as_shot_tint; + s.detail.sharpen_amount = 40.0; + s.detail.nr_color = 25.0; + s + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } + + pub fn from_json(v: &Value) -> Result { + serde_json::from_value(v.clone()) + } + + /// Merge a partial JSON object into these settings (fields not mentioned are kept). + pub fn merged(&self, partial: &Value) -> Result { + let mut v = self.to_json(); + presets::deep_merge(&mut v, partial); + DevelopSettings::from_json(&v) + } + + /// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys. + pub fn hash64(&self) -> u64 { + let s = serde_json::to_string(self).unwrap_or_default(); + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + h + } + + /// True if nothing differs from a fresh default (ignoring white balance "as shot" values). + pub fn is_unedited(&self) -> bool { + let mut a = self.clone(); + let d = DevelopSettings::default(); + a.wb = d.wb; + a == d + } + + pub fn section_enabled(&self, section: &str) -> bool { + !self.disabled_sections.iter().any(|s| s == section) + } + + pub fn set_section_enabled(&mut self, section: &str, on: bool) { + self.disabled_sections.retain(|s| s != section); + if !on { + self.disabled_sections.push(section.to_string()); + } + } + + /// Reset every control in `section` to its default. + pub fn reset_section(&mut self, section: Section) { + for c in controls::in_section(section) { + controls::set(self, c.id, c.default); + } + match section { + Section::Curve => { + let d = ToneCurve::default(); + self.curve = d; + } + Section::Color => self.wb.mode = WbMode::AsShot, + _ => {} + } + } + + /// Next free mask id. + pub fn next_mask_id(&self) -> u32 { + self.masks.iter().map(|m| m.id).max().map_or(1, |m| m + 1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn serde_roundtrip_default_and_edited() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "light.exposure", 1.25); + s.masks.push(Mask { + id: 1, + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Radial { + center: lightcraft_geom::Point::new(0.5, 0.5), + rx: 0.2, + ry: 0.1, + angle: 10.0, + feather: 50.0, + invert: false, + }, + }], + ..Default::default() + }); + let v = s.to_json(); + let back = DevelopSettings::from_json(&v).unwrap(); + assert_eq!(back, s); + assert_ne!(back.hash64(), DevelopSettings::default().hash64()); + } + + #[test] + fn missing_fields_take_defaults_and_unknown_ignored() { + let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap(); + assert_eq!(s.light.exposure, 0.5); + assert_eq!(s.grading.blending, 50.0); + } + + #[test] + fn merge_partial() { + let s = DevelopSettings::default(); + let m = s.merged(&json!({"light": {"contrast": 20}, "effects": {"clarity": 10}})).unwrap(); + assert_eq!(m.light.contrast, 20.0); + assert_eq!(m.effects.clarity, 10.0); + assert_eq!(m.light.exposure, 0.0); + } + + #[test] + fn unedited_and_reset() { + let mut s = DevelopSettings::default(); + s.wb.temp = 5000.0; + assert!(s.is_unedited()); + s.light.exposure = 1.0; + s.light.shadows = 30.0; + assert!(!s.is_unedited()); + s.reset_section(Section::Light); + assert!(s.is_unedited()); + } + + #[test] + fn section_toggles() { + let mut s = DevelopSettings::default(); + assert!(s.section_enabled("effects")); + s.set_section_enabled("effects", false); + s.set_section_enabled("effects", false); + assert!(!s.section_enabled("effects")); + assert_eq!(s.disabled_sections.len(), 1); + s.set_section_enabled("effects", true); + assert!(s.section_enabled("effects")); + } +} diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs new file mode 100644 index 000000000..349d700b6 --- /dev/null +++ b/crates/develop/src/presets.rs @@ -0,0 +1,251 @@ +//! Presets and copy/paste subsets. +//! +//! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it +//! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current +//! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]). + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::DevelopSettings; + +/// Groups shown in "Choose settings to copy" / "Create preset" (Lightroom's checklist). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SettingsGroup { + Profile, + Treatment, + WhiteBalance, + Light, + ToneCurve, + Color, + ColorMixer, + ColorGrading, + Effects, + Vignette, + Grain, + Detail, + Optics, + Geometry, + Crop, + Masks, + Spots, + RedEye, + LensBlur, +} + +impl SettingsGroup { + pub const ALL: [SettingsGroup; 19] = [ + SettingsGroup::Profile, + SettingsGroup::Treatment, + SettingsGroup::WhiteBalance, + SettingsGroup::Light, + SettingsGroup::ToneCurve, + SettingsGroup::Color, + SettingsGroup::ColorMixer, + SettingsGroup::ColorGrading, + SettingsGroup::Effects, + SettingsGroup::Vignette, + SettingsGroup::Grain, + SettingsGroup::Detail, + SettingsGroup::Optics, + SettingsGroup::Geometry, + SettingsGroup::Crop, + SettingsGroup::Masks, + SettingsGroup::Spots, + SettingsGroup::RedEye, + SettingsGroup::LensBlur, + ]; + + /// Groups included by default when copying (Lightroom excludes crop, masks and spots by default). + pub fn default_copy() -> Vec { + SettingsGroup::ALL + .into_iter() + .filter(|g| !matches!(g, SettingsGroup::Crop | SettingsGroup::Masks | SettingsGroup::Spots | SettingsGroup::RedEye)) + .collect() + } + + pub fn label(self) -> &'static str { + match self { + SettingsGroup::Profile => "Profile", + SettingsGroup::Treatment => "Treatment", + SettingsGroup::WhiteBalance => "White Balance", + SettingsGroup::Light => "Light", + SettingsGroup::ToneCurve => "Tone Curve", + SettingsGroup::Color => "Color", + SettingsGroup::ColorMixer => "Color Mixer", + SettingsGroup::ColorGrading => "Color Grading", + SettingsGroup::Effects => "Effects", + SettingsGroup::Vignette => "Vignette", + SettingsGroup::Grain => "Grain", + SettingsGroup::Detail => "Detail", + SettingsGroup::Optics => "Optics", + SettingsGroup::Geometry => "Geometry", + SettingsGroup::Crop => "Crop", + SettingsGroup::Masks => "Masking", + SettingsGroup::Spots => "Remove", + SettingsGroup::RedEye => "Red Eye", + SettingsGroup::LensBlur => "Lens Blur", + } + } + + /// Top-level JSON keys of `DevelopSettings` that belong to the group. + pub fn keys(self) -> &'static [&'static str] { + match self { + SettingsGroup::Profile => &["profile"], + SettingsGroup::Treatment => &["treatment"], + SettingsGroup::WhiteBalance => &["wb"], + SettingsGroup::Light => &["light"], + SettingsGroup::ToneCurve => &["curve"], + SettingsGroup::Color => &["color"], + SettingsGroup::ColorMixer => &["mixer", "bw_mix"], + SettingsGroup::ColorGrading => &["grading"], + SettingsGroup::Effects => &["effects"], + SettingsGroup::Vignette => &["vignette"], + SettingsGroup::Grain => &["grain"], + SettingsGroup::Detail => &["detail", "enhance"], + SettingsGroup::Optics => &["optics"], + SettingsGroup::Geometry => &["geometry"], + SettingsGroup::Crop => &["crop", "orientation"], + SettingsGroup::Masks => &["masks"], + SettingsGroup::Spots => &["spots"], + SettingsGroup::RedEye => &["red_eye"], + SettingsGroup::LensBlur => &["lens_blur"], + } + } +} + +/// The subset of `s` covering `groups`, as a partial JSON object. +pub fn extract_groups(s: &DevelopSettings, groups: &[SettingsGroup]) -> Value { + let full = s.to_json(); + let mut out = Map::new(); + for g in groups { + for k in g.keys() { + if let Some(v) = full.get(*k) { + out.insert((*k).to_string(), v.clone()); + } + } + } + Value::Object(out) +} + +/// Recursively merge `patch` into `base` (objects merge; everything else replaces). +pub fn deep_merge(base: &mut Value, patch: &Value) { + match (base, patch) { + (Value::Object(b), Value::Object(p)) => { + for (k, v) in p { + match b.get_mut(k) { + Some(bv) if bv.is_object() && v.is_object() => deep_merge(bv, v), + _ => { + b.insert(k.clone(), v.clone()); + } + } + } + } + (b, p) => *b = p.clone(), + } +} + +/// Interpolate numbers in `patch` towards/away from `base` by `t` (1 = patch as-is); non-numeric +/// values are taken from the patch when `t >= 0.5`. +fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { + match (base, patch) { + (Value::Object(b), Value::Object(p)) => { + let mut out = Map::new(); + for (k, pv) in p { + let bv = b.get(k).unwrap_or(&Value::Null); + out.insert(k.clone(), scale_patch(bv, pv, t)); + } + Value::Object(out) + } + (Value::Number(b), Value::Number(p)) => { + let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0)); + serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null) + } + (b, p) => { + if t >= 0.5 { + p.clone() + } else { + b.clone() + } + } + } +} + +/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped +/// through the control specs. +pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { + let base = s.to_json(); + let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) }; + let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone()); + for c in crate::controls::CONTROLS { + if let Some(v) = crate::controls::get(&out, c.id) { + crate::controls::set(&mut out, c.id, v); + } + } + out +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Preset { + pub id: String, + pub name: String, + pub group: String, + /// Partial settings. + pub settings: Value, + #[serde(default)] + pub favorite: bool, + /// Built-in presets ship with the app and can't be deleted. + #[serde(default)] + pub builtin: bool, +} + +impl Preset { + pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset { + Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false } + } + pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings { + apply_partial(s, &self.settings, amount) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extract_and_apply_roundtrip() { + let mut a = DevelopSettings::default(); + a.light.exposure = 1.0; + a.effects.clarity = 30.0; + a.crop.geometry.angle = 5.0; + let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy()); + assert!(p.settings.get("crop").is_none()); + let b = p.apply(&DevelopSettings::default(), 1.0); + assert_eq!(b.light.exposure, 1.0); + assert_eq!(b.effects.clarity, 30.0); + assert_eq!(b.crop.geometry.angle, 0.0); + } + + #[test] + fn amount_scales_numbers() { + let base = DevelopSettings::default(); + let partial = json!({"light": {"exposure": 1.0, "contrast": 40}}); + let half = apply_partial(&base, &partial, 0.5); + assert!((half.light.exposure - 0.5).abs() < 1e-9); + assert!((half.light.contrast - 20.0).abs() < 1e-9); + let more = apply_partial(&base, &partial, 2.0); + assert!((more.light.contrast - 80.0).abs() < 1e-9); + // clamped through specs + let lots = apply_partial(&base, &json!({"light": {"exposure": 4.0}}), 2.0); + assert_eq!(lots.light.exposure, 5.0); + } + + #[test] + fn deep_merge_objects() { + let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]}); + deep_merge(&mut a, &json!({"x": {"b": 3}, "y": [2, 3]})); + assert_eq!(a, json!({"x": {"a": 1, "b": 3}, "y": [2, 3]})); + } +} diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs new file mode 100644 index 000000000..2574c1f21 --- /dev/null +++ b/crates/develop/src/settings.rs @@ -0,0 +1,736 @@ +//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so +//! older/newer files load (unknown fields are ignored, missing fields take defaults). + +use lightcraft_geom::{CropGeometry, Orientation, Point}; +use serde::{Deserialize, Serialize}; + +pub const SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DevelopSettings { + pub version: u32, + pub profile: Profile, + pub treatment: Treatment, + pub wb: WhiteBalance, + pub light: Light, + pub curve: ToneCurve, + pub color: ColorAdj, + pub mixer: Mixer, + pub bw_mix: BwMix, + pub grading: ColorGrading, + pub effects: Effects, + pub vignette: Vignette, + pub grain: Grain, + pub detail: Detail, + pub optics: Optics, + pub geometry: Geometry, + pub crop: Crop, + pub orientation: Orientation, + pub masks: Vec, + pub spots: Vec, + pub red_eye: Vec, + pub lens_blur: LensBlur, + pub enhance: Enhance, + /// Section on/off toggles (the "eye" buttons on panel headers): section id → enabled. + pub disabled_sections: Vec, +} + +impl Default for DevelopSettings { + fn default() -> Self { + Self { + version: SCHEMA_VERSION, + profile: Profile::default(), + treatment: Treatment::Color, + wb: WhiteBalance::default(), + light: Light::default(), + curve: ToneCurve::default(), + color: ColorAdj::default(), + mixer: Mixer::default(), + bw_mix: BwMix::default(), + grading: ColorGrading::default(), + effects: Effects::default(), + vignette: Vignette::default(), + grain: Grain::default(), + detail: Detail::default(), + optics: Optics::default(), + geometry: Geometry::default(), + crop: Crop::default(), + orientation: Orientation::Normal, + masks: Vec::new(), + spots: Vec::new(), + red_eye: Vec::new(), + lens_blur: LensBlur::default(), + enhance: Enhance::default(), + disabled_sections: Vec::new(), + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Treatment { + #[default] + Color, + Bw, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Profile { + /// Profile id from the profile registry (e.g. `lc.color`, `lc.vivid`, `lc.mono`). + pub id: String, + /// Creative profile amount 0..200 (%). + pub amount: f64, +} + +impl Default for Profile { + fn default() -> Self { + Self { id: "lc.color".into(), amount: 100.0 } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WbMode { + #[default] + AsShot, + Auto, + Daylight, + Cloudy, + Shade, + Tungsten, + Fluorescent, + Flash, + Custom, +} + +impl WbMode { + pub const ALL: [WbMode; 9] = [ + WbMode::AsShot, + WbMode::Auto, + WbMode::Daylight, + WbMode::Cloudy, + WbMode::Shade, + WbMode::Tungsten, + WbMode::Fluorescent, + WbMode::Flash, + WbMode::Custom, + ]; + pub fn label(self) -> &'static str { + match self { + WbMode::AsShot => "As Shot", + WbMode::Auto => "Auto", + WbMode::Daylight => "Daylight", + WbMode::Cloudy => "Cloudy", + WbMode::Shade => "Shade", + WbMode::Tungsten => "Tungsten", + WbMode::Fluorescent => "Fluorescent", + WbMode::Flash => "Flash", + WbMode::Custom => "Custom", + } + } + /// Preset temperature/tint for raw files (K, tint). + pub fn preset(self) -> Option<(f64, f64)> { + match self { + WbMode::Daylight => Some((5500.0, 10.0)), + WbMode::Cloudy => Some((6500.0, 10.0)), + WbMode::Shade => Some((7500.0, 10.0)), + WbMode::Tungsten => Some((2850.0, 0.0)), + WbMode::Fluorescent => Some((3800.0, 21.0)), + WbMode::Flash => Some((5500.0, 0.0)), + _ => None, + } + } +} + +/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files +/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an +/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct WhiteBalance { + pub mode: WbMode, + pub temp: f64, + pub tint: f64, +} + +impl Default for WhiteBalance { + fn default() -> Self { + Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Light { + pub exposure: f64, + pub contrast: f64, + pub highlights: f64, + pub shadows: f64, + pub whites: f64, + pub blacks: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ToneCurve { + pub highlights: f64, + pub lights: f64, + pub darks: f64, + pub shadows: f64, + /// Region split points (percent), Lightroom defaults 25/50/75. + pub split_shadows: f64, + pub split_mid: f64, + pub split_highlights: f64, + /// Point curves in 0..1 (identity = empty or [(0,0),(1,1)]). + pub master: Vec, + pub red: Vec, + pub green: Vec, + pub blue: Vec, +} + +impl Default for ToneCurve { + fn default() -> Self { + Self { + highlights: 0.0, + lights: 0.0, + darks: 0.0, + shadows: 0.0, + split_shadows: 25.0, + split_mid: 50.0, + split_highlights: 75.0, + master: Vec::new(), + red: Vec::new(), + green: Vec::new(), + blue: Vec::new(), + } + } +} + +impl ToneCurve { + pub fn point_curve_is_identity(pts: &[Point]) -> bool { + pts.is_empty() || pts.iter().all(|p| (p.x - p.y).abs() < 1e-9) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ColorAdj { + pub vibrance: f64, + pub saturation: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Hsl { + pub hue: f64, + pub sat: f64, + pub lum: f64, +} + +/// The 8 colour-mixer bands in Lightroom order. +pub const MIXER_BANDS: [&str; 8] = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "magenta"]; +/// Centre hue of each band in degrees (sRGB-ish HSV hue). +pub const MIXER_HUES: [f64; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 225.0, 270.0, 315.0]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Mixer { + pub red: Hsl, + pub orange: Hsl, + pub yellow: Hsl, + pub green: Hsl, + pub aqua: Hsl, + pub blue: Hsl, + pub purple: Hsl, + pub magenta: Hsl, +} + +impl Mixer { + pub fn bands(&self) -> [Hsl; 8] { + [self.red, self.orange, self.yellow, self.green, self.aqua, self.blue, self.purple, self.magenta] + } + pub fn is_neutral(&self) -> bool { + self.bands().iter().all(|b| *b == Hsl::default()) + } +} + +/// B&W mix (used when treatment = Bw): per-band luminance contribution −100..100. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BwMix { + pub red: f64, + pub orange: f64, + pub yellow: f64, + pub green: f64, + pub aqua: f64, + pub blue: f64, + pub purple: f64, + pub magenta: f64, +} + +impl BwMix { + pub fn bands(&self) -> [f64; 8] { + [self.red, self.orange, self.yellow, self.green, self.aqua, self.blue, self.purple, self.magenta] + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Wheel { + /// Hue in degrees 0..360. + pub hue: f64, + /// Saturation 0..100. + pub sat: f64, + /// Luminance −100..100. + pub lum: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ColorGrading { + pub shadows: Wheel, + pub midtones: Wheel, + pub highlights: Wheel, + pub global: Wheel, + /// 0..100, default 50. + pub blending: f64, + /// −100..100. + pub balance: f64, +} + +impl Default for ColorGrading { + fn default() -> Self { + Self { + shadows: Wheel::default(), + midtones: Wheel::default(), + highlights: Wheel::default(), + global: Wheel::default(), + blending: 50.0, + balance: 0.0, + } + } +} + +impl ColorGrading { + pub fn is_neutral(&self) -> bool { + [self.shadows, self.midtones, self.highlights, self.global].iter().all(|w| w.sat == 0.0 && w.lum == 0.0) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Effects { + pub texture: f64, + pub clarity: f64, + pub dehaze: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VignetteStyle { + #[default] + HighlightPriority, + ColorPriority, + PaintOverlay, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Vignette { + pub amount: f64, + pub midpoint: f64, + pub roundness: f64, + pub feather: f64, + pub highlights: f64, + pub style: VignetteStyle, +} + +impl Default for Vignette { + fn default() -> Self { + Self { amount: 0.0, midpoint: 50.0, roundness: 0.0, feather: 50.0, highlights: 0.0, style: VignetteStyle::HighlightPriority } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Grain { + pub amount: f64, + pub size: f64, + pub roughness: f64, + /// Seed for the procedural grain (stable per photo). + pub seed: u32, +} + +impl Default for Grain { + fn default() -> Self { + Self { amount: 0.0, size: 25.0, roughness: 50.0, seed: 0 } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Detail { + pub sharpen_amount: f64, + pub sharpen_radius: f64, + pub sharpen_detail: f64, + pub sharpen_masking: f64, + pub nr_luminance: f64, + pub nr_detail: f64, + pub nr_contrast: f64, + pub nr_color: f64, + pub nr_color_detail: f64, + pub nr_color_smoothness: f64, +} + +impl Default for Detail { + /// Lightroom's raw defaults are sharpening 40/1.0/25/0 and colour NR 25/50/50; we use neutral + /// values for rendered files and apply raw defaults at import time (`DevelopSettings::for_raw`). + fn default() -> Self { + Self { + sharpen_amount: 0.0, + sharpen_radius: 1.0, + sharpen_detail: 25.0, + sharpen_masking: 0.0, + nr_luminance: 0.0, + nr_detail: 50.0, + nr_contrast: 0.0, + nr_color: 0.0, + nr_color_detail: 50.0, + nr_color_smoothness: 50.0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Optics { + pub remove_ca: bool, + pub lens_profile: bool, + pub profile_distortion: f64, + pub profile_vignetting: f64, + pub defringe_purple_amount: f64, + pub defringe_purple_hue_lo: f64, + pub defringe_purple_hue_hi: f64, + pub defringe_green_amount: f64, + pub defringe_green_hue_lo: f64, + pub defringe_green_hue_hi: f64, + pub distortion: f64, + pub vignetting: f64, + pub vignetting_midpoint: f64, +} + +impl Default for Optics { + fn default() -> Self { + Self { + remove_ca: false, + lens_profile: false, + profile_distortion: 100.0, + profile_vignetting: 100.0, + defringe_purple_amount: 0.0, + defringe_purple_hue_lo: 30.0, + defringe_purple_hue_hi: 70.0, + defringe_green_amount: 0.0, + defringe_green_hue_lo: 40.0, + defringe_green_hue_hi: 60.0, + distortion: 0.0, + vignetting: 0.0, + vignetting_midpoint: 50.0, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Upright { + #[default] + Off, + Auto, + Guided, + Level, + Vertical, + Full, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Geometry { + pub upright: Upright, + /// Guided Upright guides (normalized image coords), up to 4 line segments. + pub guides: Vec<(Point, Point)>, + pub vertical: f64, + pub horizontal: f64, + pub rotate: f64, + pub aspect: f64, + pub scale: f64, + pub offset_x: f64, + pub offset_y: f64, + pub constrain_crop: bool, +} + +impl Default for Geometry { + fn default() -> Self { + Self { + upright: Upright::Off, + guides: Vec::new(), + vertical: 0.0, + horizontal: 0.0, + rotate: 0.0, + aspect: 0.0, + scale: 100.0, + offset_x: 0.0, + offset_y: 0.0, + constrain_crop: false, + } + } +} + +impl Geometry { + pub fn is_identity(&self) -> bool { + self.upright == Upright::Off + && self.vertical == 0.0 + && self.horizontal == 0.0 + && self.rotate == 0.0 + && self.aspect == 0.0 + && self.scale == 100.0 + && self.offset_x == 0.0 + && self.offset_y == 0.0 + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Crop { + pub geometry: CropGeometry, + /// Locked aspect (w, h); None = free / original. + pub aspect: Option<(u32, u32)>, + pub flip_h: bool, + pub flip_v: bool, +} + +// --------------------------------------------------------------------------------------------- +// Masks and local adjustments + +/// The sliders available inside a mask (Lightroom's local adjustments). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LocalAdjustments { + pub temp: f64, + pub tint: f64, + pub exposure: f64, + pub contrast: f64, + pub highlights: f64, + pub shadows: f64, + pub whites: f64, + pub blacks: f64, + pub texture: f64, + pub clarity: f64, + pub dehaze: f64, + pub hue: f64, + pub saturation: f64, + pub sharpness: f64, + pub noise: f64, + pub moire: f64, + pub defringe: f64, + /// Colour overlay (hue degrees, saturation 0..100); sat 0 = none. + pub color_hue: f64, + pub color_sat: f64, + /// Local vignette/grain are not in Lightroom; we keep the struct extensible. + pub amount: f64, +} + +impl Default for LocalAdjustments { + fn default() -> Self { + Self { + temp: 0.0, + tint: 0.0, + exposure: 0.0, + contrast: 0.0, + highlights: 0.0, + shadows: 0.0, + whites: 0.0, + blacks: 0.0, + texture: 0.0, + clarity: 0.0, + dehaze: 0.0, + hue: 0.0, + saturation: 0.0, + sharpness: 0.0, + noise: 0.0, + moire: 0.0, + defringe: 0.0, + color_hue: 0.0, + color_sat: 0.0, + amount: 100.0, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MaskOp { + #[default] + Add, + Subtract, + Intersect, +} + +/// One brush dab sequence. Coordinates normalized to the uncropped oriented image. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BrushStroke { + pub points: Vec, + /// Radius as a fraction of the image's long edge. + pub size: f64, + /// 0..100. + pub feather: f64, + pub flow: f64, + pub density: f64, + pub erase: bool, + pub auto_mask: bool, +} + +impl Default for BrushStroke { + fn default() -> Self { + Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum MaskShape { + Brush { + strokes: Vec, + }, + /// Linear gradient: full effect at `start`, fading to none at `end` (normalized coords). + Linear { + start: Point, + end: Point, + }, + /// Radial gradient: ellipse centre, radii (normalized to the long edge), rotation (deg), feather 0..100. + Radial { + center: Point, + rx: f64, + ry: f64, + angle: f64, + feather: f64, + invert: bool, + }, + /// Colour range: sampled OkLab colours with a refine amount 0..100. + ColorRange { + samples: Vec<[f64; 3]>, + refine: f64, + }, + /// Luminance range (0..1 bounds with feathered falloff 0..1). + LuminanceRange { + lo: f64, + hi: f64, + lo_feather: f64, + hi_feather: f64, + }, + DepthRange { + lo: f64, + hi: f64, + feather: f64, + }, + /// AI / automatic selections, evaluated by a segmenter. + Subject, + Sky, + Background, + Object { + hint: Vec, + }, + People { + person: u32, + parts: Vec, + }, + Landscape { + class: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MaskComponent { + #[serde(default)] + pub op: MaskOp, + #[serde(default)] + pub invert: bool, + pub shape: MaskShape, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Mask { + pub id: u32, + pub name: String, + pub visible: bool, + pub invert: bool, + pub components: Vec, + pub adjust: LocalAdjustments, +} + +impl Default for Mask { + fn default() -> Self { + Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() } + } +} + +// --------------------------------------------------------------------------------------------- +// Heal / remove / red eye + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SpotMode { + #[default] + Remove, + Heal, + Clone, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Spot { + pub mode: SpotMode, + /// Target: a single point (circle spot) or a brushed path. Normalized coords. + pub points: Vec, + /// Radius as a fraction of the long edge. + pub size: f64, + pub feather: f64, + pub opacity: f64, + /// Source offset (normalized) from the target; None = auto (content-aware remove). + pub source_offset: Option, +} + +impl Default for Spot { + fn default() -> Self { + Self { mode: SpotMode::Remove, points: Vec::new(), size: 0.02, feather: 50.0, opacity: 100.0, source_offset: None } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct RedEye { + pub center: Point, + pub rx: f64, + pub ry: f64, + pub pupil_size: f64, + pub darken: f64, + pub pet: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LensBlur { + pub enabled: bool, + pub amount: f64, + pub focal_distance: f64, + pub focal_range: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Enhance { + pub denoise: f64, + pub raw_details: bool, + pub super_resolution: bool, +} diff --git a/crates/scenes/Cargo.toml b/crates/scenes/Cargo.toml new file mode 100644 index 000000000..ae1c9cc01 --- /dev/null +++ b/crates/scenes/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lightcraft-scenes" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Procedurally generated photographic demo scenes (HDR, linear Rec.2020) for LightCraft demos and tests (L1)" + +[lints] +workspace = true + +[dependencies] +lightcraft-color = { workspace = true } +lightcraft-raster = { workspace = true } +serde = { workspace = true } +rayon = { workspace = true } + +[dev-dependencies] +image = { workspace = true } diff --git a/crates/scenes/examples/contact_sheet.rs b/crates/scenes/examples/contact_sheet.rs new file mode 100644 index 000000000..594022714 --- /dev/null +++ b/crates/scenes/examples/contact_sheet.rs @@ -0,0 +1,29 @@ +//! Render every demo scene into a contact sheet PNG (simple clamp + sRGB encode, no develop). +//! `cargo run --release -p lightcraft-scenes --example contact_sheet -- out.png` +use lightcraft_color::{REC2020, SRGB, transfer::encode_srgb8}; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "contact.png".into()); + let lib = lightcraft_scenes::demo_library(); + let (cw, ch, cols) = (360usize, 240usize, 6usize); + let rows = lib.len().div_ceil(cols); + let mut sheet = image::RgbImage::new((cw * cols) as u32, (ch * rows) as u32); + let m = REC2020.to_space(&SRGB); + for (i, s) in lib.iter().enumerate() { + let img = s.render_fit(cw.min(ch * 3 / 2).max(ch)); + let (ox, oy) = ((i % cols) * cw + (cw - img.width.min(cw)) / 2, (i / cols) * ch + (ch - img.height.min(ch)) / 2); + for y in 0..img.height.min(ch) { + for x in 0..img.width.min(cw) { + let c = m.apply_f32(img.get(x, y)); + // simple Reinhard-ish shoulder for preview + let t = |v: f32| { + let v = v.max(0.0); + encode_srgb8(v / (1.0 + v * 0.35)) + }; + sheet.put_pixel((ox + x) as u32, (oy + y) as u32, image::Rgb([t(c[0]), t(c[1]), t(c[2])])); + } + } + } + sheet.save(&out).unwrap(); + println!("{out}"); +} diff --git a/crates/scenes/src/lib.rs b/crates/scenes/src/lib.rs new file mode 100644 index 000000000..69d80eb70 --- /dev/null +++ b/crates/scenes/src/lib.rs @@ -0,0 +1,214 @@ +//! Procedurally generated, photographic-looking demo scenes. +//! +//! Scenes are resolution independent (`render(w, h)` evaluates a continuous function) and return +//! scene-referred linear Rec.2020 radiance with real highlight headroom (the sun is ≫ 1.0), so the +//! develop pipeline's highlight recovery, tone mapping and colour tools have something real to do. +//! Every scene is deterministic for its seed. No external assets: zero licensing or privacy risk. +#![forbid(unsafe_code)] + +mod noise; +mod paint; + +use lightcraft_raster::Rgb32f; +use serde::Serialize; + +pub use paint::render_srgb_linear; + +/// Kinds of scene the generator knows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Kind { + AlpineLake, + Dunes, + OceanSunset, + Aurora, + MistyForest, + Lavender, + Macro, + Canyon, + Beach, + BlueHour, +} + +/// Fake capture metadata so the library looks like a real shoot. +#[derive(Clone, Debug, Serialize)] +pub struct SceneMeta { + pub camera: &'static str, + pub lens: &'static str, + pub focal_mm: f32, + pub aperture: f32, + pub shutter: &'static str, + pub iso: u32, + /// ISO 8601 local time. + pub captured: String, + pub location: &'static str, + pub keywords: Vec<&'static str>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Scene { + pub id: u32, + pub name: String, + pub kind: Kind, + pub seed: u32, + /// Native pixel size (what an import would report). + pub width: u32, + pub height: u32, + pub meta: SceneMeta, +} + +impl Scene { + pub fn aspect(&self) -> f32 { + self.width as f32 / self.height as f32 + } + + /// Render at `w × h` (any size; the aspect should match `aspect()`). + pub fn render(&self, w: usize, h: usize) -> Rgb32f { + paint::render(self.kind, self.seed, w, h) + } + + /// Render fitting within `max_edge` on the long side. + pub fn render_fit(&self, max_edge: usize) -> Rgb32f { + let a = self.aspect(); + let (w, h) = if a >= 1.0 { (max_edge, (max_edge as f32 / a).round() as usize) } else { ((max_edge as f32 * a).round() as usize, max_edge) }; + self.render(w.max(1), h.max(1)) + } +} + +struct Def { + kind: Kind, + name: &'static str, + seed: u32, + portrait: bool, + location: &'static str, + keywords: &'static [&'static str], +} + +const DEFS: &[Def] = &[ + Def { + kind: Kind::AlpineLake, + name: "Lake at first light", + seed: 11, + portrait: false, + location: "Dolomites", + keywords: &["mountains", "lake", "sunrise"], + }, + Def { kind: Kind::Dunes, name: "Dune ridges", seed: 7, portrait: false, location: "Namib", keywords: &["desert", "dunes"] }, + Def { kind: Kind::OceanSunset, name: "Last light over the sea", seed: 3, portrait: false, location: "Big Sur", keywords: &["ocean", "sunset"] }, + Def { + kind: Kind::Aurora, + name: "Aurora over the ridge", + seed: 21, + portrait: false, + location: "Lofoten", + keywords: &["night", "aurora", "stars"], + }, + Def { kind: Kind::MistyForest, name: "Morning fog", seed: 5, portrait: false, location: "Black Forest", keywords: &["forest", "fog"] }, + Def { kind: Kind::Lavender, name: "Lavender rows", seed: 9, portrait: false, location: "Valensole", keywords: &["fields", "lavender", "summer"] }, + Def { kind: Kind::Macro, name: "Cosmos bloom", seed: 13, portrait: true, location: "Home garden", keywords: &["flower", "macro", "bokeh"] }, + Def { kind: Kind::Canyon, name: "Canyon walls", seed: 17, portrait: false, location: "Utah", keywords: &["canyon", "rock"] }, + Def { kind: Kind::Beach, name: "Lagoon", seed: 19, portrait: false, location: "Maldives", keywords: &["beach", "tropical"] }, + Def { kind: Kind::BlueHour, name: "Blue hour peaks", seed: 23, portrait: false, location: "Patagonia", keywords: &["mountains", "blue hour"] }, + Def { kind: Kind::AlpineLake, name: "Still water", seed: 41, portrait: false, location: "Banff", keywords: &["mountains", "lake"] }, + Def { kind: Kind::OceanSunset, name: "Golden horizon", seed: 44, portrait: false, location: "Algarve", keywords: &["ocean", "sunset"] }, + Def { kind: Kind::Dunes, name: "Afternoon sand", seed: 57, portrait: true, location: "Sahara", keywords: &["desert"] }, + Def { kind: Kind::Aurora, name: "Green curtains", seed: 60, portrait: false, location: "Yukon", keywords: &["night", "aurora"] }, + Def { kind: Kind::Macro, name: "Pink petals", seed: 71, portrait: false, location: "Home garden", keywords: &["flower", "macro"] }, + Def { kind: Kind::MistyForest, name: "Valley haze", seed: 73, portrait: false, location: "Oregon", keywords: &["forest", "fog"] }, + Def { kind: Kind::Canyon, name: "Red layers", seed: 79, portrait: true, location: "Arizona", keywords: &["canyon"] }, + Def { kind: Kind::Beach, name: "Turquoise shallows", seed: 83, portrait: false, location: "Bahamas", keywords: &["beach"] }, + Def { kind: Kind::Lavender, name: "Evening fields", seed: 89, portrait: false, location: "Provence", keywords: &["fields"] }, + Def { kind: Kind::BlueHour, name: "Cold summit", seed: 97, portrait: false, location: "Alps", keywords: &["mountains", "snow"] }, + Def { kind: Kind::AlpineLake, name: "Alpenglow", seed: 101, portrait: true, location: "Zermatt", keywords: &["mountains"] }, + Def { kind: Kind::OceanSunset, name: "Calm sea", seed: 107, portrait: false, location: "Crete", keywords: &["ocean"] }, + Def { kind: Kind::Macro, name: "Violet bloom", seed: 113, portrait: false, location: "Kew", keywords: &["flower"] }, + Def { kind: Kind::Dunes, name: "Wind lines", seed: 127, portrait: false, location: "Death Valley", keywords: &["desert"] }, +]; + +const CAMERAS: &[(&str, &str, f32)] = &[ + ("Synthetic S1", "24-70mm f/2.8", 35.0), + ("Synthetic S1", "70-200mm f/4", 135.0), + ("Synthetic X2", "16-35mm f/4", 18.0), + ("Synthetic X2", "90mm f/2.8 Macro", 90.0), +]; + +/// The demo library: 24 scenes, 6000×4000 (or 4000×6000) nominal size. +pub fn demo_library() -> Vec { + DEFS.iter() + .enumerate() + .map(|(i, d)| { + let cam = if d.kind == Kind::Macro { CAMERAS[3] } else { CAMERAS[i % 3] }; + let shutter = match d.kind { + Kind::Aurora => "8", + Kind::BlueHour => "1/4", + Kind::MistyForest => "1/60", + _ => ["1/250", "1/500", "1/125", "1/1000"][i % 4], + }; + let iso = match d.kind { + Kind::Aurora => 3200, + Kind::BlueHour => 400, + _ => [100, 200, 64, 100][i % 4], + }; + let (w, h) = if d.portrait { (4000, 6000) } else { (6000, 4000) }; + Scene { + id: i as u32 + 1, + name: d.name.to_string(), + kind: d.kind, + seed: d.seed, + width: w, + height: h, + meta: SceneMeta { + camera: cam.0, + lens: cam.1, + focal_mm: cam.2, + aperture: [2.8, 8.0, 11.0, 4.0][i % 4], + shutter, + iso, + captured: format!("2026-{:02}-{:02}T{:02}:{:02}:00", 3 + i % 7, 1 + (i * 5) % 27, 5 + (i * 3) % 15, (i * 17) % 60), + location: d.location, + keywords: d.keywords.to_vec(), + }, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_is_deterministic_and_varied() { + let lib = demo_library(); + assert_eq!(lib.len(), 24); + let a = lib[0].render(64, 43); + let b = lib[0].render(64, 43); + assert_eq!(a, b); + // every scene renders finite, non-negative, non-trivial images + for s in &lib { + let img = s.render_fit(48); + assert!(img.data.iter().all(|p| p.iter().all(|v| v.is_finite() && *v >= 0.0)), "{}", s.name); + let mean: f32 = img.data.iter().map(|p| p[1]).sum::() / img.len() as f32; + assert!(mean > 0.005 && mean < 3.0, "{} mean {mean}", s.name); + } + } + + #[test] + fn sunsets_have_highlight_headroom() { + let lib = demo_library(); + let s = lib.iter().find(|s| s.kind == Kind::OceanSunset).unwrap(); + let img = s.render_fit(200); + let max = img.data.iter().map(|p| p[0]).fold(0.0f32, f32::max); + assert!(max > 2.0, "{max}"); + } + + #[test] + fn resolution_independent() { + let s = &demo_library()[1]; + let small = s.render(60, 40); + let big = s.render(240, 160); + let down = lightcraft_raster::resample::resize(&big, 60, 40, lightcraft_raster::resample::Filter::Box); + let err: f32 = small.data.iter().zip(&down.data).map(|(a, b)| (a[1] - b[1]).abs()).sum::() / small.len() as f32; + assert!(err < 0.08, "{err}"); + } +} diff --git a/crates/scenes/src/noise.rs b/crates/scenes/src/noise.rs new file mode 100644 index 000000000..a74f3e775 --- /dev/null +++ b/crates/scenes/src/noise.rs @@ -0,0 +1,68 @@ +//! Deterministic gradient noise and fractal sums. + +#[inline] +pub fn hash(x: i32, y: i32, seed: u32) -> u32 { + let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f); + h ^= h >> 13; + h = h.wrapping_mul(0x5bd1_e995); + h ^= h >> 15; + h +} + +/// Uniform in 0..1. +#[inline] +pub fn rand01(x: i32, y: i32, seed: u32) -> f32 { + (hash(x, y, seed) & 0x00ff_ffff) as f32 / 16_777_216.0 +} + +#[inline] +fn grad(ix: i32, iy: i32, seed: u32, dx: f32, dy: f32) -> f32 { + let a = rand01(ix, iy, seed) * std::f32::consts::TAU; + let (s, c) = a.sin_cos(); + c * dx + s * dy +} + +#[inline] +fn fade(t: f32) -> f32 { + t * t * t * (t * (t * 6.0 - 15.0) + 10.0) +} + +/// Gradient noise in roughly −0.7..0.7. +pub fn noise(x: f32, y: f32, seed: u32) -> f32 { + let (x0, y0) = (x.floor(), y.floor()); + let (ix, iy) = (x0 as i32, y0 as i32); + let (fx, fy) = (x - x0, y - y0); + let (u, v) = (fade(fx), fade(fy)); + let a = grad(ix, iy, seed, fx, fy); + let b = grad(ix + 1, iy, seed, fx - 1.0, fy); + let c = grad(ix, iy + 1, seed, fx, fy - 1.0); + let d = grad(ix + 1, iy + 1, seed, fx - 1.0, fy - 1.0); + let ab = a + (b - a) * u; + let cd = c + (d - c) * u; + ab + (cd - ab) * v +} + +/// Fractal Brownian motion, roughly −1..1. +pub fn fbm(x: f32, y: f32, seed: u32, octaves: u32) -> f32 { + let (mut sum, mut amp, mut f, mut norm) = (0.0, 1.0, 1.0, 0.0); + for o in 0..octaves { + sum += noise(x * f, y * f, seed.wrapping_add(o * 101)) * amp; + norm += amp; + amp *= 0.5; + f *= 2.03; + } + sum / norm * 1.4 +} + +/// Ridged multifractal, 0..1 (sharp crests — mountain ridges). +pub fn ridged(x: f32, y: f32, seed: u32, octaves: u32) -> f32 { + let (mut sum, mut amp, mut f, mut norm) = (0.0, 1.0, 1.0, 0.0); + for o in 0..octaves { + let n = 1.0 - (noise(x * f, y * f, seed.wrapping_add(o * 131)) * 1.4).abs(); + sum += n * n * amp; + norm += amp; + amp *= 0.5; + f *= 2.1; + } + sum / norm +} diff --git a/crates/scenes/src/paint.rs b/crates/scenes/src/paint.rs new file mode 100644 index 000000000..6d2b52368 --- /dev/null +++ b/crates/scenes/src/paint.rs @@ -0,0 +1,655 @@ +//! Scene painters. Coordinates: `x` = u·aspect (isotropic), `v` = 0 (top) .. 1 (bottom). +//! Colours are authored as linear sRGB and converted to linear Rec.2020 at the end. + +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_raster::Rgb32f; +use rayon::prelude::*; + +use crate::Kind; +use crate::noise::{fbm, hash, noise, rand01, ridged}; + +type C = [f32; 3]; + +fn hex(v: u32) -> C { + let f = |c: u32| lightcraft_color::transfer::srgb_to_linear(((v >> c) & 0xff) as f32 / 255.0); + [f(16), f(8), f(0)] +} +#[inline] +fn mix(a: C, b: C, t: f32) -> C { + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] +} +#[inline] +fn add(a: C, b: C) -> C { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +#[inline] +fn mul(a: C, s: f32) -> C { + [a[0] * s, a[1] * s, a[2] * s] +} +#[inline] +fn mulc(a: C, b: C) -> C { + [a[0] * b[0], a[1] * b[1], a[2] * b[2]] +} +#[inline] +fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Per-scene random parameters. +struct P { + seed: u32, + aspect: f32, +} + +impl P { + fn r(&self, i: u32) -> f32 { + rand01(i as i32, 7, self.seed) + } +} + +struct Sky { + zenith: C, + horizon: C, + horizon_v: f32, + sun: (f32, f32), + sun_col: C, + sun_disc: f32, + glow: f32, + clouds: f32, + cloud_col: C, + stars: f32, +} + +fn sky(p: &P, s: &Sky, x: f32, v: f32) -> C { + let elev = ((s.horizon_v - v) / s.horizon_v).clamp(0.0, 1.0); + let mut c = mix(s.horizon, s.zenith, elev.powf(0.55)); + let (dx, dy) = (x - s.sun.0, (v - s.sun.1) * 1.0); + let d = (dx * dx + dy * dy).sqrt(); + c = add(c, mul(s.sun_col, s.glow * (0.9 * (-d * 9.0).exp() + 0.35 * (-d * 2.6).exp()))); + let disc_r = 0.028; + if s.sun_disc > 0.0 { + let e = smooth(disc_r, disc_r * 0.8, d); + c = mix(c, mul(s.sun_col, s.sun_disc), e); + } + if s.clouds > 0.0 { + let n = fbm(x * 1.7 + 3.0, v * 5.5, p.seed ^ 0xc10d, 6); + let dens = smooth(1.0 - s.clouds, 1.25 - s.clouds, n * 0.5 + 0.5) * smooth(0.0, 0.12, elev); + if dens > 0.0 { + let edge = fbm(x * 6.0, v * 14.0, p.seed ^ 0xed9e, 3) * 0.5 + 0.5; + let lit = (-d * 2.2).exp() * 2.5 + 0.35 * edge; + let cc = add(mul(s.cloud_col, 0.55 + 0.3 * edge), mul(s.sun_col, lit * s.glow.min(1.2) * 0.5)); + c = mix(c, cc, dens * 0.92); + } + } + if s.stars > 0.0 { + let g = 900.0; + let (cx, cy) = ((x * g) as i32, (v * g) as i32); + let h = rand01(cx, cy, p.seed ^ 0x5a5a); + if h > 0.9965 { + let b = (h - 0.9965) / 0.0035; + let tint = mix([0.8, 0.85, 1.0], [1.0, 0.9, 0.75], rand01(cx, cy, 99)); + c = add(c, mul(tint, s.stars * b * b * 1.6 * elev.sqrt())); + } + } + c +} + +struct Layer { + base: f32, + amp: f32, + freq: f32, + col: C, + haze: f32, + snow: f32, + ridged: bool, + seed: u32, +} + +/// Low-octave height, for shading (slopes of the detailed crest alias into vertical streaks). +fn layer_h_lo(l: &Layer, x: f32) -> f32 { + let n = if l.ridged { + 0.75 * ridged(x * l.freq, 0.37, l.seed, 2) + 0.25 * (fbm(x * l.freq * 0.5, 1.7, l.seed, 2) * 0.5 + 0.5) + } else { + fbm(x * l.freq, 0.37, l.seed, 2) * 0.5 + 0.5 + }; + l.base + l.amp * n +} + +fn layer_h(l: &Layer, x: f32) -> f32 { + let n = if l.ridged { + 0.75 * ridged(x * l.freq, 0.37, l.seed, 4) + 0.25 * (fbm(x * l.freq * 0.5, 1.7, l.seed, 3) * 0.5 + 0.5) + } else { + fbm(x * l.freq, 0.37, l.seed, 5) * 0.5 + 0.5 + }; + l.base + l.amp * n +} + +/// Terrain shading for a layer hit at (x, v) whose crest is at `top`. +fn terrain(l: &Layer, x: f32, v: f32, top: f32, sun_x: f32, sun_col: C, haze_col: C) -> C { + let e = 0.012; + let slope = (layer_h_lo(l, x + e) - layer_h_lo(l, x - e)) / (2.0 * e); + let dir = if sun_x > x { 1.0 } else { -1.0 }; + let face = (slope * dir * 1.6).clamp(-1.0, 1.0); + let depth = (v - top).max(0.0); + let tex = fbm(x * 38.0, v * 38.0, l.seed ^ 77, 4) * 0.12; + let light = (0.62 + 0.38 * face + tex).max(0.05); + let mut c = mul(l.col, light * (1.0 - (depth * 2.5).min(0.45))); + c = add(c, mul(mulc(sun_col, l.col), 0.35 * face.max(0.0))); + if l.snow > 0.0 { + let s = l.snow * (0.6 + 0.5 * (fbm(x * 9.0, v * 4.0, l.seed ^ 5, 3) * 0.5 + 0.5)); + if depth < s * 0.18 * (1.0 - smooth(0.0, 0.18, depth) * 0.3) { + let sc = mix([0.55, 0.6, 0.72], add(sun_col, [0.35, 0.35, 0.4]), (0.55 + 0.45 * face).clamp(0.0, 1.0)); + c = mix(c, sc, 0.95); + } + } + mix(c, haze_col, l.haze) +} + +fn pine(p: &P, x: f32, v: f32, ground: f32, height: f32, density: f32) -> Option { + let tw = 0.035; + let cell = (x / tw).floor() as i32; + let mut best: Option = None; + for c in cell - 1..=cell + 1 { + if rand01(c, 1, p.seed ^ 0x7ee) > density { + continue; + } + let cx = (c as f32 + rand01(c, 2, p.seed)) * tw; + let th = height * (0.45 + 0.8 * rand01(c, 3, p.seed)); + let base = ground + 0.01 * rand01(c, 4, p.seed); + let top = base - th; + if v < top || v > base { + continue; + } + let t = (v - top) / th; + let jag = 0.75 + 0.25 * ((t * 22.0 + c as f32).sin() * 0.5 + 0.5); + let hw = t * th * 0.26 * jag + 0.0015; + let dx = (x - cx).abs(); + if dx < hw { + best = Some(best.map_or(dx / hw, |b: f32| b.min(dx / hw))); + } + } + best +} + +fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, tint: C, sun: (f32, f32), sun_col: C) -> C { + let d = v - wl; + let rip = fbm(x * 9.0, d * 60.0 / (d + 0.05), p.seed ^ 0xaa, 4) * (1.0 - calm); + let rv = (wl - d * 1.0 + rip * (0.01 + 0.06 * (1.0 - calm)) * (d * 10.0 + 0.2)).max(0.0); + let rx = x + rip * 0.02 * (d * 6.0 + 0.1); + let refl = above(rx, rv); + let fres = 0.55 + 0.4 * (1.0 - (d * 3.0).min(1.0)); + let mut c = add(mul(refl, fres), mul(tint, 0.25)); + // glitter along the sun path + let path = (-(x - sun.0).abs() * (14.0 - d * 8.0).max(3.0)).exp(); + let g = rand01((x * 700.0) as i32, (v * 1400.0) as i32, p.seed ^ 0x61); + if g > 0.93 - 0.1 * path && path > 0.02 && sun.1 < wl { + c = add(c, mul(sun_col, path * (g - 0.8) * 6.0 * (1.0 - calm * 0.6))); + } + c +} + +pub fn render(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f { + let aspect = w as f32 / h as f32; + let p = P { seed, aspect }; + let ss = if w * h <= 300_000 { 2 } else { 1 }; + let to2020 = SRGB.to_space(&REC2020).to_f32(); + let mut img = Rgb32f::new(w, h); + img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (xi, px) in row.iter_mut().enumerate() { + let mut acc = [0.0f32; 3]; + for sy in 0..ss { + for sx in 0..ss { + let u = (xi as f32 + (sx as f32 + 0.5) / ss as f32) / w as f32; + let v = (y as f32 + (sy as f32 + 0.5) / ss as f32) / h as f32; + acc = add(acc, shade(kind, &p, u * aspect, v)); + } + } + let c = mul(acc, 1.0 / (ss * ss) as f32); + let m = &to2020; + *px = [ + (m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]).max(0.0), + (m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]).max(0.0), + (m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]).max(0.0), + ]; + } + }); + img +} + +/// Linear sRGB variant (for tests/tools that don't want Rec.2020). +pub fn render_srgb_linear(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f { + let to709 = REC2020.to_space(&SRGB); + render(kind, seed, w, h).map(|c| to709.apply_f32(c).map(|v| v.max(0.0))) +} + +fn shade(kind: Kind, p: &P, x: f32, v: f32) -> C { + match kind { + Kind::AlpineLake => alpine(p, x, v, false), + Kind::BlueHour => alpine(p, x, v, true), + Kind::Dunes => dunes(p, x, v), + Kind::OceanSunset => ocean(p, x, v), + Kind::Aurora => aurora(p, x, v), + Kind::MistyForest => forest(p, x, v), + Kind::Lavender => lavender(p, x, v), + Kind::Macro => macro_flower(p, x, v), + Kind::Canyon => canyon(p, x, v), + Kind::Beach => beach(p, x, v), + } +} + +fn alpine(p: &P, x: f32, v: f32, blue: bool) -> C { + let hz = 0.62; + let sun = (p.aspect * (0.2 + 0.6 * p.r(1)), if blue { 0.75 } else { 0.28 + 0.1 * p.r(2) }); + let (sun_col, sk) = if blue { + ( + hex(0xffb4a0), + Sky { + zenith: hex(0x0b1a3c), + horizon: hex(0x6a6fa8), + horizon_v: hz, + sun, + sun_col: hex(0xff9a8a), + sun_disc: 0.0, + glow: 0.25, + clouds: 0.0, + cloud_col: hex(0x303a60), + stars: 0.5, + }, + ) + } else { + let sc = hex(0xffc27a); + ( + sc, + Sky { + zenith: hex(0x2f5c9e), + horizon: hex(0xf2c08e), + horizon_v: hz, + sun, + sun_col: sc, + sun_disc: 22.0, + glow: 1.1, + clouds: 0.35 + 0.2 * p.r(3), + cloud_col: hex(0x9aa4be), + stars: 0.0, + }, + ) + }; + let haze = if blue { hex(0x5d668f) } else { hex(0xc8b8b0) }; + let layers = [ + Layer { base: 0.02, amp: 0.05, freq: 3.0, col: hex(0x24331f), haze: 0.1, snow: 0.0, ridged: false, seed: p.seed + 4 }, + Layer { + base: 0.08, + amp: 0.16, + freq: 1.6, + col: if blue { hex(0x3a4466) } else { hex(0x5d5a58) }, + haze: 0.18, + snow: 0.7, + ridged: true, + seed: p.seed + 3, + }, + Layer { + base: 0.16, + amp: 0.28, + freq: 1.0, + col: if blue { hex(0x4d5a88) } else { hex(0x7b7a86) }, + haze: 0.42, + snow: 1.0, + ridged: true, + seed: p.seed + 2, + }, + ]; + let wl = hz + 0.035; + let above = |x: f32, v: f32| -> C { + if let Some(t) = pine(p, x, v, wl, 0.16, 0.55) { + let rim = if blue { 0.0 } else { (1.0 - t).powi(4) * 0.0 + (t).powi(6) * 0.25 }; + return add(mul(hex(0x0c140d), 1.0), mul(sun_col, rim * 0.2)); + } + for l in &layers { + let top = hz - layer_h(l, x); + if v >= top { + return terrain(l, x, v, top, sun.0, sun_col, haze); + } + } + sky(p, &sk, x, v) + }; + if v > wl { + let tint = if blue { hex(0x1a2440) } else { hex(0x20303a) }; + return water(p, x, v, wl, 0.75, &above, tint, sun, sun_col); + } + above(x, v) +} + +fn dunes(p: &P, x: f32, v: f32) -> C { + let hz = 0.42; + let sun_col = hex(0xffd29a); + let sun = (p.aspect * (0.1 + 0.25 * p.r(1)), 0.12); + let sk = Sky { + zenith: hex(0x2a64b8), + horizon: hex(0xcfd9e6), + horizon_v: hz, + sun, + sun_col, + sun_disc: 0.0, + glow: 0.5, + clouds: 0.0, + cloud_col: hex(0xffffff), + stars: 0.0, + }; + // Dune layers from near (low) to far (high). Crest = sharp ridge via |sin|. + for i in 0..5 { + let fi = i as f32; + let freq = 1.3 + fi * 0.6; + let ph = p.r(10 + i) * 6.0; + let hfun = |x: f32| { + let w = (x * freq + ph + 0.4 * fbm(x * 0.8, fi, p.seed + i, 3)).sin(); + let crest = 1.0 - w.abs().powf(0.6); + 0.05 + fi * 0.085 + crest * (0.16 - fi * 0.02) + }; + let top = 0.98 - hfun(x) - fi * 0.08; + if v >= top { + let e = 0.001; + let slope = (hfun(x + e) - hfun(x - e)) / (2.0 * e); + let lit = (0.5 - slope * 1.2).clamp(0.0, 1.0); + let ripple = ((x * 260.0 + v * 90.0 + 8.0 * fbm(x * 3.0, v * 3.0, p.seed, 3)).sin() * 0.5 + 0.5) * 0.08; + let sand = mix(hex(0xb4562a), hex(0xf2a55c), (1.0 - (v - top) * 3.0).clamp(0.2, 1.0)); + let shadow_fill = hex(0x5e3a3a); + let shade = smooth(0.35, 0.65, lit); + let c = mix(mix(mul(sand, 0.28), shadow_fill, 0.45), mul(sand, 1.05 + ripple), shade); + let far = fi / 5.0; + return mix(mul(c, 0.9), hex(0xe8c8b0), far * 0.45); + } + } + sky(p, &sk, x, v) +} + +fn ocean(p: &P, x: f32, v: f32) -> C { + let hz = 0.56; + let sun_col = hex(0xffa050); + let sun = (p.aspect * (0.35 + 0.3 * p.r(1)), hz - 0.06 - 0.05 * p.r(2)); + let sk = Sky { + zenith: hex(0x3b3f78), + horizon: hex(0xff9c55), + horizon_v: hz, + sun, + sun_col, + sun_disc: 30.0, + glow: 1.6, + clouds: 0.45, + cloud_col: hex(0x5a4a6a), + stars: 0.0, + }; + // Headland silhouette on one side. + let side = if p.r(5) > 0.5 { x } else { p.aspect - x }; + let head = 0.18 * (1.0 - smooth(0.0, 0.35 * p.aspect, side)) * (0.8 + 0.4 * fbm(x * 4.0, 0.0, p.seed, 4)); + let above = |x: f32, v: f32| -> C { + let side = if p.r(5) > 0.5 { x } else { p.aspect - x }; + let head = 0.18 * (1.0 - smooth(0.0, 0.35 * p.aspect, side)) * (0.8 + 0.4 * fbm(x * 4.0, 0.0, p.seed, 4)); + if v > hz - head && v <= hz { + return mix(hex(0x1c1420), hex(0x3a2230), (hz - v) * 2.0); + } + sky(p, &sk, x, v) + }; + if v > hz { + if v < hz + head * 0.3 { + return hex(0x120c14); + } + return water(p, x, v, hz, 0.35, &above, hex(0x1a2040), sun, sun_col); + } + let _ = head; + above(x, v) +} + +fn aurora(p: &P, x: f32, v: f32) -> C { + let hz = 0.64; + let sk = Sky { + zenith: hex(0x03060f), + horizon: hex(0x0d2230), + horizon_v: hz, + sun: (-5.0, 5.0), + sun_col: [0.0; 3], + sun_disc: 0.0, + glow: 0.0, + clouds: 0.0, + cloud_col: [0.0; 3], + stars: 1.2, + }; + let aur = |x: f32, v: f32| -> C { + let yc = 0.40 + 0.12 * fbm(x * 0.9, 1.0, p.seed ^ 0xa0, 4); + let d = yc - v; + let rays = 0.55 + 0.45 * noise(x * 38.0, v * 1.5, p.seed ^ 0xb1).abs() * 1.6; + let i = if d > 0.0 { (-d / 0.16).exp() } else { (-(d / 0.018).powi(2)).exp() }; + let fold = 0.6 + 0.4 * fbm(x * 3.0, 0.3, p.seed ^ 0xc2, 3); + let g = mul(hex(0x2cff8a), i * rays * fold * 2.2); + let pur = mul(hex(0xa040ff), i * smooth(0.06, 0.3, d) * 1.4); + add(g, pur) + }; + let layers = [ + Layer { base: 0.03, amp: 0.05, freq: 2.2, col: hex(0x0a0e12), haze: 0.0, snow: 0.0, ridged: false, seed: p.seed + 3 }, + Layer { base: 0.07, amp: 0.2, freq: 1.2, col: hex(0x1a2430), haze: 0.1, snow: 0.9, ridged: true, seed: p.seed + 2 }, + ]; + let wl = hz + 0.05; + let above = |x: f32, v: f32| -> C { + for l in &layers { + let top = hz - layer_h(l, x); + if v >= top { + let t = terrain(l, x, v, top, x + 1.0, hex(0x2c8a60), hex(0x0a1418)); + return mul(t, 0.35); + } + } + add(sky(p, &sk, x, v), aur(x, v)) + }; + if v > wl { + return water(p, x, v, wl, 0.9, &above, hex(0x020408), (-5.0, 5.0), [0.0; 3]); + } + above(x, v) +} + +fn forest(p: &P, x: f32, v: f32) -> C { + let sun = (p.aspect * (0.55 + 0.3 * p.r(1)), 0.1); + let sun_col = hex(0xfff0c8); + let fog = hex(0xcfd6c8); + let mut c = mix(hex(0xf4f0e0), hex(0xb8c4c0), (1.0 - v).powf(1.5)); + for i in 0..6 { + let fi = i as f32; + let ground = 0.5 + fi * 0.1; + let depth = 1.0 - fi / 5.0; + let col = mix(mix(hex(0x0c1c10), hex(0x1c3020), depth), fog, depth.powf(0.8) * 0.88); + if pine(&P { seed: p.seed + i * 17, aspect: p.aspect }, x * (1.0 - fi * 0.08) + fi * 3.1, v, ground, 0.22 + fi * 0.07, 0.85).is_some() + || v > ground + { + c = col; + } + } + // volumetric light shafts in front of everything, strongest in the fog + let (dx, dy) = (x - sun.0, v - sun.1); + let ang = dy.atan2(dx); + let rays = (fbm(ang * 9.0, 0.0, p.seed ^ 0x9a, 4) * 0.5 + 0.5).powf(3.0); + let dist = (dx * dx + dy * dy).sqrt(); + add(c, mul(sun_col, rays * (-dist * 1.3).exp() * 0.7 + (-dist * 7.0).exp() * 1.6)) +} + +fn lavender(p: &P, x: f32, v: f32) -> C { + let hz = 0.45; + let sun_col = hex(0xffb070); + let sun = (p.aspect * (0.6 + 0.2 * p.r(1)), hz - 0.08); + let sk = Sky { + zenith: hex(0x4a6ea8), + horizon: hex(0xffc896), + horizon_v: hz, + sun, + sun_col, + sun_disc: 18.0, + glow: 1.2, + clouds: 0.3, + cloud_col: hex(0xa89ab8), + stars: 0.0, + }; + // lone tree + let tx = p.aspect * (0.22 + 0.1 * p.r(2)); + let (dx, dy) = ((x - tx) / 0.09, (v - (hz - 0.11)) / 0.075); + let blob = dx * dx + dy * dy + 0.35 * fbm(x * 30.0, v * 30.0, p.seed, 3); + if blob < 1.0 { + return mix(hex(0x1a2412), hex(0x3a4a1c), (1.0 - blob) * 0.3); + } + if (x - tx).abs() < 0.006 && v > hz - 0.06 && v < hz + 0.004 { + return hex(0x1a1410); + } + if v > hz { + let dz = v - hz; + let z = 0.06 / (dz + 0.002); + let xw = (x - p.aspect * 0.5) / (dz + 0.004); + let row = (xw * 9.0).sin() * smooth(0.0, 0.08, dz) + (1.0 - smooth(0.0, 0.08, dz)) * 0.3; + let tex = fbm(xw * 3.0, z * 3.0, p.seed ^ 0x1a, 3); + let purple = mix(hex(0x5a3a9a), hex(0x9a70d8), (0.5 + 0.5 * tex).clamp(0.0, 1.0)); + let gap = hex(0x3a3a1e); + let t = smooth(-0.4, 0.2, row + tex * 0.4 * (1.0 - smooth(0.0, 0.2, dz))); + let mut c = mix(gap, purple, t); + c = mul(c, 0.55 + 0.6 * smooth(0.0, 0.5, dz)); + c = add(c, mul(sun_col, 0.18 * (-dz * 8.0).exp())); + let haze = (-dz * 25.0).exp(); + return mix(c, hex(0xe8b8a0), haze * 0.6); + } + sky(p, &sk, x, v) +} + +fn macro_flower(p: &P, x: f32, v: f32) -> C { + let ymax = 1.0; + let mut c = mix(hex(0x0c2a10), hex(0x3e6a1a), (1.0 - v / ymax).powf(1.2)); + // bokeh discs + for i in 0..36u32 { + let cx = p.r(100 + i) * p.aspect; + let cy = p.r(200 + i); + let r = 0.03 + 0.09 * p.r(300 + i); + let d = ((x - cx).powi(2) + (v - cy).powi(2)).sqrt(); + if d < r { + let pal = [hex(0xd8e870), hex(0x80c040), hex(0xfff0b0), hex(0xf0a0c0), hex(0x60a8a0)]; + let col = pal[(hash(i as i32, 1, p.seed) % 5) as usize]; + let rim = smooth(r * 0.8, r, d) * 0.4; + let a = smooth(r, r * 0.93, d) * (0.25 + 0.35 * p.r(400 + i)); + c = add(c, mul(col, a * (1.0 + rim))); + } + } + let center = (p.aspect * (0.45 + 0.1 * p.r(1)), 0.46); + let rr = 0.28; + // stem + if (x - center.0 - (v - center.1) * 0.15).abs() < 0.008 && v > center.1 { + return mix(hex(0x2a5a1a), hex(0x4a8a2a), 0.5); + } + let (dx, dy) = (x - center.0, v - center.1); + let r = (dx * dx + dy * dy).sqrt() / rr; + let th = dy.atan2(dx) + p.r(2) * 3.0; + let n = 8.0; + let petal = ((th * n / 2.0).cos()).abs(); + let edge = 0.35 + 0.65 * petal.powf(0.45) + 0.03 * noise(th * 20.0, 0.0, p.seed); + if r < edge { + let (pc, tip) = if p.seed.is_multiple_of(2) { (hex(0xe0408a), hex(0xffc0e0)) } else { (hex(0x8a3ad0), hex(0xe0c0ff)) }; + let vein = ((th * n * 7.0).sin() * 0.5 + 0.5) * 0.12; + let t = (r / edge).clamp(0.0, 1.0); + let mut col = mix(mul(pc, 0.55), tip, t.powf(1.5)); + col = mul(col, 0.85 + vein + 0.25 * (1.0 - petal)); + // centre + if r < 0.2 { + let dots = rand01((x * 900.0) as i32, (v * 900.0) as i32, p.seed) * 0.4; + col = mul(mix(hex(0xffb000), hex(0xffe060), 1.0 - r * 5.0), 0.8 + dots); + } + let light = 1.2 - 0.4 * (dx + dy + 0.2).clamp(-1.0, 1.0); + return mul(col, light); + } + c +} + +fn canyon(p: &P, x: f32, v: f32) -> C { + let hz = 0.3; + let sun_col = hex(0xffe0b0); + let sk = Sky { + zenith: hex(0x1e5aa8), + horizon: hex(0x9ac4e8), + horizon_v: hz + 0.2, + sun: (p.aspect * 0.8, -0.1), + sun_col, + sun_disc: 0.0, + glow: 0.3, + clouds: 0.2, + cloud_col: hex(0xe8eef4), + stars: 0.0, + }; + // Two walls meeting in a V-shaped gap. + let mid = p.aspect * (0.5 + 0.1 * (p.r(1) - 0.5)); + let gap = (x - mid).abs() / p.aspect; + let wall_top = hz + 0.25 - gap * 0.9 + 0.08 * fbm(x * 3.0, 0.0, p.seed, 5); + if v > wall_top { + let strata = ((v * 70.0 + 3.0 * fbm(x * 2.0, v * 2.0, p.seed ^ 2, 4)).sin() * 0.5 + 0.5).powf(1.5); + let base = mix(hex(0xa8401c), hex(0xe89050), strata); + let cream = hex(0xe8c8a0); + let mut c = mix(base, cream, smooth(0.8, 1.0, strata) * 0.5); + let lit_side = x < mid; + let depth = (v - wall_top).max(0.0); + let light = if lit_side { 1.0 - depth * 0.6 } else { 0.35 - depth * 0.2 }; + c = mul(c, light.max(0.08) * (0.9 + 0.2 * fbm(x * 40.0, v * 40.0, p.seed ^ 9, 3))); + if !lit_side { + c = add(c, mul(hex(0x6a4a60), 0.12)); + } + return c; + } + sky(p, &sk, x, v) +} + +fn beach(p: &P, x: f32, v: f32) -> C { + let hz = 0.42; + let sun_col = hex(0xffffff); + let sk = Sky { + zenith: hex(0x1f6fd0), + horizon: hex(0xb8e0f8), + horizon_v: hz, + sun: (p.aspect * 0.8, -0.2), + sun_col, + sun_disc: 0.0, + glow: 0.25, + clouds: 0.4, + cloud_col: hex(0xffffff), + stars: 0.0, + }; + // palm silhouette (right side) + let px0 = p.aspect * 0.86; + let trunk_x = |v: f32| px0 - (1.0 - v) * 0.25 * (1.0 - v); + if v > 0.18 && (x - trunk_x(v)).abs() < 0.012 * (0.6 + v * 0.6) { + return hex(0x2a2018); + } + let crown = (trunk_x(0.18), 0.18); + for f in 0..9 { + let a0 = -2.9 + f as f32 * 0.7 + 0.2 * p.r(50 + f); + let len = 0.22 + 0.06 * p.r(60 + f); + // parametric droop + let steps = 24; + for s in 0..steps { + let t = s as f32 / steps as f32; + let fx = crown.0 + a0.cos() * len * t; + let fy = crown.1 + a0.sin() * len * t + t * t * 0.12; + let d = ((x - fx).powi(2) + (v - fy).powi(2)).sqrt(); + let leaf = 0.022 * (1.0 - t) * (0.6 + 0.4 * ((t * 60.0).sin()).abs()); + if d < leaf + 0.002 { + return mix(hex(0x0e2410), hex(0x1a3a18), t); + } + } + } + if v <= hz { + return sky(p, &sk, x, v); + } + let shore = 0.68 + 0.04 * (x * 3.0 + p.r(1) * 5.0).sin() + 0.02 * fbm(x * 6.0, 0.0, p.seed, 3); + if v < shore { + let t = (v - hz) / (shore - hz); + let deep = hex(0x0d4a8a); + let turq = hex(0x30d0c8); + let mut c = mix(deep, turq, t.powf(1.4)); + let caust = (fbm(x * 30.0, v * 40.0, p.seed ^ 3, 3) * 0.5 + 0.5).powf(3.0) * t; + c = add(c, mul([0.6, 0.9, 0.9], caust * 0.35)); + let foam = smooth(shore - 0.012, shore, v) * (0.6 + 0.4 * noise(x * 60.0, 0.0, p.seed)); + c = mix(c, hex(0xf4f8f8), foam); + let refl = mul(sky(p, &sk, x, hz - (v - hz) * 0.8), 0.25 * (1.0 - t)); + return add(c, refl); + } + let wet = smooth(shore + 0.04, shore, v); + let sand = mul(hex(0xf0dcb8), 0.9 + 0.12 * fbm(x * 80.0, v * 80.0, p.seed ^ 4, 3)); + mix(sand, mul(hex(0xc8b090), 0.9), wet) +}