//! 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, ]; } /// 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, _ => &[], } } } /// One local adjustment: a rule about *where*, plus a chain saying *what*. pub struct MaskLayer { /// Stable identity, for the sidecar and for merge (FR-NC-9). pub id: String, /// What the user called it. Empty means "name me after my source". pub name: String, pub source: MaskSource, /// Swap inside for outside. pub invert: bool, /// Global strength of the layer, `0.0..=1.0`. pub opacity: f32, /// Off without being deleted — the A/B a local edit is always wanting. pub enabled: bool, /// Half-width of the edge transition, as a fraction of the frame's /// **shorter edge**. /// /// Normalised rather than in pixels for the same reason the gradient /// geometry is: the same edit renders to a viewport and to a 24 MP export, /// and a feather measured in pixels would be a different edge in each. /// /// Zero means no transition regardless of [`Self::falloff`]. pub feather: f32, pub falloff: Falloff, /// Grow, shrink or tidy the mask before the feather is applied. /// /// Before, and it matters: dilating a *feathered* mask would push the /// half-way point outward and soften it further, so the two controls would /// not be independent. Morphology moves the boundary; feather describes /// how the boundary is crossed. pub morphology: Morphology, /// How far, in the same units as [`Self::feather`]. pub morph_radius: f32, /// 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 layer for /// the same reason: two layers 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 layer 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 layer means; /// this says what that meant last time anybody worked it out. The /// distinction is why it takes no part in [`PartialEq`] — a layer 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 layer 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 layer 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>, /// 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(), source: self.source.clone(), invert: self.invert, opacity: self.opacity, enabled: self.enabled, feather: self.feather, falloff: self.falloff, morphology: self.morphology, morph_radius: self.morph_radius, refine: self.refine, // A refcount, not a raster. See the field. coverage: self.coverage.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("source", &self.source) .field("invert", &self.invert) .field("opacity", &self.opacity) .field("enabled", &self.enabled) .field("feather", &self.feather) .field("falloff", &self.falloff) .field("morphology", &self.morphology) .field("coverage", &self.coverage.as_ref().map(|c| c.encoded_len())) .field("active_ops", &self.active_ops().count()) .finish() } } impl PartialEq for MaskLayer { /// Every field that is the *edit*, and deliberately not /// [`Self::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.source == other.source && self.invert == other.invert && self.opacity == other.opacity && self.enabled == other.enabled && self.feather == other.feather && self.falloff == other.falloff && self.morphology == other.morphology && self.morph_radius == other.morph_radius && self.params().eq(other.params()) } } impl MaskLayer { /// A new layer over `source`, with every adjustment at neutral. pub fn new(id: impl Into, source: MaskSource) -> Self { Self { id: id.into(), name: String::new(), source, invert: false, opacity: 1.0, enabled: true, // A small default rather than zero. A watershed boundary is exact // to the pixel, and an adjustment that stops dead on one looks // pasted on — the first thing anyone would reach for, so it is // where the control starts. feather: DEFAULT_FEATHER, falloff: Falloff::default(), morphology: Morphology::default(), morph_radius: 0.0, // Off, so a layer 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, ops: layer_chain(), } } /// The name to show, falling back to the source kind. pub fn display_name(&self) -> &str { if self.name.is_empty() { self.source.kind() } else { &self.name } } /// Whether this layer would change any pixel. /// /// A layer with a mask but no adjustment is not inactive in the UI — it is /// a selection the user is still working on — but it contributes nothing /// to the shader and is omitted from it. pub fn is_active(&self) -> bool { self.enabled && self.opacity > 0.0 && self.active_ops().next().is_some() && self.covers() } /// Whether this mask could cover any pixel at all. /// /// Only a brush can answer no, and it matters more than the slot it saves. /// An unpainted mask is empty, [`Self::invert`] turns empty into /// everything, and a layer created with invert already set would apply its /// 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 layer selects whatever the picture has, and an empty result /// is the picture's answer rather than the layer's. fn covers(&self) -> bool { match &self.source { MaskSource::Brush { strokes } => strokes.iter().any(|s| !s.erase && !s.is_empty()), _ => true, } } /// 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 this layer's region ids belong to a different segmentation. /// /// Applying it anyway would produce a confidently wrong mask, so callers /// should offer to recompute rather than render it. pub fn is_stale(&self, current: u64) -> bool { match self.source { MaskSource::Regions { signature, .. } | MaskSource::Subject { signature, .. } | 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 layer, empty for any other kind of mask. pub fn strokes(&self) -> &[Stroke] { self.source.strokes() } /// How many stroke points this layer is holding. See [`MAX_LAYER_POINTS`]. pub fn stroke_points(&self) -> usize { self.strokes().iter().map(Stroke::len).sum() } /// Start a stroke, returning whether there was room for it. /// /// 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. pub fn begin_stroke(&mut self, erase: bool, radius: f32, hardness: f32, flow: f32) -> bool { let full = self.stroke_points() >= MAX_LAYER_POINTS; let MaskSource::Brush { strokes } = &mut self.source else { log::warn!( "layer {} is a {} mask, not a brush", self.id, self.source.kind() ); return false; }; if full { log::warn!( "brush layer 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, x: f32, y: f32) -> bool { let room = MAX_LAYER_POINTS.saturating_sub(self.stroke_points()); 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, } } 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] } } /// The ordered stack of local adjustments. #[derive(Debug, Clone, Default, PartialEq)] pub struct MaskStack { layers: Vec, } impl MaskStack { pub fn new() -> Self { Self::default() } pub fn layers(&self) -> &[MaskLayer] { &self.layers } pub fn layers_mut(&mut self) -> &mut [MaskLayer] { &mut self.layers } pub fn is_empty(&self) -> bool { self.layers.is_empty() } pub fn len(&self) -> usize { self.layers.len() } pub fn get(&self, id: &str) -> Option<&MaskLayer> { self.layers.iter().find(|l| l.id == id) } pub fn get_mut(&mut self, id: &str) -> Option<&mut MaskLayer> { self.layers.iter_mut().find(|l| l.id == id) } /// Add a layer, returning whether there was room for it. /// /// Refuses past [`MAX_LAYERS`] rather than dropping the oldest: a stack at /// its limit is a thing to tell the user about, and silently discarding /// work they can see on screen is the wrong way to handle it. pub fn push(&mut self, layer: MaskLayer) -> bool { if self.layers.len() >= MAX_LAYERS { log::warn!("mask stack is full ({MAX_LAYERS} layers); refusing to add another"); return false; } self.layers.push(layer); true } pub fn remove(&mut self, id: &str) -> Option { let i = self.layers.iter().position(|l| l.id == id)?; Some(self.layers.remove(i)) } /// Reorder, since later layers composite over earlier ones. pub fn move_to(&mut self, id: &str, index: usize) { let Some(from) = self.layers.iter().position(|l| l.id == id) else { return; }; let layer = self.layers.remove(from); self.layers.insert(index.min(self.layers.len()), layer); } /// The layers that will appear in the shader, in composite order. /// /// The index within *this* sequence is the texture-array layer the /// rasteriser must write, which is why both sides call this rather than /// indexing `layers` — an inactive layer occupies no mask slot, and the /// two halves disagreeing about that shows as an edit applied through the /// wrong mask. pub fn active(&self) -> impl Iterator { self.layers.iter().filter(|l| l.is_active()) } pub fn active_count(&self) -> usize { self.active().count() } /// Whether any layer changes any pixel. pub fn is_neutral(&self) -> bool { self.active_count() == 0 } /// Generate a fresh layer id that does not collide with an existing one. pub fn next_id(&self) -> String { (1..) .map(|n| format!("m{n}")) .find(|id| self.get(id).is_none()) .expect("infinite range") } } /// One layer's contribution to the generated shader. pub(crate) struct LayerShader { pub uniform_fields: String, pub uniform_values: Vec, pub body: String, pub helpers: Vec, } /// Emit the WGSL for every active layer. /// /// `slot` is the layer's index in the mask texture array, matching /// [`MaskStack::active`]. pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader { let mut out = LayerShader { uniform_fields: String::new(), uniform_values: Vec::new(), body: String::new(), helpers: Vec::new(), }; if stack.active().next().is_some() { out.helpers.push(MASK_SAMPLER); } for (slot, layer) in stack.active().enumerate() { let prefix = format!("mask{slot}"); let _ = writeln!( out.uniform_fields, " // mask {slot}: {}\n {prefix}_invert: f32,\n {prefix}_opacity: f32,", layer.display_name() ); out.uniform_values.extend_from_slice(&layer.uniforms()); let _ = writeln!( out.body, "\n // ======== mask {slot}: {} ({}) ========", layer.display_name(), layer.source.kind() ); let _ = writeln!(out.body, " {{"); // **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source* // space, and `uv_src` is the source position this output pixel came // from — after the crop, the zoom, the pan, the straightening and the // flips. Sampling by output pixel instead, as this once did, pins the // mask to the viewport: zooming in slides the photograph under a mask // that stays where it was, and cropping moves the adjustment to a // different part of the picture. // // Doing it this way also means the framing map exists in exactly one // place. A second copy here would be a second thing to keep in step // with `Framing::wgsl_prologue`, and the failure would be a mask that // is subtly wrong only when straightened. let _ = writeln!(out.body, " var m = sample_mask(uv_src, {slot});"); let _ = writeln!( out.body, " m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);" ); let _ = writeln!( out.body, " m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);" ); // Skipping the work where the mask is empty is most of the point of a // local adjustment: a mask covering a tenth of the frame should cost // about a tenth of the shader. Safe as non-uniform control flow — // nothing inside samples with derivatives or synchronises. let _ = writeln!(out.body, " if (m > 0.0) {{"); // `masked` is the outer-scope carrier: op fragments write to a `c` // they expect to own, so the inner block shadows `c` and copies the // result back out. Assigning the outer `c` from inside is not possible // precisely because it is shadowed. let _ = writeln!(out.body, " var masked = c;"); let _ = writeln!(out.body, " {{"); let _ = writeln!(out.body, " var c = masked;"); for op in layer.active_ops() { let id = op.descriptor().id.0; let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id)); let op_uniforms = op.uniforms(); if !op_uniforms.is_empty() { let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}"); } for u in &op_uniforms { let _ = writeln!(out.uniform_fields, " {op_prefix}_{}: f32,", u.name); out.uniform_values.push(u.value); } for h in op.helpers() { if !out.helpers.iter().any(|e| e.name == h.name) { out.helpers.push(*h); } } let mut fragment = op.wgsl_body(); for u in &op_uniforms { fragment = crate::operation::rewrite_uniform( &fragment, u.name, &format!("u.{op_prefix}_{}", u.name), ); } let _ = writeln!(out.body, " // ---- {id} ----"); let _ = writeln!(out.body, " {{"); for line in fragment.lines() { let _ = writeln!(out.body, " {line}"); } let _ = writeln!(out.body, " }}"); } let _ = writeln!(out.body, " masked = c;"); let _ = writeln!(out.body, " }}"); let _ = writeln!(out.body, " c = mix(c, masked, m);"); let _ = writeln!(out.body, " }}"); let _ = writeln!(out.body, " }}"); } out } /// A stable fingerprint of a segmentation, for [`MaskSource::Regions`]. /// /// Built from the things that change what a region id *means* — the proxy /// size, the region count, and the options the watershed ran with. Deliberately /// **not** a hash of the label field: that would be a readback on a path that /// must not have one (ARCH §6.1), and would also make the signature depend on /// float arithmetic whose cross-vendor determinism is exactly the open /// question (docs/segmentation.md §6, M5). pub fn segmentation_signature(width: u32, height: u32, regions: u32, tuning: u64) -> u64 { // FNV-1a over the four fields. Small, dependency-free, and adequate: this // guards against accidental mismatch, not against a forged sidecar. let mut h: u64 = 0xcbf2_9ce4_8422_2325; for word in [width as u64, height as u64, regions as u64, tuning] { for byte in word.to_le_bytes() { h ^= byte as u64; h = h.wrapping_mul(0x1000_0000_01b3); } } h } #[cfg(test)] mod tests { use super::*; use crate::descriptor::ParamId; fn regions(ids: &[u32]) -> MaskSource { MaskSource::Regions { signature: 7, level: 300, ids: ids.to_vec(), } } fn lit_layer(id: &str, ev: f32) -> MaskLayer { let mut layer = MaskLayer::new(id, regions(&[1, 2])); layer.set_param("exposure", ParamId("exposure"), ev); layer } #[test] fn a_layer_with_no_adjustment_is_not_in_the_shader() { let layer = MaskLayer::new("m1", regions(&[1])); assert!(!layer.is_active(), "a bare selection changes no pixel"); let mut stack = MaskStack::new(); stack.push(layer); assert!(stack.is_neutral()); assert_eq!(compose_layers(&stack).body, ""); } #[test] fn a_disabled_layer_is_omitted_but_kept() { let mut stack = MaskStack::new(); let mut layer = lit_layer("m1", 1.0); layer.enabled = false; stack.push(layer); assert_eq!(stack.active_count(), 0, "disabled layers do not render"); assert_eq!(stack.len(), 1, "but they are not deleted"); } #[test] fn 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(&stack); assert!(shader.body.contains("sample_mask(uv_src, 0)")); assert!(shader.body.contains("sample_mask(uv_src, 1)")); assert!(shader.body.contains("u.mask0_opacity")); assert!(shader.body.contains("u.mask1_opacity")); } /// The slot a layer renders through must follow `active()`, not the raw /// index — otherwise disabling layer 0 silently shifts every mask. #[test] fn slots_follow_active_order_not_stack_order() { let mut stack = MaskStack::new(); let mut off = lit_layer("m1", 1.0); off.enabled = false; stack.push(off); stack.push(lit_layer("m2", -1.0)); let shader = compose_layers(&stack); assert!( shader.body.contains("sample_mask(uv_src, 0)"), "the one active layer must use slot 0, not slot 1" ); assert!(!shader.body.contains("sample_mask(uv_src, 1)")); } #[test] fn each_layer_gets_its_own_uniforms() { let mut stack = MaskStack::new(); stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m2", -1.0)); let shader = compose_layers(&stack); assert!(shader.uniform_fields.contains("mask0_exposure_")); assert!(shader.uniform_fields.contains("mask1_exposure_")); assert_eq!( shader.uniform_values.len(), shader .uniform_fields .lines() .filter(|l| l.trim_start().starts_with("mask")) .count(), "one value per emitted field" ); } #[test] fn the_inner_block_shadows_c_and_copies_back() { let mut stack = MaskStack::new(); stack.push(lit_layer("m1", 1.0)); let body = compose_layers(&stack).body; assert!(body.contains("var masked = c;")); assert!(body.contains("var c = masked;")); assert!(body.contains("masked = c;")); assert!(body.contains("c = mix(c, masked, m);")); } #[test] fn a_full_stack_refuses_rather_than_dropping_work() { let mut stack = MaskStack::new(); for i in 0..MAX_LAYERS { assert!(stack.push(lit_layer(&format!("m{i}"), 1.0))); } assert!(!stack.push(lit_layer("overflow", 1.0))); assert_eq!(stack.len(), MAX_LAYERS); assert!(stack.get("overflow").is_none()); } #[test] fn ids_do_not_collide() { let mut stack = MaskStack::new(); assert_eq!(stack.next_id(), "m1"); stack.push(MaskLayer::new("m1", regions(&[1]))); assert_eq!(stack.next_id(), "m2"); } #[test] fn a_layer_from_another_segmentation_is_stale() { let layer = MaskLayer::new("m1", regions(&[1])); assert!(!layer.is_stale(7), "same signature is fine"); assert!(layer.is_stale(8), "a retuned segmentation invalidates ids"); // A gradient has no region ids, so nothing can go stale about it. let grad = MaskLayer::new( "m2", MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2, }, ); assert!(!grad.is_stale(999)); } #[test] fn signatures_separate_what_changes_a_region_id() { let base = segmentation_signature(1600, 1067, 6730, 2); assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2)); assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning"); assert_ne!( base, segmentation_signature(800, 1067, 6730, 2), "proxy size" ); assert_ne!( base, segmentation_signature(1600, 1067, 42, 2), "region count" ); } #[test] fn cloning_copies_parameters_not_handles() { let layer = lit_layer("m1", 1.5); let mut copy = layer.clone(); assert_eq!(copy, layer); copy.set_param("exposure", ParamId("exposure"), -1.0); assert_ne!(copy, layer, "the clone edits independently"); } #[test] fn params_reports_only_what_moved() { let layer = lit_layer("m1", 1.25); let moved: Vec<_> = layer.params().collect(); assert_eq!(moved, vec![("exposure", "exposure", 1.25)]); } 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(erase, radius, 0.5, 1.0); for &(x, y) in path { layer.extend_stroke(x, y); } layer.end_stroke(); } #[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(false, 0.05, 0.5, 1.0); for _ in 0..200 { layer.extend_stroke(0.5, 0.5); } layer.end_stroke(); 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(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(t, 0.5); } layer.end_stroke(); 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(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(t, if i % 2 == 0 { 0.49 } else { 0.51 }); } layer.end_stroke(); assert!(layer.stroke_points() <= MAX_LAYER_POINTS); assert!( !layer.begin_stroke(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(false, 0.05, 0.5, 1.0)); assert!(!layer.extend_stroke(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(false, 0.05, 0.5, 1.0); layer.extend_stroke(0.8, 0.8); assert_eq!(layer.strokes().len(), 2); assert!(layer.drop_last_stroke().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"); } }