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:
Daniel Voshart
2026-10-06 19:44:15 -04:00
co-authored by Claude Sonnet 5.5
parent e5848aa1b2
commit 9bb06cc9dc
4 changed files with 161 additions and 14 deletions
+34
View File
@@ -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 }))?;
+38
View File
@@ -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())
}