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
+216
View File
@@ -173,6 +173,79 @@ impl CropRect {
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
}
}
/// TRACES: FR-DEV-3
/// Reshape onto an aspect ratio, holding one point of the rect still.
///
/// `ratio` is width over height **in output pixels** — 1.5 for 3:2 — which
/// is what a photographer means by an aspect ratio and is emphatically not
/// what this rect stores. The rect is in fractions of a frame that is
/// itself not square, so the ratio it needs is `ratio * height / width` of
/// that frame. Skipping the conversion yields a "1:1" crop that is square
/// only on a square photograph, which is the one case nobody tests on.
///
/// `anchor` is the point of the rect that stays put, in the rect's own
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when
/// a ratio is chosen and the composition should stay where it is.
///
/// **The rect grows onto the ratio rather than shrinking onto it.** The
/// axis that is short is extended; the long one is never trimmed. Fitting
/// inside instead reads as a dead control — dragging a corner outward
/// along one axis alone would be immediately clamped back by the other,
/// and the handle would simply refuse to move. The result is then scaled
/// down, both axes together, only as far as the frame's edge demands.
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
let rect = self.normalised();
let ratio = finite(ratio, 0.0);
if ratio <= 0.0 || frame_w == 0 || frame_h == 0 {
return rect;
}
// Width over height as a fraction of *this* frame, not in pixels.
let r = ratio * frame_h as f32 / frame_w as f32;
let ax = finite(anchor.0, 0.5).clamp(0.0, 1.0);
let ay = finite(anchor.1, 0.5).clamp(0.0, 1.0);
// Where the fixed point sits in the frame. Everything below is built
// outwards from it, which is what makes the anchor mean anything.
let px = rect.x + ax * rect.width;
let py = rect.y + ay * rect.height;
let mut w = rect.width.max(rect.height * r);
let mut h = w / r;
// Scaled to fit, never clamped to fit: clamping one axis against the
// frame would break the very ratio this exists to hold.
let shrink = (room(px, ax) / w).min(room(py, ay) / h).min(1.0);
if shrink.is_finite() && shrink > 0.0 {
w *= shrink;
h *= shrink;
}
Self {
x: px - ax * w,
y: py - ay * h,
width: w,
height: h,
}
.normalised()
}
}
/// How far a rect anchored at `p` with the anchor `a` fractions along it may
/// extend before it leaves the `0..1` frame.
///
/// One axis at a time, and infinite where the anchor is at the far end and the
/// rect therefore only grows away from that edge.
fn room(p: f32, a: f32) -> f32 {
let before = if a > 0.0 { p / a } else { f32::INFINITY };
let after = if a < 1.0 {
(1.0 - p) / (1.0 - a)
} else {
f32::INFINITY
};
before.min(after)
}
/// Replace a non-finite value with a fallback.
@@ -1590,6 +1663,149 @@ mod tests {
}
}
// --- the aspect lock and the safe-area fit ---------------------------
/// A crop's shape in output pixels, which is the thing an aspect ratio is
/// about — the rect's own numbers are fractions of a frame that is not
/// square, so they are not comparable to `3.0 / 2.0` on their own.
fn pixel_ratio(c: CropRect, w: u32, h: u32) -> f32 {
(c.width * w as f32) / (c.height * h as f32)
}
#[test]
fn a_locked_ratio_is_a_ratio_of_pixels_not_of_fractions() {
// The whole of why `with_aspect` takes the frame size. On a 3:2
// photograph a square crop is *not* a square in fractions, and a
// version that skipped the conversion would pass every test written
// on a square frame.
let c = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
assert!(
(pixel_ratio(c, 6000, 4000) - 1.0).abs() < 1e-4,
"expected a square in pixels, got {c:?}"
);
assert!(
(c.width - c.height).abs() > 0.1,
"a square on a 3:2 frame must not be square in fractions: {c:?}"
);
}
#[test]
fn every_offered_ratio_comes_back_at_the_ratio_asked_for() {
for (rw, rh) in [(1, 1), (3, 2), (4, 3), (5, 4), (16, 9), (2, 3), (9, 16)] {
let want = rw as f32 / rh as f32;
for (fw, fh) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
let c = CropRect {
x: 0.2,
y: 0.3,
width: 0.4,
height: 0.25,
}
.with_aspect(fw, fh, want, (0.5, 0.5));
let got = pixel_ratio(c, fw, fh);
assert!(
(got / want - 1.0).abs() < 1e-3,
"{rw}:{rh} on {fw}x{fh} came back as {got}"
);
}
}
}
#[test]
fn a_locked_rect_stays_inside_the_frame_whatever_is_asked_for() {
// The reason the fit is a scale rather than a clamp: clamping one
// axis at the edge would hold the rect inside at the cost of the
// ratio, which is the one property the caller asked for.
for (rw, rh) in [(1, 1), (3, 2), (16, 9), (9, 16)] {
for anchor in [(0.0, 0.0), (1.0, 1.0), (1.0, 0.0), (0.5, 0.5)] {
let c = CropRect {
x: 0.05,
y: 0.9,
width: 0.9,
height: 0.09,
}
.with_aspect(6000, 4000, rw as f32 / rh as f32, anchor);
assert!(
c.x >= -1e-5
&& c.y >= -1e-5
&& c.x + c.width <= 1.0 + 1e-5
&& c.y + c.height <= 1.0 + 1e-5,
"{rw}:{rh} at {anchor:?} left the frame: {c:?}"
);
let got = pixel_ratio(c, 6000, 4000);
let want = rw as f32 / rh as f32;
assert!(
(got / want - 1.0).abs() < 1e-3,
"{rw}:{rh} at {anchor:?} lost its ratio: {got}"
);
}
}
}
#[test]
fn the_anchor_corner_is_the_one_that_does_not_move() {
// What makes a corner drag feel like a corner drag: the opposite
// corner stays nailed down while the held one shapes the rect.
let start = CropRect {
x: 0.2,
y: 0.2,
width: 0.3,
height: 0.3,
};
// Bottom-right held still, top-left free.
let c = start.with_aspect(6000, 4000, 1.0, (1.0, 1.0));
assert!(
(c.x + c.width - (start.x + start.width)).abs() < 1e-5,
"{c:?}"
);
assert!(
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
"{c:?}"
);
// Top-left held still, bottom-right free.
let c = start.with_aspect(6000, 4000, 1.0, (0.0, 0.0));
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
}
#[test]
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
// Shrinking to fit makes a one-axis drag do nothing at all: the other
// axis clamps the first straight back, and the handle refuses to move.
let start = CropRect {
x: 0.1,
y: 0.1,
width: 0.6,
height: 0.3,
};
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.0));
assert!(
c.width >= start.width - 1e-5,
"{c:?} narrower than {start:?}"
);
assert!(
c.height >= start.height - 1e-5,
"{c:?} shorter than {start:?}"
);
}
#[test]
fn a_free_or_nonsense_ratio_leaves_the_rect_alone() {
// Nothing here should be a failure the caller has to handle: "no
// ratio" is the ordinary state of the crop tool.
let start = CropRect {
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.4,
};
for bad in [0.0, -1.5, f32::NAN, f32::INFINITY] {
assert_eq!(start.with_aspect(6000, 4000, bad, (0.5, 0.5)), start);
}
// A frame with no extent cannot define a ratio either.
assert_eq!(start.with_aspect(0, 0, 1.0, (0.5, 0.5)), start);
}
#[test]
fn parameters_round_trip() {
let mut f = Framing::new();