Let the crop be held to a ratio 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.
Free-dragging every corner made them do that arithmetic by eye on every
drag, and get it slightly wrong.

The panel now offers Free, Original, 1:1, 3:2, 4:3 and 16:9, with a
Portrait switch for the ones that have two orientations. Original follows
the frame rather than naming a number, so it stays right on the next
photograph from another body and after a quarter turn.

**The ratio is of output pixels, and the rect is not.** `CropRect` is
stored in fractions of a frame that is not itself square, so holding a
shape needs the frame's size — `ratio * height / width` of the frame.
Skipping that gives a "1:1" crop that is square only on a square
photograph, which is the one case nobody would test on, so the conversion
lives in `CropRect::with_aspect` where it is explained and pinned by a
test that asserts the fractions are *not* equal.

Two decisions worth recording:

The reshaped rect **grows** onto the ratio rather than shrinking onto it,
then scales down only as far as the frame's edge demands. Fitting inside
instead makes a one-axis drag do nothing at all — the other axis clamps
the first straight back, and the handle simply refuses to move.

The overlay now reports **which corner the drag is holding**, because
reshaping onto a ratio has to know which corner is nailed down and only
the handle that took the press knows that. A move reports no corner and
keeps its shape: reshaping about a centre would pull an over-moved rect
smaller instead of sliding it along the edge.

The lock lives with the window rather than the session. A `DevelopSession`
is per image, and cropping a set of frames to one shape is exactly when
the lock earns its place. It is not an edit and reaches no sidecar — what
is saved is the rectangle it produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:48:51 +02:00
co-authored by Claude Opus 5
parent dbaf5358d1
commit e6ad906bc1
7 changed files with 644 additions and 56 deletions
+119
View File
@@ -583,6 +583,87 @@ fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
top * (1.0 - fy) + bot * fy
}
/// 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))
}
/// 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
})
}
}
pub struct DevelopSession {
/// This session's name, for work that outlives the frame it started on.
id: SessionId,
@@ -3138,10 +3219,48 @@ impl DevelopSession {
.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