Let a part be left out of a mask without being taken out of it

A layer built from parts was missing the one control a correction most
often wants: seeing what it did. The question a subtracted gradient
raises is whether it took only the sky, and the question a stroke raises
is whether it filled the shoulder — and the only way to ask either was
to remove the part and look, which answered the question and lost the
part. The layer's own ring answers a different question, about the
adjustment, and hiding eight layers to check one correction is not an
A/B anybody performs.

So a part carries `hidden`. It is an edit and a history step, as the
layer's switch is, and it is folded into the render fingerprint because
hiding a part changes the mask as surely as removing it does. Where the
mask is built the shown parts are walked rather than the parts, which
is what makes a hidden base hand the fold to the first part that is
shown — and a revealed layer whose every part is hidden clears its slice
rather than leaving whatever the last rasterisation put there to be
read back. `covers` asks the same shown parts, so a layer whose only
adding part is hidden costs no slice at all.

In the sidecar the key is `hidden`, in the part's block or, for the
base, in the mask block — under a word that cannot be confused with the
layer's `enabled`, which has always meant the layer. Absent means shown,
so no file written before the switch existed reads any differently.

The row wears the same ring the layer does, one row down, because it is
the same question about a smaller thing.
This commit is contained in:
2026-09-12 01:08:10 +02:00
parent 9cc52fd72b
commit 4574c35236
12 changed files with 298 additions and 51 deletions
+2
View File
@@ -1034,6 +1034,8 @@ impl EditGraph {
colour = hash_bytes(colour, part.join.name().as_bytes());
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
colour = mix(colour, u64::from(part.invert));
// Hiding a part changes the fold as surely as removing it.
colour = mix(colour, u64::from(part.hidden));
colour = hash_bytes(colour, part.falloff.name().as_bytes());
for v in [part.feather, part.morph_radius] {
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
+59 -2
View File
@@ -984,6 +984,20 @@ pub struct MaskPart {
/// subtracted gradient keeps everything to one side of a line, where
/// inverting the layer keeps everything the layer did not select.
pub invert: bool,
/// TRACES: FR-DEV-19a
/// Left out of the build without being deleted.
///
/// The A/B a *part* wants is different from the layer's: the question is
/// not "what does this adjustment do" but "what did this correction do to
/// the selection" — whether the stroke that was meant to fill in a
/// shoulder did, or the subtracted gradient took the sky it was aimed at
/// and nothing else. Removing the part answers that and loses it; this
/// answers it and keeps it.
///
/// Hidden parts are skipped where the mask is built, so a hidden base
/// leaves the first shown part to open the fold — and a mask whose every
/// adding part is hidden covers nothing, exactly as if they were gone.
pub hidden: bool,
/// Half-width of the edge transition, as a fraction of the frame's
/// **shorter edge**.
@@ -1066,6 +1080,7 @@ impl MaskPart {
join,
source,
invert: false,
hidden: false,
// A small default rather than zero. A watershed boundary is exact
// to the pixel, and an adjustment that stops dead on one looks
// pasted on — the first thing anyone would reach for, so it is
@@ -1280,6 +1295,7 @@ impl PartialEq for MaskPart {
&& self.join == other.join
&& self.source == other.source
&& self.invert == other.invert
&& self.hidden == other.hidden
&& self.feather == other.feather
&& self.falloff == other.falloff
&& self.morphology == other.morphology
@@ -1555,12 +1571,23 @@ impl MaskLayer {
/// whose other parts all subtract covers nothing however many of them
/// there are, and rasterising it would cost a slice to draw empty.
fn covers(&self) -> bool {
self.parts
.iter()
// 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.
self.shown_parts()
.enumerate()
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
}
/// TRACES: FR-DEV-19a
/// The parts that take part in the build, in order — every part that is
/// not [`MaskPart::hidden`]. The first of these opens the fold whatever
/// its index in [`Self::parts`], so a renderer walks this rather than
/// filtering for itself and getting the "first" wrong.
pub fn shown_parts(&self) -> impl Iterator<Item = &MaskPart> {
self.parts.iter().filter(|p| !p.hidden)
}
/// The operations in this layer's chain that reach the shader.
///
/// Neighbourhood operations are excluded, and not as an oversight. A
@@ -2883,6 +2910,36 @@ mod tests {
// Parts
// -----------------------------------------------------------------------
/// TRACES: FR-DEV-19a
/// A hidden part is out of the build and nothing else about it moves: it
/// is still there to be put back, and the parts that remain shown are
/// what the mask is made of — including which of them is first.
#[test]
fn a_hidden_part_leaves_the_build_and_stays_in_the_layer() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.push_part(MaskPart::new("p2", Join::Union, MaskSource::highlights()));
layer.push_part(MaskPart::painted("p3", Join::Subtract));
layer.set_param("exposure", ParamId("exposure"), 0.5);
assert_eq!(layer.shown_parts().count(), 3);
assert!(layer.is_active(), "the range adds, so the mask covers");
layer.part_mut(1).expect("p2").hidden = true;
assert_eq!(layer.parts().len(), 3, "hidden is not removed");
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
assert_eq!(shown, ["p1", "p3"]);
assert!(
!layer.is_active(),
"with the range out, an unpainted base and a subtraction cover nothing"
);
layer.part_mut(1).expect("p2").hidden = false;
layer.base_mut().hidden = true;
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
assert_eq!(shown, ["p2", "p3"], "the range now opens the fold");
assert!(layer.is_active(), "and it still covers the picture");
}
/// The invariant everything else leans on: a layer always has a selection
/// to be. Removing the base is removing the layer, and the panel must not
/// be able to reach a state where a mask exists with nothing in it.
+16
View File
@@ -1121,6 +1121,13 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
if !layer.enabled {
let _ = writeln!(out, "enabled = 0");
}
// TRACES: FR-DEV-19a
// The base part's own switch, under a different word from the layer's:
// `enabled` in this block has always meant the layer, and a part that is
// out of the build is `hidden` wherever it is written, base or not.
if layer.base().hidden {
let _ = writeln!(out, "hidden = 1");
}
write_shaping(out, layer.base());
for (op, param, value) in layer.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
@@ -1148,6 +1155,9 @@ fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) {
if part.invert {
let _ = writeln!(out, "invert = 1");
}
if part.hidden {
let _ = writeln!(out, "hidden = 1");
}
write_shaping(out, part);
write_coverage(out, part);
}
@@ -1411,6 +1421,7 @@ struct PartialPart {
/// A colour range's arc: centre and half-width, in turns.
hue: (f32, f32),
invert: bool,
hidden: bool,
/// The *part's* edge transition, distinct from the radial source's own
/// `feather` above — different quantity, different units, different key.
edge_feather: f32,
@@ -1446,6 +1457,7 @@ impl PartialPart {
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
hue: (0.06, 0.05),
invert: false,
hidden: false,
edge_feather: DEFAULT_FEATHER,
falloff: Falloff::default(),
morphology: Morphology::default(),
@@ -1510,6 +1522,9 @@ impl PartialPart {
// and the file's line order is the order they were painted in.
"stroke" => self.strokes.extend(parse_stroke(value)),
"invert" => self.invert = value != "0",
// Absent means shown, so a file from before the switch existed
// reads back with every part in the build, as it was written.
"hidden" => self.hidden = value != "0",
// Clamped, not trusted: a feather wider than the frame is not a
// mask, and a negative one is a distance field read backwards.
"edge-feather" => {
@@ -1611,6 +1626,7 @@ impl PartialPart {
let mut part = MaskPart::new(self.id, self.join, source);
part.invert = self.invert;
part.hidden = self.hidden;
part.feather = self.edge_feather;
part.falloff = self.falloff;
part.morphology = self.morphology;