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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user