From f1b2d9af11b8e2a714fdbfb666bc32ede185e6ae Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:31:12 -0400 Subject: [PATCH] Catalog/UI: show XMP face/pet regions in the loupe view Threads the Vec that lightcraft-meta already reads from XMP (MWG-RS) through to the screen, so it's no longer inert: - catalog::Meta gains a `regions` field (catalog now depends on lightcraft-meta, which doesn't depend back on catalog, so no cycle). - crates/engine/src/files.rs (meta_of) and src/sidecar.rs (parse_sidecar / merge_into) populate it for both embedded-XMP and external-sidecar photos, mirroring how `keywords` is already handled. - ui-egui's loupe view (panels/detail.rs) draws each region as a rectangle over the photo via the existing CanvasMap, color-coded by kind (Face/Pet/other) with its name label if present, right alongside the existing info overlay. Read-only, no new interaction. New end-to-end test (regions_from_sidecar_are_read_on_import) covers a region written to a sidecar reaching Photo.meta.regions through real import, not just the parser. cargo xtask ci green (fmt, clippy -D warnings, test, parity, layers, assets, wasm). Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 2 ++ crates/catalog/Cargo.toml | 1 + crates/catalog/src/model.rs | 3 ++ crates/engine/src/files.rs | 1 + crates/engine/src/sidecar.rs | 5 ++++ crates/engine/src/tests_xmp.rs | 45 +++++++++++++++++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/panels/detail.rs | 23 +++++++++++++++ 8 files changed, 81 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 1952f5c4..aa91c3d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2095,6 +2095,7 @@ version = "0.2.1" dependencies = [ "crc32fast", "lightcraft-develop", + "lightcraft-meta", "log", "proptest", "serde", @@ -2348,6 +2349,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-engine", "lightcraft-geom", + "lightcraft-meta", "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 47508359..c70c2831 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lightcraft-develop = { workspace = true } +lightcraft-meta = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 8b400bb5..763d3048 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -183,6 +183,9 @@ pub struct Meta { pub copyright_url: String, pub creator: String, pub keywords: Vec, + /// Face/pet/focus regions read from XMP (MWG-RS). Read-only today; nothing in + /// LightCraft writes regions yet. + pub regions: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index f4a57a6f..7ca1d1fd 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -38,6 +38,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { copyright_url: m.copyright_url.clone().unwrap_or_default(), creator: m.artist.clone().unwrap_or_default(), keywords: m.keywords.clone(), + regions: m.regions.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 69bf8f0f..f57ac16a 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -99,6 +99,7 @@ pub struct SidecarData { pub alt_text: Option, pub extended_description: Option, pub keywords: Option>, + pub regions: Option>, /// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or /// `xmp:CreateDate` (first found). Used only when the file itself has no capture time. pub captured: Option, @@ -133,6 +134,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result bool { if let Some(k) = &sc.keywords { m.keywords = k.clone(); } + if let Some(r) = &sc.regions { + m.regions = r.clone(); + } let develop = match &sc.develop { Some(DevelopPatch::Full(s)) => (**s).clone(), Some(DevelopPatch::Partial(v)) => { diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 0832e2fb..5b2919d0 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -173,6 +173,51 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() { let _ = std::fs::remove_dir_all(&src); } +const MWG_REGIONS: &str = r#" + + + + + + + + + + + + + + + + + + + + +"#; + +/// A face region written by Lightroom (MWG-RS) into a sidecar reaches `Photo.meta.regions` on +/// import — the same path real catalogs use, exercised end to end rather than just at the parser. +#[test] +fn regions_from_sidecar_are_read_on_import() { + let src = temp_dir("regions"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["sidecars"], 1); + let p = s.catalog.photos().next().unwrap().clone(); + assert_eq!(p.meta.regions.len(), 1); + let region = &p.meta.regions[0]; + assert_eq!(region.name.as_deref(), Some("Jane Doe")); + assert_eq!(region.kind, lightcraft_meta::RegionKind::Face); + assert!((region.rect.width() - 0.3).abs() < 1e-9, "{:?}", region.rect); + let _ = std::fs::remove_dir_all(&src); +} + fn synthetic_dng(xmp: &str) -> Vec { synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b2047b0d..1dd1a063 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lightcraft-geom = { workspace = true } +lightcraft-meta = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0697b75c..d1f1f6ba 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -333,6 +333,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let map = CanvasMap::new(&frame, img_rect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); info_overlay(app, &p, canvas, &photo); + region_overlay(&p, &map, &photo); // a fine grid while a transform (geometry) slider is dragged, to judge verticals if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { let n = 12; @@ -420,6 +421,28 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); } +/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Read-only — +/// LightCraft doesn't write regions yet. +fn region_overlay(p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) { + use lightcraft_meta::RegionKind; + let t = Tokens::get(p.ctx()); + for r in &photo.meta.regions { + let color = match r.kind { + RegionKind::Face => Color32::from_rgb(255, 214, 10), + RegionKind::Pet => Color32::from_rgb(120, 220, 120), + RegionKind::Focus | RegionKind::BarCode | RegionKind::Other(_) => Color32::from_white_alpha(140), + }; + let rect = Rect::from_two_pos(map.screen(Point::new(r.rect.x0, r.rect.y0)), map.screen(Point::new(r.rect.x1, r.rect.y1))); + p.rect_stroke(rect, 2.0, Stroke::new(1.5, color), StrokeKind::Outside); + if let Some(name) = &r.name { + let g = p.layout_no_wrap(name.clone(), t.font(12.0), color); + let at = pos2(rect.left(), rect.top() - g.size().y - 2.0); + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g, color); + } + } +} + /// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; /// hovering it explains (registered as `notice:previewOnly:loupe`). fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) {