Paint a mask without ever rasterising one on the CPU
The last line of FR-DEV-3, and the mask ARCH §5.4 was written for. darktable rasterises drawn masks on the CPU and users call the result unworkable; the architecture's answer is that a stroke arrives as *parameters* and the device draws it. This is that, from the model through the sidecar to the pixels — but not the finger: the canvas is somebody else's change, and this leaves it a seam rather than reaching into it. **A stroke is a swept disc along a polyline**, plus erase, radius, hardness and flow. `MaskSource::Brush` holds an ordered list of them, and the order is the mask: an erase after an add takes it away and the same pair reversed does not. Nothing about it is pixels, which is what makes a mask that costs a line of text, diffs by the gesture, and survives a crop, a straighten and an export at any size — the properties a stored raster has none of, and the same argument the region ids were chosen for. Two things keep the point count honest. While the finger is down, a position closer to the last than an eighth of the radius is dropped: a touch screen reports 120 a second, so a finger held still for five seconds is six hundred points in the same place, and simplification would only remove them once the gesture had ended — after every frame in between had drawn all of them. When it ends, Douglas–Peucker at an eighth of the radius removes what a disc that wide cannot express: a swept circle moved by r/8 moves its own edge by r/8, which is inside the soft part of any brush. Coordinates snap to a ten-thousandth of the frame on the way in *and* are written at that precision, so a round trip is exact rather than nearly exact — a file that drifts in the sixth decimal every save is a per-field merge conflict a day, over nothing. **Cost is why the strokes are not drawn by the full-screen triangle the other masks use.** A swept disc is the minimum distance to any of its segments, so a stroke over the whole frame costs `pixels × segments` and both terms grow together — the quadratic that is darktable's problem moved onto the GPU rather than solved. Each stroke is instead drawn over its own bounding box, grown by the radius, so the rasteriser never invokes the shader for a pixel the stroke cannot reach: `area(box) × segments`, which for a dab or a swipe is a small fraction of the frame. A gesture past 256 points continues as a second stroke for the same reason, since a shorter stroke has a smaller box. Add and erase are `dst + a(1 - dst)` and `dst(1 - a)`, which are exactly a source-over and a one-minus-source blend — so they are blend state, not arithmetic, and no pass ever reads the slice it is writing. That is what permits one draw per stroke at all. Within a stroke the coverage is the *minimum* distance over its segments rather than a sum: a path that crosses itself must not build up where it did, or every circle and every scribble would be blotchy wherever consecutive dabs overlap, which is everywhere. Not a distance field, deliberately. `dr-segment`'s transform documents the two conditions that make CPU work right there — once per mask edit, over input already CPU-side — and a stroke fails both: it changes while the finger moves, and its input is a handful of coordinates that never needed to be pixels. It also needs no transform, because the distance to a swept disc is closed form. A stroke is the one mask whose distance field is known without computing one. An unpainted brush layer is inactive rather than empty, which is not an optimisation: `invert` turns empty into everything, so a layer created with invert already set would apply its adjustment to the whole photograph before a single stroke was made. That is the loud, confident kind of wrong this codebase refuses everywhere else a mask can go missing, and there is a rendered test for it. The tests read pixels back off a device rather than checking that the two halves agree with each other. What they pin down is what is silent when wrong: the y flip between mask space and clip space, which a centred stroke would not notice; a bounding box not grown by the radius, which makes a tap draw nothing at all; an aspect ratio ignored, which makes a dab an ellipse on any frame that is not square; a stroke doubling back and building up; and an erase that lost its place in the order and put back paint the user had taken off. Not done here: the interaction. The canvas needs to begin, extend and end a stroke on the active layer, and `DevelopSession::rasterise_masks` still returns early without a segmentation — it takes the proxy size from one, and a brush needs no model to have run over the photograph first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,14 +9,18 @@
|
||||
//! # Where a mask actually exists
|
||||
//!
|
||||
//! **Not here, and not on the CPU at all.** A layer stores the *rule* — some
|
||||
//! region ids, or a gradient's geometry — and a compute pass rasterises it
|
||||
//! into a texture (ARCH §5.4). This module's job is to describe the rule and
|
||||
//! to emit the WGSL that blends by the result.
|
||||
//! 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.
|
||||
//! 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
|
||||
//!
|
||||
@@ -44,6 +48,252 @@ use crate::ops;
|
||||
/// be a literal in one.
|
||||
pub const DEFAULT_FEATHER: f32 = 0.004;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -306,6 +556,18 @@ pub enum MaskSource {
|
||||
/// 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> },
|
||||
}
|
||||
|
||||
impl MaskSource {
|
||||
@@ -316,6 +578,22 @@ impl MaskSource {
|
||||
Self::Subject { .. } => "subject",
|
||||
Self::Linear { .. } => "linear",
|
||||
Self::Radial { .. } => "radial",
|
||||
Self::Brush { .. } => "brush",
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty brush mask, ready to be painted into.
|
||||
pub fn brush() -> Self {
|
||||
Self::Brush {
|
||||
strokes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The strokes, or nothing for a source that is not painted.
|
||||
pub fn strokes(&self) -> &[Stroke] {
|
||||
match self {
|
||||
Self::Brush { strokes } => strokes,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,7 +738,22 @@ impl MaskLayer {
|
||||
/// 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.enabled && self.opacity > 0.0 && self.active_ops().next().is_some() && self.covers()
|
||||
}
|
||||
|
||||
/// Whether this mask could cover any pixel at all.
|
||||
///
|
||||
/// Only a brush can answer no, and it matters more than the slot it saves.
|
||||
/// An unpainted mask is empty, [`Self::invert`] turns empty into
|
||||
/// everything, and a layer created with invert already set would apply its
|
||||
/// adjustment to the whole photograph before a single stroke was made —
|
||||
/// the loud, wrong-looking failure this codebase avoids everywhere else a
|
||||
/// mask can go missing.
|
||||
fn covers(&self) -> bool {
|
||||
match &self.source {
|
||||
MaskSource::Brush { strokes } => strokes.iter().any(|s| !s.erase && !s.is_empty()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_ops(&self) -> impl Iterator<Item = &dyn Operation> {
|
||||
@@ -478,8 +771,117 @@ impl MaskLayer {
|
||||
}
|
||||
// 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.
|
||||
MaskSource::Linear { .. } | MaskSource::Radial { .. } => false,
|
||||
// new run can invalidate it. Painted strokes are the same: they are
|
||||
// where the user put them, not where a model thought something was.
|
||||
MaskSource::Linear { .. } | MaskSource::Radial { .. } | MaskSource::Brush { .. } => {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The strokes on this layer, empty for any other kind of mask.
|
||||
pub fn strokes(&self) -> &[Stroke] {
|
||||
self.source.strokes()
|
||||
}
|
||||
|
||||
/// How many stroke points this layer is holding. See [`MAX_LAYER_POINTS`].
|
||||
pub fn stroke_points(&self) -> usize {
|
||||
self.strokes().iter().map(Stroke::len).sum()
|
||||
}
|
||||
|
||||
/// Start a stroke, returning whether there was room for it.
|
||||
///
|
||||
/// The interaction layer calls this on press, [`Self::extend_stroke`] for
|
||||
/// every position the pointer reports, and [`Self::end_stroke`] on release.
|
||||
/// Nothing in between needs to reach the GPU by any route other than the
|
||||
/// stack itself: the rasteriser reads the strokes each time it runs.
|
||||
pub fn begin_stroke(&mut self, erase: bool, radius: f32, hardness: f32, flow: f32) -> bool {
|
||||
let full = self.stroke_points() >= MAX_LAYER_POINTS;
|
||||
let MaskSource::Brush { strokes } = &mut self.source else {
|
||||
log::warn!("layer {} is a {} mask, not a brush", self.id, self.source.kind());
|
||||
return false;
|
||||
};
|
||||
if full {
|
||||
log::warn!(
|
||||
"brush layer is full ({MAX_LAYER_POINTS} points); refusing to start another stroke"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
strokes.push(Stroke::new(erase, radius, hardness, flow));
|
||||
true
|
||||
}
|
||||
|
||||
/// Add a position to the stroke in progress, returning whether it was kept.
|
||||
///
|
||||
/// A position may be dropped for being too close to the last one, which is
|
||||
/// ordinary and not a failure. When the stroke in progress fills up the
|
||||
/// gesture continues in a new one starting at the same point, so the swept
|
||||
/// path has no gap in it — see [`MAX_STROKE_POINTS`].
|
||||
pub fn extend_stroke(&mut self, x: f32, y: f32) -> bool {
|
||||
let room = MAX_LAYER_POINTS.saturating_sub(self.stroke_points());
|
||||
let MaskSource::Brush { strokes } = &mut self.source else {
|
||||
return false;
|
||||
};
|
||||
let Some(current) = strokes.last_mut() else {
|
||||
return false;
|
||||
};
|
||||
if room == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if current.is_full() {
|
||||
// Simplified here rather than in `end_stroke`, which only ever sees
|
||||
// the last stroke of a gesture: a continuation closes the one
|
||||
// before it for good, and an unsimplified stroke would reach the
|
||||
// sidecar at full sampling — the one place the saving matters most,
|
||||
// since a gesture long enough to split is a long line of text.
|
||||
current.simplify();
|
||||
let mut next = Stroke::new(
|
||||
current.erase,
|
||||
current.radius,
|
||||
current.hardness,
|
||||
current.flow,
|
||||
);
|
||||
if let Some(&joint) = current.points.last() {
|
||||
next.points.push(joint);
|
||||
}
|
||||
strokes.push(next);
|
||||
}
|
||||
|
||||
strokes
|
||||
.last_mut()
|
||||
.expect("a stroke was just ensured")
|
||||
.push_point(x, y)
|
||||
}
|
||||
|
||||
/// Finish the stroke in progress, simplifying it.
|
||||
///
|
||||
/// A stroke that recorded nothing is dropped rather than kept as an empty
|
||||
/// one: a press with no movement still records its first point, so an empty
|
||||
/// stroke can only be a press that never reached the model, and leaving it
|
||||
/// would put a stroke in the sidecar that draws nothing.
|
||||
pub fn end_stroke(&mut self) {
|
||||
let MaskSource::Brush { strokes } = &mut self.source else {
|
||||
return;
|
||||
};
|
||||
match strokes.last_mut() {
|
||||
Some(s) if s.is_empty() => {
|
||||
strokes.pop();
|
||||
}
|
||||
Some(s) => s.simplify(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the most recent stroke, returning it.
|
||||
///
|
||||
/// The undo history already snapshots the whole graph, so this is not how
|
||||
/// undo works — it is for the interaction layer to abandon a stroke it has
|
||||
/// begun, when a gesture turns out to be a pinch or is cancelled.
|
||||
pub fn drop_last_stroke(&mut self) -> Option<Stroke> {
|
||||
match &mut self.source {
|
||||
MaskSource::Brush { strokes } => strokes.pop(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +1368,219 @@ mod tests {
|
||||
assert_eq!(moved, vec![("exposure", "exposure", 1.25)]);
|
||||
}
|
||||
|
||||
fn painted(id: &str) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new(id, MaskSource::brush());
|
||||
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
||||
layer
|
||||
}
|
||||
|
||||
/// A gesture: press, drag along `path`, release.
|
||||
fn paint(layer: &mut MaskLayer, erase: bool, radius: f32, path: &[(f32, f32)]) {
|
||||
layer.begin_stroke(erase, radius, 0.5, 1.0);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_brush_layer_is_never_stale() {
|
||||
let mut layer = painted("m1");
|
||||
paint(&mut layer, false, 0.05, &[(0.2, 0.2), (0.8, 0.8)]);
|
||||
assert!(
|
||||
!layer.is_stale(12345),
|
||||
"strokes are where the user put them, not where a model found something"
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure this prevents is loud and total: `invert` turns an empty
|
||||
/// mask into the whole frame, so an unpainted layer that rendered would
|
||||
/// apply its adjustment to the entire photograph.
|
||||
#[test]
|
||||
fn an_unpainted_brush_layer_is_not_active() {
|
||||
let mut layer = painted("m1");
|
||||
assert!(!layer.is_active(), "nothing has been painted yet");
|
||||
|
||||
paint(&mut layer, false, 0.05, &[(0.5, 0.5)]);
|
||||
assert!(layer.is_active(), "one dab is a mask");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_of_nothing_but_erasing_is_not_active() {
|
||||
let mut layer = painted("m1");
|
||||
paint(&mut layer, true, 0.05, &[(0.5, 0.5)]);
|
||||
assert!(
|
||||
!layer.is_active(),
|
||||
"erasing an unpainted layer takes nothing away"
|
||||
);
|
||||
}
|
||||
|
||||
/// A press with no movement is a tap, and a tap paints one dab. Dropping
|
||||
/// it as "no path" would make a brush that ignores the shortest stroke
|
||||
/// there is.
|
||||
#[test]
|
||||
fn a_tap_is_a_stroke() {
|
||||
let mut layer = painted("m1");
|
||||
paint(&mut layer, false, 0.05, &[(0.5, 0.5)]);
|
||||
assert_eq!(layer.strokes().len(), 1);
|
||||
assert_eq!(layer.strokes()[0].points, vec![(0.5, 0.5)]);
|
||||
}
|
||||
|
||||
/// A finger held still reports position after position at the same place.
|
||||
/// Left in, they would fill the stroke and be rasterised every frame until
|
||||
/// the gesture ended.
|
||||
#[test]
|
||||
fn a_stationary_finger_does_not_fill_the_stroke() {
|
||||
let mut layer = painted("m1");
|
||||
layer.begin_stroke(false, 0.05, 0.5, 1.0);
|
||||
for _ in 0..200 {
|
||||
layer.extend_stroke(0.5, 0.5);
|
||||
}
|
||||
layer.end_stroke();
|
||||
assert_eq!(layer.strokes()[0].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplifying_keeps_the_shape_and_drops_the_rest() {
|
||||
let mut straight = Stroke::new(false, 0.1, 0.5, 1.0);
|
||||
let mut bent = Stroke::new(false, 0.1, 0.5, 1.0);
|
||||
for i in 0..=10 {
|
||||
let t = i as f32 / 10.0;
|
||||
straight.points.push((t, 0.5));
|
||||
// A corner at the halfway point, far enough out to matter.
|
||||
bent.points.push((t, 0.5 + (0.5 - (t - 0.5).abs()) * 0.5));
|
||||
}
|
||||
|
||||
straight.simplify();
|
||||
assert_eq!(
|
||||
straight.points,
|
||||
vec![(0.0, 0.5), (1.0, 0.5)],
|
||||
"a straight line is two points however finely it was sampled"
|
||||
);
|
||||
|
||||
bent.simplify();
|
||||
assert_eq!(bent.len(), 3, "the corner survives");
|
||||
assert!(
|
||||
bent.points[1].0 > 0.4 && bent.points[1].0 < 0.6,
|
||||
"and it is the corner that survived, not an arbitrary midpoint: {:?}",
|
||||
bent.points
|
||||
);
|
||||
}
|
||||
|
||||
/// Tolerance follows the radius, because a swept disc cannot express
|
||||
/// detail finer than its own edge — so a fat brush may throw away wobble a
|
||||
/// fine one has to keep.
|
||||
#[test]
|
||||
fn a_fat_brush_simplifies_harder_than_a_fine_one() {
|
||||
let wobble: Vec<(f32, f32)> = (0..=20)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 20.0;
|
||||
(t, 0.5 + if i % 2 == 0 { 0.004 } else { -0.004 })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut fine = Stroke::new(false, 0.005, 0.5, 1.0);
|
||||
fine.points = wobble.clone();
|
||||
fine.simplify();
|
||||
|
||||
let mut fat = Stroke::new(false, 0.2, 0.5, 1.0);
|
||||
fat.points = wobble;
|
||||
fat.simplify();
|
||||
|
||||
assert!(
|
||||
fat.len() < fine.len(),
|
||||
"fat {} should keep fewer than fine {}",
|
||||
fat.len(),
|
||||
fine.len()
|
||||
);
|
||||
assert_eq!(fat.len(), 2, "the wobble is far inside a fat brush's edge");
|
||||
}
|
||||
|
||||
/// A gesture longer than one stroke holds must continue, not stop. A brush
|
||||
/// that quietly stops recording under the finger is the failure a painter
|
||||
/// notices first.
|
||||
#[test]
|
||||
fn a_long_gesture_continues_in_another_stroke() {
|
||||
let mut layer = painted("m1");
|
||||
layer.begin_stroke(false, 0.001, 0.5, 1.0);
|
||||
for i in 0..(MAX_STROKE_POINTS + 40) {
|
||||
let t = i as f32 / (MAX_STROKE_POINTS + 40) as f32;
|
||||
layer.extend_stroke(t, 0.5);
|
||||
}
|
||||
layer.end_stroke();
|
||||
|
||||
let strokes = layer.strokes();
|
||||
assert!(strokes.len() > 1, "the gesture should have continued");
|
||||
assert!(strokes[0].len() <= MAX_STROKE_POINTS);
|
||||
assert_eq!(
|
||||
strokes[0].points.last(),
|
||||
strokes[1].points.first(),
|
||||
"the continuation starts where the last one ended, so the swept \
|
||||
path has no gap in it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Refusing rather than dropping the oldest strokes, the same way the layer
|
||||
/// stack refuses a ninth layer: work already on screen must not vanish.
|
||||
#[test]
|
||||
fn a_full_brush_layer_refuses_more_paint() {
|
||||
let mut layer = painted("m1");
|
||||
layer.begin_stroke(false, 0.0005, 0.5, 1.0);
|
||||
// A zigzag, so simplification cannot quietly make room by throwing the
|
||||
// path away — this test is about the cap, not about the tolerance.
|
||||
for i in 0..(MAX_LAYER_POINTS * 4) {
|
||||
let t = i as f32 / (MAX_LAYER_POINTS * 4) as f32;
|
||||
layer.extend_stroke(t, if i % 2 == 0 { 0.49 } else { 0.51 });
|
||||
}
|
||||
layer.end_stroke();
|
||||
|
||||
assert!(layer.stroke_points() <= MAX_LAYER_POINTS);
|
||||
assert!(
|
||||
!layer.begin_stroke(false, 0.05, 0.5, 1.0),
|
||||
"a full layer says so rather than making room"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_snap_to_the_stored_grid() {
|
||||
let mut layer = painted("m1");
|
||||
paint(&mut layer, false, 0.0512345, &[(0.1234567, 0.7654321)]);
|
||||
|
||||
let stroke = &layer.strokes()[0];
|
||||
assert_eq!(stroke.points[0], (0.1235, 0.7654));
|
||||
assert_eq!(stroke.radius, 0.0512);
|
||||
}
|
||||
|
||||
/// Beginning a stroke on a gradient would be a brush painting into a mask
|
||||
/// that has nowhere to put it, and silently discarding the gesture is how
|
||||
/// a mode bug looks like a broken digitiser.
|
||||
#[test]
|
||||
fn a_stroke_on_a_layer_that_is_not_a_brush_is_refused() {
|
||||
let mut layer = MaskLayer::new(
|
||||
"m1",
|
||||
MaskSource::Linear {
|
||||
centre: (0.5, 0.5),
|
||||
angle: 0.0,
|
||||
width: 0.2,
|
||||
},
|
||||
);
|
||||
assert!(!layer.begin_stroke(false, 0.05, 0.5, 1.0));
|
||||
assert!(!layer.extend_stroke(0.5, 0.5));
|
||||
assert!(layer.strokes().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abandoned_stroke_can_be_taken_back() {
|
||||
let mut layer = painted("m1");
|
||||
paint(&mut layer, false, 0.05, &[(0.2, 0.2)]);
|
||||
layer.begin_stroke(false, 0.05, 0.5, 1.0);
|
||||
layer.extend_stroke(0.8, 0.8);
|
||||
|
||||
assert_eq!(layer.strokes().len(), 2);
|
||||
assert!(layer.drop_last_stroke().is_some());
|
||||
assert_eq!(layer.strokes().len(), 1, "the first gesture is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_moves_a_layer_within_the_stack() {
|
||||
let mut stack = MaskStack::new();
|
||||
|
||||
Reference in New Issue
Block a user