Files
DarkRoom/docs/dev/mask-editing.md
T
dtourolle 37a6d99dc4 Replace the per-body base curve with a scene-referred view transform
The base curve was a five-point spline on the unit square, flat past its
last point: every value above 1.0 left it as the same number, per
channel. Exposure and highlight recovery put values up there, and the
curve threw them away, then handed the result on as though it were
still scene-linear. The six per-body curves were also, by their own
file's account, hand-tuned shapes rather than measurements, and not
enough is known about where they came from to keep them (D19).

In their place, one view transform for every body (FR-DEV-3j): a
log-logistic sigmoid per channel, with the middle channel put back
between the other two so a hue survives the shoulder. Its two free
constants are solved from two conditions rather than set: scene grey
0.13, where the retired default curve put it, lands on display 0.18,
and the scene white four stops above grey lands on 1.0. So a highlight
a stop past sensor saturation still rolls into white, and the midtones
stay within 0.26 EV of the retired default between scene 0.03 and 1.0.
`dr_pipeline::view` holds the CPU reference and the WGSL, and the tests
there are FR-DEV-3j's acceptance criteria.

It is still fixed and still in the fused pass's tail, so a detail stage
still sees rendered values; the next commits make it an operation and
move it after the detail stage. It is skipped for a JPEG, as the base
curve was, and absent from the camera-space tap.

The base curve's database, its lookup and its twelve uniform slots go.
`RawImage` and `DemosaicedImage` lose the field, and the GPU test that
proved a curve reached the shader is replaced by one that renders the
view transform against the CPU reference and shows two highlights above
1.0 still render apart. The JPEG-and-sensor test now asserts the two
differ by exactly the view transform, where before an identity fixture
curve had made them match.
2026-09-27 16:52:53 -04:00

39 KiB
Raw Blame History

Editing a mask

Status: Draft · 2026-09-06 Companion to: requirements.md §3.3 FR-DEV-3, FR-DEV-10 · segmentation.md · architecture.md §5.4 · spot-removal.md (the pattern a canvas tool follows here)

The model finds a subject in a second and the photographer cannot then change it by a single pixel. This document specifies the tools that close that gap — paint, erase, push, and combining one selection with another — and the interface they are driven through.


1. The gap, precisely

The core is further along than the interface, and it is worth being exact about which half is missing, because it changes the size of the work.

Painting exists everywhere except where a finger is. MaskSource::Brush, [Stroke], the simplification and the point budgets, the sidecar's stroke = … line and its parser, the GPU's per-stroke bounding-box draw with add and erase blend states — all of it is written, tested, and reachable from no control in the application. toolrail.slint:138 says so in as many words: "what is missing is the canvas interaction".

A mask has exactly one source. A layer is one MaskSource and a shaping of its edge. There is no way to say this subject and that one, the sky except the branches, or the subject only where it is bright — the last being an intersection of a model mask with a range mask (FR-DEV-10), two features that already exist and cannot meet.

The edge moves all at once or not at all. Morphology grows or shrinks the whole boundary. When the model's coverage stops two pixels inside the shoulder and leaks four pixels into the hair, no global number fixes both, and that is the ordinary case rather than a corner one.

And you cannot see the mask. (Built — see §6.) The overlay on the canvas was overlay_rgba and nothing else — a CPU-built false-colour picture of what the model detected, at proxy resolution. It is not the layer's alpha: it knows nothing of the layer's feather, its falloff, its morphology, its invert, or its opacity. Nobody can refine an edge they are not being shown.

That one turned out to be load-bearing for the other three rather than the last of four. With no way to see a mask, choosing a category produced a layer whose extent was invisible and whose adjustment had not been touched yet — so the correct behaviour and the broken one look identical, and "the segmentation does not make masks" is what it reads as from the outside.

Those four are one feature, and this is its specification.

2. Non-goals

  • Not pixel layers. §1.3 of the requirements excludes them. Everything below stores geometry and parameters; no rasterised mask is ever written to a file, and no mask ever exists in CPU memory (ARCH §5.4).
  • Not a second segmentation UI. Choosing which subject or category the model offers stays exactly as it is. This is about what happens after.
  • Not per-layer neighbourhood adjustments. layer_chain() excludes detail and optics operations for reasons that have not changed.
  • Not automatic refinement. No "improve this mask" button that silently redraws a boundary the photographer approved. The tools here move an edge only where a hand is.
  • Not a mask library. Saving a mask and applying it to another photograph is a reasonable later feature and depends on none of this.

3. The five tools

Named as a photographer would say them, because these are the words the interface will use.

Tool What it does New machinery
Paint Adds to the mask under the brush none — brush_add exists
Erase Takes away under the brush none — brush_erase exists
Push Drags the boundary itself: outward fills behind it, inward empties behind it a warp pass
Combine Joins another selection to this mask — add, subtract, or keep only the overlap three blend states
Show Draws the mask that actually results, live two uniforms in the composed shader

Push is the one worth defining carefully, because "tug the edge" can mean two different operations and only one of them is right here — §5.3.

4. The model: a mask is a stack of parts

4.1 A part

The change that carries all four gaps at once is that a layer stops holding a source and starts holding an ordered list of them.

/// One selection joined into a layer's mask.
pub struct MaskPart {
    /// Stable identity, for the sidecar and for merge (FR-NC-9).
    pub id: String,
    /// How this part enters the mask built so far. Ignored on the first part,
    /// which *is* the mask so far.
    pub join: Join,
    pub source: MaskSource,
    /// Shaping, moved down from the layer: two parts of one mask routinely
    /// want different edges — a model's soft coverage joined to a hand-painted
    /// correction that must be exactly where it was painted.
    pub invert: bool,
    pub feather: f32,
    pub falloff: Falloff,
    pub morphology: Morphology,
    pub morph_radius: f32,
    pub refine: f32,
    /// The model raster behind a Subject or Category source. Per part now,
    /// for the same reason it was per layer: it materialises *this* selection.
    pub coverage: Option<Arc<Coverage>>,
}

/// How a part joins the mask before it.
pub enum Join {
    /// Everything either has. The default, and what "add a brush" means.
    Union,
    /// What the mask had, minus this. "Subtract".
    Subtract,
    /// Only where both agree. Where a subject meets a luminance band.
    Intersect,
    /// Not a set operation: this part's strokes *move* the mask under it.
    /// Only ever a painted part carrying push strokes — see §5.3.
    Warp,
}

A layer's mask is then parts.fold(empty, join), and every one of §1's gaps becomes an ordinary use of it:

  • Paint on an auto mask — append a Union part whose source is painted.
  • Erase from one — strokes inside that part carry Erase, or the part itself is a Subtract. Both work; the panel offers the first, because a photographer alternating add and erase over one area is drawing one correction, not two.
  • Merge two selections — two Union parts.
  • Cut one out of another — a Subtract part.
  • The bright part of the sky — a Category part, then an Intersect luminance part.
  • Tug an edge — a Warp part.

Why a list on the layer rather than a boolean tree. A tree expresses more and no photographer has ever wanted the extra. A flat ordered fold is what Lightroom, Capture One and darktable all present, it reads top to bottom in a panel with no parentheses to draw, and — decisively here — it merges under FR-NC-9 as a sequence of independently-keyed blocks, where a tree would merge as a shape whose two halves can be individually won by different devices and recombined into something neither ever had.

4.2 A stroke gains a mode

pub enum StrokeMode { Add, Erase, Push }

pub struct Stroke {
    pub mode: StrokeMode,          // was: erase: bool
    pub radius: f32,
    pub hardness: f32,
    pub flow: f32,
    /// How strongly the deposit clings to the picture's own edges: 0 paints
    /// anywhere, 1 paints only what matches the colour under the point the
    /// stroke began. See §5.6.
    pub cling: f32,
    pub points: Vec<(f32, f32)>,
}

erase: bool becomes a three-valued mode. Everything else about Stroke — the grid snapping, simplify, MIN_STEP_FRACTION, MAX_STROKE_POINTS and its continuation rule — is unchanged and applies to a push stroke exactly as it does to a painted one.

4.3 What the layer keeps

pub struct MaskLayer {
    pub id: String,
    pub name: String,
    pub enabled: bool,
    /// Invert and opacity stay here: they are the two uniforms the generated
    /// shader already reads per layer (LAYER_UNIFORM_FIELDS), they apply to
    /// the finished mask, and moving them would change the composed shader.
    pub invert: bool,
    pub opacity: f32,
    /// Never empty. `parts[0]` is the base.
    pub parts: Vec<MaskPart>,
    pub ops: Vec<Box<dyn Operation>>,
}

MaskSource::Brush { strokes } becomes MaskSource::Painted { strokes } — the same variant under a name that no longer implies it is the only thing a brush can touch. MaskLayer::begin_stroke / extend_stroke / end_stroke keep their signatures and route to the layer's active part, which the panel sets; that is the only change their callers see.

Budgets. MAX_LAYER_POINTS (4096) becomes a budget across all of a layer's parts rather than one part's, so a layer's worst-case sidecar size and worst-case rasterisation cost are unchanged. MAX_PARTS = 8 per layer, on the argument MAX_LAYERS makes: past that it is not a selection any more, and a bound the panel can show is better than one a file discovers.

4.4 The sidecar, and why no existing file changes

Blocks are [mask <version> <layer-id>] today. Parts are their own blocks:

[mask default m1]
name = Sky
source = category
signature = 4711
category = sky
feather = 0.004
falloff = smooth
opacity = 1

[part default m1 p2]
join = subtract
source = painted
stroke = add 0.05 0.5 1 0.6 0.31,0.42 0.33,0.44 …

Three compatibility rules, and together they mean every sidecar written by every build so far loads into this one unchanged, and a layer this build writes with one part is byte-identical to what it writes today:

  1. A [mask …] block with no [part …] blocks after it is one part. Its source keys and its shaping keys build parts[0]; nothing is missing and nothing needs a default invented for it.
  2. A layer with exactly one part writes the old shape — source and shaping inside [mask …], no part block. The format grows only when the feature is used.
  3. stroke = … lines keep their position and their meaning. The mode token gains push, and cling is a fourth number written only when non-zero. An older build meeting either drops that stroke and only that stroke, which is the rule parse_stroke already documents and already implements.

Merge (FR-NC-9). A part is a block with an id, so two devices that added different parts to the same layer merge to a layer with both, and the same part edited on both is a conflict over that part rather than over the mask. Part order is the one thing that is not per-field: it is stored as the block order and resolved the way the layer order already is.

5. On the device

5.1 The order a layer rasterises in

Per active layer, into that layer's slice of the existing mask array:

  1. Part 0 draws as it does today — one full-screen pass through the switch in mask.wgsl, or per-stroke bounding boxes for a painted one.
  2. Each further part draws the same way, with the blend state its join names (§5.2). No intermediate texture: the accumulator is the slice.
  3. A Warp part copies the slice aside and redraws it displaced (§5.3).
  4. invert and opacity are unchanged — two uniforms, read in the composed shader, applied to the finished mask.

MAX_LAYERS and the array's memory are untouched: parts collapse into one slice, so eight layers still cost eight channels.

5.2 Combining costs three blend states and no new texture

The set operations are already expressible in fixed-function blending over r8unorm, which is why this is the cheap half of the feature:

Join src_factor, dst_factor, op Result Built
Union One, One, Max max(dst, src) M1
Subtract Zero, OneMinusSrc, Add dst · (1 − src) M1
Intersect Zero, Src, Add dst · src M2 (built 2026-09-24)

The middle row is brush_erase, already constructed in MaskPass::new. The other two are the same three vertices with a different BlendState, and nothing is read back.

One correction to the first draft of this section, found in the building. It claimed no second texture was needed, because a part could be blended straight onto the layer's slice. That is wrong, and the reason is the erase stroke: an erase inside a part means a hole in that part, not a hole in the mask. Drawn straight onto the accumulator it takes away whatever the parts before it had put there — so tidying the edge of a correction punches through the subject underneath, and the failure reads as the model's mask having holes in it.

So a part is drawn into one scratch texture (proxy-sized, r8unorm, allocated the first time any layer has more than one part) and blended from there. A layer of one part still takes the old path exactly — straight into its slice, no scratch, no combine pass — which is what keeps every existing mask rendering as it did. an_erase_stroke_holes_its_own_part_and_not_the_mask in local_adjustments.rs is the test that holds this in place.

Intersection, as built, is exactly the table's row: a third pipeline beside the other two in MaskPass::new, Join::apply stating the three on the CPU, and a_part_unioned_subtracted_and_intersected_gives_the_three_fields and the_joins_match_their_definition in local_adjustments.rs holding the GPU to it. It is a product, not a minimum: the two agree wherever either side is fully in or out, and between two soft edges the product is the softer reading, which is the right way for an overlap of two partial selections to be wrong. Its blend is Add, not Max, so it adds nothing to the Android question below.

Max blending on r8unorm is core WGPU and universally supported on the desktop backends; verify it on the Android adapter before M2 lands, since that is the platform where a blend mode is most likely to be quietly emulated or absent. If it is missing, union is One, OneMinusSrc, Add — a screen blend — which differs from max only where both parts are partially covered, and never by more than the softness of their two edges.

5.3 Push is a warp, not a local morphology

Two mechanisms fit "drag the edge", and the choice matters.

Local morphology — offset the distance threshold inside the brush — is exact, but it exists only where there is a signed distance field, which is Subject and Category and nothing else. A push tool that works on a model mask and does nothing on a gradient, a range or a painted part is a tool the photographer cannot trust.

A warp works on any mask, because it never asks what the mask is made of. Each dab of a push stroke contributes a displacement, and the mask is resampled through the sum of them:

D(p)      = Σ over dabs  (b − a) · w(|p − seg(a,b)| / radius)
mask'(p)  = mask(p − D(p))

Drag from inside the mask outward and the sample point moves back into the interior, so the boundary follows the finger and the area behind it fills in. Drag from outside inward and the vacated area samples from outside, so the area behind it empties. That is exactly the pair of behaviours asked for, from one gesture, with the direction supplied by the drag rather than by a mode switch.

Three honest costs:

  • It needs the mask it is sampling, and a pass cannot sample the target it writes: a warp part costs one texture copy of the slice, plus one draw over the strokes' bounding box. One scratch texture, proxy-sized, r8unorm, allocated on first use and reused by every layer, since layers rasterise in sequence.
  • The displacement is a sum, not a sequential resample. Two crossing push strokes therefore compose approximately rather than exactly. Bound |D| at one brush radius per stroke: past that a warp tears rather than drags, and no photographer means the difference.
  • A warp moves what is under it, including detail the photographer painted by hand. That is what it is for, and it is why push is a part in the list — it can be removed later without disturbing the parts beneath it.

5.4 Painting has to be incremental

Today MaskPass::render clears each slice and redraws every stroke of the layer. That is right when a shape changes and wrong while a finger is down: at 120 reports a second, a layer holding 4096 points redraws all of them per dab, and the cost of a stroke grows as it is painted — the failure mode MAX_STROKE_POINTS already names for one stroke, here across the layer.

While a stroke is live, only the new segment is drawn. The slice already holds everything up to the previous dab, the blend states are the same ones that would have been used in a full redraw, and the segments are drawn once each in the same order — so the incremental result is identical to the rebuilt one rather than an approximation of it. MaskPass keeps, per layer, the (part count, stroke count, point count) it last drew; anything else changing falls back to the full rebuild it does now.

This is required, not an optimisation to schedule later: it is what decides whether painting is usable on the phone, and it is the specific failure mask.rs's module docs say this whole design exists to avoid.

5.5 Distance fields become per part

SubjectMasks uploads one signed distance field per active layer, in stack order, and DevelopSession builds them on the same indexing. With parts, a field belongs to the part that shaped it: the upload becomes one field per model-backed part, flattened in (layer, part) order, and the rasteriser indexes it by a running counter rather than by slot.

This is the most invasive change in the document — it touches the field builder, the upload, the key that decides when to rebuild, and the shader's subject binding index — and it is mechanical. It is also the reason M2 is its own milestone rather than a rider on M1.

5.6 Cling: paint that stops at the picture's edge

A brush that respects the photograph's own boundaries is the difference between refining a mask and colouring it in, and the ingredients are already bound to this pass: the demosaiced source (for range masks) and, when there is one, the compacted label field.

At each dab, cling > 0 multiplies the deposit by agreement with the pixel under the stroke's first point: colour distance in the same linear-sRGB space colour_mask already works in, falling off over a tolerance set by cling. Where a label field exists, agreement is 1 inside the same region and falls to the colour test outside it, so the brush stops dead at a watershed boundary and softly at a colour one.

Roughly fifteen lines of WGSL reusing image_value and hue_of, one number in the sidecar, and it is the single control that makes a 5%-radius brush usable along hair.

6. Seeing the mask

Status: built. MaskStack::rendered, Reveal as a list of (layer, colour), RevealStyle, EditGraph::compose_revealing, MaskPass::render_revealing; on the panel, an eye and a colour per row and one "Show masks as" strip above the stack.

The mask array is already bound to the composed adjust shader, so this is almost free, and it is the first thing to build because every other tool here is unusable without it.

6.1 One correction to this section, found in the building

The draft said "two uniforms — which layer to reveal (−1 for none) and which style — always emitted and guarded by the uniform, so switching the overlay on is a uniform write rather than a shader recompile". The second half of that is not available, and the reason is the case the feature exists for.

A uniform can select a slot. It cannot conjure one. A layer with no adjustment on it changes no pixel, so it is not is_active, so it occupies no slice of the mask array and the rasteriser never draws it — and that is precisely the layer a photographer wants to look at, for the whole of the time between choosing a subject and deciding what to do to it. Revealing it means rendering it, which changes the sequence of layers, which changes the uniform block. The composition moves either way.

So the slot and the style are written into the source, and turning the reveal on, off, or onto another layer recompiles the fused shader. That is a button press rather than a frame, and the uniform would only have added a branch per pixel on top of a recomposition that was happening anyway.

MaskStack::rendered(reveal) is the one sequence this rests on: active() plus the layer being looked at. The rasteriser, the composer and the distance field builder all index by position in it, so all three must be given the same reveal — two of them disagreeing shows as an adjustment applied through another layer's mask, which is why they take it as an argument rather than reading a flag.

6.2 Where the block runs, and why not with the others

After the output transform, immediately before the clip and the encode — not among the layer blocks. Everything there runs on scene-referred colour in the working space, where a flat tint would be pushed through the view transform (the base curve and the camera matrix, before D19) and arrive as some other colour, and an alpha's white on black would arrive as neither.

6.3 Not on the graph

The reveal is an argument to EditGraph::compose_revealing, and compose_for — which the exporter, the thumbnail and the neutral probe all call — has no way to ask for one. A flag on the graph would have been fewer parameters, would have type-checked, and would have been one forgotten reset away from a red tint baked into an exported file.

Three styles, all read from the same alpha:

  • Tint — the mask over the picture in its colour at ~50%. The default, and what every editor's photographers already expect. The colour is the mask's own, chosen from the swatches on its row — which is what answers a red tint over a red dress.
  • Alpha — the mask alone, white on black. For judging an edge, where a tint over a busy picture cannot be read.
  • Edge — the boundary outlined over the untouched picture. For checking registration against detail the other two hide, and the same reasoning the region overlay's white outline already carries.

Per mask, not per selection. The first build of this showed the selected layer's mask in one global style, and it answered the wrong question. What a photographer asks of two masks is how they meet — where the sky's edge sits against the building's — and that needs both on screen at once, in colours that can be told apart. So each row of the stack has an eye, and each mask a colour from a six-entry palette (MASK_COLOURS in develop.rs); the eye is drawn in that colour so the row says which shape on the picture is its. The style is the one thing that stays global, because a tint beside an outline beside an alpha would be three pictures that cannot be read against each other. Alpha therefore draws every shown mask, each in its colour, on black.

When it appears. A new layer arrives with its eye open, in the first colour nothing else is using — making a mask is asking what it selected, and for a subject or a category that question has no other answer on screen. Arming Paint or Erase opens the selected layer's eye if it was closed, on the same argument on_part_added makes: a stroke into an invisible mask is indistinguishable from a tool that did nothing. Pressing a swatch opens the eye too, since colouring a mask nobody can see would change no pixel.

Only ever this layer's eye, and only on an explicit action. Every other eye keeps whatever it was set to, and nothing re-arms in the background. The existing overlay-hidden property is the precedent and the trap it documents applies unchanged: an automatic reveal that re-arms a switch somebody turned off is worse than no automatic reveal at all.

Viewing state and not edit state: eyes and colours are on the session, not on the layer, and a photograph reopened has every eye closed.

The ~1s reveal after a shaping slider is released, from the draft, is not built. It is a timer rather than a decision.

The region overlay stays exactly what it is — a picture of what the model detected — and gains a name in the interface that says so, because two overlays that look alike and mean different things is worse than either.

7. The interface

7.1 Where the tools live

In the Local panel, not the tool rail. The rail's entries arm a canvas gesture for the whole photograph; a brush is meaningless without a layer to paint into, and a rail entry that silently created one — or that lit up and did nothing with no layer selected — is precisely the kind of surprise the rail's own notes argue against. The tool strip sits in the Local panel's header, enabled only when a part is selected:

┌ Local ─────────────────────────────┐
│ [Select] [Paint] [Erase] [Push]  ◉ │   ◉ = show mask
├────────────────────────────────────┤
│ ▸ Sky                    ● ⌄       │
│     Category · sky                 │
│     ├ Subtract · Painted        ✕  │
│     └ Intersect · Luminance     ✕  │
│   [+ Add ⌄] [− Subtract ⌄] [∩ ⌄]   │
├────────────────────────────────────┤
│ Brush   size ──●───  0.05          │
│         hardness ──●──  0.5        │
│         flow ─────●  1.0           │
│         cling ──●───  0.4          │
└────────────────────────────────────┘

toolrail.slint's note about MaskSource::Brush being the next rail entry is superseded by this and should be replaced with the reasoning, not deleted — the file is where somebody will next look for it.

7.2 The part list

Each layer row gains its parts as indented rows. A part row carries its join (a chip that cycles add / subtract / intersect), its source name, a delete, and selection — selecting a part is what points the brush and the shaping controls at it. The layer row keeps invert, opacity and enable, which are the layer's.

[+ Add], [− Subtract] and [∩] each open the same menu of sources the "new layer" buttons already offer: a gradient, a range, a subject, a category, or painted. One code path, three joins.

Folding two layers into one uses the multi-selection masks_ui.rs already supports: with two layers selected, "Combine" appends the second's parts to the first and removes it. Offered only when the second layer's adjustments are neutral, and otherwise offered with a warning that names what will be lost — quietly discarding an edit the user made is not a combine.

7.3 The brush and the cursor

Radius, hardness, flow and cling are sliders in the panel and all four are live on the canvas as a cursor: an outer ring at the radius, an inner ring at the hardness, and — this matters on a phone — the ring drawn at the touch point offset above the finger, since the thing being painted is under the hand that is painting it.

Radius is stored per tool, not per stroke and not per layer: a photographer who sets a small eraser expects it to still be small the next time they erase.

7.4 Gestures

Each of these needs a GESTURE: block beside its implementation — that is the only place gestures.md can be written from.

Gesture Touch Pointer Keyboard
Paint into the selected part Drag on the picture Drag —
Erase instead of paint Hold the Erase tool Alt-drag —
Push the boundary Drag with Push armed Drag —
Change the brush size Drag the size slider Scroll with Alt over the picture [ ]
See the mask Press the eye Press the eye \ while held
Undo one stroke The history list Ctrl+Z Ctrl+Z
Add a part [+ Add], pick a source Same —
Fold two layers Select both, Combine Same —

The why each block needs is mostly one sentence — a stroke is a decision and a decision is one undo step — except for Alt-drag, which needs to say that a modifier is the only way to alternate paint and erase without leaving the stroke, and that touch cannot have it, which is why the tool strip is a strip and not a single toggle.

7.5 The Slint hazards this walks into

Named because all of them compile:

  • The paint TouchArea must be declared in front of ScaleRotateGestureHandler to receive the press at all, exactly as the region picker and the repair placer are. The consequence is that a two-finger pinch may not reach the handler behind it while paint is armed. Repair has the same arrangement today, so check what repair mode actually does with a pinch before designing around it; if pinch is lost, the answer is geometry — arm the paint area over the picture only — not z-order.
  • A drag must be measured in the parent frame, never in the coordinates of something the drag moves. GradientHandles in masks.slint is the reference.
  • The part rows are a repeater inside the develop column, so their chips go through ChipGrid or Segmented { columns: 3 } — a non-wrapping chip row sets the width of the whole sidebar.
  • None of the above is caught by the test suite. A screenshot is the only check; see the project's notes on capturing one under XWayland.

8. History, undo, labels

A stroke is one step, recorded on release: Edit::Action, not Edit::Control. Control is the right variant for a dragged control and the wrong one here precisely because it coalesces — two strokes painted a second apart are two decisions, and coalescing them under one key would make the second untakeable back. A push stroke follows the same rule, as does adding, removing or re-joining a part.

The mask stack is already snapshotted per step and shared by Arc when a step does not touch it, so the cost of an undoable stroke is a clone of one layer's parts, not of the picture. New keys in labels.rs:

history.mask_painted      "Paint Mask"
history.mask_erased       "Erase Mask"
history.mask_pushed       "Push Mask Edge"
history.mask_part_added   "Add To Mask"
history.mask_part_removed "Remove From Mask"
history.mask_joined       "Change How Mask Joins"
history.masks_combined    "Combine Masks"

9. Sync and merge

Nothing here stores pixels, so nothing here changes what sync carries beyond size. A painted correction is bounded by MAX_LAYER_POINTS at roughly 50 kB of text in the worst case and a few hundred bytes in the ordinary one. A model part still carries its run-length coded coverage line, unchanged and still outside PartialEq.

Two conflicts are new, and both resolve per block: two devices adding different parts to one layer (both are kept, in block order), and two devices painting the same part (a conflict over that part, arbitrated the way a layer already is). A device that has never run a model still renders every part correctly, because coverage travels with the part exactly as it travelled with the layer.

10. Performance and budgets

The number that matters is the cost of one dab while the finger is down, and with §5.4 it is bounded by the dab's own bounding box rather than by the stroke's history: (2r)² pixels × one segment. At the default 5% radius on a 1600×1067 proxy that is about 25 000 pixels — a fraction of a millisecond, and independent of how long the stroke has been going.

Everything else runs on a shape change, not per frame:

Work When Rough cost at 1600×1067
Full layer rebuild part added, source changed, resize one draw per part
Warp a push part exists and something below it changed one copy + one bbox draw
Distance field a model part's morphology changed as today, CPU, per part
Overlay never — it is two uniforms in a shader that already runs —

The scratch texture for warp is one proxy-sized r8unorm — under 2 MB — and is allocated on first use, so a photographer who never pushes an edge never pays for it.

Measure against frame-budget.md, and measure it on an idle machine: the 16 ms guard is load-sensitive enough that a busy build makes it fail and an idle one makes it pass, so a single run proves nothing either way.

11. Order of work

M1 — See it and paint it. The overlay (§6), parts on the layer with Union and Subtract, Painted parts, the tool strip, the brush HUD and cursor, the canvas gestures, incremental raster (§5.4). No new shader code for the mask pass beyond the blend variants; no change to distance fields, because a painted part needs none. This is the whole of the user-visible ask except push and intersect, and it is deliberately the milestone that stands alone.

Done: parts with Union and Subtract, painted parts, the tool strip, the canvas gesture, and the overlay (§6). Outstanding: the brush HUD and cursor — radius, hardness and flow are sliders with no ring drawn on the photograph, so the size of the brush is a number rather than a thing you can see — and the incremental raster of §5.4, without which a layer's whole stroke history is redrawn per dab.

One lesson from the order it was actually built in, since §6 said it and the build did not listen: the overlay is not the last quarter of M1, it is the first. Parts and painting shipped without it and the result was a feature nobody could tell was working — the panel listed a mask, the photograph showed nothing, and every report of it came back as "the masks do not work".

M2 — Combine properly. Intersect, model and gradient and range parts, per-part distance fields (§5.5), the part list with its join chips, folding two layers.

Done (2026-09-24): Intersect — the join, its blend state, the sidecar word intersect, the part row's chip cycling + / − / ∩, and an "∩ Intersect" button beside Add and Subtract (§7.1). The part list with its chips and a per-part eye was already there from M1. Outstanding: joining a model, gradient or range part from the panel (the buttons still join a painted part, so an intersection is painted where the mask should survive), per-part distance fields, and folding two layers.

M3 — Push, and cling. The warp pass (§5.3) and edge-aware deposit (§5.6). Both are refinements of a tool that already works, which is the right place for the two riskiest pieces.

M4 — What the use of it asks for. Left open on purpose. Likely candidates: a "select the subject under the pointer" brush, pressure from a stylus, and a per-part opacity.

12. Requirements to add

Proposed text for requirements.md §3.3, in the shape the neighbouring entries take:

FR-DEV-19 — Mask editing. A mask layer's coverage shall be editable by hand after it is created, by painting into it, erasing from it, dragging its boundary, and joining further selections to it. Every edit is stored as geometry and parameters in the edit graph; no rasterised mask is written to a file and none exists in CPU memory.

FR-DEV-19a — Mask composition. A layer's mask is an ordered list of parts, each naming a source and how it joins the mask before it — union, subtraction, or intersection. A layer of one part is exactly the layer of today, and reads and writes the same sidecar.

FR-DEV-19b — Hand correction. A part may be painted, with add, erase and push strokes, at a radius, hardness, flow and edge-clinging the photographer sets. Strokes are stored as normalised coordinates and rasterised on the device.

FR-DEV-19c — Boundary push. A push stroke displaces the mask beneath it along the drag, filling behind an outward drag and emptying behind an inward one, on any mask source rather than only those with a distance field.

FR-DEV-19d — Mask visualisation. The mask a layer actually produces — after its parts, its shaping, its inversion and its opacity — shall be displayable over the photograph as a tint, as an alpha, or as an outline, and shall appear automatically while a mask is being edited.

Each needs TRACES: comments at the implementation and a regenerated traceability.md in the same commit, since that file carries line numbers.

13. Verification

Testable without a GPU, and therefore not optional:

  • Stroke mode round-trips through the sidecar, including a push stroke and a non-zero cling; an unknown mode costs one stroke and not the layer.
  • A one-part layer writes the byte-identical sidecar it writes today, and every existing fixture in mask_sidecar.rs still loads.
  • Parts merge per block: different parts added on two devices produce a layer with both; the same part edited on both is one conflict.
  • Point budgets hold across parts, and painting past them refuses rather than dropping the oldest strokes.
  • The panel model builds part rows from a hand-made stack with no device present, the way rows_from does for adjustments.
  • History: a stroke is one step, undo restores the parts, and a step that leaves the masks alone still shares the Arc.

Needing a device, in dr-gpu/tests:

  • A truth table for the joins: a half-covering part unioned, subtracted and intersected with a known base gives the three expected fields.
  • A warp of a known step edge moves it by the drag, and the area behind it fills — asserted on a readback of the mask array, which is a test reading back, not the application.
  • Incremental drawing equals a full rebuild, dab for dab, on a stroke of a few hundred points. This is the test that keeps §5.4 honest.
  • Max blending behaves on every adapter CI runs on.

And a screenshot, because §7.5 is not reachable by any of the above. Run dr-ui tests single-threaded; parallel runs segfault in this crate for unrelated reasons.

14. Decisions I need

  1. Parts now, or strokes first? M1 as written introduces parts and pays the migration once. The cheaper alternative is a strokes: Vec<Stroke> field on the layer, brushed over whatever the source produced, and parts later — half the code, and it makes "subtract this from that" a second mechanism invented afterwards. I would take the migration now.
  2. Push as a warp, or local morphology on model masks only? §5.3 argues the warp. It is the more expensive of the two and the only one that works on every source.
  3. Tool strip in the Local panel, or an entry in the rail? toolrail.slint predicts a rail entry; §7.1 argues against it. The rail is easier and worse.
  4. Do the FRs in §12 go into requirements.md now, or stay proposed here until the first milestone lands?