Give every mask an eye and a colour, and put the brush where the mask is

The first build of seeing a mask showed the selected layer's, in one global
style, from a strip at the top of the panel. It answered the wrong question and
answered it somewhere nobody looked. 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, drawn in the colour its mask is shown in,
and each mask has six swatches to choose that colour from. Several can be open
at once; a new one comes up open, in the first colour nothing else is using.
The style — tint, alpha, outline — is the one setting that stays global, above
the stack, because three styles at once are three pictures that cannot be read
against each other. Alpha now draws every shown mask, each in its colour, on
black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than
one layer, and every reveal block carries its own colour.

The brush moves too. Select, Paint and Erase and the three sliders under them
sat at the top of the panel, appeared only once a row was selected, and said
nothing about which mask they acted on — so "how do I paint" and "how do I
correct the model's outline" both had the same answer and nobody found it.
They sit under the selected mask's parts now, beside the swatches, and on a
subject or a category the hint says what a stroke there does: it becomes a
part of this mask, joined to the model's, and can be taken out again.

Eyes and colours are viewing state, on the session and not on the layer, so a
photograph reopened has every eye closed — the stored-mask round-trip test
asserts it.
This commit is contained in:
2026-09-11 19:03:51 +02:00
parent 936490880b
commit a87139b838
12 changed files with 759 additions and 287 deletions
+122 -28
View File
@@ -1787,22 +1787,62 @@ impl RevealStyle {
}
/// TRACES: FR-DEV-19c
/// The layer whose mask is being shown, and how.
/// One layer whose mask is being shown, and the colour it is shown in.
#[derive(Debug, Clone, PartialEq)]
pub struct RevealedLayer {
/// 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,
/// Linear RGB in the output space's primaries. Each shown mask has its
/// own, because two masks in one colour are one mask as far as the eye
/// can tell, and telling a sky from the building in front of it is what
/// showing them together is for.
pub colour: [f32; 3],
}
/// TRACES: FR-DEV-19c
/// The layers whose masks are being shown, and how.
///
/// Several at once, each in its own colour, one style for all of them: a tint
/// beside an outline beside an alpha would be three pictures that cannot be
/// read against each other, where three tints in three colours are one.
///
/// **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)]
#[derive(Debug, Clone, PartialEq)]
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,
/// In no particular order; the stack's order is what they draw in.
pub layers: Vec<RevealedLayer>,
pub style: RevealStyle,
}
impl Reveal {
/// One layer, shown red — what a test wants and what nothing else does.
pub fn one(layer: impl Into<String>, style: RevealStyle) -> Self {
Self {
layers: vec![RevealedLayer {
layer: layer.into(),
colour: [0.85, 0.10, 0.15],
}],
style,
}
}
/// The colour `id` is shown in, or `None` when it is not shown.
pub fn colour_of(&self, id: &str) -> Option<[f32; 3]> {
self.layers.iter().find(|l| l.layer == id).map(|l| l.colour)
}
/// Whether anything at all would be drawn.
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
}
/// The ordered stack of local adjustments.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MaskStack {
@@ -1902,7 +1942,7 @@ impl MaskStack {
) -> impl Iterator<Item = &'a MaskLayer> {
self.layers
.iter()
.filter(move |l| l.is_active() || reveal.is_some_and(|r| r.layer == l.id))
.filter(move |l| l.is_active() || reveal.is_some_and(|r| r.colour_of(&l.id).is_some()))
}
pub fn rendered_count(&self, reveal: Option<&Reveal>) -> usize {
@@ -1964,8 +2004,17 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
}
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);
if let Some((r, colour)) = reveal.and_then(|r| Some((r, r.colour_of(&layer.id)?))) {
// Alpha begins from black, once, before the first mask lands on
// it: the style is "the masks alone", and the photograph is what
// it leaves out.
if out.reveal.is_empty() && r.style == RevealStyle::Alpha {
out.reveal.push_str(
"\n // ==== showing masks alone: the photograph goes first ====\n c = vec3<f32>(0.0);\n",
);
}
out.reveal
.push_str(&reveal_block(slot, layer, r.style, colour));
}
let prefix = format!("mask{slot}");
@@ -2081,8 +2130,12 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
/// **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 {
fn reveal_block(slot: usize, layer: &MaskLayer, style: RevealStyle, colour: [f32; 3]) -> String {
let prefix = format!("mask{slot}");
let colour = format!(
"vec3<f32>({:.4}, {:.4}, {:.4})",
colour[0], colour[1], colour[2]
);
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\
@@ -2102,15 +2155,15 @@ fn reveal_block(slot: usize, layer: &MaskLayer, style: RevealStyle) -> String {
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
// 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);",
RevealStyle::Tint => " c = mix(c, COLOUR, m * 0.55);",
// Onto the black the section began with, at full strength: this is
// the mask itself, and where two overlap the later one lands on top,
// which is the order they composite in.
RevealStyle::Alpha => " c = mix(c, COLOUR, 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
@@ -2125,10 +2178,15 @@ fn reveal_block(slot: usize, layer: &MaskLayer, style: RevealStyle) -> String {
\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);"
\x20 c = mix(c, COLOUR, edge);"
}
};
let _ = writeln!(out, "{}", body.replace("SLOT", &slot.to_string()));
let _ = writeln!(
out,
"{}",
body.replace("SLOT", &slot.to_string())
.replace("COLOUR", &colour)
);
let _ = writeln!(out, " }}");
out
}
@@ -2308,10 +2366,7 @@ mod tests {
// through no slot at all.
stack.push(MaskLayer::new("m2", MaskSource::brush()));
let reveal = Reveal {
layer: "m2".into(),
style: RevealStyle::Alpha,
};
let reveal = Reveal::one("m2", RevealStyle::Alpha);
let shader = compose_layers_revealing(&stack, Some(&reveal));
assert_eq!(
@@ -2329,6 +2384,48 @@ mod tests {
);
}
/// Two masks shown together are drawn each in its own colour, in stack
/// order — which is what makes a sky and the building in front of it two
/// things on screen rather than one red shape.
#[test]
fn each_shown_mask_is_drawn_in_its_own_colour() {
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("sky", MaskSource::brush()));
stack.push(MaskLayer::new("wall", MaskSource::brush()));
let reveal = Reveal {
layers: vec![
RevealedLayer {
layer: "wall".into(),
colour: [0.0, 0.0, 1.0],
},
RevealedLayer {
layer: "sky".into(),
colour: [1.0, 0.0, 0.0],
},
],
style: RevealStyle::Tint,
};
let shader = compose_layers_revealing(&stack, Some(&reveal));
let sky = shader
.reveal
.find("vec3<f32>(1.0000, 0.0000, 0.0000)")
.expect("the sky's red is in the shader");
let wall = shader
.reveal
.find("vec3<f32>(0.0000, 0.0000, 1.0000)")
.expect("the wall's blue is in the shader");
assert!(
sky < wall,
"drawn in stack order, not in the order they were asked for"
);
assert!(
shader.reveal.contains("sample_mask(uv_src, 0)")
&& shader.reveal.contains("sample_mask(uv_src, 1)"),
"each reads its own slot"
);
}
/// A composition nobody asked to see a mask through draws none.
#[test]
fn nothing_is_revealed_unless_it_was_asked_for() {
@@ -2343,10 +2440,7 @@ mod tests {
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,
};
let reveal = Reveal::one("gone", RevealStyle::Tint);
assert_eq!(stack.rendered_count(Some(&reveal)), 1);
assert!(compose_layers_revealing(&stack, Some(&reveal))
.reveal