Split develop.rs into develop/ by area of behaviour

develop.rs had grown to 9,327 lines covering everything the develop
session does: opening a photograph, the parameter-row and curve-widget
panel model, mask viewing and editing, mask creation and the rasteriser
that turns a mask stack into GPU arrays, spot repairs, scene
segmentation, framing and zoom, white-balance sampling, rendering and
film choice, and the undo/snapshot history. docs/dev/code-health.md
CH-1 names dr-ui's lack of a view layer as the reason every feature
kept landing in a handful of files; this is the first of the two pure
splits it recommends as easy, no-behaviour-change wins independent of
that larger rework.

The boundaries follow the file's own sections (several were already
marked off with comment headers) and the seams a full read turned up
underneath them -- mask storage/rasterisation turned out to be a
distinct concern from mask viewing and editing, and rows/tabs/curves
from each other, so those split further than the headers alone
suggested. Each module stays under about 1,500 lines. Struct fields
and the handful of helper methods now called from a sibling module
became `pub(super)`, which is strictly narrower than the whole-crate
reachability a single file gave them; nothing gained visibility outside
`develop`. Tests moved with the code they test, including the few
cases where a helper one file's tests needed was itself only defined
in another's -- those became shared fixtures in `mod.rs` alongside the
`headless`/`read_back`/`grey_session` helpers that already worked that
way. `mod.rs` re-exports every item `develop::` callers outside this
module used before, so lib.rs, masks_ui.rs and the rest needed no
changes.
This commit is contained in:
2026-09-20 18:21:26 +02:00
parent 6b1aac477d
commit 050c2c9d16
15 changed files with 9617 additions and 9389 deletions
+952
View File
@@ -0,0 +1,952 @@
//! The crop, its locked aspect ratio, rotation and flips, and the zoom/pan
//! and pixel-inspection state a viewport keeps on top of the framed image.
use dr_pipeline::{CropRect, Edit};
use crate::labels;
use super::render::fit;
use super::session::DevelopSession;
/// TRACES: FR-DEV-3
/// A shape the crop rectangle is held to while it is dragged.
///
/// A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
/// not choosing four edges — they are choosing one edge and a known shape, and
/// a free crop makes them do the arithmetic by eye on every drag. This is the
/// lock that removes it.
///
/// **The ratio is of output pixels, not of the rect's own numbers.** The rect
/// is stored in fractions of a frame that is not square, so `CropRect` needs
/// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is
/// where that conversion is done and explained.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CropAspect {
/// Any shape. The handles move independently, as they always have.
#[default]
Free,
/// Whatever the frame already is, so a crop trims without reshaping.
///
/// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the
/// frame, so it stays right on the next photograph from another body and
/// after a quarter turn.
Original,
/// A named ratio of `w:h`, before the portrait switch is applied.
Fixed(u32, u32),
}
impl CropAspect {
/// The ratios the panel offers, in the order it draws them.
///
/// Short on purpose. These sit as chips in a column narrow enough for a
/// tablet, and every ratio a photographer reaches for repeatedly is here:
/// the frame's own shape, the square, the two classic camera ratios, the
/// large-format one that most print papers follow, and video's.
pub const CHOICES: [Self; 6] = [
Self::Free,
Self::Original,
Self::Fixed(1, 1),
Self::Fixed(3, 2),
Self::Fixed(4, 3),
Self::Fixed(16, 9),
];
/// The chip's text.
pub fn label(self) -> String {
match self {
Self::Free => "Free".to_string(),
Self::Original => "Original".to_string(),
Self::Fixed(w, h) => format!("{w}:{h}"),
}
}
/// Whether this choice has a portrait form at all.
///
/// A square does not, and neither does `Free`. The switch is disabled
/// rather than hidden for those, so the row does not change shape as the
/// chips are tried.
pub fn has_orientation(self) -> bool {
!matches!(self, Self::Free | Self::Fixed(1, 1))
}
/// TRACES: FR-DEV-3
/// Whether a quarter turn of the frame has to flip the orientation switch
/// to leave this ratio describing the same shape.
///
/// A quarter turn carries the crop with it — that is what makes turning a
/// photograph keep its composition — so a rect locked to 16:9 comes out of
/// the turn at 9:16, and the switch has to agree or the next drag would
/// snap the crop back and undo the turn's effect on it.
///
/// `Original` is deliberately *not* included, and getting that wrong flips
/// it twice. It is resolved against the framed size every time it is
/// asked for, and a quarter turn swaps that frame's axes — so it has
/// already turned by the time anything asks.
pub fn turns_with_the_frame(self) -> bool {
matches!(self, Self::Fixed(w, h) if w != h)
}
/// Width over height in output pixels, or `None` where nothing is locked.
///
/// `frame` is the framed size the crop is measured against — the turned
/// frame, not the sensor — which is what makes `Original` follow a quarter
/// turn instead of becoming a portrait crop on a landscape photograph.
pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option<f32> {
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
let landscape = match self {
Self::Free => return None,
Self::Original => fw / fh,
Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32,
};
Some(if portrait && self.has_orientation() {
1.0 / landscape
} else {
landscape
})
}
}
/// Re-express a crop rect after the frame it is measured against turns.
///
/// The crop lives in fractions of the *framed* image — the one the quarter
/// turns have already produced — so turning the frame another quarter leaves
/// the rect describing the wrong region unless it turns with it. Without this,
/// rotating a portrait crop on a landscape photograph slides the selection
/// onto a different part of the picture, which reads as the rotation having
/// moved the image rather than the frame.
///
/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the
/// extents; anticlockwise is the same map run the other way. Applied
/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree.
fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
let mut r = rect;
for _ in 0..turns.rem_euclid(4) {
r = CropRect {
x: 1.0 - r.y - r.height,
y: r.x,
width: r.height,
height: r.width,
};
}
r.normalised()
}
impl DevelopSession {
/// The sensor's own dimensions, before framing.
///
/// What a crop overlay needs: its handles are placed against the full
/// frame, since that is what the user is selecting *from*.
pub fn sensor_size(&self) -> (u32, u32) {
self.demosaiced.size()
}
/// Whether one source pixel now covers more than one screen pixel.
///
/// The question the interface asks to decide how the canvas is *filtered*,
/// not how it is rendered. Below 1:1 there are more source pixels than
/// screen pixels and smoothing is what stops the image aliasing; past it
/// there is no more detail to show, and smoothing only invents values
/// between real ones — at which point a photographer inspecting focus or
/// noise wants to see the pixels, not a blur of them.
///
/// Measured against the visible region rather than the zoom factor alone,
/// because the two differ: a 24 MP file in a 1200px viewport is still
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
/// already magnified at 1×.
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
// How many source pixels lie behind the render target: the framed
// image narrowed to the region the view selects. The target keeps its
// size while that region shrinks, which is what raises the ratio.
let view = self.graph.framing().view();
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
// Strictly greater, with a margin: at exactly 1:1 either filter gives
// the same answer, and flipping mode on a rounding error would make the
// canvas visibly change character mid-scroll.
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
}
/// Set the crop rectangle, in fractions of the source.
pub fn set_crop(&mut self, rect: CropRect) {
self.graph.set_crop(rect);
// Keyed on the operation, not on a parameter: one drag of one handle
// moves the origin and the extent together.
self.history
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
}
/// TRACES: FR-DEV-3
/// Set the crop rectangle, held to `aspect` about `anchor`.
///
/// The frame size the ratio needs is this session's own, so the caller
/// passes a shape rather than a rectangle and never has to know what a
/// quarter turn did to the frame's dimensions.
///
/// `anchor` is the point of the rect that must not move, in the rect's own
/// `0..1` coordinates — the corner *opposite* the handle being dragged, so
/// that shaping the rect onto the ratio pushes the held corner and leaves
/// the far one where the user put it.
pub fn set_crop_locked(
&mut self,
rect: CropRect,
aspect: CropAspect,
portrait: bool,
anchor: (f32, f32),
) {
let frame = self.framed_size();
let rect = match aspect.ratio(frame, portrait) {
Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor),
None => rect,
};
self.set_crop(rect);
}
pub fn crop(&self) -> CropRect {
self.graph.crop()
}
/// TRACES: FR-DEV-3
/// The whole frame the crop is measured against, in output pixels.
///
/// The *framed* size, not the sensor's: quarter turns swap the axes, and a
/// ratio resolved against the sensor would come out on its side the moment
/// a portrait photograph was turned upright. The crop is excluded because
/// this is the shape being selected *from*.
pub fn framed_size(&self) -> (u32, u32) {
let (sw, sh) = self.demosaiced.size();
self.graph.framing().output_size_uncropped(sw, sh)
}
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
///
/// The crop travels with the frame rather than staying where it was on
/// screen. A crop is a decision about *this part of the photograph*, and
/// leaving the rect in place while the image turns under it would move the
/// selection onto a different part of the picture — so the rect is turned
/// by the same quarter and the composition survives the rotation.
pub fn rotate_quarters(&mut self, turns: i32) {
let crop = self.graph.crop();
if !crop.is_full() {
self.graph.set_crop(rotate_crop(crop, turns));
}
self.graph.rotate_quarters(turns);
self.history
.record(&self.graph, Edit::Action(labels::step::ROTATE));
}
/// Straightening, in degrees. Positive turns the image clockwise.
pub fn angle(&self) -> f32 {
self.graph.framing().angle()
}
/// Quarter turns clockwise, 0..=3 — for the panel's readout.
pub fn quarter_turns(&self) -> u8 {
self.graph.framing().quarter_turns()
}
pub fn flips(&self) -> (bool, bool) {
self.graph.framing().flips()
}
/// Mirror horizontally, about the frame's vertical centre line.
pub fn toggle_flip_h(&mut self) {
let (h, _) = self.graph.framing().flips();
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::FLIP_H,
f32::from(u8::from(!h)),
);
self.history
.record(&self.graph, Edit::Action(labels::step::FLIP_H));
}
pub fn toggle_flip_v(&mut self) {
let (_, v) = self.graph.framing().flips();
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::FLIP_V,
f32::from(u8::from(!v)),
);
self.history
.record(&self.graph, Edit::Action(labels::step::FLIP_V));
}
/// TRACES: FR-DEV-3
/// The crop the user chose, as distinct from the one currently applied.
///
/// The remembered intent, but only while it is still credible: if the
/// graph no longer holds what the auto-crop wrote, something else has set
/// the crop since — a handle, a ratio, a sidecar, a paste, an undo — and
/// that new rectangle *is* the intent. See [`Self::auto_crop`].
pub(super) fn intended_crop(&self) -> CropRect {
match self.auto_crop {
Some((applied, intended)) if applied == self.graph.crop() => intended,
_ => self.graph.crop(),
}
}
/// TRACES: FR-DEV-3
/// Fit the crop to the area the straightening angle leaves defined.
///
/// Turning a rectangle inside its own bounds exposes its corners: there is
/// no source pixel out there and the shader renders it black. Nothing in
/// the render prevents it — a free angle deliberately does *not* change the
/// output size, so that straightening a horizon leaves the frame where the
/// user put it — which is correct for the drag and leaves black wedges in
/// the corners of the finished photograph.
///
/// This is the correction, and it runs when the gesture **finishes**.
/// Applied continuously it would fight the drag, shrinking the crop on
/// every frame of the slider.
///
/// **It grows as well as shrinks.** The crop is recomputed from
/// [`Self::intended_crop`] rather than from itself, so straightening
/// further in takes more away and straightening back out gives it back,
/// stopping at the rectangle the user actually chose. Deriving it from the
/// applied crop instead — the obvious way, and how this first shipped —
/// ratchets: every angle the slider rested at takes its cut and none of
/// them is ever returned, so coming back to zero leaves a crop that
/// nothing on screen explains.
///
/// The crop keeps its own shape — so a locked ratio survives — and keeps
/// the side of the frame it was on; see [`CropRect::fitted_into`] for why
/// it is not simply replaced by the inscribed rectangle.
pub fn auto_crop_to_angle(&mut self) {
let (sw, sh) = self.demosaiced.size();
// At zero this is the whole frame, and fitting into it is the identity
// — which is what returns an over-corrected crop to its full size.
// There is deliberately no early exit for the upright case: that exit
// is precisely what would strand the crop small.
let bound = self.graph.framing().max_inscribed_crop(sw, sh);
let intended = self.intended_crop();
let want = intended.fitted_into(bound);
if want != self.graph.crop() {
self.graph.set_crop(want);
self.history
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
}
// Recorded even when nothing moved: the pairing is what tells the next
// call that this rectangle is a correction rather than a choice.
self.auto_crop = Some((want, intended));
}
/// Set the straightening angle, in degrees.
pub fn set_angle(&mut self, degrees: f32) {
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::ANGLE,
degrees,
);
self.history.record(
&self.graph,
Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE),
);
}
/// Whether the framing currently changes the image — what lights the
/// section's modified dot and enables its reset.
///
/// Asks whether it *edits*, not whether it is active: a zoomed view makes
/// the framing active without changing the photograph, and a section that
/// claimed an edit because the user scrolled would be lying.
pub fn framing_edits_image(&self) -> bool {
self.graph.framing().edits_image()
}
/// Return crop, straightening, rotation and flips to neutral, leaving
/// every colour adjustment alone.
///
/// The zoom is deliberately preserved: it is a viewing state, and resetting
/// the framing is an edit, so throwing away where the user was looking
/// would be an unrelated second effect.
pub fn reset_framing(&mut self) {
let view = self.graph.framing().view();
self.graph.framing_mut().reset();
self.graph.framing_mut().set_view(view);
self.history
.record(&self.graph, Edit::Action(labels::step::RESET_FRAMING));
}
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
pub fn zoom(&self) -> f32 {
let v = self.graph.framing().view();
if v.width <= 0.0 {
1.0
} else {
1.0 / v.width
}
}
pub fn is_zoomed(&self) -> bool {
self.graph.framing().is_zoomed()
}
/// Zoom about a point, given in fractions of the *visible* area.
///
/// Anchoring matters: zooming about the pointer keeps whatever is under
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
/// magnifying the photograph rather than sliding it around.
///
/// `factor` multiplies the current zoom — above 1 moves in.
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
const MAX_ZOOM: f32 = 16.0;
let view = self.graph.framing().view();
let current = if view.width > 0.0 {
1.0 / view.width
} else {
1.0
};
let target = (current * factor).clamp(1.0, MAX_ZOOM);
// Snapped so scrolling back out reliably reaches "fit" rather than
// stopping a fraction short and leaving the image imperceptibly
// panned.
let target = if (target - 1.0).abs() < 0.01 {
1.0
} else {
target
};
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
// The point under the cursor, in framed coordinates, must land back
// under the cursor afterwards.
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
self.set_view_clamped(
anchor_x - at_x.clamp(0.0, 1.0) * extent,
anchor_y - at_y.clamp(0.0, 1.0) * extent,
extent,
);
}
/// Pan by a fraction of the *visible* area — what a drag reports.
pub fn pan_by(&mut self, dx: f32, dy: f32) {
let view = self.graph.framing().view();
self.set_view_clamped(
view.x + dx * view.width,
view.y + dy * view.height,
view.width,
);
}
/// Back to fitting the whole frame.
pub fn reset_zoom(&mut self) {
self.graph.framing_mut().set_view(CropRect::default());
}
/// TRACES: FR-UI-4 | FR-DSP-1
/// The zoom that puts one source pixel under one screen pixel.
///
/// **Derived from the file and the viewport rather than fixed at some
/// multiple**, because 1:1 is not a number: a 60 MP frame in a 1200px
/// viewport needs about 7× before its pixels are its own, and a
/// screen-sized JPEG needs none at all. The same arithmetic
/// [`Self::magnifies_source`] uses to decide how to *filter* the canvas,
/// asked in the other direction — which is what keeps the readout the
/// canvas shows and the zoom this lands on from disagreeing about what
/// 100% means.
///
/// Never below 1.0: fitting is as far out as the view goes, so a
/// photograph already smaller than the viewport is at 1:1 the moment it
/// is fitted.
pub fn one_to_one_zoom(&self, viewport_w: u32, viewport_h: u32) -> f32 {
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (rw, _) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
if rw == 0 {
return 1.0;
}
(fw as f32 / rw as f32).max(1.0)
}
/// TRACES: FR-UI-4
/// Where the view is centred, in fractions of the framed image.
///
/// The form the inspection point is *remembered* in, and it has to be
/// this one: the point is carried to the next photograph, and fractions
/// of the frame are the only coordinates two different files share.
pub fn inspection_point(&self) -> (f32, f32) {
let v = self.graph.framing().view();
(v.x + v.width / 2.0, v.y + v.height / 2.0)
}
/// TRACES: FR-UI-4
/// Put the view at 1:1, centred on a point in fractions of the framed
/// image.
///
/// Separate from [`Self::toggle_inspection`] because the two callers are
/// not the same person: the toggle is a photographer pressing something,
/// and this is the next photograph arriving under the magnifier the last
/// one was left under.
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
let extent =
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
}
/// TRACES: FR-UI-4 | FR-DEV-3
/// Toggle between fitting the frame and inspecting it at 1:1.
///
/// **Why 1:1 and not "zoom in a bit".** Noise reduction and capture
/// sharpening are judgements about individual pixels, and at a fitted
/// view several source pixels are averaged into each screen pixel — so
/// the frame looks cleaner and softer than it is, and the photographer
/// corrects for a softness the display invented. Over-sharpening is the
/// documented result. There is exactly one magnification at which those
/// two controls are telling the truth, and this is the gesture that
/// reaches it without anyone reading a percentage.
///
/// `at_x`/`at_y` are fractions of the *visible* area — the same
/// coordinates [`Self::zoom_about`] takes, because they come from the
/// same pointer over the same box.
///
/// Returns the point now under inspection in fractions of the framed
/// image, or `None` where the view has gone back to fit. That is the
/// answer *after* clamping, so a point near an edge is remembered where
/// the view actually landed rather than where the finger was — otherwise
/// the next photograph would be inspected somewhere the previous one
/// never showed.
///
/// Leaves the history alone, and must: this changes no pixel of the file.
/// See [`Self::framing_edits_image`] for the same distinction drawn from
/// the other side.
pub fn toggle_inspection(
&mut self,
at_x: f32,
at_y: f32,
viewport_w: u32,
viewport_h: u32,
) -> Option<(f32, f32)> {
// Out from *any* zoom, not only from 1:1. The gesture means "show me
// the whole photograph again", and a scroll wheel that stopped at
// 173% must not leave the toggle inert.
if self.is_zoomed() {
self.reset_zoom();
return None;
}
let view = self.graph.framing().view();
let x = view.x + at_x.clamp(0.0, 1.0) * view.width;
let y = view.y + at_y.clamp(0.0, 1.0) * view.height;
self.inspect_at(x, y, viewport_w, viewport_h);
Some(self.inspection_point())
}
/// Place a square view of `extent`, keeping it inside the frame.
///
/// Clamped rather than allowed to run off the edge: panning past the
/// boundary would show undefined area beside the photograph, which reads
/// as a rendering fault rather than as the end of the image.
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
let max = 1.0 - extent;
self.graph.framing_mut().set_view(CropRect {
x: x.clamp(0.0, max.max(0.0)),
y: y.clamp(0.0, max.max(0.0)),
width: extent,
height: extent,
});
}
/// The largest centred crop that, at the current straightening angle,
/// contains no undefined area. What a "straighten and fill" action
/// applies.
pub fn max_inscribed_crop(&self) -> CropRect {
let (w, h) = self.demosaiced.size();
self.graph.framing().max_inscribed_crop(w, h)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::develop::test_support::*;
// --- the crop ratio lock ---------------------------------------------
#[test]
fn a_locked_ratio_is_resolved_in_output_pixels() {
// 3:2 means three pixels across to two down, whatever shape the frame
// it is being cut out of happens to be.
let landscape = CropAspect::Fixed(3, 2);
assert_eq!(landscape.ratio((6000, 4000), false), Some(1.5));
assert_eq!(landscape.ratio((4000, 6000), false), Some(1.5));
// Stood on its short edge.
assert_eq!(landscape.ratio((6000, 4000), true), Some(2.0 / 3.0));
}
#[test]
fn the_frames_own_ratio_follows_the_frame() {
// What separates `Original` from naming the same numbers: it is right
// on the next photograph from another body, and after a quarter turn.
let a = CropAspect::Original;
assert_eq!(a.ratio((6000, 4000), false), Some(1.5));
assert_eq!(a.ratio((4000, 6000), false), Some(2.0 / 3.0));
assert_eq!(a.ratio((5000, 5000), false), Some(1.0));
}
#[test]
fn free_locks_nothing() {
assert_eq!(CropAspect::Free.ratio((6000, 4000), false), None);
assert_eq!(CropAspect::Free.ratio((6000, 4000), true), None);
assert!(!CropAspect::Free.has_orientation());
}
#[test]
fn a_square_has_no_second_orientation() {
// Turning it would be a control that visibly does nothing, so the
// switch is disabled and the flag is ignored either way.
let square = CropAspect::Fixed(1, 1);
assert!(!square.has_orientation());
assert_eq!(square.ratio((6000, 4000), true), Some(1.0));
assert_eq!(square.ratio((6000, 4000), false), Some(1.0));
}
#[test]
fn only_a_named_ratio_has_to_be_turned_with_the_frame() {
// The distinction that stops `Original` being flipped twice: a quarter
// turn swaps the frame's axes, so a ratio resolved *against* the frame
// has already turned by the time anything asks it.
assert!(CropAspect::Fixed(16, 9).turns_with_the_frame());
assert!(CropAspect::Fixed(3, 2).turns_with_the_frame());
assert!(!CropAspect::Original.turns_with_the_frame());
assert!(!CropAspect::Free.turns_with_the_frame());
assert!(!CropAspect::Fixed(1, 1).turns_with_the_frame());
}
#[test]
fn a_quarter_turn_leaves_a_locked_crop_the_shape_it_already_was() {
// The whole reason the switch is flipped on a quarter turn. A crop
// locked to 16:9 is carried through the turn by `rotate_crop`, coming
// out at 9:16 of a frame whose axes have also swapped — so the lock
// must now read as portrait, or the next drag would snap the crop back
// upright and undo what the turn did to the composition.
let (fw, fh) = (6000u32, 4000u32);
let aspect = CropAspect::Fixed(16, 9);
let before = CropRect::default().with_aspect(
fw,
fh,
aspect.ratio((fw, fh), false).unwrap(),
(0.5, 0.5),
);
let after = rotate_crop(before, 1);
let (tw, th) = (fh, fw);
let got = (after.width * tw as f32) / (after.height * th as f32);
let want = aspect.ratio((tw, th), true).unwrap();
assert!(
(got / want - 1.0).abs() < 1e-3,
"turned crop is {got}, the flipped lock says {want}"
);
}
#[test]
fn every_offered_ratio_has_a_name_and_a_place() {
// The chips are drawn from this list, so a duplicate would light two
// at once and an empty label would draw a blank button.
let mut seen = Vec::new();
for a in CropAspect::CHOICES {
assert!(!a.label().is_empty(), "{a:?} has no label");
assert!(!seen.contains(&a), "{a:?} is offered twice");
seen.push(a);
}
assert_eq!(
CropAspect::CHOICES[0],
CropAspect::Free,
"free is the default"
);
assert_eq!(CropAspect::default(), CropAspect::Free);
}
/// TRACES: FR-UI-4
/// The inspection zoom is 1:1 for *this* file in *this* viewport.
///
/// The number is the whole point. A magnifier that lands on some fixed
/// multiple tells the photographer nothing about whether they are looking
/// at the file's own pixels, and that is the only question noise reduction
/// and capture sharpening can honestly be judged by.
#[test]
fn inspecting_lands_on_one_source_pixel_per_screen_pixel() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
// Sixty-four source pixels fitted into thirty-two is one screen pixel
// per two of the file's, so 1:1 is 2×.
let one_to_one = session.one_to_one_zoom(32, 32);
assert!(
(one_to_one - 2.0).abs() < 1e-3,
"a 64px frame in a 32px viewport is 2× at 1:1, not {one_to_one}"
);
assert!(
session.toggle_inspection(0.5, 0.5, 32, 32).is_some(),
"the first toggle goes in"
);
assert!(
(session.zoom() - one_to_one).abs() < 1e-3,
"the view should have landed on 1:1, not {}",
session.zoom()
);
}
/// TRACES: FR-UI-4
/// The second press goes back to fit — from any zoom, not only from 1:1.
///
/// A scroll wheel that stopped at 173% must not leave the toggle inert:
/// the gesture means "show me the whole photograph again".
#[test]
fn the_inspection_toggle_returns_to_fit_from_any_zoom() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.zoom_about(3.0, 0.5, 0.5);
assert!(session.is_zoomed(), "the premise");
assert_eq!(
session.toggle_inspection(0.5, 0.5, 32, 32),
None,
"toggling out reports no inspection point"
);
assert!(!session.is_zoomed());
}
/// TRACES: FR-UI-4 | FR-DEV-5
/// Inspecting is a way of looking, and leaves no trace on the photograph.
///
/// The failure this guards is quiet and expensive: a zoom that recorded a
/// step would put a viewport rectangle on the undo stack and into the
/// sidecar, and the photograph would then open on another device cropped
/// to wherever somebody once looked.
#[test]
fn inspecting_writes_nothing_the_file_would_remember() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
assert!(!session.can_undo(), "the premise: nothing has been done");
session.toggle_inspection(0.25, 0.75, 32, 32);
assert!(!session.can_undo(), "a zoom is not a step to take back");
assert!(session.is_neutral(), "and it is not an edit either");
assert!(!session.framing_edits_image());
}
/// The whole scroll-to-zoom path, end to end, in the order the user drives
/// it: show the image fitted, *then* turn the wheel.
///
/// The lower layers each had zoom tests and each passed while this was
/// broken, because every one of them set a view before its first render.
/// That ordering hid the bug — a neutral framing compiles a prologue that
/// never reads the crop rect, and while zoom was absent from the structure
/// hash that pipeline stayed cached once zoomed. The session reported the
/// new zoom, the uniforms carried the new view, and the pixels never moved.
///
/// So this asserts on the rendered pixels rather than on `zoom()`: the
/// symptom was precisely that the state was right and the image was not.
#[test]
fn zooming_after_a_fitted_render_changes_the_pixels() {
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
// A gradient, so any change in the sampled region moves the pixels.
let (w, h) = (64u32, 64u32);
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]);
}
}
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
.expect("session");
let fitted = session.render(64, 64).expect("fitted render");
session.zoom_about(4.0, 0.5, 0.5);
assert!(session.is_zoomed(), "the session did not register the zoom");
let zoomed = session.render(64, 64).expect("zoomed render");
// Both images are still readable here because consecutive frames go to
// alternating textures; see `AdjustPass::targets`. Holding two frames
// at once would be meaningless against a single reused target.
let before = read_back(&ctx, &fitted);
let after = read_back(&ctx, &zoomed);
let differing = before
.iter()
.zip(after.iter())
.filter(|(a, b)| a != b)
.count();
assert!(
differing > 0,
"zooming 4x after a fitted render produced identical pixels — the \
view reached the session but not the shader"
);
}
#[test]
fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() {
// What decides whether the canvas is filtered. The distinction this
// guards is the reason the interface cannot answer it from `zoom()`
// alone: the same 4x on a large source is still showing more source
// pixels than screen pixels, while on a small one it is already
// inventing values between them.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
// Bigger than the viewport it is shown in: `fit` scales it down, so
// every screen pixel still has several source pixels behind it.
let big = vec![128u8; (800 * 800 * 4) as usize];
let mut session =
DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!session.magnifies_source(200, 200),
"a downscaled image is not magnified"
);
session.zoom_about(2.0, 0.5, 0.5);
assert!(
!session.magnifies_source(200, 200),
"2x on a 4x-downscaled source is still below 1:1"
);
session.zoom_about(8.0, 0.5, 0.5);
assert!(
session.magnifies_source(200, 200),
"16x on a 4x-downscaled source magnifies and must not be filtered"
);
// Smaller than the viewport: `fit` refuses to upscale, so the render is
// 1:1 and unzoomed is exactly the boundary — not past it.
let small = vec![128u8; (100 * 100 * 4) as usize];
let mut session =
DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!session.magnifies_source(800, 800),
"1:1 is the boundary, not past it — filtering must not flip on a \
rounding error"
);
session.zoom_about(2.0, 0.5, 0.5);
assert!(
session.magnifies_source(800, 800),
"any zoom past a 1:1 render magnifies"
);
}
#[test]
fn four_quarter_turns_return_a_crop_where_it_started() {
// The property that makes rotation safe to repeat: a user who turns
// past the orientation they wanted and keeps going must arrive back at
// the crop they had, not at a slowly drifting one.
let start = CropRect {
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.4,
};
let mut r = start;
for _ in 0..4 {
r = rotate_crop(r, 1);
}
assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x);
assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y);
assert!((r.width - start.width).abs() < 1e-5);
assert!((r.height - start.height).abs() < 1e-5);
}
#[test]
fn a_quarter_turn_exchanges_a_crops_extents() {
// A portrait selection on a landscape frame must come out landscape.
// Were the extents left alone, the rect would keep its old shape while
// the frame changed to the other one, and the crop would spill off the
// photograph.
let r = rotate_crop(
CropRect {
x: 0.0,
y: 0.0,
width: 0.25,
height: 1.0,
},
1,
);
assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width);
assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height);
}
#[test]
fn rotating_a_crop_keeps_it_inside_the_frame() {
// Whatever the angle and wherever the rect, the result must still be a
// rect the pipeline can render: outside the unit square it would
// sample undefined area, and degenerate it is a zero-sized texture.
for turns in -5..=5 {
for rect in [
CropRect {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
},
CropRect {
x: 0.7,
y: 0.8,
width: 0.3,
height: 0.2,
},
CropRect {
x: 0.0,
y: 0.45,
width: 0.02,
height: 0.02,
},
] {
let r = rotate_crop(rect, turns);
assert!(
r.x >= 0.0 && r.y >= 0.0,
"{turns} turns of {rect:?} gave {r:?}"
);
assert!(
r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5,
"{turns} turns of {rect:?} left the frame: {r:?}"
);
assert!(
r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT,
"{turns} turns of {rect:?} went degenerate: {r:?}"
);
}
}
}
#[test]
fn opposite_quarter_turns_cancel() {
// The rotate-left and rotate-right buttons must undo one another, or
// correcting an over-rotation would land somewhere new each time.
let start = CropRect {
x: 0.15,
y: 0.05,
width: 0.5,
height: 0.25,
};
let there_and_back = rotate_crop(rotate_crop(start, 1), -1);
assert!((there_and_back.x - start.x).abs() < 1e-5);
assert!((there_and_back.y - start.y).abs() < 1e-5);
assert!((there_and_back.width - start.width).abs() < 1e-5);
assert!((there_and_back.height - start.height).abs() < 1e-5);
}
#[test]
fn a_full_crop_survives_rotation_as_a_full_crop() {
// The common case: rotating an uncropped photograph must not quietly
// introduce a crop, which would shrink the exported image.
assert!(rotate_crop(CropRect::default(), 1).is_full());
assert!(rotate_crop(CropRect::default(), -3).is_full());
}
}