From 4ded49ecd9b2b2a43f75d61adb9401a3fe92782d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:21:04 -0400 Subject: [PATCH] LR-LIB-PEOPLE: face boxes and People close-ups follow Rotate Left/Right Regions are stored on the upright (EXIF-oriented) photo, but the loupe's normalized frame and the close-up crop include the user's orientation. The loupe overlay now maps each box into that frame (and a resize drag back out of it), and face_job maps the face and swaps the photo's size for a quarter turn. Flips need nothing: they are applied after the normalized frame. Adds Orientation::inverse and Orientation::map_norm_rect. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 5 +++++ crates/engine/src/tests.rs | 22 +++++++++++++++++++++ crates/geom/src/lib.rs | 30 +++++++++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 19 +++++++++++++----- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 789c880f..c5860c49 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -711,7 +711,12 @@ impl crate::Session { /// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content. pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { let p = self.catalog.photo(id)?.clone(); + // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the + // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. + let orient = p.develop.orientation; + let face = orient.map_norm_rect(face); let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; // finite, within the photo, and never wider than its short side (so the clamps below are valid) let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9becd6c6..2837d672 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -761,3 +761,25 @@ fn face_job_crops_a_square_inside_the_photo() { assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}"); assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none()); } + +/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box +/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels. +#[test] +fn face_job_follows_rotate_right() { + use lightcraft_geom::Rect; + let mut s = demo(); + let id = s.active().unwrap(); + let (w, h) = { + let p = s.catalog.photo(id).unwrap(); + (f64::from(p.width), f64::from(p.height)) + }; + // a small face left of centre on the upright photo + let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 }; + s.execute("photo.rotateRight", &json!({})).unwrap(); + let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect; + // a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325) + assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}"); + // the rotated photo is h × w pixels + let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w); + assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 509285aa..327c60fc 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -332,6 +332,23 @@ impl Orientation { // flipH ∘ rot(t) = rot(-t) ∘ flipH Orientation::from_parts(!f, (4 - t) % 4) } + /// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns + /// are their own inverses; quarter turns swap. + pub fn inverse(self) -> Orientation { + match self { + Orientation::Rotate90 => Orientation::Rotate270, + Orientation::Rotate270 => Orientation::Rotate90, + o => o, + } + } + /// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates + /// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's + /// Rotate Left/Right). + pub fn map_norm_rect(self, r: Rect) -> Rect { + let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0); + let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0); + Rect::from_points(Point::new(ax, ay), Point::new(bx, by)) + } /// Map a point in stored pixel space (w×h stored dims) to oriented space. pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) { let (flip, t) = self.to_parts(); @@ -417,6 +434,19 @@ mod tests { assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90); } + #[test] + fn orientation_inverse_and_norm_rect() { + let r = Rect::new(0.1, 0.2, 0.3, 0.6); + for v in 1..=8 { + let o = Orientation::from_exif(v); + let back = o.inverse().map_norm_rect(o.map_norm_rect(r)); + assert!([back.x0 - r.x0, back.y0 - r.y0, back.x1 - r.x1, back.y1 - r.y1].iter().all(|d| d.abs() < 1e-12), "{o:?}: {back:?}"); + } + // a quarter turn clockwise: the left of the upright photo becomes its top + let q = Orientation::Rotate90.map_norm_rect(r); + assert!([q.x0 - 0.4, q.y0 - 0.1, q.x1 - 0.8, q.y1 - 0.3].iter().all(|d| d.abs() < 1e-12), "{q:?}"); + } + proptest::proptest! { #[test] fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index d50ed597..cde3efdb 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -338,7 +338,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filter_pill(app, ui, canvas); } if app.ui.face_boxes { - match region_overlay(ui, &p, &map, &photo) { + match region_overlay(ui, &p, &map, &photo, d.orientation) { Some(RegionEdit::Remove(index)) => { let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); } @@ -458,8 +458,15 @@ const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ /// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows /// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, /// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: -/// LightCraft doesn't write regions to XMP. -fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option { +/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; +/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +fn region_overlay( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + orient: lightcraft_geom::Orientation, +) -> Option { let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); let pointer = ui.input(|i| i.pointer.hover_pos()); @@ -469,7 +476,7 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig let mut edit = None; for (index, r) in photo.meta.regions.iter().enumerate() { let dragging = live.filter(|(i, _)| *i == index); - let norm = dragging.map_or(r.rect, |(_, n)| n); + let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n)); let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1))); // white with a black keyline just outside it, so the box shows on any background p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); @@ -500,12 +507,14 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig n.max.y = pp.y.max(n.min.y + 12.0); } let (a, b) = (map.norm(n.min), map.norm(n.max)); - let new = lightcraft_geom::Rect { + let shown = lightcraft_geom::Rect { x0: a.x.min(b.x).clamp(0.0, 1.0), y0: a.y.min(b.y).clamp(0.0, 1.0), x1: a.x.max(b.x).clamp(0.0, 1.0), y1: a.y.max(b.y).clamp(0.0, 1.0), }; + // back to the upright frame the region is stored in + let new = orient.inverse().map_norm_rect(shown); ui.data_mut(|d| d.insert_temp(drag_key, (index, new))); ui.ctx().request_repaint(); }