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:
+231
-10
@@ -1760,6 +1760,49 @@ impl MaskLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// How a mask is drawn when the photographer asks to see it.
|
||||
///
|
||||
/// Three, because they answer three different questions and no one of them
|
||||
/// answers all three — which is the argument for offering a choice rather than
|
||||
/// picking the best one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RevealStyle {
|
||||
/// The mask over the photograph in a flat colour. What every editor's
|
||||
/// photographers already expect, and the only style that answers "is this
|
||||
/// selecting the right thing" while the picture is still visible.
|
||||
Tint,
|
||||
/// The mask alone, white on black. For judging an edge, which a tint over
|
||||
/// a busy photograph cannot be read against.
|
||||
Alpha,
|
||||
/// The boundary outlined over the untouched picture. For checking
|
||||
/// registration against detail the other two hide — the same reasoning the
|
||||
/// region overlay's white outline already carries.
|
||||
Edge,
|
||||
}
|
||||
|
||||
impl RevealStyle {
|
||||
/// In the order the interface offers them.
|
||||
pub const ALL: [RevealStyle; 3] = [Self::Tint, Self::Alpha, Self::Edge];
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The layer whose mask is being shown, and how.
|
||||
///
|
||||
/// **Never part of an edit.** It is not stored on [`MaskStack`] and it does
|
||||
/// not travel with the graph: it is passed to the one composition that draws
|
||||
/// the screen, so an export, a thumbnail and the neutral probe are
|
||||
/// structurally unable to reveal anything. A flag on the stack would have been
|
||||
/// fewer parameters and would have tinted every exported file red.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Reveal {
|
||||
/// Which layer, by id. By id rather than by slot because the slot is
|
||||
/// derived from which layers render, and that is decided *by* this — see
|
||||
/// [`MaskStack::rendered`].
|
||||
pub layer: String,
|
||||
pub style: RevealStyle,
|
||||
}
|
||||
|
||||
/// The ordered stack of local adjustments.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct MaskStack {
|
||||
@@ -1838,6 +1881,34 @@ impl MaskStack {
|
||||
self.active().count()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// [`Self::active`], plus the layer being looked at.
|
||||
///
|
||||
/// A selection with no adjustment on it yet is not active — it changes no
|
||||
/// pixel, so it occupies no mask slot and the rasteriser never draws it.
|
||||
/// That is right for rendering and exactly wrong for *showing* the mask,
|
||||
/// which is the state a photographer is in for the whole of the time
|
||||
/// between choosing a subject and deciding what to do to it.
|
||||
///
|
||||
/// So this is the sequence both halves walk whenever a reveal is in play,
|
||||
/// and the index within it is the texture-array slot — the same contract
|
||||
/// [`Self::active`] carries, and the reason the rasteriser, the composer
|
||||
/// and the field builder must all be given the same `reveal` or none of
|
||||
/// them. Two of them disagreeing shows as an adjustment applied through
|
||||
/// another layer's mask.
|
||||
pub fn rendered<'a>(
|
||||
&'a self,
|
||||
reveal: Option<&'a Reveal>,
|
||||
) -> impl Iterator<Item = &'a MaskLayer> {
|
||||
self.layers
|
||||
.iter()
|
||||
.filter(move |l| l.is_active() || reveal.is_some_and(|r| r.layer == l.id))
|
||||
}
|
||||
|
||||
pub fn rendered_count(&self, reveal: Option<&Reveal>) -> usize {
|
||||
self.rendered(reveal).count()
|
||||
}
|
||||
|
||||
/// Whether any layer changes any pixel.
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
self.active_count() == 0
|
||||
@@ -1858,25 +1929,45 @@ pub(crate) struct LayerShader {
|
||||
pub uniform_values: Vec<f32>,
|
||||
pub body: String,
|
||||
pub helpers: Vec<crate::operation::Helper>,
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The block that draws one layer's mask over the finished picture, empty
|
||||
/// when nothing is being revealed.
|
||||
///
|
||||
/// Kept apart from `body` because it belongs at the other end of the
|
||||
/// shader. Everything in `body` runs on scene-referred colour in the
|
||||
/// working space, where a flat tint would then be pushed through the base
|
||||
/// curve and the camera matrix and arrive as some other colour, and a
|
||||
/// white-on-black alpha would arrive as neither. This runs after the
|
||||
/// output transform, so what is written is what is seen.
|
||||
pub reveal: String,
|
||||
}
|
||||
|
||||
/// Emit the WGSL for every active layer.
|
||||
/// Emit the WGSL for every layer that renders, and for the mask being looked
|
||||
/// at.
|
||||
///
|
||||
/// `slot` is the layer's index in the mask texture array, matching
|
||||
/// [`MaskStack::active`].
|
||||
pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
|
||||
/// [`MaskStack::rendered`] — the revealed layer renders whether or not it has
|
||||
/// an adjustment on it, which is why the two are one sequence and why every
|
||||
/// other half of the pipeline has to be given the same `reveal` for the slots
|
||||
/// to mean the same thing.
|
||||
pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal>) -> LayerShader {
|
||||
let mut out = LayerShader {
|
||||
uniform_fields: String::new(),
|
||||
uniform_values: Vec::new(),
|
||||
body: String::new(),
|
||||
helpers: Vec::new(),
|
||||
reveal: String::new(),
|
||||
};
|
||||
|
||||
if stack.active().next().is_some() {
|
||||
if stack.rendered(reveal).next().is_some() {
|
||||
out.helpers.push(MASK_SAMPLER);
|
||||
}
|
||||
|
||||
for (slot, layer) in stack.active().enumerate() {
|
||||
for (slot, layer) in stack.rendered(reveal).enumerate() {
|
||||
if reveal.is_some_and(|r| r.layer == layer.id) {
|
||||
out.reveal = reveal_block(slot, layer, reveal.expect("just matched").style);
|
||||
}
|
||||
|
||||
let prefix = format!("mask{slot}");
|
||||
|
||||
let _ = writeln!(
|
||||
@@ -1973,6 +2064,75 @@ pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
|
||||
out
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The WGSL that draws one layer's mask over the finished picture.
|
||||
///
|
||||
/// # Why this is not two uniforms
|
||||
///
|
||||
/// The slot and the style are written into the source, so turning the reveal
|
||||
/// on, off, or onto another layer recompiles the fused shader. That is a
|
||||
/// button press rather than a frame — and the alternative costs more than it
|
||||
/// saves: a uniform can select a slot, but it cannot conjure one for a layer
|
||||
/// that is not rendering, and the whole reason this exists is that a selection
|
||||
/// with no adjustment on it yet is exactly that layer. So the composition
|
||||
/// changes either way, and a uniform would only have added a branch per pixel
|
||||
/// on top of it.
|
||||
///
|
||||
/// **Everything below runs after the output transform.** `c` is already in the
|
||||
/// output space's primaries and still linear — the clip and the encode come
|
||||
/// after — which is what makes a stated colour arrive as itself.
|
||||
fn reveal_block(slot: usize, layer: &MaskLayer, style: RevealStyle) -> String {
|
||||
let prefix = format!("mask{slot}");
|
||||
let mut out = format!(
|
||||
"\n // ==== showing mask {slot}: {} ====\n //\n // Not part of the\
|
||||
\n // photograph: this is the mask itself, drawn because someone asked to\
|
||||
\n // see it. Nothing downstream of the screen composes this shader.\n {{\n",
|
||||
layer.display_name()
|
||||
);
|
||||
// The shaped mask, exactly as the layer above applied it — the same two
|
||||
// uniforms, in the same order. A reveal that showed the raw slice would
|
||||
// draw a different mask from the one doing the work, which is worse than
|
||||
// showing none: it would send a photographer to fix an edge that is
|
||||
// already where they want it.
|
||||
let _ = writeln!(out, " var m = sample_mask(uv_src, {slot});");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
|
||||
);
|
||||
let _ = writeln!(out, " m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);");
|
||||
|
||||
let body = match style {
|
||||
// Red at a bit over half strength. Half is the strength every editor
|
||||
// settled on for the same reason: past it the tint is opaque enough to
|
||||
// hide the thing being judged, and below it a mask over a bright sky
|
||||
// cannot be seen at all.
|
||||
RevealStyle::Tint => " c = mix(c, vec3<f32>(0.85, 0.10, 0.15), m * 0.55);",
|
||||
// Neutral, which means the same thing in every output space that
|
||||
// shares a white point — so this one style needs no correction for the
|
||||
// panel the window happens to be on.
|
||||
RevealStyle::Alpha => " c = vec3<f32>(m);",
|
||||
// The gradient's magnitude, over the untouched picture. Central
|
||||
// differences one texel apart in the *mask's* own grid, so the outline
|
||||
// is one mask texel wide however far the view is zoomed in — the
|
||||
// boundary's position is the thing being checked, and a line that grew
|
||||
// with the zoom would hide it.
|
||||
RevealStyle::Edge => {
|
||||
" let texel = 1.0 / vec2<f32>(textureDimensions(masks));\n\
|
||||
\x20 let dx = sample_mask(uv_src + vec2<f32>(texel.x, 0.0), SLOT)\n\
|
||||
\x20 - sample_mask(uv_src - vec2<f32>(texel.x, 0.0), SLOT);\n\
|
||||
\x20 let dy = sample_mask(uv_src + vec2<f32>(0.0, texel.y), SLOT)\n\
|
||||
\x20 - sample_mask(uv_src - vec2<f32>(0.0, texel.y), SLOT);\n\
|
||||
\x20 // Doubled so a soft edge, whose gradient is spread over many\n\
|
||||
\x20 // texels and therefore shallow everywhere, still draws a line.\n\
|
||||
\x20 let edge = clamp(2.0 * sqrt(dx * dx + dy * dy), 0.0, 1.0);\n\
|
||||
\x20 c = mix(c, vec3<f32>(1.0), edge);"
|
||||
}
|
||||
};
|
||||
let _ = writeln!(out, "{}", body.replace("SLOT", &slot.to_string()));
|
||||
let _ = writeln!(out, " }}");
|
||||
out
|
||||
}
|
||||
|
||||
/// A stable fingerprint of a segmentation, for [`MaskSource::Regions`].
|
||||
///
|
||||
/// Built from the things that change what a region id *means* — the proxy
|
||||
@@ -2021,7 +2181,7 @@ mod tests {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
assert!(stack.is_neutral());
|
||||
assert_eq!(compose_layers(&stack).body, "");
|
||||
assert_eq!(compose_layers_revealing(&stack, None).body, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2107,7 +2267,7 @@ mod tests {
|
||||
stack.push(lit_layer("m1", 1.0));
|
||||
stack.push(lit_layer("m2", -1.0));
|
||||
|
||||
let shader = compose_layers(&stack);
|
||||
let shader = compose_layers_revealing(&stack, None);
|
||||
assert!(shader.body.contains("sample_mask(uv_src, 0)"));
|
||||
assert!(shader.body.contains("sample_mask(uv_src, 1)"));
|
||||
assert!(shader.body.contains("u.mask0_opacity"));
|
||||
@@ -2124,7 +2284,7 @@ mod tests {
|
||||
stack.push(off);
|
||||
stack.push(lit_layer("m2", -1.0));
|
||||
|
||||
let shader = compose_layers(&stack);
|
||||
let shader = compose_layers_revealing(&stack, None);
|
||||
assert!(
|
||||
shader.body.contains("sample_mask(uv_src, 0)"),
|
||||
"the one active layer must use slot 0, not slot 1"
|
||||
@@ -2132,13 +2292,74 @@ mod tests {
|
||||
assert!(!shader.body.contains("sample_mask(uv_src, 1)"));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The slot the reveal is given has to be the slot the layer renders
|
||||
/// through, and a revealed layer renders even with nothing done to it.
|
||||
///
|
||||
/// Both halves in one assertion because the failure is the pair coming
|
||||
/// apart: a reveal pointed at a slot the rasteriser did not draw shows
|
||||
/// whatever was last in that slice, which reads as the mask being wrong
|
||||
/// rather than as the reveal being wrong.
|
||||
#[test]
|
||||
fn a_revealed_layer_takes_a_slot_of_its_own() {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(lit_layer("m1", 1.0));
|
||||
// No adjustment, so this changes no pixel and would ordinarily render
|
||||
// through no slot at all.
|
||||
stack.push(MaskLayer::new("m2", MaskSource::brush()));
|
||||
|
||||
let reveal = Reveal {
|
||||
layer: "m2".into(),
|
||||
style: RevealStyle::Alpha,
|
||||
};
|
||||
let shader = compose_layers_revealing(&stack, Some(&reveal));
|
||||
|
||||
assert_eq!(
|
||||
stack.rendered_count(Some(&reveal)),
|
||||
2,
|
||||
"the layer being looked at renders alongside the active one"
|
||||
);
|
||||
assert!(
|
||||
shader.reveal.contains("sample_mask(uv_src, 1)"),
|
||||
"the reveal must read slot 1, which is where m2 renders"
|
||||
);
|
||||
assert!(
|
||||
shader.reveal.contains("u.mask1_opacity"),
|
||||
"and shape it with that layer's own uniforms, not another's"
|
||||
);
|
||||
}
|
||||
|
||||
/// A composition nobody asked to see a mask through draws none.
|
||||
#[test]
|
||||
fn nothing_is_revealed_unless_it_was_asked_for() {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(lit_layer("m1", 1.0));
|
||||
assert!(compose_layers_revealing(&stack, None).reveal.is_empty());
|
||||
}
|
||||
|
||||
/// A reveal aimed at a layer that is not in the stack is not a slot, and
|
||||
/// must not become one.
|
||||
#[test]
|
||||
fn a_reveal_naming_no_layer_reveals_nothing() {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(lit_layer("m1", 1.0));
|
||||
let reveal = Reveal {
|
||||
layer: "gone".into(),
|
||||
style: RevealStyle::Tint,
|
||||
};
|
||||
assert_eq!(stack.rendered_count(Some(&reveal)), 1);
|
||||
assert!(compose_layers_revealing(&stack, Some(&reveal))
|
||||
.reveal
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_layer_gets_its_own_uniforms() {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(lit_layer("m1", 1.0));
|
||||
stack.push(lit_layer("m2", -1.0));
|
||||
|
||||
let shader = compose_layers(&stack);
|
||||
let shader = compose_layers_revealing(&stack, None);
|
||||
assert!(shader.uniform_fields.contains("mask0_exposure_"));
|
||||
assert!(shader.uniform_fields.contains("mask1_exposure_"));
|
||||
assert_eq!(
|
||||
@@ -2156,7 +2377,7 @@ mod tests {
|
||||
fn the_inner_block_shadows_c_and_copies_back() {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(lit_layer("m1", 1.0));
|
||||
let body = compose_layers(&stack).body;
|
||||
let body = compose_layers_revealing(&stack, None).body;
|
||||
|
||||
assert!(body.contains("var masked = c;"));
|
||||
assert!(body.contains("var c = masked;"));
|
||||
|
||||
Reference in New Issue
Block a user