diff --git a/core/dr-gpu/src/mask.rs b/core/dr-gpu/src/mask.rs index c65f857..4ff3718 100644 --- a/core/dr-gpu/src/mask.rs +++ b/core/dr-gpu/src/mask.rs @@ -9,8 +9,17 @@ //! Rasterising is **not** on the slider path. Dragging exposure on a masked //! layer changes uniforms only; the mask array is reused untouched. This pass //! runs when a mask's *shape* changes — a different selection, a moved -//! gradient, a resized output — which is what keeps a local adjustment as -//! responsive as a global one. +//! gradient, a new stroke, a resized output — which is what keeps a local +//! 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 //! @@ -21,7 +30,7 @@ //! `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. -use dr_pipeline::mask::{MaskSource, MaskStack, MAX_LAYERS}; +use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS}; use wgpu::util::DeviceExt; use crate::{GpuContext, GpuError}; @@ -31,6 +40,12 @@ const MODE_REGIONS: u32 = 0; const MODE_LINEAR: u32 = 1; const MODE_RADIAL: u32 = 2; 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)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] @@ -54,6 +69,105 @@ struct MaskParams { _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, + 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, +} + +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. /// /// Uploaded once per image. Holds the compacted label field and nothing else — @@ -229,6 +343,18 @@ pub struct MaskPass { ctx: GpuContext, layout: wgpu::BindGroupLayout, 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, /// How many times the array texture has been (re)allocated. /// @@ -314,6 +440,76 @@ impl MaskPass { 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()) { return Err(GpuError::ShaderCompilation(err.to_string())); } @@ -327,6 +523,9 @@ impl MaskPass { ctx: ctx.clone(), layout, pipeline, + brush_layout, + brush_add, + brush_erase, array: None, allocations: 0, placeholder, @@ -394,8 +593,15 @@ impl MaskPass { }; let params = self.params(layer, field, width, height); - let selected = self.selection_buffer(layer, field); - self.draw(&mut encoder, slot as u32, ¶ms, field, &selected, subject); + match &layer.source { + MaskSource::Brush { strokes } => { + self.draw_brush(&mut encoder, slot as u32, ¶ms, strokes, width, height) + } + _ => { + let selected = self.selection_buffer(layer, field); + self.draw(&mut encoder, slot as u32, ¶ms, field, &selected, subject); + } + } } self.ctx.queue.submit([encoder.finish()]); @@ -488,6 +694,145 @@ impl MaskPass { angle: *angle, ..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 { wgpu::BindGroupLayoutEntry { binding, @@ -796,6 +1159,72 @@ mod tests { 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] fn an_empty_stack_still_yields_a_bindable_array() { let Some(ctx) = ctx() else { diff --git a/core/dr-gpu/src/shaders/mask.wgsl b/core/dr-gpu/src/shaders/mask.wgsl index a108b4d..d45df58 100644 --- a/core/dr-gpu/src/shaders/mask.wgsl +++ b/core/dr-gpu/src/shaders/mask.wgsl @@ -28,7 +28,11 @@ struct MaskParams { label_width: 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, // How many regions the label field holds, so an out-of-range label is // caught rather than read past the end of `selected`. @@ -220,3 +224,150 @@ fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { return vec4(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, + hi: vec2, + // 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 strokes: array; +@group(0) @binding(5) var stroke_points: array>; + +struct BrushVertex { + @builtin(position) pos: vec4, + // 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, 6>( + vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), + vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(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(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) -> vec2 { + let dims = vec2(f32(p.width), f32(p.height)); + return uv * dims / min(dims.x, dims.y); +} + +fn segment_distance(q: vec2, a: vec2, b: vec2) -> 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 { + let s = strokes[in.stroke]; + let q = to_square(vec2(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(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0); +} diff --git a/core/dr-gpu/tests/local_adjustments.rs b/core/dr-gpu/tests/local_adjustments.rs index 8a930c8..b577fb3 100644 --- a/core/dr-gpu/tests/local_adjustments.rs +++ b/core/dr-gpu/tests/local_adjustments.rs @@ -25,8 +25,12 @@ fn ctx() -> Option { /// A flat mid-grey JPEG-path image, so any change is the adjustment's. fn grey(ctx: &GpuContext) -> DemosaicedImage { - let data: Vec = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect(); - DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") + grey_at(ctx, SIZE, SIZE) +} + +fn grey_at(ctx: &GpuContext, w: u32, h: u32) -> DemosaicedImage { + let data: Vec = (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. @@ -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. fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec { - 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 { + let source = grey_at(ctx, w, h); let shader = compose_full( &ops::chain(), &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 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); adjust - .render_masked(&source, &shader, SIZE, SIZE, Some(array)) + .render_masked(&source, &shader, w, h, Some(array)) .expect("render"); 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}"); } +// --------------------------------------------------------------------------- +// 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 — /// otherwise merely *having* the feature would alter every unedited image. #[test] diff --git a/core/dr-pipeline/src/mask.rs b/core/dr-pipeline/src/mask.rs index 3e44ac3..971a880 100644 --- a/core/dr-pipeline/src/mask.rs +++ b/core/dr-pipeline/src/mask.rs @@ -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 }, } 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 { @@ -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 { + 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(); diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 2f21750..3b28fd8 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -67,7 +67,9 @@ use std::fmt; use std::fmt::Write as _; 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}; /// 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, "feather = {}", format_value(*feather)); } + MaskSource::Brush { strokes } => write_strokes(out, strokes), } 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 { + 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::().ok()?, y.parse::().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. /// /// Separate from [`MaskLayer`] because the source cannot be built until every @@ -794,6 +870,7 @@ struct PartialMask { falloff: Falloff, morphology: Morphology, morph_radius: f32, + strokes: Vec, params: Vec<(String, String, f32)>, } @@ -822,6 +899,7 @@ impl PartialMask { falloff: Falloff::default(), morphology: Morphology::default(), morph_radius: 0.0, + strokes: Vec::new(), params: Vec::new(), } } @@ -852,6 +930,9 @@ impl PartialMask { "angle" => self.angle = value.parse().unwrap_or(0.0), "width" => self.width = 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", "opacity" => self.opacity = value.parse::().unwrap_or(1.0).clamp(0.0, 1.0), "enabled" => self.enabled = value != "0", @@ -918,6 +999,9 @@ impl PartialMask { angle: self.angle, feather: self.feather, }, + "brush" => MaskSource::Brush { + strokes: self.strokes, + }, other => { log::warn!( "sidecar: unknown mask source '{other}'; skipping layer {}", diff --git a/core/dr-pipeline/tests/mask_sidecar.rs b/core/dr-pipeline/tests/mask_sidecar.rs index fcc02cd..5b727ee 100644 --- a/core/dr-pipeline/tests/mask_sidecar.rs +++ b/core/dr-pipeline/tests/mask_sidecar.rs @@ -276,6 +276,153 @@ fn unknown_top_level_keys_still_round_trip_alongside_masks() { 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) // --------------------------------------------------------------------------- @@ -421,13 +568,23 @@ fn show_a_sidecar() { "m2", MaskSource::Linear { centre: (0.5, 0.25), - angle: 1.5708, + angle: std::f32::consts::FRAC_PI_2, width: 0.4, }, ); grad.set_param("exposure", ParamId("exposure"), -0.6); 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 v = Version::from_graph("default", "Default", &graph); v.is_default = true;