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:
@@ -849,14 +849,7 @@ fn a_selection_with_no_adjustment_can_still_be_seen() {
|
||||
"the fixture must be a selection with nothing done to it"
|
||||
);
|
||||
|
||||
let pixels = render_revealing(
|
||||
&ctx,
|
||||
&stack,
|
||||
&Reveal {
|
||||
layer: "m1".into(),
|
||||
style: RevealStyle::Alpha,
|
||||
},
|
||||
);
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Alpha));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2) > 200,
|
||||
@@ -904,14 +897,7 @@ fn a_tint_colours_the_mask_and_leaves_the_rest_alone() {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render_revealing(
|
||||
&ctx,
|
||||
&stack,
|
||||
&Reveal {
|
||||
layer: "m1".into(),
|
||||
style: RevealStyle::Tint,
|
||||
},
|
||||
);
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Tint));
|
||||
|
||||
let at = |x: u32, y: u32| {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
@@ -947,14 +933,7 @@ fn an_outline_draws_the_boundary_and_not_the_interior() {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render_revealing(
|
||||
&ctx,
|
||||
&stack,
|
||||
&Reveal {
|
||||
layer: "m1".into(),
|
||||
style: RevealStyle::Edge,
|
||||
},
|
||||
);
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Edge));
|
||||
|
||||
// Where the line landed, along the row through the centre. Searched
|
||||
// rather than sampled at one place: the radial's edge crosses this row
|
||||
@@ -984,3 +963,62 @@ fn an_outline_draws_the_boundary_and_not_the_interior() {
|
||||
"and so is the picture outside it"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// Two masks shown at once come out in two colours, each where its own mask
|
||||
/// is — which is what makes "where do these meet" a question the screen can
|
||||
/// answer.
|
||||
#[test]
|
||||
fn two_shown_masks_are_drawn_each_in_its_own_colour() {
|
||||
use dr_pipeline::mask::RevealedLayer;
|
||||
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// A left half and a right half, as two brush layers with one fat dab each.
|
||||
let half = |id: &str, x: f32| {
|
||||
let mut layer = MaskLayer::new(id, MaskSource::brush());
|
||||
paint(&mut layer, 0, false, &[(x, 0.5)]);
|
||||
layer
|
||||
};
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(half("left", 0.2));
|
||||
stack.push(half("right", 0.8));
|
||||
|
||||
let reveal = Reveal {
|
||||
layers: vec![
|
||||
RevealedLayer {
|
||||
layer: "left".into(),
|
||||
colour: [1.0, 0.0, 0.0],
|
||||
},
|
||||
RevealedLayer {
|
||||
layer: "right".into(),
|
||||
colour: [0.0, 0.0, 1.0],
|
||||
},
|
||||
],
|
||||
style: RevealStyle::Alpha,
|
||||
};
|
||||
let pixels = render_revealing(&ctx, &stack, &reveal);
|
||||
|
||||
let at = |x: u32| {
|
||||
let i = ((SIZE / 2 * SIZE + x) * 4) as usize;
|
||||
(pixels[i], pixels[i + 1], pixels[i + 2])
|
||||
};
|
||||
let (r, _, b) = at(SIZE / 5);
|
||||
assert!(
|
||||
r > 200 && b < 40,
|
||||
"the left mask reads red, got r={r} b={b}"
|
||||
);
|
||||
let (r, _, b) = at(SIZE * 4 / 5);
|
||||
assert!(
|
||||
b > 200 && r < 40,
|
||||
"the right mask reads blue, got r={r} b={b}"
|
||||
);
|
||||
let (r, g, b) = at(SIZE / 2);
|
||||
assert!(
|
||||
r < 40 && g < 40 && b < 40,
|
||||
"between them, alpha shows black: ({r}, {g}, {b})"
|
||||
);
|
||||
}
|
||||
|
||||
+122
-28
@@ -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
|
||||
|
||||
@@ -1635,10 +1635,7 @@ mod tests {
|
||||
&stack,
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[],
|
||||
Some(&Reveal {
|
||||
layer: "m1".into(),
|
||||
style: RevealStyle::Tint,
|
||||
}),
|
||||
Some(&Reveal::one("m1", RevealStyle::Tint)),
|
||||
);
|
||||
assert!(shown.source.contains("==== showing mask"));
|
||||
assert_ne!(
|
||||
|
||||
+11
-2
@@ -5,7 +5,7 @@
|
||||
|
||||
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
||||
|
||||
36 gestures, in 4 places.
|
||||
37 gestures, in 4 places.
|
||||
|
||||
## Develop
|
||||
|
||||
@@ -139,7 +139,16 @@ The column is 280px wide and the colour mixer alone puts thirty-six of these in
|
||||
|
||||
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a history step, unlike holding "Before" — the layer really is off until it is switched back on.
|
||||
|
||||
<sub>`ui/dr-ui/ui/masks.slint:196`</sub>
|
||||
<sub>`ui/dr-ui/ui/masks.slint:209`</sub>
|
||||
|
||||
### Show or hide one mask on the photograph
|
||||
|
||||
- **Touch** — Tap the eye on its row
|
||||
- **Pointer** — Click the eye on its row
|
||||
|
||||
A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step.
|
||||
|
||||
<sub>`ui/dr-ui/ui/masks.slint:274`</sub>
|
||||
|
||||
## Collections sidebar
|
||||
|
||||
|
||||
+35
-24
@@ -396,9 +396,10 @@ usable along hair.
|
||||
|
||||
## 6. Seeing the mask
|
||||
|
||||
**Status: built.** `MaskStack::rendered`, `RevealStyle`,
|
||||
`EditGraph::compose_revealing`, `MaskPass::render_revealing`, and the "Show
|
||||
mask" strip in the Local panel.
|
||||
**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
|
||||
@@ -449,36 +450,46 @@ 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 a flat colour at ~50%. The default,
|
||||
and what every editor's photographers already expect. Red, and *not yet*
|
||||
configurable: the case for choosing the colour is a red tint over a red
|
||||
dress, and the answer to that is the Alpha style beside it until somebody
|
||||
finds a picture where neither works.
|
||||
- **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.
|
||||
|
||||
**When it appears.** From the "Show mask" strip in the Local panel, which sits
|
||||
beside the tool strip and under the same condition — both describe one
|
||||
selected mask. Off is the resting state, so entering local mode does not paint
|
||||
a photograph red.
|
||||
**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.
|
||||
|
||||
Automatic in exactly one place: arming Paint or Erase turns the tint on if
|
||||
nothing was showing the mask, which is the same nudge `on_part_added` makes and
|
||||
on the same argument — a stroke into an invisible mask is indistinguishable
|
||||
from a tool that did nothing.
|
||||
**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.
|
||||
|
||||
A nudge on an explicit action, never a standing rule. Turning the view off and
|
||||
then picking the eraser leaves it off. 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.
|
||||
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.
|
||||
|
||||
The two remaining triggers in the draft — while a part row is selected, and for
|
||||
~1s after a shaping slider is released — are not built. The second is the one
|
||||
worth returning to, and it is a timer rather than a decision.
|
||||
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
|
||||
|
||||
@@ -517,13 +517,15 @@ hardness and flow the photographer sets. Strokes are stored as normalised source
|
||||
rasterised on the device, so a correction stays on what it was painted on through a crop, a zoom and
|
||||
an export at any size. A whole stroke is one step in the history.
|
||||
|
||||
**FR-DEV-19c — Seeing the mask.** The mask a selected layer actually resolves to shall be drawable
|
||||
over the photograph, in a tint, as an alpha, or as an outline. What is shown is the finished mask —
|
||||
every part folded, with the layer's feather, falloff, morphology, invert and opacity applied — and
|
||||
it is shown for a layer that carries no adjustment yet, which is the state every mask is in for its
|
||||
first few seconds. No rendered output ever carries it: the reveal is a property of looking at an
|
||||
edit, not of the edit, so it reaches the pipeline through the composition that draws the canvas and
|
||||
through no other.
|
||||
**FR-DEV-19c — Seeing the mask.** Each layer's mask shall be drawable over the photograph, switched
|
||||
per layer by an eye on its row and drawn in a colour of that layer's own, so that several can be
|
||||
shown at once and told apart; the style — a tint, an alpha, or an outline — is one setting for all
|
||||
of them. What is shown is the finished mask — every part folded, with the layer's feather, falloff,
|
||||
morphology, invert and opacity applied — and it is shown for a layer that carries no adjustment
|
||||
yet, which is the state every mask is in for its first few seconds. No rendered output ever carries
|
||||
it: the reveal is a property of looking at an edit, not of the edit, so it reaches the pipeline
|
||||
through the composition that draws the canvas and through no other, and it does not travel in a
|
||||
sidecar.
|
||||
|
||||
Nobody can refine an edge they are not being shown. Before this the only thing drawn on the
|
||||
photograph was FR-DEV-3's region overlay — a false-coloured picture of what the *model detected*,
|
||||
|
||||
+36
-36
File diff suppressed because one or more lines are too long
+183
-64
@@ -683,6 +683,32 @@ impl CropAspect {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// How one layer's mask is shown on the canvas.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct MaskView {
|
||||
shown: bool,
|
||||
/// Index into [`MASK_COLOURS`].
|
||||
colour: usize,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The colours a mask may be shown in, in linear sRGB.
|
||||
///
|
||||
/// Six, chosen to be told apart at half strength over a photograph rather
|
||||
/// than to be pretty: red and green and blue at the corners, and the three
|
||||
/// between them. Exposed so the panel draws its swatches from the same table
|
||||
/// the shader is handed, and a seventh colour is one line here and nowhere
|
||||
/// else.
|
||||
pub const MASK_COLOURS: [[f32; 3]; 6] = [
|
||||
[0.85, 0.10, 0.15],
|
||||
[0.15, 0.80, 0.25],
|
||||
[0.20, 0.45, 1.00],
|
||||
[0.95, 0.80, 0.10],
|
||||
[0.90, 0.20, 0.85],
|
||||
[0.15, 0.85, 0.90],
|
||||
];
|
||||
|
||||
pub struct DevelopSession {
|
||||
/// This session's name, for work that outlives the frame it started on.
|
||||
id: SessionId,
|
||||
@@ -866,19 +892,27 @@ pub struct DevelopSession {
|
||||
/// Whether to draw the false-coloured region overlay.
|
||||
show_overlay: bool,
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// How the selected layer's mask is being shown, or `None` for not at all.
|
||||
/// How shown masks are drawn — one style for all of them.
|
||||
///
|
||||
/// Interface state, like `show_overlay` beside it and `active_masks` above
|
||||
/// — it changes no pixel of the photograph, it is not in the sidecar and
|
||||
/// it is not on the undo stack. It reaches the pipeline as an argument to
|
||||
/// the one composition that draws the canvas, which is what makes an
|
||||
/// export structurally unable to carry it (`EditGraph::compose_revealing`).
|
||||
reveal_style: dr_pipeline::mask::RevealStyle,
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// Per layer: whether its mask is shown, and in what colour.
|
||||
///
|
||||
/// The *style* is stored and not the layer, because the layer is always
|
||||
/// the selected one: a reveal that stayed pointed at a mask nobody was
|
||||
/// working on would be describing the wrong thing every time the selection
|
||||
/// moved, and there is no gesture that wants it.
|
||||
reveal_style: Option<dr_pipeline::mask::RevealStyle>,
|
||||
/// Per layer rather than "the selected one", because the question 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. Keyed by id, and an id that is no
|
||||
/// longer in the stack is simply never asked for; `reveal` walks the
|
||||
/// stack, not this map.
|
||||
///
|
||||
/// Viewing state and not edit state, for the reason the style is: it does
|
||||
/// not travel in a sidecar, so a photograph reopened has every eye closed.
|
||||
mask_views: std::collections::HashMap<String, MaskView>,
|
||||
/// Which attribute the panel is filtered to, or all of them.
|
||||
///
|
||||
/// `None` is "show everything" and is what a frontend that ignores
|
||||
@@ -1034,7 +1068,8 @@ impl DevelopSession {
|
||||
),
|
||||
selected_spot: None,
|
||||
show_overlay: false,
|
||||
reveal_style: None,
|
||||
reveal_style: dr_pipeline::mask::RevealStyle::Tint,
|
||||
mask_views: std::collections::HashMap::new(),
|
||||
active_tab: None,
|
||||
curve_channel: 0,
|
||||
display_space: dr_types::ColourSpace::Srgb,
|
||||
@@ -1981,9 +2016,16 @@ impl DevelopSession {
|
||||
// beneath it.
|
||||
let reveal = self.reveal();
|
||||
mix(reveal.as_ref().map_or(0, |r| {
|
||||
// Every shown id, not only which are shown: a layer joining the
|
||||
// shown set renumbers every slot after it, exactly as one joining
|
||||
// the active set does. Colours are left out — they change what
|
||||
// the shader draws, not which field it draws through.
|
||||
let mut h: u64 = 1;
|
||||
for b in r.layer.as_bytes() {
|
||||
h = h.wrapping_mul(31).wrapping_add(*b as u64);
|
||||
for l in &r.layers {
|
||||
for b in l.layer.as_bytes() {
|
||||
h = h.wrapping_mul(31).wrapping_add(*b as u64);
|
||||
}
|
||||
h = h.wrapping_mul(31).wrapping_add(0x1f);
|
||||
}
|
||||
h
|
||||
}));
|
||||
@@ -2476,51 +2518,125 @@ impl DevelopSession {
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// What the canvas should draw over the photograph, if anything.
|
||||
///
|
||||
/// **Only when exactly one layer is selected**, on the same rule the part
|
||||
/// list and the brush follow: a mask is one thing, and drawing the union
|
||||
/// of three because three rows were control-clicked would answer a
|
||||
/// question nobody asked. `None` there rather than picking the first, so
|
||||
/// the reveal disappearing is itself the signal that the selection is not
|
||||
/// what it needs to be.
|
||||
/// Every layer whose eye is open, in stack order, each in its colour —
|
||||
/// and `None` when no eye is, so the rasteriser and the composer can
|
||||
/// take the path they always took.
|
||||
///
|
||||
/// Rebuilt per call rather than kept in step with the selection, because
|
||||
/// it is two fields and a clone of one id — cheaper than the invalidation
|
||||
/// a cached copy would need every time a layer is added, removed, renamed
|
||||
/// Rebuilt per call rather than kept in step with the stack, because it
|
||||
/// is a walk over at most eight layers — cheaper than the invalidation a
|
||||
/// cached copy would need every time a layer is added, removed, renamed
|
||||
/// or reordered.
|
||||
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
|
||||
let style = self.reveal_style?;
|
||||
let [id] = self.active_masks.as_slice() else {
|
||||
use dr_pipeline::mask::{Reveal, RevealedLayer};
|
||||
let layers: Vec<RevealedLayer> = self
|
||||
.graph
|
||||
.masks()
|
||||
.layers()
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
let view = self.mask_views.get(&l.id).filter(|v| v.shown)?;
|
||||
Some(RevealedLayer {
|
||||
layer: l.id.clone(),
|
||||
colour: MASK_COLOURS[view.colour % MASK_COLOURS.len()],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if layers.is_empty() {
|
||||
return None;
|
||||
};
|
||||
Some(dr_pipeline::mask::Reveal {
|
||||
layer: id.clone(),
|
||||
style,
|
||||
}
|
||||
Some(Reveal {
|
||||
layers,
|
||||
style: self.reveal_style,
|
||||
})
|
||||
}
|
||||
|
||||
/// How the selected layer's mask is being shown, as an index into
|
||||
/// [`dr_pipeline::mask::RevealStyle::ALL`], or `0` for not at all.
|
||||
/// How shown masks are drawn, as an index into
|
||||
/// [`dr_pipeline::mask::RevealStyle::ALL`].
|
||||
///
|
||||
/// An index because the panel offers it as a strip of chips and an index
|
||||
/// is what a strip of chips reports. The enum stays the thing that is
|
||||
/// stored, so a fourth style is a variant and a label rather than a number
|
||||
/// two files have to agree on.
|
||||
pub fn mask_view(&self) -> usize {
|
||||
use dr_pipeline::mask::RevealStyle;
|
||||
self.reveal_style
|
||||
.and_then(|s| RevealStyle::ALL.iter().position(|&a| a == s))
|
||||
.map_or(0, |i| i + 1)
|
||||
pub fn mask_view_style(&self) -> usize {
|
||||
dr_pipeline::mask::RevealStyle::ALL
|
||||
.iter()
|
||||
.position(|&a| a == self.reveal_style)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Show the selected layer's mask, or stop.
|
||||
/// Choose how shown masks are drawn.
|
||||
///
|
||||
/// Takes no history step and marks nothing dirty: this is how the
|
||||
/// photograph is being *looked at*, not an edit to it.
|
||||
pub fn set_mask_view(&mut self, view: usize) {
|
||||
use dr_pipeline::mask::RevealStyle;
|
||||
self.reveal_style = view
|
||||
.checked_sub(1)
|
||||
.and_then(|i| RevealStyle::ALL.get(i).copied());
|
||||
pub fn set_mask_view_style(&mut self, style: usize) {
|
||||
if let Some(&s) = dr_pipeline::mask::RevealStyle::ALL.get(style) {
|
||||
self.reveal_style = s;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this layer's mask is drawn over the photograph.
|
||||
pub fn mask_shown(&self, id: &str) -> bool {
|
||||
self.mask_views.get(id).is_some_and(|v| v.shown)
|
||||
}
|
||||
|
||||
/// Open or close one layer's eye.
|
||||
pub fn set_mask_shown(&mut self, id: &str, shown: bool) {
|
||||
let colour = self.next_mask_colour();
|
||||
self.mask_views
|
||||
.entry(id.to_string())
|
||||
.or_insert(MaskView {
|
||||
shown: false,
|
||||
colour,
|
||||
})
|
||||
.shown = shown;
|
||||
}
|
||||
|
||||
/// Whether any mask at all is being shown.
|
||||
///
|
||||
/// What the region overlay asks before drawing: two overlays that mean
|
||||
/// different things, on top of each other, is neither.
|
||||
pub fn any_mask_shown(&self) -> bool {
|
||||
self.reveal().is_some()
|
||||
}
|
||||
|
||||
/// Which of [`MASK_COLOURS`] this layer is shown in.
|
||||
pub fn mask_colour(&self, id: &str) -> usize {
|
||||
self.mask_views
|
||||
.get(id)
|
||||
.map_or(0, |v| v.colour % MASK_COLOURS.len())
|
||||
}
|
||||
|
||||
/// Give this layer a colour from [`MASK_COLOURS`].
|
||||
///
|
||||
/// Choosing a colour is asking to see it: a swatch pressed on a layer
|
||||
/// whose eye was closed opens the eye, because nothing else the press
|
||||
/// could mean would change a pixel.
|
||||
pub fn set_mask_colour(&mut self, id: &str, colour: usize) {
|
||||
let colour = colour % MASK_COLOURS.len();
|
||||
self.mask_views
|
||||
.entry(id.to_string())
|
||||
.and_modify(|v| {
|
||||
v.colour = colour;
|
||||
v.shown = true;
|
||||
})
|
||||
.or_insert(MaskView {
|
||||
shown: true,
|
||||
colour,
|
||||
});
|
||||
}
|
||||
|
||||
/// The colour the next layer to be shown should take: the first not
|
||||
/// already in use, or round the palette again once all are.
|
||||
///
|
||||
/// So that two masks made one after the other come up in two colours
|
||||
/// without anyone having to choose — which is the case that matters,
|
||||
/// since "how do these two meet" is the question two masks are shown to
|
||||
/// answer.
|
||||
fn next_mask_colour(&self) -> usize {
|
||||
let used: Vec<usize> = self.mask_views.values().map(|v| v.colour).collect();
|
||||
(0..MASK_COLOURS.len())
|
||||
.find(|c| !used.contains(c))
|
||||
.unwrap_or(self.mask_views.len() % MASK_COLOURS.len())
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
@@ -2531,18 +2647,22 @@ impl DevelopSession {
|
||||
/// derivable from anything on screen, the layer carries no adjustment yet,
|
||||
/// and the list it was chosen from says "architecture 23%" and nothing
|
||||
/// about *which* 23%. So the mask appears with the layer rather than
|
||||
/// waiting to be asked for a second time.
|
||||
/// waiting to be asked for a second time — its eye open, in the next
|
||||
/// colour nothing else is using.
|
||||
///
|
||||
/// Only from the resting position, and only on an explicit action — the
|
||||
/// same rule arming a brush follows. Somebody who has switched to the
|
||||
/// alpha or the outline keeps it, and this never re-arms in the background;
|
||||
/// the trap `Masking.overlay-hidden` documents is an automatic reveal that
|
||||
/// undoes a switch a photographer turned off, and every caller of this is
|
||||
/// a press that asked for a new mask.
|
||||
fn show_new_mask(&mut self) {
|
||||
if self.reveal_style.is_none() {
|
||||
self.reveal_style = Some(dr_pipeline::mask::RevealStyle::Tint);
|
||||
}
|
||||
/// Only this layer's eye. Every other layer keeps whatever the
|
||||
/// photographer set it to, which is the trap `Masking.overlay-hidden`
|
||||
/// documents: an automatic reveal that undoes a switch somebody turned
|
||||
/// off is worse than none.
|
||||
fn show_new_mask(&mut self, id: &str) {
|
||||
let colour = self.next_mask_colour();
|
||||
self.mask_views.insert(
|
||||
id.to_string(),
|
||||
MaskView {
|
||||
shown: true,
|
||||
colour,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
@@ -3378,7 +3498,7 @@ impl DevelopSession {
|
||||
return None;
|
||||
}
|
||||
self.active_masks = vec![id.clone()];
|
||||
self.show_new_mask();
|
||||
self.show_new_mask(&id);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||||
Some(id)
|
||||
@@ -3422,7 +3542,7 @@ impl DevelopSession {
|
||||
return None;
|
||||
}
|
||||
self.active_masks = vec![id.clone()];
|
||||
self.show_new_mask();
|
||||
self.show_new_mask(&id);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||||
Some(id)
|
||||
@@ -3453,7 +3573,7 @@ impl DevelopSession {
|
||||
return None;
|
||||
}
|
||||
self.active_masks = vec![id.clone()];
|
||||
self.show_new_mask();
|
||||
self.show_new_mask(&id);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||||
Some(id)
|
||||
@@ -3485,7 +3605,7 @@ impl DevelopSession {
|
||||
}
|
||||
self.active_masks = vec![id.clone()];
|
||||
self.active_part = 0;
|
||||
self.show_new_mask();
|
||||
self.show_new_mask(&id);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||||
Some(id)
|
||||
@@ -3512,7 +3632,7 @@ impl DevelopSession {
|
||||
return None;
|
||||
}
|
||||
self.active_masks = vec![id.clone()];
|
||||
self.show_new_mask();
|
||||
self.show_new_mask(&id);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||||
Some(id)
|
||||
@@ -6571,15 +6691,15 @@ mod tests {
|
||||
let mut live = session_with_a_left_half_subject(&ctx);
|
||||
let id = live.add_subject_mask(0).expect("a subject layer");
|
||||
// TRACES: FR-DEV-19c
|
||||
// Making a mask shows it (`show_new_mask`), and this test is about the
|
||||
// pixels the *edit* produces. Put away here rather than left on, and
|
||||
// the asymmetry is the point rather than an inconvenience: the reveal
|
||||
// is how somebody is looking at a photograph, so a session that has
|
||||
// just made a layer legitimately draws a frame that a session which
|
||||
// read the same layer out of a file does not. Both of those are
|
||||
// Making a mask opens its eye (`show_new_mask`), and this test is
|
||||
// about the pixels the *edit* produces. Closed here rather than left
|
||||
// open, and the asymmetry is the point rather than an inconvenience:
|
||||
// the reveal is how somebody is looking at a photograph, so a session
|
||||
// that has just made a layer legitimately draws a frame that a session
|
||||
// which read the same layer out of a file does not. Both of those are
|
||||
// correct, and only one of them is what a stored raster has to
|
||||
// reproduce.
|
||||
live.set_mask_view(0);
|
||||
live.set_mask_shown(&id, false);
|
||||
live.graph
|
||||
.masks_mut()
|
||||
.get_mut(&id)
|
||||
@@ -6611,10 +6731,9 @@ mod tests {
|
||||
// reopened with its masks tinted red. Checked here because this is the
|
||||
// one test that puts an edit through a file and renders both ends, so
|
||||
// it is where the property would first go wrong.
|
||||
assert_eq!(
|
||||
reopened.mask_view(),
|
||||
0,
|
||||
"restoring an edit must not turn an overlay on"
|
||||
assert!(
|
||||
!reopened.any_mask_shown(),
|
||||
"restoring an edit must not open an eye"
|
||||
);
|
||||
let from_the_file = read_back(&ctx, &reopened.render(64, 64).expect("render"));
|
||||
|
||||
|
||||
@@ -120,6 +120,13 @@ pub const GESTURES: &[Gesture] = &[
|
||||
pointer: "Click the ring at the head of its row",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Show or hide one mask on the photograph",
|
||||
section: "Develop",
|
||||
touch: "Tap the eye on its row",
|
||||
pointer: "Click the eye on its row",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Pick a collection up to rearrange the tree",
|
||||
section: "Collections sidebar",
|
||||
|
||||
+80
-19
@@ -149,6 +149,26 @@ impl Running {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// One of [`crate::develop::MASK_COLOURS`] as the compositor draws it.
|
||||
///
|
||||
/// The table is linear sRGB because that is what the shader mixes in; the
|
||||
/// swatch on the panel is display-encoded, so the conversion is here and not
|
||||
/// in the table — a swatch that showed the linear values would be a darker,
|
||||
/// duller colour than the one it promised.
|
||||
fn mask_colour(index: usize) -> slint::Color {
|
||||
let [r, g, b] = crate::develop::MASK_COLOURS[index % crate::develop::MASK_COLOURS.len()];
|
||||
let encode = |v: f32| {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if v <= 0.003_130_8 {
|
||||
v * 12.92
|
||||
} else {
|
||||
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
};
|
||||
slint::Color::from_rgb_f32(encode(r), encode(g), encode(b))
|
||||
}
|
||||
|
||||
/// A descriptor name as a list label.
|
||||
///
|
||||
/// Only the first letter, because the names in `models/scene/categories.txt`
|
||||
@@ -210,6 +230,9 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
|
||||
band_softness,
|
||||
hue,
|
||||
hue_width,
|
||||
shown: s.mask_shown(&id),
|
||||
colour: mask_colour(s.mask_colour(&id)),
|
||||
colour_index: s.mask_colour(&id) as i32,
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
enabled,
|
||||
@@ -265,10 +288,14 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
|
||||
masking.set_parts(ModelRc::new(VecModel::from(parts)));
|
||||
|
||||
// TRACES: FR-DEV-19c
|
||||
// Pushed rather than assumed, because the session is where it lives and
|
||||
// because it can change without the panel having asked: selecting a
|
||||
// second layer takes the reveal away, since a mask is one thing.
|
||||
masking.set_mask_view(s.mask_view() as i32);
|
||||
// Pushed rather than assumed, because the session is where it lives. The
|
||||
// palette goes with it so the swatches and the shader read one table.
|
||||
masking.set_mask_view_style(s.mask_view_style() as i32);
|
||||
masking.set_mask_colours(ModelRc::new(VecModel::from(
|
||||
(0..crate::develop::MASK_COLOURS.len())
|
||||
.map(mask_colour)
|
||||
.collect::<Vec<_>>(),
|
||||
)));
|
||||
|
||||
let (radius, hardness, flow) = s.brush();
|
||||
masking.set_brush_radius(radius);
|
||||
@@ -294,7 +321,7 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
|
||||
// describing the layer being worked on wins, because by the time a mask
|
||||
// has been chosen the detections are what the photographer is choosing
|
||||
// *between* rather than what they are looking at.
|
||||
match s.overlay_image().filter(|_| s.mask_view() == 0) {
|
||||
match s.overlay_image().filter(|_| !s.any_mask_shown()) {
|
||||
Some(image) => {
|
||||
window.set_region_overlay(image);
|
||||
window.set_overlay_on(true);
|
||||
@@ -895,8 +922,10 @@ pub(crate) fn wire(
|
||||
// automatic reveal at all.
|
||||
if tool > 0 {
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
if s.mask_view() == 0 {
|
||||
s.set_mask_view(1);
|
||||
if let Some(id) = s.active_mask().map(str::to_owned) {
|
||||
if !s.mask_shown(&id) {
|
||||
s.set_mask_shown(&id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -908,21 +937,53 @@ pub(crate) fn wire(
|
||||
});
|
||||
}
|
||||
// TRACES: FR-DEV-19c
|
||||
// Three handlers, one shape: change how the masks are looked at, then a
|
||||
// redraw and not only a sync, because the reveal is in the composed
|
||||
// shader and what changed is the picture rather than the panel.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.global::<Masking>().on_mask_view_picked(move |view| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_mask_view(view.max(0) as usize);
|
||||
}
|
||||
// A redraw and not only a sync: the reveal is in the composed
|
||||
// shader, so what changed is the picture rather than the
|
||||
// panel.
|
||||
sync(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
window
|
||||
.global::<Masking>()
|
||||
.on_mask_view_style_picked(move |style| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_mask_view_style(style.max(0) as usize);
|
||||
}
|
||||
sync(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window
|
||||
.global::<Masking>()
|
||||
.on_mask_shown_toggled(move |id, shown| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_mask_shown(&id, shown);
|
||||
}
|
||||
sync(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window
|
||||
.global::<Masking>()
|
||||
.on_mask_colour_picked(move |id, colour| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_mask_colour(&id, colour.max(0) as usize);
|
||||
}
|
||||
sync(&w, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
// TRACES: FR-DEV-19a
|
||||
{
|
||||
@@ -1234,7 +1295,7 @@ pub(crate) fn reset(window: &AppWindow) {
|
||||
// next photograph has none. Left standing, they would arm a brush over a
|
||||
// photograph with nothing to paint into and claim a mask was being shown.
|
||||
masking.set_tool(0);
|
||||
masking.set_mask_view(0);
|
||||
masking.set_mask_view_style(0);
|
||||
masking.set_masks(ModelRc::new(VecModel::<MaskRow>::default()));
|
||||
masking.set_subjects(ModelRc::new(VecModel::<SubjectRow>::default()));
|
||||
masking.set_categories(ModelRc::new(VecModel::<CategoryRow>::default()));
|
||||
|
||||
@@ -60,6 +60,7 @@ component Ink inherits Path {
|
||||
// collection collection-smart
|
||||
// offline offline-held
|
||||
// photo crop mask repair
|
||||
// eye eye-closed
|
||||
export component Icon inherits Rectangle {
|
||||
in property <string> name;
|
||||
/// The single colour the whole drawing takes. Named `ink` rather than
|
||||
@@ -335,6 +336,33 @@ export component Icon inherits Rectangle {
|
||||
commands: "M 12 3.6 A 8.4 8.4 0 0 1 12 20.4 Z";
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-19c
|
||||
// An eye, open: the almond and the pupil. The pupil is filled so that at
|
||||
// 16px the open eye reads as a dot with a lid rather than as two arcs
|
||||
// that could be anything, and it takes the same ink as the rest so the
|
||||
// whole glyph can be drawn in the colour of the mask it shows.
|
||||
if root.name == "eye": Ink {
|
||||
stroke: root.ink;
|
||||
stroke-width: root.weight;
|
||||
commands: "M 2.4 12 C 5.2 6.8 8.4 4.8 12 4.8 C 15.6 4.8 18.8 6.8 21.6 12 "
|
||||
+ "C 18.8 17.2 15.6 19.2 12 19.2 C 8.4 19.2 5.2 17.2 2.4 12 Z";
|
||||
}
|
||||
if root.name == "eye": Ink {
|
||||
fill: root.ink;
|
||||
stroke-width: root.weight;
|
||||
commands: "M 12 8.6 A 3.4 3.4 0 1 0 12 15.4 A 3.4 3.4 0 1 0 12 8.6 Z";
|
||||
}
|
||||
|
||||
// An eye, closed: the lower lid alone, with three lashes. The lid is the
|
||||
// same lower curve as the open eye's, so the two glyphs sit at the same
|
||||
// height and toggling between them does not make the row jump.
|
||||
if root.name == "eye-closed": Ink {
|
||||
stroke: root.ink;
|
||||
stroke-width: root.weight;
|
||||
commands: "M 2.4 12 C 5.2 17.2 8.4 19.2 12 19.2 C 15.6 19.2 18.8 17.2 21.6 12 "
|
||||
+ "M 12 19.2 L 12 22 M 6.6 18 L 5 20.4 M 17.4 18 L 19 20.4";
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-8
|
||||
// A repair: two circles, one sampling into the other. That is literally
|
||||
// what a spot is here — a source and a destination, which is why its
|
||||
|
||||
+185
-79
@@ -9,6 +9,7 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { Develop } from "session.slint";
|
||||
import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
|
||||
import { Icon } from "icons.slint";
|
||||
import { Segmented, SliderRow } from "controls.slint";
|
||||
|
||||
/// One layer in the stack.
|
||||
@@ -66,6 +67,15 @@ export struct MaskRow {
|
||||
/// Whether it currently changes any pixel. A bare selection does not, and
|
||||
/// saying so is what stops it reading as broken.
|
||||
adjusted: bool,
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// Whether its mask is drawn over the photograph — the eye on its row.
|
||||
shown: bool,
|
||||
/// The colour it is drawn in, and which swatch that is. Both, because the
|
||||
/// row draws the colour and the swatch strip lights the index, and
|
||||
/// deriving one from the other here would mean a second copy of the
|
||||
/// palette in this file.
|
||||
colour: color,
|
||||
colour-index: int,
|
||||
}
|
||||
|
||||
/// One selection inside a layer's mask (FR-DEV-19a).
|
||||
@@ -167,6 +177,9 @@ component MaskEntry inherits Rectangle {
|
||||
callback part-removed(int);
|
||||
/// Join a fresh painted correction: 0 adds, 1 subtracts.
|
||||
callback part-added(int);
|
||||
/// TRACES: FR-DEV-19c
|
||||
callback shown-toggled(bool);
|
||||
callback colour-picked(int);
|
||||
|
||||
height: layout.preferred-height;
|
||||
background: root.data.selected ? Theme.surface-raised : transparent;
|
||||
@@ -258,6 +271,41 @@ component MaskEntry inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// GESTURE: Show or hide one mask on the photograph
|
||||
// where: Develop
|
||||
// touch: Tap the eye on its row
|
||||
// pointer: Click the eye on its row
|
||||
// why: A mask is judged by seeing where it falls, and two
|
||||
// are judged by seeing where they meet — so each row
|
||||
// has its own eye rather than the panel having one,
|
||||
// and the eye is drawn in the colour the mask shows
|
||||
// in, so the row says which shape on the picture is
|
||||
// its. Nothing about the edit changes: this is how
|
||||
// the photograph is looked at, and takes no history
|
||||
// step.
|
||||
//
|
||||
// TRACES: FR-DEV-19c
|
||||
eye := TouchArea {
|
||||
width: Theme.touch-target;
|
||||
height: Theme.touch-target;
|
||||
mouse-cursor: pointer;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.shown-toggled(!root.data.shown); }
|
||||
|
||||
Icon {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
size: 18px;
|
||||
name: root.data.shown ? "eye" : "eye-closed";
|
||||
// Open, in the mask's own colour; closed, in the dim ink
|
||||
// every idle control wears — so a glance down the stack
|
||||
// says which masks are on the picture and in what.
|
||||
ink: root.data.shown
|
||||
? root.data.colour
|
||||
: (eye.has-hover ? Theme.ink : Theme.ink-dim);
|
||||
}
|
||||
}
|
||||
|
||||
remove := TouchArea {
|
||||
width: Theme.touch-target;
|
||||
height: Theme.touch-target;
|
||||
@@ -370,6 +418,109 @@ component MaskEntry inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-19b
|
||||
// **The brush lives with the mask it paints into.** It used to sit
|
||||
// at the top of the panel, a screen's height from the row that
|
||||
// had just been made, and appeared only once a row was selected —
|
||||
// so the whole of the answer to "how do I paint" was a strip
|
||||
// somebody had to scroll up to find and had no reason to look
|
||||
// for. Here it is under the parts it adds to, which is what a
|
||||
// stroke does.
|
||||
//
|
||||
// In the panel and not in the tool rail, still: the rail's
|
||||
// entries arm a gesture for the whole photograph, and a brush has
|
||||
// no meaning without a mask to paint into.
|
||||
Segmented {
|
||||
label: "Brush";
|
||||
// Said on the model's own masks especially: a subject or a
|
||||
// category arrives approximately right, and the correction
|
||||
// the photographer reaches for is a stroke where it stopped
|
||||
// short and an erase where it leaked — which becomes a part
|
||||
// of this mask, joined to the model's, and can be taken out
|
||||
// again from the list above.
|
||||
hint: (root.data.kind == "subject" || root.data.kind == "category")
|
||||
? "Correct the model's outline by hand: Paint adds to this "
|
||||
+ "mask under the pointer, Erase takes away. Each becomes "
|
||||
+ "a part above, and a stroke is one step in the history."
|
||||
: "Paint adds to this mask under the pointer; Erase takes "
|
||||
+ "away. A stroke is one step in the history.";
|
||||
options: ["Select", "Paint", "Erase"];
|
||||
selected: Masking.tool;
|
||||
picked(i) => { Masking.tool-picked(i); }
|
||||
}
|
||||
|
||||
if Masking.tool > 0: SliderRow {
|
||||
label: "Size";
|
||||
value: Masking.brush-radius;
|
||||
default-value: 0.05;
|
||||
minimum: 0.005;
|
||||
maximum: 0.4;
|
||||
precision: 3;
|
||||
changed(v) => { Masking.brush-changed(v, Masking.brush-hardness, Masking.brush-flow); }
|
||||
reset => { Masking.brush-changed(0.05, Masking.brush-hardness, Masking.brush-flow); }
|
||||
}
|
||||
|
||||
if Masking.tool > 0: SliderRow {
|
||||
label: "Hardness";
|
||||
value: Masking.brush-hardness;
|
||||
default-value: 0.5;
|
||||
minimum: 0.0;
|
||||
maximum: 1.0;
|
||||
precision: 2;
|
||||
changed(v) => { Masking.brush-changed(Masking.brush-radius, v, Masking.brush-flow); }
|
||||
reset => { Masking.brush-changed(Masking.brush-radius, 0.5, Masking.brush-flow); }
|
||||
}
|
||||
|
||||
if Masking.tool > 0: SliderRow {
|
||||
label: "Flow";
|
||||
value: Masking.brush-flow;
|
||||
default-value: 1.0;
|
||||
minimum: 0.05;
|
||||
maximum: 1.0;
|
||||
precision: 2;
|
||||
changed(v) => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, v); }
|
||||
reset => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, 1.0); }
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-19c
|
||||
// The colour this mask is shown in. Swatches rather than a picker:
|
||||
// six colours that can be told apart at half strength over a
|
||||
// photograph is the whole of the requirement, and a wheel would
|
||||
// offer a thousand that cannot. Pressing one opens the eye, since
|
||||
// choosing a colour for a mask nobody can see would be a control
|
||||
// that changes no pixel.
|
||||
VerticalLayout {
|
||||
spacing: 4px;
|
||||
Caption { text: "Shown in"; }
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
|
||||
for c[i] in Masking.mask-colours: swatch := TouchArea {
|
||||
width: Theme.control-height;
|
||||
height: Theme.control-height;
|
||||
mouse-cursor: pointer;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.colour-picked(i); }
|
||||
|
||||
Rectangle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: 9px;
|
||||
background: c;
|
||||
// The chosen one is ringed rather than enlarged,
|
||||
// so the strip does not shimmer as the choice
|
||||
// moves along it.
|
||||
border-width: (root.data.shown && root.data.colour-index == i) ? 2px : 0px;
|
||||
border-color: Theme.ink;
|
||||
opacity: swatch.has-hover || (root.data.shown && root.data.colour-index == i) ? 1.0 : 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SliderRow {
|
||||
label: "Opacity";
|
||||
value: root.data.opacity;
|
||||
@@ -589,16 +740,20 @@ export global Masking {
|
||||
/// 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.
|
||||
/// How shown masks are drawn over the photograph — tint, alpha or edge,
|
||||
/// as an index into `dr_pipeline::mask::RevealStyle::ALL`. One style for
|
||||
/// every shown mask; *which* masks are shown is each row's own eye.
|
||||
///
|
||||
/// **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;
|
||||
in property <int> mask-view-style: 0;
|
||||
/// The colours a mask may be shown in — `MASK_COLOURS` in the session,
|
||||
/// pushed rather than declared here so the swatch and the shader are
|
||||
/// reading one table.
|
||||
in property <[color]> mask-colours;
|
||||
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
|
||||
/// and flow.
|
||||
in property <float> brush-radius: 0.05;
|
||||
@@ -647,7 +802,9 @@ export global Masking {
|
||||
/// TRACES: FR-DEV-19b
|
||||
callback tool-picked(int);
|
||||
/// TRACES: FR-DEV-19c
|
||||
callback mask-view-picked(int);
|
||||
callback mask-view-style-picked(int);
|
||||
callback mask-shown-toggled(string, bool);
|
||||
callback mask-colour-picked(string, int);
|
||||
callback brush-changed(float, float, float);
|
||||
/// TRACES: FR-DEV-19a
|
||||
callback part-selected(string, int);
|
||||
@@ -688,77 +845,6 @@ export component MaskPanel inherits Rectangle {
|
||||
|
||||
if !Develop.enabled: Caption { text: "No image"; }
|
||||
|
||||
// TRACES: FR-DEV-19b
|
||||
// **In the panel and not in the tool rail.** The rail's entries arm a
|
||||
// gesture for the whole photograph; a brush has no meaning without a
|
||||
// mask to paint into, and a rail entry that lit up with nothing
|
||||
// selected would either do nothing or silently make a layer. Here the
|
||||
// strip can simply be absent until there is something to paint on.
|
||||
if Develop.enabled && Masking.parts.length > 0: Segmented {
|
||||
options: ["Select", "Paint", "Erase"];
|
||||
selected: Masking.tool;
|
||||
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;
|
||||
default-value: 0.05;
|
||||
minimum: 0.005;
|
||||
maximum: 0.4;
|
||||
precision: 3;
|
||||
changed(v) => { Masking.brush-changed(v, Masking.brush-hardness, Masking.brush-flow); }
|
||||
reset => { Masking.brush-changed(0.05, Masking.brush-hardness, Masking.brush-flow); }
|
||||
}
|
||||
|
||||
if Develop.enabled && Masking.tool > 0: SliderRow {
|
||||
label: "Hardness";
|
||||
value: Masking.brush-hardness;
|
||||
default-value: 0.5;
|
||||
minimum: 0.0;
|
||||
maximum: 1.0;
|
||||
precision: 2;
|
||||
changed(v) => { Masking.brush-changed(Masking.brush-radius, v, Masking.brush-flow); }
|
||||
reset => { Masking.brush-changed(Masking.brush-radius, 0.5, Masking.brush-flow); }
|
||||
}
|
||||
|
||||
if Develop.enabled && Masking.tool > 0: SliderRow {
|
||||
label: "Flow";
|
||||
value: Masking.brush-flow;
|
||||
default-value: 1.0;
|
||||
minimum: 0.05;
|
||||
maximum: 1.0;
|
||||
precision: 2;
|
||||
changed(v) => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, v); }
|
||||
reset => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, 1.0); }
|
||||
}
|
||||
|
||||
// --- the region map ---------------------------------------------
|
||||
//
|
||||
// Segmentation is a thing the user starts. Half a second of watershed
|
||||
@@ -805,9 +891,9 @@ export component MaskPanel inherits Rectangle {
|
||||
// **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.
|
||||
// there is a control which really does show a mask (the eye on each
|
||||
// row of the stack), 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 what was found" : "Hide what was found";
|
||||
clicked => { Masking.overlay-hidden = !Masking.overlay-hidden; }
|
||||
@@ -954,6 +1040,24 @@ export component MaskPanel inherits Rectangle {
|
||||
// --- the stack -----------------------------------------------------
|
||||
Caption { text: "Masks"; }
|
||||
|
||||
// TRACES: FR-DEV-19c
|
||||
// How every shown mask is drawn. One strip for all of them, above
|
||||
// the rows whose eyes decide which: 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.
|
||||
//
|
||||
// Three chips rather than a toggle because the three 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.
|
||||
if Develop.enabled && Masking.masks.length > 0: Segmented {
|
||||
label: "Show masks as";
|
||||
options: ["Tint", "Alpha", "Edge"];
|
||||
selected: Masking.mask-view-style;
|
||||
picked(i) => { Masking.mask-view-style-picked(i); }
|
||||
}
|
||||
|
||||
// Gradients need no segmentation, so they are offered whether or not
|
||||
// one has been computed — a graduated filter over a sky is a local
|
||||
// adjustment that never needed to know what a sky is.
|
||||
@@ -1046,6 +1150,8 @@ export component MaskPanel inherits Rectangle {
|
||||
part-join-picked(i, j) => { Masking.part-join-picked(mask.id, i, j); }
|
||||
part-removed(i) => { Masking.part-removed(mask.id, i); }
|
||||
part-added(j) => { Masking.part-added(mask.id, j); }
|
||||
shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); }
|
||||
colour-picked(i) => { Masking.mask-colour-picked(mask.id, i); }
|
||||
}
|
||||
|
||||
// No caption saying which chain the sliders below are pointed at.
|
||||
|
||||
Reference in New Issue
Block a user