Let a mask start from a tone or a colour, not only a shape

Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.

MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.

The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.

The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.

Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
This commit is contained in:
2026-09-06 19:01:48 +02:00
parent 81b1ae8c42
commit 68ebf5d78b
16 changed files with 1555 additions and 86 deletions
+90 -3
View File
@@ -1129,6 +1129,46 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
let _ = writeln!(out, "feather = {}", format_value(*feather));
}
MaskSource::Brush { strokes } => write_strokes(out, strokes),
// TRACES: FR-DEV-10
// `band` rather than a key per edge, and the same key for both range
// sources. The two bounds and their softness are one quantity to a
// reader — "the band this mask selects" — and splitting them over
// three lines makes a hand edit that moves one edge and forgets the
// other look like a file that was written that way.
MaskSource::Luminance { lo, hi, softness } => {
let _ = writeln!(
out,
"band = {} {} {}",
format_value(*lo),
format_value(*hi),
format_value(*softness)
);
}
MaskSource::Colour {
hue,
hue_width,
chroma_lo,
chroma_hi,
softness,
} => {
// The chroma half goes out as `band` so that the two range
// sources share a key for the same idea, and the arc gets its
// own: a hue is a position on a circle and its neighbours are not
// bounds in the sense the chroma pair are.
let _ = writeln!(
out,
"hue = {} {}",
format_value(*hue),
format_value(*hue_width)
);
let _ = writeln!(
out,
"band = {} {} {}",
format_value(*chroma_lo),
format_value(*chroma_hi),
format_value(*softness)
);
}
}
if layer.invert {
@@ -1277,6 +1317,16 @@ struct PartialMask {
angle: f32,
width: f32,
feather: f32,
/// TRACES: FR-DEV-10
/// A range source's band: its two bounds and the fade at each edge.
///
/// One triple for both range kinds — tone positions for a luminance
/// layer, chroma for a colour one — because they are the same line in the
/// file and reading them into two sets of fields would be two ways to
/// spell one thing.
band: (f32, f32, f32),
/// A colour range's arc: centre and half-width, in turns.
hue: (f32, f32),
invert: bool,
opacity: f32,
enabled: bool,
@@ -1311,6 +1361,11 @@ impl PartialMask {
angle: 0.0,
width: 0.0,
feather: 0.0,
// The defaults a new layer gets, so a block that names a range
// source and nothing else reads back as the mask the panel would
// have made rather than as an empty band selecting nothing.
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
hue: (0.06, 0.05),
invert: false,
opacity: 1.0,
enabled: true,
@@ -1356,6 +1411,12 @@ impl PartialMask {
"angle" => self.angle = value.parse().unwrap_or(0.0),
"width" => self.width = value.parse().unwrap_or(0.0),
"feather" => self.feather = value.parse().unwrap_or(0.0),
// TRACES: FR-DEV-10
// Kept as written and ordered later, in `MaskSource::*_range`.
// Clamping here as well would be a second place the band's
// invariants live, and the two would drift.
"band" => self.band = triple(value).unwrap_or(self.band),
"hue" => self.hue = pair(value).unwrap_or(self.hue),
// A cache, so an unreadable one is dropped rather than refused:
// the layer still says what it selects, and the worst a `None`
// here costs is a model run. Refusing the layer over it would
@@ -1454,6 +1515,18 @@ impl PartialMask {
"brush" => MaskSource::Brush {
strokes: self.strokes,
},
// TRACES: FR-DEV-10
// Through the constructors, not built by hand: they are where the
// band's invariants are kept, and a file is exactly the input
// that has not been through them.
"luminance" => MaskSource::luminance_range(self.band.0, self.band.1, self.band.2),
"colour" => MaskSource::colour_range(
self.hue.0,
self.hue.1,
self.band.0,
self.band.1,
self.band.2,
),
other => {
log::warn!(
"sidecar: unknown mask source '{other}'; skipping layer {}",
@@ -1517,9 +1590,23 @@ impl PartialMask {
/// Two whitespace-separated floats.
fn pair(value: &str) -> Option<(f32, f32)> {
let mut it = value.split_whitespace();
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
Some((a, b))
let a: f32 = it.next()?.parse().ok()?;
let b: f32 = it.next()?.parse().ok()?;
// A NaN parses — `"nan"` is a valid float — and then survives every clamp
// downstream, because every comparison against it is false. It reaches a
// uniform, and a mask whose geometry or band is NaN selects nothing while
// looking, in the file and in the panel, exactly like one that should.
(a.is_finite() && b.is_finite()).then_some((a, b))
}
/// TRACES: FR-DEV-10
/// Three whitespace-separated floats — a range mask's band.
fn triple(value: &str) -> Option<(f32, f32, f32)> {
let mut it = value.split_whitespace();
let a: f32 = it.next()?.parse().ok()?;
let b: f32 = it.next()?.parse().ok()?;
let c: f32 = it.next()?.parse().ok()?;
(a.is_finite() && b.is_finite() && c.is_finite()).then_some((a, b, c))
}
/// Format a value without a trailing `.0` on whole numbers, and without