Files
DarkRoom/core/dr-pipeline/src/mask.rs
T
dtourolle 696bafa9d5 Undefer AI subject masking, which shipped, and give it a clause
§7 still listed "AI subject masking — deferred per D11" while
MaskSource::Subject and MaskSource::Category, backed by dr-segment's
instance and semantic models, had been the primary way a local
adjustment is made for weeks. The code was tagged FR-DEV-3, which
names gradients and brushes and says nothing about a model.

FR-DEV-3i now states what exists: a subject or a category found by a
local model, stored as identity with the run's signature so that it
merges per field and reads as stale rather than wrong, then treated as
any other layer by the edge, stroke, composition and reveal clauses.
The one place it departs from FR-DEV-19 — coverage written run-length
coded beside the layer, so a stored subject renders without a model —
is recorded in the clause instead of left for the next audit to find.
The segmentation crate and the UI's selection module are tagged to it.
2026-09-19 12:25:03 +02:00

3055 lines
124 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, a gradient's geometry, or the points a finger travelled through
//! — and a pass on the device 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. [`Stroke`] is
//! where that promise is actually kept: a gesture reaches the GPU as a few
//! numbers and a list of coordinates, and no raster of it is built anywhere
//! else at any resolution.
//!
//! # 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.
//!
//! # And the raster that had to come back anyway
//!
//! The same reasoning was applied to [`MaskSource::Subject`] and
//! [`MaskSource::Category`], and there it went one step too far. A model's
//! coverage is reproducible in principle, but only by running the model — and
//! nothing runs one except a photographer pressing a button. So a stored
//! subject layer resolved to no pixels on every path that did not have a run
//! already in memory: reopening the photograph, and exporting it from the
//! grid, which never runs one at all.
//!
//! [`MaskLayer::coverage`] is the answer, and note what it is *not*: the
//! source still stores identity, still diffs as a handful of numbers, and
//! still merges per field. The raster sits beside it as a cache, takes no part
//! in equality, and is thrown away rather than trusted when it does not fit.
//! See [`crate::coverage`].
//!
//! # The masks that are not shapes
//!
//! Everything above selects by *where*: a region, an instance, a gradient's
//! geometry, the path a finger took. [`MaskSource::Luminance`] and
//! [`MaskSource::Colour`] select by *what a pixel is* — a band over the
//! photograph's own brightness or its own colour, with the edges of the band
//! soft (FR-DEV-10). That is what a luminosity mask has always been, and it is
//! why a local edit made through one blends without a halo: the boundary
//! follows the picture's values exactly, at whatever detail the picture has,
//! rather than an outline drawn near them.
//!
//! They store the band and nothing else, for precisely the reason the region
//! ids above are ids: five numbers diff, sync and merge per field under
//! FR-NC-9, where the pixels they select would be a blob for a merge to
//! arbitrate. And the pixels never need storing, because unlike a model's
//! coverage they are reproducible by anything that can read the photograph —
//! which is every path that renders one.
use std::fmt::Write as _;
use std::sync::Arc;
use crate::coverage::Coverage;
use crate::descriptor::{Attribute, 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;
/// The most a layer's [`MaskLayer::refine`] may be, in nats.
///
/// Mirrors `dr_segment::STRICTNESS_MAX`, and is a separate constant for the
/// same reason [`Falloff`] and [`Morphology`] are separate types from that
/// crate's: this one holds the *description* of an edit and must not depend on
/// the crate that runs a model. `dr-ui` is where the two meet, and it is the
/// only place that converts between them.
///
/// Used here to bound what a sidecar is allowed to claim. A file is not
/// trusted, and a strictness off the end of the scale would render as a
/// category that had silently deleted itself.
pub const MAX_REFINE: f32 = 8.0;
/// The most points one stroke keeps before a gesture continues as a new one.
///
/// This is a *cost* bound, not a storage one. Each stroke is drawn over its own
/// bounding box and the shader walks that stroke's segments once per pixel
/// inside it, so the work is `area(box) × segments`. Both grow with the length
/// of the gesture, so an unbroken stroke is quadratic in how far it travelled —
/// and one long scribble would cost more than the mask it draws is worth.
///
/// A gesture longer than this continues as a second stroke beginning where the
/// first ended, rather than stopping. A stroke that quietly stops recording
/// half way through a drag is the failure a painter notices immediately; the
/// cost of continuing is that the two overlap by one dab, so below a flow of 1
/// the join deposits twice. One dab in 256, against a stroke that dies under
/// the finger.
pub const MAX_STROKE_POINTS: usize = 256;
/// The most points one brush layer holds, across all of its strokes.
///
/// Bounds the sidecar as much as the rasteriser: a stroke is a line of text,
/// and this is roughly 50 kB of it in the worst case, which is a file a human
/// can still open. Painting past it refuses rather than dropping the oldest
/// strokes — the same rule [`MaskStack::push`] follows, for the same reason:
/// work the user can see on screen must not vanish without being told.
pub const MAX_LAYER_POINTS: usize = 4096;
/// TRACES: FR-DEV-10
/// How far a range mask fades in and out at each edge of its band.
///
/// In the band's own units — tone position for a luminance range, chroma or
/// turns of hue for a colour one — because that is the only place a range
/// mask has an edge. It has no boundary in the picture and therefore no
/// distance from one, which is why [`MaskLayer::feather`] cannot reach it.
///
/// The default is generous on purpose. A hard band over a photograph's values
/// finds the contour lines of the picture and draws them, and a tone contour
/// crossing a smooth sky is the one artefact a luminosity mask exists to
/// avoid. Softness is what turns the band from a threshold into a weighting.
pub const DEFAULT_RANGE_SOFTNESS: f32 = 0.15;
/// The widest a hue arc may be, in turns.
///
/// Half a turn each way is the whole circle, so anything past this is not a
/// selection — it is every colour, arrived at by a control the user thought
/// was narrowing something.
pub const MAX_HUE_WIDTH: f32 = 0.5;
/// Brush radius a new stroke starts at, as a fraction of the shorter edge.
pub const DEFAULT_BRUSH_RADIUS: f32 = 0.05;
/// Fraction of the radius that is fully covered before the edge falls away.
pub const DEFAULT_BRUSH_HARDNESS: f32 = 0.5;
/// How much of the brush one stroke deposits.
pub const DEFAULT_BRUSH_FLOW: f32 = 1.0;
/// The grid stroke coordinates are rounded to, as a divisor.
///
/// Points are snapped to it on the way in *and* written at that precision, so
/// the float in memory and the text on disk are the same number. A round trip
/// is then exact rather than nearly exact, and two devices that painted the
/// same gesture produce the same line instead of a diff of noise in the sixth
/// decimal — which under per-field merge (FR-NC-9) is a conflict over nothing.
///
/// A ten-thousandth of the frame is a sixth of a pixel at the proxy size a mask
/// rasterises at, and well under a pixel on a 24 MP export, so nothing survives
/// the rounding that could be seen.
const STROKE_GRID: f32 = 10_000.0;
/// How far a simplified stroke may stray from the one that was painted, as a
/// fraction of the brush radius.
///
/// A swept disc cannot express detail finer than its own radius: moving the
/// centre line by an eighth of `r` moves the painted edge by the same eighth,
/// which is inside the softest part of any brush that is not perfectly hard.
/// So the points that describe such detail are stored bytes that no pixel can
/// tell apart from their absence.
const SIMPLIFY_FRACTION: f32 = 0.125;
/// The closest two recorded points may be, as a fraction of the brush radius.
///
/// This is what actually bounds a stroke, and it applies while the finger is
/// down rather than afterwards. A touch screen reports around 120 positions a
/// second, so a finger held still for five seconds is six hundred points at the
/// same place; simplification would remove them, but only once the gesture
/// ended, and every frame until then would have rasterised all of them.
const MIN_STEP_FRACTION: f32 = 0.125;
/// Round to the stored grid. See [`STROKE_GRID`].
fn snap(v: f32) -> f32 {
(v * STROKE_GRID).round() / STROKE_GRID
}
/// Two band edges, clamped to `0..=1` and put the right way round.
///
/// Swapped rather than collapsed to a point: dragging one handle past the
/// other is how every range control in every editor is used to reverse a
/// selection's ends, and the alternative — clamping the lower bound against
/// the upper — makes the band stick at zero width and the drag stop dead.
fn ordered(lo: f32, hi: f32) -> (f32, f32) {
let (lo, hi) = (lo.clamp(0.0, 1.0), hi.clamp(0.0, 1.0));
if lo <= hi {
(lo, hi)
} else {
(hi, lo)
}
}
/// TRACES: FR-DEV-3
/// One painted stroke: a disc of radius `radius` swept along a polyline.
///
/// # Why parameters rather than pixels
///
/// This is the whole of ARCH §5.4. darktable stores drawn masks as strokes too
/// but rasterises them on the CPU, and the lag that produces is what users
/// describe as unworkable. What arrives on the GPU here is this struct: a few
/// numbers and a list of positions, from which a shader draws the mask. No
/// raster of a brush stroke is ever built in CPU memory, at any resolution, at
/// any point.
///
/// It is also why a stroke costs almost nothing to store, to diff, to merge and
/// to undo — a mask that had to be persisted as pixels would be none of those.
///
/// # Why not a distance field
///
/// `dr-segment` computes exact Euclidean distance fields on the CPU and
/// documents when that is right: once per mask edit, over input that is already
/// CPU-side. A stroke fails both halves — it changes continuously while the
/// finger moves, and its input is a handful of coordinates that never needed to
/// be pixels. And it needs no transform at all: the distance from a point to a
/// swept disc is the distance to the nearest segment of the polyline, which is
/// a closed form. A stroke is the one mask whose distance field is known
/// without computing one.
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
/// Whether this stroke takes coverage away instead of adding it.
///
/// Painting must be able to erase or a mask is one slip away from being
/// started again. An erasing stroke removes only what earlier strokes in
/// *this* layer deposited — it cannot cut a hole in a mask it is not part
/// of, because coverage below zero has no meaning.
pub erase: bool,
/// Radius as a fraction of the frame's **shorter edge**, matching
/// [`MaskLayer::feather`]. Normalised for the same reason: the same edit
/// renders to a viewport and to a 24 MP export, and a radius in pixels
/// would be a different brush in each.
pub radius: f32,
/// Fraction of the radius that is fully covered, `0.0..=1.0`. The rest is
/// the edge falling away to nothing.
pub hardness: f32,
/// How much coverage this stroke deposits where it is fully inside,
/// `0.0..=1.0`. Strokes below 1 build up over each other.
pub flow: f32,
/// The path, in normalised source coordinates — the same space the
/// gradients use, so a stroke survives a crop, a straighten and an export
/// at another size. A single point is a legitimate stroke: it is a tap, and
/// it paints one dab.
pub points: Vec<(f32, f32)>,
}
impl Stroke {
/// A stroke with no points yet, with its parameters clamped to what the
/// rasteriser can express.
pub fn new(erase: bool, radius: f32, hardness: f32, flow: f32) -> Self {
Self {
erase,
// A radius of zero would be a stroke that paints nothing at all,
// which is indistinguishable from the brush being broken.
radius: snap(radius.clamp(1e-4, 1.0)),
hardness: snap(hardness.clamp(0.0, 1.0)),
flow: snap(flow.clamp(0.0, 1.0)),
points: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
pub fn len(&self) -> usize {
self.points.len()
}
/// Whether this stroke is full and a gesture must continue in another.
pub fn is_full(&self) -> bool {
self.points.len() >= MAX_STROKE_POINTS
}
/// Record a position, returning whether it was kept.
///
/// Rejects anything closer to the last point than [`MIN_STEP_FRACTION`] of
/// the radius, which is what stops a stationary finger filling the stroke.
/// The first point is always kept, so a tap paints.
pub fn push_point(&mut self, x: f32, y: f32) -> bool {
if self.is_full() {
return false;
}
let p = (snap(x), snap(y));
if let Some(&(lx, ly)) = self.points.last() {
let step = self.radius * MIN_STEP_FRACTION;
if (p.0 - lx).abs() < step && (p.1 - ly).abs() < step {
return false;
}
}
self.points.push(p);
true
}
/// Drop the points that a disc of this radius cannot tell apart.
///
/// Run once, when the gesture ends — never while it is being painted, since
/// simplifying a path that is still growing would move points the user has
/// already seen drawn. Ramer–Douglas–Peucker, which is the one that keeps
/// the *shape*: dropping every other point instead would round off corners,
/// and a corner is where a painter aimed.
///
/// The tolerance is in normalised units while the radius is in shorter-edge
/// units, so on a frame that is not square the horizontal tolerance is
/// larger than intended by the aspect ratio. At an eighth of the radius
/// that leaves it near a fifth on a 3:2 frame, still inside the brush's own
/// edge, and the alternative is a model that has to be told the shape of a
/// photograph it is not part of.
pub fn simplify(&mut self) {
if self.points.len() < 3 {
return;
}
let tolerance = self.radius * SIMPLIFY_FRACTION;
let last = self.points.len() - 1;
let mut keep = vec![false; self.points.len()];
keep[0] = true;
keep[last] = true;
douglas_peucker(&self.points, 0, last, tolerance, &mut keep);
let mut i = 0;
self.points.retain(|_| {
let k = keep[i];
i += 1;
k
});
}
}
/// Mark the points needed to describe `points[first..=last]` within `tolerance`.
fn douglas_peucker(
points: &[(f32, f32)],
first: usize,
last: usize,
tolerance: f32,
keep: &mut [bool],
) {
if last <= first + 1 {
return;
}
let (ax, ay) = points[first];
let (bx, by) = points[last];
let (dx, dy) = (bx - ax, by - ay);
let len2 = dx * dx + dy * dy;
let mut worst = first;
let mut worst_d = 0.0f32;
for (i, &(px, py)) in points.iter().enumerate().take(last).skip(first + 1) {
// Distance to the *segment*, not to the infinite line: a stroke that
// doubles back has both ends in the same place, and a line through them
// is undefined. Clamping the projection makes that case the distance to
// the shared endpoint, which is the right answer rather than a NaN.
let d = if len2 <= f32::EPSILON {
((px - ax).powi(2) + (py - ay).powi(2)).sqrt()
} else {
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
((px - ax - t * dx).powi(2) + (py - ay - t * dy).powi(2)).sqrt()
};
if d > worst_d {
worst_d = d;
worst = i;
}
}
if worst_d > tolerance {
keep[worst] = true;
douglas_peucker(points, first, worst, tolerance, keep);
douglas_peucker(points, worst, last, tolerance, keep);
}
}
/// 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<f32>, layer: i32) -> f32 {
let dims = vec2<f32>(textureDimensions(masks));
let last = vec2<i32>(dims) - vec2<i32>(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<f32>(0.5);
let base = vec2<i32>(floor(t));
let f = fract(t);
let p0 = clamp(base, vec2<i32>(0), last);
let p1 = clamp(base + vec2<i32>(1), vec2<i32>(0), last);
let a = textureLoad(masks, vec2<i32>(p0.x, p0.y), layer, 0).r;
let b = textureLoad(masks, vec2<i32>(p1.x, p0.y), layer, 0).r;
let c = textureLoad(masks, vec2<i32>(p0.x, p1.y), layer, 0).r;
let d = textureLoad(masks, vec2<i32>(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<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,
];
}
/// TRACES: FR-DEV-3 | FR-DEV-3i
/// 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>,
},
/// 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 is several megabytes and
/// the fields below are what is needed to find it again. The pixels do go
/// in the sidecar as well, run-length coded beside the layer rather than
/// inside this variant, because "reproducible by running the model again"
/// turned out to mean "absent everywhere a model has not been run" — see
/// [`MaskLayer::coverage`].
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,
},
/// Every pixel of one photographic category, from the scene model.
///
/// The counterpart to [`Self::Subject`], and the difference is the whole
/// reason both exist. A subject is *one* instance — this dog, not that one
/// — found by a COCO-trained instance model. A category is *all* the sky,
/// or all the foliage, from an ADE20K-trained semantic model that has no
/// notion of instances at all (docs/segmentation.md §16).
///
/// So this is what a global grade attaches to: lift the sky, desaturate
/// the vegetation, warm the architecture. Asking it for "that person
/// rather than the other two" is a category error — the model merged them
/// before the mask ever existed, and [`Self::Subject`] is the source for
/// that question.
///
/// Stored as identity like a subject, and the coverage travels beside it
/// for the same reason — see [`MaskLayer::coverage`].
Category {
/// Which segmentation run produced it, so a layer can tell whether
/// the name below still refers to something that was computed.
signature: u64,
/// The category name from `models/scene/categories.txt` — "sky",
/// "vegetation".
///
/// A name rather than an index because the descriptor is editable: a
/// category added to it would silently renumber every layer stored
/// against an index, and the failure would be a mask quietly grading
/// the wrong thing. A name that no longer exists is simply not found,
/// and the layer reads as stale.
name: String,
},
/// A linear gradient — the graduated-filter mask.
///
/// Geometry is in **normalised source coordinates**, so it survives a crop,
/// a zoom or an export at another size: the mask is rasterised in source
/// space and sampled through the framing map, exactly as a subject mask is.
/// Storing pixels would make a mask that silently moves when the frame
/// changes.
///
/// # Two spaces, and why both are here
///
/// The **centre** is a point, so it is a plain `0.0..=1.0` fraction of each
/// axis — the same coordinates a click carries.
///
/// The **angle and the width are measurements**, and a fraction of the
/// width is not the same length as a fraction of the height on any frame
/// that is not square. So they are in the frame's *isotropic* units: y
/// spans `0..1` and x spans `0..aspect`, which is what makes 45° actually
/// 45° and a circle actually round. `frame_delta` in `mask.wgsl` is the
/// one place that conversion happens, and it must stay the only one.
Linear {
/// Midpoint of the ramp, `0.0..=1.0` in each axis.
centre: (f32, f32),
/// Radians, measured clockwise from the +x axis in frame units.
/// Coverage increases in this direction.
angle: f32,
/// Distance from full effect to none, in frame units. Zero is a hard
/// edge.
width: f32,
},
/// A radial gradient — the classic vignette-shaped local adjustment.
///
/// The same two spaces as [`Self::Linear`]: a `0..1` centre, and semi-axes
/// measured in the frame's isotropic units so equal radii draw a circle.
Radial {
centre: (f32, f32),
/// Semi-axes, in frame units. Two of them, because a face is an
/// ellipse and forcing a circle makes the user compensate with a crop.
radii: (f32, f32),
/// Rotation of the ellipse, radians.
angle: f32,
/// Fraction of the radius over which the edge falls off.
feather: f32,
},
/// Painted strokes — the drawn mask (FR-DEV-3, ARCH §5.4).
///
/// Ordered, and the order is the meaning: each stroke composites over what
/// the ones before it left, so an erase after an add removes it and the
/// same two the other way round do not. Reordering them would be editing
/// the mask.
///
/// Geometry is normalised like the gradients', so a stroke stays on the
/// thing it was painted on through a crop, a straighten and an export at
/// any size.
Brush { strokes: Vec<Stroke> },
/// TRACES: FR-DEV-10
/// Every pixel whose brightness falls in a band — the luminosity mask.
///
/// The first mask in this enum that is not a shape. A gradient, a stroke
/// and a region all answer "where"; this answers "how bright", and the
/// selection that comes out has the edge the *photograph* has rather than
/// an edge somebody drew near it. That is the whole reason a local edit
/// made through one blends: there is no boundary to halo along.
///
/// # Why the band is in perceptual position, not in linear light
///
/// The bounds are positions on the same 0..1 lightness scale
/// `ops/_helpers.yaml`'s `tone_position` establishes, which is the cube
/// root of luminance. Linear light puts middle grey at 0.18, so a band
/// stated in it would call four fifths of the range "shadow" and the
/// slider's useful travel would be its first inch. The stored numbers are
/// where the eye puts them, which is also where the control puts them.
///
/// # Which image it measures
///
/// The photograph as captured, before this edit — not the edited result.
/// The mask is rasterised in its own pass, ahead of the adjustment that
/// samples it, so there is no arrangement in which it could read its own
/// output. That is a property worth having rather than a limitation
/// worked around: a band over the edited image would slide out from under
/// the edit as the edit was made, so raising the highlights would change
/// which pixels counted as highlights, and the slider would fight itself.
Luminance {
/// Lower edge of the band, as a tone position in `0.0..=1.0`.
lo: f32,
/// Upper edge, in the same units. Never below `lo` — see
/// [`MaskSource::luminance_range`], which is where that is kept true.
hi: f32,
/// How far the band fades in below `lo` and out above `hi`, in the
/// same units. Zero is a threshold, and a threshold over a
/// photograph's own tones draws contour lines.
softness: f32,
},
/// TRACES: FR-DEV-10
/// Every pixel whose colour falls in a band — the skin-tone mask.
///
/// The counterpart to [`Self::Luminance`], and the pair is not symmetric:
/// brightness is one number and colour is two, so this carries an arc of
/// hue *and* a range of chroma. Both are needed, and the chroma half is
/// the one that is easy to leave out. Hue is meaningless as a colour
/// approaches grey — a nearly-neutral wall has a hue, arrived at by
/// rounding — so an arc alone selects a haze of noise everywhere the
/// picture is desaturated. The lower chroma bound is what keeps that out,
/// and it is why the default sits well above zero.
///
/// Measured in linear sRGB, after the camera matrix, because that is the
/// only space in which a stated hue means the colour the photographer
/// named. Camera RGB is the body's own primaries: the same arc would
/// select a different set of colours on every make of sensor, and a skin
/// tone stored on one body would land on foliage on another.
Colour {
/// Centre of the accepted arc, in turns of the hue circle,
/// `0.0..1.0`. Wraps, so 0.98 and 0.02 are close together.
hue: f32,
/// Half-width of the arc, in turns. Bounded by [`MAX_HUE_WIDTH`].
hue_width: f32,
/// Lower edge of the accepted chroma, `0.0..=1.0`, in the
/// max-minus-min sense `colour_saturation` uses.
chroma_lo: f32,
/// Upper edge of the accepted chroma. Kept above `chroma_lo` by
/// [`MaskSource::colour_range`].
chroma_hi: f32,
/// The fade at every edge of the band — both ends of the arc and both
/// ends of the chroma range. One number rather than three: they are
/// the same control to a photographer, who is choosing how strictly
/// the selection is drawn, not tuning three of them against each
/// other.
softness: 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::Category { .. } => "category",
Self::Linear { .. } => "linear",
Self::Radial { .. } => "radial",
Self::Brush { .. } => "brush",
Self::Luminance { .. } => "luminance",
Self::Colour { .. } => "colour",
}
}
/// An empty brush mask, ready to be painted into.
pub fn brush() -> Self {
Self::Brush {
strokes: Vec::new(),
}
}
/// TRACES: FR-DEV-10
/// A luminance band, with the bounds put in order and inside the scale.
///
/// **The only way one is built**, including on the way in from a sidecar.
/// The invariants a band has — bounds inside `0..1`, `lo` at or below
/// `hi`, softness not negative — are cheap to state once and impossible
/// to remember at four call sites. A crossed pair is the interesting one:
/// it comes from a user dragging the lower handle past the upper, and a
/// shader given it would select nothing at all rather than the thin band
/// the gesture plainly meant.
pub fn luminance_range(lo: f32, hi: f32, softness: f32) -> Self {
let (lo, hi) = ordered(lo, hi);
Self::Luminance {
lo,
hi,
softness: softness.clamp(0.0, 1.0),
}
}
/// TRACES: FR-DEV-10
/// A colour band, clamped and ordered like [`Self::luminance_range`].
///
/// The hue is wrapped rather than clamped, because the hue circle has no
/// ends: a control dragged past red comes back round to red, where
/// clamping would stick it there and make the far side of the circle
/// unreachable from one direction.
pub fn colour_range(
hue: f32,
hue_width: f32,
chroma_lo: f32,
chroma_hi: f32,
softness: f32,
) -> Self {
let (chroma_lo, chroma_hi) = ordered(chroma_lo, chroma_hi);
Self::Colour {
hue: hue.rem_euclid(1.0),
hue_width: hue_width.clamp(0.0, MAX_HUE_WIDTH),
chroma_lo,
chroma_hi,
softness: softness.clamp(0.0, 1.0),
}
}
/// TRACES: FR-DEV-10
/// The band a new luminance layer starts on: the brighter half.
///
/// Not the whole scale, which would select everything and read as a mask
/// that had failed to do anything. The upper half is what a photographer
/// reaches for first — holding back a sky, shaping a highlight — and it
/// is immediately visible on almost any photograph, so the control the
/// user then drags starts from something they can see.
pub fn highlights() -> Self {
Self::luminance_range(0.5, 1.0, DEFAULT_RANGE_SOFTNESS)
}
/// TRACES: FR-DEV-10
/// The band a new colour layer starts on: the orange–red arc skin sits in.
///
/// Opinionated, and deliberately. Selecting by colour is a general tool
/// and skin is the reason most people reach for it, so starting there
/// means the first thing the user sees is the answer to the question they
/// asked; starting at red, or at whatever hue zero happens to be, would
/// mean the control had to be understood before it did anything.
pub fn skin_tones() -> Self {
Self::colour_range(0.06, 0.05, 0.15, 1.0, DEFAULT_RANGE_SOFTNESS)
}
/// TRACES: FR-DEV-10
/// Whether this mask selects by a property rather than by a shape.
///
/// Asked by the panel, which offers a band's controls for one and a
/// shape's for the other, and by [`MaskLayer::is_stale`].
pub fn is_range(&self) -> bool {
matches!(self, Self::Luminance { .. } | Self::Colour { .. })
}
/// The strokes, or nothing for a source that is not painted.
pub fn strokes(&self) -> &[Stroke] {
match self {
Self::Brush { strokes } => strokes,
_ => &[],
}
}
}
/// The most parts one layer's mask may be built from.
///
/// Not for the reason [`MAX_LAYERS`] exists: parts cost no VRAM, since they
/// all fold into the one slice their layer owns. What bounds them is that each
/// is a draw over the frame, and that a selection which took nine steps to
/// describe cannot be read back off a panel by the person who made it.
pub const MAX_PARTS: usize = 8;
/// The id a layer's first part is created with.
///
/// Named because two places have to agree about it: a layer built here, and a
/// sidecar block that carries no part of its own and is therefore read as one
/// (see [`crate::sidecar`]). A literal in both would eventually be a literal
/// in one.
pub const FIRST_PART_ID: &str = "p1";
/// How a part joins the mask built before it.
///
/// The first part of a layer has nothing to join to, so its value is ignored
/// rather than special-cased away: a part is one shape everywhere, and the
/// fold that builds a mask starts from nothing covered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Join {
/// Everything either covers. "Add to this mask" — which is what a hand
/// correction over a model's guess nearly always is.
#[default]
Union,
/// What the mask had, minus this. "Take this out of it."
///
/// Not the same control as an erase stroke, though the two overlap where
/// both are painted. A subtracted part can be switched off, reshaped or
/// removed a week later, and it says on the panel what it took away; an
/// erase stroke is a hole in the part it was painted into and reads as
/// nothing at all.
Subtract,
}
impl Join {
pub fn name(self) -> &'static str {
match self {
Self::Union => "union",
Self::Subtract => "subtract",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"union" => Self::Union,
"subtract" => Self::Subtract,
_ => return None,
})
}
/// Every variant, for a UI building a choice control.
pub const ALL: [Join; 2] = [Join::Union, Join::Subtract];
}
/// One selection inside a layer's mask.
///
/// # Why a layer is a list of these and not a source
///
/// A mask made of exactly one selection is the case a model never quite
/// produces. Its coverage arrives approximately right — stopping inside a
/// shoulder, leaking into the hair — and the controls that shape it move the
/// *whole* boundary, so no value of [`Self::feather`] or [`Self::morphology`]
/// fixes two errors that go opposite ways. What fixes them is a second
/// selection joined to the first: a stroke painted where the model stopped
/// short, a shape cut out where it leaked.
///
/// The shaping therefore lives here rather than on the layer. Two parts of one
/// mask routinely want different edges — a model's soft coverage joined to a
/// correction that must be exactly where the finger went — and one feather
/// over both is the same failure one boundary further out.
///
/// [`MaskLayer::invert`] and [`MaskLayer::opacity`] stay on the layer. They
/// are the two uniforms the composed shader reads and they apply to the
/// mask every part has finished building, which is a different question from
/// how one part enters it.
#[derive(Clone)]
pub struct MaskPart {
/// Stable identity **within its layer**, for the sidecar and for merge
/// (FR-NC-9). Two devices that each add a part to the same layer choose
/// different ids, so the merge keeps both rather than arbitrating between
/// them.
pub id: String,
/// How this part joins the mask built so far. Ignored on the first part.
pub join: Join,
pub source: MaskSource,
/// Swap this part's inside for its outside, before it is joined.
///
/// Distinct from [`MaskLayer::invert`], and both are wanted: inverting a
/// subtracted gradient keeps everything to one side of a line, where
/// inverting the layer keeps everything the layer did not select.
pub invert: bool,
/// TRACES: FR-DEV-19a
/// Left out of the build without being deleted.
///
/// The A/B a *part* wants is different from the layer's: the question is
/// not "what does this adjustment do" but "what did this correction do to
/// the selection" — whether the stroke that was meant to fill in a
/// shoulder did, or the subtracted gradient took the sky it was aimed at
/// and nothing else. Removing the part answers that and loses it; this
/// answers it and keeps it.
///
/// Hidden parts are skipped where the mask is built, so a hidden base
/// leaves the first shown part to open the fold — and a mask whose every
/// adding part is hidden covers nothing, exactly as if they were gone.
pub hidden: 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 this part before its 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,
/// How strictly a category source is cut back to the pixels whose colour
/// agrees with it, in nats. Zero leaves the model's own weighting alone.
///
/// Only [`MaskSource::Category`] has anything to apply it to — the
/// evidence is fitted per category during segmentation — so it sits at
/// zero and out of the panel for every other source.
///
/// A *quantity*, not a 0-to-100: it is how much more plausible than the
/// alternatives a colour must be before its weight is kept, which is why
/// `dr_segment::refine` can state where a flag falls on it and where a
/// cloud does. Bounded by [`MAX_REFINE`].
///
/// This is shaping, like [`Self::feather`], and it lives on the part for
/// the same reason: two parts may sit on the same category and want
/// different amounts of it, and the model ran once for both.
pub refine: f32,
/// The pixels this part covered, when a model produced them and they were
/// worth storing.
///
/// # Beside the source, not inside it
///
/// [`MaskSource::Subject`] and [`MaskSource::Category`] are *identity* —
/// which run, which instance, which category — and that is what makes them
/// diffable, small, and mergeable per field under FR-NC-9. Putting a
/// raster inside either variant would make two devices that selected the
/// same dog hold different values for the same selection, and the merge
/// would then have a binary blob to arbitrate rather than an index.
///
/// So this sits alongside as what it actually is: a **materialisation** of
/// the source, produced by a run of a model this crate has never heard of
/// and knows nothing about. `MaskSource` still says what the part means;
/// this says what that meant last time anybody worked it out. The
/// distinction is why it takes no part in [`PartialEq`] — a part with the
/// pixels cached and one without are the same edit, and a merge that
/// called them different would raise a conflict over a cache.
///
/// # Why it exists at all
///
/// Without it a stored subject or category renders as nothing until
/// somebody presses "find subjects" — so reopening a photograph dropped
/// its local adjustments, and a batch export, which never runs a model,
/// could not have them at any point. See [`crate::coverage`].
///
/// Shared rather than owned because the undo stack holds a snapshot per
/// step and a part is cloned by value; an `Arc` makes recording a slider
/// drag cost a refcount rather than a copy of every mask in the stack.
/// Only ever set for the two model sources — nothing else has a model
/// behind it to cache.
pub coverage: Option<Arc<Coverage>>,
}
impl MaskPart {
/// A new part over `source`, shaped the way a new layer always was.
pub fn new(id: impl Into<String>, join: Join, source: MaskSource) -> Self {
Self {
id: id.into(),
join,
source,
invert: false,
hidden: false,
// 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,
// Off, so a part built here is the model's own weighting. The
// useful starting point for a *category* is not zero, but it is
// `dr_segment`'s number to state and this crate does not depend on
// it — `Session::add_category_mask` sets it on the way in.
refine: 0.0,
// Nothing has run yet. Filled in the first time a model's coverage
// is turned into a distance field — see [`Self::coverage`].
coverage: None,
}
}
/// An empty painted part — a hand correction waiting for its first stroke.
pub fn painted(id: impl Into<String>, join: Join) -> Self {
Self::new(id, join, MaskSource::brush())
}
/// Whether this part could cover any pixel at all.
///
/// Only a brush can answer no, and it matters more than the draw it saves.
/// An unpainted part is empty, [`Self::invert`] turns empty into
/// everything, and a part created with invert already set would apply its
/// layer's adjustment to the whole photograph before a single stroke was
/// made — the loud, wrong-looking failure this codebase avoids everywhere
/// else a mask can go missing.
///
/// A range answers yes, and it is worth saying why rather than leaving it
/// to the catch-all. A band *can* be empty on a particular photograph — a
/// highlight band over a picture with no highlights in it — but nothing
/// on this side could know that without rasterising the mask, which is the
/// one thing this module promises never to do. So the honest answer is
/// that the part selects whatever the picture has, and an empty result is
/// the picture's answer rather than the part's.
pub fn covers(&self) -> bool {
match &self.source {
MaskSource::Brush { strokes } => strokes.iter().any(|s| !s.erase && !s.is_empty()),
_ => true,
}
}
/// Whether this part's 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, .. }
| MaskSource::Category { 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. Painted strokes are the same: they are
// where the user put them, not where a model thought something was.
//
// A range is the strongest case of all: it is a band over the
// photograph's own values, so it does not merely survive a new
// segmentation — there is nothing a model could ever say that
// would change which pixels it names.
MaskSource::Linear { .. }
| MaskSource::Radial { .. }
| MaskSource::Brush { .. }
| MaskSource::Luminance { .. }
| MaskSource::Colour { .. } => false,
}
}
/// The strokes on this part, empty for any other kind of source.
pub fn strokes(&self) -> &[Stroke] {
self.source.strokes()
}
/// How many stroke points this part is holding.
pub fn stroke_points(&self) -> usize {
self.strokes().iter().map(Stroke::len).sum()
}
/// Start a stroke, returning whether there was room for it.
///
/// `room` is how many points the *layer* has left ([`MAX_LAYER_POINTS`]),
/// passed in rather than counted here because the budget belongs to the
/// layer: a photographer who has painted four thousand points has painted
/// them across the whole mask, and a part that counted only its own would
/// let the layer past its limit one part at a time.
pub fn begin_stroke(
&mut self,
room: usize,
erase: bool,
radius: f32,
hardness: f32,
flow: f32,
) -> bool {
let MaskSource::Brush { strokes } = &mut self.source else {
log::warn!(
"part {} is a {} mask, not a brush",
self.id,
self.source.kind()
);
return false;
};
if room == 0 {
log::warn!(
"this mask is full ({MAX_LAYER_POINTS} points); refusing to start another stroke"
);
return false;
}
strokes.push(Stroke::new(erase, radius, hardness, flow));
true
}
/// Add a position to the stroke in progress, returning whether it was kept.
///
/// A position may be dropped for being too close to the last one, which is
/// ordinary and not a failure. When the stroke in progress fills up the
/// gesture continues in a new one starting at the same point, so the swept
/// path has no gap in it — see [`MAX_STROKE_POINTS`].
pub fn extend_stroke(&mut self, room: usize, x: f32, y: f32) -> bool {
let MaskSource::Brush { strokes } = &mut self.source else {
return false;
};
let Some(current) = strokes.last_mut() else {
return false;
};
if room == 0 {
return false;
}
if current.is_full() {
// Simplified here rather than in `end_stroke`, which only ever sees
// the last stroke of a gesture: a continuation closes the one
// before it for good, and an unsimplified stroke would reach the
// sidecar at full sampling — the one place the saving matters most,
// since a gesture long enough to split is a long line of text.
current.simplify();
let mut next = Stroke::new(
current.erase,
current.radius,
current.hardness,
current.flow,
);
if let Some(&joint) = current.points.last() {
next.points.push(joint);
}
strokes.push(next);
}
strokes
.last_mut()
.expect("a stroke was just ensured")
.push_point(x, y)
}
/// Finish the stroke in progress, simplifying it.
///
/// A stroke that recorded nothing is dropped rather than kept as an empty
/// one: a press with no movement still records its first point, so an empty
/// stroke can only be a press that never reached the model, and leaving it
/// would put a stroke in the sidecar that draws nothing.
pub fn end_stroke(&mut self) {
let MaskSource::Brush { strokes } = &mut self.source else {
return;
};
match strokes.last_mut() {
Some(s) if s.is_empty() => {
strokes.pop();
}
Some(s) => s.simplify(),
None => {}
}
}
/// Remove the most recent stroke, returning it.
///
/// The undo history already snapshots the whole graph, so this is not how
/// undo works — it is for the interaction layer to abandon a stroke it has
/// begun, when a gesture turns out to be a pinch or is cancelled.
pub fn drop_last_stroke(&mut self) -> Option<Stroke> {
match &mut self.source {
MaskSource::Brush { strokes } => strokes.pop(),
_ => None,
}
}
}
impl std::fmt::Debug for MaskPart {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MaskPart")
.field("id", &self.id)
.field("join", &self.join)
.field("source", &self.source)
.field("invert", &self.invert)
.field("feather", &self.feather)
.field("falloff", &self.falloff)
.field("morphology", &self.morphology)
.field("coverage", &self.coverage.as_ref().map(|c| c.encoded_len()))
.finish()
}
}
impl PartialEq for MaskPart {
/// Every field that is the *edit*, and deliberately not
/// [`Self::coverage`] — see the field for why a cached raster must not
/// count as a difference between two devices.
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.join == other.join
&& self.source == other.source
&& self.invert == other.invert
&& self.hidden == other.hidden
&& self.feather == other.feather
&& self.falloff == other.falloff
&& self.morphology == other.morphology
&& self.morph_radius == other.morph_radius
}
}
/// 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,
/// Swap inside for outside, once every part has been joined.
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,
/// What the mask is made of, in the order it is built.
///
/// **Never empty.** `parts[0]` is the selection the layer began as, and
/// removing it is removing the layer — which is why this is private and
/// [`Self::remove_part`] refuses to. Everything else reaches it through
/// [`Self::base`], [`Self::parts`] and [`Self::push_part`], none of which
/// can leave a layer with nothing selected.
parts: Vec<MaskPart>,
/// 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>>,
}
/// The chain a mask layer holds: every point operation, and neither the
/// neighbourhood ones nor the optical corrections.
///
/// A layer's adjustments are fused into the colour dispatch and multiplied by
/// the mask afterwards, which is exactly why a layer needs no per-operation
/// support — the composer already knows how to turn a chain into WGSL. A
/// neighbourhood operation cannot go through that path at all: it runs as its
/// own dispatch in [`crate::detail`], after the fused pass and after the masks
/// have already been applied, and there is nowhere in that arrangement for it
/// to be given one layer's mask.
///
/// Left in, it would be worse than absent. `Operation::wgsl_body` returns an
/// empty string for a detail operation, so the layer would emit an empty block
/// and the panel — which builds itself from [`MaskLayer::capabilities`] and
/// names no operation — would offer a slider that moved and did nothing.
/// Filtering here means a local sharpening or denoise control simply does not
/// appear until there is a stage that can honour it, which is the honest
/// state of affairs.
/// **The optical corrections are excluded on their own grounds**, which are
/// not the neighbourhood argument above: they would work perfectly and mean
/// nothing.
///
/// `Attribute::Optics` describes what the *lens* did to the whole frame. Its
/// corrections are radial about the optical axis, and the `radius` they read
/// is the distance from the centre of the photograph — a layer's mask does not
/// and cannot move it. So a vignetting slider inside a mask would not correct
/// the falloff within the selected region; it would lay a frame-centred radial
/// ramp over the picture and then multiply it by the mask. That is a
/// well-defined operation nobody would ever want, and worse, it is one whose
/// name promises something else entirely.
///
/// The general rule the two exclusions share: a layer holds an operation only
/// where restricting it to a region is a thing a photographer could mean.
fn layer_chain() -> Vec<Box<dyn Operation>> {
ops::chain()
.into_iter()
.filter(|o| o.detail().is_none())
.filter(|o| !o.descriptor().attributes.contains(&Attribute::Optics))
.collect()
}
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 = layer_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(),
invert: self.invert,
opacity: self.opacity,
enabled: self.enabled,
// Deep, but the only deep thing in it is a refcount: a part's
// coverage is an `Arc`. See the field.
parts: self.parts.clone(),
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("invert", &self.invert)
.field("opacity", &self.opacity)
.field("enabled", &self.enabled)
.field("parts", &self.parts)
.field("active_ops", &self.active_ops().count())
.finish()
}
}
impl PartialEq for MaskLayer {
/// Every field that is the *edit*, which for the parts means everything
/// except their cached coverage — see [`MaskPart::coverage`].
///
/// This comparison is what [`crate::sidecar::Version::merge`] uses to
/// decide whether a device changed a layer (FR-NC-9). A cached raster is
/// not something a photographer changed: one device that has run the model
/// and one that has not hold the same edit, and counting the difference
/// would raise a conflict over a cache — and, with `remote_wins`, could
/// answer it by discarding the only copy of the pixels.
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.name == other.name
&& self.invert == other.invert
&& self.opacity == other.opacity
&& self.enabled == other.enabled
&& self.parts == other.parts
&& 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(),
invert: false,
opacity: 1.0,
enabled: true,
parts: vec![MaskPart::new(FIRST_PART_ID, Join::Union, source)],
ops: layer_chain(),
}
}
/// A layer built from parts that already exist — how a sidecar arrives.
///
/// `None` for an empty list, because a layer with nothing selected is not
/// a mask: everything downstream assumes a base part exists, and inventing
/// one here would put an empty brush on the panel under the name of
/// whatever the file said.
pub fn from_parts(id: impl Into<String>, parts: Vec<MaskPart>) -> Option<Self> {
if parts.is_empty() {
return None;
}
Some(Self {
id: id.into(),
name: String::new(),
invert: false,
opacity: 1.0,
enabled: true,
parts,
ops: layer_chain(),
})
}
/// The selection this layer began as — `parts[0]`.
///
/// Named rather than indexed at every call site because the invariant it
/// relies on (a layer always has one) is this module's to keep, not its
/// callers'.
pub fn base(&self) -> &MaskPart {
self.parts.first().expect("a layer always has a base part")
}
pub fn base_mut(&mut self) -> &mut MaskPart {
self.parts
.first_mut()
.expect("a layer always has a base part")
}
/// Every part, in the order the mask is built from them.
pub fn parts(&self) -> &[MaskPart] {
&self.parts
}
pub fn parts_mut(&mut self) -> &mut [MaskPart] {
&mut self.parts
}
pub fn part(&self, index: usize) -> Option<&MaskPart> {
self.parts.get(index)
}
pub fn part_mut(&mut self, index: usize) -> Option<&mut MaskPart> {
self.parts.get_mut(index)
}
/// The part with this id, which is how the sidecar and a merge name one.
pub fn part_by_id(&self, id: &str) -> Option<&MaskPart> {
self.parts.iter().find(|p| p.id == id)
}
pub fn part_by_id_mut(&mut self, id: &str) -> Option<&mut MaskPart> {
self.parts.iter_mut().find(|p| p.id == id)
}
/// Add a part, returning whether there was room ([`MAX_PARTS`]).
///
/// Refuses rather than dropping the oldest, the rule [`MaskStack::push`]
/// follows and for the same reason: work the user can see on screen must
/// not vanish without being told.
pub fn push_part(&mut self, part: MaskPart) -> bool {
if self.parts.len() >= MAX_PARTS {
log::warn!("layer {} already has {MAX_PARTS} parts", self.id);
return false;
}
self.parts.push(part);
true
}
/// Remove a part, returning it. **Never the base.**
///
/// Removing the selection a layer began as is removing the layer, and a
/// panel that offered both would be offering the same destruction twice
/// under two names — with the version that silently promoted the second
/// part to base being the one nobody could predict.
pub fn remove_part(&mut self, index: usize) -> Option<MaskPart> {
if index == 0 || index >= self.parts.len() {
return None;
}
Some(self.parts.remove(index))
}
/// A part id no other part of this layer is using.
pub fn next_part_id(&self) -> String {
(1..)
.map(|n| format!("p{n}"))
.find(|id| self.part_by_id(id).is_none())
.expect("infinite range")
}
/// The name to show, falling back to the base part's source kind.
pub fn display_name(&self) -> &str {
if self.name.is_empty() {
self.base().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() && self.covers()
}
/// Whether this mask could cover any pixel at all.
///
/// Asked of the parts that *add*, because those are the only ones that can
/// put a pixel in the mask: a layer whose base is an unpainted brush and
/// whose other parts all subtract covers nothing however many of them
/// there are, and rasterising it would cost a slice to draw empty.
fn covers(&self) -> bool {
// A hidden part is not in the build, whichever way it joins — and a
// hidden base hands its role to the first part that is shown, which
// is why "adds" is asked of the shown parts rather than of index 0.
self.shown_parts()
.enumerate()
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
}
/// TRACES: FR-DEV-19a
/// The parts that take part in the build, in order — every part that is
/// not [`MaskPart::hidden`]. The first of these opens the fold whatever
/// its index in [`Self::parts`], so a renderer walks this rather than
/// filtering for itself and getting the "first" wrong.
pub fn shown_parts(&self) -> impl Iterator<Item = &MaskPart> {
self.parts.iter().filter(|p| !p.hidden)
}
/// The operations in this layer's chain that reach the shader.
///
/// Neighbourhood operations are excluded, and not as an oversight. A
/// layer's chain is *fused into the point-operation pass* and multiplied
/// by the mask afterwards; the detail stage runs once, over the whole
/// frame, after that pass has finished (see [`crate::detail`]). There is
/// nowhere in that arrangement for a sharpening confined to one mask to
/// happen, so a detail operation in a layer would contribute an empty
/// block, count towards [`Self::is_active`], and cost a mask rasterisation
/// to change nothing. Dropping it here means the layer reports honestly
/// that it has no adjustment rather than appearing to have one.
pub fn active_ops(&self) -> impl Iterator<Item = &dyn Operation> {
self.ops
.iter()
.map(|o| o.as_ref())
.filter(|o| o.is_active() && o.detail().is_none())
}
/// Whether any part of this mask belongs to a different segmentation.
///
/// One stale part is a stale layer: the mask is a fold over all of them,
/// so a part that would draw a confidently wrong shape makes the result
/// wrong whether it adds or subtracts.
pub fn is_stale(&self, current: u64) -> bool {
self.parts.iter().any(|p| p.is_stale(current))
}
/// The strokes on the base part, empty for any other kind of mask.
pub fn strokes(&self) -> &[Stroke] {
self.base().strokes()
}
/// How many stroke points this layer is holding, across every part.
/// See [`MAX_LAYER_POINTS`].
pub fn stroke_points(&self) -> usize {
self.parts.iter().map(MaskPart::stroke_points).sum()
}
/// How many more points this layer may record.
///
/// The budget is the layer's rather than the part's, which is what stops a
/// mask made of six painted corrections holding six times the points a
/// single painted layer may.
pub fn room(&self) -> usize {
MAX_LAYER_POINTS.saturating_sub(self.stroke_points())
}
/// Start a stroke in the given part, returning whether there was room.
///
/// The interaction layer calls this on press, [`Self::extend_stroke`] for
/// every position the pointer reports, and [`Self::end_stroke`] on release.
/// Nothing in between needs to reach the GPU by any route other than the
/// stack itself: the rasteriser reads the strokes each time it runs.
///
/// Parts are addressed by **index**, not by id, because a gesture is aimed
/// at a row the panel is already showing by position, and threading an
/// owned `String` through a press-move-release would be a clone per
/// pointer report for identity nobody consults.
pub fn begin_stroke(
&mut self,
part: usize,
erase: bool,
radius: f32,
hardness: f32,
flow: f32,
) -> bool {
let room = self.room();
match self.parts.get_mut(part) {
Some(p) => p.begin_stroke(room, erase, radius, hardness, flow),
None => {
log::warn!("no part {part} to paint into");
false
}
}
}
/// Add a position to the stroke in progress, returning whether it was kept.
pub fn extend_stroke(&mut self, part: usize, x: f32, y: f32) -> bool {
let room = self.room();
self.parts
.get_mut(part)
.is_some_and(|p| p.extend_stroke(room, x, y))
}
/// Finish the stroke in progress, simplifying it.
pub fn end_stroke(&mut self, part: usize) {
if let Some(p) = self.parts.get_mut(part) {
p.end_stroke();
}
}
/// Remove the most recent stroke from a part, returning it.
pub fn drop_last_stroke(&mut self, part: usize) -> Option<Stroke> {
self.parts
.get_mut(part)
.and_then(MaskPart::drop_last_stroke)
}
pub fn descriptors(&self) -> Vec<Arc<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.
///
/// The neighbourhood operations are absent for the same reason, and this
/// filter is the visible half of the one [`Self::active_ops`] already
/// applies. A layer's chain is fused into the point-operation pass; the
/// detail stage runs once, afterwards, over the whole frame, so there is
/// no seam through which a mask could reach it (see [`crate::detail`]).
/// Offering the controls anyway would put a sharpening slider on a mask
/// that moves and does nothing — which is worse than the control being
/// absent, because absence is legible and a dead slider is not.
pub fn capabilities(&self) -> Vec<crate::graph::OpCapability> {
self.ops
.iter()
.filter(|op| op.detail().is_none())
.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(),
attributes: desc.attributes.clone(),
}
})
.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 desc = o.descriptor();
let id = desc.id.0;
// Collected rather than borrowed from `desc`: a descriptor is an
// `Arc` handed over by value now (FR-PLG-2), so it would be
// dropped at the end of this closure and the lazy iterator would
// outlive it. The ids and defaults are all this needs, and there
// are a handful of them.
let params: Vec<(ParamId, f32)> =
desc.params.iter().map(|p| (p.id, p.default)).collect();
params.into_iter().filter_map(move |(param, default)| {
let v = o.param(param);
(v != default).then_some((id, param.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]
}
}
/// TRACES: FR-DEV-19c
/// How a mask is drawn when the photographer asks to see it.
///
/// Three, because they answer three different questions and no one of them
/// answers all three — which is the argument for offering a choice rather than
/// picking the best one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RevealStyle {
/// The mask over the photograph in a flat colour. What every editor's
/// photographers already expect, and the only style that answers "is this
/// selecting the right thing" while the picture is still visible.
Tint,
/// The mask alone, white on black. For judging an edge, which a tint over
/// a busy photograph cannot be read against.
Alpha,
/// The boundary outlined over the untouched picture. For checking
/// registration against detail the other two hide — the same reasoning the
/// region overlay's white outline already carries.
Edge,
}
impl RevealStyle {
/// In the order the interface offers them.
pub const ALL: [RevealStyle; 3] = [Self::Tint, Self::Alpha, Self::Edge];
}
/// TRACES: FR-DEV-19c
/// One layer whose mask is being shown, and the colour it is shown in.
#[derive(Debug, Clone, PartialEq)]
pub struct RevealedLayer {
/// Which layer, by id. By id rather than by slot because the slot is
/// derived from which layers render, and that is decided *by* this — see
/// [`MaskStack::rendered`].
pub layer: String,
/// Linear RGB in the output space's primaries. Each shown mask has its
/// own, because two masks in one colour are one mask as far as the eye
/// can tell, and telling a sky from the building in front of it is what
/// showing them together is for.
pub colour: [f32; 3],
}
/// TRACES: FR-DEV-19c
/// The layers whose masks are being shown, and how.
///
/// Several at once, each in its own colour, one style for all of them: a tint
/// beside an outline beside an alpha would be three pictures that cannot be
/// read against each other, where three tints in three colours are one.
///
/// **Never part of an edit.** It is not stored on [`MaskStack`] and it does
/// not travel with the graph: it is passed to the one composition that draws
/// the screen, so an export, a thumbnail and the neutral probe are
/// structurally unable to reveal anything. A flag on the stack would have been
/// fewer parameters and would have tinted every exported file red.
#[derive(Debug, Clone, PartialEq)]
pub struct Reveal {
/// In no particular order; the stack's order is what they draw in.
pub layers: Vec<RevealedLayer>,
pub style: RevealStyle,
}
impl Reveal {
/// One layer, shown red — what a test wants and what nothing else does.
pub fn one(layer: impl Into<String>, style: RevealStyle) -> Self {
Self {
layers: vec![RevealedLayer {
layer: layer.into(),
colour: [0.85, 0.10, 0.15],
}],
style,
}
}
/// The colour `id` is shown in, or `None` when it is not shown.
pub fn colour_of(&self, id: &str) -> Option<[f32; 3]> {
self.layers.iter().find(|l| l.layer == id).map(|l| l.colour)
}
/// Whether anything at all would be drawn.
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
}
/// 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()
}
/// TRACES: FR-DEV-19c
/// [`Self::active`], plus the layer being looked at.
///
/// A selection with no adjustment on it yet is not active — it changes no
/// pixel, so it occupies no mask slot and the rasteriser never draws it.
/// That is right for rendering and exactly wrong for *showing* the mask,
/// which is the state a photographer is in for the whole of the time
/// between choosing a subject and deciding what to do to it.
///
/// So this is the sequence both halves walk whenever a reveal is in play,
/// and the index within it is the texture-array slot — the same contract
/// [`Self::active`] carries, and the reason the rasteriser, the composer
/// and the field builder must all be given the same `reveal` or none of
/// them. Two of them disagreeing shows as an adjustment applied through
/// another layer's mask.
pub fn rendered<'a>(
&'a self,
reveal: Option<&'a Reveal>,
) -> impl Iterator<Item = &'a MaskLayer> {
self.layers
.iter()
.filter(move |l| l.is_active() || reveal.is_some_and(|r| r.colour_of(&l.id).is_some()))
}
pub fn rendered_count(&self, reveal: Option<&Reveal>) -> usize {
self.rendered(reveal).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>,
/// TRACES: FR-DEV-19c
/// The block that draws one layer's mask over the finished picture, empty
/// when nothing is being revealed.
///
/// Kept apart from `body` because it belongs at the other end of the
/// shader. Everything in `body` runs on scene-referred colour in the
/// working space, where a flat tint would then be pushed through the base
/// curve and the camera matrix and arrive as some other colour, and a
/// white-on-black alpha would arrive as neither. This runs after the
/// output transform, so what is written is what is seen.
pub reveal: String,
}
/// Emit the WGSL for every layer that renders, and for the mask being looked
/// at.
///
/// `slot` is the layer's index in the mask texture array, matching
/// [`MaskStack::rendered`] — the revealed layer renders whether or not it has
/// an adjustment on it, which is why the two are one sequence and why every
/// other half of the pipeline has to be given the same `reveal` for the slots
/// to mean the same thing.
pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal>) -> LayerShader {
let mut out = LayerShader {
uniform_fields: String::new(),
uniform_values: Vec::new(),
body: String::new(),
helpers: Vec::new(),
reveal: String::new(),
};
if stack.rendered(reveal).next().is_some() {
out.helpers.push(MASK_SAMPLER);
}
for (slot, layer) in stack.rendered(reveal).enumerate() {
if let Some((r, colour)) = reveal.and_then(|r| Some((r, r.colour_of(&layer.id)?))) {
// Alpha begins from black, once, before the first mask lands on
// it: the style is "the masks alone", and the photograph is what
// it leaves out.
if out.reveal.is_empty() && r.style == RevealStyle::Alpha {
out.reveal.push_str(
"\n // ==== showing masks alone: the photograph goes first ====\n c = vec3<f32>(0.0);\n",
);
}
out.reveal
.push_str(&reveal_block(slot, layer, r.style, colour));
}
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.base().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
}
/// TRACES: FR-DEV-19c
/// The WGSL that draws one layer's mask over the finished picture.
///
/// # Why this is not two uniforms
///
/// The slot and the style are written into the source, so turning the reveal
/// on, off, or onto another layer recompiles the fused shader. That is a
/// button press rather than a frame — and the alternative costs more than it
/// saves: a uniform can select a slot, but it cannot conjure one for a layer
/// that is not rendering, and the whole reason this exists is that a selection
/// with no adjustment on it yet is exactly that layer. So the composition
/// changes either way, and a uniform would only have added a branch per pixel
/// on top of it.
///
/// **Everything below runs after the output transform.** `c` is already in the
/// output space's primaries and still linear — the clip and the encode come
/// after — which is what makes a stated colour arrive as itself.
fn reveal_block(slot: usize, layer: &MaskLayer, style: RevealStyle, colour: [f32; 3]) -> String {
let prefix = format!("mask{slot}");
let colour = format!(
"vec3<f32>({:.4}, {:.4}, {:.4})",
colour[0], colour[1], colour[2]
);
let mut out = format!(
"\n // ==== showing mask {slot}: {} ====\n //\n // Not part of the\
\n // photograph: this is the mask itself, drawn because someone asked to\
\n // see it. Nothing downstream of the screen composes this shader.\n {{\n",
layer.display_name()
);
// The shaped mask, exactly as the layer above applied it — the same two
// uniforms, in the same order. A reveal that showed the raw slice would
// draw a different mask from the one doing the work, which is worse than
// showing none: it would send a photographer to fix an edge that is
// already where they want it.
let _ = writeln!(out, " var m = sample_mask(uv_src, {slot});");
let _ = writeln!(
out,
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
);
let _ = writeln!(out, " m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);");
let body = match style {
// A bit over half strength. Half is the strength every editor settled
// on for the same reason: past it the tint is opaque enough to hide
// the thing being judged, and below it a mask over a bright sky
// cannot be seen at all.
RevealStyle::Tint => " c = mix(c, COLOUR, m * 0.55);",
// Onto the black the section began with, at full strength: this is
// the mask itself, and where two overlap the later one lands on top,
// which is the order they composite in.
RevealStyle::Alpha => " c = mix(c, COLOUR, m);",
// The gradient's magnitude, over the untouched picture. Central
// differences one texel apart in the *mask's* own grid, so the outline
// is one mask texel wide however far the view is zoomed in — the
// boundary's position is the thing being checked, and a line that grew
// with the zoom would hide it.
RevealStyle::Edge => {
" let texel = 1.0 / vec2<f32>(textureDimensions(masks));\n\
\x20 let dx = sample_mask(uv_src + vec2<f32>(texel.x, 0.0), SLOT)\n\
\x20 - sample_mask(uv_src - vec2<f32>(texel.x, 0.0), SLOT);\n\
\x20 let dy = sample_mask(uv_src + vec2<f32>(0.0, texel.y), SLOT)\n\
\x20 - sample_mask(uv_src - vec2<f32>(0.0, texel.y), SLOT);\n\
\x20 // Doubled so a soft edge, whose gradient is spread over many\n\
\x20 // texels and therefore shallow everywhere, still draws a line.\n\
\x20 let edge = clamp(2.0 * sqrt(dx * dx + dy * dy), 0.0, 1.0);\n\
\x20 c = mix(c, COLOUR, edge);"
}
};
let _ = writeln!(
out,
"{}",
body.replace("SLOT", &slot.to_string())
.replace("COLOUR", &colour)
);
let _ = writeln!(out, " }}");
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_revealing(&stack, None).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 a_layer_offers_only_the_operations_it_can_actually_apply() {
// Two exclusions, for two different reasons, and the test states both
// because they fail in different ways.
//
// A layer's adjustments are fused into the colour dispatch and then
// multiplied by the mask. A neighbourhood operation cannot take that
// route: it is a dispatch of its own, run after the fused pass and
// after the masks are already applied, so there is nowhere to hand it
// one layer's mask. Left in, it moves and nothing happens.
//
// An optical correction *can* take that route, and that is the
// problem. It is radial about the frame's optical axis, which a mask
// cannot move, so it would lay a frame-centred ramp over the whole
// picture and multiply it by the mask. Left in, it moves and the wrong
// thing happens — under a name that promises the right one.
//
// The panel builds itself from `capabilities()` and names no
// operation, so anything left in this chain becomes a control.
let layer = lit_layer("m1", 1.0);
let ids: Vec<&str> = layer.capabilities().iter().map(|c| c.id.0).collect();
let global = crate::ops::chain();
let local_means_something = |op: &dyn Operation| {
op.detail().is_none()
&& !op
.descriptor()
.attributes
.contains(&crate::descriptor::Attribute::Optics)
};
for op in &global {
let id = op.descriptor().id.0;
assert_eq!(
ids.contains(&id),
local_means_something(op.as_ref()),
"{id} is offered as a local adjustment but cannot meaningfully \
be one, or is an ordinary point operation and has gone \
missing from a layer"
);
}
assert!(
ids.len() < global.len() || global.iter().all(|op| local_means_something(op.as_ref())),
"the filter dropped nothing, so either it is not running or the \
chain has nothing left that a layer cannot carry"
);
// And a clone must rebuild the same chain: it copies parameters across
// by position, so a chain built one way and rebuilt another would
// silently apply each value to the wrong operation.
let cloned: Vec<&str> = layer
.clone()
.capabilities()
.iter()
.map(|c| c.id.0)
.collect();
assert_eq!(ids, cloned);
}
#[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_revealing(&stack, None);
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_revealing(&stack, None);
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)"));
}
/// TRACES: FR-DEV-19c
/// The slot the reveal is given has to be the slot the layer renders
/// through, and a revealed layer renders even with nothing done to it.
///
/// Both halves in one assertion because the failure is the pair coming
/// apart: a reveal pointed at a slot the rasteriser did not draw shows
/// whatever was last in that slice, which reads as the mask being wrong
/// rather than as the reveal being wrong.
#[test]
fn a_revealed_layer_takes_a_slot_of_its_own() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
// No adjustment, so this changes no pixel and would ordinarily render
// through no slot at all.
stack.push(MaskLayer::new("m2", MaskSource::brush()));
let reveal = Reveal::one("m2", RevealStyle::Alpha);
let shader = compose_layers_revealing(&stack, Some(&reveal));
assert_eq!(
stack.rendered_count(Some(&reveal)),
2,
"the layer being looked at renders alongside the active one"
);
assert!(
shader.reveal.contains("sample_mask(uv_src, 1)"),
"the reveal must read slot 1, which is where m2 renders"
);
assert!(
shader.reveal.contains("u.mask1_opacity"),
"and shape it with that layer's own uniforms, not another's"
);
}
/// Two masks shown together are drawn each in its own colour, in stack
/// order — which is what makes a sky and the building in front of it two
/// things on screen rather than one red shape.
#[test]
fn each_shown_mask_is_drawn_in_its_own_colour() {
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("sky", MaskSource::brush()));
stack.push(MaskLayer::new("wall", MaskSource::brush()));
let reveal = Reveal {
layers: vec![
RevealedLayer {
layer: "wall".into(),
colour: [0.0, 0.0, 1.0],
},
RevealedLayer {
layer: "sky".into(),
colour: [1.0, 0.0, 0.0],
},
],
style: RevealStyle::Tint,
};
let shader = compose_layers_revealing(&stack, Some(&reveal));
let sky = shader
.reveal
.find("vec3<f32>(1.0000, 0.0000, 0.0000)")
.expect("the sky's red is in the shader");
let wall = shader
.reveal
.find("vec3<f32>(0.0000, 0.0000, 1.0000)")
.expect("the wall's blue is in the shader");
assert!(
sky < wall,
"drawn in stack order, not in the order they were asked for"
);
assert!(
shader.reveal.contains("sample_mask(uv_src, 0)")
&& shader.reveal.contains("sample_mask(uv_src, 1)"),
"each reads its own slot"
);
}
/// A composition nobody asked to see a mask through draws none.
#[test]
fn nothing_is_revealed_unless_it_was_asked_for() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
assert!(compose_layers_revealing(&stack, None).reveal.is_empty());
}
/// A reveal aimed at a layer that is not in the stack is not a slot, and
/// must not become one.
#[test]
fn a_reveal_naming_no_layer_reveals_nothing() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
let reveal = Reveal::one("gone", RevealStyle::Tint);
assert_eq!(stack.rendered_count(Some(&reveal)), 1);
assert!(compose_layers_revealing(&stack, Some(&reveal))
.reveal
.is_empty());
}
#[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_revealing(&stack, None);
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_revealing(&stack, None).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)]);
}
fn painted(id: &str) -> MaskLayer {
let mut layer = MaskLayer::new(id, MaskSource::brush());
layer.set_param("exposure", ParamId("exposure"), 1.0);
layer
}
/// A gesture: press, drag along `path`, release.
fn paint(layer: &mut MaskLayer, erase: bool, radius: f32, path: &[(f32, f32)]) {
layer.begin_stroke(0, erase, radius, 0.5, 1.0);
for &(x, y) in path {
layer.extend_stroke(0, x, y);
}
layer.end_stroke(0);
}
#[test]
fn a_brush_layer_is_never_stale() {
let mut layer = painted("m1");
paint(&mut layer, false, 0.05, &[(0.2, 0.2), (0.8, 0.8)]);
assert!(
!layer.is_stale(12345),
"strokes are where the user put them, not where a model found something"
);
}
/// The failure this prevents is loud and total: `invert` turns an empty
/// mask into the whole frame, so an unpainted layer that rendered would
/// apply its adjustment to the entire photograph.
#[test]
fn an_unpainted_brush_layer_is_not_active() {
let mut layer = painted("m1");
assert!(!layer.is_active(), "nothing has been painted yet");
paint(&mut layer, false, 0.05, &[(0.5, 0.5)]);
assert!(layer.is_active(), "one dab is a mask");
}
#[test]
fn a_layer_of_nothing_but_erasing_is_not_active() {
let mut layer = painted("m1");
paint(&mut layer, true, 0.05, &[(0.5, 0.5)]);
assert!(
!layer.is_active(),
"erasing an unpainted layer takes nothing away"
);
}
/// A press with no movement is a tap, and a tap paints one dab. Dropping
/// it as "no path" would make a brush that ignores the shortest stroke
/// there is.
#[test]
fn a_tap_is_a_stroke() {
let mut layer = painted("m1");
paint(&mut layer, false, 0.05, &[(0.5, 0.5)]);
assert_eq!(layer.strokes().len(), 1);
assert_eq!(layer.strokes()[0].points, vec![(0.5, 0.5)]);
}
/// A finger held still reports position after position at the same place.
/// Left in, they would fill the stroke and be rasterised every frame until
/// the gesture ended.
#[test]
fn a_stationary_finger_does_not_fill_the_stroke() {
let mut layer = painted("m1");
layer.begin_stroke(0, false, 0.05, 0.5, 1.0);
for _ in 0..200 {
layer.extend_stroke(0, 0.5, 0.5);
}
layer.end_stroke(0);
assert_eq!(layer.strokes()[0].len(), 1);
}
#[test]
fn simplifying_keeps_the_shape_and_drops_the_rest() {
let mut straight = Stroke::new(false, 0.1, 0.5, 1.0);
let mut bent = Stroke::new(false, 0.1, 0.5, 1.0);
for i in 0..=10 {
let t = i as f32 / 10.0;
straight.points.push((t, 0.5));
// A corner at the halfway point, far enough out to matter.
bent.points.push((t, 0.5 + (0.5 - (t - 0.5).abs()) * 0.5));
}
straight.simplify();
assert_eq!(
straight.points,
vec![(0.0, 0.5), (1.0, 0.5)],
"a straight line is two points however finely it was sampled"
);
bent.simplify();
assert_eq!(bent.len(), 3, "the corner survives");
assert!(
bent.points[1].0 > 0.4 && bent.points[1].0 < 0.6,
"and it is the corner that survived, not an arbitrary midpoint: {:?}",
bent.points
);
}
/// Tolerance follows the radius, because a swept disc cannot express
/// detail finer than its own edge — so a fat brush may throw away wobble a
/// fine one has to keep.
#[test]
fn a_fat_brush_simplifies_harder_than_a_fine_one() {
let wobble: Vec<(f32, f32)> = (0..=20)
.map(|i| {
let t = i as f32 / 20.0;
(t, 0.5 + if i % 2 == 0 { 0.004 } else { -0.004 })
})
.collect();
let mut fine = Stroke::new(false, 0.005, 0.5, 1.0);
fine.points = wobble.clone();
fine.simplify();
let mut fat = Stroke::new(false, 0.2, 0.5, 1.0);
fat.points = wobble;
fat.simplify();
assert!(
fat.len() < fine.len(),
"fat {} should keep fewer than fine {}",
fat.len(),
fine.len()
);
assert_eq!(fat.len(), 2, "the wobble is far inside a fat brush's edge");
}
/// A gesture longer than one stroke holds must continue, not stop. A brush
/// that quietly stops recording under the finger is the failure a painter
/// notices first.
#[test]
fn a_long_gesture_continues_in_another_stroke() {
let mut layer = painted("m1");
layer.begin_stroke(0, false, 0.001, 0.5, 1.0);
for i in 0..(MAX_STROKE_POINTS + 40) {
let t = i as f32 / (MAX_STROKE_POINTS + 40) as f32;
layer.extend_stroke(0, t, 0.5);
}
layer.end_stroke(0);
let strokes = layer.strokes();
assert!(strokes.len() > 1, "the gesture should have continued");
assert!(strokes[0].len() <= MAX_STROKE_POINTS);
assert_eq!(
strokes[0].points.last(),
strokes[1].points.first(),
"the continuation starts where the last one ended, so the swept \
path has no gap in it"
);
}
/// Refusing rather than dropping the oldest strokes, the same way the layer
/// stack refuses a ninth layer: work already on screen must not vanish.
#[test]
fn a_full_brush_layer_refuses_more_paint() {
let mut layer = painted("m1");
layer.begin_stroke(0, false, 0.0005, 0.5, 1.0);
// A zigzag, so simplification cannot quietly make room by throwing the
// path away — this test is about the cap, not about the tolerance.
for i in 0..(MAX_LAYER_POINTS * 4) {
let t = i as f32 / (MAX_LAYER_POINTS * 4) as f32;
layer.extend_stroke(0, t, if i % 2 == 0 { 0.49 } else { 0.51 });
}
layer.end_stroke(0);
assert!(layer.stroke_points() <= MAX_LAYER_POINTS);
assert!(
!layer.begin_stroke(0, false, 0.05, 0.5, 1.0),
"a full layer says so rather than making room"
);
}
#[test]
fn strokes_snap_to_the_stored_grid() {
let mut layer = painted("m1");
paint(&mut layer, false, 0.0512345, &[(0.1234567, 0.7654321)]);
let stroke = &layer.strokes()[0];
assert_eq!(stroke.points[0], (0.1235, 0.7654));
assert_eq!(stroke.radius, 0.0512);
}
/// Beginning a stroke on a gradient would be a brush painting into a mask
/// that has nowhere to put it, and silently discarding the gesture is how
/// a mode bug looks like a broken digitiser.
#[test]
fn a_stroke_on_a_layer_that_is_not_a_brush_is_refused() {
let mut layer = MaskLayer::new(
"m1",
MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 0.2,
},
);
assert!(!layer.begin_stroke(0, false, 0.05, 0.5, 1.0));
assert!(!layer.extend_stroke(0, 0.5, 0.5));
assert!(layer.strokes().is_empty());
}
#[test]
fn an_abandoned_stroke_can_be_taken_back() {
let mut layer = painted("m1");
paint(&mut layer, false, 0.05, &[(0.2, 0.2)]);
layer.begin_stroke(0, false, 0.05, 0.5, 1.0);
layer.extend_stroke(0, 0.8, 0.8);
assert_eq!(layer.strokes().len(), 2);
assert!(layer.drop_last_stroke(0).is_some());
assert_eq!(layer.strokes().len(), 1, "the first gesture is untouched");
}
#[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"]);
}
#[test]
fn a_layer_offers_no_control_it_cannot_honour() {
// A layer's chain is fused into the point-operation pass, and the
// detail stage runs once afterwards over the whole frame — so a
// neighbourhood operation inside a mask has nowhere to run.
// `active_ops` has always dropped them; this is the other half, which
// stops the panel drawing a sharpening slider on a mask that would
// move and change nothing.
let layer = lit_layer("m1", 1.0);
let global: Vec<&str> = crate::EditGraph::default_chain()
.capabilities()
.iter()
.map(|c| c.id.0)
.collect();
let scoped: Vec<&str> = layer.capabilities().iter().map(|c| c.id.0).collect();
let detail: Vec<&str> = crate::ops::chain()
.iter()
.filter(|o| o.detail().is_some())
.map(|o| o.descriptor().id.0)
.collect();
assert!(
!detail.is_empty(),
"the chain has neighbourhood operations, or this proves nothing"
);
for id in detail {
assert!(global.contains(&id), "{id} is missing from the chain");
assert!(
!scoped.contains(&id),
"{id} cannot run inside a mask and must not be offered there"
);
}
}
/// TRACES: FR-DEV-10
/// A band the user dragged inside out is the band they described, not an
/// empty one. Dropping it to zero width would make the drag stop dead
/// under the finger at the moment the handles crossed.
#[test]
fn a_crossed_band_is_the_band_between_the_two_numbers() {
let MaskSource::Luminance { lo, hi, .. } = MaskSource::luminance_range(0.8, 0.3, 0.1)
else {
panic!("not a luminance range");
};
assert_eq!((lo, hi), (0.3, 0.8));
}
/// TRACES: FR-DEV-10
/// The hue circle has no ends. Clamping a hue past red would stick it
/// there, and the far side of the circle would be unreachable from one
/// direction — a control that stops turning is a control that is broken.
#[test]
fn a_hue_past_the_end_of_the_circle_wraps() {
let MaskSource::Colour { hue, .. } = MaskSource::colour_range(1.25, 0.05, 0.1, 1.0, 0.1)
else {
panic!("not a colour range");
};
assert!((hue - 0.25).abs() < 1e-6, "hue wrapped to {hue}");
let MaskSource::Colour { hue, .. } = MaskSource::colour_range(-0.1, 0.05, 0.1, 1.0, 0.1)
else {
panic!("not a colour range");
};
assert!((hue - 0.9).abs() < 1e-6, "hue wrapped to {hue}");
}
/// TRACES: FR-DEV-10
/// An arc may not quietly become the whole circle. Half a turn each way is
/// every colour there is, arrived at through a control the photographer
/// believed was narrowing something.
#[test]
fn a_hue_arc_cannot_swallow_the_circle() {
let MaskSource::Colour { hue_width, .. } =
MaskSource::colour_range(0.0, 9.0, 0.1, 1.0, 0.1)
else {
panic!("not a colour range");
};
assert_eq!(hue_width, MAX_HUE_WIDTH);
}
/// TRACES: FR-DEV-10
/// Nothing a model finds can change which pixels a band names, so a range
/// layer is never stale — where a subject layer read against a new
/// segmentation is exactly that.
#[test]
fn a_range_never_goes_stale() {
assert!(!MaskLayer::new("m1", MaskSource::highlights()).is_stale(7));
assert!(!MaskLayer::new("m2", MaskSource::skin_tones()).is_stale(7));
}
/// TRACES: FR-DEV-10
/// A range is a rule, and the rule is the whole of what it stores.
#[test]
fn a_range_is_a_rule_and_not_a_shape() {
let source = MaskSource::skin_tones();
assert!(source.is_range());
assert!(
source.strokes().is_empty(),
"a range has no geometry to hand out"
);
assert_eq!(source.kind(), "colour");
assert_eq!(MaskSource::highlights().kind(), "luminance");
}
// -----------------------------------------------------------------------
// Parts
// -----------------------------------------------------------------------
/// TRACES: FR-DEV-19a
/// A hidden part is out of the build and nothing else about it moves: it
/// is still there to be put back, and the parts that remain shown are
/// what the mask is made of — including which of them is first.
#[test]
fn a_hidden_part_leaves_the_build_and_stays_in_the_layer() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.push_part(MaskPart::new("p2", Join::Union, MaskSource::highlights()));
layer.push_part(MaskPart::painted("p3", Join::Subtract));
layer.set_param("exposure", ParamId("exposure"), 0.5);
assert_eq!(layer.shown_parts().count(), 3);
assert!(layer.is_active(), "the range adds, so the mask covers");
layer.part_mut(1).expect("p2").hidden = true;
assert_eq!(layer.parts().len(), 3, "hidden is not removed");
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
assert_eq!(shown, ["p1", "p3"]);
assert!(
!layer.is_active(),
"with the range out, an unpainted base and a subtraction cover nothing"
);
layer.part_mut(1).expect("p2").hidden = false;
layer.base_mut().hidden = true;
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
assert_eq!(shown, ["p2", "p3"], "the range now opens the fold");
assert!(layer.is_active(), "and it still covers the picture");
}
/// The invariant everything else leans on: a layer always has a selection
/// to be. Removing the base is removing the layer, and the panel must not
/// be able to reach a state where a mask exists with nothing in it.
#[test]
fn the_base_part_cannot_be_removed() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.push_part(MaskPart::painted("p2", Join::Subtract));
assert!(layer.remove_part(0).is_none(), "the base refused");
assert_eq!(layer.parts().len(), 2);
assert!(layer.remove_part(1).is_some(), "a correction did not");
assert_eq!(layer.parts().len(), 1);
}
/// The point budget belongs to the layer, not to each part. Otherwise a
/// photographer who split one correction into six could store six times
/// what the limit says, and the sidecar line the limit exists to bound
/// would grow with the number of parts rather than with the painting.
#[test]
fn the_point_budget_is_the_layers_not_each_parts() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.push_part(MaskPart::painted("p2", Join::Union));
// Fill the base to the limit, one long gesture at a time.
while layer.room() > 0 {
let before = layer.room();
assert!(layer.begin_stroke(0, false, 0.01, 0.5, 1.0));
let mut x = 0.0;
while layer.room() > 0 && x < 1.0 {
layer.extend_stroke(0, x, 0.5);
x += 0.01;
}
layer.end_stroke(0);
assert!(layer.room() < before, "a gesture recorded nothing");
}
assert_eq!(layer.stroke_points(), MAX_LAYER_POINTS);
assert!(
!layer.begin_stroke(1, false, 0.01, 0.5, 1.0),
"a second part let the layer past its budget"
);
}
#[test]
fn parts_past_the_limit_are_refused_rather_than_displacing_one() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
for n in 2..=MAX_PARTS {
assert!(layer.push_part(MaskPart::painted(format!("p{n}"), Join::Union)));
}
assert_eq!(layer.parts().len(), MAX_PARTS);
assert!(!layer.push_part(MaskPart::painted("p99", Join::Union)));
assert_eq!(layer.parts().len(), MAX_PARTS, "nothing was displaced");
}
#[test]
fn a_part_id_is_never_reused_while_its_part_is_there() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
assert_eq!(layer.next_part_id(), "p2", "p1 is the base");
layer.push_part(MaskPart::painted(layer.next_part_id(), Join::Union));
assert_eq!(layer.next_part_id(), "p3");
layer.remove_part(1);
assert_eq!(layer.next_part_id(), "p2", "and is free again once gone");
}
/// A mask whose only painted part subtracts covers nothing, so rasterising
/// it would spend a slice to draw an empty field — and, with the layer
/// inverted, would take the adjustment global.
#[test]
fn a_layer_that_only_subtracts_covers_nothing() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.set_param("exposure", ParamId("exposure"), 1.0);
let mut cut = MaskPart::painted("p2", Join::Subtract);
cut.begin_stroke(64, false, 0.1, 0.5, 1.0);
cut.extend_stroke(64, 0.5, 0.5);
cut.end_stroke();
layer.push_part(cut);
assert!(
!layer.is_active(),
"an unpainted base with a subtraction on it selects nothing"
);
layer.begin_stroke(0, false, 0.1, 0.5, 1.0);
layer.extend_stroke(0, 0.5, 0.5);
layer.end_stroke(0);
assert!(
layer.is_active(),
"and now the base has something to cut into"
);
}
/// One stale part is a stale layer: the mask is the fold over all of them,
/// so a part that would draw a confidently wrong shape makes the result
/// wrong whichever way it joins.
#[test]
fn a_stale_part_makes_the_layer_stale() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
assert!(!layer.is_stale(7));
layer.push_part(MaskPart::new(
"p2",
Join::Union,
MaskSource::Regions {
signature: 3,
level: 0,
ids: vec![1],
},
));
assert!(layer.is_stale(7));
}
}