//! 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, magnification, shows_source_pixels}; 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 { 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() } /// TRACES: FR-DEV-17 /// The mask layers a committed crop took out of the frame. /// /// **Tied to a position in the history, not to a timer or a click.** The /// notice speaks about the step on top of the stack, and it is only true /// while that step is on top: an undo takes the crop back, a later edit puts /// something else there, and either way the notice has nothing left to say. /// So it carries the revision it was raised at, and /// [`DevelopSession::crop_notice`] stops returning it the moment the history /// moves — which is what makes taking the crop back and taking the warning /// away one step rather than two. #[derive(Debug, Clone)] pub struct CropNotice { /// [`DevelopSession::history_revision`] when the crop was let go. revision: u64, /// The framing before the crop, kept so a second drag folded into the /// same step is measured from where the step began rather than from the /// already-hidden state the first drag left. from: dr_pipeline::Framing, /// Each hidden layer's name, in stack order. names: Vec, } impl CropNotice { pub fn names(&self) -> &[String] { &self.names } } impl DevelopSession { /// The framing as it stands — what a gesture that is about to change the /// crop hands back to [`Self::notice_hidden_masks`] once it is let go. pub fn framing(&self) -> dr_pipeline::Framing { *self.graph.framing() } /// TRACES: FR-DEV-17 /// Measure the crop just committed against `before`, and raise a notice /// for the mask layers it took out of the frame. Returns the notice, or /// `None` when nothing was stranded — the common case, which says nothing. /// /// Called when a crop is *let go*, never per frame of the drag: a handle /// passing over a mask on its way somewhere else is not an event, and the /// measurement samples every layer, which is work for a commit rather than /// a pointer move. /// /// A model's selection is measured from the raster the live segmentation /// holds when the layer carries none of its own — the session keeps its /// model output outside the graph until a save folds it in. pub fn notice_hidden_masks(&mut self, before: &dr_pipeline::Framing) -> Option<&CropNotice> { use dr_pipeline::mask::{MaskPart, MaskSource}; use dr_pipeline::orphan::{hidden_by_crop, Raster}; use std::borrow::Cow; let revision = self.history.revision(); // Still standing on the step a notice was raised for: a second drag // folded into that step is measured from where the step began. let from = match &self.crop_notice { Some(n) if n.revision == revision => n.from, _ => *before, }; let source = self.demosaiced.size(); let seg = self.segmentation.as_ref(); let model = |part: &MaskPart| -> Option> { let seg = seg?; if part.is_stale(seg.signature()) { return None; } let values = match &part.source { MaskSource::Subject { index, .. } => { Cow::Borrowed(seg.instance_mask(*index as usize)?) } MaskSource::Category { name, .. } => seg.category_mask_at(name, part.refine)?, _ => return None, }; let (width, height) = seg.proxy_size(); Some(Raster { values, width, height, }) }; let names: Vec = hidden_by_crop( self.graph.masks(), &from, self.graph.framing(), source, &model, ) .into_iter() .map(|layer| layer.display_name().to_string()) .collect(); self.crop_notice = (!names.is_empty()).then_some(CropNotice { revision, from, names, }); self.crop_notice.as_ref() } /// TRACES: FR-DEV-17 /// The notice for the crop on top of the history, if it is still on top. pub fn crop_notice(&self) -> Option<&CropNotice> { self.crop_notice .as_ref() .filter(|n| n.revision == self.history.revision()) } /// TRACES: FR-DEV-17 /// Keep the crop: the photographer has read the notice and meant it. pub fn dismiss_crop_notice(&mut self) { self.crop_notice = None; } /// 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() } /// TRACES: FR-UI-4 /// Whether the canvas is showing the file's own pixels: at 1:1 or closer, /// one source pixel to one screen pixel or more. /// /// The question the interface asks to decide how the canvas is *filtered*. /// Below 1:1 there are more source pixels than screen pixels and smoothing /// is what stops the image aliasing; from 1:1 on 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. [`Self::render`] draws such a view at the /// source's own resolution for the same reason, so the canvas is the one /// enlarging it. /// /// 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×. /// /// `viewport_w`/`viewport_h` are **physical** pixels, as for /// [`Self::one_to_one_zoom`]; the arithmetic and its tolerance live in /// `render::magnification` and `render::shows_source_pixels`. pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool { let (sw, sh) = self.demosaiced.size(); let framed = self.graph.output_size(sw, sh); let view = self.graph.framing().view(); shows_source_pixels(magnification( framed, (view.width, view.height), (viewport_w, viewport_h), )) } /// 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, and the canvas // stretches the render to the box — so fitted, it is already on screen // magnified, and is drawn as pixels like any other view past 1:1. let small = vec![128u8; (100 * 100 * 4) as usize]; let session = DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL) .expect("session"); assert!( session.magnifies_source(800, 800), "a 100px image filling an 800px canvas is 8x, fitted or not" ); assert!( session.magnifies_source(100, 100), "exactly 1:1 shows the file's own pixels too" ); assert!(!session.magnifies_source(50, 50), "and half size does not"); } /// TRACES: FR-UI-4 /// Past 1:1 the pipeline renders the region at the source's resolution /// and leaves the enlargement to the canvas. /// /// The failure this guards: a viewport-sized render at 4× is the pipeline /// upsampling — bilinearly under any straightening angle or lens /// correction, and then sharpened at a radius scaled to match — so the /// canvas's nearest-neighbour filter was handed pixels already smoothed, /// and a photographer at 1:1 or beyond saw a blur rather than the file. #[test] fn a_magnified_view_is_rendered_at_the_sources_own_resolution() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); // Sixty-four source pixels in a thirty-two pixel viewport: fitted, // the render is the viewport. let fitted = session.render(32, 32).expect("fitted render"); assert_eq!((fitted.size().width, fitted.size().height), (32, 32)); // At 1:1 the region behind the viewport is thirty-two source pixels. session.toggle_inspection(0.5, 0.5, 32, 32); let one_to_one = session.render(32, 32).expect("1:1 render"); assert_eq!( (one_to_one.size().width, one_to_one.size().height), (32, 32) ); assert!(session.magnifies_source(32, 32), "1:1 is drawn as pixels"); // At 4× only sixteen are behind it, and sixteen are what is rendered. session.reset_zoom(); session.zoom_about(4.0, 0.5, 0.5); let magnified = session.render(32, 32).expect("magnified render"); assert_eq!( (magnified.size().width, magnified.size().height), (16, 16), "a 4x view of a 64px frame has 16 source pixels behind a 32px \ viewport; rendering more is the pipeline inventing them" ); } #[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()); } /// TRACES: FR-DEV-17 /// The acceptance, end to end through the session: a crop that strands a /// layer raises a notice naming it, a crop that does not says nothing, /// and one undo takes back the crop and the notice together. #[test] fn a_crop_that_strands_a_mask_is_noticed_and_undone_as_one_step() { let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = vec![128u8; 64 * 64 * 4]; let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); // A radial in the middle of the frame. session.add_gradient_mask(true).expect("a radial layer"); // Trimmed, not stranded: no notice on the common path. let before = session.framing(); session.set_crop(CropRect { x: 0.1, y: 0.1, width: 0.8, height: 0.8, }); assert!(session.notice_hidden_masks(&before).is_none()); assert!(session.crop_notice().is_none()); // Into a corner the radial does not reach. let before = session.framing(); std::thread::sleep(dr_pipeline::history::COALESCE_WINDOW); session.set_crop(CropRect { x: 0.0, y: 0.0, width: 0.12, height: 0.12, }); let names = session .notice_hidden_masks(&before) .map(|n| n.names().to_vec()) .expect("the radial was stranded"); assert_eq!(names, vec!["radial".to_string()]); assert!(session.crop_notice().is_some()); // The crop was applied, not refused. assert!(session.crop().width < 0.2); // One undo: the crop goes back and the notice goes with it. assert!(session.undo()); assert!((session.crop().width - 0.8).abs() < 1e-6); assert!(session.crop_notice().is_none()); } }