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
+153 -3
View File
@@ -2078,6 +2078,12 @@ impl DevelopSession {
// resizes, and does not need redrawing when the view moves.
let (pw, ph) = self.mask_raster_size();
let subjects = self.subjects.as_ref();
// TRACES: FR-DEV-10
// A refcount, taken before the rasteriser is borrowed mutably. A range
// layer measures the photograph itself, so the pass needs the source
// as well as the stack — and it is the same texture every other pass
// reads, not a copy made for masking.
let source = self.demosaiced.clone();
// **Built here, not in `segment`.** A gradient needs no segmentation —
// a graduated filter over a sky never had to know what a sky is — but
@@ -2104,9 +2110,16 @@ impl DevelopSession {
// No label field: region masks were the watershed's, and nothing
// produces one any more. A stored layer that still names regions is
// skipped by the rasteriser rather than drawn wrong.
pass.render(self.graph.masks(), None, subjects, pw, ph)
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
.is_ok()
pass.render(
self.graph.masks(),
None,
subjects,
Some(source.as_ref()),
pw,
ph,
)
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
.is_ok()
}
/// The size the mask array is rasterised at, in source space.
@@ -2920,6 +2933,135 @@ impl DevelopSession {
Some(id)
}
/// TRACES: FR-DEV-10
/// Add a mask that selects by a range of the photograph's own values.
///
/// Needs no segmentation, like a gradient, and for a stronger reason: a
/// band is a question about the picture rather than about what is in it.
/// `chromatic` picks the colour range over the brightness one.
pub fn add_range_mask(&mut self, chromatic: bool) -> Option<String> {
let id = self.graph.masks().next_id();
let source = if chromatic {
MaskSource::skin_tones()
} else {
MaskSource::highlights()
};
if !self
.graph
.masks_mut()
.push(MaskLayer::new(id.clone(), source))
{
return None;
}
self.active_masks = vec![id.clone()];
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
}
/// TRACES: FR-DEV-10
/// Move a range layer's band — its two bounds and the fade at each edge.
///
/// All three at once, because they are one control: dragging the lower
/// bound past the upper swaps them (see `MaskSource::luminance_range`),
/// and a setter per field would have to decide that question three times
/// with only a third of the answer each time.
///
/// A no-op on a layer that is not a range, rather than a panic: the panel
/// asks first, and a callback that arrives against a layer the user has
/// since replaced is ordinary rather than a fault.
pub fn set_mask_band(&mut self, id: &str, lo: f32, hi: f32, softness: f32) {
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
// Built into a temporary first: the arc is read out of the layer and
// the whole source is written back over it, and the two cannot be the
// same statement.
let rebuilt = match &layer.source {
MaskSource::Luminance { .. } => MaskSource::luminance_range(lo, hi, softness),
MaskSource::Colour { hue, hue_width, .. } => {
MaskSource::colour_range(*hue, *hue_width, lo, hi, softness)
}
_ => return,
};
layer.source = rebuilt;
// `Control`, not `Action`: a band is dragged, and a drag is one
// decision however many values it passes through.
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_RANGE));
}
/// TRACES: FR-DEV-10
/// Move a colour range's arc — its centre hue and its half-width.
pub fn set_mask_hue(&mut self, id: &str, hue: f32, width: f32) {
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
// A temporary, for the reason `set_mask_band` gives.
let rebuilt = match &layer.source {
MaskSource::Colour {
chroma_lo,
chroma_hi,
softness,
..
} => MaskSource::colour_range(hue, width, *chroma_lo, *chroma_hi, *softness),
// Only a colour range has an arc. A brightness one reaching here
// is a stale callback against a layer the user has replaced,
// which is ordinary rather than a fault.
_ => return,
};
layer.source = rebuilt;
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_RANGE));
}
/// TRACES: FR-DEV-10
/// Whether the band controls apply to this layer.
pub fn mask_is_ranged(&self, id: &str) -> bool {
self.graph
.masks()
.get(id)
.is_some_and(|l| l.source.is_range())
}
/// TRACES: FR-DEV-10
/// Whether this layer's band is over colour rather than over brightness.
pub fn mask_is_chromatic(&self, id: &str) -> bool {
self.graph
.masks()
.get(id)
.is_some_and(|l| matches!(l.source, MaskSource::Colour { .. }))
}
/// TRACES: FR-DEV-10
/// This layer's band: lower bound, upper bound, softness.
///
/// Tone positions for a luminance layer and chroma for a colour one —
/// the same two questions asked of different measurements, which is why
/// one panel control drives both. Zeroes for a layer that has no band,
/// which the panel never draws.
pub fn mask_band(&self, id: &str) -> (f32, f32, f32) {
match self.graph.masks().get(id).map(|l| &l.source) {
Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness),
Some(MaskSource::Colour {
chroma_lo,
chroma_hi,
softness,
..
}) => (*chroma_lo, *chroma_hi, *softness),
_ => (0.0, 0.0, 0.0),
}
}
/// TRACES: FR-DEV-10
/// A colour range's arc: centre hue and half-width, both in turns.
pub fn mask_hue(&self, id: &str) -> (f32, f32) {
match self.graph.masks().get(id).map(|l| &l.source) {
Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width),
_ => (0.0, 0.0),
}
}
pub fn remove_mask(&mut self, id: &str) {
if self.graph.masks_mut().remove(id).is_some() {
self.active_masks.retain(|a| a != id);
@@ -3067,6 +3209,14 @@ impl DevelopSession {
/// Only sources that go through the distance field. A gradient carries its
/// own falloff in its geometry, so offering a second one would be two
/// controls fighting over the same edge.
///
/// A range (FR-DEV-10) is excluded on stronger grounds than the gradient.
/// Feather, falloff and morphology are every one of them a function of the
/// *signed distance from a boundary*, and a range mask has no boundary: it
/// is a weighting over the photograph's values, soft everywhere the values
/// are, sharp everywhere they are. There is no outline to grow, shrink or
/// cross. Its edge is the softness of its own band, which is in the band's
/// units rather than in pixels — see `MaskSource::Luminance`.
pub fn mask_is_shapeable(&self, id: &str) -> bool {
use dr_pipeline::mask::MaskSource;
self.graph.masks().get(id).is_some_and(|l| {
+5
View File
@@ -49,6 +49,9 @@ pub mod step {
pub const MASK_FALLOFF: LocalizedKey = LocalizedKey("history.mask_falloff");
pub const MASK_MORPHOLOGY: LocalizedKey = LocalizedKey("history.mask_morphology");
pub const MASK_REFINE: LocalizedKey = LocalizedKey("history.mask_refine");
/// TRACES: FR-DEV-10
/// A range mask's band moved — its bounds, its softness, or its hue arc.
pub const MASK_RANGE: LocalizedKey = LocalizedKey("history.mask_range");
/// TRACES: FR-DEV-8
/// A repair's own settings changed — its radius, its source, its opacity.
@@ -92,6 +95,7 @@ pub mod step {
MASK_FALLOFF,
MASK_MORPHOLOGY,
MASK_REFINE,
MASK_RANGE,
SPOT,
SPOT_PLACED,
SPOT_MOVED,
@@ -270,6 +274,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
"history.mask_falloff" => "Mask Falloff",
"history.mask_morphology" => "Mask Grow/Shrink",
"history.mask_refine" => "Mask Refine",
"history.mask_range" => "Mask Range",
"history.spot" => "Adjust Repair",
"history.spot_placed" => "Add Repair",
"history.spot_moved" => "Move Repair",
+84 -17
View File
@@ -178,23 +178,37 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
let rows: Vec<MaskRow> = s
.mask_layers()
.into_iter()
.map(|(id, label, enabled, selected)| MaskRow {
stale: s.mask_is_stale(&id),
adjusted: s.mask_is_adjusted(&id),
kind: s.mask_kind(&id).into(),
inverted: s.mask_inverted(&id),
opacity: s.mask_opacity(&id),
feather: s.mask_feather(&id),
falloff: s.mask_falloff(&id) as i32,
morphology: s.mask_morphology(&id) as i32,
morph_radius: s.mask_morph_radius(&id),
refine: s.mask_refine(&id),
refinable: s.mask_is_refinable(&id),
shapeable: s.mask_is_shapeable(&id),
id: id.into(),
label: label.into(),
enabled,
selected,
.map(|(id, label, enabled, selected)| {
// Read once each: both answer with a tuple, and asking three
// times for three fields of one would walk the stack three times
// per row per sync.
let (band_lo, band_hi, band_softness) = s.mask_band(&id);
let (hue, hue_width) = s.mask_hue(&id);
MaskRow {
stale: s.mask_is_stale(&id),
adjusted: s.mask_is_adjusted(&id),
kind: s.mask_kind(&id).into(),
inverted: s.mask_inverted(&id),
opacity: s.mask_opacity(&id),
feather: s.mask_feather(&id),
falloff: s.mask_falloff(&id) as i32,
morphology: s.mask_morphology(&id) as i32,
morph_radius: s.mask_morph_radius(&id),
refine: s.mask_refine(&id),
refinable: s.mask_is_refinable(&id),
shapeable: s.mask_is_shapeable(&id),
ranged: s.mask_is_ranged(&id),
chromatic: s.mask_is_chromatic(&id),
band_lo,
band_hi,
band_softness,
hue,
hue_width,
id: id.into(),
label: label.into(),
enabled,
selected,
}
})
.collect();
window.set_mask_rows(ModelRc::new(VecModel::from(rows)));
@@ -611,6 +625,43 @@ pub(crate) fn wire(
redraw(&w);
});
}
// TRACES: FR-DEV-10
// A range mask's band, and a colour range's arc.
//
// Two handlers rather than five, because a band is one control: moving its
// lower bound past its upper swaps them, and a callback per field would
// have to answer that with a third of the answer. Each carries the whole
// of the thing it changes and the model puts it back in order.
//
// Cheap, unlike the refine below: a band is three floats in a uniform, so
// the mask redraws in the rasterisation the redraw was going to run
// anyway and nothing is rebuilt on the CPU at all.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_band_changed(move |id, lo, hi, softness| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_band(&id, lo, hi, softness);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_hue_changed(move |id, hue, width| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_hue(&id, hue, width);
}
sync(&w, &session);
redraw(&w);
});
}
// The refine control. Same shape as the feather's handler, and a separate
// one for the same reason every other mask control has its own: the
// callback carries the layer id, so there is nothing to share.
@@ -687,6 +738,22 @@ pub(crate) fn wire(
redraw(&w);
});
}
// TRACES: FR-DEV-10
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_add_range_mask(move |chromatic| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.add_range_mask(chromatic);
}
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();