diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index f80b6666..1ac4b860 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -133,7 +133,7 @@ pub fn specs() -> Vec { "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 { "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 { +pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result { 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 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 { 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 { } 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 { 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 { 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, ¶ms)?; 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(()) }); diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index 9b61862c..6859f805 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -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 { @@ -80,6 +85,42 @@ pub fn specs() -> Vec { 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 { 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 { } 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 { + 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 { + view_option(s, p, |g| &mut g.locked) +} + +fn lock_station(s: &mut Session, p: &Value) -> Result { + view_option(s, p, |g| &mut g.lock_station) +} + +fn snap(s: &mut Session, p: &Value) -> Result { + view_option(s, p, |g| &mut g.snap) +} + +fn rulers(s: &mut Session, p: &Value) -> Result { + 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)) +} diff --git a/crates/engine/src/tests_distort.rs b/crates/engine/src/tests_distort.rs index 5e352cd1..e7f8e836 100644 --- a/crates/engine/src/tests_distort.rs +++ b/crates/engine/src/tests_distort.rs @@ -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 = 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; diff --git a/crates/engine/src/tests_persp_select.rs b/crates/engine/src/tests_persp_select.rs index 859a48e6..88031ba2 100644 --- a/crates/engine/src/tests_persp_select.rs +++ b/crates/engine/src/tests_persp_select.rs @@ -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(); diff --git a/crates/engine/src/tests_perspgrid.rs b/crates/engine/src/tests_perspgrid.rs index a9e63485..a1e86eb6 100644 --- a/crates/engine/src/tests_perspgrid.rs +++ b/crates/engine/src/tests_perspgrid.rs @@ -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::(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 { + 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"); +} diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 577d67b2..144461f1 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -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 Option + 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 Option + 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 Option + 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 { } 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 { 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, diff --git a/crates/tools/src/distort/perspective/view.rs b/crates/tools/src/distort/perspective/view.rs new file mode 100644 index 00000000..676736ca --- /dev/null +++ b/crates/tools/src/distort/perspective/view.rs @@ -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 { + 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 Option + 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 { + 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 = + 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"); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 9931dcc4..1fbfbcf3 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6bb4460e..5c9f8ecf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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. diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index 5fab2def..3b3d994d 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -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…"]); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7068fdaa..3b7c318e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1142,6 +1142,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "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 { }) } +/// The active document's perspective grid (View → Perspective Grid toggles). +fn perspective_grid(app: &VectorcraftApp) -> Option { + 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)> { "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])), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6b3e1beb..f3d4f356 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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`. diff --git a/docs/mcp.md b/docs/mcp.md index 22ba39ce..a8ee233a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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}}} ``` +