mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
85e7acf138
commit
e3b079de8c
13 files changed
+504
-38
No files matched your search
@@ -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, ¶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(())
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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…"]);
|
||||
}
|
||||
@@ -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])),
|
||||
|
||||
@@ -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
@@ -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}}}
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user