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
+363 -60
View File
@@ -830,6 +830,30 @@ pub struct DevelopSession {
/// "make these three subjects a stop darker" is one gesture rather than
/// three. Order is insertion order and nothing reads it, only membership.
active_masks: Vec<String>,
/// TRACES: FR-DEV-19a
/// Which part of the selected layer the tools and the edge controls point
/// at. Zero — the base — whenever a layer is selected afresh.
///
/// An index rather than an id, because it addresses a row the panel is
/// already showing by position, and because a gesture that outlived the
/// part it was aimed at would be a stroke landing somewhere nobody asked
/// for. Clamped on the way in and re-checked on the way out.
active_part: usize,
/// TRACES: FR-DEV-19b
/// The layer and part a stroke in progress is going into.
///
/// Captured on press and held for the gesture: the panel's selection can
/// change under a finger — a stray tap, a sync arriving — and a stroke
/// that changed target half way through would leave half a mark in each.
painting: Option<(String, usize)>,
/// TRACES: FR-DEV-19b
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
/// and flow.
///
/// On the session rather than on a layer, because it belongs to the
/// *tool*: somebody who sets a small eraser expects it to still be small
/// the next time they erase, whichever mask they are working on.
brush: (f32, f32, f32),
/// TRACES: FR-DEV-8
/// Which repair the panel is describing, if any.
///
@@ -987,6 +1011,13 @@ impl DevelopSession {
subjects: None,
subject_key: 0,
active_masks: Vec::new(),
active_part: 0,
painting: None,
brush: (
dr_pipeline::mask::DEFAULT_BRUSH_RADIUS,
dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS,
dr_pipeline::mask::DEFAULT_BRUSH_FLOW,
),
selected_spot: None,
show_overlay: false,
active_tab: None,
@@ -1903,17 +1934,17 @@ impl DevelopSession {
};
for layer in self.graph.masks().active() {
match &layer.source {
match &layer.base().source {
MaskSource::Subject { index, .. } => {
mix(1);
mix(*index as u64);
if layer.morphology.is_compound() {
mix(match layer.morphology {
if layer.base().morphology.is_compound() {
mix(match layer.base().morphology {
Morphology::Close => 2,
Morphology::Open => 3,
_ => 0,
});
mix(layer.morph_radius.to_bits() as u64);
mix(layer.base().morph_radius.to_bits() as u64);
}
}
// Hashed by *name*, and `4` rather than `1` so a category
@@ -1925,13 +1956,13 @@ impl DevelopSession {
mix(*b as u64);
}
// Only the compound operations change the field itself.
if layer.morphology.is_compound() {
mix(match layer.morphology {
if layer.base().morphology.is_compound() {
mix(match layer.base().morphology {
Morphology::Close => 2,
Morphology::Open => 3,
_ => 0,
});
mix(layer.morph_radius.to_bits() as u64);
mix(layer.base().morph_radius.to_bits() as u64);
}
// The refine control changes which pixels are in the mask
// at all, so it changes the coverage the field is measured
@@ -1939,7 +1970,7 @@ impl DevelopSession {
// is already correct. Omitting it here is the bug where
// the slider moves and nothing happens until some other
// control happens to invalidate the cache.
mix(layer.refine.to_bits() as u64);
mix(layer.base().refine.to_bits() as u64);
}
_ => mix(0),
}
@@ -1975,8 +2006,10 @@ impl DevelopSession {
let live = self
.segmentation
.as_ref()
.and_then(|seg| match &layer.source {
MaskSource::Category { name, .. } => seg.category_mask_at(name, layer.refine),
.and_then(|seg| match &layer.base().source {
MaskSource::Category { name, .. } => {
seg.category_mask_at(name, layer.base().refine)
}
MaskSource::Subject { index, .. } => {
seg.instance_mask(*index as usize).map(Cow::Borrowed)
}
@@ -1986,10 +2019,11 @@ impl DevelopSession {
return live;
}
match layer.source {
match layer.base().source {
// Resampled where it was written against a different proxy edge;
// in the ordinary case the sizes match and this is the decode.
MaskSource::Subject { .. } | MaskSource::Category { .. } => layer
.base()
.coverage
.as_ref()
.map(|stored| Cow::Owned(stored.decode_at(width, height))),
@@ -2038,8 +2072,10 @@ impl DevelopSession {
let (pw, ph) = seg.proxy_size();
for layer in stack.layers_mut() {
let values = match &layer.source {
MaskSource::Category { name, .. } => seg.category_mask_at(name, layer.refine),
let values = match &layer.base().source {
MaskSource::Category { name, .. } => {
seg.category_mask_at(name, layer.base().refine)
}
MaskSource::Subject { index, .. } => {
seg.instance_mask(*index as usize).map(Cow::Borrowed)
}
@@ -2059,7 +2095,8 @@ impl DevelopSession {
// would describe the layer at some earlier refine, and a mask of
// the wrong shape presented as authoritative is worse than the
// honest state, which is that this one needs the model run.
layer.coverage = Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new);
layer.base_mut().coverage =
Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new);
}
stack
@@ -2096,10 +2133,10 @@ impl DevelopSession {
pw,
ph,
128,
morphology_for(layer.morphology),
morphology_for(layer.base().morphology),
// Radii are fractions of the shorter edge; the field
// is in proxy pixels.
layer.morph_radius * pw.min(ph) as f32,
layer.base().morph_radius * pw.min(ph) as f32,
)
.distance
}
@@ -2322,7 +2359,7 @@ impl DevelopSession {
/// `None` for a layer that is not a subject at all (a gradient has no
/// instance to re-detect).
pub fn subject_instance_index(&self, id: &str) -> Option<usize> {
match &self.graph.masks().get(id)?.source {
match &self.graph.masks().get(id)?.base().source {
MaskSource::Subject { index, .. } => Some(*index as usize),
_ => None,
}
@@ -2487,7 +2524,7 @@ impl DevelopSession {
/// What kind of mask a layer is — "regions", "linear", "radial".
pub fn mask_kind(&self, id: &str) -> &'static str {
self.graph.masks().get(id).map_or("", |l| l.source.kind())
self.part_of(id).map_or("", |p| p.source.kind())
}
pub fn mask_inverted(&self, id: &str) -> bool {
@@ -2544,7 +2581,7 @@ impl DevelopSession {
return Vec::new();
};
let (sw, sh) = self.demosaiced.size();
crate::gradient::handles(&layer.source, self.graph.framing(), (sw, sh))
crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh))
}
/// Drag one handle of the selected gradient, from `press` to `now`, both
@@ -2569,11 +2606,11 @@ impl DevelopSession {
let id = self.active_masks.first()?.clone();
let start = match origin {
Some(s) => s.clone(),
None => self.graph.masks().get(&id)?.source.clone(),
None => self.graph.masks().get(&id)?.base().source.clone(),
};
let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh));
self.graph.masks_mut().get_mut(&id)?.source = moved;
self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved;
// **Nothing recorded here.** A drag delivers a pointer event a frame,
// and a history step per frame would make undo walk a gesture back
// pixel by pixel. `Edit` coalesces by operation id and a mask's shape
@@ -2591,6 +2628,266 @@ impl DevelopSession {
.record(&self.graph, Edit::Action(labels::step::MASK_MOVED));
}
// --- editing a mask by hand (FR-DEV-19) --------------------------------
/// TRACES: FR-DEV-19a
/// Which part of `id` the edge controls act on.
///
/// The selected part when this is the layer being edited, and the base
/// otherwise — because a panel that is not showing a layer's parts has not
/// offered anybody a way to choose one, and answering with a part they
/// cannot see would make the same slider mean different things depending
/// on what was selected a moment ago.
fn shaped_part(&self, id: &str) -> usize {
match self.active_masks.as_slice() {
[only] if only == id => self.active_part,
_ => 0,
}
}
/// The part of `id` the edge controls read.
fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> {
let index = self.shaped_part(id);
self.graph.masks().get(id)?.part(index)
}
/// The same, to write through.
fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> {
let index = self.shaped_part(id);
self.graph.masks_mut().get_mut(id)?.part_mut(index)
}
/// Which part of the selected layer the tools point at.
pub fn active_part(&self) -> usize {
self.active_part
}
/// Point the tools at one part, or at the base when the index is past the
/// end — which is what a part being removed under the selection leaves.
pub fn set_active_part(&mut self, index: usize) {
let parts = self.active_layer().map_or(1, |l| l.parts().len());
self.active_part = if index < parts { index } else { 0 };
}
/// The parts of a layer: id, what to call it, and how it joins.
///
/// The join of the first is meaningless — there is nothing before it to
/// join to — and the panel shows it as the selection the layer *is*
/// rather than as a row with a chip that does nothing.
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize)> {
let Some(layer) = self.graph.masks().get(id) else {
return Vec::new();
};
layer
.parts()
.iter()
.map(|p| {
let join = dr_pipeline::mask::Join::ALL
.iter()
.position(|&j| j == p.join)
.unwrap_or(0);
(p.id.clone(), p.source.kind().to_string(), join)
})
.collect()
}
/// TRACES: FR-DEV-19a
/// Join a fresh painted part to a layer, returning its index.
///
/// Painted, because that is the correction a photographer reaches for
/// first and the only source that needs nothing found for it. The other
/// sources arrive when a part can carry its own distance field.
pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option<usize> {
use dr_pipeline::mask::{Join, MaskPart};
let &join = Join::ALL.get(join)?;
let layer = self.graph.masks_mut().get_mut(id)?;
let part_id = layer.next_part_id();
if !layer.push_part(MaskPart::painted(part_id, join)) {
return None;
}
let index = layer.parts().len() - 1;
self.active_part = index;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED));
Some(index)
}
/// Take a part back out of a layer. The base is not removable — removing
/// the selection a layer *is* is removing the layer.
pub fn remove_mask_part(&mut self, id: &str, index: usize) {
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
if layer.remove_part(index).is_none() {
return;
}
self.set_active_part(self.active_part.min(index.saturating_sub(1)));
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED));
}
/// Change how a part joins: added to the mask, or taken out of it.
pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) {
use dr_pipeline::mask::Join;
let Some(&join) = Join::ALL.get(join) else {
return;
};
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
// The first part joins nothing, so saying how it joins would be a
// control that moves and changes no pixel.
if index == 0 {
return;
}
let Some(part) = layer.part_mut(index) else {
return;
};
part.join = join;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_JOINED));
}
/// The brush: radius, hardness, flow.
pub fn brush(&self) -> (f32, f32, f32) {
self.brush
}
/// Set the brush. Radius is a fraction of the frame's shorter edge, so it
/// means the same thing on the phone and on the desktop and at any zoom.
pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) {
self.brush = (
radius.clamp(0.002, 0.5),
hardness.clamp(0.0, 1.0),
flow.clamp(0.01, 1.0),
);
}
/// How many stroke points the selected layer may still record.
///
/// Asked by the panel so that a mask approaching its budget can say so
/// before a gesture is refused mid-stroke — which is the moment the
/// refusal is least explicable.
pub fn mask_room(&self) -> usize {
self.active_layer().map_or(0, |l| l.room())
}
/// TRACES: FR-DEV-19b
/// Begin a stroke at a point in fractions of the shown image.
///
/// **Paints into the active part, or joins one if that part cannot hold a
/// stroke.** Pressing Paint on a mask the model made is the ordinary way
/// this is reached, and it must not answer with a refusal explaining that
/// a subject is not a brush: the correction the photographer is about to
/// make *is* a new part, so it is made.
///
/// Returns whether a stroke was started. `false` means the layer is full
/// or there is nothing selected, and the caller should not send moves.
pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool {
let Some(id) = self.active_masks.first().cloned() else {
return false;
};
if self.active_masks.len() != 1 {
// Several layers share the slider drags; a stroke has one target
// and guessing which of three it is would be worse than refusing.
return false;
}
let paintable = self
.graph
.masks()
.get(&id)
.and_then(|l| l.part(self.active_part))
.is_some_and(|p| matches!(p.source, MaskSource::Brush { .. }));
if !paintable {
use dr_pipeline::mask::Join;
let join = if erase { Join::Subtract } else { Join::Union };
let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0);
if self.add_mask_part(&id, position).is_none() {
return false;
}
}
let part = self.active_part;
let (radius, hardness, flow) = self.brush;
// An erase stroke inside a part that subtracts would take away from
// what the part removes, which reads backwards. In a subtracting part
// the brush's two modes are already the right way round.
let subtracting = self
.graph
.masks()
.get(&id)
.and_then(|l| l.part(part))
.is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract);
let erase = erase && !subtracting;
let Some(layer) = self.graph.masks_mut().get_mut(&id) else {
return false;
};
if !layer.begin_stroke(part, erase, radius, hardness, flow) {
return false;
}
self.painting = Some((id, part));
self.extend_mask_stroke(x, y);
true
}
/// Carry the stroke to another point, in fractions of the shown image.
///
/// The point is mapped into normalised **source** coordinates on the way
/// in, through the same framing map the shader applies — so a stroke stays
/// on the thing it was painted on through a zoom, a pan, a crop and a
/// straighten, and lands in an export at any size where it was drawn.
pub fn extend_mask_stroke(&mut self, x: f32, y: f32) {
let Some((id, part)) = self.painting.clone() else {
return;
};
let (sw, sh) = self.demosaiced.size();
let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh);
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
layer.extend_stroke(part, sx, sy);
}
}
/// Finish the stroke: one history step for the whole gesture.
///
/// One step, on release, for the reason a handle drag records once — a
/// stroke is a decision, and undo that walked it back dab by dab would
/// make taking a mark back cost as many presses as making it did.
pub fn end_mask_stroke(&mut self) {
let Some((id, part)) = self.painting.take() else {
return;
};
let erased = self
.graph
.masks()
.get(&id)
.and_then(|l| l.part(part))
.and_then(|p| p.strokes().last())
.is_some_and(|s| s.erase);
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
layer.end_stroke(part);
}
let step = if erased {
labels::step::MASK_ERASED
} else {
labels::step::MASK_PAINTED
};
self.history.record(&self.graph, Edit::Action(step));
}
/// Abandon a stroke that turned out to be something else — a pinch, or a
/// gesture the window cancelled. Nothing is recorded, because nothing
/// happened as far as the photographer is concerned.
pub fn cancel_mask_stroke(&mut self) {
let Some((id, part)) = self.painting.take() else {
return;
};
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
layer.drop_last_stroke(part);
}
}
// --- repairs (FR-DEV-8) ------------------------------------------------
/// TRACES: FR-DEV-8
@@ -2827,13 +3124,19 @@ impl DevelopSession {
/// The selected layer's mask rule, for a caller that has to remember what
/// a gesture started from.
pub fn active_mask_source(&self) -> Option<MaskSource> {
self.active_layer().map(|l| l.source.clone())
self.active_layer().map(|l| l.base().source.clone())
}
/// Select exactly one layer for editing, or `None` to return the panel to
/// the global chain. Replaces whatever was selected before, including a
/// multi-selection — the ordinary, unmodified click.
/// Selecting a layer points the tools at its base again.
///
/// Without this, selecting a two-part mask after a three-part one would
/// leave the brush aimed at a part that is not there — or, worse, at a
/// different part than the one highlighted in the panel.
pub fn set_active_mask(&mut self, id: Option<&str>) {
self.active_part = 0;
self.active_masks = id
.filter(|id| self.graph.masks().get(id).is_some())
.map(|id| vec![id.to_string()])
@@ -2916,7 +3219,7 @@ impl DevelopSession {
.as_ref()
.is_some_and(|seg| seg.category_is_refinable(name))
{
layer.refine = dr_segment::STRICTNESS_DEFAULT;
layer.base_mut().refine = dr_segment::STRICTNESS_DEFAULT;
}
if !self.graph.masks_mut().push(layer) {
return None;
@@ -3038,7 +3341,7 @@ impl DevelopSession {
/// 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 {
let Some(layer) = self.part_of_mut(id) else {
return;
};
// Built into a temporary first: the arc is read out of the layer and
@@ -3061,7 +3364,7 @@ impl DevelopSession {
/// 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 {
let Some(layer) = self.part_of_mut(id) else {
return;
};
// A temporary, for the reason `set_mask_band` gives.
@@ -3085,19 +3388,14 @@ impl DevelopSession {
/// 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())
self.part_of(id).is_some_and(|p| p.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 { .. }))
self.part_of(id)
.is_some_and(|p| matches!(p.source, MaskSource::Colour { .. }))
}
/// TRACES: FR-DEV-10
@@ -3108,7 +3406,7 @@ impl DevelopSession {
/// 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) {
match self.part_of(id).map(|p| &p.source) {
Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness),
Some(MaskSource::Colour {
chroma_lo,
@@ -3123,7 +3421,7 @@ impl DevelopSession {
/// 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) {
match self.part_of(id).map(|p| &p.source) {
Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width),
_ => (0.0, 0.0),
}
@@ -3155,8 +3453,8 @@ impl DevelopSession {
/// Edge transition half-width, in fractions of the shorter edge.
pub fn set_mask_feather(&mut self, id: &str, feather: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.feather = feather.clamp(0.0, 1.0);
if let Some(part) = self.part_of_mut(id) {
part.feather = feather.clamp(0.0, 1.0);
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_FEATHER));
}
@@ -3171,8 +3469,8 @@ impl DevelopSession {
/// `subject_signature`. It still runs no model: the evidence was fitted
/// during segmentation and this is a smoothstep over it.
pub fn set_mask_refine(&mut self, id: &str, refine: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE);
if let Some(part) = self.part_of_mut(id) {
part.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE);
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_REFINE));
}
@@ -3183,8 +3481,8 @@ impl DevelopSession {
let Some(&falloff) = Falloff::ALL.get(index) else {
return;
};
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.falloff = falloff;
if let Some(part) = self.part_of_mut(id) {
part.falloff = falloff;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF));
}
@@ -3195,12 +3493,12 @@ impl DevelopSession {
let Some(&morphology) = Morphology::ALL.get(index) else {
return;
};
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.morphology = morphology;
if let Some(part) = self.part_of_mut(id) {
part.morphology = morphology;
// Picking an operation with no amount set would appear to do
// nothing, and the user would reasonably conclude it is broken.
if morphology != Morphology::None && layer.morph_radius <= 0.0 {
layer.morph_radius = 0.006;
if morphology != Morphology::None && part.morph_radius <= 0.0 {
part.morph_radius = 0.006;
}
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY));
@@ -3208,8 +3506,8 @@ impl DevelopSession {
}
pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.morph_radius = radius.clamp(0.0, 1.0);
if let Some(part) = self.part_of_mut(id) {
part.morph_radius = radius.clamp(0.0, 1.0);
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_MORPH));
}
@@ -3218,34 +3516,34 @@ impl DevelopSession {
/// Which falloff a layer uses, as an index into `Falloff::ALL`.
pub fn mask_falloff(&self, id: &str) -> usize {
use dr_pipeline::mask::Falloff;
self.graph.masks().get(id).map_or(0, |l| {
self.part_of(id).map_or(0, |p| {
Falloff::ALL
.iter()
.position(|&f| f == l.falloff)
.position(|&f| f == p.falloff)
.unwrap_or(0)
})
}
pub fn mask_morphology(&self, id: &str) -> usize {
use dr_pipeline::mask::Morphology;
self.graph.masks().get(id).map_or(0, |l| {
self.part_of(id).map_or(0, |p| {
Morphology::ALL
.iter()
.position(|&m| m == l.morphology)
.position(|&m| m == p.morphology)
.unwrap_or(0)
})
}
pub fn mask_feather(&self, id: &str) -> f32 {
self.graph.masks().get(id).map_or(0.0, |l| l.feather)
self.part_of(id).map_or(0.0, |p| p.feather)
}
pub fn mask_morph_radius(&self, id: &str) -> f32 {
self.graph.masks().get(id).map_or(0.0, |l| l.morph_radius)
self.part_of(id).map_or(0.0, |p| p.morph_radius)
}
pub fn mask_refine(&self, id: &str) -> f32 {
self.graph.masks().get(id).map_or(0.0, |l| l.refine)
self.part_of(id).map_or(0.0, |p| p.refine)
}
/// Whether this layer has a refinement to act on.
@@ -3263,7 +3561,11 @@ impl DevelopSession {
let Some(layer) = self.graph.masks().get(id) else {
return false;
};
let MaskSource::Category { name, .. } = &layer.source else {
let index = self.shaped_part(id);
let Some(part) = layer.part(index) else {
return false;
};
let MaskSource::Category { name, .. } = &part.source else {
return false;
};
self.segmentation
@@ -3286,9 +3588,9 @@ impl DevelopSession {
/// 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| {
self.part_of(id).is_some_and(|p| {
matches!(
l.source,
p.source,
MaskSource::Subject { .. }
| MaskSource::Category { .. }
| MaskSource::Regions { .. }
@@ -5719,7 +6021,7 @@ mod tests {
);
layer.set_param("exposure", ParamId("exposure"), 2.0);
if stored {
layer.coverage = Some(Arc::new(
layer.base_mut().coverage = Some(Arc::new(
Coverage::encode(&values, pw, ph, RENDERED_LEVELS).expect("a half frame encodes"),
));
}
@@ -6015,7 +6317,7 @@ mod tests {
assert_eq!(stored.len(), 1);
assert!(
stored.layers()[0].coverage.is_some(),
stored.layers()[0].base().coverage.is_some(),
"the raster the sidecar gave us must go back to the sidecar"
);
}
@@ -6131,6 +6433,7 @@ mod tests {
let coverage = stored
.get(&id)
.expect("the layer is in the stack")
.base()
.coverage
.as_ref()
.expect("a subject the model found has coverage to store");
@@ -6173,7 +6476,7 @@ mod tests {
{
let layer = session.graph.masks_mut().get_mut(&id).expect("the layer");
layer.set_param("exposure", ParamId("exposure"), 2.0);
layer.coverage = Some(Arc::new(
layer.base_mut().coverage = Some(Arc::new(
dr_pipeline::Coverage::encode(
&mirrored,
pw,
+7
View File
@@ -36,6 +36,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "The scroll wheel over it",
keys: "",
},
Gesture {
title: "Paint a mask by hand",
section: "Develop",
touch: "Choose Paint or Erase, then drag on the photograph",
pointer: "Choose Paint or Erase, then drag",
keys: "",
},
Gesture {
title: "Move a magnified photograph about",
section: "Develop",
+22
View File
@@ -61,6 +61,18 @@ pub mod step {
/// 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-19b
/// A stroke, named for what the hand did rather than for the part it
/// landed in: a photographer takes back the mark they made, and which
/// selection was holding it is not what they were thinking about.
pub const MASK_PAINTED: LocalizedKey = LocalizedKey("history.mask_painted");
pub const MASK_ERASED: LocalizedKey = LocalizedKey("history.mask_erased");
/// TRACES: FR-DEV-19a
/// A selection joined to a mask, taken out of it, or joined the other way.
pub const MASK_PART_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added");
pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed");
pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined");
/// TRACES: FR-DEV-8
/// A repair's own settings changed — its radius, its source, its opacity.
pub const SPOT: LocalizedKey = LocalizedKey("history.spot");
@@ -105,6 +117,11 @@ pub mod step {
MASK_MORPHOLOGY,
MASK_REFINE,
MASK_RANGE,
MASK_PAINTED,
MASK_ERASED,
MASK_PART_ADDED,
MASK_PART_REMOVED,
MASK_JOINED,
SPOT,
SPOT_PLACED,
SPOT_MOVED,
@@ -294,6 +311,11 @@ fn catalogued(key: &str) -> Option<&'static str> {
"history.mask_morphology" => "Mask Grow/Shrink",
"history.mask_refine" => "Mask Refine",
"history.mask_range" => "Mask Range",
"history.mask_painted" => "Paint Mask",
"history.mask_erased" => "Erase Mask",
"history.mask_part_added" => "Add To Mask",
"history.mask_part_removed" => "Remove From Mask",
"history.mask_joined" => "Change How A Mask Joins",
"history.spot" => "Adjust Repair",
"history.spot_placed" => "Add Repair",
"history.spot_moved" => "Move Repair",
+265 -1
View File
@@ -30,7 +30,9 @@ use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId};
use crate::segmentation;
use crate::{sync_rows, AppWindow, CategoryRow, GradientHandle, MaskRow, ParamRow, SubjectRow};
use crate::{
sync_rows, AppWindow, CategoryRow, GradientHandle, MaskRow, ParamRow, PartRow, SubjectRow,
};
/// What the adjust panel's heading says when the controls are global.
///
@@ -238,6 +240,31 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
.collect();
window.set_category_rows(ModelRc::new(VecModel::from(categories)));
// TRACES: FR-DEV-19a
// Only the selected layer's parts, and only when exactly one is selected:
// a stroke has one target, and so does the edge the sliders shape.
let parts: Vec<PartRow> = match s.active_masks() {
[id] => s
.mask_parts(id)
.into_iter()
.enumerate()
.map(|(i, (part_id, label, join))| PartRow {
id: part_id.into(),
label: label.into(),
join: join as i32,
selected: i == s.active_part(),
base: i == 0,
})
.collect(),
_ => Vec::new(),
};
window.set_mask_parts(ModelRc::new(VecModel::from(parts)));
let (radius, hardness, flow) = s.brush();
window.set_brush_radius(radius);
window.set_brush_hardness(hardness);
window.set_brush_flow(flow);
window.set_segmented(s.has_segmentation());
window.set_adjust_scope(scope_label(s).into());
sync_handles(window, s);
@@ -723,6 +750,141 @@ pub(crate) fn wire(
}
// --- adding layers ------------------------------------------------------
// TRACES: FR-DEV-19b
// Painting. Three handlers for one gesture, and the middle one runs per
// pointer report — so it does the least it can: map the point, record it,
// redraw. No sync, because nothing the panel shows changes while a finger
// is down, and rebuilding eight rows a frame is how a brush starts to lag.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_paint_began(move |x, y| {
let Some(w) = weak.upgrade() else { return };
let erase = w.get_mask_tool() == 2;
if let Some(s) = session.borrow_mut().as_mut() {
s.begin_mask_stroke(x, y, erase);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_paint_moved(move |x, y| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.extend_mask_stroke(x, y);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_paint_ended(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.end_mask_stroke();
}
// Now the panel is worth rebuilding: the gesture may have joined a
// part to the mask, and the history has a step in it.
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
window.on_brush_changed(move |radius, hardness, flow| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_brush(radius, hardness, flow);
}
sync(&w, &session);
});
}
{
let weak = window.as_weak();
let session = session.clone();
window.on_mask_tool_picked(move |_tool| {
// The tool itself lives in the interface — it arms a gesture and
// changes no pixel — so nothing is set here. What this does is
// re-sync, because arming the brush is what makes the parts of a
// mask worth showing.
let Some(w) = weak.upgrade() else { return };
sync(&w, &session);
});
}
// TRACES: FR-DEV-19a
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_mask_part_selected(move |_id, index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_active_part(index.max(0) as usize);
}
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_mask_part_added(move |id, join| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.add_mask_part(&id, join.max(0) as usize);
}
// Armed, because adding an empty painted part and leaving the
// pointer in "select" would look like the button did nothing —
// the part covers no pixels until something is painted into it.
if w.get_mask_tool() == 0 {
w.set_mask_tool(1);
}
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_mask_part_removed(move |id, index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.remove_mask_part(&id, index.max(0) as usize);
}
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_part_join_picked(move |id, index, join| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_part_join(&id, index.max(0) as usize, join.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
@@ -948,6 +1110,108 @@ mod tests {
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL).ok()
}
/// TRACES: FR-DEV-19b
/// Pressing Paint on a mask the model made must not answer with a
/// refusal about a subject not being a brush. The correction the
/// photographer is about to make *is* a new part, so it is made.
#[test]
fn painting_on_a_mask_that_cannot_hold_a_stroke_joins_a_part_that_can() {
let Some(mut s) = session() else {
eprintln!("no adapter; skipping");
return;
};
let id = s.add_gradient_mask(true).expect("radial");
s.set_active_mask(Some(&id));
assert_eq!(s.mask_parts(&id).len(), 1, "one selection to begin with");
assert!(s.begin_mask_stroke(0.5, 0.5, false), "the stroke started");
s.extend_mask_stroke(0.55, 0.5);
s.end_mask_stroke();
let parts = s.mask_parts(&id);
assert_eq!(parts.len(), 2, "a painted part was joined");
assert_eq!(parts[1].1, "brush");
assert_eq!(s.active_part(), 1, "and the tools point at it");
}
/// A stroke is one decision. Recording per pointer report would make
/// taking a mark back cost as many presses as making it did.
#[test]
fn a_whole_stroke_is_one_step_in_the_history() {
let Some(mut s) = session() else {
eprintln!("no adapter; skipping");
return;
};
let id = s.add_gradient_mask(true).expect("radial");
s.set_active_mask(Some(&id));
let before = s.history_rows().len();
assert!(s.begin_mask_stroke(0.4, 0.4, false));
for i in 0..12 {
s.extend_mask_stroke(0.4 + i as f32 * 0.02, 0.5);
}
s.end_mask_stroke();
// Two: joining the part is its own step, and the stroke is the other.
assert_eq!(
s.history_rows().len(),
before + 2,
"a stroke recorded more than one step"
);
}
/// A gesture the window cancels leaves nothing behind — including in the
/// history, because as far as the photographer is concerned nothing
/// happened.
#[test]
fn a_cancelled_stroke_leaves_no_mark() {
let Some(mut s) = session() else {
eprintln!("no adapter; skipping");
return;
};
let id = s.add_gradient_mask(true).expect("radial");
s.set_active_mask(Some(&id));
assert!(s.begin_mask_stroke(0.4, 0.4, false));
s.extend_mask_stroke(0.5, 0.5);
let steps = s.history_rows().len();
s.cancel_mask_stroke();
assert_eq!(s.history_rows().len(), steps, "nothing was recorded");
assert!(
s.masks()
.get(&id)
.expect("layer")
.part(1)
.expect("part")
.strokes()
.is_empty(),
"and the abandoned stroke is not in the mask"
);
}
/// TRACES: FR-DEV-19a
/// The edge controls point at the part that is selected, so that dragging
/// the feather while a correction is selected does not silently soften the
/// model's mask underneath it.
#[test]
fn the_edge_controls_shape_the_selected_part() {
let Some(mut s) = session() else {
eprintln!("no adapter; skipping");
return;
};
let id = s.add_gradient_mask(true).expect("radial");
s.set_active_mask(Some(&id));
s.set_mask_feather(&id, 0.03);
let index = s.add_mask_part(&id, 0).expect("a part was joined");
assert_eq!(s.active_part(), index);
s.set_mask_feather(&id, 0.0);
assert_eq!(s.mask_feather(&id), 0.0, "the selected part was shaped");
s.set_active_part(0);
assert_eq!(s.mask_feather(&id), 0.03, "and the base was left alone");
}
/// TRACES: FR-DEV-3 | FR-UI-1
/// The fault this pass exists for, stated as a test.
///
+2 -2
View File
@@ -1162,9 +1162,9 @@ mod tests {
assert_eq!(back.id, "m1");
assert!((back.opacity - 0.75).abs() < 1e-6, "and with its settings");
assert!(
matches!(back.source, MaskSource::Radial { .. }),
matches!(back.base().source, MaskSource::Radial { .. }),
"and its kind: {:?}",
back.source.kind()
back.base().source.kind()
);
}