Merge pull request #189 from storytold/m8-perspective-selection

M8.12: Perspective Selection tool scales, copies, moves perpendicular and nudges in perspective
This commit is contained in:
BFlat 2026-10-06 11:00:53 +07:00 committed by GitHub
commit 5157dc87b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1251 additions and 141 deletions

View File

@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates)) | 8–12 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again) | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |

View File

@ -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};

View File

@ -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<Box<crate::PerspectiveAttachment>>,
}
/// 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 {

View File

@ -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 } }
}
}

View File

@ -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<CommandSpec> {
"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<CommandSpec> {
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<Value> {
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<Value> {
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<Plane>,
copy: bool,
step: PerspStep,
) -> Result<(Vec<NodeId>, Option<Attached>)> {
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::<Vec<_>>() })
}
/// 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>(), "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<Value> {

View File

@ -219,6 +219,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, 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::<Vec<_>>() }))
}
@ -299,6 +300,9 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
}
fn transform_again(s: &mut Session, _: &Value) -> Result<Value> {
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 }))

View File

@ -81,6 +81,9 @@ pub struct Interaction {
/// Per-document state restored on cancel (current layer, isolation).
pub active_layer: Option<NodeId>,
pub isolation: Option<NodeId>,
/// The perspective transform the previews make (`perspective.transform` params): Transform
/// Again repeats it once the drag is committed.
pub perspective_again: Option<Value>,
}
/// Per-document editing state.
@ -131,6 +134,9 @@ pub struct DocState {
pub recovery: Option<cmd::recovery::RecoveryCopy>,
/// 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<Value>,
}
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;

View File

@ -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<Point> {
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());
}

View File

@ -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}})),

View File

@ -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;

View File

@ -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<Self> {
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<Point> {
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<Self> {
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<Self> {
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<Rect> {
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<Rect> = 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));
}
}
}

View File

@ -73,6 +73,7 @@ fn tool_key(name: &str) -> Option<ToolKey> {
"[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft,
"]" | "bracketright" | "closebracket" => ToolKey::BracketRight,
"tab" => ToolKey::Tab,
k if k.len() == 1 => ToolKey::Digit(k.parse().ok()?),
_ => return None,
})
}

View File

@ -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;

View File

@ -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");
}

View File

@ -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<Point> {
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<Self> {
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<Self> {
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<Self> {
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::<u64>().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::<u64>().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<Homography> {
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<Homography> {
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<Homography> {
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<Vec2> {
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<f64> {
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<Homography> {
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<Plane> {
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<Rect> {
let mut acc: Option<Rect> = 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<Overlay> {
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<Action> {
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<Overlay> {
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<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`).
Scale { ids: Vec<NodeId>, 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<NodeId>, 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> {
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<Action> {
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<Action> {
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<Overlay> {
// 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<Action> {
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<Overlay> {
// 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<Point> = 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<Action> {
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);
}
}

View File

@ -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.

View File

@ -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+/"));

View File

@ -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<u8> = 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<u8> {
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();

View File

@ -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":{}}}
```