M8.13: Perspective planes move along their normals with widgets, alone, with or copying their objects

The grid keeps per-plane offsets (leftOffset, rightOffset, groundOffset). Each plane has a widget
(a diamond in its colour) that the Perspective Grid and Perspective Selection tools drag: plain
moves the plane, Shift moves the objects on it too, Alt copies them (perspective.plane.move).
Double-clicking a widget, or ui.perspectivePlane, opens the plane's options dialog (location; Do
Not Move / Move All Objects / Copy All Objects). Object › Perspective › Move Plane to Match Object
(perspective.plane.matchObject). New objects attach where the plane is. Grid edits leave
attached art in place as the reference app does; perspective.grid.set {reproject: true} moves it
with the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-06 11:44:11 +07:00
co-authored by Claude Opus 5.5
parent 7ec936a0c1
commit 0f0dbbf9d5
11 changed files with 631 additions and 16 deletions
+95 -4
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Perspective Grid",
[],
None,
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?} merge into the document's grid",
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)",
has_doc,
grid_set
),
@@ -164,6 +164,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
persp_nudge
),
cmd!(
"perspective.plane.move",
"Move Plane",
[],
None,
"{plane?: left|right|ground (default: the active plane), offset?: pt (where along its normal; 0 is its place in the grid's definition) | by?: pt, objects?: none|move|copy (default none: the objects on the plane stay; move: they move with it, as Shift-dragging a plane widget does; copy: copies of them do, as Alt-dragging does)} → {plane, offset, ids}",
has_doc,
plane_move
),
cmd!(
"perspective.plane.matchObject",
"Move Plane to Match Object",
[],
None,
"{id? (default: the first selected object)} move the plane the object is attached to onto the object and make it the active plane → {plane, offset}",
has_doc,
plane_match
),
]
}
@@ -528,8 +546,19 @@ fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<Persp
}
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
let g = grid_of(&s.doc()?.doc).merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let old = grid_of(&s.doc()?.doc);
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let reproject = bool_or(p, "reproject", false);
s.edit("Define Perspective Grid", |d, _| {
if reproject {
old.adopt_stored_attachments(d);
for (id, plane, depth) in persp::attached_roots(d) {
let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth));
let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?;
let m = h.then_after(&hi);
warp_checked(d, id, &|q| m.apply(q))?;
}
}
store_grid(d, &g);
Ok(())
})?;
@@ -582,7 +611,7 @@ fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane
let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?;
let m = g.attach_map(plane, b).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
warp_checked(d, id, &m)?;
persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, 0.0);
persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane));
g.attached.insert(id.0.to_string(), plane);
Ok(())
}
@@ -653,7 +682,7 @@ fn persp_edit(
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl)?;
(pl, 0.0)
(pl, g.offset(pl))
}
};
first.get_or_insert((plane, depth));
@@ -754,6 +783,68 @@ pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
persp_transform(s, &p)
}
fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.plane.move";
let old = grid_of(&s.doc()?.doc);
let plane = plane_param(&old, p, C)?;
let from = old.offset(plane);
let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) {
(Some(o), _) => o,
(None, Some(b)) => from + b,
(None, None) => return Err(bad(C, "give offset or by")),
};
let objects = str_param(p, "objects").unwrap_or("none");
if !matches!(objects, "none" | "move" | "copy") {
return Err(bad(C, "objects must be none, move or copy"));
}
let mut g = old.clone();
g.set_offset(plane, to);
g.validate().map_err(|e| bad(C, e))?;
let label = match objects {
"copy" => "Move Plane and Copy Objects",
"move" => "Move Plane and Objects",
_ => "Move Plane",
};
let ids = s.edit(label, |d, sel| {
old.adopt_stored_attachments(d);
let mut ids = vec![];
if objects != "none" {
// The objects on the plane where it was (not those moved off it along its normal).
let on: Vec<NodeId> = persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.map(|(id, ..)| id)
.collect();
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
for id in &ids {
warp_checked(d, *id, &|q| m.apply(q))?;
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to);
}
}
store_grid(d, &g);
Ok(ids)
})?;
Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = id_param(p, "id")
.or_else(|| st.selection.objects.first().copied())
.ok_or_else(|| EngineError::Other("select an object in perspective".into()))?;
let mut g = grid_of(&st.doc);
let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?;
g.set_offset(plane, depth);
g.plane = plane;
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
s.edit("Move Plane to Match Object", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(json!({ "plane": plane.id(), "offset": depth }))
}
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.nudge";
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
+2
View File
@@ -1284,6 +1284,8 @@ mod tests_pdfpresets;
#[cfg(test)]
mod tests_pdfraster;
#[cfg(test)]
mod tests_persp_planes;
#[cfg(test)]
mod tests_persp_select;
#[cfg(test)]
mod tests_perspgrid;
+184
View File
@@ -0,0 +1,184 @@
//! Perspective planes: offsets, plane widgets (alone, Shift with the objects, Alt with copies),
//! Move Plane to Match Object, and grid edits with attached art.
use serde_json::json;
use vectorcraft_geom::{Point, Vec2};
use vectorcraft_tools::Mods;
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use super::tests_persp_select::{attached, drag, grid, persp_session, plane_pts, undo_len};
use super::*;
fn path(s: &Session, id: NodeId) -> vectorcraft_geom::PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
#[test]
fn moving_a_plane_alone_leaves_its_objects() {
let (mut s, id) = persp_session();
let before = path(&s, id);
let n = undo_len(&s);
let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 50})).unwrap();
assert_eq!((r["plane"].as_str(), r["offset"].as_f64()), (Some("right"), Some(50.0)));
assert_eq!(undo_len(&s), n + 1);
let g = grid(&s);
assert_eq!(g.right_offset, 50.0);
// The plane is drawn where it was moved: its origin corner lies 50 pt along its normal.
let o = g.homography(Plane::Right).unwrap().apply(Point::ZERO).unwrap();
assert!((o - g.homography_at(Plane::Right, 50.0).unwrap().apply(Point::ZERO).unwrap()).hypot() < 1e-9);
assert!((o - Point::new(g.origin[0], g.origin[1])).hypot() > 1.0);
assert_eq!(path(&s, id), before);
assert_eq!(attached(&s, id), Some((Plane::Right, 0.0)));
// Relative moves; new objects attach where the plane is.
s.execute("perspective.plane.move", &json!({"plane": "right", "by": -20})).unwrap();
assert_eq!(grid(&s).right_offset, 30.0);
let r = s.execute("shape.rectangle", &json!({"x": 470, "y": 300, "width": 30, "height": 30})).unwrap();
let b = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [b.0], "plane": "right"})).unwrap();
assert_eq!(attached(&s, b), Some((Plane::Right, 30.0)));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(grid(&s).right_offset, 50.0);
assert!(s.execute("perspective.plane.move", &json!({"plane": "right"})).is_err());
assert!(s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 1, "objects": "all"})).is_err());
assert!(s.execute("perspective.plane.move", &json!({"plane": "right", "offset": -1e9})).is_err(), "behind the viewer");
}
#[test]
fn moving_a_plane_with_its_objects_or_copies() {
let (mut s, id) = persp_session();
// A second object moved off the plane along its normal stays put.
let r = s.execute("shape.rectangle", &json!({"x": 560, "y": 330, "width": 30, "height": 30})).unwrap();
let off = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [off.0], "plane": "right"})).unwrap();
s.execute("perspective.transform", &json!({"ids": [off.0], "matrix": [1, 0, 0, 1, 0, 0], "depth": 20})).unwrap();
let off_path = path(&s, off);
let q = plane_pts(&s, id, Plane::Right, 0.0);
let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 35, "objects": "move"})).unwrap();
assert_eq!(r["ids"], json!([id.0]));
assert_eq!(attached(&s, id), Some((Plane::Right, 35.0)));
for (a, b) in q.iter().zip(plane_pts(&s, id, Plane::Right, 35.0)) {
assert!((*a - b).hypot() < 1e-6, "keeps its place on the plane");
}
assert_eq!(path(&s, off), off_path);
// Copies: the originals stay, the copies go with the plane.
let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 60, "objects": "copy"})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
assert_ne!(copy, id);
assert_eq!(attached(&s, id), Some((Plane::Right, 35.0)));
assert_eq!(attached(&s, copy), Some((Plane::Right, 60.0)));
for (a, b) in q.iter().zip(plane_pts(&s, copy, Plane::Right, 60.0)) {
assert!((*a - b).hypot() < 1e-6);
}
}
#[test]
fn plane_widgets_drag_with_shift_and_alt() {
let (mut s, id) = persp_session();
s.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
for tool in ["perspectiveGrid", "perspectiveSelection"] {
s.select_tool(tool, ViewInfo::default()).unwrap();
let g = grid(&s);
let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap();
let from = g.offset(Plane::Right);
// Where the widget lands 15 pt further along the plane's normal.
let to = g
.homography_at(Plane::Right, from + 15.0)
.unwrap()
.apply(g.homography(Plane::Right).unwrap().inverse().unwrap().apply(w).unwrap())
.unwrap();
let n = undo_len(&s);
drag(&mut s, &[w, to, to], Mods { shift: true, ..Default::default() });
assert_eq!(undo_len(&s), n + 1, "{tool}");
let (cmd, p) = s.journal.last().unwrap().clone();
assert_eq!((cmd.as_str(), p["objects"].as_str()), ("perspective.plane.move", Some("move")));
assert!((grid(&s).right_offset - from - 15.0).abs() < 1e-6, "{tool}: {}", grid(&s).right_offset);
assert!((attached(&s, id).unwrap().1 - from - 15.0).abs() < 1e-6, "{tool}: the object went along");
}
// Alt copies the objects; a plain drag moves the plane alone.
let g = grid(&s);
let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
let k = count(&s);
drag(&mut s, &[w, w + Vec2::new(-12.0, 0.0), w + Vec2::new(-12.0, 0.0)], Mods { alt: true, ..Default::default() });
assert_eq!(count(&s), k + 1);
let g = grid(&s);
let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap();
let depth = attached(&s, id).unwrap().1;
drag(&mut s, &[w, w + Vec2::new(-12.0, 0.0), w + Vec2::new(-12.0, 0.0)], Mods::default());
assert_eq!(attached(&s, id).unwrap().1, depth);
assert_eq!(count(&s), k + 1);
}
#[test]
fn double_clicking_a_plane_widget_asks_for_its_options() {
let (mut s, _) = persp_session();
s.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
let v = ViewInfo::default();
let (_, w) = grid(&s).plane_widgets().into_iter().find(|(p, _)| *p == Plane::Ground).unwrap();
for tool in ["perspectiveGrid", "perspectiveSelection"] {
s.select_tool(tool, v).unwrap();
let r = s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, w.x, w.y), v).unwrap();
assert_eq!(r, vec![crate::UiRequest::Dialog("perspectivePlane".into(), json!({"plane": "ground"}))], "{tool}");
}
}
#[test]
fn move_plane_to_match_object() {
let (mut s, id) = persp_session();
let q0 = plane_pts(&s, id, Plane::Right, 0.0);
s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -25})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap();
let r = s.execute("perspective.plane.matchObject", &json!({})).unwrap();
assert_eq!(r, json!({"plane": "right", "offset": -25.0}));
let g = grid(&s);
assert_eq!((g.right_offset, g.plane), (-25.0, Plane::Right));
// The object now lies on the plane where it is drawn.
let h = g.homography(Plane::Right).unwrap();
for (q, a) in q0.iter().zip(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors()) {
assert!((h.apply(*q).unwrap() - a.2.p).hypot() < 1e-6);
}
s.execute("perspective.release", &json!({"ids": [id.0]})).unwrap();
assert!(s.execute("perspective.plane.matchObject", &json!({"id": id.0})).is_err());
}
#[test]
fn grid_edits_keep_attached_art_in_place_unless_reprojected() {
let (mut s, id) = persp_session();
let before = path(&s, id);
let q = plane_pts(&s, id, Plane::Right, 0.0);
let vr = grid(&s).vp_right;
s.execute("perspective.grid.set", &json!({"vpRight": vr + 150.0})).unwrap();
assert_eq!(path(&s, id), before, "as in the reference app, art stays where it is");
s.execute("perspective.grid.set", &json!({"vpRight": vr - 100.0, "reproject": true})).unwrap();
assert_ne!(path(&s, id), before);
// Re-projected from the previous grid: the same place on the plane of the new one.
let old = {
let mut g = grid(&s);
g.vp_right = vr + 150.0;
g
};
let hi = old.homography(Plane::Right).unwrap().inverse().unwrap();
let back: Vec<Point> = before.anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect();
for (a, b) in back.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
assert!((*a - b).hypot() < 1e-6, "{a:?} {b:?}");
}
assert!(q.len() == back.len());
}
#[test]
fn plane_offsets_round_trip_and_old_grids_have_none() {
let (mut s, _) = persp_session();
s.execute("perspective.grid.set", &json!({"leftOffset": 12, "groundOffset": -8})).unwrap();
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let g = PerspectiveGrid::from_doc(&doc).unwrap();
assert_eq!((g.left_offset, g.right_offset, g.ground_offset), (12.0, 0.0, -8.0));
let mut stored = doc.unknown["perspectiveGrid"].clone();
assert!(stored.get("rightOffset").is_none(), "zero offsets aren't written");
for k in ["leftOffset", "groundOffset"] {
stored.as_object_mut().unwrap().remove(k);
}
let old: PerspectiveGrid = serde_json::from_value(stored).unwrap();
assert_eq!((old.left_offset, old.ground_offset), (0.0, 0.0));
}