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:
@@ -551,6 +551,28 @@ pub fn compose_full(
|
||||
masks: &MaskStack,
|
||||
spots: &crate::spot::SpotSet,
|
||||
warps: &[Box<dyn crate::lens::Warp>],
|
||||
) -> ComposedShader {
|
||||
compose_full_revealing(ops, framing, output, masks, spots, warps, None)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// [`compose_full`], with one layer's mask drawn over the finished picture.
|
||||
///
|
||||
/// Separate from [`compose_full`] rather than an argument on it, and that is
|
||||
/// the safety property rather than a convenience: the reveal is a thing the
|
||||
/// screen does, and every other consumer of the pipeline — the exporter, the
|
||||
/// thumbnail, the neutral probe — calls the function that has no way to ask
|
||||
/// for it. A flag reachable from the graph would have been one forgotten reset
|
||||
/// away from a red tint baked into an exported file.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compose_full_revealing(
|
||||
ops: &[Box<dyn Operation>],
|
||||
framing: &Framing,
|
||||
output: ColourSpace,
|
||||
masks: &MaskStack,
|
||||
spots: &crate::spot::SpotSet,
|
||||
warps: &[Box<dyn crate::lens::Warp>],
|
||||
reveal: Option<&crate::mask::Reveal>,
|
||||
) -> ComposedShader {
|
||||
// The lens corrections, composed into one coordinate transform. Beside
|
||||
// `framing` because they are the other half of the same stage: framing
|
||||
@@ -715,7 +737,13 @@ pub fn compose_full(
|
||||
// from. Their uniforms follow the global ops' in the block for the same
|
||||
// reason those follow framing's — slot order is emission order, and
|
||||
// nothing addresses a slot by number.
|
||||
let layers = crate::mask::compose_layers(masks);
|
||||
let layers = crate::mask::compose_layers_revealing(masks, reveal);
|
||||
// TRACES: FR-DEV-19c
|
||||
// Held apart from the body, because it belongs after the output transform
|
||||
// rather than among the operations — see `mask::LayerShader::reveal`.
|
||||
// Empty for every composition nobody is looking at a mask through, which
|
||||
// is all of them but the screen's.
|
||||
let reveal_block = layers.reveal.clone();
|
||||
uniform_fields.push_str(&layers.uniform_fields);
|
||||
uniform_values.extend_from_slice(&layers.uniform_values);
|
||||
body.push_str(&layers.body);
|
||||
@@ -975,7 +1003,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
c = mix(c, neutral, clipped);
|
||||
}}
|
||||
{body}
|
||||
{rendering_tail}{to_output}
|
||||
{rendering_tail}{to_output}{reveal_block}
|
||||
{store}
|
||||
}}
|
||||
",
|
||||
@@ -1569,6 +1597,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// **The property that keeps a reveal off an exported file.**
|
||||
///
|
||||
/// `compose_full` is the entry point the exporter, the thumbnail and the
|
||||
/// neutral probe all use, and it has no argument that could ask for a
|
||||
/// mask overlay. Only `compose_full_revealing` does, and only the canvas
|
||||
/// calls it. Asserted rather than left to the type signature because the
|
||||
/// tempting simplification — a flag on the graph — would type-check, be
|
||||
/// shorter, and bake a red tint into every file the photographer sold.
|
||||
#[test]
|
||||
fn an_ordinary_composition_cannot_draw_a_mask_over_the_picture() {
|
||||
use crate::mask::{MaskLayer, MaskSource, Reveal, RevealStyle};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
let mut layer = MaskLayer::new("m1", MaskSource::brush());
|
||||
layer.set_param("exposure", crate::descriptor::ParamId("exposure"), 1.0);
|
||||
stack.push(layer);
|
||||
|
||||
let plain = compose_full(
|
||||
&crate::ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&stack,
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
!plain.source.contains("==== showing mask"),
|
||||
"an export must never carry the overlay"
|
||||
);
|
||||
|
||||
let shown = compose_full_revealing(
|
||||
&crate::ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&stack,
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[],
|
||||
Some(&Reveal {
|
||||
layer: "m1".into(),
|
||||
style: RevealStyle::Tint,
|
||||
}),
|
||||
);
|
||||
assert!(shown.source.contains("==== showing mask"));
|
||||
assert_ne!(
|
||||
plain.structure_hash, shown.structure_hash,
|
||||
"two different shaders must not share a pipeline cache entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// Distortion alone samples once; chromatic aberration samples three times.
|
||||
///
|
||||
/// `splits_channels` is the whole reason for this test. Lateral CA fetches
|
||||
|
||||
Reference in New Issue
Block a user