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:
2026-08-22 12:37:42 +02:00
co-authored by Claude Opus 5
parent 586698db00
commit c396a22dfd
6 changed files with 1787 additions and 20 deletions
+434 -5
View File
@@ -9,8 +9,17 @@
//! Rasterising is **not** on the slider path. Dragging exposure on a masked //! Rasterising is **not** on the slider path. Dragging exposure on a masked
//! layer changes uniforms only; the mask array is reused untouched. This pass //! layer changes uniforms only; the mask array is reused untouched. This pass
//! runs when a mask's *shape* changes — a different selection, a moved //! runs when a mask's *shape* changes — a different selection, a moved
//! gradient, a resized output — which is what keeps a local adjustment as //! gradient, a new stroke, a resized output — which is what keeps a local
//! responsive as a global one. //! adjustment as responsive as a global one.
//!
//! # The two shapes of pass
//!
//! A parametric mask is a function of the whole frame, so it is one full-screen
//! triangle. A brush is not: a stroke reaches a bounded part of the picture,
//! and drawing it over the whole frame would cost `pixels × segments` for a
//! mark the size of a thumb. So strokes are drawn over their own bounding
//! boxes, one draw each, compositing onto the slice with blend state — see the
//! second half of `mask.wgsl`.
//! //!
//! # The label field //! # The label field
//! //!
@@ -21,7 +30,7 @@
//! `region_count`. The compaction is CPU-side and once per image, which is the //! `region_count`. The compaction is CPU-side and once per image, which is the
//! same place and cadence the region adjacency graph is already built at. //! same place and cadence the region adjacency graph is already built at.
use dr_pipeline::mask::{MaskSource, MaskStack, MAX_LAYERS}; use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::{GpuContext, GpuError}; use crate::{GpuContext, GpuError};
@@ -31,6 +40,12 @@ const MODE_REGIONS: u32 = 0;
const MODE_LINEAR: u32 = 1; const MODE_LINEAR: u32 = 1;
const MODE_RADIAL: u32 = 2; const MODE_RADIAL: u32 = 2;
const MODE_SUBJECT: u32 = 3; const MODE_SUBJECT: u32 = 3;
/// Brush layers go through their own entry points rather than the `switch`, so
/// this is only ever read by a person looking at a captured frame.
const MODE_BRUSH: u32 = 4;
/// Six vertices — two triangles — per stroke. See `vs_brush`.
const VERTICES_PER_STROKE: u32 = 6;
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
@@ -54,6 +69,105 @@ struct MaskParams {
_pad1: [f32; 2], _pad1: [f32; 2],
} }
/// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it.
///
/// The bounding box is computed here rather than in the shader because the
/// vertex stage needs it before there is anything to compute it from — that is
/// the whole trick: the box is what stops the fragment shader running over
/// pixels the stroke cannot reach. Finding it is a pass over a few hundred
/// coordinates, which is not rasterising a mask on the CPU by any reading of
/// ARCH §5.4: no pixel is produced, and the output is four floats.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct StrokeHeader {
lo: [f32; 2],
hi: [f32; 2],
radius: f32,
hardness: f32,
flow: f32,
first: u32,
count: u32,
_pad: u32,
}
/// The strokes of one layer, packed for the shader.
///
/// Empty when the layer has nothing to draw, which is not the same as an error:
/// a brush layer with no strokes is a mask covering nothing, and a mask
/// covering nothing is what an unpainted layer should be.
struct StrokeBatch {
headers: Vec<StrokeHeader>,
points: Vec<[f32; 2]>,
/// Whether each header erases, in step with `headers`. Not in the header
/// itself because it selects a *pipeline* rather than a value the shader
/// reads: add and erase are two blend states over one fragment shader.
erases: Vec<bool>,
}
impl StrokeBatch {
/// Pack `strokes` for a mask of `width`×`height`.
fn pack(strokes: &[Stroke], width: u32, height: u32) -> Self {
let short = field_short_edge(width, height);
// Back out of shorter-edge units into normalised ones, per axis. The
// radius is a fraction of the shorter edge, so on a landscape frame it
// is a smaller fraction of the width than of the height, and growing
// the box by the same amount in both would clip the ends of a stroke
// along the long axis.
let margin = |extent: u32| short / extent.max(1) as f32;
let (mx, my) = (margin(width), margin(height));
let texel = (1.0 / width.max(1) as f32).max(1.0 / height.max(1) as f32);
let mut out = Self {
headers: Vec::with_capacity(strokes.len()),
points: Vec::new(),
erases: Vec::with_capacity(strokes.len()),
};
for stroke in strokes {
if stroke.points.is_empty() {
continue;
}
let mut lo = [f32::MAX, f32::MAX];
let mut hi = [f32::MIN, f32::MIN];
for &(x, y) in &stroke.points {
lo = [lo[0].min(x), lo[1].min(y)];
hi = [hi[0].max(x), hi[1].max(y)];
}
// Grown by the radius, or a stroke would be drawn only where its
// centre line ran — and a tap, whose box has no area at all, would
// draw nothing whatever.
let grow = [stroke.radius * mx + texel, stroke.radius * my + texel];
out.headers.push(StrokeHeader {
lo: [
(lo[0] - grow[0]).clamp(0.0, 1.0),
(lo[1] - grow[1]).clamp(0.0, 1.0),
],
hi: [
(hi[0] + grow[0]).clamp(0.0, 1.0),
(hi[1] + grow[1]).clamp(0.0, 1.0),
],
radius: stroke.radius,
hardness: stroke.hardness,
flow: stroke.flow,
first: out.points.len() as u32,
count: stroke.points.len() as u32,
_pad: 0,
});
out.erases.push(stroke.erase);
out.points
.extend(stroke.points.iter().map(|&(x, y)| [x, y]));
}
out
}
fn is_empty(&self) -> bool {
self.headers.is_empty()
}
}
/// The segmentation a region mask indexes into, resident on the GPU. /// The segmentation a region mask indexes into, resident on the GPU.
/// ///
/// Uploaded once per image. Holds the compacted label field and nothing else — /// Uploaded once per image. Holds the compacted label field and nothing else —
@@ -229,6 +343,18 @@ pub struct MaskPass {
ctx: GpuContext, ctx: GpuContext,
layout: wgpu::BindGroupLayout, layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline, pipeline: wgpu::RenderPipeline,
/// The brush's own bindings: the parameters, plus the stroke buffers.
///
/// A second layout rather than two more entries on the first, because a
/// brush reads neither the label field nor a distance field and the
/// parametric masks read no strokes. Sharing one layout would mean binding
/// a placeholder in every draw for something that pass provably cannot
/// touch.
brush_layout: wgpu::BindGroupLayout,
/// One fragment shader, two blend states: `dst + a(1 - dst)` to paint and
/// `dst(1 - a)` to erase.
brush_add: wgpu::RenderPipeline,
brush_erase: wgpu::RenderPipeline,
array: Option<MaskArray>, array: Option<MaskArray>,
/// How many times the array texture has been (re)allocated. /// How many times the array texture has been (re)allocated.
/// ///
@@ -314,6 +440,76 @@ impl MaskPass {
cache: None, cache: None,
}); });
let brush_layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("mask-brush-bgl"),
entries: &[
uniform_entry(0),
// Visible to the vertex stage too: the stroke headers are
// where the bounding box comes from, and the box is what
// the vertex shader draws.
wgpu::BindGroupLayoutEntry {
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
..storage_entry(4)
},
storage_entry(5),
],
});
let brush_pipeline_layout =
ctx.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mask-brush-layout"),
bind_group_layouts: &[Some(&brush_layout)],
immediate_size: 0,
});
let brush = |label, blend| {
ctx.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&brush_pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_brush"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_brush"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: MaskArray::FORMAT,
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
};
// Source-over: what the stroke deposits, plus what it did not cover of
// whatever was already there. Two strokes at half flow reach three
// quarters rather than one, which is what "build up" means.
let brush_add = brush(
"mask-brush-add",
blend_state(wgpu::BlendFactor::One, wgpu::BlendFactor::OneMinusSrc),
);
// The same, with the deposit thrown away: coverage is only ever taken
// off what earlier strokes on this layer put down. There is no negative
// coverage to accumulate, so erasing an unpainted layer is a no-op
// rather than a mask that comes back inverted.
let brush_erase = brush(
"mask-brush-erase",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
);
if let Some(err) = pollster::block_on(scope.pop()) { if let Some(err) = pollster::block_on(scope.pop()) {
return Err(GpuError::ShaderCompilation(err.to_string())); return Err(GpuError::ShaderCompilation(err.to_string()));
} }
@@ -327,6 +523,9 @@ impl MaskPass {
ctx: ctx.clone(), ctx: ctx.clone(),
layout, layout,
pipeline, pipeline,
brush_layout,
brush_add,
brush_erase,
array: None, array: None,
allocations: 0, allocations: 0,
placeholder, placeholder,
@@ -394,8 +593,15 @@ impl MaskPass {
}; };
let params = self.params(layer, field, width, height); let params = self.params(layer, field, width, height);
let selected = self.selection_buffer(layer, field); match &layer.source {
self.draw(&mut encoder, slot as u32, &params, field, &selected, subject); MaskSource::Brush { strokes } => {
self.draw_brush(&mut encoder, slot as u32, &params, strokes, width, height)
}
_ => {
let selected = self.selection_buffer(layer, field);
self.draw(&mut encoder, slot as u32, &params, field, &selected, subject);
}
}
} }
self.ctx.queue.submit([encoder.finish()]); self.ctx.queue.submit([encoder.finish()]);
@@ -488,6 +694,145 @@ impl MaskPass {
angle: *angle, angle: *angle,
..base ..base
}, },
// A brush carries everything else per stroke, so the only fields it
// reads here are the output dimensions — which it needs for the
// aspect ratio, not for a coordinate.
MaskSource::Brush { .. } => MaskParams {
mode: MODE_BRUSH,
..base
},
}
}
/// Paint one brush layer's slice.
///
/// The slice is cleared and then the strokes are blended onto it in the
/// order they were painted, which is why this is a pass of its own rather
/// than a variation on [`Self::draw`]: the accumulating mask *is* the
/// attachment, so an erase can take away what an add put down without
/// either of them reading the texture.
///
/// Consecutive strokes that composite the same way go out as one draw,
/// since the only thing that changes between them is the pipeline. A layer
/// painted and never erased is therefore one draw call however many strokes
/// it holds.
fn draw_brush(
&self,
encoder: &mut wgpu::CommandEncoder,
slot: u32,
params: &MaskParams,
strokes: &[Stroke],
width: u32,
height: u32,
) {
let batch = StrokeBatch::pack(strokes, width, height);
// Still worth beginning the pass: the slice has to be cleared, or an
// unpainted layer would show whatever the last edit left in it.
let bind_group = (!batch.is_empty()).then(|| {
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-brush-params"),
contents: bytemuck::bytes_of(params),
usage: wgpu::BufferUsages::UNIFORM,
});
// Rebuilt per rasterisation rather than kept and patched. This runs
// when a mask's shape changes, not per frame, and a few kilobytes
// of stroke geometry is cheaper to upload than a residency scheme
// is to get wrong.
let headers = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-strokes"),
contents: bytemuck::cast_slice(&batch.headers),
usage: wgpu::BufferUsages::STORAGE,
});
let points = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-stroke-points"),
contents: bytemuck::cast_slice(&batch.points),
usage: wgpu::BufferUsages::STORAGE,
});
self.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mask-brush-bind"),
layout: &self.brush_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: headers.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 5,
resource: points.as_entire_binding(),
},
],
})
});
let array = self.array.as_ref().expect("array ensured by caller");
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("mask-slice"),
dimension: Some(wgpu::TextureViewDimension::D2),
base_array_layer: slot,
array_layer_count: Some(1),
..Default::default()
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-brush-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
// Nothing at all until a stroke covers it, which is what
// makes an unpainted brush layer mask nothing rather than
// everything.
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
let Some(bind_group) = bind_group else {
return;
};
pass.set_bind_group(0, &bind_group, &[]);
let mut run = 0;
while run < batch.erases.len() {
let erases = batch.erases[run];
let mut end = run + 1;
while end < batch.erases.len() && batch.erases[end] == erases {
end += 1;
}
pass.set_pipeline(if erases {
&self.brush_erase
} else {
&self.brush_add
});
pass.draw(
run as u32 * VERTICES_PER_STROKE..end as u32 * VERTICES_PER_STROKE,
0..1,
);
run = end;
} }
} }
@@ -675,6 +1020,24 @@ fn falloff_code(falloff: dr_pipeline::mask::Falloff) -> u32 {
} }
} }
/// `src * src_factor + dst * dst_factor`, on both components.
///
/// The mask is a single channel, so the alpha component is never written — but
/// a target still has to declare one, and declaring something different there
/// would be a difference nothing could observe and everything could be confused
/// by.
fn blend_state(src: wgpu::BlendFactor, dst: wgpu::BlendFactor) -> wgpu::BlendState {
let component = wgpu::BlendComponent {
src_factor: src,
dst_factor: dst,
operation: wgpu::BlendOperation::Add,
};
wgpu::BlendState {
color: component,
alpha: component,
}
}
fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry {
binding, binding,
@@ -796,6 +1159,72 @@ mod tests {
assert_eq!(array.layers(), 2, "one slice per active layer"); assert_eq!(array.layers(), 2, "one slice per active layer");
} }
/// One gesture: whether it erases, its radius, and its path.
type Gesture = (bool, f32, Vec<(f32, f32)>);
fn painted(gestures: &[Gesture]) -> MaskLayer {
let mut layer = lit(MaskSource::brush());
for (erase, radius, path) in gestures {
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
for &(x, y) in path {
layer.extend_stroke(x, y);
}
layer.end_stroke();
}
layer
}
/// A brush is the one mask that needs nothing uploaded first — no
/// segmentation, no distance field, no label. Requiring one would mean a
/// photograph could not be painted on until a model had run over it.
#[test]
fn a_brush_needs_no_segmentation() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(painted(&[(false, 0.1, vec![(0.2, 0.2), (0.8, 0.8)])]));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass.render(&stack, None, None, 32, 32).expect("render");
assert_eq!(array.layers(), 1);
}
/// The box a stroke is drawn over has to be grown by its radius. Packed
/// from the points alone, a tap's box has no area at all and the stroke
/// would be silently missing from the mask.
#[test]
fn a_taps_box_has_room_for_its_dab() {
let layer = painted(&[(false, 0.25, vec![(0.5, 0.5)])]);
let batch = StrokeBatch::pack(layer.strokes(), 64, 32);
assert_eq!(batch.headers.len(), 1);
let h = &batch.headers[0];
assert!(h.hi[0] - h.lo[0] > 0.2, "wide enough for the dab: {h:?}", h = (h.lo, h.hi));
assert!(
h.hi[1] - h.lo[1] > h.hi[0] - h.lo[0],
"and taller than it is wide in normalised units, since the radius \
is a fraction of the shorter edge"
);
}
/// The pipeline is chosen per stroke, so the packed order has to be the
/// painted order — an erase that ended up before its add would put paint
/// back that the user removed.
#[test]
fn packing_keeps_the_painted_order() {
let layer = painted(&[
(false, 0.1, vec![(0.2, 0.5), (0.4, 0.5)]),
(true, 0.1, vec![(0.3, 0.5)]),
(false, 0.1, vec![(0.8, 0.5)]),
]);
let batch = StrokeBatch::pack(layer.strokes(), 32, 32);
assert_eq!(batch.erases, [false, true, false]);
assert_eq!(batch.headers[0].first, 0);
assert_eq!(batch.headers[1].first, batch.headers[0].count);
}
#[test] #[test]
fn an_empty_stack_still_yields_a_bindable_array() { fn an_empty_stack_still_yields_a_bindable_array() {
let Some(ctx) = ctx() else { let Some(ctx) = ctx() else {
+152 -1
View File
@@ -28,7 +28,11 @@ struct MaskParams {
label_width: u32, label_width: u32,
label_height: u32, label_height: u32,
// 0 = regions, 1 = linear, 2 = radial, 3 = subject. // 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
//
// A brush does not read this — it has its own entry points, because it is
// the one mask that is not a function of the whole frame — but it is set
// anyway so a captured frame says which kind of mask a pass was drawing.
mode: u32, mode: u32,
// How many regions the label field holds, so an out-of-range label is // How many regions the label field holds, so an out-of-range label is
// caught rather than read past the end of `selected`. // caught rather than read past the end of `selected`.
@@ -220,3 +224,150 @@ fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0); return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
} }
// ---------------------------------------------------------------------------
// Brush strokes (ARCH §5.4)
// ---------------------------------------------------------------------------
//
// The mask the architecture was written for. What arrives is a list of
// positions, a radius, a hardness and a flow; what leaves is pixels. Nothing
// between the two ever exists in CPU memory, which is the whole difference from
// darktable, where the same strokes are rasterised on the CPU and the lag makes
// painting unusable.
//
// # Why the strokes are not drawn by the full-screen triangle above
//
// Cost. A swept disc is the minimum distance to any segment of its polyline, so
// evaluating one stroke costs a distance per segment *per pixel*. Over the
// whole frame that is `pixels × segments`, and a stroke that wandered across
// the photograph has both terms large at once.
//
// So each stroke is drawn over its own bounding box instead, expanded by the
// radius. The rasteriser then never invokes the fragment shader for a pixel the
// stroke cannot reach, and the cost becomes `area(box) × segments` — for the
// ordinary case, a dab or a swipe, a small fraction of the frame. The model
// splits a long gesture into strokes of bounded length for the same reason:
// both terms of that product grow with how far one stroke travelled.
//
// # Why the strokes composite with fixed-function blending
//
// Add is `dst + a(1 - dst)` and erase is `dst(1 - a)`, which are exactly a
// source-over and a one-minus-source blend. Expressing them as blend state
// rather than as arithmetic in the shader is what allows one draw per stroke:
// the accumulating mask is the attachment, and no pass ever has to read the
// slice it is writing.
struct StrokeHeader {
// Bounding box in normalised coordinates, already grown by the radius and
// a texel — the vertex shader trusts it and draws nothing outside it.
lo: vec2<f32>,
hi: vec2<f32>,
// Radius in units of the frame's shorter edge, so a dab is round on a frame
// that is not square.
radius: f32,
// Fraction of the radius that is fully covered.
hardness: f32,
// Coverage deposited where the stroke is solid.
flow: f32,
// Window into `stroke_points`.
first: u32,
count: u32,
_pad: u32,
}
@group(0) @binding(4) var<storage, read> strokes: array<StrokeHeader>;
@group(0) @binding(5) var<storage, read> stroke_points: array<vec2<f32>>;
struct BrushVertex {
@builtin(position) pos: vec4<f32>,
// Flat: a stroke index interpolated across its own quad would name a
// different stroke in the middle of it.
@location(0) @interpolate(flat) stroke: u32,
}
// Six vertices per stroke, non-instanced.
//
// Deliberately not one instance per stroke: `@builtin(instance_index)` with a
// non-zero first instance needs base-instance support, which the GL backend
// this has to run on under Android cannot promise. Dividing the vertex index
// costs one integer operation and works everywhere.
@vertex
fn vs_brush(@builtin(vertex_index) v: u32) -> BrushVertex {
var quad = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
);
let i = v / 6u;
let s = strokes[i];
let uv = mix(s.lo, s.hi, quad[v % 6u]);
var out: BrushVertex;
// y is flipped because normalised mask coordinates run downwards, the way
// the fragment shader above reads them, and clip space runs upwards. A
// stroke drawn without this lands mirrored about the horizon, which is
// plausible enough on a symmetric test image to survive a careless check.
out.pos = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
out.stroke = i;
return out;
}
// Into units of the frame's shorter edge.
//
// Without this the brush would be a circle in normalised coordinates, which on
// a 3:2 frame is an ellipse half again as wide as it is tall. A brush whose dab
// is not round is not a brush.
fn to_square(uv: vec2<f32>) -> vec2<f32> {
let dims = vec2<f32>(f32(p.width), f32(p.height));
return uv * dims / min(dims.x, dims.y);
}
fn segment_distance(q: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
let ab = b - a;
let len2 = dot(ab, ab);
// A finger that stopped and went back leaves a zero-length segment, and
// dividing by its length is a NaN — which propagates through the min()
// below and takes the whole stroke with it.
if (len2 <= 1e-12) {
return length(q - a);
}
let t = clamp(dot(q - a, ab) / len2, 0.0, 1.0);
return length(q - (a + ab * t));
}
@fragment
fn fs_brush(in: BrushVertex) -> @location(0) vec4<f32> {
let s = strokes[in.stroke];
let q = to_square(vec2<f32>(in.pos.x / f32(p.width), in.pos.y / f32(p.height)));
// The *minimum* over the segments, which is the maximum of their coverage.
// Accumulating the segments instead would make a stroke that crosses itself
// — every circle, every scribble — build up a bright patch where it did,
// and a soft brush would go blotchy along any curve tight enough for
// consecutive dabs to overlap, which is all of them.
var d = 1e30;
if (s.count == 1u) {
// A tap. One point is a legitimate stroke, and it paints one dab.
d = length(q - to_square(stroke_points[s.first]));
} else {
for (var k = 0u; k + 1u < s.count; k = k + 1u) {
d = min(
d,
segment_distance(
q,
to_square(stroke_points[s.first + k]),
to_square(stroke_points[s.first + k + 1u]),
),
);
}
}
// Even at full hardness the edge keeps a one-pixel ramp. A true step would
// alias into a staircase, and the mask is sampled bilinearly at whatever
// zoom the user is inspecting it at — which is where an edge is judged.
let texel = 1.0 / f32(min(p.width, p.height));
let inner = min(s.radius * clamp(s.hardness, 0.0, 1.0), max(s.radius - texel, 0.0));
let coverage = 1.0 - smoothstep(inner, s.radius, d);
return vec4<f32>(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0);
}
+336 -5
View File
@@ -25,8 +25,12 @@ fn ctx() -> Option<GpuContext> {
/// A flat mid-grey JPEG-path image, so any change is the adjustment's. /// A flat mid-grey JPEG-path image, so any change is the adjustment's.
fn grey(ctx: &GpuContext) -> DemosaicedImage { fn grey(ctx: &GpuContext) -> DemosaicedImage {
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect(); grey_at(ctx, SIZE, SIZE)
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") }
fn grey_at(ctx: &GpuContext, w: u32, h: u32) -> DemosaicedImage {
let data: Vec<u8> = (0..w * h).flat_map(|_| [128, 128, 128, 255]).collect();
DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload")
} }
/// Two regions: 0 is the left half, 1 the right. /// Two regions: 0 is the left half, 1 the right.
@@ -50,7 +54,17 @@ fn luma_at(pixels: &[u8], x: u32, y: u32) -> u8 {
/// Render `stack` over flat grey and hand back the RGBA8 result. /// Render `stack` over flat grey and hand back the RGBA8 result.
fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec<u8> { fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec<u8> {
let source = grey(ctx); render_at(ctx, stack, field, SIZE, SIZE)
}
fn render_at(
ctx: &GpuContext,
stack: &MaskStack,
field: Option<&LabelField>,
w: u32,
h: u32,
) -> Vec<u8> {
let source = grey_at(ctx, w, h);
let shader = compose_full( let shader = compose_full(
&ops::chain(), &ops::chain(),
&Framing::new(), &Framing::new(),
@@ -59,11 +73,11 @@ fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Ve
); );
let mut masks = MaskPass::new(ctx).expect("mask pass"); let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, None, SIZE, SIZE).expect("rasterise"); let array = masks.render(stack, field, None, w, h).expect("rasterise");
let mut adjust = AdjustPass::new(ctx); let mut adjust = AdjustPass::new(ctx);
adjust adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array)) .render_masked(&source, &shader, w, h, Some(array))
.expect("render"); .expect("render");
adjust.export_pixels().expect("readback").0 adjust.export_pixels().expect("readback").0
} }
@@ -247,6 +261,323 @@ fn stacked_layers_use_their_own_masks() {
assert!(right < 100, "right should have darkened, got {right}"); assert!(right < 100, "right should have darkened, got {right}");
} }
// ---------------------------------------------------------------------------
// Brush strokes (ARCH §5.4)
// ---------------------------------------------------------------------------
const UNTOUCHED: u8 = 128;
fn luma_in(pixels: &[u8], w: u32, x: u32, y: u32) -> u8 {
pixels[((y * w + x) * 4) as usize]
}
/// One gesture: whether it erases, its radius, its flow, and its path.
type Gesture = (bool, f32, f32, Vec<(f32, f32)>);
/// A brightening layer with the given gestures already painted onto it.
fn painted(gestures: &[Gesture]) -> MaskLayer {
let mut layer = brighten(MaskSource::brush());
for (erase, radius, flow, path) in gestures {
layer.begin_stroke(*erase, *radius, 0.9, *flow);
for &(x, y) in path {
layer.extend_stroke(x, y);
}
layer.end_stroke();
}
layer
}
fn stack_of(layer: MaskLayer) -> MaskStack {
let mut stack = MaskStack::new();
stack.push(layer);
stack
}
/// The whole feature, at its simplest: paint somewhere, and that is where the
/// adjustment lands.
///
/// Painted across the top rather than down the middle, because a mask drawn
/// upside down is symmetric about the middle and a centred stroke would not
/// notice — and the vertex shader that draws a stroke has to flip y to reach
/// clip space, which is exactly the kind of thing that is wrong once.
#[test]
fn a_stroke_paints_where_it_was_drawn_and_nowhere_else() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = stack_of(painted(&[(
false,
0.1,
1.0,
vec![(0.2, 0.25), (0.8, 0.25)],
)]));
let pixels = render(&ctx, &stack, None);
let under = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 4);
let below = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 3 / 4);
assert!(
under > UNTOUCHED + 40,
"the stroke should have brightened the upper quarter, got {under}"
);
assert!(
(120..=136).contains(&below),
"the lower half was never painted and must be untouched, got {below}"
);
}
/// The failure a bounding box that is not grown by the radius produces: a tap
/// has no extent at all, so its quad has no area and nothing is drawn. Silent,
/// and it looks exactly like a brush that ignores short gestures.
#[test]
fn a_tap_paints_a_dab() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = stack_of(painted(&[(false, 0.2, 1.0, vec![(0.5, 0.5)])]));
let pixels = render(&ctx, &stack, None);
let centre = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
let corner = luma_in(&pixels, SIZE, 1, 1);
assert!(centre > 180, "the dab should be there, got {centre}");
assert!(
(120..=136).contains(&corner),
"and only there, got {corner}"
);
}
/// Painting must be able to erase, or a mask is one mistake away from being
/// started again.
#[test]
fn an_erasing_stroke_takes_back_what_was_painted() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = stack_of(painted(&[
(false, 0.25, 1.0, vec![(0.15, 0.5), (0.85, 0.5)]),
(true, 0.12, 1.0, vec![(0.5, 0.5)]),
]));
let pixels = render(&ctx, &stack, None);
let erased = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
let kept = luma_in(&pixels, SIZE, 3, SIZE / 2);
assert!(
(120..=136).contains(&erased),
"the erased middle should be back to untouched grey, got {erased}"
);
assert!(
kept > 180,
"the ends of the stroke are still painted, got {kept}"
);
}
/// Order is the mask. The same two gestures the other way round leave the
/// paint alone, and a rasteriser that composited by kind rather than by
/// sequence would give the same answer to both.
#[test]
fn erasing_before_painting_removes_nothing() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let stack = stack_of(painted(&[
(true, 0.12, 1.0, vec![(0.5, 0.5)]),
(false, 0.25, 1.0, vec![(0.15, 0.5), (0.85, 0.5)]),
]));
let pixels = render(&ctx, &stack, None);
let middle = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
assert!(
middle > 180,
"an erase before the paint has nothing to take away, got {middle}"
);
}
/// A stroke that crosses itself must not build up where it did. Summing the
/// segments instead of taking the nearest would make every circle and every
/// scribble blotchy — and at full flow it would not show at all, which is why
/// this paints at half.
#[test]
fn a_stroke_that_doubles_back_does_not_build_up() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let once = stack_of(painted(&[(
false,
0.15,
0.5,
vec![(0.1, 0.5), (0.9, 0.5)],
)]));
let twice = stack_of(painted(&[(
false,
0.15,
0.5,
// Out to the right and back over the last third of itself.
vec![(0.1, 0.5), (0.9, 0.5), (0.65, 0.5)],
)]));
let single = luma_in(&render(&ctx, &once, None), SIZE, SIZE * 3 / 4, SIZE / 2);
let crossed = luma_in(&render(&ctx, &twice, None), SIZE, SIZE * 3 / 4, SIZE / 2);
assert_eq!(
single, crossed,
"one pass of the brush, however many times the path went over it"
);
}
/// Between gestures, though, paint does build up — that is what a flow below
/// one is for, and it is the same blend that lets an erase work.
#[test]
fn two_gestures_at_half_flow_build_up() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let dab = (false, 0.2, 0.5, vec![(0.5, 0.5)]);
let once = stack_of(painted(std::slice::from_ref(&dab)));
let twice = stack_of(painted(&[dab.clone(), dab]));
let single = luma_in(&render(&ctx, &once, None), SIZE, SIZE / 2, SIZE / 2);
let doubled = luma_in(&render(&ctx, &twice, None), SIZE, SIZE / 2, SIZE / 2);
assert!(
doubled > single,
"a second pass should deposit more: {single} then {doubled}"
);
}
/// A brush whose dab is an ellipse is not a brush. The radius is a fraction of
/// the *shorter* edge, so on a frame twice as wide as it is tall a circle in
/// normalised coordinates would come out twice as wide as it is high.
#[test]
fn a_dab_is_round_on_a_frame_that_is_not_square() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
const W: u32 = 64;
const H: u32 = 32;
let stack = stack_of(painted(&[(false, 0.25, 1.0, vec![(0.5, 0.5)])]));
let pixels = render_at(&ctx, &stack, None, W, H);
let lit = |v: u8| v > 160;
let across = (0..W).filter(|&x| lit(luma_in(&pixels, W, x, H / 2))).count();
let down = (0..H).filter(|&y| lit(luma_in(&pixels, W, W / 2, y))).count();
assert!(across > 4 && down > 4, "the dab should exist: {across}x{down}");
assert!(
across.abs_diff(down) <= 2,
"a dab must be as wide as it is tall, got {across} across and {down} down"
);
}
/// Hardness is the edge, and the edge is what a brush is judged on. A hard
/// brush that faded like a soft one would make the control do nothing anyone
/// could see.
#[test]
fn hardness_decides_how_quickly_the_edge_falls_away() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let edge = |hardness: f32| {
let mut layer = brighten(MaskSource::brush());
layer.begin_stroke(false, 0.4, hardness, 1.0);
layer.extend_stroke(0.5, 0.5);
layer.end_stroke();
let pixels = render(&ctx, &stack_of(layer), None);
// How many pixels along the centre row are neither fully painted nor
// fully clear — the width of the transition. "Fully painted" is read
// from the middle of the dab rather than assumed: +2 EV over mid grey
// lands wherever the output transform puts it.
let solid = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
(0..SIZE)
.filter(|&x| {
let v = luma_in(&pixels, SIZE, x, SIZE / 2);
v > UNTOUCHED + 8 && v < solid - 8
})
.count()
};
let soft = edge(0.0);
let hard = edge(1.0);
assert!(
hard < soft,
"a hard brush should transition in fewer pixels: hard {hard}, soft {soft}"
);
assert!(hard <= 4, "and it should be nearly a step, got {hard}");
}
/// The loud failure an unpainted mask can produce: empty inverts to
/// everything, so a layer created with invert already set would apply its
/// adjustment to the whole photograph before a stroke was made.
#[test]
fn an_inverted_brush_layer_with_no_strokes_changes_nothing() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut layer = brighten(MaskSource::brush());
layer.invert = true;
let pixels = render(&ctx, &stack_of(layer), None);
for (x, y) in [(1, 1), (SIZE / 2, SIZE / 2), (SIZE - 2, SIZE - 2)] {
let v = luma_in(&pixels, SIZE, x, y);
assert!(
(120..=136).contains(&v),
"an unpainted mask covers nothing, inverted or not; got {v} at {x},{y}"
);
}
}
/// A painted layer and a gradient in one stack must not read each other's
/// slice — the brush writes its slot through a different pipeline, which is
/// exactly where a slot could be got wrong without either alone noticing.
#[test]
fn a_brush_layer_and_a_gradient_keep_their_own_slices() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(painted(&[(false, 0.15, 1.0, vec![(0.5, 0.15)])]));
let mut darken = MaskLayer::new(
"m2",
MaskSource::Radial {
centre: (0.5, 0.85),
radii: (0.15, 0.15),
angle: 0.0,
feather: 0.1,
},
);
darken.set_param("exposure", ParamId("exposure"), -2.0);
stack.push(darken);
let pixels = render(&ctx, &stack, None);
let top = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 3 / 20);
let bottom = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 17 / 20);
assert!(top > 180, "the painted dab should have brightened: {top}");
assert!(bottom < 100, "the radial should have darkened: {bottom}");
}
/// A neutral edit must render identically whether or not masks are bound — /// A neutral edit must render identically whether or not masks are bound —
/// otherwise merely *having* the feature would alter every unedited image. /// otherwise merely *having* the feature would alter every unedited image.
#[test] #[test]
+622 -7
View File
@@ -9,14 +9,18 @@
//! # Where a mask actually exists //! # Where a mask actually exists
//! //!
//! **Not here, and not on the CPU at all.** A layer stores the *rule* — some //! **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 //! region ids, a gradient's geometry, or the points a finger travelled through
//! into a texture (ARCH §5.4). This module's job is to describe the rule and //! — and a pass on the device rasterises it into a texture (ARCH §5.4). This
//! to emit the WGSL that blends by the result. //! 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 //! That split is the direct response to darktable, where CPU-rasterised brush
//! masks make painting lag badly enough that users call it unworkable. The //! masks make painting lag badly enough that users call it unworkable. The
//! problem there is architectural rather than a tuning failure, and the only //! 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 //! # Why region ids rather than a raster
//! //!
@@ -44,6 +48,252 @@ use crate::ops;
/// be a literal in one. /// be a literal in one.
pub const DEFAULT_FEATHER: f32 = 0.004; 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. /// Bilinear sampling of one slice of the mask array, in **source** space.
/// ///
/// Hand-rolled rather than done with a sampler, matching how the source /// 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. /// Fraction of the radius over which the edge falls off.
feather: f32, 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 { impl MaskSource {
@@ -316,6 +578,22 @@ impl MaskSource {
Self::Subject { .. } => "subject", Self::Subject { .. } => "subject",
Self::Linear { .. } => "linear", Self::Linear { .. } => "linear",
Self::Radial { .. } => "radial", 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 /// a selection the user is still working on — but it contributes nothing
/// to the shader and is omitted from it. /// to the shader and is omitted from it.
pub fn is_active(&self) -> bool { 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> { 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 // A gradient is geometry in normalised coordinates. It means the
// same thing whatever was or was not detected, so nothing about a // same thing whatever was or was not detected, so nothing about a
// new run can invalidate it. // new run can invalidate it. Painted strokes are the same: they are
MaskSource::Linear { .. } | MaskSource::Radial { .. } => false, // 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)]); 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] #[test]
fn reordering_moves_a_layer_within_the_stack() { fn reordering_moves_a_layer_within_the_stack() {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
+85 -1
View File
@@ -67,7 +67,9 @@ use std::fmt;
use std::fmt::Write as _; use std::fmt::Write as _;
use crate::graph::EditGraph; use crate::graph::EditGraph;
use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, DEFAULT_FEATHER}; use crate::mask::{
Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER,
};
use crate::preset::{resolve, Preset}; use crate::preset::{resolve, Preset};
/// Format version of the document itself. /// Format version of the document itself.
@@ -735,6 +737,7 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
let _ = writeln!(out, "angle = {}", format_value(*angle)); let _ = writeln!(out, "angle = {}", format_value(*angle));
let _ = writeln!(out, "feather = {}", format_value(*feather)); let _ = writeln!(out, "feather = {}", format_value(*feather));
} }
MaskSource::Brush { strokes } => write_strokes(out, strokes),
} }
if layer.invert { if layer.invert {
@@ -764,6 +767,79 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
} }
} }
/// Write a brush layer's strokes, one line each.
///
/// A line per stroke, in the order they were painted, because the order *is*
/// the mask: an erase after an add removes it and the same pair reversed does
/// not. It is also the granularity anyone reading a diff wants — a stroke is
/// what the user made and what an undo takes back. A line per point would bury
/// the rest of the file, and one line for the whole layer would make adding a
/// stroke look like the entire mask had been rewritten.
///
/// Points are `x,y` pairs rather than a flat run of numbers. A truncated or
/// hand-edited line would otherwise shift every coordinate by one and land the
/// mask somewhere else entirely, which is the failure that looks like the
/// software forgot the edit rather than like a damaged file.
fn write_strokes(out: &mut String, strokes: &[Stroke]) {
for stroke in strokes {
let _ = write!(
out,
"stroke = {} {} {} {}",
if stroke.erase { "erase" } else { "add" },
format_value(stroke.radius),
format_value(stroke.hardness),
format_value(stroke.flow),
);
for (x, y) in &stroke.points {
let _ = write!(out, " {},{}", format_value(*x), format_value(*y));
}
let _ = writeln!(out);
}
}
/// Read one `stroke = …` line, or nothing if it cannot be trusted.
///
/// A malformed stroke costs that stroke and not the layer. Refusing the whole
/// block would throw away every other stroke on it over one bad line, and
/// guessing at the missing half would put paint somewhere the user never
/// touched — which of the three is worst depends on the line, but a wrong mask
/// is the only one that looks like it worked.
fn parse_stroke(value: &str) -> Option<Stroke> {
let mut tokens = value.split_whitespace();
let erase = match tokens.next()? {
"add" => false,
"erase" => true,
other => {
log::warn!("sidecar: stroke is neither add nor erase ('{other}'); ignoring it");
return None;
}
};
let radius: f32 = tokens.next()?.parse().ok()?;
let hardness: f32 = tokens.next()?.parse().ok()?;
let flow: f32 = tokens.next()?.parse().ok()?;
if !(radius.is_finite() && hardness.is_finite() && flow.is_finite()) {
return None;
}
let mut stroke = Stroke::new(erase, radius, hardness, flow);
for token in tokens {
let (x, y) = token.split_once(',')?;
let (x, y) = (x.parse::<f32>().ok()?, y.parse::<f32>().ok()?);
if !(x.is_finite() && y.is_finite()) {
return None;
}
// Straight onto the list rather than through `push_point`, which drops
// a point too close to the last: that rule belongs to a finger being
// dragged, and applying it here would quietly rewrite a stroke every
// time the file was read — so a sidecar would not survive its own round
// trip, and two devices would rewrite each other's masks forever.
stroke.points.push((x, y));
}
(!stroke.is_empty()).then_some(stroke)
}
/// A mask block being read, before it is complete enough to be a layer. /// A mask block being read, before it is complete enough to be a layer.
/// ///
/// Separate from [`MaskLayer`] because the source cannot be built until every /// Separate from [`MaskLayer`] because the source cannot be built until every
@@ -794,6 +870,7 @@ struct PartialMask {
falloff: Falloff, falloff: Falloff,
morphology: Morphology, morphology: Morphology,
morph_radius: f32, morph_radius: f32,
strokes: Vec<Stroke>,
params: Vec<(String, String, f32)>, params: Vec<(String, String, f32)>,
} }
@@ -822,6 +899,7 @@ impl PartialMask {
falloff: Falloff::default(), falloff: Falloff::default(),
morphology: Morphology::default(), morphology: Morphology::default(),
morph_radius: 0.0, morph_radius: 0.0,
strokes: Vec::new(),
params: Vec::new(), params: Vec::new(),
} }
} }
@@ -852,6 +930,9 @@ impl PartialMask {
"angle" => self.angle = value.parse().unwrap_or(0.0), "angle" => self.angle = value.parse().unwrap_or(0.0),
"width" => self.width = value.parse().unwrap_or(0.0), "width" => self.width = value.parse().unwrap_or(0.0),
"feather" => self.feather = value.parse().unwrap_or(0.0), "feather" => self.feather = value.parse().unwrap_or(0.0),
// Appended rather than assigned: a brush layer is a list of these,
// and the file's line order is the order they were painted in.
"stroke" => self.strokes.extend(parse_stroke(value)),
"invert" => self.invert = value != "0", "invert" => self.invert = value != "0",
"opacity" => self.opacity = value.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0), "opacity" => self.opacity = value.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0),
"enabled" => self.enabled = value != "0", "enabled" => self.enabled = value != "0",
@@ -918,6 +999,9 @@ impl PartialMask {
angle: self.angle, angle: self.angle,
feather: self.feather, feather: self.feather,
}, },
"brush" => MaskSource::Brush {
strokes: self.strokes,
},
other => { other => {
log::warn!( log::warn!(
"sidecar: unknown mask source '{other}'; skipping layer {}", "sidecar: unknown mask source '{other}'; skipping layer {}",
+158 -1
View File
@@ -276,6 +276,153 @@ fn unknown_top_level_keys_still_round_trip_alongside_masks() {
assert!(out.contains("[mask default m1]")); assert!(out.contains("[mask default m1]"));
} }
// ---------------------------------------------------------------------------
// Brush strokes
// ---------------------------------------------------------------------------
/// One gesture to paint: whether it erases, its radius, and its path.
type Gesture = (bool, f32, Vec<(f32, f32)>);
/// Paint gestures onto a fresh brush layer.
fn brushed(id: &str, gestures: &[Gesture]) -> MaskLayer {
let mut layer = MaskLayer::new(id, MaskSource::brush());
layer.set_param("exposure", ParamId("exposure"), 1.0);
for (erase, radius, path) in gestures {
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
for &(x, y) in path {
layer.extend_stroke(x, y);
}
layer.end_stroke();
}
layer
}
/// The whole point of storing strokes as parameters: they must come back as
/// the *same numbers*, not as numbers that render similarly. A coordinate that
/// drifts in the sixth decimal on every save is a file that never stops
/// changing, and under per-field merge that is a conflict a day.
#[test]
fn strokes_survive_a_round_trip_exactly() {
let mut graph = EditGraph::default_chain();
let painted = brushed(
"m1",
&[(
false,
0.0625,
vec![(0.1234, 0.5), (0.4, 0.2), (0.8, 0.75), (0.9, 0.1)],
)],
);
let expected = painted.strokes().to_vec();
graph.masks_mut().push(painted);
let restored = round_trip(&graph);
let layer = &restored.masks().layers()[0];
assert_eq!(layer.strokes(), expected.as_slice());
}
/// Order is the mask. An erase written before the add it was meant to cut into
/// would silently repaint what the user removed — the mask still looks like a
/// mask, so nothing announces it.
#[test]
fn stroke_order_and_direction_survive() {
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(brushed(
"m1",
&[
(false, 0.2, vec![(0.2, 0.5), (0.8, 0.5)]),
(true, 0.1, vec![(0.5, 0.5)]),
],
));
let restored = round_trip(&graph);
let strokes = restored.masks().layers()[0].strokes();
assert_eq!(strokes.len(), 2);
assert!(!strokes[0].erase, "the add must still come first");
assert!(strokes[1].erase, "and the erase second");
assert_eq!(strokes[1].radius, 0.1, "each stroke keeps its own brush");
}
#[test]
fn a_brush_layer_writes_one_line_per_stroke() {
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(brushed(
"m1",
&[
(false, 0.05, vec![(0.2, 0.5), (0.8, 0.5)]),
(true, 0.05, vec![(0.5, 0.5)]),
],
));
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let text = sidecar.to_text();
let lines: Vec<&str> = text
.lines()
.filter(|l| l.starts_with("stroke = "))
.collect();
assert_eq!(lines, ["stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5", "stroke = erase 0.05 0.5 1 0.5,0.5"]);
}
#[test]
fn writing_a_painted_mask_twice_is_byte_identical() {
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(brushed(
"m1",
&[(false, 0.05, vec![(0.2, 0.5), (0.4, 0.6), (0.8, 0.5)])],
));
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let once = sidecar.to_text();
let twice = Sidecar::parse(&once).expect("reparse").to_text();
assert_eq!(once, twice);
}
/// One damaged line must not cost the strokes either side of it. Refusing the
/// whole layer would throw away a mask over a typo, and guessing at the missing
/// half would put paint where nobody touched.
#[test]
fn a_malformed_stroke_costs_only_that_stroke() {
let text = "drsc 1\n\
\n[version default]\n\
name = Default\n\
revision = 1\n\
modified = 0\n\
\n[mask default m1]\n\
source = brush\n\
stroke = add 0.05 0.5 1 0.2,0.5\n\
stroke = sideways 0.05 0.5 1 0.3,0.5\n\
stroke = add 0.05 0.5 1 0.4 0.5\n\
stroke = add 0.05 0.5 1 0.6,0.5\n\
exposure.exposure = 1\n";
let parsed = Sidecar::parse(text).expect("parse");
let strokes = parsed.versions["default"].masks.layers()[0].strokes();
assert_eq!(strokes.len(), 2, "the two readable strokes survived");
assert_eq!(strokes[0].points, vec![(0.2, 0.5)]);
assert_eq!(
strokes[1].points,
vec![(0.6, 0.5)],
"a point without its comma is refused rather than read as one number"
);
}
/// A brush layer with nothing painted on it is still work — the user made the
/// layer and set its adjustment — and must not disappear because it happens to
/// render nothing yet.
#[test]
fn an_unpainted_brush_layer_still_persists() {
let mut graph = EditGraph::default_chain();
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.set_param("exposure", ParamId("exposure"), 1.0);
graph.masks_mut().push(layer);
let restored = round_trip(&graph);
assert_eq!(restored.masks().len(), 1);
assert_eq!(restored.masks().layers()[0].source, MaskSource::brush());
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sync merge (FR-NC-9) // Sync merge (FR-NC-9)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -421,13 +568,23 @@ fn show_a_sidecar() {
"m2", "m2",
MaskSource::Linear { MaskSource::Linear {
centre: (0.5, 0.25), centre: (0.5, 0.25),
angle: 1.5708, angle: std::f32::consts::FRAC_PI_2,
width: 0.4, width: 0.4,
}, },
); );
grad.set_param("exposure", ParamId("exposure"), -0.6); grad.set_param("exposure", ParamId("exposure"), -0.6);
graph.masks_mut().push(grad); graph.masks_mut().push(grad);
let mut brush = brushed(
"m3",
&[
(false, 0.06, vec![(0.31, 0.44), (0.35, 0.46), (0.4, 0.52)]),
(true, 0.03, vec![(0.36, 0.47)]),
],
);
brush.name = "dodge".into();
graph.masks_mut().push(brush);
let mut sidecar = Sidecar::new(); let mut sidecar = Sidecar::new();
let mut v = Version::from_graph("default", "Default", &graph); let mut v = Version::from_graph("default", "Default", &graph);
v.is_default = true; v.is_default = true;