Keep only where two selections agree, as a third way to join a mask part

A layer's parts could be added to the mask or taken out of it, and nothing
else. The selections that need composing most are the ones that are
neither: the sky that is also bright, the subject that is also skin. With
union and subtract alone, "this and that" had to be spelled as "this minus
everything that is not that", which needs a second part that selects the
complement and rarely exists.

Join gains Intersect, stored as "intersect" in the part block of a sidecar.
It is the product of the two coverages, dst * src, which is one more
fixed-function blend state beside union's max and subtract's
dst * (1 - src) (mask-editing.md 5.2): the same scratch texture, the same
three vertices, no shader arithmetic. The product equals the minimum
wherever either side is fully in or out, and is the softer reading where
two soft edges overlap. Join::apply spells the three operations on the CPU
so the GPU tests can be held to one definition.

A layer that intersects with a part covering nothing now reports that it
covers nothing, so it is not rasterised as an empty slice. Old sidecars
never contain the word, so they read as before; a build from before this
reads "intersect" as a union, the existing unknown-join fallback, which
keeps the part visible rather than dropping it. Join::ALL keeps union and
subtract at indices 0 and 1 so a stored panel index still means the same
join.
This commit is contained in:
2026-09-24 21:25:48 -04:00
parent 229def0afc
commit 8cdad3863d
7 changed files with 286 additions and 14 deletions
+13
View File
@@ -395,6 +395,7 @@ pub struct MaskPass {
combine_layout: wgpu::BindGroupLayout, combine_layout: wgpu::BindGroupLayout,
combine_union: wgpu::RenderPipeline, combine_union: wgpu::RenderPipeline,
combine_subtract: wgpu::RenderPipeline, combine_subtract: wgpu::RenderPipeline,
combine_intersect: wgpu::RenderPipeline,
/// Where a part is drawn before it is joined. /// Where a part is drawn before it is joined.
/// ///
/// One texture for the whole stack rather than one per layer, because /// One texture for the whole stack rather than one per layer, because
@@ -651,6 +652,16 @@ impl MaskPass {
"mask-combine-subtract", "mask-combine-subtract",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc), blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
); );
// TRACES: FR-DEV-19a
// `dst * src`: what the mask had, kept only in proportion to how much
// of it this part also covers. The same three vertices and the same
// scratch, so a third set operation is a third blend state and
// nothing more — which is what `Join::apply` states on the CPU and
// `the_joins_match_their_definition` holds this to.
let combine_intersect = combine(
"mask-combine-intersect",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::Src),
);
// The same, with the deposit thrown away: coverage is only ever taken // The same, with the deposit thrown away: coverage is only ever taken
// off what earlier strokes on this layer put down. There is no negative // off what earlier strokes on this layer put down. There is no negative
@@ -681,6 +692,7 @@ impl MaskPass {
combine_layout, combine_layout,
combine_union, combine_union,
combine_subtract, combine_subtract,
combine_intersect,
scratch: None, scratch: None,
array: None, array: None,
allocations: 0, allocations: 0,
@@ -1376,6 +1388,7 @@ impl MaskPass {
pass.set_pipeline(match join { pass.set_pipeline(match join {
Join::Union => &self.combine_union, Join::Union => &self.combine_union,
Join::Subtract => &self.combine_subtract, Join::Subtract => &self.combine_subtract,
Join::Intersect => &self.combine_intersect,
}); });
pass.set_bind_group(0, &bind_group, &[]); pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1); pass.draw(0..3, 0..1);
+113
View File
@@ -790,6 +790,119 @@ fn the_order_parts_are_joined_in_is_the_mask() {
); );
} }
/// TRACES: FR-DEV-19a
/// The truth table `docs/dev/mask-editing.md` §13 asks for: one base, one
/// part that half-covers it, joined each of the three ways. The base is the
/// left half of the frame and the part a dab in the middle, so the four
/// quarters of the table are four pixels.
#[test]
fn a_part_unioned_subtracted_and_intersected_gives_the_three_fields() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let joined = |join: Join| {
let mut layer = brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
});
assert!(layer.push_part(MaskPart::painted("p2", join)));
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
let mut stack = MaskStack::new();
stack.push(layer);
render(&ctx, &stack, Some(&field))
};
// (base, part): left outside the dab, left inside, right inside, right
// outside.
let cells = [(4, 16), (14, 16), (18, 16), (27, 16)];
let lit = |pixels: &[u8]| cells.map(|(x, y)| luma_at(pixels, x, y) > 200);
assert_eq!(
lit(&joined(Join::Union)),
[true, true, true, false],
"union: either"
);
assert_eq!(
lit(&joined(Join::Subtract)),
[true, false, false, false],
"subtract: the base without the dab"
);
assert_eq!(
lit(&joined(Join::Intersect)),
[false, true, false, false],
"intersect: only where both are"
);
}
/// TRACES: FR-DEV-19a
/// Intersection on soft coverage is the product `Join::apply` defines, on
/// either side of the join: a gradient intersected with a region it fills is
/// the gradient there and nothing elsewhere, and a region intersected with a
/// gradient is the gradient wherever the region is.
#[test]
fn the_joins_match_their_definition() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let ramp = || MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 1.0,
};
let right_half = || MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![1],
};
let draw = |layer: MaskLayer| {
let mut stack = MaskStack::new();
stack.push(layer);
render(&ctx, &stack, Some(&field))
};
let alone = draw(brighten(ramp()));
let mut ramp_then_region = brighten(ramp());
assert!(ramp_then_region.push_part(MaskPart::new("p2", Join::Intersect, right_half())));
let ramp_then_region = draw(ramp_then_region);
let mut region_then_ramp = brighten(whole_frame());
assert!(region_then_ramp.push_part(MaskPart::new("p2", Join::Intersect, ramp())));
let region_then_ramp = draw(region_then_ramp);
for x in 0..SIZE {
let y = SIZE / 2;
let want = luma_at(&alone, x, y);
// dst · 1 = dst on the right; dst · 0 = 0 on the left. Pixels
// within two of the seam are left out: the region's own edge
// is soft there, so neither side of the table is 0 or 1.
let got = luma_at(&ramp_then_region, x, y);
if x.abs_diff(SIZE / 2) <= 2 {
// The seam.
} else if x > SIZE / 2 {
assert!(
got.abs_diff(want) <= 1,
"x={x}: the ramp survives where the region is ({got} vs {want})"
);
} else {
assert_eq!(got, 128, "x={x}: and nothing survives where it is not");
}
// 1 · src = src everywhere.
let got = luma_at(&region_then_ramp, x, y);
assert!(
got.abs_diff(want) <= 1,
"x={x}: a full base intersected with the ramp is the ramp ({got} vs {want})"
);
}
}
// --- seeing the mask (FR-DEV-19c) ------------------------------------------ // --- seeing the mask (FR-DEV-19c) ------------------------------------------
/// A radial that covers the middle of the frame and nothing near the corners. /// A radial that covers the middle of the frame and nothing near the corners.
+111 -3
View File
@@ -926,6 +926,21 @@ pub enum Join {
/// erase stroke is a hole in the part it was painted into and reads as /// erase stroke is a hole in the part it was painted into and reads as
/// nothing at all. /// nothing at all.
Subtract, Subtract,
/// TRACES: FR-DEV-19a
/// Only where both agree. "Keep the part of this mask that is also that."
///
/// The join that makes cheap criteria precise: a sky is a category *and*
/// a luminance band, skin is a subject *and* a hue. Neither alone is the
/// selection, and no feather on either makes it one.
///
/// The product of the two coverages rather than their minimum, because
/// that is what one fixed-function blend gives on the device
/// (`dst · src`, `docs/dev/mask-editing.md` §5.2) and it agrees with the
/// minimum wherever either side is fully in or fully out. Between two soft
/// edges it is the softer of the two readings, which is the right way to
/// be wrong: an overlap of two partial selections is less certainly
/// selected than either.
Intersect,
} }
impl Join { impl Join {
@@ -933,6 +948,7 @@ impl Join {
match self { match self {
Self::Union => "union", Self::Union => "union",
Self::Subtract => "subtract", Self::Subtract => "subtract",
Self::Intersect => "intersect",
} }
} }
@@ -940,12 +956,30 @@ impl Join {
Some(match name { Some(match name {
"union" => Self::Union, "union" => Self::Union,
"subtract" => Self::Subtract, "subtract" => Self::Subtract,
"intersect" => Self::Intersect,
_ => return None, _ => return None,
}) })
} }
/// Every variant, for a UI building a choice control. /// TRACES: FR-DEV-19a
pub const ALL: [Join; 2] = [Join::Union, Join::Subtract]; /// The coverage this join leaves at one point, given what the mask had
/// there (`dst`) and what the part covers (`src`), both in `0..=1`.
///
/// The definition the device's blend states implement, spelled out once
/// on the CPU so a test can hold the GPU to it and a reader can see the
/// three set operations side by side without reading `wgpu` enums.
pub fn apply(self, dst: f32, src: f32) -> f32 {
match self {
Self::Union => dst.max(src),
Self::Subtract => dst * (1.0 - src),
Self::Intersect => dst * src,
}
}
/// Every variant, for a UI building a choice control. The order is the
/// panel's: a chip cycles through it and a stored index names a place in
/// it, so a new join goes on the end.
pub const ALL: [Join; 3] = [Join::Union, Join::Subtract, Join::Intersect];
} }
/// One selection inside a layer's mask. /// One selection inside a layer's mask.
@@ -1580,9 +1614,20 @@ impl MaskLayer {
// A hidden part is not in the build, whichever way it joins — and a // A hidden part is not in the build, whichever way it joins — and a
// hidden base hands its role to the first part that is shown, which // hidden base hands its role to the first part that is shown, which
// is why "adds" is asked of the shown parts rather than of index 0. // is why "adds" is asked of the shown parts rather than of index 0.
//
// Folded rather than asked with `any`, because an intersection can
// take away everything the parts before it added: a subject
// intersected with an unpainted brush covers nothing. An inverted
// part is taken to cover, whatever its source — an inverted empty
// brush is the whole frame, and saying "covers nothing" of a layer
// that does would hide an adjustment the photographer made.
self.shown_parts() self.shown_parts()
.enumerate() .enumerate()
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers()) .fold(false, |acc, (i, p)| match (i, p.join) {
(0, _) | (_, Join::Union) => acc || p.covers(),
(_, Join::Subtract) => acc,
(_, Join::Intersect) => acc && (p.covers() || p.invert),
})
} }
/// TRACES: FR-DEV-19a /// TRACES: FR-DEV-19a
@@ -3037,6 +3082,69 @@ mod tests {
); );
} }
/// TRACES: FR-DEV-19a
/// The three joins, pointwise, on every pair of coverages a part and a
/// mask can meet at: fully in, fully out, and each soft edge. Intersection
/// is the product, which agrees with the minimum wherever either side is
/// decided and is the softer reading where both are not.
#[test]
fn the_joins_are_max_cut_and_product() {
let levels = [0.0f32, 0.25, 0.5, 0.75, 1.0];
for &dst in &levels {
for &src in &levels {
assert_eq!(Join::Union.apply(dst, src), dst.max(src));
assert_eq!(Join::Subtract.apply(dst, src), dst * (1.0 - src));
let meet = Join::Intersect.apply(dst, src);
assert_eq!(meet, dst * src);
assert!(meet <= dst.min(src), "never more than either side");
if dst == 0.0 || dst == 1.0 || src == 0.0 || src == 1.0 {
assert_eq!(meet, dst.min(src), "the minimum where either is decided");
}
}
}
}
/// A stored index names a place in [`Join::ALL`], and a sidecar names a
/// join by word: both have to survive a third join arriving, which means
/// the first two keep their places and every name reads back as itself.
#[test]
fn every_join_reads_back_by_name_and_keeps_its_place() {
assert_eq!(Join::ALL[0], Join::Union);
assert_eq!(Join::ALL[1], Join::Subtract);
for join in Join::ALL {
assert_eq!(Join::from_name(join.name()), Some(join));
}
assert_eq!(Join::from_name("intersect"), Some(Join::Intersect));
}
/// TRACES: FR-DEV-19a
/// An intersection can empty a mask the parts before it filled: a range
/// meeting an unpainted brush selects nothing, and rasterising it would
/// spend a slice to draw an empty field. Painting the brush, or inverting
/// it, gives the intersection something to keep.
#[test]
fn an_intersection_with_nothing_covers_nothing() {
let mut layer = MaskLayer::new("m1", MaskSource::highlights());
layer.set_param("exposure", ParamId("exposure"), 1.0);
layer.push_part(MaskPart::painted("p2", Join::Intersect));
assert!(
!layer.is_active(),
"a range intersected with an unpainted brush selects nothing"
);
layer.part_mut(1).expect("p2").invert = true;
assert!(layer.is_active(), "inverted, the empty brush is everywhere");
layer.part_mut(1).expect("p2").invert = false;
layer.begin_stroke(1, false, 0.1, 0.5, 1.0);
layer.extend_stroke(1, 0.5, 0.5);
layer.end_stroke(1);
assert!(layer.is_active(), "and painted, it keeps what it covers");
layer.part_mut(1).expect("p2").hidden = true;
assert!(layer.is_active(), "hidden, it is out of the build");
}
/// One stale part is a stale layer: the mask is the fold over all of them, /// One stale part is a stale layer: the mask is the fold over all of them,
/// so a part that would draw a confidently wrong shape makes the result /// so a part that would draw a confidently wrong shape makes the result
/// wrong whichever way it joins. /// wrong whichever way it joins.
+22
View File
@@ -1344,6 +1344,28 @@ fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
); );
} }
/// TRACES: FR-DEV-19a
/// An intersecting part is written under its own word and reads back as an
/// intersection. A build from before intersection read that word as a union
/// (see `an_unknown_join_adds_the_part`), which keeps the part visible and
/// fixable rather than silently cutting the mask down.
#[test]
fn an_intersecting_part_survives_a_round_trip() {
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(corrected("m1", Join::Intersect));
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let text = sidecar.to_text();
assert!(text.contains("join = intersect"), "{text}");
let restored = round_trip(&graph);
let layer = &restored.masks().layers()[0];
assert_eq!(layer.parts().len(), 2);
assert_eq!(layer.parts()[1].join, Join::Intersect);
assert_eq!(layer.parts()[0].join, Join::Union, "the base is untouched");
}
/// TRACES: FR-DEV-19a /// TRACES: FR-DEV-19a
/// A part left out of the build comes back left out, and the file says so /// A part left out of the build comes back left out, and the file says so
/// under a word that cannot be confused with the layer's own switch. /// under a word that cannot be confused with the layer's own switch.
File diff suppressed because one or more lines are too long
+13 -4
View File
@@ -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. 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.
47 gestures, in 4 places. 48 gestures, in 4 places.
## Develop ## Develop
@@ -189,7 +189,7 @@ The history is forgotten with the sitting, on purpose; a snapshot is the photogr
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 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
<sub>`ui/dr-ui/ui/masks.slint:212`</sub> <sub>`ui/dr-ui/ui/masks.slint:213`</sub>
### Show or hide one mask on the photograph ### Show or hide one mask on the photograph
@@ -198,7 +198,16 @@ Disabling a layer is the before-and-after a local edit constantly wants, so it i
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. 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:279`</sub> <sub>`ui/dr-ui/ui/masks.slint:280`</sub>
### Change how a part joins its mask
- **Touch** — Tap the + / − / ∩ chip on the part's row
- **Pointer** — Click the + / − / ∩ chip on the part's row
A chip that cycles rather than a menu, because a photographer flips a join while looking at the picture, not at the panel: add, take away, keep only where both agree, and round.
<sub>`ui/dr-ui/ui/masks.slint:366`</sub>
### Leave one part out of a mask, and put it back ### Leave one part out of a mask, and put it back
@@ -207,7 +216,7 @@ A mask is judged by seeing where it falls, and two are judged by seeing where th
The question a correction raises is whether it did what it was for — whether the stroke filled the shoulder, whether the subtracted gradient took only the sky. Removing it answers that and loses it. The same ring the layer wears, one row down, because it is the same question about a smaller thing. The question a correction raises is whether it did what it was for — whether the stroke filled the shoulder, whether the subtracted gradient took only the sky. Removing it answers that and loses it. The same ring the layer wears, one row down, because it is the same question about a smaller thing.
<sub>`ui/dr-ui/ui/masks.slint:394`</sub> <sub>`ui/dr-ui/ui/masks.slint:405`</sub>
## Collections sidebar ## Collections sidebar
+7
View File
@@ -162,6 +162,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Click the eye on its row", pointer: "Click the eye on its row",
keys: "", keys: "",
}, },
Gesture {
title: "Change how a part joins its mask",
section: "Develop",
touch: "Tap the + / − / ∩ chip on the part's row",
pointer: "Click the + / − / ∩ chip on the part's row",
keys: "",
},
Gesture { Gesture {
title: "Leave one part out of a mask, and put it back", title: "Leave one part out of a mask, and put it back",
section: "Develop", section: "Develop",