Show the photographer the mask they are shaping

Nobody can refine an edge they are not being shown. The only thing drawn on
the canvas was the region overlay — a false-coloured picture of what the model
*detected* — which knows nothing of a layer's feather, its falloff, its
morphology, its invert or its opacity, and nothing at all about a gradient, a
range or a stroke. Every control added for mask editing therefore acted on
something invisible, which is why the whole feature reads as absent rather
than as unfinished.

A layer's finished mask now draws over the photograph in one of three styles:
a tint for whether the right thing is selected, an alpha for where the edge
is, an outline for whether that edge is registered against the detail the
other two hide.

The hard part is not the shader. A selection with no adjustment on it changes
no pixel, so it is not active, so it holds no slice of the mask array and is
never rasterised — and that is exactly the layer somebody wants to look at,
for the whole of the time between choosing a subject and deciding what to do
to it. So `MaskStack::rendered` is `active()` plus the layer being looked at,
and the rasteriser, the composer and the distance-field builder all index by
position in it. Which is also why the design's "two uniforms, no recompile" is
not available: a uniform can select a slot, it cannot conjure one.

The reveal is never on the graph. It reaches the pipeline as an argument to
`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 shorter, would have type-checked, and would have been one
forgotten reset away from a red tint baked into an exported file.

And the tools that shape a mask now arm. `Masking.tool` is an `in` property
only Rust may write, and the handler wrote nothing back, so the strip reported
"Select" however many times Paint was pressed and the paint area was never
enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no
control in the application.

The region overlay stands down while a mask is being shown, and its button now
says what it hides: two overlays that look alike and mean different things is
worse than either.
This commit is contained in:
2026-09-10 20:28:06 +02:00
parent 193b35a249
commit c045702a47
10 changed files with 927 additions and 51 deletions
+57 -3
View File
@@ -582,7 +582,23 @@ export global Masking {
in property <[PartRow]> parts;
/// TRACES: FR-DEV-19b
/// What a drag on the photograph does: 0 selects, 1 paints, 2 erases.
///
/// Written by Rust and read here, like every other `in` property on this
/// global: `tool-picked` is what the strip reports, and the answer comes
/// back through this. A version of the handler that recorded nothing left
/// the strip stuck on "Select" and the brush reachable from no control.
in property <int> tool: 0;
/// TRACES: FR-DEV-19c
/// How the selected layer's mask is drawn over the photograph: 0 not at
/// all, then tint, alpha and edge — `dr_pipeline::mask::RevealStyle::ALL`
/// shifted by one so that zero can mean off.
///
/// **Not `overlay-hidden`.** That switch belongs to the region overlay,
/// which is a picture of what the *model detected*; this is the mask a
/// layer actually resolves to, feather, morphology, invert and all. Two
/// overlays that look alike and mean different things is worse than
/// either, so they are named apart and switched apart.
in property <int> mask-view: 0;
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
/// and flow.
in property <float> brush-radius: 0.05;
@@ -630,6 +646,8 @@ export global Masking {
/// TRACES: FR-DEV-19b
callback tool-picked(int);
/// TRACES: FR-DEV-19c
callback mask-view-picked(int);
callback brush-changed(float, float, float);
/// TRACES: FR-DEV-19a
callback part-selected(string, int);
@@ -638,6 +656,8 @@ export global Masking {
callback part-added(string, int);
callback add-gradient(bool);
/// TRACES: FR-DEV-19b
callback add-brush();
/// `true` asks for the colour range rather than the brightness one.
callback add-range(bool);
callback add-subject(int);
@@ -680,6 +700,32 @@ export component MaskPanel inherits Rectangle {
picked(i) => { Masking.tool-picked(i); }
}
// TRACES: FR-DEV-19c
// **Seeing the mask, which every tool above is unusable without.**
// Nobody can refine an edge they are not being shown, and until this
// existed the only thing drawn on the photograph was the model's
// detections — which know nothing of a layer's feather, its falloff,
// its morphology, its invert or its opacity, and nothing at all about
// a gradient or a stroke.
//
// Four chips rather than a toggle because the three styles answer
// three different questions and no one of them answers all three: a
// tint says whether the right thing is selected, an alpha says where
// the edge is, an outline says whether that edge is registered against
// detail the other two hide.
//
// Beside the tool strip and under the same condition, because both
// describe one selected mask — and `columns: 2` for the reason
// `ChipGrid` exists: four chips in a row would set the width of the
// develop column and take every other panel's controls off the edge.
if Develop.enabled && Masking.parts.length > 0: Segmented {
label: "Show mask";
options: ["Off", "Tint", "Alpha", "Edge"];
columns: 2;
selected: Masking.mask-view;
picked(i) => { Masking.mask-view-picked(i); }
}
if Develop.enabled && Masking.tool > 0: SliderRow {
label: "Size";
value: Masking.brush-radius;
@@ -752,10 +798,18 @@ export component MaskPanel inherits Rectangle {
// a mask is judged against the photograph under it, and you cannot see
// that photograph through the thing describing it.
//
// Reads as its own action rather than its state: "Show the mask" is
// what pressing it will do, not what is currently true.
// Reads as its own action rather than its state: "Show what was found"
// is what pressing it will do, not what is currently true.
//
// TRACES: FR-DEV-19c
// **Named for what it actually hides**, which is not the mask. This
// said "Show the mask" / "Hide the mask" while switching the
// false-coloured picture of what the *model detected* — and now that
// there is a control which really does show a mask ("Show mask",
// above), two things called the same thing and meaning different ones
// would be worse than either.
if Develop.enabled && Masking.segmented: Button {
text: Masking.overlay-hidden ? "Show the mask" : "Hide the mask";
text: Masking.overlay-hidden ? "Show what was found" : "Hide what was found";
clicked => { Masking.overlay-hidden = !Masking.overlay-hidden; }
}