diff --git a/ROADMAP.md b/ROADMAP.md index 905ac228..b787a12d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | -| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects) | 8–12 | +| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager) | 8–12 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 | 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}}} +```