Merge pull request #195 from storytold/m8-perspective-view-options

M8.10: Perspective grid view options: Lock Grid, Lock Station Point, Snap to Grid, Show Rulers
This commit is contained in:
BFlat authored and GitHub committed 2026-10-06 11:49:33 +07:00
commit fc0cb255a4
14 files changed
+505 -39

No files matched your search

+1 -1
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), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object) | 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, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) | 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 |
+18 -10
View File
@@ -133,7 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move in Perspective",
[],
None,
"{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",
"{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), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
has_doc,
persp_move
),
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Draw in Perspective",
[],
None,
"{command: shape.* id, params, plane?} run a shape command and attach the result to the plane",
"{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)",
has_doc,
persp_draw
),
@@ -533,7 +533,7 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
}
/// Change view state of the grid without an undo step.
fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
let st = s.doc_mut()?;
let mut g = grid_of(&st.doc);
f(&mut g);
@@ -607,9 +607,9 @@ fn warp_checked(d: &mut Document, id: NodeId, f: &dyn Fn(Point) -> Option<Point>
Ok(())
}
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane) -> Result<()> {
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool) -> Result<()> {
let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?;
let m = g.attach_map(plane, b).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
let m = g.attach_map_with(plane, b, snap).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, g.offset(plane));
g.attached.insert(id.0.to_string(), plane);
@@ -622,7 +622,7 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
let plane = plane_param(&g, p, "perspective.attach")?;
s.edit("Attach to Active Plane", |d, _| {
for id in &ids {
attach_in(d, &mut g, *id, plane)?;
attach_in(d, &mut g, *id, plane, false)?;
}
store_grid(d, &g);
Ok(())
@@ -681,7 +681,7 @@ fn persp_edit(
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)?;
attach_in(d, &mut g, *id, pl, false)?;
(pl, g.offset(pl))
}
};
@@ -725,13 +725,18 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
}
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));
// Snap to Grid: a correction for the objects on the first one's plane and depth.
let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2)
};
// 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)?;
let dv = snapped(pl, depth, 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))
}
};
@@ -742,7 +747,9 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
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})),
false => {
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
}
};
if let Some(a) = again {
record_again(s, a)?;
@@ -871,11 +878,12 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, C)?;
let snap = bool_or(p, "snap", g.snap);
let undo_before = s.doc()?.history.undo.len();
let r = s.execute(&command, &params)?;
let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?;
let res = s.edit("Draw in Perspective", |d, _| {
attach_in(d, &mut g, id, plane)?;
attach_in(d, &mut g, id, plane, snap)?;
store_grid(d, &g);
Ok(())
});
+84 -3
View File
@@ -5,14 +5,19 @@
//! Presets: the built-in views (generated in code, fitted to the first artboard, protected) and
//! the user's, saved with the preferences ([`crate::Prefs::perspective_presets`]) as Define Grid
//! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them.
//!
//! View options (Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) are view state on
//! the grid: they toggle without an undo step, as Show Grid does.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_tools::distort::perspective::Plane;
use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin};
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
use super::distortcmds::{grid_of, store_grid};
use super::distortcmds::{grid_of, silent, store_grid};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -80,6 +85,42 @@ pub fn specs() -> Vec<CommandSpec> {
always,
presets_import
),
cmd!(
"perspective.grid.lock",
"Lock Grid",
[],
None,
"{on?: bool} (no param toggles) View › Perspective Grid › Lock Grid: the grid's widgets can't be dragged (the Plane Switching Widget still works). View state: not an undo step → {on}",
has_doc,
lock
),
cmd!(
"perspective.grid.lockStation",
"Lock Station Point",
[],
None,
"{on?: bool} (no param toggles) dragging one vanishing point moves the other around the station point (the viewing angle turns, the viewer stays). View state: not an undo step → {on}",
has_doc,
lock_station
),
cmd!(
"perspective.grid.snap",
"Snap to Grid",
[],
None,
"{on?: bool} (no param toggles) art drawn or moved in perspective lands on gridlines within a quarter cell (on by default). View state: not an undo step → {on}",
has_doc,
snap
),
cmd!(
"perspective.grid.rulers",
"Show Rulers",
[],
None,
"{on?: bool} (no param toggles) a ruler up the line where the planes meet, in the grid's units at its scale. View state: not an undo step → {on}",
has_doc,
rulers
),
]
}
@@ -201,7 +242,8 @@ pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
let old = grid_of(&s.doc()?.doc);
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?;
let g = PerspectiveGrid { attached: old.attached, ..g };
// The objects stay attached and the view options stay as they were.
let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g };
s.edit("Perspective Grid Preset", |d, _| {
store_grid(d, &g);
Ok(())
@@ -336,3 +378,42 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({ "imported": imported }))
}
/// Toggle (or set, with `on`) a view option of the grid.
fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
let want = p.get("on").and_then(Value::as_bool);
let mut g = silent(s, |g| {
let f = field(g);
*f = want.unwrap_or(!*f);
})?;
Ok(json!({ "on": *field(&mut g) }))
}
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.locked)
}
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.lock_station)
}
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.snap)
}
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.rulers)
}
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
/// the plane and depth of the first one and the plane-space correction that lands the edge of the
/// joint bounds of the objects there nearer a gridline on it.
pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> {
let g = grid_of(doc);
if !bool_or(p, "snap", g.snap) {
return None;
}
let (plane, depth, b) = g.plane_bounds(doc, ids)?;
let dv = g.plane_delta(plane, depth, from, to)?;
Some((plane, depth, g.snap_offset(b, dv) - dv))
}
+4
View File
@@ -296,6 +296,8 @@ fn rectangle_tool_draws_in_perspective_on_the_active_plane() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
// Snap to Grid (on by default) would move the dragged corners onto gridlines.
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let n = undo_len(&s);
gesture(&mut s, "rectangle", &[(300.0, 320.0), (330.0, 360.0), (350.0, 400.0)], Mods::default());
assert_eq!(undo_len(&s), n + 1);
@@ -319,6 +321,8 @@ fn perspective_selection_moves_within_the_plane() {
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
// Snap to Grid (on by default) would land the moved edges on gridlines.
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let g = grid(&s);
let before: Vec<Point> = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect();
let centre = before.iter().fold(vectorcraft_geom::Vec2::ZERO, |a, p| a + p.to_vec2()) / before.len() as f64;
+2
View File
@@ -13,6 +13,8 @@ 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();
// Snap to Grid (on by default) would land moved edges on gridlines: these tests measure exact moves.
s.execute("perspective.grid.snap", &json!({"on": false})).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();
+142
View File
@@ -208,3 +208,145 @@ fn presets_export_import_and_live_in_the_preferences() {
assert_eq!(back.perspective_presets, t.prefs.perspective_presets);
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().perspective_presets.is_empty());
}
// ---------- M8.10: view options ----------
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::perspective::Plane;
use vectorcraft_tools::{Overlay, PointerEvent, PointerKind};
fn drag(s: &mut Session, tool: &str, from: Point, to: Point) {
let v = ViewInfo::default();
s.select_tool(tool, v).unwrap();
for (k, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(k, p.x, p.y), v).unwrap();
}
}
#[test]
fn view_options_toggle_without_undo_steps_and_survive_presets() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let n = undo_len(&s);
let g = grid(&s);
assert!(g.snap && !g.locked && !g.lock_station && !g.rulers, "Snap to Grid is on by default");
for (id, on) in
[("perspective.grid.lock", true), ("perspective.grid.lockStation", true), ("perspective.grid.snap", false), ("perspective.grid.rulers", true)]
{
assert_eq!(run(&mut s, id, json!({})), json!({"on": on}), "{id} toggles");
}
assert_eq!(run(&mut s, "perspective.grid.rulers", json!({"on": true})), json!({"on": true}));
assert_eq!(undo_len(&s), n);
run(&mut s, "perspective.grid.preset", json!({"name": "[2P-High View]"}));
let g = grid(&s);
assert!(g.locked && g.lock_station && !g.snap && g.rulers, "a preset keeps the view options");
// Saved with the document.
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(PerspectiveGrid::from_doc(&back), Some(g));
}
#[test]
fn lock_grid_keeps_the_widgets_still_but_planes_switch() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.lock", json!({"on": true}));
let g = grid(&s);
let n = undo_len(&s);
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 50.0, g.horizon));
assert_eq!((grid(&s).vp_left, undo_len(&s)), (g.vp_left, n));
let (faces, _) = g.widget(&s.doc().unwrap().doc, 1.0);
let q = faces[2].1;
let c = Point::new((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0);
drag(&mut s, "perspectiveGrid", c, c);
assert_eq!(grid(&s).plane, Plane::Ground);
run(&mut s, "perspective.grid.lock", json!({"on": false}));
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 50.0, g.horizon));
assert!((grid(&s).vp_left - (g.vp_left - 50.0)).abs() < 1e-6);
}
#[test]
fn lock_station_point_swings_the_other_vanishing_point() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let st = g.station();
// Unlocked: only the dragged point moves (the viewing angle stays).
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 60.0, g.horizon));
assert_eq!(grid(&s).vp_right, g.vp_right);
s.execute("edit.undo", &json!({})).unwrap();
run(&mut s, "perspective.grid.lockStation", json!({"on": true}));
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 60.0, g.horizon));
let n = grid(&s);
assert!((n.vp_left - (g.vp_left - 60.0)).abs() < 1e-6);
assert!(n.vp_right < g.vp_right, "the right vanishing point comes in");
let st2 = n.station();
assert!((st2.x - st.x).abs() < 1e-6 && (st2.distance - st.distance).abs() < 1e-6, "the viewer stays put");
assert!(n.viewing_angle() < 45.0);
}
#[test]
fn snap_to_grid_lands_drawn_and_moved_art_on_gridlines() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let on_line = |v: f64| (v / g.cell - (v / g.cell).round()).abs() < 1e-6;
// A corner a little off the gridlines (within a quarter cell) is drawn on them.
let a = g.to_page(Plane::Left, Point::new(2.0 * g.cell + 3.0, g.cell - 2.0)).unwrap();
let b = g.to_page(Plane::Left, Point::new(4.0 * g.cell - 1.0, 3.0 * g.cell + 4.0)).unwrap();
let rect = json!({"x": a.x.min(b.x), "y": a.y.min(b.y), "width": (a.x - b.x).abs(), "height": (a.y - b.y).abs()});
let r = run(&mut s, "perspective.draw", json!({"command": "shape.rectangle", "params": rect, "plane": "left"}));
let id = NodeId(r["id"].as_u64().unwrap());
let corners = |s: &Session| -> Vec<Point> {
let n = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
n.anchors().map(|(_, _, an)| g.to_plane(Plane::Left, an.p).unwrap()).collect()
};
assert!(corners(&s).iter().all(|q| on_line(q.x) && on_line(q.y)), "{:?}", corners(&s));
// Moved a cell and a bit: it lands a whole cell over.
let from = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap();
let to = g.to_page(Plane::Left, Point::new(4.0 * g.cell + 2.0, 2.0 * g.cell)).unwrap();
run(&mut s, "perspective.move", json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y]}));
assert!(corners(&s).iter().all(|q| on_line(q.x) && on_line(q.y)), "{:?}", corners(&s));
// Without snapping it moves exactly.
let before = corners(&s);
run(&mut s, "perspective.move", json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "snap": false}));
for (p, q) in before.iter().zip(corners(&s)) {
assert!((q.x - p.x - (g.cell + 2.0)).abs() < 1e-6 && (q.y - p.y).abs() < 1e-6);
}
}
#[test]
fn grid_overlays_use_the_colours_opacity_and_rulers() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.define", json!({"leftColor": "#102030", "opacity": 20}));
let ov = s.overlays(ViewInfo::default());
assert!(ov.iter().any(|o| matches!(o, Overlay::GridLine { color: [0x10, 0x20, 0x30, 51], .. })));
let labels = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Label { .. })).count();
assert_eq!(labels(&ov), 0);
run(&mut s, "perspective.grid.rulers", json!({"on": true}));
assert!(labels(&s.overlays(ViewInfo::default())) > 2);
}
#[test]
fn snapped_moves_keep_objects_on_a_plane_together() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let rect_at = |s: &mut Session, u: f64, v: f64| {
let (a, b) = (g.to_page(Plane::Left, Point::new(u, v)).unwrap(), g.to_page(Plane::Left, Point::new(u + g.cell, v + g.cell)).unwrap());
let p = json!({"x": a.x.min(b.x), "y": a.y.min(b.y), "width": (a.x - b.x).abs(), "height": (a.y - b.y).abs()});
NodeId(run(s, "perspective.draw", json!({"command": "shape.rectangle", "params": p, "plane": "left"}))["id"].as_u64().unwrap())
};
let (a, b) = (rect_at(&mut s, 2.0 * g.cell, g.cell), rect_at(&mut s, 4.0 * g.cell + 7.0, g.cell));
let left = |s: &Session, id: NodeId| {
let p = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
p.anchors().map(|(_, _, an)| g.to_plane(Plane::Left, an.p).unwrap().x).fold(f64::MAX, f64::min)
};
let gap = left(&s, b) - left(&s, a);
let from = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap();
let to = g.to_page(Plane::Left, Point::new(4.0 * g.cell + 3.0, 2.0 * g.cell)).unwrap();
run(&mut s, "perspective.move", json!({"ids": [a.0, b.0], "from": [from.x, from.y], "to": [to.x, to.y]}));
assert!(near(left(&s, b) - left(&s, a), gap), "they move by the same amount");
assert!(near(left(&s, a), 3.0 * g.cell), "the joint bounds land on a gridline");
}
+42 -10
View File
@@ -26,6 +26,7 @@ use crate::bbox::{Handle as BoxHandle, scale_for_drag};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub mod define;
pub mod view;
pub use define::{GridDefinition, Rgb, Station};
/// Key under `Document.unknown`.
@@ -152,6 +153,18 @@ pub struct PerspectiveGrid {
pub right_offset: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub ground_offset: f64,
/// View → Perspective Grid → Lock Grid: the grid's widgets can't be dragged.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
/// Lock Station Point: dragging one vanishing point moves the other around the station point.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lock_station: bool,
/// Snap to Grid: art drawn or moved in perspective lands on gridlines within a quarter cell.
#[serde(default = "yes")]
pub snap: bool,
/// Show Rulers: a ruler up the line where the planes meet.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub rulers: bool,
}
impl PerspectiveGrid {
@@ -188,6 +201,10 @@ impl PerspectiveGrid {
left_offset: 0.0,
right_offset: 0.0,
ground_offset: 0.0,
locked: false,
lock_station: false,
snap: true,
rulers: false,
}
}
@@ -463,10 +480,16 @@ impl PerspectiveGrid {
/// bottom-right, then the plane homography projects it (so a drawn rectangle keeps the two
/// corners the user dragged between).
pub fn attach_map(&self, plane: Plane, b: Rect) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
self.attach_map_with(plane, b, false)
}
/// [`Self::attach_map`]; with `snap` the corners land on the nearest gridlines (Snap to Grid).
pub fn attach_map_with(&self, plane: Plane, b: Rect, snap: bool) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
let h = self.homography(plane)?;
let hi = h.inverse()?;
let a = hi.apply(Point::new(b.x0, b.y0))?;
let c = hi.apply(Point::new(b.x1, b.y1))?;
let snapped = |q: Point| if snap { self.snap_plane(q) } else { q };
let a = snapped(hi.apply(Point::new(b.x0, b.y0))?);
let c = snapped(hi.apply(Point::new(b.x1, b.y1))?);
let sx = if b.width().abs() > 1e-9 { (c.x - a.x) / b.width() } else { 1.0 };
let sy = if b.height().abs() > 1e-9 { (c.y - a.y) / b.height() } else { sx.abs() * if c.y < a.y { -1.0 } else { 1.0 } };
let sx = if b.width().abs() > 1e-9 { sx } else { sy.abs() };
@@ -476,10 +499,8 @@ impl PerspectiveGrid {
/// The map that slides art lying on `plane` by the plane-space offset between `from` and `to`.
pub fn move_map(&self, plane: Plane, from: Point, to: Point) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
let h = self.homography(plane)?;
let hi = h.inverse()?;
let d = hi.apply(to)? - hi.apply(from)?;
Some(move |p: Point| h.apply(hi.apply(p)? + d))
let hi = self.homography(plane)?.inverse()?;
self.move_map_by(plane, hi.apply(to)? - hi.apply(from)?)
}
/// The plane object `id` is attached to, as collected by [`Self::from_doc`].
@@ -706,9 +727,9 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
}
let mut out = vec![];
for pl in g.planes() {
let color = pl.color();
let (line, color) = (g.line_color(pl), g.plane_color(pl));
for (a, b) in g.lines(pl) {
out.push(Overlay::Line { a, b, color, dashed: false });
out.push(Overlay::GridLine { a, b, color: line });
}
if pl == g.plane
&& let Some(h) = g.homography(pl)
@@ -724,6 +745,9 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
let xs = [g.vp_left, g.vp_right, g.origin[0]];
let (x0, x1) = (xs.iter().cloned().fold(f64::MAX, f64::min) - 50.0 * tol, xs.iter().cloned().fold(f64::MIN, f64::max) + 50.0 * tol);
out.push(Overlay::Line { a: Point::new(x0, g.horizon), b: Point::new(x1, g.horizon), color: [0x60, 0x60, 0x60], dashed: true });
if g.rulers {
out.extend(g.ruler_overlays(tol));
}
// Widget.
let (faces, (c, r)) = g.widget(doc, tol);
for (pl, q) in faces {
@@ -783,6 +807,10 @@ fn handles(g: &PerspectiveGrid) -> Vec<(Handle, Point)> {
fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid {
let mut n = g.clone();
match h {
Handle::VpLeft | Handle::VpRight if g.lock_station && g.kind != 1 => {
n.horizon = p.y;
n.swing(h == Handle::VpLeft, p.x);
}
Handle::VpLeft => {
n.vp_left = p.x;
n.horizon = p.y;
@@ -837,8 +865,9 @@ impl Tool for PerspectiveGridTool {
return pre;
}
let tol = cx.tol(6.0);
if let Some((h, _)) =
handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p)))
if !g.locked
&& let Some((h, _)) =
handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p)))
{
self.drag = Some(h);
pre.push(Action::Begin("Edit Perspective Grid".into()));
@@ -874,6 +903,9 @@ impl Tool for PerspectiveGridTool {
if self.plane.is_some() || PlaneDrag::hit(&g, tol, p).is_some() {
return Cursor::Move;
}
if g.locked {
return Cursor::Arrow;
}
match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) {
Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV,
Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH,
@@ -0,0 +1,142 @@
//! View → Perspective Grid options on the grid: Snap to Grid (plane coordinates land on
//! gridlines), the gridline colours and opacity, and Show Rulers (a ruler up the vertical line
//! where the planes meet, in Define Grid's real-world units).
use vectorcraft_doc::Unit;
use vectorcraft_geom::{Point, Rect, Vec2};
use super::{PerspectiveGrid, Plane};
use crate::Overlay;
/// Snap to Grid reaches this fraction of a cell from a gridline.
const SNAP: f64 = 0.25;
/// Ruler ticks are this many screen pixels long, and labels at least this far apart.
const TICK_PX: f64 = 6.0;
const LABEL_PX: f64 = 36.0;
impl PerspectiveGrid {
/// The gridline (a multiple of the cell) within a quarter cell of `v`, if any.
fn gridline_near(&self, v: f64) -> Option<f64> {
let line = (v / self.cell).round() * self.cell;
((line - v).abs() <= SNAP * self.cell).then_some(line)
}
/// Plane coordinates `q` on the nearest gridlines within a quarter cell (each axis).
pub fn snap_plane(&self, q: Point) -> Point {
Point::new(self.gridline_near(q.x).unwrap_or(q.x), self.gridline_near(q.y).unwrap_or(q.y))
}
/// A plane-space move `d` of art spanning `b` (plane coordinates) adjusted so the edge nearer
/// a gridline on each axis lands on it, within a quarter cell.
pub fn snap_offset(&self, b: Rect, d: Vec2) -> Vec2 {
let axis = |lo: f64, hi: f64, d: f64| {
let pulls = [lo + d, hi + d].map(|e| self.gridline_near(e).map(|l| l - e));
pulls.into_iter().flatten().min_by(|a, b| a.abs().total_cmp(&b.abs())).map_or(d, |c| d + c)
};
Vec2::new(axis(b.x0, b.x1, d.x), axis(b.y0, b.y1, d.y))
}
/// The map that slides art lying on `plane` by `d` in plane coordinates.
pub fn move_map_by(&self, plane: Plane, d: Vec2) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
let h = self.homography(plane)?;
let hi = h.inverse()?;
Some(move |p: Point| h.apply(hi.apply(p)? + d))
}
/// Lock Station Point: the left (`left`) or right vanishing point dragged to `x` turns the
/// view around the station point, which stays put: the other vanishing point moves so the
/// station still sees them at a right angle. A drag past the centre of vision is ignored.
pub fn swing(&mut self, left: bool, x: f64) {
let st = self.station();
let off = if left { st.x - x } else { x - st.x };
if off <= 1e-6 || st.distance <= 0.0 {
return;
}
let a = off.atan2(st.distance).to_degrees();
self.set_station(st, if left { 90.0 - a } else { a });
}
/// A plane's gridline colour (Define Grid).
pub fn plane_color(&self, plane: Plane) -> [u8; 3] {
match plane {
Plane::Left => self.left_color.0,
Plane::Right => self.right_color.0,
Plane::Ground => self.ground_color.0,
Plane::None => plane.color(),
}
}
/// A plane's gridline colour with the grid's opacity.
pub fn line_color(&self, plane: Plane) -> [u8; 4] {
let [r, g, b] = self.plane_color(plane);
[r, g, b, (self.opacity.clamp(0.0, 100.0) * 2.55).round() as u8]
}
/// Show Rulers: ticks at the gridlines up the vertical line where the planes meet (the true
/// height line), labelled in Define Grid's real-world units. `tol` = document units per
/// screen pixel.
pub fn ruler_overlays(&self, tol: f64) -> Vec<Overlay> {
let Some(h) = self.homography(Plane::Left) else { return vec![] };
let unit = Unit::named(&self.units).unwrap_or_default();
let ratio = unit.points() * self.scale[0] / self.scale[1];
let color = [0x40, 0x40, 0x40];
let mut out = vec![];
let (Some(o), Some(top)) = (h.apply(Point::ZERO), h.apply(Point::new(0.0, self.height))) else { return out };
out.push(Overlay::Line { a: o, b: top, color, dashed: false });
// Labels every k cells, k the least that keeps them LABEL_PX apart at the origin.
let cell_px = self.cell / tol.max(1e-9);
let every = (LABEL_PX / cell_px.max(1e-9)).ceil().clamp(1.0, 1.0e6) as u64;
let tick = TICK_PX * tol;
let count = (self.height / self.cell).floor().min(2000.0) as u64;
for i in 0..=count {
let v = i as f64 * self.cell;
let Some(p) = h.apply(Point::new(0.0, v)) else { continue };
let long = i % every == 0;
out.push(Overlay::Line { a: p, b: p + Vec2::new(if long { -2.0 * tick } else { -tick }, 0.0), color, dashed: false });
if long {
let text = format!("{} {}", unit.number(unit.to_pt(v / ratio)), unit.suffix());
out.push(Overlay::Label { p: p + Vec2::new(-2.0 * tick, 0.0), text, color });
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn grid() -> PerspectiveGrid {
PerspectiveGrid { cell: 20.0, ..PerspectiveGrid::normal(2, Rect::new(0.0, 0.0, 800.0, 600.0)) }
}
#[test]
fn snapping_lands_within_a_quarter_cell_on_gridlines() {
let g = grid();
assert_eq!(g.snap_plane(Point::new(43.0, 53.0)), Point::new(40.0, 53.0));
assert_eq!(g.snap_plane(Point::new(-4.9, 61.0)), Point::new(0.0, 60.0));
// The nearer edge lands on a line: the right one (1 from 60) rather than the left (3 from 40).
let b = Rect::new(10.0, 10.0, 34.0, 20.0);
assert_eq!(g.snap_offset(b, Vec2::new(27.0, 30.0)), Vec2::new(26.0, 30.0));
assert_eq!(g.snap_offset(b, Vec2::new(30.0, 0.0)), Vec2::new(30.0, 0.0), "an edge on a line stays");
assert_eq!(g.snap_offset(b, Vec2::new(18.0, 0.0)), Vec2::new(18.0, 0.0), "nothing within reach");
}
#[test]
fn colours_and_rulers_come_from_the_definition() {
let mut g = grid();
g.opacity = 40.0;
g.right_color = super::super::Rgb([1, 2, 3]);
assert_eq!(g.line_color(Plane::Right), [1, 2, 3, 102]);
g.units = "inches".into();
g.cell = 72.0;
let labels: Vec<String> =
g.ruler_overlays(1.0).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect();
assert_eq!(labels.first().map(String::as_str), Some("0 in"));
assert_eq!(labels.get(1).map(String::as_str), Some("1 in"));
g.scale = [1.0, 4.0];
let second = g.ruler_overlays(1.0).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).nth(1);
assert_eq!(second.as_deref(), Some("4 in"), "real-world lengths at 1:4");
}
}
+2
View File
@@ -269,6 +269,8 @@ pub enum Overlay {
Highlight { quad: [Point; 4], color: [u8; 4] },
/// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected.
Swatch { p: Point, color: [u8; 4], selected: bool },
/// A hairline in a translucent colour (perspective gridlines), RGBA.
GridLine { a: Point, b: Point, color: [u8; 4] },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+4
View File
@@ -1158,6 +1158,10 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
}
Overlay::GridLine { a, b, color } => {
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c));
}
Overlay::Swatch { p: pt, color, selected } => {
// A white disc under the colour shows its opacity; a dark rim keeps it readable on
// any art, and the accent ring marks the selected stop.
@@ -161,3 +161,25 @@ fn perspective_preset_menus_list_the_views_of_each_type() {
let presets = entries.iter().find(|e| e.command.as_deref() == Some("ui.perspectivePresetsDialog")).unwrap();
assert_eq!((presets.label.as_str(), presets.path.first().map(String::as_str)), ("Perspective Grid Presets…", Some("Edit")));
}
#[test]
fn perspective_grid_view_toggles_flip_their_labels_and_checks() {
use crate::menus::{checked, dynamic_label};
let mut app = app();
assert_eq!(dynamic_label(&app, "perspective.grid.show", ""), "Show Grid");
assert_eq!(checked(&app, "perspective.grid.snap", &Value::Null), Some(true), "Snap to Grid is on by default");
app.run("perspective.grid.show", json!({})).unwrap();
app.run("perspective.grid.rulers", json!({})).unwrap();
app.run("perspective.grid.lock", json!({})).unwrap();
app.run("perspective.grid.lockStation", json!({})).unwrap();
app.run("perspective.grid.snap", json!({})).unwrap();
assert_eq!(
["perspective.grid.show", "perspective.grid.rulers", "perspective.grid.lock"].map(|id| dynamic_label(&app, id, "")),
["Hide Grid", "Hide Rulers", "Unlock Grid"]
);
assert_eq!(checked(&app, "perspective.grid.lockStation", &Value::Null), Some(true));
assert_eq!(checked(&app, "perspective.grid.snap", &Value::Null), Some(false));
let entries = crate::menus::menu_entries(&app);
let labels: Vec<&str> = entries.iter().filter(|e| e.path.last().is_some_and(|p| p == "Perspective Grid")).map(|e| e.label.as_str()).collect();
assert_eq!(&labels[..6], ["Hide Grid", "Hide Rulers", "Snap to Grid", "Unlock Grid", "Lock Station Point", "Define Grid…"]);
}
+14
View File
@@ -1142,6 +1142,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"view.proofColors" => vectorcraft_render::proof::view().proof_colors,
"view.overprintPreview" => vectorcraft_render::proof::view().overprint,
"view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()),
"perspective.grid.snap" => perspective_grid(app)?.snap,
"perspective.grid.lockStation" => perspective_grid(app)?.lock_station,
"file.documentColorMode" | "object.convertDocumentColorMode" => {
let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk);
p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" })
@@ -1150,6 +1152,11 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
})
}
/// The active document's perspective grid (View → Perspective Grid toggles).
fn perspective_grid(app: &VectorcraftApp) -> Option<vectorcraft_tools::distort::perspective::PerspectiveGrid> {
app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::effective(&d.doc))
}
/// Label for toggles whose text flips (Outline/Preview, Hide/Show …).
pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
let v = &app.ui.view;
@@ -1198,6 +1205,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => {
crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into())
}
"perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(),
"perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(),
"perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(),
_ => label.into(),
}
}
@@ -1946,6 +1956,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
"Perspective Grid",
vec![
c("Show Grid", "perspective.grid.show"),
c("Show Rulers", "perspective.grid.rulers"),
c("Snap to Grid", "perspective.grid.snap"),
c("Lock Grid", "perspective.grid.lock"),
c("Lock Station Point", "perspective.grid.lockStation"),
Sep,
c("Define Grid…", "ui.perspectiveGridDialog"),
sub("One Point Perspective", crate::dialogs::perspective_presets::menu(1, PERSPECTIVE_SLOTS[0])),
+4
View File
@@ -511,3 +511,7 @@ A selected blend shows its spine. With Direct Selection (`directSelection`) a `u
moves it (`object.blend.spine.moveAnchor`, one undo step) and, once a point is clicked, from one of its handles
reshapes the curve; Direct and Group Selection pick a blend's key objects. With the Pen tool a click on a selected
blend's spine adds a point (on a point no key sits on, deletes it).
Perspective grid view options: View → Perspective Grid lists Show/Hide Grid, Show/Hide Rulers, Snap to Grid
(checked), Lock/Unlock Grid and Lock Station Point (checked); `ui.menu.list` reports the current labels. They run
`perspective.grid.show`, `rulers`, `snap`, `lock` and `lockStation`.
+24 -15
View File
@@ -1782,6 +1782,29 @@ the viewing angle, so the vanishing points sit at `x − distance/tan(angle)` an
{"name":"run_command","arguments":{"command":"perspective.grid.define","params":{"units":"inches","gridline":0.5,"angle":30,"distance":6,"horizonHeight":3}}}
```
Perspective grid presets: `perspective.presets.list` lists the built-in views (`[1P-Normal View]`, `[1P-Low View]`,
`[1P-High View]`, `[2P-Normal View]`, `[2P-Low View]`, `[2P-High View]`, `[3P-Normal View]`, `[3P-Low View]`;
`builtIn: true`, fitted to the first artboard, protected), then the saved ones, each with its Define Grid fields.
`perspective.grid.preset {name}` (or `{kind}`, that type's normal view) resets the grid to one, fitted to the first
artboard. `perspective.presets.save {name?, newName?, preset?, …fields}` saves a preset: just `{name}` saves the
document's grid (View → Perspective Grid → Save Grid as Preset). `perspective.presets.delete {name}`,
`perspective.presets.export {names?, path?}` (a `.vcperspective` JSON file; without `path` it returns `data`) and
`perspective.presets.import {path? | data? | dataBase64?, replace?}` manage them. Saved presets are kept with the
preferences, and a grid whose fields are a preset's is named after it (`name`).
```json
{"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 grid view options (View → Perspective Grid; view state saved with the document, no undo step, each
`{on?: bool}`, no param toggles, → `{on}`): `perspective.grid.lock` (Lock Grid: the grid's widgets can't be dragged),
`perspective.grid.lockStation` (Lock Station Point: dragging one vanishing point turns the view around the station
point, so the other one moves), `perspective.grid.snap` (Snap to Grid, on by default: `perspective.draw` lands the
drawn corners, and `perspective.move` the nearer edge, on gridlines within a quarter cell; both take `snap: false`)
and `perspective.grid.rulers` (Show Rulers: a ruler up the line where the planes meet, in the grid's units at its
scale). Gridlines draw in the Define Grid colours at its opacity.
## Envelopes
Object → Envelope Distort wraps the selection in a live envelope: `object.envelope.makeWithWarp` (`style`: arc,
@@ -1830,21 +1853,6 @@ its current size (with Shift too it keeps its proportions). With Use Pressure Pe
{"name":"run_command","arguments":{"command":"object.liquify","params":{"tool":"scallop","points":[[300,150],[300,250]],"complexity":3,"affectAnchors":false}}}
```
Perspective grid presets: `perspective.presets.list` lists the built-in views (`[1P-Normal View]`, `[1P-Low View]`,
`[1P-High View]`, `[2P-Normal View]`, `[2P-Low View]`, `[2P-High View]`, `[3P-Normal View]`, `[3P-Low View]`;
`builtIn: true`, fitted to the first artboard, protected), then the saved ones, each with its Define Grid fields.
`perspective.grid.preset {name}` (or `{kind}`, that type's normal view) resets the grid to one, fitted to the first
artboard. `perspective.presets.save {name?, newName?, preset?, …fields}` saves a preset: just `{name}` saves the
document's grid (View → Perspective Grid → Save Grid as Preset). `perspective.presets.delete {name}`,
`perspective.presets.export {names?, path?}` (a `.vcperspective` JSON file; without `path` it returns `data`) and
`perspective.presets.import {path? | data? | dataBase64?, replace?}` manage them. Saved presets are kept with the
preferences, and a grid whose fields are a preset's is named after it (`name`).
```json
{"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
@@ -1903,3 +1911,4 @@ sits on; the first edit turns the spine into a path. Moving a key moves its end
{"name":"run_command","arguments":{"command":"object.blend.spine.addAnchor","params":{"id":40,"x":150,"y":100}}}
{"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}}
```