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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-07 06:21:04 -04:00
parent 8c253b28ad
commit 4ded49ecd9
4 changed files with 71 additions and 5 deletions

View File

@ -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<RenderJob> {
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) };

View File

@ -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}");
}

View File

@ -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) {

View File

@ -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<RegionEdit> {
/// 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<RegionEdit> {
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();
}