From c3c4185cefa9bd3fd8779b2c942f1852c668e2a8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 10:04:03 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/distortcmds.rs | 20 +- crates/engine/src/cmd/perspgrid.rs | 280 +++++++++++++++++- crates/engine/src/lib.rs | 6 + crates/engine/src/tests_perspgrid.rs | 97 ++++++ crates/engine/tests/import_fuzz.rs | 31 ++ .../tools/src/distort/perspective/define.rs | 5 + crates/ui-egui/src/dialogs/mod.rs | 2 + .../ui-egui/src/dialogs/perspective_grid.rs | 149 +++++++++- .../src/dialogs/perspective_presets.rs | 223 ++++++++++++++ .../ui-egui/src/dialogs/tests_perspective.rs | 82 +++++ crates/ui-egui/src/io.rs | 5 +- crates/ui-egui/src/menus.rs | 161 +++++++++- docs/control-protocol.md | 12 + docs/mcp.md | 15 + 14 files changed, 1045 insertions(+), 43 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/perspective_presets.rs diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 2963649b..a493ca9f 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -87,9 +87,9 @@ pub fn specs() -> Vec { "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 { Ok(g.definition_json()) } -fn grid_preset(s: &mut Session, p: &Value) -> Result { - 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 { let want = p.get("visible").and_then(Value::as_bool); let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?; diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index f27e84d8..9b61862c 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -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 { 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, +} + +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 { + 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 { + 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 { + 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 { + 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) -> 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 { 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 { } 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 { + 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 { + let rows: Vec = 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 { + 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 { + 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 { + 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::>>()? + } + 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 { + 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::>>()?; + 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 })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b321c760..206b07af 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -482,6 +482,11 @@ pub struct Prefs { /// resetting the preferences keeps them; `tool.setOption` edits them. #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub tool_settings: std::collections::BTreeMap>, + /// View → Perspective Grid presets: the user's (the built-in ones aren't stored). A local + /// library, not a Preferences dialog field: resetting the preferences keeps it; + /// `perspective.presets.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub perspective_presets: Vec, } impl Default for Prefs { @@ -616,6 +621,7 @@ impl Default for Prefs { print_presets: vec![], constrain_proportions: false, tool_settings: Default::default(), + perspective_presets: vec![], } } } diff --git a/crates/engine/src/tests_perspgrid.rs b/crates/engine/src/tests_perspgrid.rs index 5aeb0b7a..a9e63485 100644 --- a/crates/engine/src/tests_perspgrid.rs +++ b/crates/engine/src/tests_perspgrid.rs @@ -111,3 +111,100 @@ fn grid_definition_fields_survive_a_native_round_trip() { let g: PerspectiveGrid = serde_json::from_value(old).unwrap(); assert_eq!((g.angle, g.units.as_str(), g.scale, g.opacity), (None, "points", [1.0, 1.0], 50.0)); } + +// ---------- M8.9: presets ---------- + +fn preset_names(s: &mut Session) -> Vec { + let r = run(s, "perspective.presets.list", json!({})); + r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn built_in_views_apply_by_name_or_type() { + let mut s = session(); + let names = preset_names(&mut s); + assert_eq!(&names[..3], ["[1P-Normal View]", "[1P-Low View]", "[1P-High View]"]); + assert!(names.contains(&"[3P-Normal View]".to_string())); + let r = run(&mut s, "perspective.presets.list", json!({})); + assert!(r["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == json!(true))); + run(&mut s, "perspective.grid.preset", json!({"name": "[2P-low view]"})); + let low = grid(&s); + assert_eq!((low.name.as_str(), low.kind, low.visible), ("[2P-Low View]", 2, true)); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let normal = grid(&s); + assert_eq!(normal.name, "[2P-Normal View]"); + assert!(low.origin[1] - low.horizon < normal.origin[1] - normal.horizon, "the low view's horizon is nearer the ground"); + assert!(s.execute("perspective.grid.preset", &json!({"name": "[9P-Fisheye]"})).is_err()); + assert!(s.execute("perspective.grid.preset", &json!({})).is_err()); +} + +#[test] +fn presets_save_rename_delete_and_protect_the_built_in_ones() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 3})); + run(&mut s, "perspective.grid.define", json!({"angle": 35, "opacity": 30})); + // Save Grid as Preset: the document's grid under a name, not an undo step. + let n = undo_len(&s); + let r = run(&mut s, "perspective.presets.save", json!({"name": "Tower"})); + assert_eq!((r["name"].clone(), r["created"].clone(), r["definition"]["kind"].clone()), (json!("Tower"), json!(true), json!(3))); + assert_eq!(undo_len(&s), n); + let saved = &s.prefs.perspective_presets[0]; + assert!((saved.angle - 35.0).abs() < 1e-9 && saved.opacity == 30.0); + // A grid with its fields is that preset; changing one makes it custom again. + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + run(&mut s, "perspective.grid.preset", json!({"name": "tower"})); + assert_eq!(grid(&s).name, "Tower"); + assert!((grid(&s).viewing_angle() - 35.0).abs() < 1e-9); + run(&mut s, "perspective.grid.define", json!({"opacity": 31})); + assert_eq!(grid(&s).name, ""); + run(&mut s, "perspective.grid.define", json!({"name": "Tower", "opacity": 30})); + assert_eq!(grid(&s).name, "Tower"); + // Edit, rename, start from another preset. + let r = run(&mut s, "perspective.presets.save", json!({"name": "Tower", "newName": "Spire", "gridline": 12})); + assert_eq!((r["name"].clone(), r["created"].clone()), (json!("Spire"), json!(false))); + assert_eq!(s.prefs.perspective_presets[0].gridline, 12.0); + assert_eq!(run(&mut s, "perspective.presets.save", json!({"preset": "[1P-Normal View]"}))["name"], json!("Perspective Preset 1")); + assert_eq!(s.prefs.perspective_presets[1].kind, 1); + // Built-in names are protected and names stay unique. + assert!(s.execute("perspective.presets.save", &json!({"name": "[2P-Normal View]"})).is_err()); + assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "newName": "[1P-Low View]"})).is_err()); + assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "newName": "perspective preset 1"})).is_err()); + assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "angle": 95})).is_err()); + assert!(s.execute("perspective.presets.delete", &json!({"name": "[3P-Low View]"})).is_err()); + assert_eq!(run(&mut s, "perspective.presets.delete", json!({"name": "spire"}))["deleted"], json!("Spire")); + assert!(s.execute("perspective.presets.delete", &json!({"name": "Spire"})).is_err()); + assert_eq!(preset_names(&mut s).last().unwrap(), "Perspective Preset 1"); +} + +#[test] +fn presets_export_import_and_live_in_the_preferences() { + let mut s = session(); + run(&mut s, "perspective.presets.save", json!({"name": "Street", "units": "inches", "gridline": 1, "distance": 8, "horizonHeight": 4})); + let text = run(&mut s, "perspective.presets.export", json!({"names": ["Street", "[2P-High View]"]}))["data"].as_str().unwrap().to_string(); + assert!(text.contains("vcperspective")); + let mut t = session(); + let r = run(&mut t, "perspective.presets.import", json!({"data": text})); + assert_eq!(r["imported"], json!(["Street", "2P-High View"]), "a built-in view comes in as a saved copy"); + assert_eq!(t.prefs.perspective_presets[0], s.prefs.perspective_presets[0]); + // Again: names in use get a number, or replace. + assert_eq!(run(&mut t, "perspective.presets.import", json!({"data": text}))["imported"], json!(["Street 2", "2P-High View 2"])); + assert_eq!(run(&mut t, "perspective.presets.import", json!({"data": text, "replace": true}))["imported"], json!(["Street", "2P-High View"])); + assert_eq!(t.prefs.perspective_presets.len(), 4); + // Bad files are refused whole. + for bad in [ + json!({"data": "{}"}), + json!({"data": "{\"format\": \"vcprintpresets\", \"presets\": []}"}), + json!({"data": "{\"format\": \"vcperspective\", \"presets\": [{\"name\": \"ok\"}, {\"name\": \"x\", \"angle\": 120}]}"}), + json!({"data": "{\"format\": \"vcperspective\", \"presets\": [{\"kind\": \"two\"}]}"}), + json!({"dataBase64": "%%%"}), + json!({}), + ] { + assert!(t.execute("perspective.presets.import", &bad).is_err(), "{bad}"); + } + assert_eq!(t.prefs.perspective_presets.len(), 4); + assert!(Session::new().execute("perspective.presets.export", &json!({})).is_err(), "nothing saved to export"); + // Saved with the preferences; older preferences have none. + let back: Prefs = serde_json::from_value(serde_json::to_value(&t.prefs).unwrap()).unwrap(); + assert_eq!(back.perspective_presets, t.prefs.perspective_presets); + assert!(serde_json::from_value::(json!({})).unwrap().perspective_presets.is_empty()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 9106405b..6c58f17f 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1204,3 +1204,34 @@ proptest! { print_presets("mutated print presets", &mutate_text(&text, cut, &edits))?; } } + +// ---------- perspective grid presets files ---------- + +/// Import perspective grid presets from `data`, then apply each one and draw on its grid. +fn perspective_presets(what: &str, data: &str) -> Result<(), TestCaseError> { + survive_library(what, "perspective.presets.import", data, |s, r| { + for name in r["imported"].as_array().into_iter().flatten() { + let _ = s.execute("perspective.grid.preset", &json!({"name": name})); + let _ = s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3})); + let _ = s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}})); + } + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn perspective_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcperspective\", ", "{\"format\": \"vcperspective\", \"presets\": [{\"name\": \"x\", \"kind\": 3, \"scale\": "])) { + perspective_presets("perspective presets garbage", &format!("{head}{s}"))?; + } + + #[test] + fn mutated_perspective_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { + let mut s = rich_session(); + s.execute("perspective.presets.save", &json!({"name": "Tall", "kind": 3, "units": "inches", "scale": [1, 4], "angle": 25, "thirdVp": [1, 30]})).unwrap(); + s.execute("perspective.presets.save", &json!({"name": "Flat", "preset": "[1P-Low View]", "gridline": 4, "groundColor": "#00ff00"})).unwrap(); + let text = s.execute("perspective.presets.export", &json!({"names": ["Tall", "Flat", "[2P-High View]"]})).unwrap()["data"].as_str().unwrap().to_string(); + perspective_presets("mutated perspective presets", &mutate_text(&text, cut, &edits))?; + } +} diff --git a/crates/tools/src/distort/perspective/define.rs b/crates/tools/src/distort/perspective/define.rs index 87594a57..65f6fdc1 100644 --- a/crates/tools/src/distort/perspective/define.rs +++ b/crates/tools/src/distort/perspective/define.rs @@ -38,6 +38,11 @@ pub const BUILTINS: [(&str, u8, f64); 8] = [ ("[3P-Low View]", 3, 0.66), ]; +/// Is `name` a built-in preset's (any case)? +pub fn is_builtin(name: &str) -> bool { + BUILTINS.iter().any(|b| b.0.eq_ignore_ascii_case(name.trim())) +} + /// The first artboard of `doc` (a Letter page without one). pub fn first_artboard(doc: &Document) -> Rect { doc.artboards.first().map_or(LETTER, |a| a.rect) diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 3d4adef6..5c714c8b 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -42,6 +42,7 @@ pub mod package; mod path_ops; pub mod pdf_presets; pub mod perspective_grid; +pub mod perspective_presets; pub mod place; pub mod placement_options; pub mod plugin; @@ -255,6 +256,7 @@ registry! { PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC, LiquifyOptions: [liquify::KIND] => liquify::SPEC, + PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index 0337e786..8cbe4e17 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -3,11 +3,18 @@ //! colours and opacity, prefilled from the grid (`perspective.grid.get`). OK runs //! `perspective.grid.define`: one undo step, nothing when nothing changed. //! -//! Fields: those of `perspective.grid.define` (lengths are real-world lengths in `units`). +//! The Preset menu loads a preset's fields (it reads [Custom] once they differ); Save Preset… +//! asks for a name and saves the fields as a preset (`perspective.presets.save`), then comes back. +//! The same dialog is View → Perspective Grid → Save Grid as Preset… and the preset editor of +//! Edit → Perspective Grid Presets (New…, Edit…), whose OK returns to the presets. +//! +//! Fields: those of `perspective.grid.define` (lengths are real-world lengths in `units`; `name` +//! the preset), plus `__mode` (`save`, `edit` or none for Define Grid), `__from` (`define`: Save +//! Preset… returns to Define Grid) and `__original` (the saved preset an edit changes). use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_tools::distort::perspective::Rgb; +use vectorcraft_tools::distort::perspective::{GridDefinition, Rgb}; use super::{DialogSpec, form}; use crate::state::Dialog; @@ -24,6 +31,14 @@ const FIELD: f32 = 120.0; /// The unit the lengths were last shown in. const SHOWN: &str = "__units"; +/// What the dialog is: Define Grid (none), `save` (Save Grid as Preset) or `edit` (the preset +/// editor). +const MODE: &str = "__mode"; +const FROM: &str = "__from"; +const ORIGINAL: &str = "__original"; + +/// The Preset menu's entry for fields no preset has. +const CUSTOM: &str = "[Custom]"; /// The lengths among the fields: real-world lengths in `units`. const LENGTHS: [&str; 3] = ["gridline", "distance", "horizonHeight"]; @@ -43,23 +58,94 @@ const SCALES: [(&str, [f64; 2]); 9] = [ const TYPES: [&str; 3] = ["One Point Perspective", "Two Point Perspective", "Three Point Perspective"]; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| "Define Perspective Grid".into(), body, confirm, min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| { + match d.str(MODE).as_str() { + "save" => "Save Grid as Preset", + "edit" => "Perspective Grid Preset", + _ => "Define Perspective Grid", + } + .into() + }, + body, + confirm, + min_width: 420.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// Open the dialog on `def`'s fields as `mode` (`""`, `save` or `edit`). +fn open_on(app: &mut VectorcraftApp, mut def: Value, mode: &str) { + def[SHOWN] = def["units"].clone(); + def[MODE] = json!(mode); + app.ui.dialog = Some(Dialog::new(KIND, def)); +} + +/// The active document's grid as Define Grid fields. +fn grid_fields(app: &mut VectorcraftApp) -> Result { + Ok(app.session.execute("perspective.grid.get", &json!({})).map_err(|e| e.to_string())?["define"].take()) +} /// Open Define Grid on the active document's grid. pub fn open(app: &mut VectorcraftApp) -> Result { - let mut fields = app.session.execute("perspective.grid.get", &json!({})).map_err(|e| e.to_string())?["define"].take(); - fields[SHOWN] = fields["units"].clone(); - app.ui.dialog = Some(Dialog::new(KIND, fields)); + let fields = grid_fields(app)?; + open_on(app, fields, ""); Ok(Value::Null) } -fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let r = app.run("perspective.grid.define", form::params(d)); - if r.is_ok() { - app.ui.dialog = None; +/// View → Perspective Grid → Save Grid as Preset…: the grid's fields under a new preset name. +pub fn open_save(app: &mut VectorcraftApp) -> Result { + let mut fields = grid_fields(app)?; + fields["name"] = json!(app.session.new_perspective_preset_name()); + open_on(app, fields, "save"); + Ok(Value::Null) +} + +/// The preset editor: the saved preset `original` (Edit…), or a new preset starting from `base` +/// (New…). Its OK returns to Edit → Perspective Grid Presets. +pub fn open_edit(app: &mut VectorcraftApp, original: Option<&str>, base: &str) -> Result<(), String> { + let name = original.unwrap_or(base); + let mut def = json!(app.session.perspective_preset(name).ok_or_else(|| format!("no perspective grid preset named `{name}`"))?); + match original { + Some(n) => def[ORIGINAL] = json!(n), + None => def["name"] = json!(app.session.new_perspective_preset_name()), + } + open_on(app, def, "edit"); + Ok(()) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut params = form::params(d); + match d.str(MODE).as_str() { + "save" | "edit" => { + if let Some(original) = d.fields.get(ORIGINAL).and_then(Value::as_str) { + params["newName"] = params["name"].take(); + params["name"] = json!(original); + } + let r = app.run("perspective.presets.save", params)?; + let name = r["name"].as_str().unwrap_or_default().to_string(); + if d.str(MODE) == "edit" { + super::perspective_presets::open(app, Some(&name)); + } else if d.str(FROM) == "define" { + // Back to Define Grid, on the preset just saved. + if let Some(back) = app.ui.dialog.as_mut() { + back.fields.remove(MODE); + back.fields.remove(FROM); + back.fields.insert("name".into(), json!(name)); + } + } else { + app.ui.dialog = None; + } + Ok(r) + } + _ => { + let r = app.run("perspective.grid.define", params); + if r.is_ok() { + app.ui.dialog = None; + } + r + } } - r } /// The unit the lengths are in. @@ -114,8 +200,45 @@ fn color(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { }); } -fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { +/// The Name row: in Define Grid the Preset menu (choosing a preset loads its fields) and Save +/// Preset…, else the preset's name. +fn name_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + if !d.str(MODE).is_empty() { + widgets::label_row(ui, "Name:", LABEL, |ui| { + form::text(ui, d, "name", 200.0); + }); + return; + } + let presets = app.session.perspective_presets(); + let name = d.str("name"); + // The fields no longer the named preset's: [Custom]. + let fields: Option = serde_json::from_value(form::params(d)).ok(); + let current = presets.iter().find(|p| p.name == name).filter(|p| fields.as_ref().is_some_and(|f| f.same(p))); + if current.is_none() && !name.is_empty() { + d.fields.insert("name".into(), json!("")); + } + let shown = current.map_or(CUSTOM, |p| p.name.as_str()); + let names: Vec<&str> = std::iter::once(CUSTOM).chain(presets.iter().map(|p| p.name.as_str())).collect(); + widgets::label_row(ui, "Preset:", LABEL, |ui| { + if let Some(p) = widgets::dropdown(ui, "persp-preset", shown, &names, 200.0).and_then(|i| i.checked_sub(1)).and_then(|i| presets.get(i)) { + let mut fields = json!(p); + fields[SHOWN] = fields["units"].clone(); + if let Some(o) = fields.as_object() { + d.fields.extend(o.clone()); + } + } + if widgets::flat_button(ui, "Save Preset…", 96.0).on_hover_text("Save these settings as a preset").clicked() { + d.fields.insert(MODE.into(), json!("save")); + d.fields.insert(FROM.into(), json!("define")); + d.fields.insert("name".into(), json!(app.session.new_perspective_preset_name())); + } + }); + ui.add_space(8.0); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); + name_row(app, ui, d); widgets::subheader(ui, "Perspective Grid Settings"); ui.add_space(6.0); let k = kind(d); diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs new file mode 100644 index 00000000..39940635 --- /dev/null +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -0,0 +1,223 @@ +//! Edit → Perspective Grid Presets…: every preset in a list (the built-in views, protected, then +//! the saved ones) with the selected one's settings beside it; New… (a preset starting from the +//! selected one), Edit… (saved presets), Delete, Import… and Export… (the selected preset) under +//! the list. New… and Edit… open the preset editor (the Define Grid dialog, mode `edit`), whose OK +//! comes back here. +//! +//! Also View → Perspective Grid → One/Two/Three Point Perspective: the built-in views of the type +//! and slots for the saved presets of that type ([`SLOT`]). +//! +//! Field: `selected` (a preset's name). + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_engine::cmd::perspgrid::{PRESET_EXTS, PRESET_FORMAT}; +use vectorcraft_tools::distort::perspective::GridDefinition; +use vectorcraft_tools::distort::perspective::define::{BUILTINS, is_builtin}; + +use super::DialogSpec; +use crate::menus::Item; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Perspective Grid Presets. +pub const KIND: &str = "perspectiveGridPresets"; + +/// The id prefix of the saved-preset slots in the One/Two/Three Point Perspective menus: +/// `ui.perspectiveUserPreset.` is the n-th saved preset of that type. +pub const SLOT: &str = "ui.perspectiveUserPreset"; + +/// Saved-preset slots per type. +pub const SLOTS: usize = 5; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Perspective Grid Presets".into(), + body, + confirm: |app, _| { + app.ui.dialog = None; + Ok(Value::Null) + }, + ok: None, + min_width: 600.0, + ..DialogSpec::FORM +}; + +/// Open the presets manager on `selected` (default: the first preset). +pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) { + app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(BUILTINS[0].0) }))); +} + +/// What a button asked for, done once the list and the details are drawn. +pub(super) enum Action { + Select(String), + New, + Edit, + Delete, + Import, + Export, +} + +/// The settings rows of a preset: (label, value). +fn details(p: &GridDefinition) -> Vec<(&'static str, String)> { + let u = p.unit(); + let len = |v: f64| u.format(u.to_pt(v)); + let mut rows = vec![ + ("Type:", ["One Point", "Two Point", "Three Point"].get(usize::from(p.kind.clamp(1, 3) - 1)).copied().unwrap_or_default().to_string()), + ("Units:", u.label().to_string()), + ("Scale:", format!("{}:{}", Unit::Points.number(p.scale[0]), Unit::Points.number(p.scale[1]))), + ("Gridline every:", len(p.gridline)), + ]; + if p.kind != 1 { + rows.push(("Viewing Angle:", format!("{:.1}°", p.angle))); + } + rows.extend([("Viewing Distance:", len(p.distance)), ("Horizon Height:", len(p.horizon_height))]); + if p.kind == 3 { + rows.push(("Third Vanishing Point:", format!("{}, {}", len(p.third_vp[0]), len(p.third_vp[1])))); + } + rows.push(("Opacity:", format!("{:.0}%", p.opacity))); + rows +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let presets = app.session.perspective_presets(); + let selected = d.str("selected"); + let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(selected.trim())).unwrap_or(0); + let Some(preset) = presets.get(i) else { return false }; + let builtin = is_builtin(&preset.name); + let mut act = None; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(260.0); + widgets::dim_label(ui, "Presets:"); + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("perspective-presets").min_scrolled_height(240.0).max_height(240.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(240.0); + for (k, p) in presets.iter().enumerate() { + if ui.selectable_label(k == i, &p.name).clicked() { + act = Some(Action::Select(p.name.clone())); + } + } + }); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() { + act = Some(Action::New); + } + if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() { + act = Some(Action::Edit); + } + if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() { + act = Some(Action::Delete); + } + if widgets::flat_button(ui, "Import…", 60.0).clicked() { + act = Some(Action::Import); + } + if widgets::flat_button(ui, "Export…", 60.0).on_hover_text("Save the selected preset to a file").clicked() { + act = Some(Action::Export); + } + }); + }); + ui.add_space(18.0); + ui.vertical(|ui| { + ui.set_width(280.0); + ui.label(egui::RichText::new(&preset.name).color(t.text_strong).strong()); + ui.add_space(8.0); + egui::Grid::new("perspective-preset-details").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { + for (label, value) in details(preset) { + ui.label(egui::RichText::new(label).color(t.text_dim)); + ui.label(egui::RichText::new(value).color(t.text)); + ui.end_row(); + } + }); + if builtin { + ui.add_space(6.0); + ui.label(egui::RichText::new("Built-in presets don't change: New… starts an editable copy.").color(t.text_dim).size(11.5)); + } + }); + }); + let name = preset.name.clone(); + if let Some(a) = act + && let Err(e) = run(app, d, a, &name) + { + app.status(e); + } + false +} + +fn select(d: &mut Dialog, name: &str) { + d.fields.insert("selected".into(), json!(name)); +} + +pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> Result<(), String> { + match act { + Action::Select(name) => select(d, &name), + // The editor replaces this dialog; its OK comes back here. + Action::New | Action::Edit => { + let original = matches!(act, Action::Edit).then_some(current); + super::perspective_grid::open_edit(app, original, current)?; + *d = app.ui.dialog.take().ok_or("no preset editor")?; + } + Action::Delete => { + let i = app.session.prefs.perspective_presets.iter().position(|p| p.name == current); + app.run("perspective.presets.delete", json!({ "name": current }))?; + // The one above it takes its place (the last built-in view above the first saved one). + let saved = &app.session.prefs.perspective_presets; + let next = + i.and_then(|i| i.checked_sub(1)).and_then(|i| saved.get(i)).map_or(BUILTINS[BUILTINS.len() - 1].0, |p| p.name.as_str()).to_string(); + select(d, &next); + } + Action::Import => { + // On the web the picked file arrives later and imports through `io::open_bytes`. + if let Some(f) = app.services.open_async.as_mut() { + f(); + return Ok(()); + } + let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; + let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; + let r = app.run("perspective.presets.import", json!({ "path": path }))?; + if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { + select(d, first); + } + } + Action::Export => { + crate::io::save_command_output(app, "perspective.presets.export", PRESET_FORMAT, json!({ "names": [current] }))?; + } + } + Ok(()) +} + +// ---------- the One/Two/Three Point Perspective menus ---------- + +/// The saved preset a slot id (`ui.perspectiveUserPreset2.1`) stands for. +pub fn slot_preset(app: &VectorcraftApp, id: &str) -> Option { + let (kind, n) = id.strip_prefix(SLOT)?.split_once('.')?; + let (kind, n): (u8, usize) = (kind.parse().ok()?, n.parse().ok()?); + app.session.prefs.perspective_presets.iter().filter(|p| p.kind == kind).nth(n.checked_sub(1)?).map(|p| p.name.clone()) +} + +/// How many saved presets the slots list (the native menu is rebuilt when this changes). +pub fn listed_slots(app: &VectorcraftApp) -> usize { + (1..=3u8).map(|k| app.session.prefs.perspective_presets.iter().filter(|p| p.kind == k).count().min(SLOTS)).sum() +} + +/// View → Perspective Grid → One/Two/Three Point Perspective (`kind` 1–3): the built-in views of +/// the type, then the saved presets' slots (`slots`, the type's ids). +pub fn menu(kind: u8, slots: [&'static str; SLOTS]) -> Vec { + let mut items: Vec = BUILTINS + .iter() + .filter(|b| b.1 == kind) + .enumerate() + .map(|(i, b)| { + // The normal view is the type's preset (`kind`), as before named presets. + let p = if i == 0 { json!({ "kind": kind }) } else { json!({ "name": b.0 }) }; + Item::Cmd(b.0, "perspective.grid.preset", p) + }) + .collect(); + items.extend(slots.map(|id| Item::Cmd("User Preset", id, Value::Null))); + items +} diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index 93a824cc..5fab2def 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -79,3 +79,85 @@ fn define_grid_is_a_view_menu_item() { assert!(e.path.iter().any(|p| p == "Perspective Grid"), "{:?}", e.path); assert!(e.enabled); } + +#[test] +fn define_grid_loads_and_saves_presets() { + let mut app = app(); + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + frame(&mut app); + assert_eq!(field(&app, "name"), json!("[2P-Normal View]")); + // Choosing a preset (an agent sets its fields; the menu loads them) keeps its name while the + // fields are its; a changed field makes the fields [Custom]. + set(&mut app, "angle", json!(20)); + frame(&mut app); + assert_eq!(field(&app, "name"), json!("")); + // Save Preset… asks for a name; OK saves the fields and comes back to Define Grid on it. + app.ui.dialog.as_mut().unwrap().fields.insert("__mode".into(), json!("save")); + set(&mut app, "__from", json!("define")); + set(&mut app, "name", json!("Shallow")); + frame(&mut app); + super::confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some(super::perspective_grid::KIND)); + assert_eq!((field(&app, "name"), field(&app, "__mode")), (json!("Shallow"), Value::Null)); + assert_eq!(app.session.prefs.perspective_presets[0].angle, 20.0); + frame(&mut app); + assert_eq!(field(&app, "name"), json!("Shallow"), "the fields are the saved preset's"); + super::confirm(&mut app).unwrap(); + assert_eq!(grid(&app).name, "Shallow"); + // View › Perspective Grid › Save Grid as Preset…: the grid under a new name. + app.run("ui.savePerspectivePreset", json!({})).unwrap(); + assert_eq!(field(&app, "name"), json!("Perspective Preset 1")); + frame(&mut app); + super::confirm(&mut app).unwrap(); + assert_eq!(kind(&app), None); + assert_eq!(app.session.prefs.perspective_presets.len(), 2); +} + +#[test] +fn the_presets_manager_creates_edits_and_deletes_presets() { + use super::perspective_presets::{self, Action}; + let mut app = app(); + app.run("ui.perspectivePresetsDialog", json!({"selected": "[3P-Normal View]"})).unwrap(); + frame(&mut app); + let press = |app: &mut VectorcraftApp, act: Action, current: &str| { + let mut d = app.ui.dialog.take().unwrap(); + perspective_presets::run(app, &mut d, act, current).unwrap(); + app.ui.dialog = Some(d); + frame(app); + }; + // New… opens the editor on a copy of the selected preset; its OK comes back here. + press(&mut app, Action::New, "[3P-Normal View]"); + assert_eq!((kind(&app), field(&app, "kind")), (Some(super::perspective_grid::KIND), json!(3))); + set(&mut app, "name", json!("Canyon")); + set(&mut app, "opacity", json!(90)); + super::confirm(&mut app).unwrap(); + assert_eq!((kind(&app), field(&app, "selected")), (Some(perspective_presets::KIND), json!("Canyon"))); + // Edit… renames it. + press(&mut app, Action::Edit, "Canyon"); + set(&mut app, "name", json!("Gorge")); + super::confirm(&mut app).unwrap(); + assert_eq!(field(&app, "selected"), json!("Gorge")); + assert_eq!(app.session.prefs.perspective_presets.iter().map(|p| p.name.as_str()).collect::>(), ["Gorge"]); + assert_eq!(app.session.prefs.perspective_presets[0].opacity, 90.0); + // Its type's menu lists it in a slot, which applies it. + assert_eq!(crate::menus::dynamic_label(&app, "ui.perspectiveUserPreset3.1", ""), "Gorge"); + assert!(crate::menus::hidden_slot(&app, "ui.perspectiveUserPreset3.2") && crate::menus::hidden_slot(&app, "ui.perspectiveUserPreset2.1")); + app.run("ui.perspectiveUserPreset3.1", json!({})).unwrap(); + assert_eq!(grid(&app).name, "Gorge"); + // Delete selects the one above it. + press(&mut app, Action::Delete, "Gorge"); + assert!(app.session.prefs.perspective_presets.is_empty()); + assert_eq!(field(&app, "selected"), json!("[3P-Low View]")); + assert!(app.run("ui.perspectiveUserPreset3.1", json!({})).is_err()); +} + +#[test] +fn perspective_preset_menus_list_the_views_of_each_type() { + let app = app(); + let entries = crate::menus::menu_entries(&app); + let under = |sub: &str| entries.iter().filter(|e| e.path.iter().any(|p| p == sub)).map(|e| e.label.clone()).collect::>(); + assert_eq!(under("One Point Perspective"), ["[1P-Normal View]", "[1P-Low View]", "[1P-High View]"]); + assert_eq!(under("Three Point Perspective"), ["[3P-Normal View]", "[3P-Low View]"]); + let presets = entries.iter().find(|e| e.command.as_deref() == Some("ui.perspectivePresetsDialog")).unwrap(); + assert_eq!((presets.label.as_str(), presets.path.first().map(String::as_str)), ("Perspective Grid Presets…", Some("Edit"))); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 2e62387b..4feef1f4 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -15,8 +15,8 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs}; const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; /// Open bytes of any readable format as a new document (templates open untitled); swatch and -/// graphic style library files open in the library panel and flattener, PDF and print presets -/// files are imported. +/// graphic style library files open in the library panel and flattener, PDF, print and perspective +/// grid presets files are imported. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { let ext = fileio::extension(name); // A WebAssembly plug-in is installed. @@ -30,6 +30,7 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), + (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), ]; if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e1ae915d..51cb4ec7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -553,6 +553,108 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", ), + ( + "ui.perspectivePresetsDialog", + "Perspective Grid Presets…", + "", + "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*", + ), + ( + "ui.savePerspectivePreset", + "Save Grid as Preset…", + "", + "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields", + ), + ( + "ui.perspectiveUserPreset1.1", + "One Point Perspective Preset 1", + "", + "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.2", + "One Point Perspective Preset 2", + "", + "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.3", + "One Point Perspective Preset 3", + "", + "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.4", + "One Point Perspective Preset 4", + "", + "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.5", + "One Point Perspective Preset 5", + "", + "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.1", + "Two Point Perspective Preset 1", + "", + "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.2", + "Two Point Perspective Preset 2", + "", + "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.3", + "Two Point Perspective Preset 3", + "", + "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.4", + "Two Point Perspective Preset 4", + "", + "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.5", + "Two Point Perspective Preset 5", + "", + "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.1", + "Three Point Perspective Preset 1", + "", + "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.2", + "Three Point Perspective Preset 2", + "", + "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.3", + "Three Point Perspective Preset 3", + "", + "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.4", + "Three Point Perspective Preset 4", + "", + "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.5", + "Three Point Perspective Preset 5", + "", + "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -970,6 +1072,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::plugin::open(app, p), "ui.installPlugin" => io::install_plugin(app, s("path")), "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), + "ui.perspectivePresetsDialog" => { + crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.savePerspectivePreset" => crate::dialogs::perspective_grid::open_save(app), + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => match crate::dialogs::perspective_presets::slot_preset(app, id) { + Some(name) => app.run("perspective.grid.preset", json!({ "name": name })), + None => Err("no such perspective grid preset".into()), + }, _ => return None, }; Some(r) @@ -1070,6 +1181,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) } + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) + } _ => label.into(), } } @@ -1081,6 +1195,7 @@ fn hidden_when_disabled(id: &str) -> bool { || id.starts_with("file.openRecent") || id.starts_with(crate::panels::swatches::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT) + || id.starts_with(crate::dialogs::perspective_presets::SLOT) } /// Menu items another item stands in for right now: Envelope Distort's Reset with Warp and Reset @@ -1146,7 +1261,11 @@ pub fn listed_slots(app: &VectorcraftApp) -> usize { use vectorcraft_engine::cmd::{stylelib, swatchlib}; let user = |libs: Vec| libs.iter().filter(|l| l.category == "user").count().min(10); let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10)); - views + io::recent_files(app).len().min(RECENT_IDS.len()) + user(swatchlib::libraries(&app.session)) + user(stylelib::libraries(&app.session)) + views + + io::recent_files(app).len().min(RECENT_IDS.len()) + + user(swatchlib::libraries(&app.session)) + + user(stylelib::libraries(&app.session)) + + crate::dialogs::perspective_presets::listed_slots(app) } /// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins @@ -1155,6 +1274,31 @@ pub fn plugin_revision() -> u64 { vectorcraft_plugins::registry::revision() } +/// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. +const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ + [ + "ui.perspectiveUserPreset1.1", + "ui.perspectiveUserPreset1.2", + "ui.perspectiveUserPreset1.3", + "ui.perspectiveUserPreset1.4", + "ui.perspectiveUserPreset1.5", + ], + [ + "ui.perspectiveUserPreset2.1", + "ui.perspectiveUserPreset2.2", + "ui.perspectiveUserPreset2.3", + "ui.perspectiveUserPreset2.4", + "ui.perspectiveUserPreset2.5", + ], + [ + "ui.perspectiveUserPreset3.1", + "ui.perspectiveUserPreset3.2", + "ui.perspectiveUserPreset3.3", + "ui.perspectiveUserPreset3.4", + "ui.perspectiveUserPreset3.5", + ], +]; + /// Type → Recent Fonts slots. const RECENT_FONT_IDS: [&str; 10] = [ "type.recentFont1", @@ -1227,7 +1371,10 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "css.copy" | "css.exportFile" => app.session.active().is_some(), "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), "plugin.dialog" => app.session.active().is_some(), - "ui.perspectiveGridDialog" => app.session.active().is_some(), + "ui.perspectiveGridDialog" | "ui.savePerspectivePreset" => app.session.active().is_some(), + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() + } _ => true, } } @@ -1363,7 +1510,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), c("Print Presets…", "ui.printPresetsDialog"), c("PDF Presets…", "ui.pdfPresetsDialog"), - cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})), + c("Perspective Grid Presets…", "ui.perspectivePresetsDialog"), Sep, c("Color Settings…", "edit.colorSettings"), c("Assign Profile…", "edit.assignProfile"), @@ -1778,10 +1925,12 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { vec![ c("Show Grid", "perspective.grid.show"), Sep, - cp("One Point Perspective", "perspective.grid.preset", json!({"kind": 1})), - cp("Two Point Perspective", "perspective.grid.preset", json!({"kind": 2})), - cp("Three Point Perspective", "perspective.grid.preset", json!({"kind": 3})), c("Define Grid…", "ui.perspectiveGridDialog"), + sub("One Point Perspective", crate::dialogs::perspective_presets::menu(1, PERSPECTIVE_SLOTS[0])), + sub("Two Point Perspective", crate::dialogs::perspective_presets::menu(2, PERSPECTIVE_SLOTS[1])), + sub("Three Point Perspective", crate::dialogs::perspective_presets::menu(3, PERSPECTIVE_SLOTS[2])), + Sep, + c("Save Grid as Preset…", "ui.savePerspectivePreset"), ], ), c("Show Grid", "view.grid"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7592b9ef..49e0626d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -479,3 +479,15 @@ then the tool's options `detail`, `simplify` and `simplifyOn` (Warp, Twirl, Puck `complexity`, `affectAnchors`, `affectIn` and `affectOut` (Scallop, Crystallize, Wrinkle), `horizontal` and `vertical` (Wrinkle, %), and `showBrush`. `ui.dialog.confirm` runs `tool.setOption {tool, values}`. `ui.pointer` events take `pressure` (0..1, default 1): it is the Liquify intensity while Use Pressure Pen is on. + +Perspective grid presets: in the `perspectiveGrid` dialog, `name` is the preset whose fields are loaded (setting the +fields of a preset, as the Preset menu does, keeps its name; other changes clear it, which reads [Custom]). Save +Preset… sets `__mode: "save"` and `__from: "define"` with `name` the new preset's name: `ui.dialog.confirm` then saves +the fields as that preset (`perspective.presets.save`) and returns to Define Grid on it. View → Perspective Grid → +Save Grid as Preset… (`ui.savePerspectivePreset`) opens the same dialog in `save` mode; OK saves and closes. Edit → +Perspective Grid Presets… (`ui.perspectivePresetsDialog {selected?}`) opens dialog `perspectiveGridPresets` (field +`selected`): New… and Edit… open the preset editor (`perspectiveGrid` with `__mode: "edit"`, `__original` the preset +edited), whose OK runs `perspective.presets.save` and comes back; Delete, Import… and Export… are +`perspective.presets.delete`, `import` and `export`. View → Perspective Grid → One/Two/Three Point Perspective list +that type's built-in views and the saved presets (`ui.perspectiveUserPreset.`, hidden while empty). Opening a +`.vcperspective` file with `app.open` imports its presets. diff --git a/docs/mcp.md b/docs/mcp.md index f7eb3f25..9f6ee2e8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1817,3 +1817,18 @@ its current size (with Shift too it keeps its proportions). With Use Pressure Pe {"name":"pointer_gesture","arguments":{"tool":"bloat","events":[{"kind":"down","x":280,"y":150,"pressure":0.3},{"kind":"drag","x":280,"y":200,"pressure":0.8},{"kind":"up","x":280,"y":250}]}} {"name":"run_command","arguments":{"command":"object.liquify","params":{"tool":"scallop","points":[[300,150],[300,250]],"complexity":3,"affectAnchors":false}}} ``` + +Perspective grid presets: `perspective.presets.list` lists the built-in views (`[1P-Normal View]`, `[1P-Low View]`, +`[1P-High View]`, `[2P-Normal View]`, `[2P-Low View]`, `[2P-High View]`, `[3P-Normal View]`, `[3P-Low View]`; +`builtIn: true`, fitted to the first artboard, protected), then the saved ones, each with its Define Grid fields. +`perspective.grid.preset {name}` (or `{kind}`, that type's normal view) resets the grid to one, fitted to the first +artboard. `perspective.presets.save {name?, newName?, preset?, …fields}` saves a preset: just `{name}` saves the +document's grid (View → Perspective Grid → Save Grid as Preset). `perspective.presets.delete {name}`, +`perspective.presets.export {names?, path?}` (a `.vcperspective` JSON file; without `path` it returns `data`) and +`perspective.presets.import {path? | data? | dataBase64?, replace?}` manage them. Saved presets are kept with the +preferences, and a grid whose fields are a preset's is named after it (`name`). + +```json +{"name":"run_command","arguments":{"command":"perspective.grid.preset","params":{"name":"[2P-Low View]"}}} +{"name":"run_command","arguments":{"command":"perspective.presets.save","params":{"name":"Street","units":"feet","scale":[1,48],"gridline":1}}} +```