//! Local adjustments — a stack of masked edits over the global chain. //! //! FR-DEV-3's last line: "linear gradient, radial gradient, and brush masks". //! A mask layer is an ordinary develop chain plus a rule saying *where* it //! applies, and the two halves are deliberately independent — every operation //! that works globally works locally, with no per-operation support needed and //! nothing to add when a new one is declared in `ops/`. //! //! # Where a mask actually exists //! //! **Not here, and not on the CPU at all.** A layer stores the *rule* — some //! region ids, or a gradient's geometry — and a compute pass rasterises it //! into a texture (ARCH §5.4). This module's job is to describe the rule and //! to emit the WGSL that blends by the result. //! //! That split is the direct response to darktable, where CPU-rasterised brush //! masks make painting lag badly enough that users call it unworkable. The //! problem there is architectural rather than a tuning failure, and the only //! way not to inherit it is to never put a mask in CPU memory. //! //! # Why region ids rather than a raster //! //! [`MaskSource::Regions`] stores integers naming regions in the segmentation //! hierarchy (`dr-segment`). That choice is what makes a mask diffable, cheap //! in a sidecar, and mergeable per-field under FR-NC-9 — three properties a //! stored raster has none of (docs/segmentation.md §1). Two devices that //! select the same subject produce the same small sorted list, and a sync //! conflict between them is resolvable rather than a binary blob fight. //! //! The cost is that the ids only mean anything alongside the segmentation that //! produced them, so [`MaskSource::Regions::signature`] records which one — //! see there for what happens when it does not match. use std::fmt::Write as _; use crate::descriptor::{OpDescriptor, ParamId}; use crate::operation::Operation; use crate::ops; /// The feather a new layer starts with, as a fraction of the shorter edge. /// /// Named because two places have to agree about it: the constructor sets it, /// and the sidecar omits it when unchanged. A literal in both would eventually /// be a literal in one. pub const DEFAULT_FEATHER: f32 = 0.004; /// Bilinear sampling of one slice of the mask array, in **source** space. /// /// Hand-rolled rather than done with a sampler, matching how the source /// texture is read: adding a sampler would change a bind group layout every /// pass shares. /// /// Bilinear rather than a straight load because the array is at proxy /// resolution and the view may be zoomed well past it. A nearest-neighbour /// mask shows as visible stair-stepping along the edge of the adjustment at /// 100%, which is exactly where a mask is judged. pub(crate) const MASK_SAMPLER: crate::operation::Helper = crate::operation::Helper { name: "sample_mask", source: "fn sample_mask(uv: vec2, layer: i32) -> f32 { let dims = vec2(textureDimensions(masks)); let last = vec2(dims) - vec2(1); // Sample positions are texel centres, so the half-texel offset is what // keeps the interpolated edge where the rasteriser drew it. let t = uv * dims - vec2(0.5); let base = vec2(floor(t)); let f = fract(t); let p0 = clamp(base, vec2(0), last); let p1 = clamp(base + vec2(1), vec2(0), last); let a = textureLoad(masks, vec2(p0.x, p0.y), layer, 0).r; let b = textureLoad(masks, vec2(p1.x, p0.y), layer, 0).r; let c = textureLoad(masks, vec2(p0.x, p1.y), layer, 0).r; let d = textureLoad(masks, vec2(p1.x, p1.y), layer, 0).r; return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); }", }; /// Per-layer uniforms the generated shader reads: `invert`, then `opacity`. pub const LAYER_UNIFORM_FIELDS: usize = 2; /// The most layers one image may carry. /// /// A limit exists because the masks are bound as one texture array and every /// layer costs a full-resolution channel of VRAM — at 24 MP that is ~24 MB /// each, so an unbounded stack is an out-of-memory waiting for a patient user. /// Eight is comfortably past what an edit uses in practice and still bounded. pub const MAX_LAYERS: usize = 8; /// How a mask's coverage falls away from its edge. /// /// Applied to the **signed distance** from the mask boundary, which is what /// makes an arbitrary curve possible: the rasteriser computes one exact /// Euclidean distance field and the choice below is a function of it, so a /// new shape costs a line rather than a pass. /// /// A watershed boundary is pixel-exact, which is correct and also harsher /// than any edit wants at a subject's edge — an exposure change that stops /// dead at a hairline reads as a cut-out. So the useful default is a soft one. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Falloff { /// No transition. The boundary as the segmentation drew it. /// /// Worth keeping rather than approximating with a tiny feather: it is what /// you want when checking *where* a boundary actually fell, and a feather /// hides exactly that. Hard, /// Straight ramp. Predictable, and visibly banded on a gradient. Linear, /// Smoothstep — zero derivative at both ends. /// /// The default. The ends are where a ramp shows: a linear falloff leaves a /// visible crease where the effect starts and where it stops, because the /// eye finds discontinuities in the *slope*, not in the value. #[default] Smooth, /// Gaussian-shaped. Softest, and reaches further than its radius suggests. Gaussian, /// Sharp near the edge, long tail. For blending an adjustment out over a /// large area without moving the boundary itself. Exponential, } impl Falloff { pub fn name(self) -> &'static str { match self { Self::Hard => "hard", Self::Linear => "linear", Self::Smooth => "smooth", Self::Gaussian => "gaussian", Self::Exponential => "exponential", } } pub fn from_name(name: &str) -> Option { Some(match name { "hard" => Self::Hard, "linear" => Self::Linear, "smooth" => Self::Smooth, "gaussian" => Self::Gaussian, "exponential" => Self::Exponential, _ => return None, }) } /// Every variant, for a UI building a choice control. pub const ALL: [Falloff; 5] = [ Falloff::Hard, Falloff::Linear, Falloff::Smooth, Falloff::Gaussian, Falloff::Exponential, ]; } /// Growing, shrinking and tidying a mask's extent. /// /// All four are thresholds of the same distance field, which is why they /// arrive together rather than one at a time: dilation is "distance ≥ −r", /// erosion is "distance ≥ +r", and the two compound operations are one of /// those followed by the other. /// /// The compound pair costs a **second** distance field, because after the /// first threshold the shape has changed and the old distances no longer /// describe it. That is a real cost and the reason they are named separately /// rather than presented as a radius that happens to be signed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Morphology { #[default] None, /// Grow. The everyday fix for a selection that stops just inside a /// subject's edge, which is what an under-segmented boundary produces. Dilate, /// Shrink. Pulls a selection back off a halo it caught. Erode, /// Dilate then erode: fills pinholes and closes narrow gaps without /// growing the outline. What to reach for when a mask is speckled with /// missed pixels inside an area that is plainly one thing. Close, /// Erode then dilate: removes specks and thin spurs without shrinking the /// outline. The complement, for a selection that leaked along an edge. Open, } impl Morphology { pub fn name(self) -> &'static str { match self { Self::None => "none", Self::Dilate => "dilate", Self::Erode => "erode", Self::Close => "close", Self::Open => "open", } } pub fn from_name(name: &str) -> Option { Some(match name { "none" => Self::None, "dilate" => Self::Dilate, "erode" => Self::Erode, "close" => Self::Close, "open" => Self::Open, _ => return None, }) } /// Whether this needs a second distance field. pub fn is_compound(self) -> bool { matches!(self, Self::Close | Self::Open) } pub const ALL: [Morphology; 5] = [ Morphology::None, Morphology::Dilate, Morphology::Erode, Morphology::Close, Morphology::Open, ]; } /// Where a mask layer applies. #[derive(Debug, Clone, PartialEq)] pub enum MaskSource { /// A set of segmentation regions — the click-to-select mask. /// /// This is what the watershed and the semantic model exist to produce. /// Selecting a subject means "the regions the model's instance covers", /// and the resulting edge is the watershed's, which is to say the image's /// own (docs/segmentation.md §5). Regions { /// Which segmentation these ids index into. /// /// Region numbering is a property of one particular segmentation of /// one particular image at one particular proxy size. Store ids /// without recording that, and a later build with a retuned watershed /// silently reinterprets the mask as a different shape — the failure /// mode being *a wrong mask*, which is far worse than *no mask*, /// because nothing announces it. /// /// When this does not match the current segmentation the layer is /// treated as stale rather than applied: see [`MaskLayer::is_stale`]. signature: u64, /// Granularity: how far up the merge hierarchy the ids were taken. level: u32, /// Sorted and deduplicated, so the same selection is byte-identical /// however it was arrived at — which is what lets it be a cache key. ids: Vec, }, /// One object the model recognised, used as the mask directly. /// /// **The primary way a local adjustment is made.** The watershed hierarchy /// this crate was first built around does not survive a photograph: its /// saddles are near zero almost everywhere, so a global cut collapses the /// frame into one region plus noise (docs/segmentation.md §15). A model /// instance is a whole object, found as one thing, and needs no ladder. /// /// The trade is that the boundary is the model's — a quarter-resolution /// sigmoid — rather than the image's own gradient. That is what the edge /// treatment on [`MaskLayer`] is for: the mask arrives approximately /// right and soft, and dilation, erosion and a chosen falloff are how it /// is made to fit. /// /// Stored as *identity*, not as pixels. The mask itself is several /// megabytes and is reproducible by running the same model over the same /// image, so the sidecar carries what is needed to find it again and the /// session carries the pixels. Subject { /// Which segmentation run produced it, so a layer can tell whether /// the index below still means what it meant. signature: u64, /// Position in that run's detection list, strongest first. index: u32, /// The class name, for display and as a sanity check on re-detection: /// if instance 3 is a "car" where it was a "dog", the model or the /// image changed and the layer should be treated as stale rather than /// silently masking something else. class: String, score: f32, }, /// A linear gradient — the graduated-filter mask. /// /// Geometry is in **normalised output coordinates**, so it survives a crop /// or an export at another size. Storing pixels would make a mask that /// silently moves when the frame changes. Linear { /// Midpoint of the ramp, `0.0..=1.0` in each axis. centre: (f32, f32), /// Radians, measured from the +x axis. angle: f32, /// Distance from full effect to none, in normalised units. Zero is a /// hard edge. width: f32, }, /// A radial gradient — the classic vignette-shaped local adjustment. Radial { centre: (f32, f32), /// Semi-axes, normalised. Two of them, because a face is an ellipse /// and forcing a circle makes the user compensate with a crop. radii: (f32, f32), angle: f32, /// Fraction of the radius over which the edge falls off. feather: f32, }, } impl MaskSource { /// A short stable name for the UI and for debugging. pub fn kind(&self) -> &'static str { match self { Self::Regions { .. } => "regions", Self::Subject { .. } => "subject", Self::Linear { .. } => "linear", Self::Radial { .. } => "radial", } } } /// One local adjustment: a rule about *where*, plus a chain saying *what*. pub struct MaskLayer { /// Stable identity, for the sidecar and for merge (FR-NC-9). pub id: String, /// What the user called it. Empty means "name me after my source". pub name: String, pub source: MaskSource, /// Swap inside for outside. pub invert: bool, /// Global strength of the layer, `0.0..=1.0`. pub opacity: f32, /// Off without being deleted — the A/B a local edit is always wanting. pub enabled: bool, /// Half-width of the edge transition, as a fraction of the frame's /// **shorter edge**. /// /// Normalised rather than in pixels for the same reason the gradient /// geometry is: the same edit renders to a viewport and to a 24 MP export, /// and a feather measured in pixels would be a different edge in each. /// /// Zero means no transition regardless of [`Self::falloff`]. pub feather: f32, pub falloff: Falloff, /// Grow, shrink or tidy the mask before the feather is applied. /// /// Before, and it matters: dilating a *feathered* mask would push the /// half-way point outward and soften it further, so the two controls would /// not be independent. Morphology moves the boundary; feather describes /// how the boundary is crossed. pub morphology: Morphology, /// How far, in the same units as [`Self::feather`]. pub morph_radius: f32, /// This layer's adjustments. /// /// A full chain, the same one [`crate::EditGraph`] holds. That is the /// whole reason local adjustments need no per-operation support: the /// composer already knows how to turn a chain into WGSL, and a mask layer /// is a chain that happens to be multiplied by a mask afterwards. pub ops: Vec>, } impl Clone for MaskLayer { /// Cloned by *value*, not by handle: the ops are trait objects, so this /// rebuilds a fresh chain and copies the parameters across. Needed because /// the UI edits a layer speculatively and the history stores snapshots. fn clone(&self) -> Self { let mut ops = ops::chain(); for (dst, src) in ops.iter_mut().zip(&self.ops) { for p in src.descriptor().params { dst.set_param(p.id, src.param(p.id)); } } Self { id: self.id.clone(), name: self.name.clone(), source: self.source.clone(), invert: self.invert, opacity: self.opacity, enabled: self.enabled, feather: self.feather, falloff: self.falloff, morphology: self.morphology, morph_radius: self.morph_radius, ops, } } } impl std::fmt::Debug for MaskLayer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("MaskLayer") .field("id", &self.id) .field("name", &self.name) .field("source", &self.source) .field("invert", &self.invert) .field("opacity", &self.opacity) .field("enabled", &self.enabled) .field("feather", &self.feather) .field("falloff", &self.falloff) .field("morphology", &self.morphology) .field("active_ops", &self.active_ops().count()) .finish() } } impl PartialEq for MaskLayer { fn eq(&self, other: &Self) -> bool { self.id == other.id && self.name == other.name && self.source == other.source && self.invert == other.invert && self.opacity == other.opacity && self.enabled == other.enabled && self.feather == other.feather && self.falloff == other.falloff && self.morphology == other.morphology && self.morph_radius == other.morph_radius && self.params().eq(other.params()) } } impl MaskLayer { /// A new layer over `source`, with every adjustment at neutral. pub fn new(id: impl Into, source: MaskSource) -> Self { Self { id: id.into(), name: String::new(), source, invert: false, opacity: 1.0, enabled: true, // A small default rather than zero. A watershed boundary is exact // to the pixel, and an adjustment that stops dead on one looks // pasted on — the first thing anyone would reach for, so it is // where the control starts. feather: DEFAULT_FEATHER, falloff: Falloff::default(), morphology: Morphology::default(), morph_radius: 0.0, ops: ops::chain(), } } /// The name to show, falling back to the source kind. pub fn display_name(&self) -> &str { if self.name.is_empty() { self.source.kind() } else { &self.name } } /// Whether this layer would change any pixel. /// /// A layer with a mask but no adjustment is not inactive in the UI — it is /// a selection the user is still working on — but it contributes nothing /// to the shader and is omitted from it. pub fn is_active(&self) -> bool { self.enabled && self.opacity > 0.0 && self.active_ops().next().is_some() } pub fn active_ops(&self) -> impl Iterator { self.ops.iter().map(|o| o.as_ref()).filter(|o| o.is_active()) } /// Whether this layer's region ids belong to a different segmentation. /// /// Applying it anyway would produce a confidently wrong mask, so callers /// should offer to recompute rather than render it. pub fn is_stale(&self, current: u64) -> bool { match self.source { MaskSource::Regions { signature, .. } | MaskSource::Subject { signature, .. } => { signature != current } // A gradient is geometry in normalised coordinates. It means the // same thing whatever was or was not detected, so nothing about a // new run can invalidate it. MaskSource::Linear { .. } | MaskSource::Radial { .. } => false, } } pub fn descriptors(&self) -> Vec<&'static OpDescriptor> { self.ops.iter().map(|o| o.descriptor()).collect() } /// TRACES: FR-DEV-3a | FR-DEV-3c /// The controls for this layer's adjustments. /// /// The same shape [`crate::EditGraph::capabilities`] returns, so a panel /// that can render the global chain renders a mask layer with no new code /// — which is the practical payoff of a layer holding a real chain rather /// than a handful of special-cased sliders. /// /// Framing is absent, and that is the one real difference: a crop changes /// the output's dimensions, so it is a property of the photograph and not /// of a region within it. There is no such thing as cropping part of an /// image. pub fn capabilities(&self) -> Vec { self.ops .iter() .map(|op| { let desc = op.descriptor(); crate::graph::OpCapability { id: desc.id, label: desc.label, active: op.is_active(), params: desc .params .iter() .map(|p| crate::graph::ParamCapability { id: p.id, label: p.label, kind: p.kind.clone(), default: p.default, value: op.param(p.id), facet: p.facet, }) .collect(), presentation: op.presentation(), } }) .collect() } /// Reset every adjustment, keeping the selection. /// /// The selection is the expensive half — it took a click and a scroll to /// arrive at — so "start this layer's edit again" must not throw it away. pub fn reset_adjustments(&mut self) { for op in &mut self.ops { for p in op.descriptor().params { op.set_param(p.id, p.default); } } } pub fn set_param(&mut self, op: &str, param: ParamId, value: f32) { if let Some(o) = self.ops.iter_mut().find(|o| o.descriptor().id.0 == op) { let clamped = o .descriptor() .param(param) .map_or(value, |d| d.clamp(value)); o.set_param(param, clamped); } } /// Every non-default parameter, for the sidecar. pub fn params(&self) -> impl Iterator + '_ { self.ops.iter().flat_map(|o| { let id = o.descriptor().id.0; o.descriptor().params.iter().filter_map(move |p| { let v = o.param(p.id); (v != p.default).then_some((id, p.id.0, v)) }) }) } /// The two uniforms the generated shader reads for this layer. fn uniforms(&self) -> [f32; LAYER_UNIFORM_FIELDS] { [if self.invert { 1.0 } else { 0.0 }, self.opacity] } } /// The ordered stack of local adjustments. #[derive(Debug, Clone, Default, PartialEq)] pub struct MaskStack { layers: Vec, } impl MaskStack { pub fn new() -> Self { Self::default() } pub fn layers(&self) -> &[MaskLayer] { &self.layers } pub fn layers_mut(&mut self) -> &mut [MaskLayer] { &mut self.layers } pub fn is_empty(&self) -> bool { self.layers.is_empty() } pub fn len(&self) -> usize { self.layers.len() } pub fn get(&self, id: &str) -> Option<&MaskLayer> { self.layers.iter().find(|l| l.id == id) } pub fn get_mut(&mut self, id: &str) -> Option<&mut MaskLayer> { self.layers.iter_mut().find(|l| l.id == id) } /// Add a layer, returning whether there was room for it. /// /// Refuses past [`MAX_LAYERS`] rather than dropping the oldest: a stack at /// its limit is a thing to tell the user about, and silently discarding /// work they can see on screen is the wrong way to handle it. pub fn push(&mut self, layer: MaskLayer) -> bool { if self.layers.len() >= MAX_LAYERS { log::warn!("mask stack is full ({MAX_LAYERS} layers); refusing to add another"); return false; } self.layers.push(layer); true } pub fn remove(&mut self, id: &str) -> Option { let i = self.layers.iter().position(|l| l.id == id)?; Some(self.layers.remove(i)) } /// Reorder, since later layers composite over earlier ones. pub fn move_to(&mut self, id: &str, index: usize) { let Some(from) = self.layers.iter().position(|l| l.id == id) else { return; }; let layer = self.layers.remove(from); self.layers.insert(index.min(self.layers.len()), layer); } /// The layers that will appear in the shader, in composite order. /// /// The index within *this* sequence is the texture-array layer the /// rasteriser must write, which is why both sides call this rather than /// indexing `layers` — an inactive layer occupies no mask slot, and the /// two halves disagreeing about that shows as an edit applied through the /// wrong mask. pub fn active(&self) -> impl Iterator { self.layers.iter().filter(|l| l.is_active()) } pub fn active_count(&self) -> usize { self.active().count() } /// Whether any layer changes any pixel. pub fn is_neutral(&self) -> bool { self.active_count() == 0 } /// Generate a fresh layer id that does not collide with an existing one. pub fn next_id(&self) -> String { (1..).map(|n| format!("m{n}")).find(|id| self.get(id).is_none()).expect("infinite range") } } /// One layer's contribution to the generated shader. pub(crate) struct LayerShader { pub uniform_fields: String, pub uniform_values: Vec, pub body: String, pub helpers: Vec, } /// Emit the WGSL for every active layer. /// /// `slot` is the layer's index in the mask texture array, matching /// [`MaskStack::active`]. pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader { let mut out = LayerShader { uniform_fields: String::new(), uniform_values: Vec::new(), body: String::new(), helpers: Vec::new(), }; if stack.active().next().is_some() { out.helpers.push(MASK_SAMPLER); } for (slot, layer) in stack.active().enumerate() { let prefix = format!("mask{slot}"); let _ = writeln!( out.uniform_fields, " // mask {slot}: {}\n {prefix}_invert: f32,\n {prefix}_opacity: f32,", layer.display_name() ); out.uniform_values.extend_from_slice(&layer.uniforms()); let _ = writeln!( out.body, "\n // ======== mask {slot}: {} ({}) ========", layer.display_name(), layer.source.kind() ); let _ = writeln!(out.body, " {{"); // **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source* // space, and `uv_src` is the source position this output pixel came // from — after the crop, the zoom, the pan, the straightening and the // flips. Sampling by output pixel instead, as this once did, pins the // mask to the viewport: zooming in slides the photograph under a mask // that stays where it was, and cropping moves the adjustment to a // different part of the picture. // // Doing it this way also means the framing map exists in exactly one // place. A second copy here would be a second thing to keep in step // with `Framing::wgsl_prologue`, and the failure would be a mask that // is subtly wrong only when straightened. let _ = writeln!( out.body, " var m = sample_mask(uv_src, {slot});" ); let _ = writeln!( out.body, " m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);" ); let _ = writeln!( out.body, " m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);" ); // Skipping the work where the mask is empty is most of the point of a // local adjustment: a mask covering a tenth of the frame should cost // about a tenth of the shader. Safe as non-uniform control flow — // nothing inside samples with derivatives or synchronises. let _ = writeln!(out.body, " if (m > 0.0) {{"); // `masked` is the outer-scope carrier: op fragments write to a `c` // they expect to own, so the inner block shadows `c` and copies the // result back out. Assigning the outer `c` from inside is not possible // precisely because it is shadowed. let _ = writeln!(out.body, " var masked = c;"); let _ = writeln!(out.body, " {{"); let _ = writeln!(out.body, " var c = masked;"); for op in layer.active_ops() { let id = op.descriptor().id.0; let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id)); let op_uniforms = op.uniforms(); if !op_uniforms.is_empty() { let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}"); } for u in &op_uniforms { let _ = writeln!(out.uniform_fields, " {op_prefix}_{}: f32,", u.name); out.uniform_values.push(u.value); } for h in op.helpers() { if !out.helpers.iter().any(|e| e.name == h.name) { out.helpers.push(*h); } } let mut fragment = op.wgsl_body(); for u in &op_uniforms { fragment = crate::operation::rewrite_uniform( &fragment, u.name, &format!("u.{op_prefix}_{}", u.name), ); } let _ = writeln!(out.body, " // ---- {id} ----"); let _ = writeln!(out.body, " {{"); for line in fragment.lines() { let _ = writeln!(out.body, " {line}"); } let _ = writeln!(out.body, " }}"); } let _ = writeln!(out.body, " masked = c;"); let _ = writeln!(out.body, " }}"); let _ = writeln!(out.body, " c = mix(c, masked, m);"); let _ = writeln!(out.body, " }}"); let _ = writeln!(out.body, " }}"); } out } /// A stable fingerprint of a segmentation, for [`MaskSource::Regions`]. /// /// Built from the things that change what a region id *means* — the proxy /// size, the region count, and the options the watershed ran with. Deliberately /// **not** a hash of the label field: that would be a readback on a path that /// must not have one (ARCH §6.1), and would also make the signature depend on /// float arithmetic whose cross-vendor determinism is exactly the open /// question (docs/segmentation.md §6, M5). pub fn segmentation_signature(width: u32, height: u32, regions: u32, tuning: u64) -> u64 { // FNV-1a over the four fields. Small, dependency-free, and adequate: this // guards against accidental mismatch, not against a forged sidecar. let mut h: u64 = 0xcbf2_9ce4_8422_2325; for word in [width as u64, height as u64, regions as u64, tuning] { for byte in word.to_le_bytes() { h ^= byte as u64; h = h.wrapping_mul(0x1000_0000_01b3); } } h } #[cfg(test)] mod tests { use super::*; use crate::descriptor::ParamId; fn regions(ids: &[u32]) -> MaskSource { MaskSource::Regions { signature: 7, level: 300, ids: ids.to_vec(), } } fn lit_layer(id: &str, ev: f32) -> MaskLayer { let mut layer = MaskLayer::new(id, regions(&[1, 2])); layer.set_param("exposure", ParamId("exposure"), ev); layer } #[test] fn a_layer_with_no_adjustment_is_not_in_the_shader() { let layer = MaskLayer::new("m1", regions(&[1])); assert!(!layer.is_active(), "a bare selection changes no pixel"); let mut stack = MaskStack::new(); stack.push(layer); assert!(stack.is_neutral()); assert_eq!(compose_layers(&stack).body, ""); } #[test] fn a_disabled_layer_is_omitted_but_kept() { let mut stack = MaskStack::new(); let mut layer = lit_layer("m1", 1.0); layer.enabled = false; stack.push(layer); assert_eq!(stack.active_count(), 0, "disabled layers do not render"); assert_eq!(stack.len(), 1, "but they are not deleted"); } #[test] fn zero_opacity_is_inactive() { let mut layer = lit_layer("m1", 1.0); layer.opacity = 0.0; assert!(!layer.is_active()); } #[test] fn the_generated_block_reads_its_own_mask_slot() { let mut stack = MaskStack::new(); stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m2", -1.0)); let shader = compose_layers(&stack); assert!(shader.body.contains("sample_mask(uv_src, 0)")); assert!(shader.body.contains("sample_mask(uv_src, 1)")); assert!(shader.body.contains("u.mask0_opacity")); assert!(shader.body.contains("u.mask1_opacity")); } /// The slot a layer renders through must follow `active()`, not the raw /// index — otherwise disabling layer 0 silently shifts every mask. #[test] fn slots_follow_active_order_not_stack_order() { let mut stack = MaskStack::new(); let mut off = lit_layer("m1", 1.0); off.enabled = false; stack.push(off); stack.push(lit_layer("m2", -1.0)); let shader = compose_layers(&stack); assert!( shader.body.contains("sample_mask(uv_src, 0)"), "the one active layer must use slot 0, not slot 1" ); assert!(!shader.body.contains("sample_mask(uv_src, 1)")); } #[test] fn each_layer_gets_its_own_uniforms() { let mut stack = MaskStack::new(); stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m2", -1.0)); let shader = compose_layers(&stack); assert!(shader.uniform_fields.contains("mask0_exposure_")); assert!(shader.uniform_fields.contains("mask1_exposure_")); assert_eq!( shader.uniform_values.len(), shader.uniform_fields.lines().filter(|l| l.trim_start().starts_with("mask")).count(), "one value per emitted field" ); } #[test] fn the_inner_block_shadows_c_and_copies_back() { let mut stack = MaskStack::new(); stack.push(lit_layer("m1", 1.0)); let body = compose_layers(&stack).body; assert!(body.contains("var masked = c;")); assert!(body.contains("var c = masked;")); assert!(body.contains("masked = c;")); assert!(body.contains("c = mix(c, masked, m);")); } #[test] fn a_full_stack_refuses_rather_than_dropping_work() { let mut stack = MaskStack::new(); for i in 0..MAX_LAYERS { assert!(stack.push(lit_layer(&format!("m{i}"), 1.0))); } assert!(!stack.push(lit_layer("overflow", 1.0))); assert_eq!(stack.len(), MAX_LAYERS); assert!(stack.get("overflow").is_none()); } #[test] fn ids_do_not_collide() { let mut stack = MaskStack::new(); assert_eq!(stack.next_id(), "m1"); stack.push(MaskLayer::new("m1", regions(&[1]))); assert_eq!(stack.next_id(), "m2"); } #[test] fn a_layer_from_another_segmentation_is_stale() { let layer = MaskLayer::new("m1", regions(&[1])); assert!(!layer.is_stale(7), "same signature is fine"); assert!(layer.is_stale(8), "a retuned segmentation invalidates ids"); // A gradient has no region ids, so nothing can go stale about it. let grad = MaskLayer::new( "m2", MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2 }, ); assert!(!grad.is_stale(999)); } #[test] fn signatures_separate_what_changes_a_region_id() { let base = segmentation_signature(1600, 1067, 6730, 2); assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2)); assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning"); assert_ne!(base, segmentation_signature(800, 1067, 6730, 2), "proxy size"); assert_ne!(base, segmentation_signature(1600, 1067, 42, 2), "region count"); } #[test] fn cloning_copies_parameters_not_handles() { let layer = lit_layer("m1", 1.5); let mut copy = layer.clone(); assert_eq!(copy, layer); copy.set_param("exposure", ParamId("exposure"), -1.0); assert_ne!(copy, layer, "the clone edits independently"); } #[test] fn params_reports_only_what_moved() { let layer = lit_layer("m1", 1.25); let moved: Vec<_> = layer.params().collect(); assert_eq!(moved, vec![("exposure", "exposure", 1.25)]); } #[test] fn reordering_moves_a_layer_within_the_stack() { let mut stack = MaskStack::new(); stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m2", 1.0)); stack.push(lit_layer("m3", 1.0)); stack.move_to("m3", 0); let order: Vec<&str> = stack.layers().iter().map(|l| l.id.as_str()).collect(); assert_eq!(order, ["m3", "m1", "m2"]); } }