diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 3a84b1dc..57b6898e 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -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> { v?.as_array()?.iter().map(Value::as_f64).collect() @@ -146,7 +157,7 @@ pub fn specs() -> Vec { "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 { } } 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 { 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 = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect(); @@ -187,7 +201,7 @@ pub fn specs() -> Vec { .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 { } 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 { 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 { } 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 { 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 { } 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 { 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 { 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; } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 36df6d01..a23b5e53 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_crop_angle.rs b/crates/engine/src/tests_crop_angle.rs new file mode 100644 index 00000000..47759d60 --- /dev/null +++ b/crates/engine/src/tests_crop_angle.rs @@ -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)); +} diff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs index a86fdb7e..810e39cd 100644 --- a/crates/geom/src/crop.rs +++ b/crates/geom/src/crop.rs @@ -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 }; diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index cfbfbde6..10cbbe99 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -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 diff --git a/docs/mcp.md b/docs/mcp.md index 81cbc4bd..0fb65914 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index bd621e23..43074692 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 |