mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Merge pull request #201 from storytold/m8-perspective-widgets
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
This commit is contained in:
+1
-1
@@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes, M8.7 Width tool on compound paths, every subpath, cursors, M8.20 blend interpolation fidelity, knockout, Expand/Release keep attributes, M8.16 envelopes bend images, symbols, gradients, patterns, appearance; warps keep their frame, M8.14 type and symbols really projected with Edit Text, attachments survive copies) | 8–12 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes, M8.7 Width tool on compound paths, every subpath, cursors, M8.20 blend interpolation fidelity, knockout, Expand/Release keep attributes, M8.16 envelopes bend images, symbols, gradients, patterns, appearance; warps keep their frame, M8.14 type and symbols really projected with Edit Text, attachments survive copies, M8.11 grid widgets, screen-fixed Plane Switching Widget with 1-4 keys) | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
|
||||
@@ -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 …).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user