Let one mask be built from more than one selection, and paint into it

A mask the model draws arrives approximately right — stopping inside a
shoulder, leaking into the hair — and FR-DEV-3's edge controls move the
*whole* boundary, so no value of feather or dilation fixes two errors that
go opposite ways. What fixes them is a second selection joined to the first,
and a layer that held exactly one source had nowhere to put one. The brush
the core has had all along was reachable from no control in the application.

A layer is now an ordered list of parts. Each names a source and how it
joins the mask before it — added to it, or taken out of it — and carries its
own edge treatment, because a model's soft coverage and a stroke painted
where it stopped short do not want the same feather. Invert and opacity stay
on the layer, where the composed shader already reads them.

The sidecar grows `[part]` blocks and nothing else. A layer of one part
writes exactly the bytes it always did; a mask block with no part blocks
after it reads back as one part; and a stroke, a join or a source this build
cannot read costs that part rather than the layer. So every sidecar in every
library still parses to the edit it always was.

On the device the parts fold into the layer's one slice, so eight layers
still cost eight channels: union is a `max` blend and subtraction is the
erase blend the brush already used. A part is drawn into a scratch texture
before it is joined, and that is not incidental — an erase stroke means a
hole in *that part*, not a hole in the mask, and drawn straight onto the
accumulator it would punch through the subject underneath. A layer of one
part skips all of it and takes the path it always took.

In the interface: a part list under the selected layer with a chip saying
which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip
with the brush's size, hardness and flow, and a drag on the photograph that
paints. Pressing Paint on a mask that cannot hold a stroke joins a part that
can, rather than explaining that a subject is not a brush. A whole stroke is
one step in the history.

The edge controls now shape the part that is selected rather than the layer,
which is the one behaviour change to an existing control: with a correction
selected, the feather slider softens the correction and leaves the model's
mask alone.
This commit is contained in:
2026-09-07 20:00:40 +02:00
parent 9ede23073d
commit df741a8a49
20 changed files with 2998 additions and 654 deletions
+281 -85
View File
@@ -69,7 +69,9 @@ use std::fmt::Write as _;
use crate::coverage::Coverage;
use crate::graph::EditGraph;
use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER};
use crate::mask::{
Falloff, Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER,
};
use crate::preset::Preset;
use crate::spot::{Spot, SpotMode, SpotSet};
use crate::state::{EditState, FilmRebake};
@@ -833,6 +835,10 @@ impl Sidecar {
// then simply dropped instead of corrupting whichever version
// happened to be open.
let mut mask: Option<PartialMask> = None;
// Whether the `[part]` block being read names a mask that is not the
// one above it. Its keys are dropped rather than falling through to
// the version, where they would be read as somebody's global edit.
let mut orphan_part = false;
let mut masks: Vec<(String, MaskLayer)> = Vec::new();
for raw in lines {
@@ -846,6 +852,7 @@ impl Sidecar {
.and_then(|s| s.strip_suffix(']'))
{
masks.extend(mask.take().and_then(PartialMask::finish));
orphan_part = false;
if let Some(v) = current.take() {
sidecar.put(v);
}
@@ -861,6 +868,7 @@ impl Sidecar {
.and_then(|s| s.strip_suffix(']'))
{
masks.extend(mask.take().and_then(PartialMask::finish));
orphan_part = false;
match head.split_once(char::is_whitespace) {
Some((version, id)) => {
mask = Some(PartialMask::new(version.trim(), id.trim()));
@@ -874,6 +882,36 @@ impl Sidecar {
continue;
}
// A part of the mask block above this one. **Three names**, and
// the two it repeats are checked rather than assumed: a block
// reordered by a hand edit or by a merge would otherwise attach
// somebody's correction to whichever mask happened to precede it,
// which is a wrong mask rather than a missing one.
if let Some(head) = line
.strip_prefix("[part ")
.and_then(|s| s.strip_suffix(']'))
{
let mut names = head.split_whitespace();
orphan_part = true;
if let (Some(version), Some(layer), Some(part)) =
(names.next(), names.next(), names.next())
{
match mask.as_mut() {
Some(m) if m.version == version && m.id == layer => {
m.begin_part(part);
orphan_part = false;
}
_ => log::warn!(
"sidecar: part '{part}' names mask {layer} of version {version}, \
which is not the block it follows; ignoring it"
),
}
} else {
log::warn!("sidecar: malformed part header '{line}'; ignoring");
}
continue;
}
let Some((key, value)) = line.split_once('=') else {
// Not a key-value line and not a block header. Keep it so a
// newer format's construct survives a round trip here.
@@ -887,6 +925,10 @@ impl Sidecar {
};
let (key, value) = (key.trim(), value.trim());
if orphan_part {
continue;
}
if let Some(m) = mask.as_mut() {
m.set(key, value);
continue;
@@ -1062,9 +1104,59 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
if !layer.name.is_empty() {
let _ = writeln!(out, "name = {}", layer.name);
}
let _ = writeln!(out, "source = {}", layer.source.kind());
match &layer.source {
// **The base part is written into the layer's own block**, without a name
// and without a join, and that is what makes this format change no file
// that does not use it: a layer of one part is the same bytes it always
// was, and a block with no `[part]` after it reads back as one part
// (see [`PartialMask::finish`]).
write_source(out, layer.base());
if layer.invert {
let _ = writeln!(out, "invert = 1");
}
if layer.opacity != 1.0 {
let _ = writeln!(out, "opacity = {}", format_value(layer.opacity));
}
if !layer.enabled {
let _ = writeln!(out, "enabled = 0");
}
write_shaping(out, layer.base());
for (op, param, value) in layer.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
}
write_coverage(out, layer.base());
for part in &layer.parts()[1..] {
write_part(out, version, &layer.id, part);
}
}
/// Write one part of a layer's mask as its own block.
///
/// Its own block rather than a nested key, because a merge compares lines and
/// a part is the granularity a photographer edits: adding a correction to a
/// mask should read, in a diff, as a correction added — not as the whole mask
/// having been rewritten. The version and the layer are both named in the
/// header for the reason [`write_mask`] names the version: "belongs to
/// whatever appeared above me" is a relationship that does not survive a hand
/// edit or a merge.
fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) {
let _ = write!(out, "\n[part {version} {layer} {}]\n", part.id);
let _ = writeln!(out, "join = {}", part.join.name());
write_source(out, part);
if part.invert {
let _ = writeln!(out, "invert = 1");
}
write_shaping(out, part);
write_coverage(out, part);
}
/// The `source = …` line and whatever else that kind of source needs.
fn write_source(out: &mut String, part: &MaskPart) {
let _ = writeln!(out, "source = {}", part.source.kind());
match &part.source {
MaskSource::Regions {
signature,
level,
@@ -1170,54 +1262,46 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
);
}
}
}
if layer.invert {
let _ = writeln!(out, "invert = 1");
/// The edge treatment, written only where it is not the default — the same
/// rule the parameters follow, so a file stays readable and a mask nobody has
/// fiddled with contributes four fewer lines.
fn write_shaping(out: &mut String, part: &MaskPart) {
if part.feather != DEFAULT_FEATHER {
let _ = writeln!(out, "edge-feather = {}", format_value(part.feather));
}
if layer.opacity != 1.0 {
let _ = writeln!(out, "opacity = {}", format_value(layer.opacity));
if part.falloff != Falloff::default() {
let _ = writeln!(out, "edge-falloff = {}", part.falloff.name());
}
if !layer.enabled {
let _ = writeln!(out, "enabled = 0");
}
// The edge treatment, written only when it is not the default — the same
// rule the parameters follow, so a file stays readable and a mask nobody
// has fiddled with contributes four fewer lines.
if layer.feather != DEFAULT_FEATHER {
let _ = writeln!(out, "edge-feather = {}", format_value(layer.feather));
}
if layer.falloff != Falloff::default() {
let _ = writeln!(out, "edge-falloff = {}", layer.falloff.name());
}
if layer.morphology != Morphology::default() {
let _ = writeln!(out, "morphology = {}", layer.morphology.name());
let _ = writeln!(out, "morph-radius = {}", format_value(layer.morph_radius));
if part.morphology != Morphology::default() {
let _ = writeln!(out, "morphology = {}", part.morphology.name());
let _ = writeln!(out, "morph-radius = {}", format_value(part.morph_radius));
}
// Absent means zero, which is the model's own weighting — so a file
// written before this control existed reads back looking exactly as it did.
if layer.refine != 0.0 {
let _ = writeln!(out, "refine = {}", format_value(layer.refine));
}
for (op, param, value) in layer.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
if part.refine != 0.0 {
let _ = writeln!(out, "refine = {}", format_value(part.refine));
}
}
// TRACES: FR-DEV-3 | FR-CAT-8
// The pixels a model found, so that opening the photograph again — or
// exporting it from the grid, where no model is ever run — renders the
// layer instead of silently dropping it. See [`crate::coverage`] for the
// encoding and for why it is one line.
//
// **Last in the block, and that is on purpose.** It is thousands of
// characters against a dozen elsewhere, and a sidecar is read by hand when
// an edit has gone wrong (ARCH §6.12); everything a human is looking for
// should be above it rather than after it.
//
// Omitted, not truncated, when it will not encode: a layer with no stored
// coverage behaves exactly as every layer did before this existed, which
// is a mask that needs the model run — where a *partial* one would be a
// mask that is confidently wrong.
if let Some(coverage) = layer.coverage.as_ref() {
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The pixels a model found, so that opening the photograph again — or
/// exporting it from the grid, where no model is ever run — renders the part
/// instead of silently dropping it. See [`crate::coverage`] for the encoding
/// and for why it is one line.
///
/// **Last in the block, and that is on purpose.** It is thousands of
/// characters against a dozen elsewhere, and a sidecar is read by hand when an
/// edit has gone wrong (ARCH §6.12); everything a human is looking for should
/// be above it rather than after it.
///
/// Omitted, not truncated, when it will not encode: a part with no stored
/// coverage behaves exactly as every layer did before this existed, which is a
/// mask that needs the model run — where a *partial* one would be a mask that
/// is confidently wrong.
fn write_coverage(out: &mut String, part: &MaskPart) {
if let Some(coverage) = part.coverage.as_ref() {
let _ = writeln!(out, "coverage = {}", coverage.to_text());
}
}
@@ -1295,15 +1379,14 @@ fn parse_stroke(value: &str) -> Option<Stroke> {
(!stroke.is_empty()).then_some(stroke)
}
/// A mask block being read, before it is complete enough to be a layer.
/// One part of a mask being read, before it is complete enough to be a part.
///
/// Separate from [`MaskLayer`] because the source cannot be built until every
/// Separate from [`MaskPart`] because the source cannot be built until every
/// one of its fields has been seen, and the fields arrive one line at a time
/// in whatever order the writer chose.
struct PartialMask {
version: String,
struct PartialPart {
id: String,
name: String,
join: Join,
kind: String,
signature: u64,
level: u32,
@@ -1321,16 +1404,14 @@ struct PartialMask {
/// 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
/// part, 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,
/// The *layer's* edge transition, distinct from the radial source's own
/// The *part's* edge transition, distinct from the radial source's own
/// `feather` above — different quantity, different units, different key.
edge_feather: f32,
falloff: Falloff,
@@ -1339,15 +1420,13 @@ struct PartialMask {
refine: f32,
coverage: Option<Coverage>,
strokes: Vec<Stroke>,
params: Vec<(String, String, f32)>,
}
impl PartialMask {
fn new(version: &str, id: &str) -> Self {
impl PartialPart {
fn new(id: &str) -> Self {
Self {
version: version.to_string(),
id: id.to_string(),
name: String::new(),
join: Join::default(),
kind: String::new(),
signature: 0,
level: 0,
@@ -1367,8 +1446,6 @@ impl PartialMask {
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
hue: (0.06, 0.05),
invert: false,
opacity: 1.0,
enabled: true,
edge_feather: DEFAULT_FEATHER,
falloff: Falloff::default(),
morphology: Morphology::default(),
@@ -1376,13 +1453,12 @@ impl PartialMask {
refine: 0.0,
coverage: None,
strokes: Vec::new(),
params: Vec::new(),
}
}
fn set(&mut self, key: &str, value: &str) {
/// Take one key, returning whether it was one of this part's.
fn set(&mut self, key: &str, value: &str) -> bool {
match key {
"name" => self.name = value.to_string(),
"source" => self.kind = value.to_string(),
"signature" => self.signature = value.parse().unwrap_or(0),
"level" => self.level = value.parse().unwrap_or(0),
@@ -1434,8 +1510,6 @@ impl PartialMask {
// 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",
"opacity" => self.opacity = value.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0),
"enabled" => self.enabled = 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" => {
@@ -1472,19 +1546,18 @@ impl PartialMask {
Morphology::default()
})
}
_ => match (key.split_once('.'), value.parse::<f32>()) {
(Some((op, param)), Ok(v)) if v.is_finite() => {
self.params.push((op.to_string(), param.to_string(), v));
}
_ => log::warn!("sidecar: unreadable mask key {key}; ignoring"),
},
// Not one of ours. The caller decides what that means: an
// adjustment's parameter inside the mask block, or a key nothing
// in this build understands.
_ => return false,
}
true
}
/// Build the layer, or `None` if the source kind is one this build has
/// Build the part, or `None` if the source kind is one this build has
/// never heard of — a newer format's mask type, which is skipped rather
/// than guessed at.
fn finish(self) -> Option<(String, MaskLayer)> {
fn finish(self) -> Option<MaskPart> {
let source = match self.kind.as_str() {
"regions" => MaskSource::Regions {
signature: self.signature,
@@ -1529,33 +1602,156 @@ impl PartialMask {
),
other => {
log::warn!(
"sidecar: unknown mask source '{other}'; skipping layer {}",
"sidecar: unknown mask source '{other}'; skipping part {}",
self.id
);
return None;
}
};
let mut layer = MaskLayer::new(self.id, source);
layer.name = self.name;
layer.invert = self.invert;
layer.opacity = self.opacity;
layer.enabled = self.enabled;
layer.feather = self.edge_feather;
layer.falloff = self.falloff;
layer.morphology = self.morphology;
layer.morph_radius = self.morph_radius;
layer.refine = self.refine;
// Only where there is a model behind the layer to have produced it. A
let mut part = MaskPart::new(self.id, self.join, source);
part.invert = self.invert;
part.feather = self.edge_feather;
part.falloff = self.falloff;
part.morphology = self.morphology;
part.morph_radius = self.morph_radius;
part.refine = self.refine;
// Only where there is a model behind the part to have produced it. A
// gradient or a brush that arrived carrying one is a file that has
// been hand-edited or written by a build that means something else by
// the key, and honouring it would upload a raster nothing samples.
layer.coverage = match layer.source {
part.coverage = match part.source {
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
self.coverage.map(std::sync::Arc::new)
}
_ => None,
};
Some(part)
}
}
/// A mask block being read, before it is complete enough to be a layer.
///
/// Holds what belongs to the *layer* — its name, its inversion, its opacity,
/// its adjustments — and one [`PartialPart`] per selection the mask is built
/// from. `parts[0]` is filled by the mask block's own keys, which is what
/// makes a file written before parts existed read back as a layer of one.
struct PartialMask {
version: String,
id: String,
name: String,
invert: bool,
opacity: f32,
enabled: bool,
params: Vec<(String, String, f32)>,
parts: Vec<PartialPart>,
/// Whether a `[part]` block is open, so `invert` can be told apart: in the
/// mask block it flips the finished mask, and in a part block it flips
/// that part before it is joined. Same word, two controls, and the block
/// is the only thing that distinguishes them.
in_part: bool,
}
impl PartialMask {
fn new(version: &str, id: &str) -> Self {
Self {
version: version.to_string(),
id: id.to_string(),
name: String::new(),
invert: false,
opacity: 1.0,
enabled: true,
params: Vec::new(),
parts: vec![PartialPart::new(crate::mask::FIRST_PART_ID)],
in_part: false,
}
}
/// Open a `[part]` block. Every key from here to the next block header
/// belongs to it.
fn begin_part(&mut self, id: &str) {
self.parts.push(PartialPart::new(id));
self.in_part = true;
}
fn set(&mut self, key: &str, value: &str) {
// The layer's own keys, and only while no part block is open. A part
// has an `invert` of its own and no opacity at all, so which of the
// two an `invert` line means is decided by the block it is in.
if !self.in_part {
match key {
"name" => {
self.name = value.to_string();
return;
}
"invert" => {
self.invert = value != "0";
return;
}
"opacity" => {
self.opacity = value.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0);
return;
}
"enabled" => {
self.enabled = value != "0";
return;
}
_ => {}
}
} else if key == "join" {
// Unknown falls back to a union rather than dropping the part: a
// join this build does not have is a part that joins some other
// way, and adding it is the reading that keeps the selection
// visible and therefore fixable. Silently subtracting under a name
// nobody could see would not be.
if let Some(part) = self.parts.last_mut() {
part.join = Join::from_name(value).unwrap_or_else(|| {
log::warn!("sidecar: unknown join '{value}'; adding the part instead");
Join::Union
});
}
return;
}
if let Some(part) = self.parts.last_mut() {
if part.set(key, value) {
return;
}
}
// Whatever is left is an adjustment, which belongs to the layer
// however deep in the block it was written.
match (key.split_once('.'), value.parse::<f32>()) {
(Some((op, param)), Ok(v)) if v.is_finite() => {
self.params.push((op.to_string(), param.to_string(), v));
}
_ => log::warn!("sidecar: unreadable mask key {key}; ignoring"),
}
}
/// Build the layer, or `None` when its base part cannot be built — a
/// newer format's mask type, which is skipped rather than guessed at.
///
/// A *later* part that cannot be built costs that part and not the layer,
/// on the rule [`parse_stroke`] already follows: the rest of the mask is
/// work the photographer did, and throwing it away to protect the
/// consistency of one correction loses more than it saves.
fn finish(self) -> Option<(String, MaskLayer)> {
let id = self.id;
let mut parts = Vec::new();
for (i, part) in self.parts.into_iter().enumerate() {
match (part.finish(), i) {
(Some(p), _) => parts.push(p),
(None, 0) => return None,
(None, _) => {}
}
}
let mut layer = MaskLayer::from_parts(id, parts)?;
layer.name = self.name;
layer.invert = self.invert;
layer.opacity = self.opacity;
layer.enabled = self.enabled;
for (op, param, value) in &self.params {
// `ParamId` holds a `&'static str` and this one came off disk, so
// it is matched against the descriptors and the *static* id is