diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index c99bde452..a02de1235 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -71,8 +71,9 @@ impl Frame { ow, oh, crop, - flip_h: apply_crop && s.crop.flip_h, - flip_v: apply_crop && s.crop.flip_v, + // a flip is not part of the crop: the crop tool's uncropped view shows it too + flip_h: s.crop.flip_h, + flip_v: s.crop.flip_v, warp: (!warp.is_identity()).then_some(warp), view: None, }; @@ -387,4 +388,22 @@ mod tests { assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5); assert!((out.get(3, 0)[0] - 0.0).abs() < 1e-5); } + + /// The crop tool's uncropped frame keeps the flips: Flip H / Flip V show while cropping, and + /// screen points map back through the mirror (the crop box is drawn and dragged right). + #[test] + fn uncropped_frame_keeps_the_flips() { + let mut s = DevelopSettings::default(); + s.crop.flip_h = true; + s.crop.flip_v = true; + s.crop.geometry.rect = Rect::new(0.1, 0.2, 0.6, 0.9); + let src = Rgb32f::from_fn(4, 2, |x, y| [x as f32, y as f32, 0.0]); + let f = Frame::new(4, 2, &s, false); + let out = f.sample(&src, 4, 2); + assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5 && (out.get(0, 0)[1] - 1.0).abs() < 1e-5, "{:?}", out.get(0, 0)); + assert!((out.get(3, 1)[0] - 0.0).abs() < 1e-5 && (out.get(3, 1)[1] - 0.0).abs() < 1e-5, "{:?}", out.get(3, 1)); + // the whole image, mirrored: output top-left is the image's bottom-right + let p = f.out_to_norm(400, 200).apply(Point::new(0.0, 0.0)); + assert!((p.x - 1.0).abs() < 1e-9 && (p.y - 1.0).abs() < 1e-9, "{p:?}"); + } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 8ec6a8114..ebdf174ac 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1255,11 +1255,13 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let shade = Color32::from_black_alpha(150); let mut mesh = egui::epaint::Mesh::default(); let outer = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()]; + // the box's corners in screen order (a flipped photo shows its top-left corner elsewhere) + let on_screen = flipped_corners(&pts, frame.flip_h, frame.flip_v); for i in 0..4 { let a = outer[i]; let b = outer[(i + 1) % 4]; - let c = pts[(i + 1) % 4]; - let dd = pts[i]; + let c = on_screen[(i + 1) % 4]; + let dd = on_screen[i]; let base = mesh.vertices.len() as u32; for v in [a, b, c, dd] { mesh.vertices.push(egui::epaint::Vertex { pos: v, uv: Pos2::ZERO, color: shade }); @@ -1509,6 +1511,19 @@ fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point { Point::new(r.x / w, r.y / h) } +/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo) +/// in the order they appear on screen when the photo is shown flipped. +fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { + let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO); + let order = match (flip_h, flip_v) { + (false, false) => [0, 1, 2, 3], + (true, false) => [1, 0, 3, 2], + (false, true) => [3, 2, 1, 0], + (true, true) => [2, 3, 0, 1], + }; + order.map(at) +} + fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { let f = Frame { crop: d.crop.geometry, ..frame.clone() }; f.crop_quad_norm() @@ -2237,7 +2252,8 @@ mod tests { assert!(near(super::rotate_cursor_turn(&tilted, rot(pos2(200.0, 60.0)), false), 30f32.to_radians())); } - use super::{film_label, texture_side}; + use super::{film_label, flipped_corners, texture_side}; + use egui::pos2; /// Issue #652: a native host reports the GPU's texture limit in the first frame's input only. /// The loupe must still know it in every later frame (it read the raw input, found nothing and @@ -2266,6 +2282,22 @@ mod tests { assert_eq!(film_label("DSC_0001"), "DSC_0001"); assert_eq!(film_label(""), ""); } + + /// With the photo flipped, the crop box's corners reach the screen mirrored; the dimming + /// outside the box pairs each photo corner with the box corner on the same side of the screen + /// (it used to pair them in data order, so a flipped photo was dimmed inside the box). + #[test] + fn flipped_crop_box_corners_come_in_screen_order() { + let screen = [pos2(10.0, 10.0), pos2(90.0, 10.0), pos2(90.0, 50.0), pos2(10.0, 50.0)]; + // data order (top-left, top-right, bottom-right, bottom-left) as each flip shows it + let h = [screen[1], screen[0], screen[3], screen[2]]; + let v = [screen[3], screen[2], screen[1], screen[0]]; + let hv = [screen[2], screen[3], screen[0], screen[1]]; + assert_eq!(flipped_corners(&screen, false, false), screen); + assert_eq!(flipped_corners(&h, true, false), screen); + assert_eq!(flipped_corners(&v, false, true), screen); + assert_eq!(flipped_corners(&hv, true, true), screen); + } } #[cfg(test)] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2463c79df..3309447c7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -250,7 +250,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | -| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | +| 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 | | LR-CROP-ZOOM | Zoom while cropping | P1 | ✅ | `cmd:view.zoom100`, `cmd:view.zoomIn`, `crates/ui-egui/src/panels/detail.rs` | zoom levels apply with the crop tool open (verified: 100% shows the photo at native size with the crop frame) | | LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 | | |