M8.11: Perspective grid widgets: ground-level points move the grid, separate extents, cell size widget, screen-fixed Plane Switching Widget with 1-4 keys

The Perspective Grid tool's left and right ground-level points move the whole
grid; the left and right extents are separate (extentRight; Alt drags both,
Shift goes by whole cells); a cell size widget sits up the line where the planes
meet; Shift keeps a vanishing point on the horizon and the ground points and
origin on one axis. The Plane Switching Widget stays put in a corner of the
document window (ViewInfo/ToolContext carry the window as a ScreenFrame), takes
its clicks with any tool while the grid shows (the Perspective Selection tool
too while it's hidden), and 1-4 pick the left, horizontal, right and no plane.
Double-clicking the Perspective Grid tool opens Perspective Grid Options
(show the widget, its corner; perspective.widget.options, kept with the
preferences).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-06 12:16:39 +07:00
co-authored by Claude Opus 5.5
parent 565b75b91d
commit 0889f13cd8
20 changed files with 771 additions and 69 deletions
+62
View File
@@ -15,7 +15,9 @@ 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::widget::{WidgetCorner, WidgetPlace};
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
use super::distortcmds::{grid_of, silent, store_grid};
use super::*;
@@ -85,6 +87,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
presets_import
),
cmd!(
query "perspective.widget.options",
"Perspective Grid Options",
[],
None,
"{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight} the Plane Switching Widget: shown or not, and the corner of the document window it stays in (kept with the preferences; no params reads them) → {show, position}",
always,
widget_options
),
cmd!(
"perspective.grid.lock",
"Lock Grid",
@@ -417,3 +428,54 @@ pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (P
let dv = g.plane_delta(plane, depth, from, to)?;
Some((plane, depth, g.snap_offset(b, dv) - dv))
}
fn widget_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.widget.options";
let mut o = s.prefs.perspective_widget;
if let Some(v) = p.get("show") {
o.show = v.as_bool().ok_or_else(|| bad(C, "show must be true or false"))?;
}
if let Some(v) = p.get("position") {
o.position =
v.as_str().and_then(WidgetCorner::parse).ok_or_else(|| bad(C, "position must be topLeft, topRight, bottomLeft or bottomRight"))?;
}
s.prefs.perspective_widget = o;
Ok(json!(o))
}
impl Session {
/// The Plane Switching Widget's part of a pointer event: a press on it picks the plane (its
/// drag and release go nowhere); None when the event isn't the widget's.
pub(crate) fn plane_widget_pointer(&mut self, ev: &PointerEvent, view: crate::ViewInfo) -> Option<Result<Vec<crate::UiRequest>>> {
match ev.kind {
PointerKind::Down => {
let w = self.prefs.perspective_widget;
let st = self.active().filter(|_| w.show)?;
let g = PerspectiveGrid::current(&st.doc);
if !g.shown(self.tool_id()) {
return None;
}
let place = WidgetPlace { screen: view.screen.as_ref(), corner: w.position };
let plane = g.widget_hit_at(&st.doc, 1.0 / view.zoom.max(1e-9), place, ev.pos)?;
self.plane_widget_press = true;
Some(self.execute("perspective.plane.set", &json!({ "plane": plane.id() })).map(|_| vec![]))
}
PointerKind::Drag if self.plane_widget_press => Some(Ok(vec![])),
PointerKind::Up if std::mem::take(&mut self.plane_widget_press) => Some(Ok(vec![])),
_ => None,
}
}
/// The plane digit key `key` picks while the grid is shown: 1 left, 2 horizontal (ground),
/// 3 right, 4 none.
pub(crate) fn plane_key(&self, key: ToolKey) -> Option<Plane> {
let plane = match key {
ToolKey::Digit(1) => Plane::Left,
ToolKey::Digit(2) => Plane::Ground,
ToolKey::Digit(3) => Plane::Right,
ToolKey::Digit(4) => Plane::None,
_ => return None,
};
PerspectiveGrid::current(&self.active()?.doc).shown(self.tool_id()).then_some(plane)
}
}
+12 -2
View File
@@ -329,10 +329,11 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> {
}
/// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`). `prefs.get`
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`) and the
/// Perspective Grid Options (`perspective.widget.options`). `prefs.get`
/// and `prefs.set` take them by key (a partial object updates what it names) and `prefs.reset`
/// without a category resets them.
pub const PREF_GROUPS: &[&str] = &["eyedropper"];
pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget"];
/// Validate a value for preference group `key` against `current`.
fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<Value, String> {
@@ -341,6 +342,15 @@ fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<
let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default();
cur.merged(v).map(|o| json!(o))
}
"perspectiveWidget" => {
let mut merged = current.clone();
if let (Some(o), Some(p)) = (merged.as_object_mut(), v.as_object()) {
o.extend(p.clone());
}
serde_json::from_value::<vectorcraft_tools::distort::perspective::widget::WidgetOptions>(merged)
.map(|o| json!(o))
.map_err(|e| e.to_string())
}
_ => Err(format!("unknown preference `{key}`")),
}
}
+7
View File
@@ -501,6 +501,9 @@ pub struct Prefs {
/// preferences keeps it.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_options: Option<vectorcraft_doc::live::BlendDefaults>,
/// Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane
/// Switching Widget shows and where (`perspective.widget.options`).
pub perspective_widget: vectorcraft_tools::distort::perspective::widget::WidgetOptions,
}
impl Default for Prefs {
@@ -637,6 +640,7 @@ impl Default for Prefs {
tool_settings: Default::default(),
perspective_presets: vec![],
blend_options: None,
perspective_widget: Default::default(),
}
}
}
@@ -710,6 +714,8 @@ pub struct Session {
/// The Liquify stroke the last live preview applied, which the next sample of the drag goes on
/// from ([`cmd::distortcmds::LiquifyStroke`]).
pub(crate) liquify_stroke: Option<Box<cmd::distortcmds::LiquifyStroke>>,
/// A press on the Plane Switching Widget is under way: its drag and release are the widget's.
pub(crate) plane_widget_press: bool,
}
impl Default for Session {
@@ -753,6 +759,7 @@ impl Session {
in_tool_actions: false,
envelope_defaults: None,
liquify_stroke: None,
plane_widget_press: false,
}
}
+174
View File
@@ -350,3 +350,177 @@ fn snapped_moves_keep_objects_on_a_plane_together() {
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");
}
// ---------- M8.11: grid widgets and the Plane Switching Widget ----------
use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace};
use vectorcraft_tools::{Mods, ScreenFrame, ToolKey};
/// A 1000 × 700 window at zoom 2 showing the document from (100, 50).
fn screen() -> ScreenFrame {
ScreenFrame {
origin: Point::new(100.0, 50.0),
right: vectorcraft_geom::Vec2::new(0.5, 0.0),
down: vectorcraft_geom::Vec2::new(0.0, 0.5),
size: (1000.0, 700.0),
}
}
fn windowed() -> ViewInfo {
ViewInfo { zoom: 2.0, screen: Some(screen()), ..Default::default() }
}
/// The centre of the widget's `plane` face in `view`.
fn face(s: &Session, view: ViewInfo, corner: WidgetCorner, plane: Plane) -> Point {
let d = &s.doc().unwrap().doc;
let (faces, (c, _)) = grid(s).widget_at(d, 1.0 / view.zoom, WidgetPlace { screen: view.screen.as_ref(), corner });
match faces.into_iter().find(|f| f.0 == plane) {
Some((_, q)) => Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0),
None => c,
}
}
fn click(s: &mut Session, p: Point, view: ViewInfo) {
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, p.x, p.y), view).unwrap();
}
}
#[test]
fn the_plane_widget_stays_on_screen_and_takes_clicks_with_any_tool() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let view = windowed();
// In the window's top-left corner, whatever part of the document shows.
let p = face(&s, view, WidgetCorner::TopLeft, Plane::Right);
assert!(p.x > 100.0 && p.x < 100.0 + 40.0 && p.y > 50.0 && p.y < 50.0 + 40.0, "{p:?}");
// The Selection tool's click on it picks the plane and selects nothing.
s.select_tool("selection", view).unwrap();
let n = undo_len(&s);
click(&mut s, p, view);
assert_eq!(grid(&s).plane, Plane::Right);
assert!(s.doc().unwrap().selection.is_empty());
assert_eq!(undo_len(&s), n);
// While the grid is hidden only the perspective tools show it (and take its clicks).
run(&mut s, "perspective.grid.show", json!({"visible": false}));
let q = face(&s, view, WidgetCorner::TopLeft, Plane::Ground);
click(&mut s, q, view);
assert_eq!(grid(&s).plane, Plane::Right);
s.select_tool("perspectiveSelection", view).unwrap();
click(&mut s, q, view);
assert_eq!(grid(&s).plane, Plane::Ground);
// Perspective Grid Options move it to another corner or hide it.
assert_eq!(run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})), json!({"show": true, "position": "bottomRight"}));
let r = face(&s, view, WidgetCorner::BottomRight, Plane::Left);
assert!(r.x > 100.0 + 450.0 && r.y > 50.0 + 300.0, "{r:?}");
click(&mut s, r, view);
assert_eq!(grid(&s).plane, Plane::Left);
run(&mut s, "perspective.widget.options", json!({"show": false}));
let hidden = face(&s, view, WidgetCorner::BottomRight, Plane::Right);
click(&mut s, hidden, view);
assert_eq!(grid(&s).plane, Plane::Left, "a hidden widget takes no clicks");
assert!(s.execute("perspective.widget.options", &json!({"position": "middle"})).is_err());
assert!(s.execute("perspective.widget.options", &json!({"show": "yes"})).is_err());
}
#[test]
fn digit_keys_pick_the_plane_while_the_grid_shows() {
let mut s = session();
let view = ViewInfo::default();
assert!(!s.tool_claims_key(ToolKey::Digit(2), view), "no grid shown: the digit is free");
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
for (n, plane) in [(1, Plane::Left), (2, Plane::Ground), (3, Plane::Right), (4, Plane::None)] {
assert!(s.tool_claims_key(ToolKey::Digit(n), view));
s.tool_key(ToolKey::Digit(n), Mods::default(), view).unwrap();
assert_eq!(grid(&s).plane, plane, "key {n}");
}
assert!(!s.tool_claims_key(ToolKey::Digit(5), view));
}
#[test]
fn ground_level_points_move_the_whole_grid() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let o = Point::new(g.origin[0], g.origin[1]);
// The left ground-level point sits 24 px from the origin along the left wall's ground line.
let towards = g.to_page(Plane::Left, Point::new(g.cell, 0.0)).unwrap();
let gl = o + (towards - o).normalize() * 24.0;
drag(&mut s, "perspectiveGrid", gl, gl + vectorcraft_geom::Vec2::new(30.0, -20.0));
let n = grid(&s);
let moved = [n.origin[0] - o.x, n.origin[1] - o.y, n.horizon - g.horizon, n.vp_left - g.vp_left, n.vp_right - g.vp_right];
for (got, want) in moved.into_iter().zip([30.0, -20.0, -20.0, 30.0, 30.0]) {
assert!(near(got, want), "{moved:?}");
}
assert_eq!((n.cell, n.viewing_angle()), (g.cell, g.viewing_angle()), "the same grid, elsewhere");
// Shift keeps it on one axis.
s.execute("edit.undo", &json!({})).unwrap();
gesture_mods(&mut s, gl, gl + vectorcraft_geom::Vec2::new(30.0, -8.0), Mods { shift: true, ..Default::default() });
let n = grid(&s);
assert!(near(n.origin[0], o.x + 30.0) && n.origin[1] == o.y && n.horizon == g.horizon);
}
/// A Perspective Grid tool drag with modifiers held.
fn gesture_mods(s: &mut Session, from: Point, to: Point, mods: Mods) {
let v = ViewInfo::default();
s.select_tool("perspectiveGrid", v).unwrap();
for (k, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(k, p.x, p.y).with_mods(mods), v).unwrap();
}
}
#[test]
fn extents_cell_size_and_vanishing_points_follow_their_widgets() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
// The right extent alone.
let er = g.to_page(Plane::Right, Point::new(g.extent, 0.0)).unwrap();
let to = g.to_page(Plane::Right, Point::new(g.extent * 0.5, 0.0)).unwrap();
drag(&mut s, "perspectiveGrid", er, to);
let n = grid(&s);
assert!((n.right_extent() - g.extent * 0.5).abs() < 1e-6 && n.extent == g.extent, "{:?} {}", n.extent_right, n.extent);
assert_eq!(n.domain(Plane::Right).width(), n.right_extent());
// Alt drags both; Shift goes by whole cells.
let el = g.to_page(Plane::Left, Point::new(g.extent, 0.0)).unwrap();
let to = g.to_page(Plane::Left, Point::new(5.3 * g.cell, 0.0)).unwrap();
gesture_mods(&mut s, el, to, Mods { alt: true, shift: true, ..Default::default() });
let n = grid(&s);
assert!((n.extent - 5.0 * g.cell).abs() < 1e-6 && n.extent_right == Some(n.extent));
// The cell widget: a cell up the line where the planes meet (more cells when they're small).
let c = g.to_page(Plane::Left, Point::new(0.0, g.cell)).unwrap();
drag(&mut s, "perspectiveGrid", c, g.to_page(Plane::Left, Point::new(0.0, 40.0)).unwrap());
assert!((grid(&s).cell - 40.0).abs() < 1e-6, "{}", grid(&s).cell);
// Shift-dragging a vanishing point keeps the horizon.
gesture_mods(
&mut s,
Point::new(g.vp_right, g.horizon),
Point::new(g.vp_right + 20.0, g.horizon + 30.0),
Mods { shift: true, ..Default::default() },
);
assert_eq!((grid(&s).vp_right, grid(&s).horizon), (g.vp_right + 20.0, g.horizon));
}
#[test]
fn widget_options_and_the_right_extent_are_saved() {
let mut s = session();
run(&mut s, "perspective.widget.options", json!({"position": "Top Right"}));
let back: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert_eq!(back.perspective_widget.position, WidgetCorner::TopRight);
run(&mut s, "perspective.grid.set", json!({"extentRight": 123}));
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(PerspectiveGrid::from_doc(&back).unwrap().extent_right, Some(123.0));
assert!(s.execute("perspective.grid.set", &json!({"extentRight": -1})).is_err());
}
#[test]
fn perspective_grid_options_are_a_preference_group() {
let mut s = session();
run(&mut s, "prefs.set", json!({"key": "perspectiveWidget", "value": {"position": "bottomLeft"}}));
assert_eq!(s.prefs.perspective_widget.position, WidgetCorner::BottomLeft);
assert!(s.prefs.perspective_widget.show, "a partial object keeps the rest");
assert!(s.execute("prefs.set", &json!({"key": "perspectiveWidget", "value": {"position": "nowhere"}})).is_err());
run(&mut s, "prefs.reset", json!({}));
assert_eq!(s.prefs.perspective_widget, Default::default());
}
+19 -2
View File
@@ -2,6 +2,7 @@
use serde_json::{Map, Value};
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::perspective::widget::WidgetPlace;
use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
use crate::{EngineError, Prefs, Result, Session};
@@ -20,6 +21,8 @@ pub struct ViewInfo {
pub snap_to_point: bool,
/// View → Show Corner Widget.
pub corner_widgets: bool,
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<vectorcraft_tools::ScreenFrame>,
}
impl Default for ViewInfo {
@@ -33,6 +36,7 @@ impl Default for ViewInfo {
snap_to_pixel: false,
snap_to_point: true,
corner_widgets: true,
screen: None,
}
}
}
@@ -134,6 +138,8 @@ impl Session {
paste_plain_text: self.prefs.paste_text_formatting == "plain",
slices_hidden: self.menu.slices_hidden,
slices_locked: self.menu.slices_locked,
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};
let tool = &mut self.tool;
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
@@ -157,6 +163,10 @@ impl Session {
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.last_view = view;
// The Plane Switching Widget takes its clicks whatever the tool.
if let Some(r) = self.plane_widget_pointer(ev, view) {
return r;
}
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
self.take_tool_panic()?;
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
@@ -183,6 +193,11 @@ impl Session {
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
// 1–4 pick the active perspective plane while the grid is shown.
if let Some(plane) = self.plane_key(key) {
self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?;
return Ok(vec![]);
}
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
self.take_tool_panic()?;
self.apply_actions(acts)
@@ -205,13 +220,15 @@ impl Session {
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
if let Some(d) = self.active() {
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays(&d.doc, 1.0 / view.zoom.max(1e-9), self.tool.id()));
let w = self.prefs.perspective_widget;
let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position });
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), self.tool.id(), place));
}
v
}
+152 -61
View File
@@ -27,6 +27,7 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
pub mod define;
pub mod view;
pub mod widget;
pub use define::{GridDefinition, Rgb, Station};
/// Key under `Document.unknown`.
@@ -165,6 +166,9 @@ pub struct PerspectiveGrid {
/// Show Rulers: a ruler up the line where the planes meet.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub rulers: bool,
/// Horizontal extent of the right plane (points, plane units); None: `extent`, as the left.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extent_right: Option<f64>,
}
impl PerspectiveGrid {
@@ -205,6 +209,7 @@ impl PerspectiveGrid {
lock_station: false,
snap: true,
rulers: false,
extent_right: None,
}
}
@@ -458,14 +463,28 @@ impl PerspectiveGrid {
out
}
/// Plane-coordinate extent of a plane.
/// Plane-coordinate extent of a plane: the left plane is `extent` wide, the right one
/// [`Self::right_extent`]; the ground spans both (one-point: the front plane's width by the
/// side wall's depth).
pub fn domain(&self, plane: Plane) -> Rect {
match plane {
Plane::Ground => Rect::new(0.0, 0.0, self.extent, self.extent),
match (plane, self.kind) {
(Plane::Ground, 1) => Rect::new(0.0, 0.0, self.extent, self.right_extent()),
(Plane::Ground, _) => Rect::new(0.0, 0.0, self.right_extent(), self.extent),
(Plane::Right, _) => Rect::new(0.0, 0.0, self.right_extent(), self.height),
_ => Rect::new(0.0, 0.0, self.extent, self.height),
}
}
/// The right plane's horizontal extent.
pub fn right_extent(&self) -> f64 {
self.extent_right.unwrap_or(self.extent)
}
/// Is the grid drawn (shown, or a perspective tool active)?
pub fn shown(&self, tool_id: &str) -> bool {
self.visible || matches!(tool_id, "perspectiveGrid" | "perspectiveSelection")
}
/// Page → plane coordinates.
pub fn to_plane(&self, plane: Plane, p: Point) -> Option<Point> {
self.homography(plane)?.inverse()?.apply(p)
@@ -560,18 +579,10 @@ impl PerspectiveGrid {
out
}
/// Plane Switching widget geometry: (plane, quad) faces and the "no plane" circle (centre, r).
/// Plane Switching widget geometry in the first artboard's top-left corner (headless): (plane,
/// quad) faces and the "no plane" circle (centre, r). See [`Self::widget_at`].
pub fn widget(&self, doc: &Document, tol: f64) -> WidgetGeom {
let ab = doc.artboards.first().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
let s = 40.0 * tol;
let (cx, cy) = (ab.x0 + 10.0 * tol + s / 2.0, ab.y0 + 10.0 * tol + s / 2.0);
let p = |dx: f64, dy: f64| Point::new(cx + dx * s, cy + dy * s);
let faces = vec![
(Plane::Left, [p(-0.5, -0.25), p(0.0, 0.0), p(0.0, 0.5), p(-0.5, 0.25)]),
(Plane::Right, [p(0.0, 0.0), p(0.5, -0.25), p(0.5, 0.25), p(0.0, 0.5)]),
(Plane::Ground, [p(0.0, -0.5), p(0.5, -0.25), p(0.0, 0.0), p(-0.5, -0.25)]),
];
(faces, (p(0.62, 0.62), 0.12 * s))
self.widget_at(doc, tol, widget::WidgetPlace::default())
}
/// Which widget control is at `p`.
@@ -753,9 +764,13 @@ fn in_quad(q: &[Point; 4], p: Point) -> bool {
/// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible or
/// a perspective tool is active. `tol` = document units per screen pixel.
pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
grid_overlays_in(doc, tol, tool_id, Some(widget::WidgetPlace::default()))
}
/// [`grid_overlays`] with the Plane Switching Widget at `place` (None: hidden).
pub fn grid_overlays_in(doc: &Document, tol: f64, tool_id: &str, place: Option<widget::WidgetPlace>) -> Vec<Overlay> {
let g = PerspectiveGrid::current(doc);
let tool = matches!(tool_id, "perspectiveGrid" | "perspectiveSelection");
if !g.visible && !tool {
if !g.shown(tool_id) {
return vec![];
}
let mut out = vec![];
@@ -782,7 +797,8 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
out.extend(g.ruler_overlays(tol));
}
// Widget.
let (faces, (c, r)) = g.widget(doc, tol);
let Some(place) = place else { return out };
let (faces, (c, r)) = g.widget_at(doc, tol, place);
for (pl, q) in faces {
let [cr, cg, cb] = pl.color();
out.push(Overlay::Highlight { quad: q, color: [cr, cg, cb, if pl == g.plane { 230 } else { 70 }] });
@@ -810,9 +826,25 @@ enum Handle {
ExtentLeft,
ExtentRight,
Height,
/// The left and right ground-level points: they move the whole grid.
GroundLeft,
GroundRight,
/// The grid cell size widget, `n` cells up the line where the planes meet.
Cell,
}
fn handles(g: &PerspectiveGrid) -> Vec<(Handle, Point)> {
/// The ground-level points and the cell widget sit at least this far (screen pixels) from the
/// origin.
const NEAR_ORIGIN_PX: f64 = 24.0;
/// How many cells up the cell widget sits so it stays clear of the origin (`tol` = document units
/// per screen pixel).
fn cell_steps(g: &PerspectiveGrid, tol: f64) -> f64 {
(NEAR_ORIGIN_PX * tol / g.cell.max(1e-9)).ceil().clamp(1.0, 1.0e6)
}
fn handles(g: &PerspectiveGrid, tol: f64) -> Vec<(Handle, Point)> {
let o = Point::new(g.origin[0], g.origin[1]);
let mut v = vec![(Handle::VpLeft, Point::new(g.vp_left, g.horizon))];
if g.kind != 1 {
v.push((Handle::VpRight, Point::new(g.vp_right, g.horizon)));
@@ -820,48 +852,103 @@ fn handles(g: &PerspectiveGrid) -> Vec<(Handle, Point)> {
if g.kind == 3 {
v.push((Handle::VpVertical, Point::new(g.vp_vertical[0], g.vp_vertical[1])));
}
v.push((Handle::Origin, Point::new(g.origin[0], g.origin[1])));
let (lp, rp) = (Plane::Left, Plane::Right);
if let Some(p) = g.to_page(lp, Point::new(g.extent, 0.0)) {
v.push((Handle::ExtentLeft, p));
}
if let Some(p) = g.to_page(rp, Point::new(g.extent, 0.0)) {
v.push((Handle::ExtentRight, p));
}
if let Some(p) = g.to_page(Plane::Left, Point::new(0.0, g.height)) {
v.push((Handle::Height, p));
v.push((Handle::Origin, o));
let mut at = |h: Handle, pl: Plane, q: Point| {
if let Some(p) = g.to_page(pl, q) {
v.push((h, p));
}
};
at(Handle::ExtentLeft, Plane::Left, Point::new(g.extent, 0.0));
at(Handle::ExtentRight, Plane::Right, Point::new(g.right_extent(), 0.0));
at(Handle::Height, Plane::Left, Point::new(0.0, g.height));
at(Handle::Cell, Plane::Left, Point::new(0.0, g.cell * cell_steps(g, tol)));
// The ground-level points: a little way from the origin along each wall's ground line.
for (h, pl) in [(Handle::GroundLeft, Plane::Left), (Handle::GroundRight, Plane::Right)] {
if let Some(p) = g.to_page(pl, Point::new(g.cell, 0.0)).filter(|p| p.distance(o) > 1e-9) {
v.push((h, o + (p - o).normalize() * (NEAR_ORIGIN_PX * tol)));
}
}
// The horizon handle sits on the horizon above the origin.
v.push((Handle::Horizon, Point::new(g.origin[0], g.horizon)));
v
}
/// Apply a handle drag to a copy of the grid.
fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid {
/// A widget drag: the widget, the grid and the pointer when it was pressed, and the cell widget's
/// steps then.
struct Press {
handle: Handle,
grid: PerspectiveGrid,
at: Point,
steps: f64,
}
/// `g` moved by `d`: the whole grid, vanishing points and horizon too.
fn translated(g: &PerspectiveGrid, d: Vec2) -> PerspectiveGrid {
let mut n = g.clone();
n.origin = [g.origin[0] + d.x, g.origin[1] + d.y];
n.horizon += d.y;
n.vp_left += d.x;
n.vp_right += d.x;
n.vp_vertical = [g.vp_vertical[0] + d.x, g.vp_vertical[1] + d.y];
n
}
/// The grid with a widget dragged to `p`. Shift constrains: a vanishing point keeps the horizon,
/// the ground-level points and the origin move along one axis, extents and the height go by whole
/// cells. Alt drags both extents together.
fn drag_handle(press: &Press, p: Point, mods: Mods) -> PerspectiveGrid {
let g = &press.grid;
let mut n = g.clone();
let along_axis = |p: Point| {
let d = p - press.at;
if !mods.shift {
d
} else if d.x.abs() >= d.y.abs() {
Vec2::new(d.x, 0.0)
} else {
Vec2::new(0.0, d.y)
}
};
let cells = |v: f64| if mods.shift { (v / g.cell).round().max(1.0) * g.cell } else { v.max(g.cell) };
let h = press.handle;
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;
}
Handle::VpRight => {
n.vp_right = p.x;
n.horizon = p.y;
Handle::VpLeft | Handle::VpRight => {
if !mods.shift {
n.horizon = p.y;
}
match (h == Handle::VpLeft, g.lock_station && g.kind != 1) {
(left, true) => n.swing(left, p.x),
(true, false) => n.vp_left = p.x,
(false, false) => n.vp_right = p.x,
}
}
Handle::VpVertical => n.vp_vertical = [p.x, p.y],
Handle::Horizon => n.horizon = p.y,
Handle::Origin => n.origin = [p.x, p.y],
Handle::ExtentLeft | Handle::ExtentRight | Handle::Height => {
Handle::Origin => {
let d = along_axis(p);
n.origin = [g.origin[0] + d.x, g.origin[1] + d.y];
}
Handle::GroundLeft | Handle::GroundRight => n = translated(g, along_axis(p)),
Handle::ExtentLeft | Handle::ExtentRight => {
let pl = if h == Handle::ExtentRight { Plane::Right } else { Plane::Left };
if let Some(q) = g.to_plane(pl, p) {
match h {
Handle::Height => n.height = q.y.max(g.cell),
_ => n.extent = q.x.max(g.cell),
let e = cells(q.x);
if h == Handle::ExtentLeft || mods.alt {
n.extent = e;
}
if h == Handle::ExtentRight || mods.alt {
n.extent_right = Some(e);
}
}
}
Handle::Height => {
if let Some(q) = g.to_plane(Plane::Left, p) {
n.height = cells(q.y);
}
}
Handle::Cell => {
if let Some(q) = g.to_plane(Plane::Left, p) {
n.cell = (q.y / press.steps).max(0.5);
}
}
}
@@ -870,7 +957,7 @@ fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid {
#[derive(Default)]
pub struct PerspectiveGridTool {
drag: Option<Handle>,
drag: Option<Press>,
/// Dragging a plane widget.
plane: Option<PlaneDrag>,
}
@@ -888,7 +975,7 @@ impl Tool for PerspectiveGridTool {
if !g.visible {
pre.push(Action::Exec("perspective.grid.show".into(), json!({"visible": true})));
}
if let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p) {
if let Some(pl) = g.widget_hit_cx(cx, p) {
pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()})));
return pre;
}
@@ -899,17 +986,20 @@ impl Tool for PerspectiveGridTool {
}
let tol = cx.tol(6.0);
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)))
&& let Some((h, _)) = handles(&g, cx.tol(1.0))
.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);
let steps = cell_steps(&g, cx.tol(1.0));
self.drag = Some(Press { handle: h, grid: g, at: p, steps });
pre.push(Action::Begin("Edit Perspective Grid".into()));
}
pre
}
PointerKind::Drag if self.plane.is_some() => self.plane.and_then(|d| d.preview(&g, p, ev.mods)).into_iter().collect(),
PointerKind::Drag => match self.drag {
Some(h) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(&g, h, p).definition_json())],
PointerKind::Drag => match &self.drag {
Some(press) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(press, p, ev.mods).definition_json())],
None => vec![],
},
PointerKind::Up => match (self.drag.take(), self.plane.take()) {
@@ -922,11 +1012,14 @@ impl Tool for PerspectiveGridTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let g = PerspectiveGrid::current(cx.doc);
handles(&g)
let ink = [0x20, 0x20, 0x20];
handles(&g, cx.tol(1.0))
.into_iter()
.map(|(h, p)| match h {
Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: [0x20, 0x20, 0x20], filled: true, size: 7.0 },
_ => Overlay::Anchor { p, color: [0x20, 0x20, 0x20], filled: false, size: 6.0 },
Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: ink, filled: true, size: 7.0 },
Handle::GroundLeft | Handle::GroundRight => Overlay::Anchor { p, color: ink, filled: true, size: 5.0 },
Handle::Cell => Overlay::Handle { p, color: ink },
_ => Overlay::Anchor { p, color: ink, filled: false, size: 6.0 },
})
.collect()
}
@@ -939,8 +1032,8 @@ impl Tool for PerspectiveGridTool {
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,
match handles(&g, cx.tol(1.0)).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) {
Some(Handle::Horizon | Handle::Height | Handle::Cell) => Cursor::ResizeV,
Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH,
Some(_) => Cursor::Move,
None => Cursor::Arrow,
@@ -1038,9 +1131,7 @@ impl Tool for PerspectiveSelectionTool {
match (ev.kind, std::mem::take(&mut self.drag)) {
(PointerKind::Down, _) => {
self.measure = None;
if g.visible
&& let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p)
{
if let Some(pl) = g.widget_hit_cx(cx, p) {
return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))];
}
if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) {
@@ -323,6 +323,9 @@ impl PerspectiveGrid {
if !(0.0..=100.0).contains(&self.opacity) {
return Err("opacity must be 0–100".into());
}
if self.extent_right.is_some_and(|e| !(e > 0.0 && e <= MAX_LEN)) {
return Err("extentRight must be positive".into());
}
Ok(())
}
@@ -0,0 +1,156 @@
//! The Plane Switching Widget: a small cube in a corner of the document window whose faces pick
//! the active plane (left, horizontal, right) and whose dot picks none. It stays put on screen
//! when the view pans or zooms ([`ScreenFrame`]); without a window (headless) it sits in the first
//! artboard's corner. Perspective Grid Options (double-click the tool) hide it or move it to
//! another corner ([`WidgetOptions`], kept with the preferences).
use serde::{Deserialize, Serialize};
use vectorcraft_doc::Document;
use vectorcraft_geom::{Point, Vec2};
use super::{PerspectiveGrid, Plane, WidgetGeom, in_quad};
use crate::{ScreenFrame, ToolContext};
/// The widget's size and its margin from the window's edges, in screen pixels.
const SIZE: f64 = 40.0;
const MARGIN: f64 = 10.0;
/// Where the widget sits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WidgetCorner {
#[default]
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl WidgetCorner {
pub const ALL: [Self; 4] = [Self::TopLeft, Self::TopRight, Self::BottomLeft, Self::BottomRight];
pub fn id(self) -> &'static str {
match self {
Self::TopLeft => "topLeft",
Self::TopRight => "topRight",
Self::BottomLeft => "bottomLeft",
Self::BottomRight => "bottomRight",
}
}
pub fn label(self) -> &'static str {
match self {
Self::TopLeft => "Top Left",
Self::TopRight => "Top Right",
Self::BottomLeft => "Bottom Left",
Self::BottomRight => "Bottom Right",
}
}
/// By id or label, any case.
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s.trim()) || c.label().eq_ignore_ascii_case(s.trim()))
}
}
/// Perspective Grid Options: whether the Plane Switching Widget shows, and where.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct WidgetOptions {
pub show: bool,
pub position: WidgetCorner,
}
impl Default for WidgetOptions {
fn default() -> Self {
Self { show: true, position: WidgetCorner::TopLeft }
}
}
/// Where the widget is: the window it stays put in (none headless) and its corner.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct WidgetPlace<'a> {
pub screen: Option<&'a ScreenFrame>,
pub corner: WidgetCorner,
}
impl PerspectiveGrid {
/// The widget's geometry (`tol` = document units per screen pixel) at `place`.
pub fn widget_at(&self, doc: &Document, tol: f64, place: WidgetPlace) -> WidgetGeom {
let right = matches!(place.corner, WidgetCorner::TopRight | WidgetCorner::BottomRight);
let bottom = matches!(place.corner, WidgetCorner::BottomLeft | WidgetCorner::BottomRight);
let inset = MARGIN + SIZE / 2.0;
// Headless, the first artboard stands in for the window.
let frame = place.screen.copied().unwrap_or_else(|| {
let ab = super::define::first_artboard(doc);
let tol = tol.max(1e-9);
ScreenFrame {
origin: Point::new(ab.x0, ab.y0),
right: Vec2::new(tol, 0.0),
down: Vec2::new(0.0, tol),
size: (ab.width() / tol, ab.height() / tol),
}
});
let (w, h) = frame.size;
let c = Vec2::new(if right { w - inset } else { inset }, if bottom { h - inset } else { inset });
let p = |dx: f64, dy: f64| frame.at(c.x + dx * SIZE, c.y + dy * SIZE);
let faces = vec![
(Plane::Left, [p(-0.5, -0.25), p(0.0, 0.0), p(0.0, 0.5), p(-0.5, 0.25)]),
(Plane::Right, [p(0.0, 0.0), p(0.5, -0.25), p(0.5, 0.25), p(0.0, 0.5)]),
(Plane::Ground, [p(0.0, -0.5), p(0.5, -0.25), p(0.0, 0.0), p(-0.5, -0.25)]),
];
// The dot sits off the cube's corner toward the window's inside.
let (sx, sy) = (if right { -0.62 } else { 0.62 }, if bottom { -0.62 } else { 0.62 });
(faces, (p(sx, sy), 0.12 * SIZE * tol))
}
/// Which widget control is at `p` (see [`Self::widget_at`]).
pub fn widget_hit_at(&self, doc: &Document, tol: f64, place: WidgetPlace, p: Point) -> Option<Plane> {
let (faces, (c, r)) = self.widget_at(doc, tol, place);
if p.distance(c) <= r * 1.5 {
return Some(Plane::None);
}
faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl)
}
/// Which widget control the tool context's pointer `p` is on (none while the widget is hidden).
pub fn widget_hit_cx(&self, cx: &ToolContext, p: Point) -> Option<Plane> {
let corner = cx.plane_widget?;
self.widget_hit_at(cx.doc, cx.tol(1.0), WidgetPlace { screen: cx.screen.as_ref(), corner }, p)
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::Rect;
fn frame(origin: Point, zoom: f64) -> ScreenFrame {
ScreenFrame { origin, right: Vec2::new(1.0 / zoom, 0.0), down: Vec2::new(0.0, 1.0 / zoom), size: (600.0, 400.0) }
}
#[test]
fn the_widget_stays_put_on_screen() {
let d = Document::new(800.0, 600.0);
let g = PerspectiveGrid::normal(2, Rect::new(0.0, 0.0, 800.0, 600.0));
for corner in WidgetCorner::ALL {
// Panned and zoomed, the widget is at the same screen pixels.
let (a, b) = (frame(Point::new(0.0, 0.0), 1.0), frame(Point::new(250.0, -40.0), 4.0));
let (fa, _) = g.widget_at(&d, 1.0, WidgetPlace { screen: Some(&a), corner });
let (fb, _) = g.widget_at(&d, 0.25, WidgetPlace { screen: Some(&b), corner });
for ((_, qa), (_, qb)) in fa.iter().zip(&fb) {
for (pa, pb) in qa.iter().zip(qb) {
let px = (*pb - b.origin) * 4.0;
assert!((px - pa.to_vec2()).hypot() < 1e-9, "{corner:?}");
}
}
// Inside the window, near the chosen corner.
let x = fa[0].1[0].x;
assert!(if matches!(corner, WidgetCorner::TopRight | WidgetCorner::BottomRight) { x > 500.0 } else { x < 100.0 }, "{corner:?}");
let centre = |q: &[Point; 4]| Point::new(q.iter().map(|p| p.x).sum::<f64>() / 4.0, q.iter().map(|p| p.y).sum::<f64>() / 4.0);
assert_eq!(g.widget_hit_at(&d, 0.25, WidgetPlace { screen: Some(&b), corner }, centre(&fb[1].1)), Some(Plane::Right));
}
// Headless, the top-left one is in the first artboard's corner, as before.
let (faces, _) = g.widget(&d, 1.0);
assert_eq!(faces[0].1[0], Point::new(10.0, 20.0));
assert_eq!(WidgetCorner::parse("Bottom Left"), Some(WidgetCorner::BottomLeft));
assert_eq!(WidgetCorner::parse("middle"), None);
}
}
+27 -1
View File
@@ -34,7 +34,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use vectorcraft_color::Paint;
use vectorcraft_doc::{Document, NodeId, Selection, Unit};
use vectorcraft_geom::{BezPath, Point, Rect};
use vectorcraft_geom::{BezPath, Point, Rect, Vec2};
pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info};
@@ -182,6 +182,26 @@ impl Default for PaintDefaults {
}
}
/// The document window on screen, in document coordinates: widgets that stay put on screen (the
/// Plane Switching Widget) are placed with it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScreenFrame {
/// The window's top-left corner.
pub origin: Point,
/// One screen pixel to the right and one down.
pub right: Vec2,
pub down: Vec2,
/// The window's size in screen pixels.
pub size: (f64, f64),
}
impl ScreenFrame {
/// The document point `x`, `y` screen pixels from the window's top-left corner.
pub fn at(&self, x: f64, y: f64) -> Point {
self.origin + self.right * x + self.down * y
}
}
/// Read-only context a tool sees.
pub struct ToolContext<'a> {
pub doc: &'a Document,
@@ -229,6 +249,10 @@ pub struct ToolContext<'a> {
pub slices_hidden: bool,
/// View → Lock Slices: the Slice Selection tool leaves locked slices alone.
pub slices_locked: bool,
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<ScreenFrame>,
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
pub plane_widget: Option<distort::perspective::widget::WidgetCorner>,
}
impl ToolContext<'_> {
@@ -467,6 +491,8 @@ pub(crate) mod testutil {
paste_plain_text: false,
slices_hidden: false,
slices_locked: false,
screen: None,
plane_widget: Some(Default::default()),
}
}
}
+2
View File
@@ -43,6 +43,7 @@ pub mod package;
mod path_ops;
pub mod pdf_presets;
pub mod perspective_grid;
pub mod perspective_options;
pub mod perspective_plane;
pub mod perspective_presets;
pub mod place;
@@ -259,6 +260,7 @@ registry! {
Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC,
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
}
+13 -2
View File
@@ -321,7 +321,18 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
color(ui, d, "leftColor", "Left Grid:");
color(ui, d, "rightColor", "Right Grid:");
color(ui, d, "groundColor", "Horizontal Grid:");
let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8);
form::slider_w(ui, d, ("opacity", "Opacity:", LABEL), 0.0..=100.0, "%", &track);
// Opacity: a slider and its field, in the label column the rows above use.
widgets::label_row(ui, "Opacity:", LABEL, |ui| {
let v = d.f64("opacity", 50.0).clamp(0.0, 100.0);
let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8);
let mut new =
widgets::color_slider(ui, "persp-opacity", (v / 100.0) as f32, form::SLIDER_WIDTH, &track).0.map(|x| (f64::from(x) * 100.0).round());
if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) {
new = Some(x.round().clamp(0.0, 100.0));
}
if let Some(n) = new.filter(|n| *n != v) {
d.fields.insert("opacity".into(), json!(n));
}
});
false
}
@@ -0,0 +1,42 @@
//! Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane Switching
//! Widget shows and which corner of the window it sits in. OK runs `perspective.widget.options`
//! (the options live in the preferences).
//!
//! Fields: `show` (bool) and `position` (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`).
use serde_json::{Value, json};
use vectorcraft_tools::distort::perspective::widget::WidgetCorner;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Perspective Grid Options.
pub const KIND: &str = "perspectiveGridOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Perspective Grid Options".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
/// Open the dialog with the current options.
pub fn open(app: &mut VectorcraftApp) {
app.ui.dialog = Some(Dialog::new(KIND, json!(app.session.prefs.perspective_widget)));
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let r = app.run("perspective.widget.options", form::params(d));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let on = d.bool("show");
if widgets::check(ui, "Show Active Plane Widget", on, true) {
d.fields.insert("show".into(), json!(!on));
}
ui.add_space(8.0);
let corners: Vec<(&str, &str)> = WidgetCorner::ALL.iter().map(|c| (c.id(), c.label())).collect();
ui.add_enabled_ui(on, |ui| form::choice(ui, d, "position", "Widget Position:", (110.0, 160.0), &corners));
false
}
@@ -40,6 +40,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
},
ok: None,
min_width: 600.0,
max_width: Some(600.0),
..DialogSpec::FORM
};
@@ -183,3 +183,45 @@ fn perspective_grid_view_toggles_flip_their_labels_and_checks() {
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…"]);
}
#[test]
fn double_clicking_the_tool_opens_perspective_grid_options() {
let mut app = app();
let r = app.run("tool.options", json!({"tool": "perspectiveGrid"})).unwrap();
assert_eq!(r["dialog"], json!(super::perspective_options::KIND));
assert_eq!((field(&app, "show"), field(&app, "position")), (json!(true), json!("topLeft")));
frame(&mut app);
set(&mut app, "position", json!("bottomLeft"));
frame(&mut app);
super::confirm(&mut app).unwrap();
assert_eq!(kind(&app), None);
assert_eq!(app.session.prefs.perspective_widget.position.id(), "bottomLeft");
app.run("tool.options", json!({"tool": "perspectiveGrid"})).unwrap();
set(&mut app, "show", json!(false));
super::confirm(&mut app).unwrap();
assert!(!app.session.prefs.perspective_widget.show);
}
#[test]
fn digit_keys_switch_planes_and_the_widget_follows_the_window() {
let mut app = app();
app.run("perspective.grid.preset", json!({"kind": 2})).unwrap();
let key = |app: &mut VectorcraftApp, k: egui::Key| {
let ctx = egui::Context::default();
let press = egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let input = egui::RawInput { events: vec![press], ..Default::default() };
let mut out = ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx()));
out.textures_delta.clear();
};
key(&mut app, egui::Key::Num3);
assert_eq!(grid(&app).plane.id(), "right");
key(&mut app, egui::Key::Num2);
assert_eq!(grid(&app).plane.id(), "ground");
// The window the widget stays in: none before the canvas is laid out.
app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)));
let f = app.screen_frame().unwrap();
assert_eq!(f.size, (800.0, 600.0));
let z = app.view().unwrap().zoom;
assert!((f.right.x - 1.0 / z).abs() < 1e-9 && f.right.y.abs() < 1e-9);
assert_eq!(app.view_info().screen, Some(f));
}
+14
View File
@@ -393,9 +393,23 @@ impl VectorcraftApp {
show_bbox: self.ui.view.bounding_box,
snap_to_point: self.ui.view.snap_to_point,
corner_widgets: self.ui.view.corner_widgets,
screen: self.screen_frame(),
}
}
/// The canvas on screen in document coordinates (none before it is laid out).
pub fn screen_frame(&self) -> Option<vectorcraft_tools::ScreenFrame> {
let (rect, view) = (self.canvas_rect?, self.view()?);
let xf = canvas::Xf::new(rect, view);
let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y));
Some(vectorcraft_tools::ScreenFrame {
origin: xf.to_doc(rect.left_top()),
right: px(1.0, 0.0),
down: px(0.0, 1.0),
size: (f64::from(rect.width()), f64::from(rect.height())),
})
}
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if let Some(r) = menus::run_ui_command(self, id, &params) {
+1 -1
View File
@@ -1154,7 +1154,7 @@ 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))
app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc))
}
/// Label for toggles whose text flips (Outline/Preview, Hide/Show …).
+22
View File
@@ -252,6 +252,28 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
}
}
// Digits the active tool or the perspective grid takes (1–4 pick the plane while it shows).
if !(m.command || m.alt || m.ctrl) {
let digits: Vec<(u8, Key)> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, .. } => digit_of(*key).map(|d| (d, *key)),
_ => None,
})
.collect()
});
for (d, k) in digits {
if app.session.tool_claims_key(ToolKey::Digit(d), view) && ctx.input_mut(|i| i.consume_key(m, k)) {
// The digit's text is the key's too: no single-key shortcut sees it.
let text = d.to_string();
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(t) if *t == text)));
let r = app.session.tool_key(ToolKey::Digit(d), crate::canvas::mods(m, false), view);
crate::canvas::apply_requests(app, r);
return;
}
}
}
// Command shortcuts.
let mut fire = None;
for (sc, id, p) in all_shortcuts() {
+4
View File
@@ -215,6 +215,10 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
crate::dialogs::blend_options::open(app)?;
Ok(json!({ "dialog": crate::dialogs::blend_options::KIND }))
}
"perspectiveGrid" => {
crate::dialogs::perspective_options::open(app);
Ok(json!({ "dialog": crate::dialogs::perspective_options::KIND }))
}
// A double click on the Print Tiling tool puts the pages back where the placement puts them.
"printTiling" => app.run("print.tiling.set", json!({ "reset": true })),
// The Liquify tools: their Tool Options (the Global Brush Dimensions and the tool's own).
+6
View File
@@ -520,3 +520,9 @@ 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`.
Perspective grid widgets: the Plane Switching Widget is fixed in a corner of the canvas (`ui.pointer` with
`space: "screen"` reaches it at the same pixels whatever the zoom); a press on it with any tool picks the plane while
the grid shows. `ui.key` with `key` `"1"`…`"4"` picks the left, horizontal, right or no plane. Double-clicking the
Perspective Grid tool (`tool.options {tool: "perspectiveGrid"}`) opens `perspectiveGridOptions` (fields `show`,
`position`: `topLeft`, `topRight`, `bottomLeft`, `bottomRight`); `ui.dialog.confirm` runs `perspective.widget.options`.
+12
View File
@@ -1807,6 +1807,18 @@ drawn corners, and `perspective.move` the nearer edge, on gridlines within a qua
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.
Perspective grid widgets: with the Perspective Grid tool (`pointer_gesture`), the vanishing points (Shift keeps the
horizon; with Lock Station Point the other one swings), the horizon, the third vanishing point, the origin, the left
and right ground-level points (24 px from the origin along each wall's ground line: they move the whole grid; Shift
keeps one axis), the left and right extents (separate: `extent` and `extentRight`; Alt drags both, Shift goes by
whole cells), the vertical extent and the cell size widget (on the line where the planes meet, a cell up, or more
cells while they're small) reshape the grid; Lock Grid stops them. The Plane Switching Widget stays put in a corner
of the document window (headless: the first artboard's top-left corner); a press on it picks the plane with any tool
while the grid shows (a perspective tool shows it), and the keys 1–4 (`key` with `"1"`…`"4"`) pick the left,
horizontal, right and no plane. `perspective.widget.options {show?, position?}` (Perspective Grid Options,
double-click the tool) hides it or moves it to `topLeft`, `topRight`, `bottomLeft` or `bottomRight`, kept with the
preferences (`prefs.get`/`prefs.set` key `perspectiveWidget`).
## Envelopes
Object → Envelope Distort wraps the selection in a live envelope: `object.envelope.makeWithWarp` (`style`: arc,