Files
DarkRoom/ui/dr-ui/src/develop/masks.rs
T
dtourolle 5f0b7ac799 Offer intersection in the mask panel: an Intersect button and a third chip state
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.
2026-09-24 21:25:48 -04:00

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));
}
}