M8.9: Perspective grid presets: built-in views, Save Grid as Preset, presets manager
View > Perspective Grid > One/Two/Three Point Perspective list the built-in views ([1P/2P/3P-Normal View], plus Low and High views generated in code) and the saved presets of each type; Save Grid as Preset... and Define Grid's Preset menu and Save Preset... save the fields. Edit > Perspective Grid Presets... (previously a mislabelled reset to the two-point grid) manages them: New, Edit, Delete, Import, Export (.vcperspective), stored with the preferences. New commands perspective.presets.list/save/delete/export/import; perspective.grid.preset takes a preset name. Preset import is fuzzed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d850a2864
commit
c3c4185cef
|
|
@ -87,9 +87,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Perspective Grid Preset",
|
||||
[],
|
||||
None,
|
||||
"{kind: 1|2|3} reset the grid to the one/two/three-point preset for the first artboard (and show it)",
|
||||
"{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached)",
|
||||
has_doc,
|
||||
grid_preset
|
||||
super::perspgrid::grid_preset
|
||||
),
|
||||
cmd!(
|
||||
"perspective.grid.show",
|
||||
|
|
@ -477,22 +477,6 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(g.definition_json())
|
||||
}
|
||||
|
||||
fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = p
|
||||
.get("kind")
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|k| (1..=3).contains(k))
|
||||
.ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8;
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
|
||||
let g = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) };
|
||||
s.edit("Perspective Grid Preset", |d, _| {
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(g.definition_json())
|
||||
}
|
||||
|
||||
fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let want = p.get("visible").and_then(Value::as_bool);
|
||||
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
//! View → Perspective Grid → Define Grid: `perspective.grid.get` reads the grid (the model, the
|
||||
//! Define Grid fields and the station point) and `perspective.grid.define` sets it from those
|
||||
//! fields. The model and the camera maths live in `vectorcraft_tools::distort::perspective`.
|
||||
//!
|
||||
//! Presets: the built-in views (generated in code, fitted to the first artboard, protected) and
|
||||
//! the user's, saved with the preferences ([`crate::Prefs::perspective_presets`]) as Define Grid
|
||||
//! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin};
|
||||
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
|
||||
|
||||
use super::distortcmds::{grid_of, store_grid};
|
||||
|
|
@ -28,9 +35,128 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
grid_define
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.list",
|
||||
"Perspective Grid Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{builtIn, name, kind, …the perspective.grid.define fields}]} the built-in views (fitted to the first artboard, protected) first, then the saved presets",
|
||||
always,
|
||||
presets_list
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.save",
|
||||
"Save Perspective Grid Preset",
|
||||
[],
|
||||
None,
|
||||
"{name?: (default: a new \"Perspective Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else the document's grid), …the perspective.grid.define fields (over it)} create or change a saved preset (Save Grid as Preset: just `name`); built-in ones are protected → {name, created, definition}",
|
||||
always,
|
||||
presets_save
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.delete",
|
||||
"Delete Perspective Grid Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} delete a saved preset (built-in ones stay) → {deleted: name}",
|
||||
always,
|
||||
presets_delete
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.export",
|
||||
"Export Perspective Grid Presets",
|
||||
[],
|
||||
None,
|
||||
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcperspective file (JSON) → {path, count}; without path → {data: the file's text, count}",
|
||||
always,
|
||||
presets_export
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.import",
|
||||
"Import Perspective Grid Presets",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else imported ones whose name is taken get a number)} add the presets of a .vcperspective file (as perspective.presets.export writes) to the saved ones → {imported: [names]}",
|
||||
always,
|
||||
presets_import
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `format` of a perspective grid presets file, also its extension.
|
||||
pub const PRESET_FORMAT: &str = "vcperspective";
|
||||
|
||||
/// The extensions of perspective grid presets files (opening one imports it).
|
||||
pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
|
||||
|
||||
/// The most presets one file imports.
|
||||
const MAX_IMPORT: usize = 1000;
|
||||
|
||||
/// What `perspective.presets.export` writes and `perspective.presets.import` reads.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PresetFile {
|
||||
format: String,
|
||||
#[serde(default)]
|
||||
presets: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The artboard presets are fitted to: the active document's first (a Letter page without).
|
||||
fn preset_artboard(&self) -> Rect {
|
||||
self.active().map_or(LETTER, |d| first_artboard(&d.doc))
|
||||
}
|
||||
|
||||
/// Every perspective grid preset: the built-in views, then the saved ones.
|
||||
pub fn perspective_presets(&self) -> Vec<GridDefinition> {
|
||||
let ab = self.preset_artboard();
|
||||
let builtins = BUILTINS.iter().filter_map(|b| PerspectiveGrid::builtin(b.0, ab)).map(|g| g.definition());
|
||||
builtins.chain(self.prefs.perspective_presets.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
/// The preset `name` (any case): a built-in view or a saved one.
|
||||
pub fn perspective_preset(&self, name: &str) -> Option<GridDefinition> {
|
||||
match PerspectiveGrid::builtin(name, self.preset_artboard()) {
|
||||
Some(g) => Some(g.definition()),
|
||||
None => self.persp_saved_index(name).and_then(|i| self.prefs.perspective_presets.get(i)).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid preset `name` makes on `ab`: a built-in view, or a saved preset's fields over the
|
||||
/// normal view of its type.
|
||||
pub fn perspective_preset_grid(&self, name: &str, ab: Rect) -> Option<PerspectiveGrid> {
|
||||
if let Some(g) = PerspectiveGrid::builtin(name, ab) {
|
||||
return Some(g);
|
||||
}
|
||||
let def = self.perspective_preset(name)?;
|
||||
PerspectiveGrid::normal(def.kind, ab).with_definition(&def).ok()
|
||||
}
|
||||
|
||||
/// The first free "Perspective Preset N": the name a new preset gets.
|
||||
pub fn new_perspective_preset_name(&self) -> String {
|
||||
(1..).map(|i| format!("Perspective Preset {i}")).find(|n| !self.persp_preset_taken(n, None)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The index of the saved preset `name` (any case).
|
||||
fn persp_saved_index(&self, name: &str) -> Option<usize> {
|
||||
self.prefs.perspective_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// Whether `name` is a built-in view's or a saved preset's other than the one at `except`.
|
||||
fn persp_preset_taken(&self, name: &str, except: Option<usize>) -> bool {
|
||||
is_builtin(name) || self.persp_saved_index(name).is_some_and(|i| Some(i) != except)
|
||||
}
|
||||
|
||||
/// The name of the first preset among `candidates` whose fields `def` has ("" for none).
|
||||
fn preset_named(&self, def: &GridDefinition, candidates: &[&str]) -> String {
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|n| !n.is_empty())
|
||||
.find_map(|n| self.perspective_preset(n).filter(|p| p.same(def)))
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `perspective.grid.get` reports for `g`.
|
||||
pub(crate) fn grid_info(doc: &vectorcraft_doc::Document, g: &PerspectiveGrid) -> Value {
|
||||
let st = g.station();
|
||||
|
|
@ -52,10 +178,8 @@ fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let old = grid_of(&s.doc()?.doc);
|
||||
let def: GridDefinition = old.definition().merged(p).map_err(|e| bad(C, e))?;
|
||||
let mut g = old.with_definition(&def).map_err(|e| bad(C, e))?;
|
||||
// A changed grid is no longer the preset it came from (unless it was given a name).
|
||||
if def.name == old.name && !def.same(&old.definition()) {
|
||||
g.name.clear();
|
||||
}
|
||||
// The grid is the preset it was given or came from while it has that preset's fields.
|
||||
g.name = s.preset_named(&g.definition(), &[&def.name, &old.name]);
|
||||
if g != old {
|
||||
s.edit("Define Perspective Grid", |d, _| {
|
||||
store_grid(d, &g);
|
||||
|
|
@ -64,3 +188,151 @@ fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
Ok(json!(g.definition()))
|
||||
}
|
||||
|
||||
/// `perspective.grid.preset`: reset the grid to a preset fitted to the first artboard (shown;
|
||||
/// the attached objects stay attached).
|
||||
pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.grid.preset";
|
||||
let name = match (str_param(p, "name"), p.get("kind").and_then(Value::as_u64)) {
|
||||
(Some(n), _) => n.to_string(),
|
||||
(None, Some(k @ 1..=3)) => PerspectiveGrid::normal(k as u8, LETTER).name,
|
||||
_ => return Err(bad(C, "kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list)")),
|
||||
};
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
|
||||
let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?;
|
||||
let g = PerspectiveGrid { attached: old.attached, ..g };
|
||||
s.edit("Perspective Grid Preset", |d, _| {
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(g.definition_json())
|
||||
}
|
||||
|
||||
fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let rows: Vec<Value> = s
|
||||
.perspective_presets()
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let mut v = json!(d);
|
||||
v["builtIn"] = json!(is_builtin(&d.name));
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "presets": rows }))
|
||||
}
|
||||
|
||||
fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.presets.save";
|
||||
let name = match str_param(p, "name").map(str::trim) {
|
||||
Some("") => return Err(bad(C, "`name` is empty")),
|
||||
Some(n) => n.to_string(),
|
||||
None => s.new_perspective_preset_name(),
|
||||
};
|
||||
if is_builtin(&name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name")));
|
||||
}
|
||||
let at = s.persp_saved_index(&name);
|
||||
let saved = at.and_then(|i| s.prefs.perspective_presets.get(i)).cloned();
|
||||
// A saved preset changes from its own fields unless `preset` says where to start.
|
||||
let base = match (str_param(p, "preset"), &saved) {
|
||||
(Some(b), _) => {
|
||||
s.perspective_preset(b).ok_or_else(|| bad(C, format!("no perspective grid preset named `{b}` (see perspective.presets.list)")))?
|
||||
}
|
||||
(None, Some(old)) => old.clone(),
|
||||
(None, None) => s.active().map(|d| grid_of(&d.doc).definition()).unwrap_or_default(),
|
||||
};
|
||||
let mut def = base.merged(p).map_err(|e| bad(C, e))?;
|
||||
def.name = match str_param(p, "newName").map(str::trim) {
|
||||
Some("") => return Err(bad(C, "`newName` is empty")),
|
||||
Some(n) if s.persp_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
|
||||
Some(n) => n.to_string(),
|
||||
None => saved.map_or(name, |old| old.name),
|
||||
};
|
||||
let r = json!({ "name": def.name, "created": at.is_none(), "definition": def });
|
||||
match at.and_then(|i| s.prefs.perspective_presets.get_mut(i)) {
|
||||
Some(slot) => *slot = def,
|
||||
None => s.prefs.perspective_presets.push(def),
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.presets.delete";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
if is_builtin(name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
|
||||
}
|
||||
let i = s.persp_saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?;
|
||||
Ok(json!({ "deleted": s.prefs.perspective_presets.remove(i).name }))
|
||||
}
|
||||
|
||||
fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.presets.export";
|
||||
let presets = match p.get("names").and_then(Value::as_array) {
|
||||
Some(names) => {
|
||||
let find = |n: &Value| {
|
||||
let n = n.as_str().unwrap_or_default();
|
||||
s.perspective_preset(n).ok_or_else(|| bad(C, format!("no perspective grid preset named `{n}`")))
|
||||
};
|
||||
names.iter().map(find).collect::<Result<Vec<_>>>()?
|
||||
}
|
||||
None => s.prefs.perspective_presets.clone(),
|
||||
};
|
||||
if presets.is_empty() {
|
||||
return Err(bad(C, "no saved presets to export (name built-in ones in `names`)"));
|
||||
}
|
||||
let count = presets.len();
|
||||
let presets = presets.iter().map(|d| json!(d)).collect();
|
||||
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?;
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
super::fileio::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "count": count}))
|
||||
}
|
||||
None => Ok(json!({"data": text, "count": count})),
|
||||
}
|
||||
}
|
||||
|
||||
fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.presets.import";
|
||||
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
(Some(path), ..) => super::fileio::read_file(path)?,
|
||||
(None, Some(text), _) => text.as_bytes().to_vec(),
|
||||
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?,
|
||||
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a perspective grid presets file: {e}")))?;
|
||||
if file.format != PRESET_FORMAT {
|
||||
return Err(bad(C, format!("not a perspective grid presets file (format `{}`)", file.format)));
|
||||
}
|
||||
if file.presets.len() > MAX_IMPORT {
|
||||
return Err(bad(C, format!("more than {MAX_IMPORT} presets")));
|
||||
}
|
||||
// Every preset is checked as Define Grid checks its fields before any is added.
|
||||
let base = GridDefinition::default();
|
||||
let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?"))));
|
||||
let presets = file.presets.iter().map(check).collect::<Result<Vec<_>>>()?;
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let mut imported = vec![];
|
||||
for mut preset in presets {
|
||||
// A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays.
|
||||
let name = preset.name.trim().trim_start_matches('[').trim_end_matches(']').trim();
|
||||
let base = Some(name).filter(|n| !n.is_empty()).unwrap_or("Perspective Preset").to_string();
|
||||
match s.persp_saved_index(&base).filter(|_| replace) {
|
||||
Some(i) => {
|
||||
if let Some(slot) = s.prefs.perspective_presets.get_mut(i) {
|
||||
preset.name = slot.name.clone();
|
||||
imported.push(preset.name.clone());
|
||||
*slot = preset;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
preset.name = unique_name(&base, |n| s.persp_preset_taken(n, None));
|
||||
imported.push(preset.name.clone());
|
||||
s.prefs.perspective_presets.push(preset);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "imported": imported }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, serde_json::Map<String, Value>>,
|
||||
/// 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<vectorcraft_tools::distort::perspective::GridDefinition>,
|
||||
}
|
||||
|
||||
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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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::<Prefs>(json!({})).unwrap().perspective_presets.is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<GridDefinition> = 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);
|
||||
|
|
|
|||
|
|
@ -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<kind>.<n>` 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<String> {
|
||||
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<Item> {
|
||||
let mut items: Vec<Item> = 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
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>(), ["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::<Vec<_>>();
|
||||
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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>) -> 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)}))?;
|
||||
|
|
|
|||
|
|
@ -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<R
|
|||
"plugin.dialog" => 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<swatchlib::LibraryInfo>| 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<Item>)> {
|
|||
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<Item>)> {
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -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<type>.<n>`, hidden while empty). Opening a
|
||||
`.vcperspective` file with `app.open` imports its presets.
|
||||
|
|
|
|||
15
docs/mcp.md
15
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}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue