Merge PR #2322: LR-CROP-RECT: a free crop corner on a straightened photo grows both sides (#2308)

crates/geom/src/crop_drag.rs: kept main's 'mod tests;'; the PR's four new tests ported to crop_drag/tests.rs.
This commit is contained in:
aiongg
2026-10-31 02:08:45 +00:00
3 changed files with 106 additions and 10 deletions
+40 -9
View File
@@ -114,7 +114,10 @@ pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer:
};
// straightened, go to the nearest place that fits, so the frame slides along tilted edges (#2299)
let nearest = if base.angle != 0.0 {
let from = |r: Rect| nearest_move(r, base.angle, w, h, Vec2::new(dx, dy)).map(|v| r.translate(v)).filter(|r| feasible(*r));
let from = |r: Rect| {
let corners = r.corners().map(|c| (c, Vec2::new(1.0, 1.0)));
nearest_offset(&corners, &[], base.angle, w, h, Vec2::new(dx, dy)).map(|v| r.translate(v)).filter(|r| feasible(*r))
};
// a frame at the tolerance's edge on opposite sides moves only once it is inside it
from(sr).or_else(|| shrunk_inside(sr, base.angle, w, h).and_then(from))
} else {
@@ -138,8 +141,14 @@ pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer:
let at = |t: f64| lerp_rect(cur, target, t);
at(bisect(0.0, 1.0, |t| feasible(at(t))))
};
let cur = grow(sr, dx, 0.0);
grow(cur, 0.0, dy)
// straightened, a free corner goes to the nearest place that fits, growing both sides (#2308)
let corner = (mx0 || mx1) && (my0 || my1);
let nearest =
if base.angle != 0.0 && corner { nearest_corner(sr, mx0, my0, base.angle, (w, h), min_side, Vec2::new(dx, dy)) } else { None };
match nearest.filter(|r| feasible(*r)) {
Some(r) => r,
None => grow(grow(sr, dx, 0.0), 0.0, dy),
}
}
(hd, Some(a)) => {
let (mx0, _, my0, _) = sides(hd);
@@ -212,20 +221,22 @@ pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer:
if [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { CropGeometry { rect, angle: base.angle } } else { start }
}
/// The translation (pixels) nearest `want` that keeps the frame `r` inside a `w × h` image turned by `angle` degrees.
fn nearest_move(r: Rect, angle: f64, w: f64, h: f64, want: Vec2) -> Option<Vec2> {
/// The offset `v` (pixels) nearest `want` that keeps each `p + m ⊙ v` inside a `w × h` image turned by `angle`
/// degrees and meets the `extra` half-planes `n · v <= b`.
fn nearest_offset(points: &[(Point, Vec2)], extra: &[(Vec2, f64)], angle: f64, w: f64, h: f64, want: Vec2) -> Option<Vec2> {
let back = Affine::rotate_about(-angle.to_radians(), Point::new(w / 2.0, h / 2.0));
let (ex, ey) = (back.apply_vec(Vec2::new(1.0, 0.0)), back.apply_vec(Vec2::new(0.0, 1.0)));
let (nx, ny) = (Vec2::new(ex.x, ey.x), Vec2::new(ex.y, ey.y));
// never tighter than where the frame is, plus a slack so a zero-width strip survives rounding
let (sx, sy) = (1e-9 * w, 1e-9 * h);
let mut planes = Vec::with_capacity(16);
for c in r.corners() {
let q = back.apply(c);
let mut planes = Vec::with_capacity(4 * points.len() + extra.len());
for (p, m) in points {
let q = back.apply(*p);
let (nx, ny) = (Vec2::new(ex.x * m.x, ey.x * m.y), Vec2::new(ex.y * m.x, ey.y * m.y));
for (n, b, s) in [(nx, w - q.x, sx), (-nx, q.x, sx), (ny, h - q.y, sy), (-ny, q.y, sy)] {
planes.push((n, b.max(0.0) + s));
}
}
planes.extend_from_slice(extra);
if planes.iter().all(|(n, b)| n.dot(want) <= *b) {
return Some(want);
}
@@ -261,6 +272,26 @@ fn shrunk_inside(r: Rect, angle: f64, w: f64, h: f64) -> Option<Rect> {
inside(lo).then(|| scaled(bisect_range(lo, 1.0, inside)))
}
/// The frame `r` (pixels) with a corner dragged by `want`, the opposite one held, at the nearest place that fits.
fn nearest_corner(r: Rect, left: bool, top: bool, angle: f64, (w, h): (f64, f64), min_side: f64, want: Vec2) -> Option<Rect> {
let anchor = Point::new(if left { r.x1 } else { r.x0 }, if top { r.y1 } else { r.y0 });
let corner = Point::new(if left { r.x0 } else { r.x1 }, if top { r.y0 } else { r.y1 });
let points =
[(corner, Vec2::new(1.0, 1.0)), (Point::new(corner.x, anchor.y), Vec2::new(1.0, 0.0)), (Point::new(anchor.x, corner.y), Vec2::new(0.0, 1.0))];
let (sx, sy) = (if left { -1.0 } else { 1.0 }, if top { -1.0 } else { 1.0 });
// at least `min_side`, and per axis only between where it is and where the pointer asks
let extra = [
(Vec2::new(-sx, 0.0), sx * (corner.x - anchor.x) - min_side),
(Vec2::new(0.0, -sy), sy * (corner.y - anchor.y) - min_side),
(Vec2::new(1.0, 0.0), want.x.max(0.0)),
(Vec2::new(-1.0, 0.0), (-want.x).max(0.0)),
(Vec2::new(0.0, 1.0), want.y.max(0.0)),
(Vec2::new(0.0, -1.0), (-want.y).max(0.0)),
];
let v = nearest_offset(&points, &extra, angle, w, h, want)?;
Some(Rect::from_points(anchor, corner + v))
}
/// The part of the convex polygon `poly` where `n · v <= b`.
fn clip_half_plane(poly: &[Vec2], n: Vec2, b: f64) -> Vec<Vec2> {
let mut out = Vec::with_capacity(poly.len() + 1);
+64
View File
@@ -301,3 +301,67 @@ fn given_a_small_frame_pushed_past_a_tilted_edge_then_it_stops_on_the_edge_and_s
}
assert!(b.rect.y0 > a.rect.y0 + 0.01 && a.rect.y0 > start.rect.y0, "{:?} then {:?}", a.rect, b.rect);
}
// Scenario: a free corner dragged diagonally out on a straightened photo (issue #2308)
#[test]
fn given_straightened_photo_when_a_free_corner_is_dragged_out_diagonally_then_both_sides_grow() {
for angle in [6.0, 12.0] {
let start = CropGeometry { rect: Rect::new(0.3, 0.3, 0.6, 0.6), angle };
let g = drag(start, BottomRight, p(0.6, 0.6), p(1.2, 1.2), None);
assert!(g.is_within_image(W, H), "{angle}: {:?}", g.rect);
assert!(near(g.rect.x0, 0.3) && near(g.rect.y0, 0.3), "{angle}: the anchor corner stays: {:?}", g.rect);
// both sides grow (main stops at y1 0.600)
assert!(g.rect.x1 > 0.9 && g.rect.y1 > 0.9, "{angle}: one side only: {:?}", g.rect);
let further = drag(g, BottomRight, p(g.rect.x1, g.rect.y1), p(1.2, 1.2), None);
assert!((further.rect.x1 - g.rect.x1).abs() < 1e-4 && (further.rect.y1 - g.rect.y1).abs() < 1e-4, "{angle}: {:?}", further.rect);
}
let start = CropGeometry { rect: Rect::new(0.3, 0.3, 0.6, 0.6), angle: 8.0 };
for (hd, from, to) in [(TopLeft, p(0.3, 0.3), p(-0.5, -0.5)), (TopRight, p(0.6, 0.3), p(1.5, -0.5)), (BottomLeft, p(0.3, 0.6), p(-0.5, 1.5))]
{
let g = drag(start, hd, from, to, None);
assert!(g.is_within_image(W, H), "{hd:?}: {:?}", g.rect);
assert!(g.rect.width() > 0.5 && g.rect.height() > 0.45, "{hd:?}: one side only: {:?}", g.rect);
let (ax, ay) = (if from.x < 0.5 { g.rect.x1 } else { g.rect.x0 }, if from.y < 0.5 { g.rect.y1 } else { g.rect.y0 });
assert!(near(ax, if from.x < 0.5 { 0.6 } else { 0.3 }) && near(ay, if from.y < 0.5 { 0.6 } else { 0.3 }), "{hd:?}: {:?}", g.rect);
}
}
// Scenario: a free corner pushed along one axis past a tilted edge on a straightened photo
#[test]
fn given_straightened_photo_when_a_free_corner_is_pushed_sideways_past_an_edge_then_the_other_side_keeps_its_size() {
for angle in [6.0, 12.0, -8.0] {
let start = CropGeometry { rect: Rect::new(0.3, 0.3, 0.6, 0.6), angle };
let g = drag(start, BottomRight, p(0.6, 0.6), p(1.3, 0.6), None);
assert!(g.is_within_image(W, H), "{angle}: {:?}", g.rect);
assert!(near(g.rect.y1, 0.6) && near(g.rect.x0, 0.3) && near(g.rect.y0, 0.3), "{angle}: the height changed: {:?}", g.rect);
assert!(g.rect.x1 > 0.85, "{angle}: it stopped short of the edge: {:?}", g.rect);
let g = drag(start, BottomRight, p(0.6, 0.6), p(0.6, 1.3), None);
assert!(near(g.rect.x1, 0.6) && g.rect.y1 > 0.8, "{angle}: the width changed: {:?}", g.rect);
}
}
// Scenario: a free corner of a crop under the minimum size on a straightened photo
#[test]
fn given_straightened_photo_when_a_corner_of_a_too_small_crop_is_dragged_then_it_reaches_the_minimum() {
let start = CropGeometry { rect: Rect::new(0.3, 0.3, 0.3 + 2.0 / W, 0.6), angle: 6.0 };
let g = drag(start, BottomRight, p(start.rect.x1, 0.6), p(start.rect.x1 + 2.0 / W, 0.62), None);
assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS, "{:?}", g.rect);
assert!(g.is_within_image(W, H) && near(g.rect.x0, 0.3) && near(g.rect.y0, 0.3), "{:?}", g.rect);
}
// Scenario: a free corner moved inside, or across its anchor, on a straightened photo
#[test]
fn given_straightened_photo_when_a_free_corner_moves_inside_then_it_follows_and_keeps_a_minimum_size() {
let start = CropGeometry { rect: Rect::new(0.3, 0.3, 0.6, 0.6), angle: 8.0 };
let g = drag(start, BottomRight, p(0.6, 0.6), p(0.7, 0.55), None);
assert!(near(g.rect.x1, 0.7) && near(g.rect.y1, 0.55) && near(g.rect.x0, 0.3) && near(g.rect.y0, 0.3), "{:?}", g.rect);
let z = drag(start, BottomRight, p(0.6, 0.6), p(0.6, 0.6), None).rect;
assert!(near(z.x1, 0.6) && near(z.y1, 0.6), "a click moves nothing: {z:?}");
let crossed = drag(start, BottomRight, p(0.6, 0.6), p(0.0, 0.0), None);
assert!(near(crossed.rect.x0, 0.3) && near(crossed.rect.y0, 0.3), "{:?}", crossed.rect);
assert!(
crossed.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && crossed.rect.height() * H >= MIN_CROP_FRACTION * H - EPS,
"{:?}",
crossed.rect
);
}
+2 -1
View File
@@ -247,7 +247,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299) |
| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299), and a free corner dragged out goes to the place nearest the pointer that fits, growing both sides instead of only the first one to reach a tilted edge (issue #2308) |
| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A, its own row in Keyboard Shortcuts: `crop.lockAspect` toggles it, sharing A with Visualize Spots by context), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) |
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests/crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests/crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) |
| LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required); turns by at most 10° (`STRAIGHTEN_MAX_DEGREES`) and only on at least 3 agreeing lines (`STRAIGHTEN_MIN_LINES`), and says so when it finds nothing (`auto_straighten_says_when_it_finds_nothing`). Measured on 68 photos straightened by hand in Lightroom Classic and 300 left as shot: it levels 14 of the 68 within 0.5° (1 more than 1° off) and turns 19 of the 300 by more than 1° (before the limits: 79, some by 20° or more). 🟡 because it still leaves most tilted photos alone: a soft sea horizon among wave crests, and verticals that converge in perspective, aren't used yet |
@@ -828,6 +828,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-19 | minor | LR-CROP-ASPECT: Lock Aspect Ratio is its own row in Help ▸ Keyboard Shortcuts (`crop.lockAspect`, default A, sharing A with Visualize Spots as a contextual pair); statuses unchanged (✅) (`tests_keymap` `lock_aspect_ratio_is_a_listed_row_on_a`, `a_is_the_aspect_lock_under_crop_and_visualize_spots_elsewhere`) |
| 2026-10-19 | patch | LR-LIB-RATING: a rating, flag or label that takes the active photo out of the view makes the photo after it active instead of leaving an unseen photo active (→ then went to the view's first photo), and Shift / Auto Advance doesn't step past it (#2312); status unchanged (✅) |
| 2026-10-19 | patch | LR-LIB-CULL: Assisted Culling's burst gap and similarity are adjustable (dialog, `photo.analyze` `burstGap` / `similarity`; #2275). Statuses and feature counts unchanged. |
| 2026-10-19 | patch | LR-CROP-RECT: on a straightened photo a free crop corner dragged out grows both sides to the place nearest the pointer that fits (#2308); status unchanged |
| 2026-10-19 | patch | LR-CROP-RECT: on a straightened photo a moved crop slides along the tilted edges, so a box that fills the photo's height reaches its sides (#2299); status unchanged |
| 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged |
| 2026-10-18 | patch | Export regression integration (maintenance): Graphic watermarks honour EXIF rotation/mirroring through the shared `read_graphic` path and keep their aspect ratio when fitted to the output. HDR JPEG `limitKb` includes the gain map and metadata. Refreshed onto main fb7ac08b, retaining upstream export bounds, colour metadata, JPEG encoding and opaque RGB/real-alpha handling (#1691); no feature-count or readiness estimate changes. |