Files
DarkRoom/ui/dr-ui/src/develop/masks.rs
T
dtourolle 0b06e31bf3 Let a zoomed view fill the viewport rather than keep the photograph's shape
The view was the same fraction of each axis, so it kept the frame's
aspect at every zoom: a portrait zoomed on a landscape screen stayed a
portrait strip with the screen's sides empty. Each axis now shows as
much of the frame as the viewport holds at that magnification, capped
at the whole frame, and the render is fitted to the viewed region
rather than to the frame. A redraw re-cuts a zoomed view about its
centre when the viewport or the crop changes shape.
2026-10-02 22:29:48 -04:00

924 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,
/// and a keystoned one (FR-DEV-20) shows it unwarped.
/// 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, 64, 64);
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, 64, 64);
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));
}
}