A mask layer shall be able to hold more than one source, combined by intersection, union and subtraction, each source independently toggleable.
Why
Precision in modern masking comes from stacking cheap criteria, not from careful brushwork:
a sky is a category mask ∩ a colour range
skin is a subject or person mask ∩ a hue band
a lifted foreground is a radial gradient − the subject
Today MaskLayer carries exactly one MaskSource (mask.rs:506). invert exists; composing two sources into one selection does not. Layers stack, but each applies its own chain independently — they never combine into a single selection.
The consequence is that DarkRoom's four excellent selection mechanisms are each stranded on their own, and portraiture has no route through the panel at all.
Acceptance
MaskLayer holds an ordered list of (op, source) rather than one source.
A single-source layer serialises unchanged, so existing sidecars load with no migration.
Each source can be hidden without deleting it, and the panel shows the resulting coverage.
Feather, morphology and opacity apply to the composed result, not per source — they describe the boundary, and there is one boundary.
Merge behaviour under FR-NC-9 defined for a layer whose source list differs between devices.
Depends on
The range-mask ticket. Composition without ranges to compose is worth considerably less — build the ranges first, then do this once.
Lands in
core/dr-pipeline/src/mask.rs (the layer model and the WGSL emission), the sidecar round-trip, ui/dr-ui/src/masks_ui.rs.
Part of the Develop Ergonomics spec (FR-DEV series proposal), 2026-09-05.
A mask layer shall be able to hold **more than one source**, combined by intersection, union and subtraction, each source independently toggleable.
## Why
Precision in modern masking comes from stacking cheap criteria, not from careful brushwork:
- a sky is a category mask ∩ a colour range
- skin is a subject or person mask ∩ a hue band
- a lifted foreground is a radial gradient − the subject
Today `MaskLayer` carries exactly one `MaskSource` (`mask.rs:506`). `invert` exists; composing two sources into one selection does not. Layers stack, but each applies its own chain independently — they never combine into a single selection.
The consequence is that DarkRoom's four excellent selection mechanisms are each stranded on their own, and portraiture has no route through the panel at all.
## Acceptance
- [ ] `MaskLayer` holds an ordered list of `(op, source)` rather than one source.
- [ ] A single-source layer serialises unchanged, so **existing sidecars load with no migration**.
- [ ] Each source can be hidden without deleting it, and the panel shows the resulting coverage.
- [ ] Feather, morphology and opacity apply to the **composed** result, not per source — they describe the boundary, and there is one boundary.
- [ ] Merge behaviour under FR-NC-9 defined for a layer whose source list differs between devices.
## Depends on
The range-mask ticket. Composition without ranges to compose is worth considerably less — build the ranges first, then do this once.
## Lands in
`core/dr-pipeline/src/mask.rs` (the layer model and the WGSL emission), the sidecar round-trip, `ui/dr-ui/src/masks_ui.rs`.
---
Part of the Develop Ergonomics spec (FR-DEV series proposal), 2026-09-05.
dtourolle
changed title from FR-DEV-11 — Compose a mask from more than one source to FR-DEV-19a — Intersection as a third join2026-09-19 10:19:42 +00:00
Most of this landed 2026-09-06 in df741a8 as FR-DEV-19a: a layer is an ordered list of parts, each joined by Join::Union or Join::Subtract, each toggleable and with its own edge treatment. The spec took the number 19 for that work, so this issue's FR-DEV-11 does not exist there. What remains is the intersection this issue asked for — Join::ALL is [Union, Subtract] — and the title now says so.
Most of this landed 2026-09-06 in df741a8 as FR-DEV-19a: a layer is an ordered list of parts, each joined by `Join::Union` or `Join::Subtract`, each toggleable and with its own edge treatment. The spec took the number 19 for that work, so this issue's FR-DEV-11 does not exist there. What remains is the intersection this issue asked for — `Join::ALL` is `[Union, Subtract]` — and the title now says so.
Join::Intersect in core/dr-pipeline/src/mask.rs, defined as the product dst · src per mask-editing.md §5.2. Join::apply states all three joins once, and the GPU tests check against it.
GPU: a combine_intersect pipeline with blend Zero, Src, Add, per the plan's table.
Sidecar: join = intersect. Old sidecars load unchanged; an older build reads the word as a union.
Panel: an "∩ Intersect" button, and the chip on each part cycles + / − / ∩.
Tests: the join maths, name round-trip, an empty intersection covering nothing, a sidecar round-trip, a GPU truth table and soft-edge check (run on a real adapter), and a panel test. Also checked on screen.
Not covered, and worth a follow-up if they matter:
The panel's buttons still join only painted parts. You cannot yet intersect with a range, gradient or model part from the panel.
Feather and morphology are still per part, not applied once to the composed result.
At the panel's font size "∩" looks like an "n".
Merged to master: 8cdad38, 5f0b7ac, 8f3df7b, e8f68a9.
- `Join::Intersect` in `core/dr-pipeline/src/mask.rs`, defined as the product `dst · src` per mask-editing.md §5.2. `Join::apply` states all three joins once, and the GPU tests check against it.
- GPU: a `combine_intersect` pipeline with blend `Zero, Src, Add`, per the plan's table.
- Sidecar: `join = intersect`. Old sidecars load unchanged; an older build reads the word as a union.
- Panel: an "∩ Intersect" button, and the chip on each part cycles + / − / ∩.
- Tests: the join maths, name round-trip, an empty intersection covering nothing, a sidecar round-trip, a GPU truth table and soft-edge check (run on a real adapter), and a panel test. Also checked on screen.
Not covered, and worth a follow-up if they matter:
- The panel's buttons still join only painted parts. You cannot yet intersect with a range, gradient or model part from the panel.
- Feather and morphology are still per part, not applied once to the composed result.
- At the panel's font size "∩" looks like an "n".
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
A mask layer shall be able to hold more than one source, combined by intersection, union and subtraction, each source independently toggleable.
Why
Precision in modern masking comes from stacking cheap criteria, not from careful brushwork:
Today
MaskLayercarries exactly oneMaskSource(mask.rs:506).invertexists; composing two sources into one selection does not. Layers stack, but each applies its own chain independently — they never combine into a single selection.The consequence is that DarkRoom's four excellent selection mechanisms are each stranded on their own, and portraiture has no route through the panel at all.
Acceptance
MaskLayerholds an ordered list of(op, source)rather than one source.Depends on
The range-mask ticket. Composition without ranges to compose is worth considerably less — build the ranges first, then do this once.
Lands in
core/dr-pipeline/src/mask.rs(the layer model and the WGSL emission), the sidecar round-trip,ui/dr-ui/src/masks_ui.rs.Part of the Develop Ergonomics spec (FR-DEV series proposal), 2026-09-05.
Depends on #7. Sequence: ranges land, then this changes the layer model once.
Then #10 becomes worth doing.
FR-DEV-11 — Compose a mask from more than one sourceto FR-DEV-19a — Intersection as a third joinMost of this landed 2026-09-06 in
df741a8as FR-DEV-19a: a layer is an ordered list of parts, each joined byJoin::UnionorJoin::Subtract, each toggleable and with its own edge treatment. The spec took the number 19 for that work, so this issue's FR-DEV-11 does not exist there. What remains is the intersection this issue asked for —Join::ALLis[Union, Subtract]— and the title now says so.Merged to master:
8cdad38,5f0b7ac,8f3df7b,e8f68a9.Join::Intersectincore/dr-pipeline/src/mask.rs, defined as the productdst · srcper mask-editing.md §5.2.Join::applystates all three joins once, and the GPU tests check against it.combine_intersectpipeline with blendZero, Src, Add, per the plan's table.join = intersect. Old sidecars load unchanged; an older build reads the word as a union.Not covered, and worth a follow-up if they matter: