vectorcraft/crates/engine/src/cmd/perspgrid.rs

482 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! View → Perspective Grid → Define Grid: `perspective.grid.get` reads the grid (the model, the
//! Define Grid fields and the station point) and `perspective.grid.define` sets it from those
//! fields. The model and the camera maths live in `vectorcraft_tools::distort::perspective`.
//!
//! Presets: the built-in views (generated in code, fitted to the first artboard, protected) and
//! the user's, saved with the preferences ([`crate::Prefs::perspective_presets`]) as Define Grid
//! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them.
//!
//! View options (Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) are view state on
//! the grid: they toggle without an undo step, as Show Grid does.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_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::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "perspective.grid.get",
"Perspective Grid",
[],
None,
"{} → {grid: the model (as perspective.grid.set takes it), define: the Define Grid fields (as perspective.grid.define takes them), station: {x, y, distance} (the viewer: centre of vision on the horizon, distance to the picture plane, pt), defined: false while the document uses the default grid}",
has_doc,
grid_get
),
cmd!(
"perspective.grid.define",
"Define Perspective Grid",
[],
None,
"{name?: preset name (\"\" = custom), kind?: 1|2|3, units?: unit name (points, pixels, inches, centimeters, millimeters…), scale?: [artboard, real world], gridline?: gridline every, angle?: viewing angle (0–90°), distance?: viewing distance, horizonHeight?: above the ground level, thirdVp?: [x right, y up] from the centre of vision (3-point), leftColor?, rightColor?, groundColor?: \"#rrggbb\", opacity?: 0–100} lengths are real-world lengths in `units` at `scale`; missing fields keep the grid's (see perspective.grid.get define). Only what changes changes: a new type, angle or distance moves the vanishing points around the station point. One undo step → the new define fields",
has_doc,
grid_define
),
cmd!(
query "perspective.presets.list",
"Perspective Grid Presets",
[],
None,
"{} → {presets: [{builtIn, name, kind, …the perspective.grid.define fields}]} the built-in views (fitted to the first artboard, protected) first, then the saved presets",
always,
presets_list
),
cmd!(
query "perspective.presets.save",
"Save Perspective Grid Preset",
[],
None,
"{name?: (default: a new \"Perspective Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else the document's grid), …the perspective.grid.define fields (over it)} create or change a saved preset (Save Grid as Preset: just `name`); built-in ones are protected → {name, created, definition}",
always,
presets_save
),
cmd!(
query "perspective.presets.delete",
"Delete Perspective Grid Preset",
[],
None,
"{name} delete a saved preset (built-in ones stay) → {deleted: name}",
always,
presets_delete
),
cmd!(
query "perspective.presets.export",
"Export Perspective Grid Presets",
[],
None,
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcperspective file (JSON) → {path, count}; without path → {data: the file's text, count}",
always,
presets_export
),
cmd!(
query "perspective.presets.import",
"Import Perspective Grid Presets",
[],
None,
"{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else imported ones whose name is taken get a number)} add the presets of a .vcperspective file (as perspective.presets.export writes) to the saved ones → {imported: [names]}",
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",
[],
None,
"{on?: bool} (no param toggles) View › Perspective Grid › Lock Grid: the grid's widgets can't be dragged (the Plane Switching Widget still works). View state: not an undo step → {on}",
has_doc,
lock
),
cmd!(
"perspective.grid.lockStation",
"Lock Station Point",
[],
None,
"{on?: bool} (no param toggles) dragging one vanishing point moves the other around the station point (the viewing angle turns, the viewer stays). View state: not an undo step → {on}",
has_doc,
lock_station
),
cmd!(
"perspective.grid.snap",
"Snap to Grid",
[],
None,
"{on?: bool} (no param toggles) art drawn or moved in perspective lands on gridlines within a quarter cell (on by default). View state: not an undo step → {on}",
has_doc,
snap
),
cmd!(
"perspective.grid.rulers",
"Show Rulers",
[],
None,
"{on?: bool} (no param toggles) a ruler up the line where the planes meet, in the grid's units at its scale. View state: not an undo step → {on}",
has_doc,
rulers
),
]
}
/// The `format` of a perspective grid presets file, also its extension.
pub const PRESET_FORMAT: &str = "vcperspective";
/// The extensions of perspective grid presets files (opening one imports it).
pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
/// The most presets one file imports.
const MAX_IMPORT: usize = 1000;
/// What `perspective.presets.export` writes and `perspective.presets.import` reads.
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
#[serde(default)]
presets: Vec<Value>,
}
impl Session {
/// The artboard presets are fitted to: the active document's first (a Letter page without).
fn preset_artboard(&self) -> Rect {
self.active().map_or(LETTER, |d| first_artboard(&d.doc))
}
/// Every perspective grid preset: the built-in views, then the saved ones.
pub fn perspective_presets(&self) -> Vec<GridDefinition> {
let ab = self.preset_artboard();
let builtins = BUILTINS.iter().filter_map(|b| PerspectiveGrid::builtin(b.0, ab)).map(|g| g.definition());
builtins.chain(self.prefs.perspective_presets.iter().cloned()).collect()
}
/// The preset `name` (any case): a built-in view or a saved one.
pub fn perspective_preset(&self, name: &str) -> Option<GridDefinition> {
match PerspectiveGrid::builtin(name, self.preset_artboard()) {
Some(g) => Some(g.definition()),
None => self.persp_saved_index(name).and_then(|i| self.prefs.perspective_presets.get(i)).cloned(),
}
}
/// The grid preset `name` makes on `ab`: a built-in view, or a saved preset's fields over the
/// normal view of its type.
pub fn perspective_preset_grid(&self, name: &str, ab: Rect) -> Option<PerspectiveGrid> {
if let Some(g) = PerspectiveGrid::builtin(name, ab) {
return Some(g);
}
let def = self.perspective_preset(name)?;
PerspectiveGrid::normal(def.kind, ab).with_definition(&def).ok()
}
/// The first free "Perspective Preset N": the name a new preset gets.
pub fn new_perspective_preset_name(&self) -> String {
(1..).map(|i| format!("Perspective Preset {i}")).find(|n| !self.persp_preset_taken(n, None)).unwrap_or_default()
}
/// The index of the saved preset `name` (any case).
fn persp_saved_index(&self, name: &str) -> Option<usize> {
self.prefs.perspective_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
}
/// Whether `name` is a built-in view's or a saved preset's other than the one at `except`.
fn persp_preset_taken(&self, name: &str, except: Option<usize>) -> bool {
is_builtin(name) || self.persp_saved_index(name).is_some_and(|i| Some(i) != except)
}
/// The name of the first preset among `candidates` whose fields `def` has ("" for none).
fn preset_named(&self, def: &GridDefinition, candidates: &[&str]) -> String {
candidates
.iter()
.filter(|n| !n.is_empty())
.find_map(|n| self.perspective_preset(n).filter(|p| p.same(def)))
.map(|p| p.name)
.unwrap_or_default()
}
}
/// What `perspective.grid.get` reports for `g`.
pub(crate) fn grid_info(doc: &vectorcraft_doc::Document, g: &PerspectiveGrid) -> Value {
let st = g.station();
json!({
"grid": g.definition_json(),
"define": g.definition(),
"station": {"x": st.x, "y": g.horizon, "distance": st.distance},
"defined": PerspectiveGrid::from_doc(doc).is_some(),
})
}
fn grid_get(s: &mut Session, _: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
Ok(grid_info(doc, &grid_of(doc)))
}
fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.define";
let old = grid_of(&s.doc()?.doc);
let def: GridDefinition = old.definition().merged(p).map_err(|e| bad(C, e))?;
let mut g = old.with_definition(&def).map_err(|e| bad(C, e))?;
// The grid is the preset it was given or came from while it has that preset's fields.
g.name = s.preset_named(&g.definition(), &[&def.name, &old.name]);
if g != old {
s.edit("Define Perspective Grid", |d, _| {
store_grid(d, &g);
Ok(())
})?;
}
Ok(json!(g.definition()))
}
/// `perspective.grid.preset`: reset the grid to a preset fitted to the first artboard (shown;
/// the attached objects stay attached).
pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.preset";
let name = match (str_param(p, "name"), p.get("kind").and_then(Value::as_u64)) {
(Some(n), _) => n.to_string(),
(None, Some(k @ 1..=3)) => PerspectiveGrid::normal(k as u8, LETTER).name,
_ => return Err(bad(C, "kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list)")),
};
let old = grid_of(&s.doc()?.doc);
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?;
// The objects stay attached and the view options stay as they were.
let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g };
s.edit("Perspective Grid Preset", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(g.definition_json())
}
fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
let rows: Vec<Value> = s
.perspective_presets()
.into_iter()
.map(|d| {
let mut v = json!(d);
v["builtIn"] = json!(is_builtin(&d.name));
v
})
.collect();
Ok(json!({ "presets": rows }))
}
fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.presets.save";
let name = match str_param(p, "name").map(str::trim) {
Some("") => return Err(bad(C, "`name` is empty")),
Some(n) => n.to_string(),
None => s.new_perspective_preset_name(),
};
if is_builtin(&name) {
return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name")));
}
let at = s.persp_saved_index(&name);
let saved = at.and_then(|i| s.prefs.perspective_presets.get(i)).cloned();
// A saved preset changes from its own fields unless `preset` says where to start.
let base = match (str_param(p, "preset"), &saved) {
(Some(b), _) => {
s.perspective_preset(b).ok_or_else(|| bad(C, format!("no perspective grid preset named `{b}` (see perspective.presets.list)")))?
}
(None, Some(old)) => old.clone(),
(None, None) => s.active().map(|d| grid_of(&d.doc).definition()).unwrap_or_default(),
};
let mut def = base.merged(p).map_err(|e| bad(C, e))?;
def.name = match str_param(p, "newName").map(str::trim) {
Some("") => return Err(bad(C, "`newName` is empty")),
Some(n) if s.persp_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
Some(n) => n.to_string(),
None => saved.map_or(name, |old| old.name),
};
let r = json!({ "name": def.name, "created": at.is_none(), "definition": def });
match at.and_then(|i| s.prefs.perspective_presets.get_mut(i)) {
Some(slot) => *slot = def,
None => s.prefs.perspective_presets.push(def),
}
Ok(r)
}
fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.presets.delete";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
if is_builtin(name) {
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
}
let i = s.persp_saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?;
Ok(json!({ "deleted": s.prefs.perspective_presets.remove(i).name }))
}
fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.presets.export";
let presets = match p.get("names").and_then(Value::as_array) {
Some(names) => {
let find = |n: &Value| {
let n = n.as_str().unwrap_or_default();
s.perspective_preset(n).ok_or_else(|| bad(C, format!("no perspective grid preset named `{n}`")))
};
names.iter().map(find).collect::<Result<Vec<_>>>()?
}
None => s.prefs.perspective_presets.clone(),
};
if presets.is_empty() {
return Err(bad(C, "no saved presets to export (name built-in ones in `names`)"));
}
let count = presets.len();
let presets = presets.iter().map(|d| json!(d)).collect();
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?;
match str_param(p, "path") {
Some(path) => {
super::fileio::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "count": count}))
}
None => Ok(json!({"data": text, "count": count})),
}
}
fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.presets.import";
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file(path)?,
(None, Some(text), _) => text.as_bytes().to_vec(),
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?,
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a perspective grid presets file: {e}")))?;
if file.format != PRESET_FORMAT {
return Err(bad(C, format!("not a perspective grid presets file (format `{}`)", file.format)));
}
if file.presets.len() > MAX_IMPORT {
return Err(bad(C, format!("more than {MAX_IMPORT} presets")));
}
// Every preset is checked as Define Grid checks its fields before any is added.
let base = GridDefinition::default();
let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?"))));
let presets = file.presets.iter().map(check).collect::<Result<Vec<_>>>()?;
let replace = bool_or(p, "replace", false);
let mut imported = vec![];
for mut preset in presets {
// A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays.
let name = preset.name.trim().trim_start_matches('[').trim_end_matches(']').trim();
let base = Some(name).filter(|n| !n.is_empty()).unwrap_or("Perspective Preset").to_string();
match s.persp_saved_index(&base).filter(|_| replace) {
Some(i) => {
if let Some(slot) = s.prefs.perspective_presets.get_mut(i) {
preset.name = slot.name.clone();
imported.push(preset.name.clone());
*slot = preset;
}
}
None => {
preset.name = unique_name(&base, |n| s.persp_preset_taken(n, None));
imported.push(preset.name.clone());
s.prefs.perspective_presets.push(preset);
}
}
}
Ok(json!({ "imported": imported }))
}
/// Toggle (or set, with `on`) a view option of the grid.
fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
let want = p.get("on").and_then(Value::as_bool);
let mut g = silent(s, |g| {
let f = field(g);
*f = want.unwrap_or(!*f);
})?;
Ok(json!({ "on": *field(&mut g) }))
}
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.locked)
}
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.lock_station)
}
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.snap)
}
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.rulers)
}
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
/// the plane and depth of the first one and the plane-space correction that lands the edge of the
/// joint bounds of the objects there nearer a gridline on it.
pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> {
let g = grid_of(doc);
if !bool_or(p, "snap", g.snap) {
return None;
}
let (plane, depth, b) = g.plane_bounds(doc, ids)?;
let dv = g.plane_delta(plane, depth, from, to)?;
Some((plane, depth, g.snap_offset(b, dv) - dv))
}
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.visible {
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).visible.then_some(plane)
}
}