From 0889f13cd8d2c2c29b6750f74849234fb609186d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 11:41:58 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/perspgrid.rs | 62 +++++ crates/engine/src/cmd/prefscmds.rs | 14 +- crates/engine/src/lib.rs | 7 + crates/engine/src/tests_perspgrid.rs | 174 ++++++++++++++ crates/engine/src/tooling.rs | 21 +- crates/tools/src/distort/perspective.rs | 213 +++++++++++++----- .../tools/src/distort/perspective/define.rs | 3 + .../tools/src/distort/perspective/widget.rs | 156 +++++++++++++ crates/tools/src/lib.rs | 28 ++- crates/ui-egui/src/dialogs/mod.rs | 2 + .../ui-egui/src/dialogs/perspective_grid.rs | 15 +- .../src/dialogs/perspective_options.rs | 42 ++++ .../src/dialogs/perspective_presets.rs | 1 + .../ui-egui/src/dialogs/tests_perspective.rs | 42 ++++ crates/ui-egui/src/lib.rs | 14 ++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 22 ++ crates/ui-egui/src/toolbar.rs | 4 + docs/control-protocol.md | 6 + docs/mcp.md | 12 + 20 files changed, 771 insertions(+), 69 deletions(-) create mode 100644 crates/tools/src/distort/perspective/widget.rs create mode 100644 crates/ui-egui/src/dialogs/perspective_options.rs diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index 6859f8058..e272dce41 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -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 { 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 { + 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>> { + 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 { + 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) + } +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 9e9cb2bc3..80cde8688 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -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 { @@ -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::(merged) + .map(|o| json!(o)) + .map_err(|e| e.to_string()) + } _ => Err(format!("unknown preference `{key}`")), } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7685dfbe3..fc6692fdb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -501,6 +501,9 @@ pub struct Prefs { /// preferences keeps it. #[serde(skip_serializing_if = "Option::is_none")] pub blend_options: Option, + /// 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>, + /// 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, } } diff --git a/crates/engine/src/tests_perspgrid.rs b/crates/engine/src/tests_perspgrid.rs index a1e86eb66..8061b137f 100644 --- a/crates/engine/src/tests_perspgrid.rs +++ b/crates/engine/src/tests_perspgrid.rs @@ -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()); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index e4e90ad6c..738929de8 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -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, } 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> { 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> { + // 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 { 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 } diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 4e893ac7e..8256ac36f 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -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, } 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 { 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 { + 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) -> Vec { 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 { 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, + drag: Option, /// Dragging a plane widget. plane: Option, } @@ -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 { 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) { diff --git a/crates/tools/src/distort/perspective/define.rs b/crates/tools/src/distort/perspective/define.rs index 65f6fdc19..a2dd6f424 100644 --- a/crates/tools/src/distort/perspective/define.rs +++ b/crates/tools/src/distort/perspective/define.rs @@ -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(()) } diff --git a/crates/tools/src/distort/perspective/widget.rs b/crates/tools/src/distort/perspective/widget.rs new file mode 100644 index 000000000..4af1173e8 --- /dev/null +++ b/crates/tools/src/distort/perspective/widget.rs @@ -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::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 { + 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 { + 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::() / 4.0, q.iter().map(|p| p.y).sum::() / 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); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 26da4e879..9cf2fb061 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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, + /// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden. + pub plane_widget: Option, } 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()), } } } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9946bcded..94c5e53d8 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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, } diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index 8cbe4e179..92123945c 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -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 } diff --git a/crates/ui-egui/src/dialogs/perspective_options.rs b/crates/ui-egui/src/dialogs/perspective_options.rs new file mode 100644 index 000000000..21936c441 --- /dev/null +++ b/crates/ui-egui/src/dialogs/perspective_options.rs @@ -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 { + 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 +} diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index 39940635f..52d8c2e6e 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -40,6 +40,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { }, ok: None, min_width: 600.0, + max_width: Some(600.0), ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index 3b3d994dd..ee9332297 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -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)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bdbbbfbac..21ad7cc3e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 { + 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 { if let Some(r) = menus::run_ui_command(self, id, ¶ms) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c9077f70e..b00800ceb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1154,7 +1154,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { /// The active document's perspective grid (View → Perspective Grid toggles). fn perspective_grid(app: &VectorcraftApp) -> Option { - app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::effective(&d.doc)) + app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc)) } /// Label for toggles whose text flips (Outline/Preview, Hide/Show …). diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ddfd0ea15..3cbd512ed 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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() { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 525369429..8d782dfdf 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -215,6 +215,10 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result { + 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). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c4b2ac358..ace70e7f2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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`. diff --git a/docs/mcp.md b/docs/mcp.md index 2acd4f115..cd0c05d3a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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,