M8.9: Perspective grid presets: built-in views, Save Grid as Preset, presets manager

View > Perspective Grid > One/Two/Three Point Perspective list the built-in views
([1P/2P/3P-Normal View], plus Low and High views generated in code) and the saved
presets of each type; Save Grid as Preset... and Define Grid's Preset menu and Save
Preset... save the fields. Edit > Perspective Grid Presets... (previously a
mislabelled reset to the two-point grid) manages them: New, Edit, Delete, Import,
Export (.vcperspective), stored with the preferences. New commands
perspective.presets.list/save/delete/export/import; perspective.grid.preset takes
a preset name. Preset import is fuzzed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-06 10:51:12 +07:00
co-authored by Claude Opus 5.5
parent 3d850a2864
commit c3c4185cef
14 changed files with 1044 additions and 42 deletions
+2 -18
View File
@@ -87,9 +87,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Perspective Grid Preset",
[],
None,
"{kind: 1|2|3} reset the grid to the one/two/three-point preset for the first artboard (and show it)",
"{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached)",
has_doc,
grid_preset
super::perspgrid::grid_preset
),
cmd!(
"perspective.grid.show",
@@ -477,22 +477,6 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
Ok(g.definition_json())
}
fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
let kind = p
.get("kind")
.and_then(Value::as_u64)
.filter(|k| (1..=3).contains(k))
.ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8;
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 = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) };
s.edit("Perspective Grid Preset", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(g.definition_json())
}
fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
let want = p.get("visible").and_then(Value::as_bool);
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
+276 -4
View File
@@ -1,8 +1,15 @@
//! 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.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin};
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
use super::distortcmds::{grid_of, store_grid};
@@ -28,9 +35,128 @@ pub fn specs() -> Vec<CommandSpec> {
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
),
]
}
/// 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();
@@ -52,10 +178,8 @@ fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
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))?;
// A changed grid is no longer the preset it came from (unless it was given a name).
if def.name == old.name && !def.same(&old.definition()) {
g.name.clear();
}
// 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);
@@ -64,3 +188,151 @@ fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
}
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}`")))?;
let g = PerspectiveGrid { attached: old.attached, ..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 }))
}