Faces: resize a face box with eight drag handles
Hovering a box shows handles at its corners and edge midpoints; dragging one
previews the new box live and commits once on release, as one undo step, through
the new `photo.setRegion {id?, index, rect}`: catalog-only like removal, never
rewriting the sidecar, with the rectangle validated (finite numbers only,
clamped to the photo, at least 0.5 % each way) and a test with a real sidecar.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e5848aa1b2
commit
9bb06cc9dc
|
|
@ -472,6 +472,40 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(json!({"removed": gone.name}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"photo.setRegion",
|
||||
"Resize Face Box",
|
||||
[],
|
||||
None,
|
||||
"{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "photo.setRegion";
|
||||
// a drag is one undo step however many frames it took: the caller commits once, on release
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
|
||||
let index = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.ok_or_else(|| bad(C, "missing or invalid `index`"))?;
|
||||
let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?;
|
||||
let num = |k: &str| {
|
||||
r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number")))
|
||||
};
|
||||
let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?);
|
||||
let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0));
|
||||
let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0));
|
||||
if x1 - x0 < 0.005 || y1 - y0 < 0.005 {
|
||||
return Err(bad(C, "the box would be too small"));
|
||||
}
|
||||
let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone();
|
||||
let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?;
|
||||
region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 };
|
||||
s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
|
||||
s.skip_auto_write = true;
|
||||
Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}}))
|
||||
}
|
||||
),
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
|
|
|
|||
|
|
@ -248,6 +248,44 @@ fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() {
|
|||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Resizing a face box is a catalog-only, undoable edit with strict input handling; it leaves the
|
||||
/// sidecar alone like removal does.
|
||||
#[test]
|
||||
fn resizing_a_region_validates_clamps_undoes_and_leaves_the_sidecar_alone() {
|
||||
let src = temp_dir("region-resize");
|
||||
write_png(&src.join("portrait.png"), 9);
|
||||
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
|
||||
let original = s.catalog.photo(id).unwrap().meta.regions[0].rect;
|
||||
let set = |s: &mut Session, rect: serde_json::Value| s.execute("photo.setRegion", &json!({"index": 0, "rect": rect}));
|
||||
// hostile input is an error and changes nothing
|
||||
for bad in [
|
||||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1, "y1": 0.5}),
|
||||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1001, "y1": 0.5}),
|
||||
json!({"x0": 0.1, "y0": 0.1, "x1": 0.5}),
|
||||
json!({"x0": "a", "y0": 0.1, "x1": 0.5, "y1": 0.5}),
|
||||
json!({"x0": null, "y0": 0.1, "x1": 0.5, "y1": 0.5}),
|
||||
] {
|
||||
assert!(set(&mut s, bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("photo.setRegion", &json!({"index": 9, "rect": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.5}})).is_err(), "no such region");
|
||||
assert!(s.execute("photo.setRegion", &json!({"index": 0})).is_err(), "no rect");
|
||||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
|
||||
// corners given in any order and outside the photo are clamped into it
|
||||
let r = set(&mut s, json!({"x0": 0.6, "y0": 1.5, "x1": -0.2, "y1": 0.2})).unwrap();
|
||||
assert_eq!(r["rect"], json!({"x0": 0.0, "y0": 0.2, "x1": 0.6, "y1": 1.0}));
|
||||
let now = s.catalog.photo(id).unwrap().meta.regions[0].rect;
|
||||
assert_eq!((now.x0, now.y0, now.x1, now.y1), (0.0, 0.2, 0.6, 1.0));
|
||||
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
fn synthetic_dng(xmp: &str) -> Vec<u8> {
|
||||
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -336,10 +336,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
if !fullscreen {
|
||||
filter_pill(app, ui, canvas);
|
||||
}
|
||||
if app.ui.face_boxes
|
||||
&& let Some(index) = region_overlay(ui, &p, &map, &photo)
|
||||
{
|
||||
let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index}));
|
||||
if app.ui.face_boxes {
|
||||
match region_overlay(ui, &p, &map, &photo) {
|
||||
Some(RegionEdit::Remove(index)) => {
|
||||
let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index}));
|
||||
}
|
||||
Some(RegionEdit::Resize(index, r)) => {
|
||||
let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}}));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// 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.")) {
|
||||
|
|
@ -428,21 +434,90 @@ 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)));
|
||||
}
|
||||
|
||||
/// What the user did to a face box this frame.
|
||||
enum RegionEdit {
|
||||
/// The × was clicked.
|
||||
Remove(usize),
|
||||
/// A handle drag ended: the region's new box (normalized, upright frame).
|
||||
Resize(usize, lightcraft_geom::Rect),
|
||||
}
|
||||
|
||||
/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show.
|
||||
const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [
|
||||
(0.0, 0.0, egui::CursorIcon::ResizeNwSe),
|
||||
(0.5, 0.0, egui::CursorIcon::ResizeVertical),
|
||||
(1.0, 0.0, egui::CursorIcon::ResizeNeSw),
|
||||
(1.0, 0.5, egui::CursorIcon::ResizeHorizontal),
|
||||
(1.0, 1.0, egui::CursorIcon::ResizeNwSe),
|
||||
(0.5, 1.0, egui::CursorIcon::ResizeVertical),
|
||||
(0.0, 1.0, egui::CursorIcon::ResizeNeSw),
|
||||
(0.0, 0.5, egui::CursorIcon::ResizeHorizontal),
|
||||
];
|
||||
|
||||
/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows
|
||||
/// a × in its corner; clicking it returns that region's index so the caller can remove it (a
|
||||
/// catalog-only edit: LightCraft doesn't write regions to XMP).
|
||||
fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option<usize> {
|
||||
/// 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> {
|
||||
let t = Tokens::get(p.ctx());
|
||||
let clip = p.clip_rect();
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut remove = None;
|
||||
// the box being dragged: (region index, its box so far)
|
||||
let drag_key = egui::Id::new("region-drag");
|
||||
let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key));
|
||||
let mut edit = None;
|
||||
for (index, r) in photo.meta.regions.iter().enumerate() {
|
||||
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)));
|
||||
let dragging = live.filter(|(i, _)| *i == index);
|
||||
let norm = 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);
|
||||
p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside);
|
||||
if pointer.is_some_and(|h| rect.expand(3.0).contains(h)) {
|
||||
let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.top() + 11.0), vec2(16.0, 16.0));
|
||||
if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) {
|
||||
for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() {
|
||||
let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height());
|
||||
let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::new(("region-handle", index, h)), Sense::drag());
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(*cursor);
|
||||
}
|
||||
let sq = Rect::from_center_size(c, vec2(7.0, 7.0));
|
||||
p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE });
|
||||
p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside);
|
||||
if resp.dragged()
|
||||
&& let Some(pp) = resp.interact_pointer_pos()
|
||||
{
|
||||
// move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one
|
||||
let mut n = rect;
|
||||
if *fx == 0.0 {
|
||||
n.min.x = pp.x.min(n.max.x - 12.0);
|
||||
} else if *fx == 1.0 {
|
||||
n.max.x = pp.x.max(n.min.x + 12.0);
|
||||
}
|
||||
if *fy == 0.0 {
|
||||
n.min.y = pp.y.min(n.max.y - 12.0);
|
||||
} else if *fy == 1.0 {
|
||||
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 {
|
||||
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),
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(drag_key, (index, new)));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) {
|
||||
edit = Some(RegionEdit::Resize(i, new));
|
||||
}
|
||||
ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) {
|
||||
let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click());
|
||||
register(ui.ctx(), format!("regionRemove:{index}"), xr);
|
||||
let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 });
|
||||
|
|
@ -453,7 +528,7 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig
|
|||
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
|
||||
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
|
||||
if resp.on_hover_text("Remove this face box (undo with Edit ▸ Undo)").clicked() {
|
||||
remove = Some(index);
|
||||
edit = Some(RegionEdit::Remove(index));
|
||||
}
|
||||
}
|
||||
let Some(name) = &r.name else { continue };
|
||||
|
|
@ -476,7 +551,7 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig
|
|||
p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE));
|
||||
p.galley(label.min + pad, g, Color32::from_gray(225));
|
||||
}
|
||||
remove
|
||||
edit
|
||||
}
|
||||
|
||||
/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle |
|
||||
| LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels |
|
||||
| LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop |
|
||||
| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) and removed with the × shown on hover (catalog-only and undoable; the sidecar is never rewritten for it). No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back |
|
||||
| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back |
|
||||
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
|
||||
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
|
||||
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" |
|
||||
|
|
|
|||
Loading…
Reference in New Issue