Files
DarkRoom/ui/dr-ui/src/develop/framing.rs
T
dtourolle 114d979397 Add Vertical and Horizontal perspective sliders to Compose
The keystone existed in framing but nothing in develop could reach it:
framing is presented by its own Compose panel rather than generated, so
new framing parameters get no control until the panel names them.

Compose now has Vertical and Horizontal sliders under Straighten,
mirrored from the session like the angle, recorded as parameter steps
("Vertical Perspective" in the history), cleared by the Compose reset
and by opening the next photograph. Releasing either slider refits the
crop the way releasing the straighten slider does: a keystone alone
needs no crop, but it moves the empty corners of a straightened frame,
so the crop that avoided them before may not after, or may have room
to grow back.
2026-09-24 22:13:12 -04:00

1254 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<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()
}
/// 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<String>,
}
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<Raster<'_>> {
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<String> = 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.
///
/// TRACES: FR-DEV-20
/// **A keystone gesture ends here too.** A keystone alone never exposes a
/// corner, but it reshapes the area a *straightened* frame has pixels
/// in, so the crop that avoided the corners before it may not avoid them
/// after — and one it had to shrink may now have room to grow back.
/// [`dr_pipeline::Framing::max_inscribed_crop`] accounts for both.
///
/// 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),
);
}
/// TRACES: FR-DEV-20
/// The vertical and horizontal keystone, as the panel shows them.
pub fn keystone(&self) -> (f32, f32) {
self.graph.framing().keystone()
}
/// TRACES: FR-DEV-20
/// Set the vertical keystone. Positive spreads the top of the frame.
///
/// Recorded as a parameter step, like the angle, so a drag collapses into
/// one entry in the history rather than one per frame of the slider.
pub fn set_keystone_v(&mut self, amount: f32) {
self.set_framing_param(dr_pipeline::framing::KEYSTONE_V, amount);
}
/// TRACES: FR-DEV-20
/// Set the horizontal keystone. Positive spreads the right-hand side.
pub fn set_keystone_h(&mut self, amount: f32) {
self.set_framing_param(dr_pipeline::framing::KEYSTONE_H, amount);
}
fn set_framing_param(&mut self, param: dr_pipeline::ParamId, value: f32) {
self.graph.set_param(dr_pipeline::framing::ID, param, value);
self.history
.record(&self.graph, Edit::Param(dr_pipeline::framing::ID, param));
}
/// 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"
);
}
/// TRACES: FR-DEV-20
/// The keystone sliders are edits with a history, a reset, and a crop
/// that follows them.
#[test]
fn a_keystone_is_an_undoable_edit_the_crop_follows() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.set_keystone_v(60.0);
assert_eq!(session.keystone(), (60.0, 0.0));
assert!(session.framing_edits_image());
assert!(
session.undo_label().contains("Vertical Perspective"),
"{}",
session.undo_label()
);
// Alone it needs no crop: the end of the gesture leaves it full.
session.auto_crop_to_angle();
assert!(session.crop().is_full(), "{:?}", session.crop());
// Straightened as well, the end of the gesture pulls the crop inside
// the area both together leave defined — the core's inscribed rect,
// at the shape of the crop the user chose (here the whole frame).
session.set_angle(6.0);
session.auto_crop_to_angle();
let with_both = session.crop();
assert_eq!(
with_both,
CropRect::default().fitted_into(session.max_inscribed_crop())
);
assert!(!with_both.is_full());
// Releasing the keystone refits from the crop the user chose, so the
// crop grows back to the straightening's own.
session.set_keystone_v(0.0);
session.auto_crop_to_angle();
let straightened_only = session.crop();
assert_eq!(
straightened_only,
CropRect::default().fitted_into(session.max_inscribed_crop())
);
assert_ne!(straightened_only, with_both);
// And it is one reset away from neutral, like the rest of framing.
session.set_keystone_h(-30.0);
session.reset_framing();
assert_eq!(session.keystone(), (0.0, 0.0));
assert!(!session.framing_edits_image());
assert!(session.undo());
assert_eq!(
session.keystone(),
(0.0, -30.0),
"undo restores the keystone"
);
}
#[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());
}
}