Cropping tighter past a mask layer made it invisible without a word: the layer stayed in the panel and the sidecar, and its adjustment went on landing on pixels nobody would see again. When a crop is let go, develop now measures what the gesture did to the mask stack (dr_pipeline::orphan) and, if any layer is now entirely or mostly outside the frame, shows a notice over the photograph: how many layers, their names, "Undo crop" and "Keep crop". The crop is already applied and nothing waits on the answer. The crop overlay gains a release callback carrying the rect the press began from, so the measurement runs once per gesture and never on the drag's per-frame changes. Choosing a ratio is measured the same way, being a crop committed in one click. "Undo crop" is the ordinary undo, and the notice is tied to the history revision it was raised at: the redraw that follows any history move clears it, so the crop and its warning go back as one step. A second drag folded into the same step is measured from where that step began. A crop that strands nothing shows nothing.
1162 lines
47 KiB
Rust
1162 lines
47 KiB
Rust
//! 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.
|
||
///
|
||
/// 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());
|
||
}
|
||
}
|