mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
7ec936a0c1
commit
0f0dbbf9d5
11 files changed
+631
-16
No files matched your search
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Define Perspective Grid",
|
"Define Perspective Grid",
|
||||||
[],
|
[],
|
||||||
None,
|
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,
|
has_doc,
|
||||||
grid_set
|
grid_set
|
||||||
),
|
),
|
||||||
@@ -164,6 +164,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
has_selection,
|
has_selection,
|
||||||
persp_nudge
|
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> {
|
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, _| {
|
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);
|
store_grid(d, &g);
|
||||||
Ok(())
|
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 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()))?;
|
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)?;
|
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);
|
g.attached.insert(id.0.to_string(), plane);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -653,7 +682,7 @@ fn persp_edit(
|
|||||||
None => {
|
None => {
|
||||||
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
|
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
|
||||||
attach_in(d, &mut g, *id, pl)?;
|
attach_in(d, &mut g, *id, pl)?;
|
||||||
(pl, 0.0)
|
(pl, g.offset(pl))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
first.get_or_insert((plane, depth));
|
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)
|
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> {
|
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
const C: &str = "perspective.nudge";
|
const C: &str = "perspective.nudge";
|
||||||
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||||
|
|||||||
@@ -1284,6 +1284,8 @@ mod tests_pdfpresets;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_pdfraster;
|
mod tests_pdfraster;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_persp_planes;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_persp_select;
|
mod tests_persp_select;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_perspgrid;
|
mod tests_perspgrid;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -230,6 +230,12 @@ fn structured_junk() {
|
|||||||
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -1e9, "plane": "left", "copy": true})),
|
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -1e9, "plane": "left", "copy": true})),
|
||||||
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 1e300, 0], "plane": "left"})),
|
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 1e300, 0], "plane": "left"})),
|
||||||
("perspective.nudge", json!({"dx": 1e308, "dy": -1e308, "big": true, "copy": true})),
|
("perspective.nudge", json!({"dx": 1e308, "dy": -1e308, "big": true, "copy": true})),
|
||||||
|
("perspective.plane.move", json!({"plane": "ground", "offset": 1e308, "objects": "copy"})),
|
||||||
|
("perspective.plane.move", json!({"plane": "left", "by": -1e7, "objects": "move"})),
|
||||||
|
("perspective.plane.move", json!({"plane": "none", "offset": 0})),
|
||||||
|
("perspective.plane.matchObject", json!({"id": u64::MAX})),
|
||||||
|
("perspective.grid.set", json!({"leftOffset": -1e12, "reproject": true})),
|
||||||
|
("perspective.grid.set", json!({"vpRight": -1e6, "reproject": true})),
|
||||||
("object.setProps", json!({"opacity": -1, "blend": 5})),
|
("object.setProps", json!({"opacity": -1, "blend": 5})),
|
||||||
("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})),
|
("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})),
|
||||||
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
|
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ fn two() -> u8 {
|
|||||||
fn yes() -> bool {
|
fn yes() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
fn is_zero(v: &f64) -> bool {
|
||||||
|
*v == 0.0
|
||||||
|
}
|
||||||
|
|
||||||
/// The perspective grid definition (Define Grid) plus view state.
|
/// The perspective grid definition (Define Grid) plus view state.
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -141,6 +144,14 @@ pub struct PerspectiveGrid {
|
|||||||
/// Gridline opacity, 0–100 %.
|
/// Gridline opacity, 0–100 %.
|
||||||
#[serde(default = "define::half")]
|
#[serde(default = "define::half")]
|
||||||
pub opacity: f64,
|
pub opacity: f64,
|
||||||
|
/// Where the left, right and horizontal planes were moved along their normals (points; 0: their
|
||||||
|
/// place in the grid's definition): the plane widgets and `perspective.plane.move` set them.
|
||||||
|
#[serde(default, skip_serializing_if = "is_zero")]
|
||||||
|
pub left_offset: f64,
|
||||||
|
#[serde(default, skip_serializing_if = "is_zero")]
|
||||||
|
pub right_offset: f64,
|
||||||
|
#[serde(default, skip_serializing_if = "is_zero")]
|
||||||
|
pub ground_offset: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PerspectiveGrid {
|
impl PerspectiveGrid {
|
||||||
@@ -174,6 +185,29 @@ impl PerspectiveGrid {
|
|||||||
right_color: define::right_rgb(),
|
right_color: define::right_rgb(),
|
||||||
ground_color: define::ground_rgb(),
|
ground_color: define::ground_rgb(),
|
||||||
opacity: define::half(),
|
opacity: define::half(),
|
||||||
|
left_offset: 0.0,
|
||||||
|
right_offset: 0.0,
|
||||||
|
ground_offset: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `plane` is along its normal (points; 0: its place in the grid's definition).
|
||||||
|
pub fn offset(&self, plane: Plane) -> f64 {
|
||||||
|
match plane {
|
||||||
|
Plane::Left => self.left_offset,
|
||||||
|
Plane::Right => self.right_offset,
|
||||||
|
Plane::Ground => self.ground_offset,
|
||||||
|
Plane::None => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move `plane` to `offset` along its normal.
|
||||||
|
pub fn set_offset(&mut self, plane: Plane, offset: f64) {
|
||||||
|
match plane {
|
||||||
|
Plane::Left => self.left_offset = offset,
|
||||||
|
Plane::Right => self.right_offset = offset,
|
||||||
|
Plane::Ground => self.ground_offset = offset,
|
||||||
|
Plane::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +327,11 @@ impl PerspectiveGrid {
|
|||||||
if (self.origin[1] - self.horizon).abs() < 1e-6 {
|
if (self.origin[1] - self.horizon).abs() < 1e-6 {
|
||||||
return Err("ground level must differ from the horizon".into());
|
return Err("ground level must differ from the horizon".into());
|
||||||
}
|
}
|
||||||
|
// A moved plane stays in front of the viewer.
|
||||||
|
let behind = |p: Plane| self.axes(p).is_some_and(|[.., n]| 1.0 + self.offset(p) * self.axis_col(n)[2] <= 1e-9);
|
||||||
|
if Plane::ALL.iter().any(|p| !self.offset(*p).is_finite() || self.offset(*p).abs() > MAX_DEPTH || behind(*p)) {
|
||||||
|
return Err("a plane is moved too far (behind the viewer)".into());
|
||||||
|
}
|
||||||
self.validate_definition()
|
self.validate_definition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,9 +365,9 @@ impl PerspectiveGrid {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The homography of `plane` (plane coordinates in points → page).
|
/// The homography of `plane` where it is now (plane coordinates in points → page).
|
||||||
pub fn homography(&self, plane: Plane) -> Option<Homography> {
|
pub fn homography(&self, plane: Plane) -> Option<Homography> {
|
||||||
self.homography_at(plane, 0.0)
|
self.homography_at(plane, self.offset(plane))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The homography of the plane parallel to `plane` that lies `depth` points along its normal
|
/// The homography of the plane parallel to `plane` that lies `depth` points along its normal
|
||||||
@@ -500,6 +539,78 @@ impl PerspectiveGrid {
|
|||||||
}
|
}
|
||||||
faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl)
|
faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The left, right and horizontal plane widgets: where each sits (on its plane: the middle of a
|
||||||
|
/// wall's ground edge, the middle of the horizontal plane).
|
||||||
|
pub fn plane_widgets(&self) -> Vec<(Plane, Point)> {
|
||||||
|
let e = self.extent / 2.0;
|
||||||
|
Plane::ALL
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|pl| {
|
||||||
|
let q = if pl == Plane::Ground { Point::new(e, e) } else { Point::new(e, 0.0) };
|
||||||
|
Some((pl, self.homography(pl)?.apply(q)?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dragging a plane widget: the plane moves along its normal by as much as the pointer moved along
|
||||||
|
/// it (from the widget, wherever on it the press was).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct PlaneDrag {
|
||||||
|
pub plane: Plane,
|
||||||
|
anchor: Point,
|
||||||
|
press: Point,
|
||||||
|
offset: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaneDrag {
|
||||||
|
/// The plane widget within `tol` of `p` (drawn grids only).
|
||||||
|
pub fn hit(g: &PerspectiveGrid, tol: f64, p: Point) -> Option<Self> {
|
||||||
|
if !g.visible {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (plane, anchor) = g.plane_widgets().into_iter().find(|(_, q)| q.distance(p) <= tol)?;
|
||||||
|
Some(Self { plane, anchor, press: p, offset: g.offset(plane) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preview for the pointer at `p`: Shift moves the objects on the plane with it, Alt copies
|
||||||
|
/// them.
|
||||||
|
pub fn preview(&self, g: &PerspectiveGrid, p: Point, m: Mods) -> Option<Action> {
|
||||||
|
let offset = g.depth_at(self.plane, self.offset, self.anchor, self.anchor + (p - self.press))?;
|
||||||
|
let objects = if m.alt {
|
||||||
|
"copy"
|
||||||
|
} else if m.shift {
|
||||||
|
"move"
|
||||||
|
} else {
|
||||||
|
"none"
|
||||||
|
};
|
||||||
|
Some(Action::Preview("perspective.plane.move".into(), json!({"plane": self.plane.id(), "offset": offset, "objects": objects})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plane options dialog a double-click on the widget opens.
|
||||||
|
pub fn options(&self) -> Action {
|
||||||
|
Action::Dialog(PLANE_DIALOG.into(), json!({"plane": self.plane.id()}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dialog kind of a plane's options (double-click its widget).
|
||||||
|
pub const PLANE_DIALOG: &str = "perspectivePlane";
|
||||||
|
|
||||||
|
/// The plane widgets as drawn: a diamond in each plane's colour.
|
||||||
|
fn plane_widget_overlays(g: &PerspectiveGrid, tol: f64) -> Vec<Overlay> {
|
||||||
|
let r = 5.0 * tol;
|
||||||
|
g.plane_widgets()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|(pl, p)| {
|
||||||
|
let [cr, cg, cb] = pl.color();
|
||||||
|
let quad = [Point::new(p.x, p.y - r), Point::new(p.x + r, p.y), Point::new(p.x, p.y + r), Point::new(p.x - r, p.y)];
|
||||||
|
[
|
||||||
|
Overlay::Highlight { quad, color: [cr, cg, cb, 220] },
|
||||||
|
Overlay::Path { path: super::diamond(p, r), color: [0x20, 0x20, 0x20], width: 1.0, dashed: false },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How far (points) along a plane's normal objects and planes may go.
|
/// How far (points) along a plane's normal objects and planes may go.
|
||||||
@@ -520,6 +631,21 @@ pub fn attachment(n: &Node) -> Option<(Plane, f64)> {
|
|||||||
Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth))
|
Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The objects in perspective that aren't inside another one: (id, plane, depth).
|
||||||
|
pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> {
|
||||||
|
fn visit(n: &Node, out: &mut Vec<(NodeId, Plane, f64)>) {
|
||||||
|
match attachment(n) {
|
||||||
|
Some((pl, depth)) => out.push((n.id, pl, depth)),
|
||||||
|
None => n.children().into_iter().flatten().for_each(|c| visit(c, out)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = vec![];
|
||||||
|
for l in &doc.layers {
|
||||||
|
visit(l, &mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach `n` to `plane` at `depth`.
|
/// Attach `n` to `plane` at `depth`.
|
||||||
pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) {
|
pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) {
|
||||||
n.perspective = Some(Box::new(PerspectiveAttachment::new(plane.id(), depth)));
|
n.perspective = Some(Box::new(PerspectiveAttachment::new(plane.id(), depth)));
|
||||||
@@ -611,6 +737,7 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
|
|||||||
width: 1.5,
|
width: 1.5,
|
||||||
dashed: false,
|
dashed: false,
|
||||||
});
|
});
|
||||||
|
out.extend(plane_widget_overlays(&g, tol));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +810,8 @@ fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PerspectiveGridTool {
|
pub struct PerspectiveGridTool {
|
||||||
drag: Option<Handle>,
|
drag: Option<Handle>,
|
||||||
|
/// Dragging a plane widget.
|
||||||
|
plane: Option<PlaneDrag>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tool for PerspectiveGridTool {
|
impl Tool for PerspectiveGridTool {
|
||||||
@@ -702,6 +831,11 @@ impl Tool for PerspectiveGridTool {
|
|||||||
pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()})));
|
pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()})));
|
||||||
return pre;
|
return pre;
|
||||||
}
|
}
|
||||||
|
if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) {
|
||||||
|
self.plane = Some(d);
|
||||||
|
pre.push(Action::Begin("Move Plane".into()));
|
||||||
|
return pre;
|
||||||
|
}
|
||||||
let tol = cx.tol(6.0);
|
let tol = cx.tol(6.0);
|
||||||
if let Some((h, _)) =
|
if let Some((h, _)) =
|
||||||
handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p)))
|
handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p)))
|
||||||
@@ -711,14 +845,16 @@ impl Tool for PerspectiveGridTool {
|
|||||||
}
|
}
|
||||||
pre
|
pre
|
||||||
}
|
}
|
||||||
|
PointerKind::Drag if self.plane.is_some() => self.plane.and_then(|d| d.preview(&g, p, ev.mods)).into_iter().collect(),
|
||||||
PointerKind::Drag => match self.drag {
|
PointerKind::Drag => match self.drag {
|
||||||
Some(h) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(&g, h, p).definition_json())],
|
Some(h) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(&g, h, p).definition_json())],
|
||||||
None => vec![],
|
None => vec![],
|
||||||
},
|
},
|
||||||
PointerKind::Up => match self.drag.take() {
|
PointerKind::Up => match (self.drag.take(), self.plane.take()) {
|
||||||
Some(_) => vec![Action::Commit],
|
(None, None) => vec![],
|
||||||
None => vec![],
|
_ => vec![Action::Commit],
|
||||||
},
|
},
|
||||||
|
PointerKind::DoubleClick => PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect(),
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,6 +871,9 @@ impl Tool for PerspectiveGridTool {
|
|||||||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||||||
let g = PerspectiveGrid::current(cx.doc);
|
let g = PerspectiveGrid::current(cx.doc);
|
||||||
let tol = cx.tol(6.0);
|
let tol = cx.tol(6.0);
|
||||||
|
if self.plane.is_some() || PlaneDrag::hit(&g, tol, p).is_some() {
|
||||||
|
return Cursor::Move;
|
||||||
|
}
|
||||||
match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) {
|
match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) {
|
||||||
Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV,
|
Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV,
|
||||||
Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH,
|
Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH,
|
||||||
@@ -743,10 +882,13 @@ impl Tool for PerspectiveGridTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn busy(&self) -> bool {
|
fn busy(&self) -> bool {
|
||||||
self.drag.is_some()
|
self.drag.is_some() || self.plane.is_some()
|
||||||
}
|
}
|
||||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||||
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
match (self.drag.take(), self.plane.take()) {
|
||||||
|
(None, None) => vec![],
|
||||||
|
_ => vec![Action::Commit],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,6 +903,8 @@ enum SelDrag {
|
|||||||
Move { ids: Vec<NodeId>, from: Point, last: Point, began: bool, perp: bool, copy: bool },
|
Move { ids: Vec<NodeId>, from: Point, last: Point, began: bool, perp: bool, copy: bool },
|
||||||
/// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`).
|
/// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`).
|
||||||
Scale { ids: Vec<NodeId>, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle },
|
Scale { ids: Vec<NodeId>, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle },
|
||||||
|
/// Dragging a plane widget.
|
||||||
|
Plane(PlaneDrag),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move
|
/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move
|
||||||
@@ -805,7 +949,7 @@ impl PerspectiveSelectionTool {
|
|||||||
if g.plane != Plane::None {
|
if g.plane != Plane::None {
|
||||||
v["plane"] = json!(g.plane.id());
|
v["plane"] = json!(g.plane.id());
|
||||||
}
|
}
|
||||||
let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, 0.0)));
|
let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane))));
|
||||||
self.measure = at.and_then(|(pl, depth)| {
|
self.measure = at.and_then(|(pl, depth)| {
|
||||||
let text = if *perp {
|
let text = if *perp {
|
||||||
format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth))
|
format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth))
|
||||||
@@ -834,6 +978,10 @@ impl Tool for PerspectiveSelectionTool {
|
|||||||
{
|
{
|
||||||
return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))];
|
return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))];
|
||||||
}
|
}
|
||||||
|
if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) {
|
||||||
|
self.drag = SelDrag::Plane(d);
|
||||||
|
return vec![Action::Begin("Move Plane".into())];
|
||||||
|
}
|
||||||
if let Some((plane, depth, rect, h)) = selection_persp_box(cx, &g)
|
if let Some((plane, depth, rect, h)) = selection_persp_box(cx, &g)
|
||||||
&& let Some(handle) = persp_handle_at(rect, &h, p, cx.tol(5.0))
|
&& let Some(handle) = persp_handle_at(rect, &h, p, cx.tol(5.0))
|
||||||
{
|
{
|
||||||
@@ -886,10 +1034,18 @@ impl Tool for PerspectiveSelectionTool {
|
|||||||
self.measure = None;
|
self.measure = None;
|
||||||
if began { vec![Action::Commit] } else { vec![] }
|
if began { vec![Action::Commit] } else { vec![] }
|
||||||
}
|
}
|
||||||
(PointerKind::Up, SelDrag::Scale { .. }) => {
|
(PointerKind::Drag, SelDrag::Plane(d)) => {
|
||||||
|
self.drag = SelDrag::Plane(d);
|
||||||
|
d.preview(&g, p, m).into_iter().collect()
|
||||||
|
}
|
||||||
|
(PointerKind::Up, SelDrag::Scale { .. } | SelDrag::Plane(_)) => {
|
||||||
self.measure = None;
|
self.measure = None;
|
||||||
vec![Action::Commit]
|
vec![Action::Commit]
|
||||||
}
|
}
|
||||||
|
(PointerKind::DoubleClick, d) => {
|
||||||
|
self.drag = d;
|
||||||
|
PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect()
|
||||||
|
}
|
||||||
(_, d) => {
|
(_, d) => {
|
||||||
self.drag = d;
|
self.drag = d;
|
||||||
vec![]
|
vec![]
|
||||||
@@ -947,9 +1103,12 @@ impl Tool for PerspectiveSelectionTool {
|
|||||||
let g = PerspectiveGrid::current(cx.doc);
|
let g = PerspectiveGrid::current(cx.doc);
|
||||||
let handle = match &self.drag {
|
let handle = match &self.drag {
|
||||||
SelDrag::Scale { handle, .. } => Some(*handle),
|
SelDrag::Scale { handle, .. } => Some(*handle),
|
||||||
SelDrag::Move { began: true, .. } => return Cursor::Move,
|
SelDrag::Move { began: true, .. } | SelDrag::Plane(_) => return Cursor::Move,
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
if PlaneDrag::hit(&g, cx.tol(6.0), p).is_some() {
|
||||||
|
return Cursor::Move;
|
||||||
|
}
|
||||||
if let Some((_, _, rect, h)) = selection_persp_box(cx, &g)
|
if let Some((_, _, rect, h)) = selection_persp_box(cx, &g)
|
||||||
&& let Some(k) = handle.or_else(|| persp_handle_at(rect, &h, p, cx.tol(5.0)))
|
&& let Some(k) = handle.or_else(|| persp_handle_at(rect, &h, p, cx.tol(5.0)))
|
||||||
&& let (Some(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center()))
|
&& let (Some(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center()))
|
||||||
@@ -964,7 +1123,7 @@ impl Tool for PerspectiveSelectionTool {
|
|||||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||||
self.measure = None;
|
self.measure = None;
|
||||||
match std::mem::take(&mut self.drag) {
|
match std::mem::take(&mut self.drag) {
|
||||||
SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } => vec![Action::Commit],
|
SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } | SelDrag::Plane(_) => vec![Action::Commit],
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub mod package;
|
|||||||
mod path_ops;
|
mod path_ops;
|
||||||
pub mod pdf_presets;
|
pub mod pdf_presets;
|
||||||
pub mod perspective_grid;
|
pub mod perspective_grid;
|
||||||
|
pub mod perspective_plane;
|
||||||
pub mod perspective_presets;
|
pub mod perspective_presets;
|
||||||
pub mod place;
|
pub mod place;
|
||||||
pub mod placement_options;
|
pub mod placement_options;
|
||||||
@@ -259,6 +260,7 @@ registry! {
|
|||||||
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
|
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
|
||||||
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
|
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
|
||||||
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
|
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
|
||||||
|
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//! Perspective plane options: double-clicking a plane widget of the perspective grid (or
|
||||||
|
//! `ui.perspectivePlane {plane}`) moves that plane to an exact location, alone, with the objects on
|
||||||
|
//! it, or with copies of them. OK runs `perspective.plane.move`.
|
||||||
|
//!
|
||||||
|
//! Fields: `plane` (`left`, `right` or `ground`), `location` (points along the plane's normal; 0
|
||||||
|
//! is its place in the grid's definition), `objects` (`none`, `move` or `copy`).
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
|
||||||
|
|
||||||
|
use super::swatch_options::{grid, label};
|
||||||
|
use super::{DialogSpec, run_and_close};
|
||||||
|
use crate::state::Dialog;
|
||||||
|
use crate::{VectorcraftApp, widgets};
|
||||||
|
|
||||||
|
/// The dialog kind of the plane options.
|
||||||
|
pub const KIND: &str = "perspectivePlane";
|
||||||
|
|
||||||
|
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 300.0, ..DialogSpec::FORM };
|
||||||
|
|
||||||
|
/// What becomes of the objects on the plane: (`objects` value, label).
|
||||||
|
const OBJECTS: [(&str, &str); 3] = [("none", "Do Not Move"), ("move", "Move All Objects"), ("copy", "Copy All Objects")];
|
||||||
|
|
||||||
|
fn heading(d: &Dialog) -> String {
|
||||||
|
match Plane::parse(&d.str("plane")) {
|
||||||
|
Some(Plane::Right) => "Right Plane",
|
||||||
|
Some(Plane::Ground) => "Horizontal Plane",
|
||||||
|
_ => "Left Plane",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the options of `plane` (`{plane}`; default the active plane), filled in with its location.
|
||||||
|
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||||
|
let doc = &app.session.active().ok_or("no document")?.doc;
|
||||||
|
let g = PerspectiveGrid::current(doc);
|
||||||
|
let plane = match p.get("plane").and_then(Value::as_str) {
|
||||||
|
Some(s) => Plane::parse(s).ok_or("plane must be left, right or ground")?,
|
||||||
|
None => g.plane,
|
||||||
|
};
|
||||||
|
if plane == Plane::None {
|
||||||
|
return Err("no active perspective plane".into());
|
||||||
|
}
|
||||||
|
let fields = json!({"plane": plane.id(), "location": g.offset(plane), "objects": "none"});
|
||||||
|
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||||
|
let unit = app.session.general_unit();
|
||||||
|
grid(ui, |ui| {
|
||||||
|
label(ui, "Location:");
|
||||||
|
if let Some(v) = widgets::num_field(ui, "pp-location", Some(d.f64("location", 0.0)), unit, 120.0) {
|
||||||
|
d.fields.insert("location".into(), json!(v));
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
ui.add_space(6.0);
|
||||||
|
let cur = d.str("objects");
|
||||||
|
for (value, text) in OBJECTS {
|
||||||
|
if widgets::radio(ui, text, cur == value || (cur.is_empty() && value == "none"), true) {
|
||||||
|
d.fields.insert("objects".into(), json!(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||||
|
let objects = Some(d.str("objects")).filter(|o| OBJECTS.iter().any(|(v, _)| v == o)).unwrap_or_else(|| "none".into());
|
||||||
|
let p = json!({"plane": d.str("plane"), "offset": d.f64("location", 0.0), "objects": objects});
|
||||||
|
run_and_close(app, "perspective.plane.move", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use vectorcraft_engine::Session;
|
||||||
|
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||||
|
|
||||||
|
fn shown(app: &mut VectorcraftApp) -> Vec<String> {
|
||||||
|
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||||
|
match s {
|
||||||
|
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||||
|
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
crate::theme::install_fonts(&ctx);
|
||||||
|
// A new window lays itself out in its first frame and paints in the next.
|
||||||
|
ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear();
|
||||||
|
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
|
||||||
|
out.textures_delta.clear();
|
||||||
|
let mut v = vec![];
|
||||||
|
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_clicking_a_plane_widget_opens_its_options_and_ok_moves_it() {
|
||||||
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||||
|
app.run("file.new", json!({"width": 800, "height": 600})).unwrap();
|
||||||
|
app.run("perspective.grid.preset", json!({"kind": 2})).unwrap();
|
||||||
|
let id = app.run("shape.rectangle", json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||||||
|
app.run("perspective.attach", json!({"ids": [id], "plane": "right"})).unwrap();
|
||||||
|
let g = PerspectiveGrid::current(&app.session.active().unwrap().doc);
|
||||||
|
let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap();
|
||||||
|
app.select_tool("perspectiveGrid");
|
||||||
|
let view = app.view_info();
|
||||||
|
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::DoubleClick, w.x, w.y), view);
|
||||||
|
let d = app.ui.dialog.clone().expect("the plane's options opened");
|
||||||
|
assert_eq!((d.kind.as_str(), d.str("plane").as_str(), d.f64("location", 1.0)), (KIND, "right", 0.0));
|
||||||
|
let texts = shown(&mut app);
|
||||||
|
for s in ["Right Plane", "Location:", "Do Not Move", "Move All Objects", "Copy All Objects"] {
|
||||||
|
assert!(texts.iter().any(|t| t == s), "{s} in {texts:?}");
|
||||||
|
}
|
||||||
|
let d = app.ui.dialog.as_mut().unwrap();
|
||||||
|
d.fields.insert("location".into(), json!(40));
|
||||||
|
d.fields.insert("objects".into(), json!("copy"));
|
||||||
|
super::super::confirm(&mut app).unwrap();
|
||||||
|
assert!(app.ui.dialog.is_none());
|
||||||
|
let doc = &app.session.active().unwrap().doc;
|
||||||
|
assert_eq!(PerspectiveGrid::current(doc).right_offset, 40.0);
|
||||||
|
assert_eq!(doc.layers[0].children().unwrap().len(), 2, "copied");
|
||||||
|
// The UI command opens it too; unknown planes are refused.
|
||||||
|
app.run("ui.perspectivePlane", json!({"plane": "ground"})).unwrap();
|
||||||
|
assert_eq!(heading(app.ui.dialog.as_ref().unwrap()), "Horizontal Plane");
|
||||||
|
assert!(app.run("ui.perspectivePlane", json!({"plane": "up"})).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Double-clicking a plane widget of the perspective grid: that plane's options.
|
||||||
|
super::perspective_plane::KIND => {
|
||||||
|
if let Err(e) = super::perspective_plane::open(app, &p) {
|
||||||
|
app.status(e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Double-clicking a slice with the Slice Selection tool: Slice Options.
|
// Double-clicking a slice with the Slice Selection tool: Slice Options.
|
||||||
super::slices::OPTIONS => {
|
super::slices::OPTIONS => {
|
||||||
if let Err(e) = super::slices::open_options(app) {
|
if let Err(e) = super::slices::open_options(app) {
|
||||||
|
|||||||
@@ -661,6 +661,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||||||
"",
|
"",
|
||||||
"{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with",
|
"{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"ui.perspectivePlane",
|
||||||
|
"Perspective Plane Options…",
|
||||||
|
"",
|
||||||
|
"{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||||
@@ -1088,6 +1094,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||||||
None => Err("no such perspective grid preset".into()),
|
None => Err("no such perspective grid preset".into()),
|
||||||
},
|
},
|
||||||
"ui.blendOptions" => crate::dialogs::blend_options::open(app),
|
"ui.blendOptions" => crate::dialogs::blend_options::open(app),
|
||||||
|
"ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(r)
|
Some(r)
|
||||||
@@ -1382,6 +1389,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||||||
id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => {
|
id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => {
|
||||||
app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some()
|
app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some()
|
||||||
}
|
}
|
||||||
|
"ui.perspectivePlane" => app.session.active().is_some(),
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1682,7 +1690,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||||||
c("Edit Contents", "object.envelope.editContents"),
|
c("Edit Contents", "object.envelope.editContents"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
sub("Perspective", vec![c("Attach to Active Plane", "perspective.attach"), c("Release with Perspective", "perspective.release")]),
|
sub(
|
||||||
|
"Perspective",
|
||||||
|
vec![
|
||||||
|
c("Attach to Active Plane", "perspective.attach"),
|
||||||
|
c("Release with Perspective", "perspective.release"),
|
||||||
|
c("Move Plane to Match Object", "perspective.plane.matchObject"),
|
||||||
|
],
|
||||||
|
),
|
||||||
sub(
|
sub(
|
||||||
"Live Paint",
|
"Live Paint",
|
||||||
vec![
|
vec![
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ Width Point Edit: double-clicking a width point with the Width tool, or `ui.widt
|
|||||||
`adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard",
|
`adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard",
|
||||||
value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`.
|
value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`.
|
||||||
|
|
||||||
|
Perspective plane options: double-clicking a plane widget of the perspective grid, or `ui.perspectivePlane {plane}`,
|
||||||
|
opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `location`: points along the plane's
|
||||||
|
normal; `objects`: none, move or copy). `ui.dialog.confirm` runs `perspective.plane.move` with them.
|
||||||
|
|
||||||
Units: dialog distance fields (Move's `dx`/`dy`, shape sizes, Offset Path's `offset`, Split Into Grid's `gutter`,
|
Units: dialog distance fields (Move's `dx`/`dy`, shape sizes, Offset Path's `offset`, Split Into Grid's `gutter`,
|
||||||
Artboard Options' sizes, effects' distances, `transformEach`'s `moveH`/`moveV`, New Document's `width`/`height`, the
|
Artboard Options' sizes, effects' distances, `transformEach`'s `moveH`/`moveV`, New Document's `width`/`height`, the
|
||||||
length preferences in `preferences`) show in the General unit (the active document's units, or `unitsGeneral` without a
|
length preferences in `preferences`) show in the General unit (the active document's units, or `unitsGeneral` without a
|
||||||
|
|||||||
+15
@@ -1863,6 +1863,21 @@ copies). Object › Transform › Transform Again repeats the last perspective m
|
|||||||
{"name":"run_command","arguments":{"command":"object.transformAgain","params":{}}}
|
{"name":"run_command","arguments":{"command":"object.transformAgain","params":{}}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Planes move along their normals: `perspective.plane.move {plane, offset | by, objects}` (`leftOffset`,
|
||||||
|
`rightOffset`, `groundOffset` in the grid). The plane widgets (a diamond on each plane, drawn while the grid shows)
|
||||||
|
do it with the Perspective Grid and Perspective Selection tools: a plain drag moves the plane alone, Shift-drag moves
|
||||||
|
the objects on it too, Alt-drag copies them; double-clicking a widget (or `ui.perspectivePlane {plane}`) opens the
|
||||||
|
`perspectivePlane` dialog (`location` in points, `objects`: none, move or copy). Objects keep their place when the
|
||||||
|
grid's definition changes, as in the reference app; `perspective.grid.set` with `reproject: true` moves them with it
|
||||||
|
instead. `perspective.plane.matchObject` (Object › Perspective › Move Plane to Match Object) moves the plane of the
|
||||||
|
selected object onto it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"run_command","arguments":{"command":"perspective.plane.move","params":{"plane":"right","offset":40,"objects":"move"}}}
|
||||||
|
{"name":"run_command","arguments":{"command":"perspective.plane.matchObject","params":{}}}
|
||||||
|
{"name":"run_command","arguments":{"command":"ui.perspectivePlane","params":{"plane":"ground"}}}
|
||||||
|
```
|
||||||
|
|
||||||
## Blends
|
## Blends
|
||||||
|
|
||||||
`object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to
|
`object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to
|
||||||
|
|||||||
Reference in new issue
Block a user