Files
DarkRoom/docs/dev/mask-editing.md
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

799 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Editing a mask
**Status:** Draft · 2026-09-06
**Companion to:** [requirements.md](requirements.md) §3.3 FR-DEV-3, FR-DEV-10 ·
[segmentation.md](segmentation.md) · [architecture.md](architecture.md) §5.4 ·
[spot-removal.md](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`](../../core/dr-pipeline/src/mask.rs), [`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`](../../ui/dr-ui/ui/toolrail.slint#L138) 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`](../../ui/dr-ui/src/segmentation.rs) 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.
```rust
/// 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
```rust
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
```rust
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`](../../core/dr-pipeline/src/sidecar.rs)
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`](../../core/dr-gpu/src/mask.rs). 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`](../../core/dr-gpu/tests/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`](../../core/dr-gpu/src/mask.rs) 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](../../core/dr-pipeline/src/mask.rs) say this whole
design exists to avoid.
### 5.5 Distance fields become per part
[`SubjectMasks`](../../core/dr-gpu/src/mask.rs) uploads one signed distance field
**per active layer, in stack order**, and
[`DevelopSession`](../../ui/dr-ui/src/develop.rs) 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`](../../ui/dr-ui/src/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](../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`](../../ui/dr-ui/src/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](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](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](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?