//! 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, layer: i32) -> f32 { let dims = vec2(textureDimensions(masks)); let last = vec2(dims) - vec2(1); // Sample positions are texel centres, so the half-texel offset is what // keeps the interpolated edge where the rasteriser drew it. let t = uv * dims - vec2(0.5); let base = vec2(floor(t)); let f = fract(t); let p0 = clamp(base, vec2(0), last); let p1 = clamp(base + vec2(1), vec2(0), last); let a = textureLoad(masks, vec2(p0.x, p0.y), layer, 0).r; let b = textureLoad(masks, vec2(p1.x, p0.y), layer, 0).r; let c = textureLoad(masks, vec2(p0.x, p1.y), layer, 0).r; let d = textureLoad(masks, vec2(p1.x, p1.y), layer, 0).r; return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); }", }; /// Per-layer uniforms the generated shader reads: `invert`, then `opacity`. pub const LAYER_UNIFORM_FIELDS: usize = 2; /// The most layers one image may carry. /// /// A limit exists because the masks are bound as one texture array and every /// layer costs a full-resolution channel of VRAM — at 24 MP that is ~24 MB /// each, so an unbounded stack is an out-of-memory waiting for a patient user. /// Eight is comfortably past what an edit uses in practice and still bounded. pub const MAX_LAYERS: usize = 8; /// How a mask's coverage falls away from its edge. /// /// Applied to the **signed distance** from the mask boundary, which is what /// makes an arbitrary curve possible: the rasteriser computes one exact /// Euclidean distance field and the choice below is a function of it, so a /// new shape costs a line rather than a pass. /// /// A watershed boundary is pixel-exact, which is correct and also harsher /// than any edit wants at a subject's edge — an exposure change that stops /// dead at a hairline reads as a cut-out. So the useful default is a soft one. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Falloff { /// No transition. The boundary as the segmentation drew it. /// /// Worth keeping rather than approximating with a tiny feather: it is what /// you want when checking *where* a boundary actually fell, and a feather /// hides exactly that. Hard, /// Straight ramp. Predictable, and visibly banded on a gradient. Linear, /// Smoothstep — zero derivative at both ends. /// /// The default. The ends are where a ramp shows: a linear falloff leaves a /// visible crease where the effect starts and where it stops, because the /// eye finds discontinuities in the *slope*, not in the value. #[default] Smooth, /// Gaussian-shaped. Softest, and reaches further than its radius suggests. Gaussian, /// Sharp near the edge, long tail. For blending an adjustment out over a /// large area without moving the boundary itself. Exponential, } impl Falloff { pub fn name(self) -> &'static str { match self { Self::Hard => "hard", Self::Linear => "linear", Self::Smooth => "smooth", Self::Gaussian => "gaussian", Self::Exponential => "exponential", } } pub fn from_name(name: &str) -> Option { Some(match name { "hard" => Self::Hard, "linear" => Self::Linear, "smooth" => Self::Smooth, "gaussian" => Self::Gaussian, "exponential" => Self::Exponential, _ => return None, }) } /// Every variant, for a UI building a choice control. pub const ALL: [Falloff; 5] = [ Falloff::Hard, Falloff::Linear, Falloff::Smooth, Falloff::Gaussian, Falloff::Exponential, ]; } /// Growing, shrinking and tidying a mask's extent. /// /// All four are thresholds of the same distance field, which is why they /// arrive together rather than one at a time: dilation is "distance ≥ −r", /// erosion is "distance ≥ +r", and the two compound operations are one of /// those followed by the other. /// /// The compound pair costs a **second** distance field, because after the /// first threshold the shape has changed and the old distances no longer /// describe it. That is a real cost and the reason they are named separately /// rather than presented as a radius that happens to be signed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Morphology { #[default] None, /// Grow. The everyday fix for a selection that stops just inside a /// subject's edge, which is what an under-segmented boundary produces. Dilate, /// Shrink. Pulls a selection back off a halo it caught. Erode, /// Dilate then erode: fills pinholes and closes narrow gaps without /// growing the outline. What to reach for when a mask is speckled with /// missed pixels inside an area that is plainly one thing. Close, /// Erode then dilate: removes specks and thin spurs without shrinking the /// outline. The complement, for a selection that leaked along an edge. Open, } impl Morphology { pub fn name(self) -> &'static str { match self { Self::None => "none", Self::Dilate => "dilate", Self::Erode => "erode", Self::Close => "close", Self::Open => "open", } } pub fn from_name(name: &str) -> Option { Some(match name { "none" => Self::None, "dilate" => Self::Dilate, "erode" => Self::Erode, "close" => Self::Close, "open" => Self::Open, _ => return None, }) } /// Whether this needs a second distance field. pub fn is_compound(self) -> bool { matches!(self, Self::Close | Self::Open) } pub const ALL: [Morphology; 5] = [ Morphology::None, Morphology::Dilate, Morphology::Erode, Morphology::Close, Morphology::Open, ]; } /// 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, }, /// 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 }, /// 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 { 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>, } impl MaskPart { /// A new part over `source`, shaped the way a new layer always was. pub fn new(id: impl Into, 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, 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 { 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 } } /// TRACES: FR-DEV-19 /// One local adjustment: a rule about *where*, plus a chain saying *what*. /// /// The *where* is editable after the fact — composed from parts (FR-DEV-19a), /// painted into and out of (FR-DEV-19b), shown (FR-DEV-19c) — and every edit /// to it is geometry and parameters in this struct, never a raster in a file. 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, /// 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>, } /// 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> { 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, 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, parts: Vec) -> Option { 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 { 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 { 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 { 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 { self.parts .get_mut(part) .and_then(MaskPart::drop_last_stroke) } pub fn descriptors(&self) -> Vec> { 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 { 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 + '_ { 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, pub style: RevealStyle, } impl Reveal { /// One layer, shown red — what a test wants and what nothing else does. pub fn one(layer: impl Into, 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, } impl MaskStack { pub fn new() -> Self { Self::default() } pub fn layers(&self) -> &[MaskLayer] { &self.layers } pub fn layers_mut(&mut self) -> &mut [MaskLayer] { &mut self.layers } pub fn is_empty(&self) -> bool { self.layers.is_empty() } pub fn len(&self) -> usize { self.layers.len() } pub fn get(&self, id: &str) -> Option<&MaskLayer> { self.layers.iter().find(|l| l.id == id) } pub fn get_mut(&mut self, id: &str) -> Option<&mut MaskLayer> { self.layers.iter_mut().find(|l| l.id == id) } /// Add a layer, returning whether there was room for it. /// /// Refuses past [`MAX_LAYERS`] rather than dropping the oldest: a stack at /// its limit is a thing to tell the user about, and silently discarding /// work they can see on screen is the wrong way to handle it. pub fn push(&mut self, layer: MaskLayer) -> bool { if self.layers.len() >= MAX_LAYERS { log::warn!("mask stack is full ({MAX_LAYERS} layers); refusing to add another"); return false; } self.layers.push(layer); true } pub fn remove(&mut self, id: &str) -> Option { let i = self.layers.iter().position(|l| l.id == id)?; Some(self.layers.remove(i)) } /// Reorder, since later layers composite over earlier ones. pub fn move_to(&mut self, id: &str, index: usize) { let Some(from) = self.layers.iter().position(|l| l.id == id) else { return; }; let layer = self.layers.remove(from); self.layers.insert(index.min(self.layers.len()), layer); } /// The layers that will appear in the shader, in composite order. /// /// The index within *this* sequence is the texture-array layer the /// rasteriser must write, which is why both sides call this rather than /// indexing `layers` — an inactive layer occupies no mask slot, and the /// two halves disagreeing about that shows as an edit applied through the /// wrong mask. pub fn active(&self) -> impl Iterator { self.layers.iter().filter(|l| l.is_active()) } pub fn active_count(&self) -> usize { self.active().count() } /// 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 { 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, pub body: String, pub helpers: Vec, /// 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(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({:.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(textureDimensions(masks));\n\ \x20 let dx = sample_mask(uv_src + vec2(texel.x, 0.0), SLOT)\n\ \x20 - sample_mask(uv_src - vec2(texel.x, 0.0), SLOT);\n\ \x20 let dy = sample_mask(uv_src + vec2(0.0, texel.y), SLOT)\n\ \x20 - sample_mask(uv_src - vec2(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(1.0000, 0.0000, 0.0000)") .expect("the sky's red is in the shader"); let wall = shader .reveal .find("vec3(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)); } }