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