The pipeline could now keep only where two selections agree, but the panel had no way to ask for it: the part row's chip flipped between + and -, and the buttons under the parts joined an added or a subtracted correction. An "∩ Intersect" button joins a painted part that intersects, and the chip on a part row cycles + -> - -> ∩ and round, so an existing part can be turned into an intersection without being repainted. Both go through the same session calls as before, indexing Join::ALL, whose first two entries kept their places. The chip is now a tagged gesture, so it is in the gesture book.
923 lines
36 KiB
Rust
923 lines
36 KiB
Rust
//! Looking at masks: view style, the segmentation overlay, the layer list,
|
|
//! and editing a mask by hand with the brush.
|
|
#[cfg(test)]
|
|
use dr_gpu::GpuContext;
|
|
use dr_pipeline::mask::MaskSource;
|
|
|
|
use crate::labels;
|
|
use dr_pipeline::Edit;
|
|
|
|
use super::session::DevelopSession;
|
|
|
|
/// TRACES: FR-DEV-19c
|
|
/// How one layer's mask is shown on the canvas.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(super) struct MaskView {
|
|
shown: bool,
|
|
/// Index into [`MASK_COLOURS`].
|
|
colour: usize,
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19c
|
|
/// The colours a mask may be shown in, in linear sRGB.
|
|
///
|
|
/// Six, chosen to be told apart at half strength over a photograph rather
|
|
/// than to be pretty: red and green and blue at the corners, and the three
|
|
/// between them. Exposed so the panel draws its swatches from the same table
|
|
/// the shader is handed, and a seventh colour is one line here and nowhere
|
|
/// else.
|
|
pub const MASK_COLOURS: [[f32; 3]; 6] = [
|
|
[0.85, 0.10, 0.15],
|
|
[0.15, 0.80, 0.25],
|
|
[0.20, 0.45, 1.00],
|
|
[0.95, 0.80, 0.10],
|
|
[0.90, 0.20, 0.85],
|
|
[0.15, 0.85, 0.90],
|
|
];
|
|
|
|
impl DevelopSession {
|
|
// ----------------------------------------------------------------------
|
|
// Seeing the mask (FR-DEV-19c)
|
|
// ----------------------------------------------------------------------
|
|
|
|
/// TRACES: FR-DEV-19c
|
|
/// What the canvas should draw over the photograph, if anything.
|
|
///
|
|
/// Every layer whose eye is open, in stack order, each in its colour —
|
|
/// and `None` when no eye is, so the rasteriser and the composer can
|
|
/// take the path they always took.
|
|
///
|
|
/// Rebuilt per call rather than kept in step with the stack, because it
|
|
/// is a walk over at most eight layers — cheaper than the invalidation a
|
|
/// cached copy would need every time a layer is added, removed, renamed
|
|
/// or reordered.
|
|
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
|
|
use dr_pipeline::mask::{Reveal, RevealedLayer};
|
|
// Only while masking. The eyes are per layer and outlive the mode,
|
|
// so a photographer coming back finds the layers they were looking
|
|
// at still lit — but a tint is a way of looking at a *mask*, and
|
|
// outside Local there is no mask being looked at. Without this the
|
|
// sky stayed red through Repair and back in Photo, a mode that had
|
|
// been left leaving its overlay behind (ui-navigation.md D-N1).
|
|
if !self.show_overlay {
|
|
return None;
|
|
}
|
|
let layers: Vec<RevealedLayer> = self
|
|
.graph
|
|
.masks()
|
|
.layers()
|
|
.iter()
|
|
.filter_map(|l| {
|
|
let view = self.mask_views.get(&l.id).filter(|v| v.shown)?;
|
|
Some(RevealedLayer {
|
|
layer: l.id.clone(),
|
|
colour: MASK_COLOURS[view.colour % MASK_COLOURS.len()],
|
|
})
|
|
})
|
|
.collect();
|
|
if layers.is_empty() {
|
|
return None;
|
|
}
|
|
Some(Reveal {
|
|
layers,
|
|
style: self.reveal_style,
|
|
})
|
|
}
|
|
|
|
/// How shown masks are drawn, as an index into
|
|
/// [`dr_pipeline::mask::RevealStyle::ALL`].
|
|
///
|
|
/// An index because the panel offers it as a strip of chips and an index
|
|
/// is what a strip of chips reports. The enum stays the thing that is
|
|
/// stored, so a fourth style is a variant and a label rather than a number
|
|
/// two files have to agree on.
|
|
pub fn mask_view_style(&self) -> usize {
|
|
dr_pipeline::mask::RevealStyle::ALL
|
|
.iter()
|
|
.position(|&a| a == self.reveal_style)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Choose how shown masks are drawn.
|
|
///
|
|
/// Takes no history step and marks nothing dirty: this is how the
|
|
/// photograph is being *looked at*, not an edit to it.
|
|
pub fn set_mask_view_style(&mut self, style: usize) {
|
|
if let Some(&s) = dr_pipeline::mask::RevealStyle::ALL.get(style) {
|
|
self.reveal_style = s;
|
|
}
|
|
}
|
|
|
|
/// Whether this layer's mask is drawn over the photograph.
|
|
pub fn mask_shown(&self, id: &str) -> bool {
|
|
self.mask_views.get(id).is_some_and(|v| v.shown)
|
|
}
|
|
|
|
/// Open or close one layer's eye.
|
|
pub fn set_mask_shown(&mut self, id: &str, shown: bool) {
|
|
let colour = self.next_mask_colour();
|
|
self.mask_views
|
|
.entry(id.to_string())
|
|
.or_insert(MaskView {
|
|
shown: false,
|
|
colour,
|
|
})
|
|
.shown = shown;
|
|
}
|
|
|
|
/// Whether any mask at all is being shown.
|
|
///
|
|
/// What the region overlay asks before drawing: two overlays that mean
|
|
/// different things, on top of each other, is neither.
|
|
pub fn any_mask_shown(&self) -> bool {
|
|
self.reveal().is_some()
|
|
}
|
|
|
|
/// Which of [`MASK_COLOURS`] this layer is shown in.
|
|
pub fn mask_colour(&self, id: &str) -> usize {
|
|
self.mask_views
|
|
.get(id)
|
|
.map_or(0, |v| v.colour % MASK_COLOURS.len())
|
|
}
|
|
|
|
/// Give this layer a colour from [`MASK_COLOURS`].
|
|
///
|
|
/// Choosing a colour is asking to see it: a swatch pressed on a layer
|
|
/// whose eye was closed opens the eye, because nothing else the press
|
|
/// could mean would change a pixel.
|
|
pub fn set_mask_colour(&mut self, id: &str, colour: usize) {
|
|
let colour = colour % MASK_COLOURS.len();
|
|
self.mask_views
|
|
.entry(id.to_string())
|
|
.and_modify(|v| {
|
|
v.colour = colour;
|
|
v.shown = true;
|
|
})
|
|
.or_insert(MaskView {
|
|
shown: true,
|
|
colour,
|
|
});
|
|
}
|
|
|
|
/// The colour the next layer to be shown should take: the first not
|
|
/// already in use, or round the palette again once all are.
|
|
///
|
|
/// So that two masks made one after the other come up in two colours
|
|
/// without anyone having to choose — which is the case that matters,
|
|
/// since "how do these two meet" is the question two masks are shown to
|
|
/// answer.
|
|
fn next_mask_colour(&self) -> usize {
|
|
let used: Vec<usize> = self.mask_views.values().map(|v| v.colour).collect();
|
|
(0..MASK_COLOURS.len())
|
|
.find(|c| !used.contains(c))
|
|
.unwrap_or(self.mask_views.len() % MASK_COLOURS.len())
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19c
|
|
/// Show the mask of a layer that has just been made.
|
|
///
|
|
/// **Making a mask is asking what it selected**, and for a subject or a
|
|
/// category that question has no other answer: the model's outline is not
|
|
/// derivable from anything on screen, the layer carries no adjustment yet,
|
|
/// and the list it was chosen from says "architecture 23%" and nothing
|
|
/// about *which* 23%. So the mask appears with the layer rather than
|
|
/// waiting to be asked for a second time — its eye open, in the next
|
|
/// colour nothing else is using.
|
|
///
|
|
/// Only this layer's eye. Every other layer keeps whatever the
|
|
/// photographer set it to, which is the trap `Masking.overlay-hidden`
|
|
/// documents: an automatic reveal that undoes a switch somebody turned
|
|
/// off is worse than none.
|
|
pub(super) fn show_new_mask(&mut self, id: &str) {
|
|
let colour = self.next_mask_colour();
|
|
self.mask_views.insert(
|
|
id.to_string(),
|
|
MaskView {
|
|
shown: true,
|
|
colour,
|
|
},
|
|
);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// The region overlay
|
|
// ----------------------------------------------------------------------
|
|
|
|
pub fn overlay_enabled(&self) -> bool {
|
|
self.show_overlay
|
|
}
|
|
|
|
pub fn set_overlay(&mut self, on: bool) {
|
|
self.show_overlay = on;
|
|
}
|
|
|
|
/// The part of the overlay the view is currently showing, in overlay
|
|
/// pixels: `(x, y, width, height)`.
|
|
///
|
|
/// The overlay is a **source-space** picture, and the canvas beside it
|
|
/// shows whatever the crop, the zoom and the pan selected out of that same
|
|
/// space. Drawn whole, it stays the size of the frame while the photograph
|
|
/// moves underneath — which is exactly the fault this exists to fix.
|
|
///
|
|
/// Reported as a clip rectangle rather than resampled here: the compositor
|
|
/// crops and scales a texture for nothing, where doing it on the CPU would
|
|
/// mean rebuilding a megapixel image on every frame of a drag.
|
|
///
|
|
/// **Known gap.** A quarter turn or a flip permutes the axes, and a clip
|
|
/// rectangle cannot express that — the straightening angle is handled
|
|
/// alongside this, but a quarter-turned frame shows the overlay unturned.
|
|
/// Fixing it properly means running the overlay through the same shader
|
|
/// prologue the image goes through, which is the right answer and a larger
|
|
/// one than this.
|
|
pub fn overlay_clip(&self) -> (i32, i32, i32, i32) {
|
|
let Some(seg) = self.segmentation.as_ref() else {
|
|
return (0, 0, 0, 0);
|
|
};
|
|
// **Shown pixels, matching `overlay_image`.** The crop and the
|
|
// viewport are fractions of the photograph as the user sees it — the
|
|
// prologue maps an output pixel through `crop_rect` *before* it
|
|
// unturns the frame — so measuring them against the sensor's width
|
|
// and height puts the clip on the wrong axis the moment the two
|
|
// differ. That is the same confusion as the overlay itself had, one
|
|
// layer down, and it is silent for exactly the images where it is
|
|
// wrong: a landscape frame has nothing to notice.
|
|
let (sw, sh) = seg.proxy_size();
|
|
let (w, h) = self
|
|
.graph
|
|
.framing()
|
|
.effective_orientation()
|
|
.oriented_size(sw as u32, sh as u32);
|
|
let rect = self.graph.framing().visible_rect();
|
|
|
|
// Rounded outward, so half a pixel of rounding never shows as a strip
|
|
// of missing overlay along an edge.
|
|
let x = (rect.x * w as f32).floor().max(0.0) as i32;
|
|
let y = (rect.y * h as f32).floor().max(0.0) as i32;
|
|
let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32;
|
|
let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32;
|
|
|
|
(x, y, (right - x).max(1), (bottom - y).max(1))
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// A false-coloured picture of what a click can select, for the canvas.
|
|
///
|
|
/// Returned as a CPU image rather than a texture, and deliberately: it is
|
|
/// regenerated only when the segmentation changes, it is proxy-sized
|
|
/// rather than viewport-sized, and the compositor scales and clips it for
|
|
/// free. Putting it on the GPU would buy nothing and add a second texture
|
|
/// to keep in step with the view.
|
|
///
|
|
/// `None` when the overlay is off or nothing has been segmented, so the
|
|
/// caller can bind this straight to an image source.
|
|
pub fn overlay_image(&self) -> Option<slint::Image> {
|
|
if !self.show_overlay {
|
|
return None;
|
|
}
|
|
let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba();
|
|
|
|
// TRACES: FR-DEV-3h
|
|
// **Turned the right way up before it is drawn.** Instance masks live
|
|
// in sensor space, because the generated shader samples them after
|
|
// the framing map (`uv_src`) — but this is not sampled by that shader.
|
|
// It is a flat image handed to the compositor to lay over a
|
|
// photograph that *has* been through the framing map, so it has to
|
|
// arrive in the same space the photograph is in.
|
|
//
|
|
// Without this the outlines are drawn in the sensor's orientation over
|
|
// an upright picture: on a portrait frame the colour sits nowhere near
|
|
// the subject, which reads as the detector having failed rather than
|
|
// as the overlay being turned. Nothing announces it, and it is
|
|
// invisible on landscape frames, which is most of them.
|
|
let (rgba, w, h) = self
|
|
.graph
|
|
.framing()
|
|
.effective_orientation()
|
|
.into_shown(&rgba, w, h, 4);
|
|
|
|
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(&rgba, w, h);
|
|
Some(slint::Image::from_rgba8(buffer))
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Mask layers
|
|
// ----------------------------------------------------------------------
|
|
|
|
/// The layers, as `(id, name, enabled, is_active_selection)`.
|
|
pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> {
|
|
self.graph
|
|
.masks()
|
|
.layers()
|
|
.iter()
|
|
.map(|l| {
|
|
(
|
|
l.id.clone(),
|
|
l.display_name().to_string(),
|
|
l.enabled,
|
|
self.active_masks.iter().any(|a| a == &l.id),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// What kind of mask a layer is — "regions", "linear", "radial".
|
|
pub fn mask_kind(&self, id: &str) -> &'static str {
|
|
self.part_of(id).map_or("", |p| p.source.kind())
|
|
}
|
|
|
|
pub fn mask_inverted(&self, id: &str) -> bool {
|
|
self.graph.masks().get(id).is_some_and(|l| l.invert)
|
|
}
|
|
|
|
pub fn mask_opacity(&self, id: &str) -> f32 {
|
|
self.graph.masks().get(id).map_or(1.0, |l| l.opacity)
|
|
}
|
|
|
|
/// Whether a layer has any adjustment on it yet.
|
|
///
|
|
/// Distinct from `is_active`, which also asks whether the layer is enabled
|
|
/// and visible. The panel wants specifically "you have made a selection
|
|
/// and not yet done anything with it", because that state looks identical
|
|
/// to a broken mask and is the most likely thing a first-time user hits.
|
|
pub fn mask_is_adjusted(&self, id: &str) -> bool {
|
|
self.graph
|
|
.masks()
|
|
.get(id)
|
|
.is_some_and(|l| l.active_ops().next().is_some())
|
|
}
|
|
|
|
/// The panel's representative selection — see [`Self::active_layer`] for
|
|
/// what "representative" means once more than one layer is selected.
|
|
pub fn active_mask(&self) -> Option<&str> {
|
|
self.active_masks.first().map(String::as_str)
|
|
}
|
|
|
|
/// Every selected layer's id, in selection order.
|
|
pub fn active_masks(&self) -> &[String] {
|
|
&self.active_masks
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-UI-3
|
|
/// The selected gradient's handles, in fractions of the shown image.
|
|
///
|
|
/// Empty unless **exactly one** gradient layer is selected. Dragging a
|
|
/// shared handle for several gradients at once has no single geometry to
|
|
/// move — each one's centre, angle and extent differ — so multi-select
|
|
/// simply offers no handles rather than moving one layer's shape while
|
|
/// silently leaving the others behind.
|
|
///
|
|
/// Recomputed on every redraw rather than cached, because the answer
|
|
/// changes with the *view* and not only with the mask: a pan moves every
|
|
/// handle and touches no geometry. Four handles through an affine map is
|
|
/// not work worth caching, and a cache keyed on the wrong thing is how a
|
|
/// handle comes to sit where the mask used to be.
|
|
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
|
|
if self.active_masks.len() != 1 {
|
|
return Vec::new();
|
|
}
|
|
let Some(layer) = self.active_layer() else {
|
|
return Vec::new();
|
|
};
|
|
let (sw, sh) = self.demosaiced.size();
|
|
crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh))
|
|
}
|
|
|
|
/// Drag one handle of the selected gradient, from `press` to `now`, both
|
|
/// in fractions of the shown image.
|
|
///
|
|
/// `origin` is the geometry the gesture started from — see
|
|
/// [`crate::gradient::drag`] for why a drag is applied to that rather than
|
|
/// accumulated. Returns it, so the caller can hold it for the rest of the
|
|
/// gesture; `None` when there is no gradient selected to drag.
|
|
pub fn drag_gradient_handle(
|
|
&mut self,
|
|
role: crate::HandleRole,
|
|
origin: Option<&MaskSource>,
|
|
press: (f32, f32),
|
|
now: (f32, f32),
|
|
) -> Option<MaskSource> {
|
|
if self.active_masks.len() != 1 {
|
|
return None;
|
|
}
|
|
let (sw, sh) = self.demosaiced.size();
|
|
let framing = *self.graph.framing();
|
|
let id = self.active_masks.first()?.clone();
|
|
let start = match origin {
|
|
Some(s) => s.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)?.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
|
|
// is not an operation, so there is no key to coalesce under — the
|
|
// honest answer is to record once, on release.
|
|
Some(start)
|
|
}
|
|
|
|
/// A handle drag finished: one history step for the whole gesture.
|
|
///
|
|
/// Called on the pointer's release rather than on each move, which is what
|
|
/// makes a drag one decision in the undo stack however many frames it took.
|
|
pub fn commit_gradient_drag(&mut self) {
|
|
self.history
|
|
.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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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, bool)> {
|
|
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, p.hidden)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19a
|
|
/// Leave one part out of the build, or put it back. An edit, and one
|
|
/// history step, for the same reason the layer's own switch is: the part
|
|
/// really is out until it is switched back.
|
|
pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) {
|
|
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
|
|
return;
|
|
};
|
|
let Some(part) = layer.part_mut(index) else {
|
|
return;
|
|
};
|
|
if part.hidden == hidden {
|
|
return;
|
|
}
|
|
part.hidden = hidden;
|
|
self.history
|
|
.record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED));
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19a
|
|
/// Change how a part joins: added to the mask, taken out of it, or kept
|
|
/// only where the mask already was. `join` indexes `Join::ALL`.
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::develop::test_support::*;
|
|
|
|
// ----------------------------------------------------------------------
|
|
// The overlay's clip rectangle
|
|
// ----------------------------------------------------------------------
|
|
//
|
|
// The overlay is a source-space picture and the canvas shows whatever the
|
|
// crop, the zoom and the pan selected out of that space. Drawn whole it
|
|
// stays frame-sized while the photograph moves underneath, which is what
|
|
// these pin down.
|
|
|
|
/// A session with a segmentation, so the clip has a proxy to measure
|
|
/// against.
|
|
///
|
|
/// The model finds nothing in flat grey, and that is fine: the clip is
|
|
/// computed from the framing and the proxy size, neither of which depends
|
|
/// on what was detected.
|
|
fn segmented_session(ctx: &GpuContext) -> Option<DevelopSession> {
|
|
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
|
|
let mut session =
|
|
DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
|
.expect("session");
|
|
session
|
|
.segment(&crate::segmentation::Options::default())
|
|
.ok()?;
|
|
Some(session)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
|
/// The whole claim, end to end: what is stored renders what was rendered.
|
|
///
|
|
/// A session with a model's coverage in hand draws the mask; the stack it
|
|
/// hands the sidecar writer goes through the file and into a session with
|
|
/// no model at all; and the two frames must be the same. Anything weaker
|
|
/// — that the coverage is present, that it round-trips as bytes — would
|
|
/// still pass if the raster came back at the wrong scale, upside down, or
|
|
/// a threshold out.
|
|
#[test]
|
|
fn a_shown_mask_is_only_shown_while_masking() {
|
|
let Some(ctx) = headless() else { return };
|
|
let mut s = session_with_a_left_half_subject(&ctx);
|
|
let id = s.add_subject_mask(0).expect("a subject layer");
|
|
s.set_overlay(true);
|
|
s.set_mask_shown(&id, true);
|
|
assert!(s.any_mask_shown(), "lit, in Local mode");
|
|
|
|
// Leaving the mode — what `on_mode_picked` does for Photo and Spots.
|
|
s.set_overlay(false);
|
|
assert!(
|
|
!s.any_mask_shown(),
|
|
"the tint belongs to the mode, not to the photograph"
|
|
);
|
|
assert!(s.mask_shown(&id), "the eye itself is remembered");
|
|
|
|
s.set_overlay(true);
|
|
assert!(s.any_mask_shown(), "and is lit again on return");
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// Multi-select: one slider, applied to every selected layer.
|
|
///
|
|
/// `toggle_active_mask` builds the selection a control-click makes, and
|
|
/// `set_param`/`reset_op` are what a drag and a reset call — this pins
|
|
/// down that both fan out to every layer in it rather than only the
|
|
/// first, which is the whole point of selecting more than one.
|
|
#[test]
|
|
fn a_slider_moved_with_two_layers_selected_moves_both() {
|
|
let Some(ctx) = headless() else { return };
|
|
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
|
let mut session =
|
|
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
|
.expect("session");
|
|
|
|
let a = session.add_gradient_mask(true).expect("first gradient");
|
|
let b = session.add_gradient_mask(false).expect("second gradient");
|
|
// Adding `b` selected it alone — build the multi-selection a
|
|
// control-click would, starting from that single-layer state.
|
|
session.toggle_active_mask(&a);
|
|
assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice());
|
|
|
|
assert!(
|
|
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
|
|
"neither layer has been touched yet"
|
|
);
|
|
|
|
let row = session.rows()[0].clone();
|
|
session.set_param(row.op_index, row.param_index, row.maximum);
|
|
|
|
assert!(
|
|
session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b),
|
|
"one slider, both layers selected, both layers must show the edit"
|
|
);
|
|
|
|
// And a reset walks the same set.
|
|
session.reset_op(row.op_index);
|
|
assert!(
|
|
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
|
|
"resetting with both selected must clear both, not just the one \
|
|
the panel happens to read values from"
|
|
);
|
|
}
|
|
|
|
/// A control-click twice — once to add, once to remove — is a no-op on
|
|
/// the selection, which is the sanity check for `toggle_active_mask`
|
|
/// itself before trusting anything built on it.
|
|
#[test]
|
|
fn toggling_a_layer_twice_returns_to_the_starting_selection() {
|
|
let Some(ctx) = headless() else { return };
|
|
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
|
let mut session =
|
|
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
|
.expect("session");
|
|
|
|
let a = session.add_gradient_mask(true).expect("gradient");
|
|
assert_eq!(session.active_masks(), [a.clone()].as_slice());
|
|
|
|
session.toggle_active_mask(&a);
|
|
assert!(session.active_masks().is_empty(), "removed by the toggle");
|
|
|
|
session.toggle_active_mask(&a);
|
|
assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unzoomed_overlay_shows_the_whole_frame() {
|
|
let Some(ctx) = headless() else { return };
|
|
let Some(session) = segmented_session(&ctx) else {
|
|
eprintln!("no model; skipping");
|
|
return;
|
|
};
|
|
|
|
let (x, y, w, h) = session.overlay_clip();
|
|
assert_eq!((x, y), (0, 0));
|
|
assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}");
|
|
}
|
|
|
|
/// The bug this exists for: zooming must narrow the clip, or the overlay
|
|
/// keeps showing the whole picture at frame size while the canvas shows a
|
|
/// detail of it.
|
|
#[test]
|
|
fn zooming_narrows_the_overlay_to_what_is_visible() {
|
|
let Some(ctx) = headless() else { return };
|
|
let Some(mut session) = segmented_session(&ctx) else {
|
|
eprintln!("no model; skipping");
|
|
return;
|
|
};
|
|
|
|
let (_, _, full_w, full_h) = session.overlay_clip();
|
|
session.zoom_about(4.0, 0.5, 0.5);
|
|
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
|
|
|
|
assert!(
|
|
zoomed_w < full_w && zoomed_h < full_h,
|
|
"zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \
|
|
against {full_w}x{full_h}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn panning_moves_the_overlay_with_the_photograph() {
|
|
let Some(ctx) = headless() else { return };
|
|
let Some(mut session) = segmented_session(&ctx) else {
|
|
eprintln!("no model; skipping");
|
|
return;
|
|
};
|
|
|
|
session.zoom_about(4.0, 0.5, 0.5);
|
|
let (before_x, _, _, _) = session.overlay_clip();
|
|
session.pan_by(0.3, 0.0);
|
|
let (after_x, _, _, _) = session.overlay_clip();
|
|
|
|
assert!(
|
|
after_x > before_x,
|
|
"panning right moves the visible window right: {before_x} then {after_x}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cropping_narrows_the_overlay_too() {
|
|
let Some(ctx) = headless() else { return };
|
|
let Some(mut session) = segmented_session(&ctx) else {
|
|
eprintln!("no model; skipping");
|
|
return;
|
|
};
|
|
|
|
let (_, _, full_w, _) = session.overlay_clip();
|
|
session.set_crop(dr_pipeline::CropRect {
|
|
x: 0.25,
|
|
y: 0.25,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
let (x, y, w, _) = session.overlay_clip();
|
|
|
|
assert!(w < full_w, "a half-width crop shows half the overlay");
|
|
assert!(x > 0 && y > 0, "and it starts inside the frame");
|
|
}
|
|
|
|
/// Nothing segmented means no overlay, and no rectangle a caller might
|
|
/// divide by.
|
|
#[test]
|
|
fn no_segmentation_means_no_clip() {
|
|
let Some(ctx) = headless() else { return };
|
|
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
|
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
|
.expect("session");
|
|
assert_eq!(session.overlay_clip(), (0, 0, 0, 0));
|
|
}
|
|
}
|