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
+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) ------------------------------------------
/// A radial that covers the middle of the frame and nothing near the corners.