diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3e2ed68f..b7d6b94e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -19,6 +19,7 @@ pub mod node; pub mod orient; pub mod overprint; pub mod pattern; +pub mod perspective; mod pixels; pub mod profiles; pub mod range; @@ -65,6 +66,7 @@ pub use node::{ImageMap, ObjectAttributes}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; +pub use perspective::PerspectiveAttachment; pub use profiles::ColorProfiles; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; pub use selection::{AnchorRef, Selection}; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 63f09c78..b41552b1 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -343,6 +343,9 @@ pub struct Node { /// [`crate::orient`]); Reset Bounding Box sets it back to 0. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub bbox_angle: f64, + /// Object › Perspective: the perspective grid plane the object is attached to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub perspective: Option>, } /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). @@ -392,6 +395,7 @@ impl Node { attrs: None, slice: None, bbox_angle: 0.0, + perspective: None, } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs new file mode 100644 index 00000000..74bae06a --- /dev/null +++ b/crates/doc/src/perspective.rs @@ -0,0 +1,26 @@ +//! Perspective Grid attachments (Object › Perspective). +//! +//! The grid itself is document data (`Document.unknown["perspectiveGrid"]`, modelled by the tools +//! crate). Which plane an object lies on is kept on the object, so copies, duplicates and pastes +//! stay attached and deleted objects leave nothing stale behind. + +use serde::{Deserialize, Serialize}; + +/// The plane an object in perspective lies on. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PerspectiveAttachment { + /// `left`, `right` or `ground`. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub plane: String, + /// Where along the plane's normal (points) the parallel plane the object lies on is: 0 is the + /// grid plane in its original place. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub depth: f64, +} + +impl PerspectiveAttachment { + pub fn new(plane: &str, depth: f64) -> Self { + Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 } } + } +} diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index a493ca9f..dbe7d58f 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -8,12 +8,12 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile}; -use vectorcraft_geom::Point; +use vectorcraft_geom::{Affine, Homography, Point}; use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample}; -use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; +use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with}; -use super::edit::selected_roots; +use super::edit::{duplicate_in, selected_roots}; use super::*; use crate::EngineError; @@ -124,7 +124,7 @@ pub fn specs() -> Vec { "Move in Perspective", [], None, - "{ids?, from: [x,y], to: [x,y], plane?} slide objects within their plane (unattached objects attach to `plane`/the active plane first)", + "{ids?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); Transform Again repeats it", has_doc, persp_move ), @@ -137,6 +137,24 @@ pub fn specs() -> Vec { has_doc, persp_draw ), + cmd!( + "perspective.transform", + "Transform in Perspective", + [], + None, + "{ids?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}", + has_doc, + persp_transform + ), + cmd!( + "perspective.nudge", + "Nudge in Perspective", + [], + None, + "{dx, dy: arrow direction (-1, 0 or 1), big?: bool (×10), copy?: bool} move the selection in perspective by the keyboard increment, as the arrow keys do with the Perspective Selection tool → {ids}", + has_selection, + persp_nudge + ), ] } @@ -523,6 +541,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); g.attached.insert(id.0.to_string(), plane); Ok(()) } @@ -547,6 +566,9 @@ fn release(s: &mut Session, p: &Value) -> Result { s.edit("Release with Perspective", |d, _| { for id in &ids { g.attached.remove(&id.0.to_string()); + if let Some(n) = d.node_mut(*id) { + n.perspective = None; + } } store_grid(d, &g); Ok(()) @@ -554,30 +576,161 @@ fn release(s: &mut Session, p: &Value) -> Result { ok() } +/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and +/// the depth it ends at. +type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>; + +/// Where an object is in perspective: its plane and depth. +type Attached = (Plane, f64); + +fn beyond_horizon() -> EngineError { + EngineError::Other("the pointer is beyond the plane's horizon".into()) +} + +/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay +/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach +/// to `fallback` first. → the edited objects and where the first one was (plane, depth). +fn persp_edit( + s: &mut Session, + label: &str, + ids: &[NodeId], + fallback: Option, + copy: bool, + step: PerspStep, +) -> Result<(Vec, Option)> { + if ids.is_empty() { + return Err(EngineError::Other("select the objects to transform in perspective".into())); + } + let mut g = grid_of(&s.doc()?.doc); + s.edit(label, |d, sel| { + g.adopt_stored_attachments(d); + let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() }; + let mut first = None; + for id in &targets { + let (plane, depth) = match g.attachment_of(d, *id) { + Some(a) => a, + 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) + } + }; + first.get_or_insert((plane, depth)); + let (h, depth) = step(&g, plane, depth)?; + warp_checked(d, *id, &|q| h.apply(q))?; + persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth); + } + store_grid(d, &g); + Ok((targets, first)) + }) +} + +/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once +/// the drag that made it is committed. +fn record_again(s: &mut Session, again: Value) -> Result<()> { + let st = s.doc_mut()?; + match &mut st.interaction { + Some(it) => it.perspective_again = Some(again), + None => st.last_perspective = Some(again), + } + Ok(()) +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// A plane-space affine map as `perspective.transform`'s `matrix` param. +fn matrix_json(m: Affine) -> Value { + json!(m.as_coeffs()) +} + fn persp_move(s: &mut Session, p: &Value) -> Result { const C: &str = "perspective.move"; let ids = roots(s, p)?; let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?; let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?; - let mut g = grid_of(&s.doc()?.doc); - let fallback = plane_param(&g, p, C).ok(); - s.edit("Move in Perspective", |d, _| { - for id in &ids { - let plane = match g.attached_plane(*id) { - Some(pl) => pl, - 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 - } - }; - let m = g.move_map(plane, from, to).ok_or_else(|| EngineError::Other("the pointer is beyond the plane's horizon".into()))?; - warp_checked(d, *id, &m)?; + if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) { + return Err(bad(C, "from and to must be finite")); + } + let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); + let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false)); + // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. + let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { + if perp { + let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; + Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) + } else { + let dv = g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?; + Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) } - store_grid(d, &g); - Ok(()) - })?; - ok() + }; + let label = if copy { "Copy in Perspective" } else { "Move in Perspective" }; + let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?; + // Transform Again repeats the first object's move. + if let Some((pl, depth)) = first { + let g = grid_of(&s.doc()?.doc); + let again = match perp { + true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), + false => g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(dv)), "copy": copy})), + }; + if let Some(a) = again { + record_again(s, a)?; + } + } + Ok(ids_json(&targets)) +} + +fn persp_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.transform"; + let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?; + let dz = f64_or(p, "depth", 0.0); + if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() { + return Err(bad(C, "matrix must be finite and invertible, depth finite")); + } + let ids = roots(s, p)?; + let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); + let copy = bool_or(p, "copy", false); + let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { + let to = depth + dz; + if to.abs() > persp::MAX_DEPTH { + return Err(EngineError::Other("the objects would leave the grid".into())); + } + Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to)) + }; + let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?; + record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?; + Ok(ids_json(&targets)) +} + +/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space +/// transform on the selection. +pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result { + let mut p = again.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("ids"); + } + persp_transform(s, &p) +} + +fn persp_nudge(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.nudge"; + let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { + return Err(bad(C, "dx and dy give the arrow's direction")); + } + let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 }; + let ids = selected_roots(s)?; + // The step is measured on the page at the centre of the selection's perspective box. + let st = s.doc()?; + let g = grid_of(&st.doc); + let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?; + let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?; + let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k; + persp_move( + s, + &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}), + ) } fn persp_draw(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 991b7cc9..655064f7 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -219,6 +219,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf let st = s.doc_mut()?; if st.interaction.is_none() { st.last_transform = Some((xf, copy)); + st.last_perspective = None; } Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } @@ -299,6 +300,9 @@ fn shear(s: &mut Session, p: &Value) -> Result { } fn transform_again(s: &mut Session, _: &Value) -> Result { + if let Some(again) = s.doc()?.last_perspective.clone() { + return super::distortcmds::transform_again(s, &again); + } let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?; let ids = selected_roots(s)?; apply_transform(s, "Transform Again", ids, m, &json!({ "copy": copy })) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 206b07af..14313d4a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -81,6 +81,9 @@ pub struct Interaction { /// Per-document state restored on cancel (current layer, isolation). pub active_layer: Option, pub isolation: Option, + /// The perspective transform the previews make (`perspective.transform` params): Transform + /// Again repeats it once the drag is committed. + pub perspective_again: Option, } /// Per-document editing state. @@ -131,6 +134,9 @@ pub struct DocState { pub recovery: Option, /// View → Show Print Tiling, per document (view state: not saved, not undoable). pub print_tiling: bool, + /// Transform Again after a perspective move or scale (Perspective Selection tool): the + /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. + pub last_perspective: Option, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -163,6 +169,7 @@ impl DocState { recovered: false, recovery: None, print_tiling: false, + last_perspective: None, } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). @@ -1052,6 +1059,7 @@ impl Session { preview: None, active_layer: st.active_layer, isolation: st.isolation, + perspective_again: None, }); Ok(()) } @@ -1080,14 +1088,19 @@ impl Session { let st = self.doc_mut()?; let Some(mut it) = st.interaction.take() else { return Ok(()) }; let Some(preview) = it.preview.take() else { return Ok(()) }; + let perspective_again = it.perspective_again.take(); st.keep_interaction(it); if preview.0 == "object.transform" { let m = cmd::matrix_param(&preview.1, "matrix"); let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); if let Some(m) = m { st.last_transform = Some((m, copy)); + st.last_perspective = None; } } + if perspective_again.is_some() { + st.last_perspective = perspective_again; + } self.journal.push(preview); Ok(()) } @@ -1256,6 +1269,8 @@ mod tests_pdfpresets; #[cfg(test)] mod tests_pdfraster; #[cfg(test)] +mod tests_persp_select; +#[cfg(test)] mod tests_perspgrid; #[cfg(test)] mod tests_place; diff --git a/crates/engine/src/tests_persp_select.rs b/crates/engine/src/tests_persp_select.rs new file mode 100644 index 00000000..859a48e6 --- /dev/null +++ b/crates/engine/src/tests_persp_select.rs @@ -0,0 +1,253 @@ +//! Perspective Selection tool: scaling in perspective, Alt-drag copies, moving perpendicular to the +//! plane (5), Transform Again and arrow nudges in perspective. + +use serde_json::json; +use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; + +/// An 800 × 600 document with a two-point grid and a rectangle attached to the right plane. +pub(crate) fn persp_session() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + (s, id) +} + +pub(crate) fn grid(s: &Session) -> PerspectiveGrid { + PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap() +} + +pub(crate) fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Where `id` is attached (plane, depth). +pub(crate) fn attached(s: &Session, id: NodeId) -> Option<(Plane, f64)> { + grid(s).attachment_of(&s.doc().unwrap().doc, id) +} + +/// The anchors of path `id` in the plane coordinates of `plane` at `depth`. +pub(crate) fn plane_pts(s: &Session, id: NodeId, plane: Plane, depth: f64) -> Vec { + let g = grid(s); + let hi = g.homography_at(plane, depth).unwrap().inverse().unwrap(); + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect() +} + +pub(crate) fn bounds_of(pts: &[Point]) -> Rect { + pts.iter().skip(1).fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p)) +} + +/// Pointer events with the active tool: down at the first point, drags, up at the last. +pub(crate) fn drag(s: &mut Session, pts: &[Point], mods: Mods) { + let v = ViewInfo::default(); + for (i, p) in pts.iter().enumerate() { + let kind = if i == 0 { + PointerKind::Down + } else if i == pts.len() - 1 { + PointerKind::Up + } else { + PointerKind::Drag + }; + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap(); + } +} + +#[test] +fn handles_scale_in_perspective_about_the_opposite_handle() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let g = grid(&s); + let before = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0)); + // The box's handles are drawn in perspective: the far corner of the plane-space box. + let h = g.homography(Plane::Right).unwrap(); + let corner = h.apply(Point::new(before.x1, before.y1)).unwrap(); + let anchors = s.overlays(ViewInfo::default()).into_iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Anchor { .. })).count(); + assert!(anchors >= 8, "{anchors} handles"); + // Drag that handle to twice the size in plane space. + let target = h.apply(Point::new(before.x0 + 2.0 * before.width(), before.y0 + 2.0 * before.height())).unwrap(); + let n = undo_len(&s); + drag(&mut s, &[corner, target, target], Mods::default()); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + assert_eq!(s.journal.last().unwrap().0, "perspective.transform"); + let after = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0)); + assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "the opposite corner stays: {before:?} → {after:?}"); + assert!((after.width() - 2.0 * before.width()).abs() < 1e-6 && (after.height() - 2.0 * before.height()).abs() < 1e-6, "{after:?}"); + // Still a rectangle on the plane. + for q in plane_pts(&s, id, Plane::Right, 0.0) { + assert!(((q.x - after.x0).abs() < 1e-6 || (q.x - after.x1).abs() < 1e-6) && ((q.y - after.y0).abs() < 1e-6 || (q.y - after.y1).abs() < 1e-6)); + } + assert_eq!(attached(&s, id), Some((Plane::Right, 0.0))); +} + +#[test] +fn alt_drag_copies_and_the_copy_stays_attached() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let c = bounds_of(&before).center(); + let h = grid(&s).homography(Plane::Right).unwrap(); + let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(80.0, 0.0)).unwrap()); + let n = undo_len(&s); + drag(&mut s, &[from, to, to], Mods { alt: true, ..Default::default() }); + assert_eq!(undo_len(&s), n + 1); + let copy = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_ne!(copy, id, "the copy is selected"); + assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before, "the original stays"); + assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)), "the copy is attached"); + for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) { + assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(copy).is_none()); +} + +#[test] +fn pressing_5_while_dragging_moves_perpendicular_to_the_plane() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let c = bounds_of(&before).center(); + let g = grid(&s); + let from = g.homography(Plane::Right).unwrap().apply(c).unwrap(); + // The point 40 pt in front of the plane (along its normal, toward the viewer's left wall side). + let to = g.homography_at(Plane::Right, 40.0).unwrap().apply(c).unwrap(); + let v = ViewInfo::default(); + let n = undo_len(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap(); + s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let (plane, depth) = attached(&s, id).unwrap(); + assert_eq!(plane, Plane::Right); + assert!((depth - 40.0).abs() < 1e-6, "{depth}"); + // Same place within the plane, on the parallel plane 40 pt along the normal. + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 40.0)) { + assert!((*a - b).hypot() < 1e-6, "{a:?} {b:?}"); + } + // Pressing 5 again goes back to moving within the plane. + let c2 = g.homography_at(Plane::Right, 40.0).unwrap().apply(c).unwrap(); + let to2 = g.homography_at(Plane::Right, 40.0).unwrap().apply(c + Vec2::new(30.0, 0.0)).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, c2.x, c2.y), v).unwrap(); + s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap(); + s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to2.x, to2.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to2.x, to2.y), v).unwrap(); + assert!((attached(&s, id).unwrap().1 - 40.0).abs() < 1e-6); + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 40.0)) { + assert!((b - *a - Vec2::new(30.0, 0.0)).hypot() < 1e-6); + } +} + +#[test] +fn transform_again_repeats_the_last_perspective_move() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let c = bounds_of(&before).center(); + let h = grid(&s).homography(Plane::Right).unwrap(); + let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(25.0, 10.0)).unwrap()); + drag(&mut s, &[from, to, to], Mods::default()); + // Twice the plane-space move, not twice the page move (which foreshortening would distort). + s.execute("object.transformAgain", &json!({})).unwrap(); + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { + assert!((b - *a - Vec2::new(50.0, 20.0)).hypot() < 1e-6, "{a:?} → {b:?}"); + } + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Transform in Perspective"); + // An ordinary move takes over Transform Again. + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + let p0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + s.execute("object.transformAgain", &json!({})).unwrap(); + let p1 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert!((p1.x0 - p0.x0 - 5.0).abs() < 1e-9 && (p1.y0 - p0.y0).abs() < 1e-9); + // A cancelled drag records nothing. + s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 7, 0]})).unwrap(); + let v = ViewInfo::default(); + let c = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center(); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 20.0, c.y), v).unwrap(); + assert!(s.in_interaction()); + s.cancel_interaction().unwrap(); + assert_eq!(s.doc().unwrap().last_perspective.as_ref().unwrap()["matrix"][4], json!(7.0)); +} + +#[test] +fn transform_again_repeats_a_perspective_scale_and_copies() { + let (mut s, id) = persp_session(); + let before = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0)); + let r = s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 100, 0], "copy": true})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + s.execute("object.transformAgain", &json!({})).unwrap(); + let third = *s.doc().unwrap().selection.objects.first().unwrap(); + assert!(third != copy && third != id); + let b = bounds_of(&plane_pts(&s, third, Plane::Right, 0.0)); + assert!((b.x0 - before.x0 - 200.0).abs() < 1e-6, "{before:?} {b:?}"); + assert_eq!(attached(&s, third), Some((Plane::Right, 0.0))); + assert!(s.execute("perspective.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).is_err()); + assert!(s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": f64::MAX})).is_err()); +} + +#[test] +fn arrow_keys_nudge_in_perspective() { + let (mut s, id) = persp_session(); + let v = ViewInfo::default(); + s.select_tool("perspectiveSelection", v).unwrap(); + assert!(s.tool_claims_key(ToolKey::Right, v), "the tool takes the arrows for a selection in perspective"); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let n = undo_len(&s); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let after = plane_pts(&s, id, Plane::Right, 0.0); + let d = after[0] - before[0]; + for (a, b) in before.iter().zip(&after) { + assert!((*b - *a - d).hypot() < 1e-6, "moves within the plane"); + } + // The page step at the centre is the keyboard increment. + let h = grid(&s).homography(Plane::Right).unwrap(); + let c = bounds_of(&before).center(); + let step = h.apply(c + d).unwrap() - h.apply(c).unwrap(); + assert!((step - Vec2::new(s.prefs.keyboard_increment, 0.0)).hypot() < 1e-6, "{step:?}"); + // Alt copies; Shift takes ten steps. + s.tool_key(ToolKey::Left, Mods { alt: true, shift: true, ..Default::default() }, v).unwrap(); + let copy = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_ne!(copy, id); + assert_eq!(attached(&s, copy).map(|a| a.0), Some(Plane::Right)); + // Nothing attached: the arrows nudge as usual. + s.execute("perspective.release", &json!({"ids": [id.0, copy.0]})).unwrap(); + assert!(!s.tool_claims_key(ToolKey::Right, v)); + assert!(s.execute("perspective.nudge", &json!({"dx": 1, "dy": 0})).is_err()); + assert!(s.execute("perspective.nudge", &json!({"dx": 0, "dy": 0})).is_err()); +} + +#[test] +fn selecting_an_attached_object_switches_to_its_plane() { + let (mut s, id) = persp_session(); + s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let c = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center(); + drag(&mut s, &[c, c], Mods::default()); + assert_eq!(grid(&s).plane, Plane::Right); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + // A click is no edit. + assert_eq!(persp::attachment(s.doc().unwrap().doc.node(id).unwrap()), Some((Plane::Right, 0.0))); +} + +#[test] +fn attachments_round_trip_through_native_files() { + let (mut s, id) = persp_session(); + s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": 12.5})).unwrap(); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(persp::attachment(doc.node(id).unwrap()), Some((Plane::Right, 12.5))); + assert_eq!(*doc.node(id).unwrap(), *s.doc().unwrap().doc.node(id).unwrap()); + // Objects outside perspective write nothing for it. + let v = serde_json::to_value(vectorcraft_doc::Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![], clip: false })).unwrap(); + assert!(v.get("perspective").is_none()); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 811de578..2cf4a5dc 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -224,6 +224,12 @@ fn structured_junk() { ("stroke.set", json!({"dash": [1e-12]})), ("transparency.set", json!({"opacity": -50})), ("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})), + ("perspective.move", json!({"from": [0, 0], "to": [1e308, -1e308], "perpendicular": true, "copy": true, "plane": "ground"})), + ("perspective.move", json!({"from": [f64::MAX, 0], "to": [0, 0], "plane": "left"})), + ("perspective.transform", json!({"matrix": [1e308, 0, 0, 1e308, 0, 0], "plane": "right"})), + ("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.nudge", json!({"dx": 1e308, "dy": -1e308, "big": true, "copy": true})), ("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.zigZag", "params": {"size": 1e308, "ridges": 100}})), diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 79ed3e2f..7273c34a 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -10,6 +10,7 @@ pub mod bez; pub mod hit; pub mod path; +pub mod projective; pub mod recognize; pub mod shapes; pub mod snap; @@ -17,6 +18,7 @@ pub mod snap; pub use kurbo; pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2}; pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath}; +pub use projective::Homography; /// Tolerance used when comparing handle positions to anchor positions. pub const EPS: f64 = 1e-9; diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs new file mode 100644 index 00000000..bd386b2e --- /dev/null +++ b/crates/geom/src/projective.rs @@ -0,0 +1,162 @@ +//! Projective (perspective) transforms of the plane: 3×3 homographies. +//! +//! The Perspective Grid maps each grid plane to the page with one, objects in perspective move by +//! conjugating plane-space affine maps with it, and type and symbol instances in perspective keep +//! one that projects their flat art. + +use kurbo::{Affine, Point, Rect}; + +/// A 3×3 projective transform (row-major), acting on `(x, y, 1)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Homography(pub [[f64; 3]; 3]); + +impl Homography { + pub const IDENTITY: Self = Self([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + + /// From the images of (1,0,0), (0,1,0) and (0,0,1). + pub fn from_cols(c1: [f64; 3], c2: [f64; 3], c3: [f64; 3]) -> Self { + Self([[c1[0], c2[0], c3[0]], [c1[1], c2[1], c3[1]], [c1[2], c2[2], c3[2]]]) + } + + /// The affine map `a` as a homography. + pub fn from_affine(a: Affine) -> Self { + let [m0, m1, m2, m3, m4, m5] = a.as_coeffs(); + Self([[m0, m2, m4], [m1, m3, m5], [0.0, 0.0, 1.0]]) + } + + /// From nine row-major numbers (as stored in documents); `None` unless all are finite and the + /// map is invertible. + pub fn from_array(v: [f64; 9]) -> Option { + if v.iter().any(|x| !x.is_finite()) { + return None; + } + let h = Self([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]]); + (h.det().abs() > 1e-15).then_some(h) + } + + /// The nine row-major numbers. + pub fn to_array(&self) -> [f64; 9] { + let m = &self.0; + [m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]] + } + + /// Map a point; `None` when it lands on or beyond the horizon (w ≤ 0). + pub fn apply(&self, p: Point) -> Option { + let m = &self.0; + let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2]; + let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2]; + let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2]; + if w <= 1e-9 || !x.is_finite() || !y.is_finite() { + return None; + } + Some(Point::new(x / w, y / w)) + } + + pub fn det(&self) -> f64 { + let m = &self.0; + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) + } + + /// Inverse. + pub fn inverse(&self) -> Option { + let m = &self.0; + let d = self.det(); + if d.abs() < 1e-15 || !d.is_finite() { + return None; + } + let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]; + let adj = [ + [c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)], + [-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)], + [c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)], + ]; + let mut out = [[0.0; 3]; 3]; + for (r, row) in adj.iter().enumerate() { + for (k, v) in row.iter().enumerate() { + out[r][k] = v / d; + } + } + // H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0. + Some(Self(out)) + } + + /// `self ∘ other`: `other` first. + pub fn then_after(&self, other: &Self) -> Self { + let (a, b) = (&self.0, &other.0); + let mut out = [[0.0; 3]; 3]; + for (r, row) in out.iter_mut().enumerate() { + for (k, v) in row.iter_mut().enumerate() { + *v = a[r][0] * b[0][k] + a[r][1] * b[1][k] + a[r][2] * b[2][k]; + } + } + Self(out) + } + + /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it + /// maps from and to is moved by `a`). `None` when `a` isn't invertible. + pub fn conjugated(&self, a: Affine) -> Option { + if a.determinant().abs() < 1e-15 { + return None; + } + Some(Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse()))) + } + + /// The bounding box of the image of `r` (lines stay lines: its corners bound it), `None` when + /// part of it crosses the horizon. + pub fn map_rect_bbox(&self, r: Rect) -> Option { + let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + let mut out: Option = None; + for p in pts { + let q = self.apply(p)?; + out = Some(out.map_or(Rect::from_points(q, q), |b| b.union_pt(q))); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h() -> Homography { + Homography([[1.2, 0.1, 5.0], [-0.2, 0.9, 3.0], [0.001, 0.002, 1.0]]) + } + + #[test] + fn compose_invert_and_round_trip() { + let a = h(); + let inv = a.inverse().unwrap(); + let id = a.then_after(&inv); + for (r, row) in id.0.iter().enumerate() { + for (k, v) in row.iter().enumerate() { + assert!((v - if r == k { 1.0 } else { 0.0 }).abs() < 1e-9); + } + } + let p = Point::new(30.0, -12.0); + assert!(inv.apply(a.apply(p).unwrap()).unwrap().distance(p) < 1e-9); + assert_eq!(Homography::from_array(a.to_array()), Some(a)); + assert!(Homography::from_array([f64::NAN; 9]).is_none()); + assert!(Homography::from_array([0.0; 9]).is_none()); + } + + #[test] + fn affine_and_conjugation() { + let t = Affine::translate((10.0, -4.0)) * Affine::scale(2.0); + let p = Point::new(3.0, 7.0); + assert!(Homography::from_affine(t).apply(p).unwrap().distance(t * p) < 1e-12); + // Conjugating: moving the input and the output by `t` keeps the picture moved by `t`. + let c = h().conjugated(t).unwrap(); + assert!(c.apply(t * p).unwrap().distance(t * h().apply(p).unwrap()) < 1e-9); + assert!(h().conjugated(Affine::scale(0.0)).is_none()); + } + + #[test] + fn rect_bbox_is_the_corners_box() { + let b = h().map_rect_bbox(Rect::new(0.0, 0.0, 10.0, 20.0)).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(10.0, 20.0), Point::new(10.0, 0.0), Point::new(5.0, 10.0)] { + let q = h().apply(p).unwrap(); + assert!(b.inflate(1e-9, 1e-9).contains(q)); + } + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 9644d688..a881f1e3 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -73,6 +73,7 @@ fn tool_key(name: &str) -> Option { "[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft, "]" | "bracketright" | "closebracket" => ToolKey::BracketRight, "tab" => ToolKey::Tab, + k if k.len() == 1 => ToolKey::Digit(k.parse().ok()?), _ => return None, }) } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index b04d76b3..711c6b09 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -44,6 +44,8 @@ mod tests_links; #[cfg(test)] mod tests_liquify; #[cfg(test)] +mod tests_persp; +#[cfg(test)] mod tests_place; #[cfg(test)] mod tests_svg; diff --git a/crates/mcp/src/tests_persp.rs b/crates/mcp/src/tests_persp.rs new file mode 100644 index 00000000..2afc60f3 --- /dev/null +++ b/crates/mcp/src/tests_persp.rs @@ -0,0 +1,39 @@ +//! The Perspective Selection tool over MCP: a drag with 5 pressed half-way moves perpendicular to +//! the plane, the arrows nudge in perspective. + +use serde_json::{Value, json}; +use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; + +use crate::{Headless, call_tool}; + +fn text(r: &crate::ToolResult) -> Value { + assert!(!r.is_error, "{r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap() +} + +fn run(h: &mut Headless, command: &str, params: Value) -> Value { + text(&call_tool(h, "run_command", &json!({"command": command, "params": params}))) +} + +#[test] +fn perspective_selection_takes_5_and_the_arrows_over_mcp() { + let mut h = Headless::with_document(); + run(&mut h, "perspective.grid.preset", json!({"kind": 2})); + let id = run(&mut h, "shape.rectangle", json!({"x": 330, "y": 500, "width": 60, "height": 60}))["id"].as_u64().unwrap(); + run(&mut h, "perspective.attach", json!({"ids": [id], "plane": "right"})); + let depth = |h: &Headless| persp::attachment(h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap()).unwrap(); + let doc = &h.session.doc().unwrap().doc; + let g = PerspectiveGrid::from_doc(doc).unwrap(); + let (_, _, rect) = g.plane_bounds(doc, &[vectorcraft_doc::NodeId(id)]).unwrap(); + let from = g.homography_at(Plane::Right, 0.0).unwrap().apply(rect.center()).unwrap(); + let to = g.homography_at(Plane::Right, 30.0).unwrap().apply(rect.center()).unwrap(); + let ev = |kind: &str, p: vectorcraft_geom::Point| json!({"kind": kind, "x": p.x, "y": p.y}); + text(&call_tool(&mut h, "pointer_gesture", &json!({"tool": "perspectiveSelection", "events": [ev("down", from), ev("drag", to)]}))); + assert_eq!(text(&call_tool(&mut h, "press_key", &json!({"key": "5"})))["handledBy"], "tool"); + text(&call_tool(&mut h, "pointer_gesture", &json!({"events": [ev("up", to)]}))); + let (plane, d) = depth(&h); + assert_eq!(plane, Plane::Right); + assert!((d - 30.0).abs() < 1e-6, "{d}"); + assert_eq!(text(&call_tool(&mut h, "press_key", &json!({"key": "Right"})))["handledBy"], "tool"); + assert_eq!(h.session.journal.last().unwrap().0, "perspective.nudge"); +} diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index a1ae9481..28789054 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -19,10 +19,11 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeId}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PerspectiveAttachment}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::bbox::{Handle as BoxHandle, scale_for_drag}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; pub mod define; pub use define::{GridDefinition, Rgb, Station}; @@ -74,54 +75,7 @@ impl Plane { /// Plane Switching widget: (plane, face quad) list and the "no plane" circle (centre, radius). pub type WidgetGeom = (Vec<(Plane, [Point; 4])>, (Point, f64)); -/// A 3×3 projective transform (row-major). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Homography(pub [[f64; 3]; 3]); - -impl Homography { - /// From the images of (1,0,0), (0,1,0) and (0,0,1). - pub fn from_cols(c1: [f64; 3], c2: [f64; 3], c3: [f64; 3]) -> Self { - Self([[c1[0], c2[0], c3[0]], [c1[1], c2[1], c3[1]], [c1[2], c2[2], c3[2]]]) - } - /// Map a point; None when it lands on or beyond the horizon (w ≤ 0). - pub fn apply(&self, p: Point) -> Option { - let m = &self.0; - let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2]; - let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2]; - let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2]; - if w <= 1e-9 || !x.is_finite() || !y.is_finite() { - return None; - } - Some(Point::new(x / w, y / w)) - } - pub fn det(&self) -> f64 { - let m = &self.0; - m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) - + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) - } - /// Inverse. - pub fn inverse(&self) -> Option { - let m = &self.0; - let d = self.det(); - if d.abs() < 1e-15 { - return None; - } - let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]; - let adj = [ - [c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)], - [-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)], - [c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)], - ]; - let mut out = [[0.0; 3]; 3]; - for (r, row) in adj.iter().enumerate() { - for (k, v) in row.iter().enumerate() { - out[r][k] = v / d; - } - } - // H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0. - Some(Self(out)) - } -} +pub use vectorcraft_geom::Homography; fn two() -> u8 { 2 @@ -223,25 +177,70 @@ impl PerspectiveGrid { } } - /// The document's grid, if one was defined. + /// The document's grid, if one was defined, with [`Self::attached`] collected from the objects. pub fn from_doc(doc: &Document) -> Option { + let mut g = Self::stored(doc)?; + g.collect_attached(doc); + Some(g) + } + + /// The document's grid, or the default two-point preset for the first artboard (hidden), with + /// [`Self::attached`] collected from the objects. + pub fn effective(doc: &Document) -> Self { + let mut g = Self::current(doc); + g.collect_attached(doc); + g + } + + /// [`Self::effective`] without walking the document for attached objects (for drawing and + /// tools: ask single objects with [`Self::attachment_of`]). + pub fn current(doc: &Document) -> Self { + Self::stored(doc).unwrap_or_else(|| Self { visible: false, ..Self::normal(2, define::first_artboard(doc)) }) + } + + /// The grid as stored. Its `attached` holds what files from before attachments were kept on the + /// objects recorded. + fn stored(doc: &Document) -> Option { doc.unknown.get(DOC_KEY).and_then(|v| serde_json::from_value(v.clone()).ok()) } - /// The document's grid, or the default two-point preset for the first artboard (hidden). - pub fn effective(doc: &Document) -> Self { - Self::from_doc(doc).unwrap_or_else(|| Self { visible: false, ..Self::normal(2, define::first_artboard(doc)) }) + fn collect_attached(&mut self, doc: &Document) { + doc.walk(|n| { + if let Some((pl, _)) = attachment(n) { + self.attached.insert(n.id.0.to_string(), pl); + } + }); } - /// Store into the document. + /// Store into the document. Attachments live on the objects: the stored grid keeps none. pub fn store(&self, doc: &mut Document) { + self.adopt_stored_attachments(doc); let mut g = self.clone(); - g.attached.retain(|k, _| k.parse::().ok().is_some_and(|id| doc.node(NodeId(id)).is_some())); + g.attached.clear(); if let Ok(v) = serde_json::to_value(&g) { doc.unknown.insert(DOC_KEY.into(), v); } } + /// Move the attachments a grid stored before they were kept on the objects onto the objects, + /// unless this grid no longer has them (released since); ids of deleted objects are dropped. + pub fn adopt_stored_attachments(&self, doc: &mut Document) { + let Some(old) = Self::stored(doc) else { return }; + for (k, pl) in &old.attached { + if self.attached.get(k) != Some(pl) { + continue; + } + if let Some(n) = k.parse::().ok().and_then(|id| doc.node_mut(NodeId(id))) + && n.perspective.is_none() + { + n.perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0))); + } + } + if let Some(v) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_object_mut) { + v.remove("attached"); + } + } + /// Definition only (for tool previews and the Define Grid dialog). pub fn definition_json(&self) -> Value { let mut v = serde_json::to_value(self).unwrap_or(Value::Null); @@ -301,27 +300,108 @@ impl PerspectiveGrid { Plane::ALL } + /// The image of `axis`'s point at infinity: a vanishing point scaled by `1/distance` for a + /// receding axis, a direction for an axis parallel to the picture plane. + fn axis_col(&self, axis: Axis) -> [f64; 3] { + // Each receding axis foreshortens by the viewer's distance to its vanishing point. + let (ll, lr, lu) = self.foreshortening(); + match axis { + Axis::X if self.kind == 1 => [1.0, 0.0, 0.0], + Axis::X => [self.vp_right * lr, self.horizon * lr, lr], + Axis::Y if self.kind == 3 => [self.vp_vertical[0] * lu, self.vp_vertical[1] * lu, lu], + Axis::Y => [0.0, -1.0, 0.0], + Axis::Z => [self.vp_left * ll, self.horizon * ll, ll], + } + } + + /// A plane's axes: `[u, v, normal]`. + pub fn axes(&self, plane: Plane) -> Option<[Axis; 3]> { + Some(match (self.kind, plane) { + (_, Plane::None) => return None, + (1, Plane::Left) => [Axis::X, Axis::Y, Axis::Z], + (1, Plane::Right) => [Axis::Z, Axis::Y, Axis::X], + (_, Plane::Left) => [Axis::Z, Axis::Y, Axis::X], + (_, Plane::Right) => [Axis::X, Axis::Y, Axis::Z], + (_, Plane::Ground) => [Axis::X, Axis::Z, Axis::Y], + }) + } + /// The homography of `plane` (plane coordinates in points → page). pub fn homography(&self, plane: Plane) -> Option { - let (ll, lr, lu) = self.foreshortening(); - let o = [self.origin[0], self.origin[1], 1.0]; - let vl = [self.vp_left * ll, self.horizon * ll, ll]; - let vr = [self.vp_right * lr, self.horizon * lr, lr]; - let up = if self.kind == 3 { [self.vp_vertical[0] * lu, self.vp_vertical[1] * lu, lu] } else { [0.0, -1.0, 0.0] }; - let flat = [1.0, 0.0, 0.0]; - let (c1, c2) = match (self.kind, plane) { - (_, Plane::None) => return None, - (1, Plane::Left) => (flat, [0.0, -1.0, 0.0]), - (1, Plane::Right) => (vl, [0.0, -1.0, 0.0]), - (1, Plane::Ground) => (flat, vl), - (_, Plane::Left) => (vl, up), - (_, Plane::Right) => (vr, up), - (_, Plane::Ground) => (vr, vl), - }; - let h = Homography::from_cols(c1, c2, o); + self.homography_at(plane, 0.0) + } + + /// The homography of the plane parallel to `plane` that lies `depth` points along its normal + /// (`None` behind the viewer). + pub fn homography_at(&self, plane: Plane, depth: f64) -> Option { + let [u, v, n] = self.axes(plane)?; + self.frame_homography(u, v, (n, depth)) + } + + /// The plane spanned by axes `u` and `v` through the point `at.1` along axis `at.0`. + fn frame_homography(&self, u: Axis, v: Axis, at: (Axis, f64)) -> Option { + if !at.1.is_finite() { + return None; + } + let c = self.axis_col(at.0); + let o = [self.origin[0] + at.1 * c[0], self.origin[1] + at.1 * c[1], 1.0 + at.1 * c[2]]; + if o[2] <= 1e-9 { + return None; + } + let h = Homography::from_cols(self.axis_col(u), self.axis_col(v), o); (h.det().abs() > 1e-15).then_some(h) } + /// The plane-space offset between page points `from` and `to` on `plane` at `depth`. + pub fn plane_delta(&self, plane: Plane, depth: f64, from: Point, to: Point) -> Option { + let hi = self.homography_at(plane, depth)?.inverse()?; + Some(hi.apply(to)? - hi.apply(from)?) + } + + /// The depth an object on `plane` at `depth` dragged from `from` to `to` perpendicular to its + /// plane reaches: the pointer is read on the plane through the drag start that holds the + /// normal and the plane's v axis. + pub fn depth_at(&self, plane: Plane, depth: f64, from: Point, to: Point) -> Option { + let [u, v, n] = self.axes(plane)?; + let q = self.homography_at(plane, depth)?.inverse()?.apply(from)?; + let ci = self.frame_homography(n, v, (u, q.x))?.inverse()?; + let d = depth + (ci.apply(to)?.x - ci.apply(from)?.x); + (d.is_finite() && d.abs() <= MAX_DEPTH).then_some(d) + } + + /// The page map of plane-space affine map `m` for art on `plane` at `depth`, then moved `dz` + /// along the plane's normal: `H(depth + dz) · m · H(depth)⁻¹`. + pub fn transform_map(&self, plane: Plane, depth: f64, m: Affine, dz: f64) -> Option { + let h = self.homography_at(plane, depth)?; + let to = self.homography_at(plane, depth + dz)?; + Some(to.then_after(&Homography::from_affine(m)).then_after(&h.inverse()?)) + } + + /// Where object `id` is attached (plane, depth): its own record, else the one a grid stored + /// before attachments were kept on the objects. + pub fn attachment_of(&self, doc: &Document, id: NodeId) -> Option<(Plane, f64)> { + doc.node(id).and_then(attachment).or_else(|| self.attached_plane(id).map(|p| (p, 0.0))) + } + + /// The plane-space bounds of the attached objects among `ids` lying on the plane of the first + /// one: (that plane, the first one's depth, bounds). + pub fn plane_bounds(&self, doc: &Document, ids: &[NodeId]) -> Option<(Plane, f64, Rect)> { + let mut out: Option<(Plane, f64, Rect)> = None; + for id in ids { + let Some((pl, depth)) = self.attachment_of(doc, *id) else { continue }; + if out.is_some_and(|o| o.0 != pl) { + continue; + } + let hi = self.homography_at(pl, depth).and_then(|h| h.inverse()); + let Some(b) = hi.zip(doc.node(*id)).and_then(|(hi, n)| mapped_bounds(n, &hi)) else { continue }; + out = Some(match out { + Some((p, d, r)) => (p, d, r.union(b)), + None => (pl, depth, b), + }); + } + out + } + /// Plane-coordinate extent of a plane. pub fn domain(&self, plane: Plane) -> Rect { match plane { @@ -363,6 +443,7 @@ impl PerspectiveGrid { Some(move |p: Point| h.apply(hi.apply(p)? + d)) } + /// The plane object `id` is attached to, as collected by [`Self::from_doc`]. pub fn attached_plane(&self, id: NodeId) -> Option { self.attached.get(&id.0.to_string()).copied() } @@ -421,6 +502,57 @@ impl PerspectiveGrid { } } +/// How far (points) along a plane's normal objects and planes may go. +pub const MAX_DEPTH: f64 = 1.0e6; + +/// An axis of the grid's 3-D frame: Y is up, X and Z recede to the right and left vanishing points +/// (in 1-point perspective X is the flat horizontal and Z recedes to the vanishing point). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Axis { + X, + Y, + Z, +} + +/// The plane and depth object `n` is attached to (its own record). +pub fn attachment(n: &Node) -> Option<(Plane, f64)> { + let a = n.perspective.as_deref()?; + Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth)) +} + +/// Attach `n` to `plane` at `depth`. +pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { + n.perspective = Some(Box::new(PerspectiveAttachment::new(plane.id(), depth))); +} + +/// The bounds of `n` mapped by `hi`: paths by their mapped curves, other objects by their boxes' +/// corners. `None` when part of it maps beyond the horizon. +fn mapped_bounds(n: &Node, hi: &Homography) -> Option { + let mut acc: Option = None; + let mut ok = true; + n.walk(&mut |c| match &c.kind { + NodeKind::Path { path, .. } => { + let mut path = path.clone(); + for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) { + for q in [&mut a.p, &mut a.h_in, &mut a.h_out] { + match hi.apply(*q) { + Some(v) => *q = v, + None => ok = false, + } + } + } + acc = vectorcraft_geom::union_opt(acc, path.bounds()); + } + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } | NodeKind::Blend { .. } => {} + _ => match c.geometric_bounds().map(|b| hi.map_rect_bbox(b)) { + Some(Some(b)) => acc = vectorcraft_geom::union_opt(acc, Some(b)), + Some(None) => ok = false, + None => {} + }, + }); + if ok { acc } else { None } +} + fn in_quad(q: &[Point; 4], p: Point) -> bool { let mut sign = 0.0; for i in 0..4 { @@ -441,7 +573,7 @@ fn in_quad(q: &[Point; 4], p: Point) -> bool { /// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible or /// a perspective tool is active. `tol` = document units per screen pixel. pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec { - let g = PerspectiveGrid::effective(doc); + let g = PerspectiveGrid::current(doc); let tool = matches!(tool_id, "perspectiveGrid" | "perspectiveSelection"); if !g.visible && !tool { return vec![]; @@ -559,7 +691,7 @@ impl Tool for PerspectiveGridTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; - let g = PerspectiveGrid::effective(cx.doc); + let g = PerspectiveGrid::current(cx.doc); match ev.kind { PointerKind::Down => { let mut pre = vec![]; @@ -591,7 +723,7 @@ impl Tool for PerspectiveGridTool { } } fn overlays(&self, cx: &ToolContext) -> Vec { - let g = PerspectiveGrid::effective(cx.doc); + let g = PerspectiveGrid::current(cx.doc); handles(&g) .into_iter() .map(|(h, p)| match h { @@ -601,7 +733,7 @@ impl Tool for PerspectiveGridTool { .collect() } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { - let g = PerspectiveGrid::effective(cx.doc); + let g = PerspectiveGrid::current(cx.doc); let tol = cx.tol(6.0); match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) { Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV, @@ -620,9 +752,71 @@ impl Tool for PerspectiveGridTool { // ---------- Perspective Selection tool ---------- +#[derive(Clone, Debug, Default)] +enum SelDrag { + #[default] + Idle, + /// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along + /// the plane's normal (5 pressed while dragging); `copy`: Alt at the last event. + Move { ids: Vec, from: Point, last: Point, began: bool, perp: bool, copy: bool }, + /// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`). + Scale { ids: Vec, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle }, +} + +/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move +/// perpendicular to the plane) and scales (bounding-box handles; Shift proportional, Alt from the +/// centre) objects within their perspective planes; the arrow keys nudge them in perspective. #[derive(Default)] pub struct PerspectiveSelectionTool { - drag: Option<(Vec, Point)>, + drag: SelDrag, + measure: Option<(Point, String)>, +} + +/// The selection's perspective bounding box: plane, depth, plane-space rect and the homography +/// that draws it. +fn selection_persp_box(cx: &ToolContext, g: &PerspectiveGrid) -> Option<(Plane, f64, Rect, Homography)> { + let (plane, depth, rect) = g.plane_bounds(cx.doc, &cx.selection.objects)?; + Some((plane, depth, rect, g.homography_at(plane, depth)?)) +} + +/// The handle of the perspective box (`rect` drawn by `h`) within `tol` of `p`. +fn persp_handle_at(rect: Rect, h: &Homography, p: Point, tol: f64) -> Option { + BoxHandle::ALL.into_iter().find(|k| h.apply(k.pos(rect)).is_some_and(|q| q.distance(p) <= tol)) +} + +/// The resize cursor that fits a handle seen in direction `d` from the box's centre. +fn resize_cursor(d: Vec2) -> Cursor { + // Counter-clockwise degrees on screen (y down), folded to 0..180. + let a = (-d.y).atan2(d.x).to_degrees().rem_euclid(180.0); + match a { + a if !(22.5..157.5).contains(&a) => Cursor::ResizeH, + a if a < 67.5 => Cursor::ResizeNeSw, + a if a < 112.5 => Cursor::ResizeV, + _ => Cursor::ResizeNwSe, + } +} + +impl PerspectiveSelectionTool { + /// The `perspective.move` preview for the move drag (and its measurement label). + fn move_preview(&mut self, cx: &ToolContext) -> Option { + let SelDrag::Move { ids, from, last, perp, copy, .. } = &self.drag else { return None }; + let g = PerspectiveGrid::current(cx.doc); + let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp}); + if g.plane != Plane::None { + 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))); + self.measure = at.and_then(|(pl, depth)| { + let text = if *perp { + format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth)) + } else { + let d = g.plane_delta(pl, depth, *from, *last)?; + cx.offset_label(d.x, d.y) + }; + Some((*last, text)) + }); + Some(Action::Preview("perspective.move".into(), v)) + } } impl Tool for PerspectiveSelectionTool { @@ -630,19 +824,26 @@ impl Tool for PerspectiveSelectionTool { "perspectiveSelection" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let p = ev.pos; - let g = PerspectiveGrid::effective(cx.doc); - match ev.kind { - PointerKind::Down => { + let (p, m) = (ev.pos, ev.mods); + let g = PerspectiveGrid::current(cx.doc); + match (ev.kind, std::mem::take(&mut self.drag)) { + (PointerKind::Down, _) => { + self.measure = None; if g.visible && let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p) { return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))]; } - let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else { + 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)) + { + self.drag = SelDrag::Scale { ids: cx.selection.objects.clone(), plane, depth, rect, handle }; + return vec![Action::Begin("Scale in Perspective".into())]; + } + let Some(hit) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else { return vec![Action::Exec("select.set".into(), json!({"ids": []}))]; }; - let top = h.top_object(cx.isolation); + let top = hit.top_object(cx.isolation); let mut acts = vec![]; let ids = if cx.selection.objects.contains(&top) { cx.selection.objects.clone() @@ -650,53 +851,122 @@ impl Tool for PerspectiveSelectionTool { acts.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); vec![top] }; - acts.push(Action::Begin("Move in Perspective".into())); - self.drag = Some((ids, p)); + // Selecting an object in perspective makes its plane the active one. + if let Some((pl, _)) = g.attachment_of(cx.doc, top) + && pl != g.plane + { + acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); + } + self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt }; acts } - PointerKind::Drag => match &self.drag { - Some((ids, from)) => { - let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [p.x, p.y]}); - if g.plane != Plane::None { - v["plane"] = json!(g.plane.id()); - } - vec![Action::Preview("perspective.move".into(), v)] + (PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => { + if !began && p.distance(from) < cx.tol(3.0) { + self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt }; + return vec![]; } - None => vec![], - }, - PointerKind::Up => match self.drag.take() { - Some(_) => vec![Action::Commit], - None => vec![], - }, - _ => vec![], + let mut out = vec![]; + if !began { + out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into())); + } + self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt }; + out.extend(self.move_preview(cx)); + out + } + (PointerKind::Drag, SelDrag::Scale { ids, plane, depth, rect, handle }) => { + let q = g.homography_at(plane, depth).and_then(|h| h.inverse()).and_then(|hi| hi.apply(p)); + self.drag = SelDrag::Scale { ids: ids.clone(), plane, depth, rect, handle }; + let Some(q) = q else { return vec![] }; + let a = scale_for_drag(rect, handle, q, m.shift, m.alt); + let nr = a.transform_rect_bbox(rect); + self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); + vec![Action::Preview("perspective.transform".into(), json!({"ids": crate::json_ids(&ids), "matrix": crate::select::matrix_json(a)}))] + } + (PointerKind::Up, SelDrag::Move { began, .. }) => { + self.measure = None; + if began { vec![Action::Commit] } else { vec![] } + } + (PointerKind::Up, SelDrag::Scale { .. }) => { + self.measure = None; + vec![Action::Commit] + } + (_, d) => { + self.drag = d; + vec![] + } } } - fn overlays(&self, cx: &ToolContext) -> Vec { - // Attached objects in the selection get their plane's colour outline. - let g = PerspectiveGrid::effective(cx.doc); - let mut out = vec![]; - for id in &cx.selection.objects { - if let (Some(pl), Some(n)) = (g.attached_plane(*id), cx.doc.node(*id)) - && let Some(b) = n.geometric_bounds() - { - out.push(Overlay::Path { - path: crate::xform::polygon(&crate::xform::rect_corners(b), true), - color: pl.color(), - width: 1.0, - dashed: true, - }); + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) { + *perp = !*perp; + return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; + } + let (dx, dy) = match key { + ToolKey::Left => (-1, 0), + ToolKey::Right => (1, 0), + ToolKey::Up => (0, -1), + ToolKey::Down => (0, 1), + _ => return vec![], + }; + if !self.claims_key(cx, key) { + return vec![]; + } + vec![Action::Exec("perspective.nudge".into(), json!({"dx": dx, "dy": dy, "big": mods.shift, "copy": mods.alt}))] + } + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + // The arrows nudge a selection in perspective. + matches!(key, ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down) + && matches!(self.drag, SelDrag::Idle) + && cx.selection.anchors.is_empty() + && { + let g = PerspectiveGrid::current(cx.doc); + cx.selection.objects.iter().any(|id| g.attachment_of(cx.doc, *id).is_some()) } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + // The selection's bounding box in perspective, in its plane's colour, with its handles. + let g = PerspectiveGrid::current(cx.doc); + let mut out = vec![]; + if let Some((plane, _, rect, h)) = selection_persp_box(cx, &g) { + let corners: Vec = crate::xform::rect_corners(rect).iter().filter_map(|q| h.apply(*q)).collect(); + if corners.len() == 4 { + out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color: plane.color(), width: 1.0, dashed: false }); + } + for k in BoxHandle::ALL { + if let Some(q) = h.apply(k.pos(rect)) { + out.push(Overlay::Anchor { p: q, color: plane.color(), filled: false, size: 6.0 }); + } + } + } + if let Some((p, t)) = &self.measure { + out.push(Overlay::Measure { p: *p, text: t.clone() }); } out } - 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 handle = match &self.drag { + SelDrag::Scale { handle, .. } => Some(*handle), + SelDrag::Move { began: true, .. } => return Cursor::Move, + _ => None, + }; + 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(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center())) + { + return resize_cursor(a - c); + } Cursor::Arrow } fn busy(&self) -> bool { - self.drag.is_some() + !matches!(self.drag, SelDrag::Idle) } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } + self.measure = None; + match std::mem::take(&mut self.drag) { + SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } => vec![Action::Commit], + _ => vec![], + } } } @@ -820,4 +1090,101 @@ mod tests { assert_eq!(v["horizon"], json!(g.horizon + 10.0)); assert!(!grid_overlays(&d, 1.0, "selection").is_empty()); } + + #[test] + fn depth_moves_along_the_plane_normal() { + // A grid with a viewing angle foreshortens each axis by its own distance. + let angled = PerspectiveGrid { angle: Some(30.0), ..grid(3) }; + assert_ne!(angled.foreshortening().0, angled.foreshortening().1); + for g in [grid(1), grid(2), grid(3), angled] { + let kind = g.kind; + for pl in Plane::ALL { + let q = Point::new(40.0, 30.0); + let from = g.homography_at(pl, 0.0).unwrap().apply(q).unwrap(); + let to = g.homography_at(pl, 25.0).unwrap().apply(q).unwrap(); + let d = g.depth_at(pl, 0.0, from, to).unwrap(); + assert!((d - 25.0).abs() < 1e-6, "{kind} {pl:?} {d}"); + // The map to the parallel plane keeps plane coordinates. + let m = g.transform_map(pl, 0.0, Affine::IDENTITY, 25.0).unwrap(); + assert!((m.apply(from).unwrap() - to).hypot() < 1e-6); + // In-plane: a plane-space translation. + let m = g.transform_map(pl, 25.0, Affine::translate((10.0, -5.0)), 0.0).unwrap(); + let moved = g.homography_at(pl, 25.0).unwrap().inverse().unwrap().apply(m.apply(to).unwrap()).unwrap(); + assert!((moved - Point::new(50.0, 25.0)).hypot() < 1e-6, "{kind} {pl:?} {moved:?}"); + } + } + // Behind the viewer there is no plane. + assert!(grid(2).homography_at(Plane::Left, -1.0e9).is_none()); + } + + #[test] + fn attachments_live_on_objects_and_legacy_ones_are_adopted() { + let (mut d, id) = doc_with_rect(); + let mut g = grid(2); + g.attached.insert(id.0.to_string(), Plane::Right); + g.attached.insert("999999".into(), Plane::Left); + // A grid as files from before stored it: the attachments in the grid. + d.unknown.insert(DOC_KEY.into(), serde_json::to_value(&g).unwrap()); + let read = PerspectiveGrid::from_doc(&d).unwrap(); + assert_eq!(read.attachment_of(&d, id), Some((Plane::Right, 0.0))); + read.store(&mut d); + assert!(d.unknown[DOC_KEY].get("attached").is_none(), "the stored grid keeps none"); + assert_eq!(attachment(d.node(id).unwrap()), Some((Plane::Right, 0.0))); + assert_eq!(PerspectiveGrid::from_doc(&d).unwrap().attached_plane(id), Some(Plane::Right)); + // Released since: not adopted. + let (mut d2, id2) = doc_with_rect(); + let mut old = grid(2); + old.attached.insert(id2.0.to_string(), Plane::Left); + d2.unknown.insert(DOC_KEY.into(), serde_json::to_value(&old).unwrap()); + grid(2).store(&mut d2); + assert!(d2.node(id2).unwrap().perspective.is_none()); + } + + #[test] + fn selection_tool_scales_with_handles_and_5_moves_perpendicular() { + let (mut d, id) = doc_with_rect(); + grid(2).store(&mut d); + set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let g = PerspectiveGrid::current(&d); + let (plane, depth, rect) = g.plane_bounds(&d, &[id]).unwrap(); + assert_eq!((plane, depth), (Plane::Right, 0.0)); + let h = g.homography(Plane::Right).unwrap(); + let mut t = PerspectiveSelectionTool::default(); + assert!(t.claims_key(&c, ToolKey::Left)); + // A corner handle scales. + let corner = h.apply(Point::new(rect.x1, rect.y1)).unwrap(); + assert!(matches!(t.cursor(&c, corner, Mods::default()), Cursor::ResizeNwSe | Cursor::ResizeNeSw)); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale in Perspective".into())]); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, corner.x + 10.0, corner.y + 10.0)); + assert!(matches!(&acts[0], Action::Preview(cmd, _) if cmd == "perspective.transform")); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, corner.x + 10.0, corner.y + 10.0)), vec![Action::Commit]); + // Moving: 5 toggles the perpendicular move. + let mid = h.apply(rect.center()).unwrap(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, mid.x, mid.y)); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, mid.x + 20.0, mid.y)); + assert_eq!(acts[0], Action::Begin("Move in Perspective".into())); + let Action::Preview(_, v) = &acts[1] else { panic!("{acts:?}") }; + assert_eq!(v["perpendicular"], json!(false)); + assert!(!t.claims_key(&c, ToolKey::Left), "busy"); + let acts = t.key(&c, ToolKey::Digit(5), Mods::default()); + let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((cmd.as_str(), &v["perpendicular"], &v["to"]), ("perspective.move", &json!(true), &json!([mid.x + 20.0, mid.y]))); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.starts_with("dZ")))); + assert!(t.key(&c, ToolKey::Digit(3), Mods::default()).is_empty()); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, mid.x + 20.0, mid.y)), vec![Action::Commit]); + assert!(!t.busy()); + } + + #[test] + fn resize_cursors_follow_the_handle_on_screen() { + assert_eq!(resize_cursor(Vec2::new(1.0, 0.1)), Cursor::ResizeH); + assert_eq!(resize_cursor(Vec2::new(0.1, -1.0)), Cursor::ResizeV); + assert_eq!(resize_cursor(Vec2::new(1.0, -1.0)), Cursor::ResizeNeSw); + assert_eq!(resize_cursor(Vec2::new(1.0, 1.0)), Cursor::ResizeNwSe); + assert_eq!(resize_cursor(Vec2::new(-1.0, -1.0)), Cursor::ResizeNwSe); + } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index b8545bb0..26c61758 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -115,6 +115,9 @@ pub enum ToolKey { Tab, Home, End, + /// A digit key 0–9 (5 while dragging with the Perspective Selection tool moves perpendicular + /// to the plane). + Digit(u8), } /// What a tool asks the engine to do. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c4e6f51e..9fffe070 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -526,6 +526,16 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { ("Shift", true), (" keeps its proportions)", false), ], + "perspectiveSelection" => &[ + ("Drag", true), + (" to move in perspective | ", false), + ("Alt+Drag", true), + (" to copy | ", false), + ("5", true), + (" while dragging to move perpendicular to the plane | ", false), + ("Drag a handle", true), + (" to scale in perspective", false), + ], _ => return None, }) } @@ -587,6 +597,7 @@ mod tests { assert!(text("zoom").contains(&pretty("Alt+Click"))); assert!(text("rotate").contains(&pretty("Alt+Click"))); assert!(text("eyedropper").contains(&pretty("Alt+Click"))); + assert!(text("perspectiveSelection").contains(&pretty("Alt+Drag")) && text("perspectiveSelection").contains("perpendicular")); assert!(text("paintbrush").contains(&pretty("Cmd+Shift+/")), "the Search Commands shortcut"); if !cfg!(target_os = "macos") { assert!(text("zoom").contains("Alt+Click") && text("paintbrush").contains("Ctrl+Shift+/")); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0927a266..ddfd0ea1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -220,6 +220,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let _ = app.session.tool_key(tk, Mods::default(), view); } } + // Digits (5 while dragging with the Perspective Selection tool), once per press. + let digits: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, repeat: false, .. } => digit_of(*key), + _ => None, + }) + .collect() + }); + for d in digits { + let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view); + } return; } // Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop: @@ -297,6 +310,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// The digit a number-row or keypad key types. +fn digit_of(k: Key) -> Option { + const DIGITS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; + DIGITS.iter().position(|d| *d == k).map(|i| i as u8) +} + #[cfg(test)] mod tests { use super::*; @@ -378,6 +397,29 @@ mod tests { assert!(zoom(&mut app) > before); } + #[test] + fn digit_keys_reach_a_dragging_tool_once_per_press() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + app.session.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.session.execute("perspective.attach", &json!({"ids": [id], "plane": "right"})).unwrap(); + app.select_tool("perspectiveSelection"); + let v = app.view_info(); + let c = + app.session.doc().unwrap().doc.node(vectorcraft_engine::doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().center(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 30.0, c.y), v).unwrap(); + let key = |repeat| egui::Event::Key { key: Key::Num5, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }; + // A held key repeats: only its first press toggles. + frame(&mut app, vec![key(false), key(true)]); + let preview = app.session.doc().unwrap().interaction.as_ref().unwrap().preview.clone().unwrap(); + assert_eq!((preview.0.as_str(), &preview.1["perpendicular"]), ("perspective.move", &json!(true))); + assert_eq!(digit_of(Key::Num0), Some(0)); + assert_eq!(digit_of(Key::A), None); + } + #[test] fn parses() { let s = parse("Cmd+Shift+]").unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 9f6ee2e8..9ac2805d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -57,7 +57,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. | -| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool. | +| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool). | | `type_text` | `{text}` | Remote only. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. | @@ -1832,3 +1832,21 @@ preferences, and a grid whose fields are a preset's is named after it (`name`). {"name":"run_command","arguments":{"command":"perspective.grid.preset","params":{"name":"[2P-Low View]"}}} {"name":"run_command","arguments":{"command":"perspective.presets.save","params":{"name":"Street","units":"feet","scale":[1,48],"gridline":1}}} ``` + +## Perspective Selection + +Objects in perspective keep their attachment themselves (`perspective` on the object: `plane` and `depth`, the +distance along the plane's normal), so copies, duplicates and pastes stay attached. With the +`perspectiveSelection` tool, a drag moves the selection within its plane (`perspective.move`; Alt copies), the +bounding-box handles scale it in plane space (`perspective.transform`; Shift keeps proportions, Alt scales about the +centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch +back). The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt +copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. + +```json +{"name":"pointer_gesture","arguments":{"tool":"perspectiveSelection","events":[{"kind":"down","x":480,"y":410},{"kind":"drag","x":470,"y":405}]}} +{"name":"press_key","arguments":{"key":"5"}} +{"name":"pointer_gesture","arguments":{"events":[{"kind":"up","x":470,"y":405}]}} +{"name":"run_command","arguments":{"command":"perspective.transform","params":{"matrix":[1.5,0,0,1.5,0,0],"copy":true}}} +{"name":"run_command","arguments":{"command":"object.transformAgain","params":{}}} +```