# Conflicts:
#	docs/parity-checklist.md
This commit is contained in:
Claude 2026-10-11 11:44:45 +00:00
commit 06ef27bb37
No known key found for this signature in database
7 changed files with 211 additions and 18 deletions

View File

@ -37,6 +37,17 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
if o.swaps_axes() { (h, w) } else { (w, h) }
}
/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`:
/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in
/// canvas outside the photo (issue #742).
fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) {
if touched {
d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h);
}
}
const CROP_ANGLE: &str = "crop.angle";
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
v?.as_array()?.iter().map(Value::as_f64).collect()
@ -146,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Develop Values",
[],
None,
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls",
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)",
has_active,
|s, p| {
let mut vals: Vec<(String, f64)> = Vec::new();
@ -167,7 +178,9 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
let before = d.crop.geometry;
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
@ -178,6 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
d.wb.mode = WbMode::Custom;
}
}
refit_crop_angle(angle, before, d, dims);
};
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
@ -187,7 +201,7 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|(id, d)| {
let mut d = (*d).clone();
let info = s.source_info(id);
apply(&mut d, &info);
apply(&mut d, &info, crop_dims(s, id));
s.develop_op(id, d, &label)
})
.collect();
@ -196,8 +210,9 @@ pub fn specs() -> Vec<CommandSpec> {
}
let id = active(s, "develop.set")?;
let info = s.source_info(id);
let dims = crop_dims(s, id);
edit(s, "develop.set", &label, |d| {
apply(d, &info);
apply(d, &info, dims);
Ok(())
})
}
@ -206,8 +221,10 @@ pub fn specs() -> Vec<CommandSpec> {
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
let delta = f64_req(p, "delta", "develop.adjust")?;
let info = s.source_info(active(s, "develop.adjust")?);
let id = active(s, "develop.adjust")?;
let (info, dims) = (s.source_info(id), crop_dims(s, id));
edit(s, "develop.adjust", spec.label, |d| {
let before = d.crop.geometry;
if c == "wb.temp" || c == "wb.tint" {
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
@ -217,6 +234,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
let v = controls::get(d, &c).unwrap_or(spec.default);
controls::set(d, &c, v + delta);
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
Ok(())
})
}),
@ -306,8 +324,11 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| {
let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?;
let dims = crop_dims(s, active(s, "develop.resetControl")?);
edit(s, "develop.resetControl", spec.label, |d| {
let before = d.crop.geometry;
controls::set(d, &c, spec.default);
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
Ok(())
})
}),
@ -493,10 +514,12 @@ pub fn specs() -> Vec<CommandSpec> {
}
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
}
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
d.crop.geometry.angle = a.clamp(-45.0, 45.0);
}
d.crop.geometry = d.crop.geometry.constrained(w, h);
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
d.crop.geometry = match (p.get("rect"), angle) {
// a new angle alone turns the crop as Straighten does (issue #742)
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
};
Ok(())
})
}),
@ -552,11 +575,8 @@ pub fn specs() -> Vec<CommandSpec> {
let (w, h) = crop_dims(s, id);
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
edit(s, "crop.straighten", "Straighten", |d| {
let r = d.crop.geometry.rect_px(w, h);
let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height()));
// keep the current crop size if it still fits, else shrink
let cand = CropGeometry { rect: d.crop.geometry.rect, angle };
d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit };
// keep the current crop if it still fits, else the largest of its aspect that does
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
Ok(())
})
}
@ -856,8 +876,10 @@ pub fn specs() -> Vec<CommandSpec> {
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
}
let before = nd.crop.geometry;
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
controls::set(&mut nd, &ctl, v + delta);
refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id));
if ctl == "wb.temp" || ctl == "wb.tint" {
nd.wb.mode = WbMode::Custom;
}

View File

@ -875,6 +875,8 @@ mod tests_album_order;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_crop_angle;
#[cfg(test)]
mod tests_denoise;
#[cfg(test)]
mod tests_export;

View File

@ -0,0 +1,108 @@
//! Every way of setting the crop angle keeps the crop inside the photo (issue #742): the generic
//! control path (`develop.set` on `crop.angle`, as MCP, the control channel and `lightcraft-cli
//! render --set` use it, plus `develop.adjust`, `develop.quickAdjust`, `develop.resetControl`) gives
//! the same crop as `crop.straighten` (the rotate handle, the Straighten slider, the Angle field)
//! and `crop.set {angle}`.
use lightcraft_geom::{CropGeometry, Rect};
use serde_json::{Value, json};
use crate::Session;
fn geometry(s: &Session) -> CropGeometry {
s.develop_of(s.active().unwrap()).unwrap().crop.geometry
}
fn dims(s: &Session) -> (f64, f64) {
let p = s.catalog.photo(s.active().unwrap()).unwrap();
let (w, h) = (p.width as f64, p.height as f64);
if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }
}
/// Run `cmd` from the current state, return the crop it leaves, and undo it.
fn try_cmd(s: &mut Session, cmd: &str, p: Value) -> CropGeometry {
s.execute(cmd, &p).unwrap();
let g = geometry(s);
s.execute("edit.undo", &json!({})).unwrap();
g
}
fn close(a: CropGeometry, b: CropGeometry) -> bool {
let r = |g: CropGeometry| [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1, g.angle];
r(a).iter().zip(r(b)).all(|(x, y)| (x - y).abs() < 1e-9)
}
#[test]
fn every_way_of_setting_the_angle_gives_the_straighten_crop() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
for aspect in [json!("original"), json!("free"), json!("1x1"), json!("16x9"), json!("4x5")] {
s.execute("crop.aspect", &json!({"aspect": aspect})).unwrap();
let start = geometry(&s);
for angle in [-45.0, -30.0, -7.5, 0.5, 2.0, 15.0, 33.3] {
let want = try_cmd(&mut s, "crop.straighten", json!({"angle": angle}));
assert!(want.is_within_image(w, h), "{aspect} {angle}°: straighten fits");
assert_eq!(want.angle, angle);
for (cmd, p) in [
("develop.set", json!({"control": "crop.angle", "value": angle})),
("develop.set", json!({"values": {"crop.angle": angle, "light.exposure": 0.3}})),
("crop.set", json!({"angle": angle})),
("develop.adjust", json!({"control": "crop.angle", "delta": angle - start.angle})),
] {
let got = try_cmd(&mut s, cmd, p.clone());
assert!(close(got, want), "{aspect} {angle}°: {cmd} {p} gave {got:?}, crop.straighten {want:?}");
}
assert!(close(geometry(&s), start), "undo restored the crop");
}
}
}
#[test]
fn a_crop_that_still_fits_is_kept() {
let mut s = Session::with_demo();
s.execute("crop.set", &json!({"rect": [0.3, 0.3, 0.7, 0.7]})).unwrap();
for angle in [-4.0, 1.0, 3.0, 6.0] {
s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap();
let g = geometry(&s);
assert_eq!(g.rect, Rect::new(0.3, 0.3, 0.7, 0.7), "no needless shrink at {angle}°");
assert_eq!(g.angle, angle);
}
}
#[test]
fn quick_develop_reset_and_batch_paths_fit_too() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
let id = s.active().unwrap();
let want = try_cmd(&mut s, "crop.straighten", json!({"angle": 12.0}));
let got = try_cmd(&mut s, "develop.quickAdjust", json!({"control": "crop.angle", "delta": 12.0, "ids": [id.0]}));
assert!(close(got, want), "quickAdjust {got:?} vs {want:?}");
let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]}));
assert!(close(got, want), "develop.set ids {got:?} vs {want:?}");
// back to 0°: the crop that fitted at 12° fits at 0° too and is kept
s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap();
s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap();
let g = geometry(&s);
assert_eq!((g.rect, g.angle), (want.rect, 0.0));
assert!(g.is_within_image(w, h));
}
#[test]
fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
let start = geometry(&s);
let undo = s.undo.len();
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
for angle in [1.0, 4.0, 9.0, 15.0] {
s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap();
assert!(geometry(&s).is_within_image(w, h), "fitted at every step ({angle}°)");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
assert_eq!(s.undo.len(), undo + 1, "the drag is one undo step");
assert_eq!(geometry(&s).angle, 15.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(geometry(&s), start));
}

View File

@ -79,6 +79,22 @@ impl CropGeometry {
let _ = best;
CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle }
}
/// This crop turned to `angle_deg` on a `w × h` image: kept as it is when it still fits inside
/// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's
/// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle,
/// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so
/// they all give the same crop (issue #742).
pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry {
let cand = CropGeometry { rect: self.rect, angle: angle_deg };
if cand.is_within_image(w, h) {
return cand;
}
let r = self.rect_px(w, h);
// a degenerate rect has no aspect to keep: fall back to the image's own
let aspect = Some(r.width() / r.height()).filter(|a| a.is_finite() && *a > 0.0);
crop_fit_angle(w, h, angle_deg, aspect)
}
}
/// Largest scale `s` such that a centred `cw·s × ch·s` rectangle fits inside a `w × h` image rotated
@ -133,6 +149,38 @@ mod tests {
assert!(k.is_within_image(300.0, 200.0));
}
#[test]
fn with_angle_keeps_a_crop_that_still_fits() {
// a small centred crop fits at a few degrees: the angle changes, the rect does not
let c = CropGeometry { rect: Rect::new(0.3, 0.3, 0.7, 0.7), angle: 0.0 };
for ang in [-3.0, 0.5, 2.0, 5.0] {
let t = c.with_angle(ang, 600.0, 400.0);
assert_eq!(t.rect, c.rect, "{ang}°");
assert_eq!(t.angle, ang);
}
}
#[test]
fn with_angle_shrinks_a_crop_that_no_longer_fits_keeping_its_aspect() {
for (rect, ang) in [(Rect::UNIT, 15.0), (Rect::new(0.0, 0.1, 0.8, 0.9), -20.0), (Rect::new(0.2, 0.0, 0.8, 1.0), 45.0)] {
let c = CropGeometry { rect, angle: 0.0 };
let t = c.with_angle(ang, 600.0, 400.0);
assert!(t.is_within_image(600.0, 400.0), "{rect:?} at {ang}°");
let (a, b) = (c.rect_px(600.0, 400.0), t.rect_px(600.0, 400.0));
assert!((a.aspect() - b.aspect()).abs() < 1e-9, "aspect kept: {} vs {}", a.aspect(), b.aspect());
assert_eq!(t.angle, ang);
}
}
#[test]
fn with_angle_survives_a_degenerate_crop() {
// zero height: no aspect to keep (width / height = ∞)
let c = CropGeometry { rect: Rect::new(0.0, 0.5, 1.0, 0.5), angle: 0.0 };
let t = c.with_angle(30.0, 600.0, 400.0);
assert!(t.is_within_image(600.0, 400.0));
assert!([t.rect.x0, t.rect.y0, t.rect.x1, t.rect.y1].iter().all(|v| v.is_finite()));
}
#[test]
fn half_crop_maps_to_centre_quadrant() {
let c = CropGeometry { rect: Rect::new(0.25, 0.25, 0.75, 0.75), angle: 0.0 };

View File

@ -31,6 +31,17 @@ fn angle(h: &Headless) -> f64 {
h.app.session.catalog.photo(id).expect("the photo").develop.crop.geometry.angle
}
/// The crop box lies inside the rotated photo (issue #742: a typed or slid angle shrinks it to fit,
/// as the rotate handle does).
fn crop_fits(h: &Headless) -> bool {
let id = h.app.session.active().expect("a photo");
let p = h.app.session.catalog.photo(id).expect("the photo");
let (w, h) = (p.width as f64, p.height as f64);
let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
let g = p.develop.crop.geometry;
g.rect != lightcraft_geom::Rect::UNIT && g.is_within_image(w, h)
}
fn hover(h: &mut Headless, p: egui::Pos2) {
let r = h.request("ui.move", json!({"x": p.x, "y": p.y}), T);
assert_eq!(r["ok"], true, "{r}");
@ -88,6 +99,7 @@ fn the_straighten_value_takes_an_exact_angle() {
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!((angle(&h) - 2.5).abs() < 1e-9, "typed 2.5°, got {}", angle(&h));
assert!(crop_fits(&h), "the Straighten value shrinks the crop to fit the photo");
}
/// The angle next to the pointer reads like the Straighten value, in degrees.
@ -145,6 +157,7 @@ fn the_crop_panel_has_an_angle_field() {
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!((angle(&h) + 3.25).abs() < 1e-9, "typed -3.25°, got {}", angle(&h));
assert!(crop_fits(&h), "the Angle field shrinks the crop to fit the photo");
}
/// Rotating with the pointer at the canvas's corner keeps the angle readout on the canvas (and so

View File

@ -164,9 +164,9 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
| `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were |
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |
| `get_develop {id?}` | Full develop-settings JSON, with `process`: the rendering process the photo is on ([process-versions.md](process-versions.md)) |
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable |
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted) |
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true` |
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does |
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif`; `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true |

View File

@ -246,9 +246,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|---|---|---|---|---|---|
| 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) |
| 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, visible Lock toggle (also A), button names the active ratio; every handle honours the lock 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) |
| 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) |
| 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 to the largest crop of its aspect inside the 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) |
| LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) |
| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN) |
| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) |
| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |
| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical`, `crates/pipeline/src/geometry.rs` | the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match |
| LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays |