Two things, and the second is why the first matters more than expected. Mask layers gain a feather, a falloff curve and a morphology, all defined against a signed distance from the boundary rather than as separate features — one exact distance field answers "how soft" and "how far" at once, so dilation is a threshold at -r, erosion one at +r, and closing and opening are one of each in sequence. The compound pair costs a second distance field, which is why they are named rather than presented as a radius that happens to be signed. Types, defaults and sidecar round-trip only; the field itself is next. `edge-feather` and `edge-falloff`, not `feather` and `falloff`, because a radial mask already writes `feather` for the fraction of its radius it ramps over. Same word, different quantity, different units — sharing the key would have made an existing file ambiguous. The diagnostic that provoked this is committed as an ignored test, because "does the ladder land on things a person means" is the question S15 exists to answer and it should not depend on whoever still has the script. On bus.jpg it answers badly: 35,075 regions at blur 2 over an 810x1080 frame, and cutting that to 400 gives *one* region covering nearly the whole picture plus 399 noise specks. Not over-segmentation — collapse. Almost every saddle is near zero, so the merge order joins everything meaningful before it joins anything spurious, and a global cut spends its entire budget on grain. So the granularity ladder does not currently work on a photograph, and the region masks built on it inherit that. Recorded rather than worked around: the next commits move local masking onto the model's instances, where the edge treatment above is what makes a quarter-resolution mask usable.
889 lines
32 KiB
Rust
889 lines
32 KiB
Rust
//! 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;
|
||
|
||
/// 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<Self> {
|
||
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<Self> {
|
||
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<u32>,
|
||
},
|
||
|
||
/// 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::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<Box<dyn Operation>>,
|
||
}
|
||
|
||
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<String>, 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<Item = &dyn Operation> {
|
||
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 {
|
||
matches!(self.source, MaskSource::Regions { signature, .. } if signature != current)
|
||
}
|
||
|
||
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<crate::graph::OpCapability> {
|
||
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<Item = (&'static str, &'static str, f32)> + '_ {
|
||
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<MaskLayer>,
|
||
}
|
||
|
||
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<MaskLayer> {
|
||
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<Item = &MaskLayer> {
|
||
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<f32>,
|
||
pub body: String,
|
||
pub helpers: Vec<crate::operation::Helper>,
|
||
}
|
||
|
||
/// 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(),
|
||
};
|
||
|
||
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, " {{");
|
||
let _ = writeln!(
|
||
out.body,
|
||
" var m = textureLoad(masks, vec2<i32>(gid.xy), {slot}, 0).r;"
|
||
);
|
||
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("textureLoad(masks, vec2<i32>(gid.xy), 0, 0)"));
|
||
assert!(shader.body.contains("textureLoad(masks, vec2<i32>(gid.xy), 1, 0)"));
|
||
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("gid.xy), 0, 0"),
|
||
"the one active layer must use slot 0, not slot 1"
|
||
);
|
||
assert!(!shader.body.contains("gid.xy), 1, 0"));
|
||
}
|
||
|
||
#[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"]);
|
||
}
|
||
}
|