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:
@@ -926,6 +926,21 @@ pub enum Join {
|
||||
/// erase stroke is a hole in the part it was painted into and reads as
|
||||
/// nothing at all.
|
||||
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 {
|
||||
@@ -933,6 +948,7 @@ impl Join {
|
||||
match self {
|
||||
Self::Union => "union",
|
||||
Self::Subtract => "subtract",
|
||||
Self::Intersect => "intersect",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,12 +956,30 @@ impl Join {
|
||||
Some(match name {
|
||||
"union" => Self::Union,
|
||||
"subtract" => Self::Subtract,
|
||||
"intersect" => Self::Intersect,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every variant, for a UI building a choice control.
|
||||
pub const ALL: [Join; 2] = [Join::Union, Join::Subtract];
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// 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.
|
||||
@@ -1580,9 +1614,20 @@ impl MaskLayer {
|
||||
// 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
|
||||
// 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()
|
||||
.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
|
||||
@@ -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,
|
||||
/// so a part that would draw a confidently wrong shape makes the result
|
||||
/// wrong whichever way it joins.
|
||||
|
||||
Reference in New Issue
Block a user