M8.10: Perspective grid view options: Lock Grid, Lock Station Point, Snap to Grid, Show Rulers

View > Perspective Grid gains Show/Hide Rulers, Snap to Grid (on by default),
Lock/Unlock Grid and Lock Station Point, with flipping labels and checkmarks (and
Show/Hide Grid's label). Lock Grid stops the grid widgets from dragging; Lock
Station Point swings the other vanishing point around the station point; Snap to
Grid lands drawn corners and moved edges on gridlines within a quarter cell
(perspective.draw/move take snap?); Show Rulers draws a ruler up the true height
line in the grid's units. Gridlines draw in the Define Grid colours and opacity.
New commands perspective.grid.lock/lockStation/snap/rulers (view state).

Existing tests that measure exact perspective moves (two in tests_distort.rs,
the persp_session helper of tests_persp_select.rs) turn snapping off first (it is on by
default and would move their exact corners onto gridlines).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-06 11:49:19 +07:00
1 parent 85e7acf138
commit e3b079de8c
13 files changed
+504 -38

No files matched your search

+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}}}
```