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}`")),
}
}