Design properties: project, author and licence in the design, its exports and inspect_design (#1015) (#1189)
A design carries metadata (format 21): project, part number, revision, author, organization, description and license, plus other tools' data under reverse-DNS keys, kept as written. Edited in the Design Properties window (Document Settings), set by document.metadata.set, reported by inspect_design, and written into STEP, IGES and 3MF where the format has a place for them. Translated, with tests.
This commit is contained in:
1 parent
78079587ad
commit
a4a8b48567
33 files changed
+1106
-21
No files matched your search
@@ -1622,6 +1622,9 @@ pub struct Document {
|
||||
pub name: String,
|
||||
#[serde(default = "default_units")]
|
||||
pub units: String,
|
||||
/// Design properties (project, author…) and other tools' data, kept as they are (#1015).
|
||||
#[serde(default, skip_serializing_if = "crate::Metadata::is_empty")]
|
||||
pub metadata: crate::Metadata,
|
||||
pub params: Vec<Param>,
|
||||
pub features: Vec<Feature>,
|
||||
/// Timeline marker: features at or after this index are rolled back (not evaluated).
|
||||
@@ -1835,6 +1838,7 @@ impl Document {
|
||||
format: default_format(),
|
||||
name: name.into(),
|
||||
units: default_units(),
|
||||
metadata: Default::default(),
|
||||
params: Vec::new(),
|
||||
features: Vec::new(),
|
||||
marker: None,
|
||||
|
||||
@@ -158,6 +158,12 @@
|
||||
//! transformation (an absent field means no setups, and empty render state is not saved); the
|
||||
//! version stamp makes 0.11.0 (which reads up to 20) ask for an update instead of silently
|
||||
//! dropping saved setups.
|
||||
//! Also first saved in 21, a design can carry `metadata` (#1015): design properties
|
||||
//! (`project`, `part_number`, `revision`, `author`, `organization`, `description`, `license`,
|
||||
//! each text) and other tools' data, each tool's under its own reverse-DNS key
|
||||
//! (`com.example.tool`). Every key is kept as it is, and nothing in it is checked when the
|
||||
//! design opens. A design without it saves as before. 0.11.x refuses format 21, so no
|
||||
//! released build drops the properties when it saves.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -400,7 +406,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
"this design was saved by a newer SolidCraft (file format {version}; this version reads formats up to {FORMAT}): update SolidCraft to open it"
|
||||
)));
|
||||
}
|
||||
check_numbers(&v, 0)?;
|
||||
check_design_numbers(&v)?;
|
||||
crate::config::validate_rules_json(&v)?;
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
crate::face_anchor::validate_face_anchors_json(&v)?;
|
||||
@@ -869,6 +875,16 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
|
||||
/// outside what the kernel's tolerances serve.
|
||||
const MAX_REAL: f64 = 1e9;
|
||||
|
||||
/// Every number the design itself holds is in range ([`check_numbers`]). `metadata` is left
|
||||
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
|
||||
/// already bounds its nesting.
|
||||
fn check_design_numbers(v: &Value) -> Result<()> {
|
||||
match v {
|
||||
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != "metadata").try_for_each(|(_, x)| check_numbers(x, 1)),
|
||||
v => check_numbers(v, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile
|
||||
/// file is refused instead of overflowing the geometry later).
|
||||
fn check_numbers(v: &Value, depth: usize) -> Result<()> {
|
||||
|
||||
@@ -28,6 +28,8 @@ mod history_names;
|
||||
pub use format_offsets::whole_profile_selection_from_value;
|
||||
pub mod joints;
|
||||
mod lineage;
|
||||
pub mod metadata;
|
||||
pub use metadata::Metadata;
|
||||
pub mod motion;
|
||||
mod name_lookup;
|
||||
pub mod naming;
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Design properties (#1015): the design's optional top-level `metadata` object.
|
||||
//!
|
||||
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
|
||||
//! revision, author, organization, description, licence) and keeps every other key exactly as
|
||||
//! it was through open, edit and save. Other tools keep their own data under one reverse-DNS
|
||||
//! key each (`com.example.tool`), holding an object.
|
||||
//!
|
||||
//! Reading never validates: a well-known key holding something other than text is kept as it
|
||||
//! is and reads as absent, so a file another tool wrote always opens. Writing through
|
||||
//! [`Metadata::set`] (the `document.metadata.set` command) checks the key, the value's kind and
|
||||
//! its size, so nothing SolidCraft writes is malformed.
|
||||
//!
|
||||
//! The map is stored as is: typed fields would refuse a design another tool had written
|
||||
//! `"author": 42` into. Everything under `metadata` is informational, never geometry, so keeping
|
||||
//! unknown keys can't leave stale model data behind (unlike keeping unknown top-level fields).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
pub const PROJECT: &str = "project";
|
||||
pub const PART_NUMBER: &str = "part_number";
|
||||
pub const REVISION: &str = "revision";
|
||||
pub const AUTHOR: &str = "author";
|
||||
pub const ORGANIZATION: &str = "organization";
|
||||
pub const DESCRIPTION: &str = "description";
|
||||
pub const LICENSE: &str = "license";
|
||||
|
||||
/// SolidCraft's own keys, in the order the Design Properties dialog shows them. Every other
|
||||
/// unprefixed key is reserved for SolidCraft too.
|
||||
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
|
||||
|
||||
/// Longest key, in bytes.
|
||||
pub const MAX_KEY_BYTES: usize = 128;
|
||||
/// Longest well-known text value, in bytes.
|
||||
pub const MAX_TEXT_BYTES: usize = 4096;
|
||||
/// Largest namespaced value, as compact JSON.
|
||||
pub const MAX_VALUE_BYTES: usize = 64 * 1024;
|
||||
/// Deepest nesting of a namespaced value.
|
||||
pub const MAX_VALUE_DEPTH: usize = 32;
|
||||
/// Most keys, and largest object as compact JSON, that a change may grow the metadata to.
|
||||
pub const MAX_KEYS: usize = 256;
|
||||
pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// The design's `metadata` object.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Metadata(pub Map<String, Value>);
|
||||
|
||||
/// A key SolidCraft reads as a design property.
|
||||
pub fn is_well_known(key: &str) -> bool {
|
||||
WELL_KNOWN.contains(&key)
|
||||
}
|
||||
|
||||
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
|
||||
/// ASCII letters, digits, `-` and `_` (`com.example.tool`, `org.printfarm`).
|
||||
pub fn is_namespaced(key: &str) -> bool {
|
||||
key.len() <= MAX_KEY_BYTES
|
||||
&& key.split('.').count() >= 2
|
||||
&& key.split('.').all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
|
||||
}
|
||||
|
||||
fn depth(v: &Value) -> usize {
|
||||
// Iterative, so a hostile value can't overflow the stack here.
|
||||
let mut deepest = 0;
|
||||
let mut stack = vec![(v, 1usize)];
|
||||
while let Some((v, d)) = stack.pop() {
|
||||
deepest = deepest.max(d);
|
||||
if d > MAX_VALUE_DEPTH {
|
||||
break;
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => stack.extend(a.iter().map(|x| (x, d + 1))),
|
||||
Value::Object(m) => stack.extend(m.values().map(|x| (x, d + 1))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
deepest
|
||||
}
|
||||
|
||||
fn json_len(v: &impl Serialize) -> usize {
|
||||
serde_json::to_vec(v).map_or(usize::MAX, |b| b.len())
|
||||
}
|
||||
|
||||
fn invalid(msg: String) -> DocError {
|
||||
DocError::Invalid(msg)
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||
self.0.get(key)
|
||||
}
|
||||
|
||||
/// A text value: `None` when the key is missing, empty or holds something other than text.
|
||||
pub fn text(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key)?.as_str().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn project(&self) -> Option<&str> {
|
||||
self.text(PROJECT)
|
||||
}
|
||||
pub fn part_number(&self) -> Option<&str> {
|
||||
self.text(PART_NUMBER)
|
||||
}
|
||||
pub fn revision(&self) -> Option<&str> {
|
||||
self.text(REVISION)
|
||||
}
|
||||
pub fn author(&self) -> Option<&str> {
|
||||
self.text(AUTHOR)
|
||||
}
|
||||
pub fn organization(&self) -> Option<&str> {
|
||||
self.text(ORGANIZATION)
|
||||
}
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.text(DESCRIPTION)
|
||||
}
|
||||
pub fn license(&self) -> Option<&str> {
|
||||
self.text(LICENSE)
|
||||
}
|
||||
|
||||
/// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added),
|
||||
/// kept as they are.
|
||||
pub fn others(&self) -> impl Iterator<Item = (&String, &Value)> {
|
||||
self.0.iter().filter(|(k, _)| !is_well_known(k))
|
||||
}
|
||||
|
||||
/// Set `key` to `value` after checking both; `null` (or empty text for a well-known key)
|
||||
/// removes it. Returns whether the metadata changed.
|
||||
pub fn set(&mut self, key: &str, value: Value) -> Result<bool> {
|
||||
let value = check(key, value)?;
|
||||
let Some(value) = value else { return Ok(self.remove(key)) };
|
||||
if self.0.get(key) == Some(&value) {
|
||||
return Ok(false);
|
||||
}
|
||||
let (keys, bytes) = (self.0.len(), json_len(&self.0));
|
||||
let old = self.0.insert(key.to_string(), value);
|
||||
// Growing past the limits is refused; a file already past them can still shrink.
|
||||
let (new_keys, new_bytes) = (self.0.len(), json_len(&self.0));
|
||||
if (new_keys > MAX_KEYS && new_keys > keys) || (new_bytes > MAX_TOTAL_BYTES && new_bytes > bytes) {
|
||||
match old {
|
||||
Some(old) => self.0.insert(key.to_string(), old),
|
||||
None => self.0.remove(key),
|
||||
};
|
||||
return Err(invalid(format!("design properties are limited to {MAX_KEYS} keys and {} KiB", MAX_TOTAL_BYTES / 1024)));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remove `key`, whatever it holds. Returns whether it was there.
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
self.0.remove(key).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` as `key` may hold it (`None`: remove the key), or why it can't.
|
||||
fn check(key: &str, value: Value) -> Result<Option<Value>> {
|
||||
if is_well_known(key) {
|
||||
return match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s.is_empty() => Ok(None),
|
||||
Value::String(s) => {
|
||||
if s.len() > MAX_TEXT_BYTES {
|
||||
return Err(invalid(format!("`{key}` is longer than {MAX_TEXT_BYTES} bytes")));
|
||||
}
|
||||
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
|
||||
return Err(invalid(format!("`{key}` has a control character")));
|
||||
}
|
||||
Ok(Some(Value::String(s)))
|
||||
}
|
||||
_ => Err(invalid(format!("`{key}` takes text"))),
|
||||
};
|
||||
}
|
||||
if is_namespaced(key) {
|
||||
return match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::Object(m) => {
|
||||
let v = Value::Object(m);
|
||||
if depth(&v) > MAX_VALUE_DEPTH {
|
||||
return Err(invalid(format!("`{key}` is nested more than {MAX_VALUE_DEPTH} levels deep")));
|
||||
}
|
||||
if json_len(&v) > MAX_VALUE_BYTES {
|
||||
return Err(invalid(format!("`{key}` is larger than {} KiB", MAX_VALUE_BYTES / 1024)));
|
||||
}
|
||||
Ok(Some(v))
|
||||
}
|
||||
_ => Err(invalid(format!("`{key}` must hold an object (a tool keeps its own data as an object under its reverse-DNS key)"))),
|
||||
};
|
||||
}
|
||||
let shown: String = key.chars().take(64).collect();
|
||||
Err(invalid(format!(
|
||||
"`{shown}` is not a design property: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
|
||||
WELL_KNOWN.join(", ")
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn well_known_keys_read_as_text_and_ignore_other_kinds() {
|
||||
let m: Metadata = serde_json::from_value(json!({"author": "Jane Doe", "revision": 3, "project": "", "license": null})).unwrap();
|
||||
assert_eq!(m.author(), Some("Jane Doe"));
|
||||
assert_eq!(m.revision(), None, "a number is kept but is not a revision");
|
||||
assert_eq!(m.project(), None, "empty text reads as absent");
|
||||
assert_eq!(m.license(), None);
|
||||
assert_eq!(m.get("revision"), Some(&json!(3)), "kept as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_checks_keys_kinds_and_sizes() {
|
||||
let mut m = Metadata::default();
|
||||
assert!(m.set("author", json!("Jane")).unwrap());
|
||||
assert!(!m.set("author", json!("Jane")).unwrap(), "the same value is no change");
|
||||
assert!(m.set("com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
|
||||
assert!(m.set("author", json!(42)).is_err(), "well-known keys take text");
|
||||
assert!(m.set("author", json!("a\u{7}b")).is_err(), "no control characters");
|
||||
assert!(m.set("description", json!("two\nlines\tand a tab")).unwrap());
|
||||
assert!(m.set("com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
|
||||
assert!(m.set("colour", json!("red")).is_err(), "unprefixed keys are reserved");
|
||||
assert!(m.set("", json!({})).is_err());
|
||||
for bad in ["com.", ".com", "com..x", "com.ex ample", "com.é", "single"] {
|
||||
assert!(m.set(bad, json!({})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(m.set("author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
|
||||
assert!(m.set("org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
|
||||
let mut deep = json!({});
|
||||
for _ in 0..MAX_VALUE_DEPTH {
|
||||
deep = json!({ "a": deep });
|
||||
}
|
||||
assert!(m.set("org.deep", deep).is_err());
|
||||
assert_eq!(m.author(), Some("Jane"), "a refused value leaves the old one");
|
||||
// Null and empty text remove.
|
||||
assert!(m.set("author", json!("")).unwrap());
|
||||
assert!(m.set("com.acme.tool", Value::Null).unwrap());
|
||||
assert!(!m.set("com.acme.tool", Value::Null).unwrap());
|
||||
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["description"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
|
||||
let mut m = Metadata::default();
|
||||
for i in 0..MAX_KEYS {
|
||||
m.set(&format!("org.k{i}"), json!({})).unwrap();
|
||||
}
|
||||
assert!(m.set("org.one_more", json!({})).is_err());
|
||||
assert_eq!(m.0.len(), MAX_KEYS);
|
||||
// A file written past the limits by another tool still opens and can be cut down.
|
||||
let mut big = Metadata::default();
|
||||
for i in 0..MAX_KEYS + 10 {
|
||||
big.0.insert(format!("org.k{i}"), json!({}));
|
||||
}
|
||||
assert!(big.set("org.k0", Value::Null).unwrap());
|
||||
assert!(big.set("author", json!("Jane")).is_err(), "still over: no new keys");
|
||||
assert!(big.set("org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
|
||||
}
|
||||
}
|
||||
@@ -318,8 +318,9 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
|
||||
} else if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
|
||||
solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
|
||||
} else {
|
||||
// Bodies carry the colour they are shown in (appearance, material or imported).
|
||||
solidcraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)?
|
||||
// Bodies carry the colour they are shown in (appearance, material or imported), and the
|
||||
// file the design's properties where the format has a place for them.
|
||||
solidcraft_io::export_with_metadata(&s.doc.painted(&s.world_state()), bodies, format, &name, &s.doc.metadata)?
|
||||
};
|
||||
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
|
||||
|
||||
@@ -252,7 +252,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)}))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"name": s.doc.name, "units": s.doc.units, "metadata": s.doc.metadata, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Design properties (#1015): `document.metadata` reads the design's `metadata` object, and
|
||||
//! `document.metadata.set` and `document.metadata.remove` change it, each in one undo step. The
|
||||
//! keys SolidCraft reads, and the checks a change gets, are `solidcraft_doc::metadata`'s; keys
|
||||
//! other tools wrote are kept as they are. The UI's Design Properties dialog, scripts and MCP
|
||||
//! agents all go through these commands.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::metadata::{MAX_KEYS, WELL_KNOWN};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: project, part_number, revision, \
|
||||
author, organization, description and license, each its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys: project, part_number, \
|
||||
revision, author, organization, description, license (text; \"\" or null removes); another tool's data goes under its \
|
||||
reverse-DNS key such as com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
|
||||
.doc_only()
|
||||
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
|
||||
];
|
||||
|
||||
fn read(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let m = &s.doc.metadata;
|
||||
let properties: Map<String, Value> = WELL_KNOWN.iter().map(|k| (k.to_string(), m.text(k).map_or(Value::Null, |t| json!(t)))).collect();
|
||||
Ok(json!({"metadata": m, "properties": properties}))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "document.metadata.set";
|
||||
let entries: Vec<(&str, &Value)> = match (p.get("values"), p.get("key")) {
|
||||
(Some(Value::Object(values)), None) if values.len() <= MAX_KEYS => values.iter().map(|(k, v)| (k.as_str(), v)).collect(),
|
||||
(Some(Value::Object(_)), None) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
|
||||
(None, Some(_)) => {
|
||||
let key = str_(p, "key").ok_or_else(|| bad(cmd, "`key` must be text"))?;
|
||||
let value = p.get("value").ok_or_else(|| bad(cmd, "`value` is required (null removes the key)"))?;
|
||||
vec![(key, value)]
|
||||
}
|
||||
_ => return Err(bad(cmd, "give `key` and `value`, or `values`: {key: value, …}")),
|
||||
};
|
||||
let mut next = s.doc.metadata.clone();
|
||||
let mut changed = Vec::new();
|
||||
for (key, value) in entries {
|
||||
if next.set(key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
changed.push(key.to_string());
|
||||
}
|
||||
}
|
||||
// Only a real change touches the design (an undo step, unsaved changes).
|
||||
if next != s.doc.metadata {
|
||||
s.doc_mut().metadata = next;
|
||||
}
|
||||
Ok(json!({"metadata": s.doc.metadata, "changed": changed}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "document.metadata.remove";
|
||||
let keys: Vec<&str> = match (p.get("keys"), p.get("key")) {
|
||||
(Some(Value::Array(keys)), None) if keys.len() <= MAX_KEYS => {
|
||||
keys.iter().map(Value::as_str).collect::<Option<_>>().ok_or_else(|| bad(cmd, "`keys` must be a list of keys"))?
|
||||
}
|
||||
(None, Some(_)) => vec![str_(p, "key").ok_or_else(|| bad(cmd, "`key` must be text"))?],
|
||||
_ => return Err(bad(cmd, format!("give `key`, or `keys`: a list of at most {MAX_KEYS} keys"))),
|
||||
};
|
||||
let mut removed: Vec<&str> = Vec::new();
|
||||
for k in keys {
|
||||
if s.doc.metadata.get(k).is_some() && !removed.contains(&k) {
|
||||
removed.push(k);
|
||||
}
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
let m = &mut s.doc_mut().metadata;
|
||||
for k in &removed {
|
||||
m.remove(k);
|
||||
}
|
||||
}
|
||||
Ok(json!({"metadata": s.doc.metadata, "removed": removed}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder of its own for one test's files.
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-metadata-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_set_read_and_remove_in_one_undo_step_each() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
s.mark_saved();
|
||||
let undo = s.undo.len();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"document.metadata.set",
|
||||
json!({"values": {"author": "Jane Doe", "part_number": "ACME-1042", "com.acme.shop": {"shop_id": 88123}}}),
|
||||
);
|
||||
assert_eq!(r["changed"], json!(["author", "com.acme.shop", "part_number"]));
|
||||
assert_eq!(s.undo.len(), undo + 1, "several keys, one undo step");
|
||||
assert!(s.is_dirty());
|
||||
let r = run(&mut s, "document.metadata", json!({}));
|
||||
assert_eq!(r["properties"]["author"], "Jane Doe");
|
||||
assert_eq!(r["properties"]["project"], Value::Null);
|
||||
assert_eq!(r["metadata"]["com.acme.shop"]["shop_id"], 88123);
|
||||
|
||||
// Setting what is already there changes nothing: no undo step.
|
||||
let r = run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane Doe"}));
|
||||
assert_eq!(r["changed"], json!([]));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
|
||||
let r = run(&mut s, "document.metadata.remove", json!({"keys": ["author", "author", "not.there"]}));
|
||||
assert_eq!(r["removed"], json!(["author"]));
|
||||
assert_eq!(s.doc.metadata.author(), None);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.metadata.author(), Some("Jane Doe"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.metadata.is_empty());
|
||||
assert!(!s.is_dirty(), "undone back to the saved design");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.metadata.part_number(), Some("ACME-1042"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_change_leaves_the_design_as_it_was() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane"}));
|
||||
let undo = s.undo.len();
|
||||
for p in [
|
||||
json!({"values": {"project": "P", "author": 42}}),
|
||||
json!({"values": {"project": "P", "colour": "red"}}),
|
||||
json!({"values": {"project": "P", "com.acme": "not an object"}}),
|
||||
json!({"key": "author"}),
|
||||
json!({"key": 7, "value": "x"}),
|
||||
json!({"key": "author", "value": "x", "values": {}}),
|
||||
json!({"values": [1, 2]}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(s.execute("document.metadata.set", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [json!({}), json!({"keys": "author"}), json!({"keys": [1]}), json!({"key": ["author"]})] {
|
||||
assert!(s.execute("document.metadata.remove", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(s.doc.metadata.project(), None, "all or nothing");
|
||||
assert_eq!(s.doc.metadata.author(), Some("Jane"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tools_keys_and_odd_values_survive_open_edit_and_save() {
|
||||
let dir = scratch("roundtrip");
|
||||
// A design another tool added metadata to by hand: a namespaced object with every kind of
|
||||
// JSON value, a well-known key of the wrong type, a key a newer SolidCraft might read, and
|
||||
// numbers no feature input could hold.
|
||||
let foreign = json!({
|
||||
"author": 42,
|
||||
"revision": "B",
|
||||
"com.acme.description": {"shop_id": 88123, "nested": {"list": [1, 2.5, null, true, "x", {"deep": []}]}, "big": 1.5e300},
|
||||
"org.printfarm": {"slicer_profile": "PETG-0.2"},
|
||||
"future_key": ["kept"],
|
||||
});
|
||||
let mut design: Value = serde_json::from_str(&solidcraft_doc::Document::new("Foreign").to_json()).unwrap();
|
||||
design["metadata"] = foreign.clone();
|
||||
let plain = dir.join("foreign.json");
|
||||
std::fs::write(&plain, serde_json::to_string_pretty(&design).unwrap()).unwrap();
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": plain}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.metadata).unwrap(), foreign, "opened as it was");
|
||||
assert_eq!(s.doc.metadata.author(), None, "a number is not an author");
|
||||
let r = run(&mut s, "document.metadata", json!({}));
|
||||
assert_eq!(r["properties"]["author"], Value::Null);
|
||||
assert_eq!(r["properties"]["revision"], "B");
|
||||
|
||||
run(&mut s, "document.metadata.set", json!({"key": "project", "value": "ACME enclosures"}));
|
||||
let mut expected = foreign.clone();
|
||||
expected["project"] = json!("ACME enclosures");
|
||||
for (file, format) in [("edited.json", "json"), ("edited.solidcraft", "container")] {
|
||||
let path = dir.join(file);
|
||||
run(&mut s, "file.save_as", json!({"path": path, "format": format}));
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "doc.open", json!({"path": path}));
|
||||
assert_eq!(serde_json::to_value(&reopened.doc.metadata).unwrap(), expected, "{format}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_without_properties_saves_as_before() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let before = solidcraft_io::write_design(&s.doc);
|
||||
run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane"}));
|
||||
run(&mut s, "document.metadata.remove", json!({"key": "author"}));
|
||||
assert_eq!(solidcraft_io::write_design(&s.doc), before, "byte for byte");
|
||||
assert!(!String::from_utf8_lossy(&before).contains("\"metadata\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_carry_the_properties_and_never_other_tools_keys() {
|
||||
// `file.export` hands the design's properties to the writers; the mapping itself is
|
||||
// tested in solidcraft-io.
|
||||
let dir = scratch("export");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(
|
||||
&mut s,
|
||||
"document.metadata.set",
|
||||
json!({"values": {
|
||||
"project": "ACME enclosures", "author": "Jane Doe", "organization": "ACME", "license": "CC-BY-4.0",
|
||||
"description": "Vented case\nfor a Pi", "part_number": "ACME-1042", "com.acme.secret": {"token": "SECRET-VALUE"},
|
||||
}}),
|
||||
);
|
||||
let text = |s: &mut Session, name: &str| {
|
||||
let path = dir.join(name);
|
||||
run(s, "file.export", json!({"path": path}));
|
||||
std::fs::read(&path).unwrap()
|
||||
};
|
||||
let step = String::from_utf8(text(&mut s, "part.step")).unwrap();
|
||||
assert!(step.contains("FILE_DESCRIPTION(('Vented case for a Pi'),'2;1');"), "{}", &step[..400]);
|
||||
assert!(step.contains("('Jane Doe'),('ACME')"), "{}", &step[..400]);
|
||||
let iges = String::from_utf8(text(&mut s, "part.igs")).unwrap();
|
||||
assert!(iges.contains("8HJane Doe") && iges.contains("4HACME"), "{}", &iges[..600]);
|
||||
// 3MF model metadata is checked where it is written (solidcraft-io); here: it exports.
|
||||
assert!(!text(&mut s, "part.3mf").is_empty());
|
||||
for out in [step.as_bytes(), iges.as_bytes()] {
|
||||
let found = |needle: &str| out.windows(needle.len()).any(|w| w == needle.as_bytes());
|
||||
assert!(!found("SECRET-VALUE") && !found("com.acme"), "another tool's data is never exported");
|
||||
}
|
||||
// An assembly's STEP gets the same header.
|
||||
run(&mut s, "component.create", json!({}));
|
||||
assert!(!s.doc.occurrences.is_empty());
|
||||
let assembly = String::from_utf8(text(&mut s, "assembly.step")).unwrap();
|
||||
assert!(assembly.contains("('Jane Doe'),('ACME')"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_lists_the_metadata() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(run(&mut s, "document.inspect", json!({}))["metadata"], json!({}));
|
||||
run(&mut s, "document.metadata.set", json!({"key": "revision", "value": "C"}));
|
||||
assert_eq!(run(&mut s, "document.inspect", json!({}))["metadata"], json!({"revision": "C"}));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ mod material_properties;
|
||||
mod materials;
|
||||
mod measure_sel;
|
||||
mod mesh;
|
||||
mod metadata;
|
||||
pub(crate) mod motion;
|
||||
mod parts;
|
||||
mod script;
|
||||
@@ -252,6 +253,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(script::COMMANDS.iter());
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(metadata::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(materials::COMMANDS.iter());
|
||||
v.extend(material_properties::COMMANDS.iter());
|
||||
|
||||
@@ -157,6 +157,22 @@ pub(crate) fn script_steps(doc: &Document) -> Result<(Vec<Value>, Vec<String>)>
|
||||
if doc.units != "mm" {
|
||||
out.push(step("document.units", json!({"units": doc.units})));
|
||||
}
|
||||
// Design properties, in one step. Keys `document.metadata.set` would refuse (a well-known
|
||||
// key holding something other than text, a key another tool wrote outside the rules) are
|
||||
// listed as left out instead of failing the replay.
|
||||
let mut properties = solidcraft_doc::Metadata::default();
|
||||
let mut odd = Vec::new();
|
||||
for (key, value) in &doc.metadata.0 {
|
||||
if properties.set(key, value.clone()).is_err() {
|
||||
odd.push(key.as_str());
|
||||
}
|
||||
}
|
||||
if !properties.is_empty() {
|
||||
out.push(step("document.metadata.set", json!({"values": properties})));
|
||||
}
|
||||
if !odd.is_empty() {
|
||||
omitted.push(format!("design properties document.metadata.set can't set ({})", odd.join(", ")));
|
||||
}
|
||||
for rule in &doc.sheet.rules {
|
||||
let mut p = serde_json::to_value(rule).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if doc.sheet.active.as_deref() == Some(rule.name.as_str())
|
||||
|
||||
@@ -140,6 +140,23 @@ fn units_materials_and_rules_come_back() {
|
||||
assert_eq!(a, b, "mass comes from the replayed material");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_properties_come_back_and_odd_keys_are_listed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "document.metadata.set", json!({"values": {"author": "Jane", "description": "two\nlines", "com.acme.tool": {"id": [1, 2]}}}));
|
||||
// Keys another tool wrote that the command would refuse.
|
||||
s.doc_mut().metadata.0.insert("revision".into(), json!(3));
|
||||
s.doc_mut().metadata.0.insert("org.other".into(), json!("text"));
|
||||
let r = run(&mut s, "timeline.export_script", json!({}));
|
||||
assert!(r["omitted"].to_string().contains("(org.other, revision)"), "{r}");
|
||||
let t = replay(&script(&mut s));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&t.doc.metadata).unwrap(),
|
||||
json!({"author": "Jane", "description": "two\nlines", "com.acme.tool": {"id": [1, 2]}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_script_leaves_out_is_listed_and_components_are_refused() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -1130,8 +1130,15 @@ mod tests {
|
||||
doc.param_comments.insert("test".into(), "metadata".into());
|
||||
let setup = solidcraft_doc::render_settings::RenderSetup { exposure_ev: 1.5, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut doc.render).setups.insert("Studio".into(), setup);
|
||||
// Design properties and other tools' data (#1015), kept as they are in both forms.
|
||||
doc.metadata = serde_json::from_value(json!({
|
||||
"author": 42,
|
||||
"revision": "B",
|
||||
"com.acme.description": {"shop_id": 88123, "list": [1, 2.5, null, true, "x", {"deep": []}]},
|
||||
}))
|
||||
.unwrap();
|
||||
let expected = crate::read_design(&crate::write_design(&doc)).unwrap();
|
||||
assert_eq!(expected.render, doc.render);
|
||||
assert_eq!(expected, doc);
|
||||
assert_eq!(crate::read_design(&write_design_container(&doc).unwrap()).unwrap(), expected);
|
||||
}
|
||||
|
||||
|
||||
+81
-9
@@ -33,10 +33,10 @@ pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
pub use svg::read_svg;
|
||||
mod obj;
|
||||
pub use obj::read_obj;
|
||||
pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, read_3mf, weld, write_3mf};
|
||||
pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, model_xml_with, read_3mf, weld, write_3mf, write_3mf_with};
|
||||
pub use urdf::{UrdfOptions, UrdfPackage, urdf};
|
||||
|
||||
use solidcraft_doc::{Document, ModelState};
|
||||
use solidcraft_doc::{Document, Metadata, ModelState};
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
|
||||
/// Design file extension.
|
||||
@@ -310,6 +310,39 @@ pub fn obj(meshes: &[(String, Mesh)]) -> String {
|
||||
|
||||
/// Export the given bodies of a model (all when `bodies` is empty) in a format.
|
||||
pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str) -> Result<Vec<u8>> {
|
||||
export_with_metadata(state, bodies, format, name, &Metadata::default())
|
||||
}
|
||||
|
||||
/// The STEP or IGES header of an export named `file_name`: the design's author, organization
|
||||
/// and description (#1015).
|
||||
fn step_header(file_name: String, meta: &Metadata) -> solidcraft_kernel::StepHeader {
|
||||
solidcraft_kernel::StepHeader {
|
||||
file_name,
|
||||
author: header_line(meta.author()),
|
||||
organization: header_line(meta.organization()),
|
||||
description: header_line(meta.description()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A header field is one line: each run of white space (line breaks too) becomes one space.
|
||||
fn header_line(text: Option<&str>) -> String {
|
||||
text.map(|t| t.split_whitespace().collect::<Vec<_>>().join(" ")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 3MF model metadata from the design properties, by the names the 3MF core specification
|
||||
/// gives them. Other tools' keys are never exported.
|
||||
fn threemf_metadata(meta: &Metadata) -> Vec<(&'static str, &str)> {
|
||||
[("Title", meta.project()), ("Designer", meta.author()), ("Description", meta.description()), ("LicenseTerms", meta.license())]
|
||||
.into_iter()
|
||||
.filter_map(|(name, text)| Some((name, text?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`export`], with the design properties `meta` in the formats that have a place for them:
|
||||
/// STEP and IGES headers (author, organization; STEP also the description) and 3MF model
|
||||
/// metadata (project as the title, author as the designer, description, licence).
|
||||
pub fn export_with_metadata(state: &ModelState, bodies: &[String], format: Format, name: &str, meta: &Metadata) -> Result<Vec<u8>> {
|
||||
let sel: Vec<&solidcraft_doc::ModelBody> = state.bodies.iter().filter(|b| bodies.is_empty() || bodies.contains(&b.name)).collect();
|
||||
if sel.is_empty() {
|
||||
return Err(IoError::Empty);
|
||||
@@ -335,18 +368,16 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
|
||||
Format::Step => {
|
||||
let bs: Vec<solidcraft_kernel::ExportBody> =
|
||||
sel.iter().map(|b| solidcraft_kernel::ExportBody { name: b.name.clone(), body: &b.body, color: b.body.color() }).collect();
|
||||
let header = solidcraft_kernel::StepHeader { file_name: format!("{name}.step"), ..Default::default() };
|
||||
solidcraft_kernel::step_export_bodies(&bs, &header)?.into_bytes()
|
||||
solidcraft_kernel::step_export_bodies(&bs, &step_header(format!("{name}.step"), meta))?.into_bytes()
|
||||
}
|
||||
Format::Iges => {
|
||||
let bs: Vec<solidcraft_kernel::ExportBody> =
|
||||
sel.iter().map(|b| solidcraft_kernel::ExportBody { name: b.name.clone(), body: &b.body, color: b.body.color() }).collect();
|
||||
let header = solidcraft_kernel::StepHeader { file_name: format!("{name}.igs"), ..Default::default() };
|
||||
solidcraft_kernel::iges_export_bodies(&bs, &header)?.into_bytes()
|
||||
solidcraft_kernel::iges_export_bodies(&bs, &step_header(format!("{name}.igs"), meta))?.into_bytes()
|
||||
}
|
||||
Format::ThreeMf | Format::ThreeMfTagged => {
|
||||
let objs: Vec<MeshObject> = sel.iter().map(|b| mesh_object(b, format == Format::ThreeMfTagged)).collect::<Result<_>>()?;
|
||||
write_3mf(&objs)?
|
||||
write_3mf_with(&objs, &threemf_metadata(meta))?
|
||||
}
|
||||
Format::Design => return Err(IoError::Format("solidcraft (save the document instead)".into())),
|
||||
Format::Urdf => return Err(IoError::Format("urdf (an assembly package; use file.export)".into())),
|
||||
@@ -408,8 +439,7 @@ pub fn step_assembly(doc: &Document, local: &ModelState, name: &str) -> Result<V
|
||||
p.children.push((child, o.transform, o.name.clone()));
|
||||
}
|
||||
}
|
||||
let header = solidcraft_kernel::StepHeader { file_name: format!("{name}.step"), ..Default::default() };
|
||||
Ok(solidcraft_kernel::step_export_products(&products, 0, &header)?.into_bytes())
|
||||
Ok(solidcraft_kernel::step_export_products(&products, 0, &step_header(format!("{name}.step"), &doc.metadata))?.into_bytes())
|
||||
}
|
||||
|
||||
/// Read a design file's bytes.
|
||||
@@ -595,6 +625,48 @@ mod tests {
|
||||
assert!(o.contains("o Body1") && o.contains("\nf "));
|
||||
}
|
||||
|
||||
/// The design properties land in each format's own metadata (#1015): STEP and IGES headers,
|
||||
/// 3MF model metadata. Other tools' keys never leave the design file.
|
||||
#[test]
|
||||
fn exports_write_design_properties() {
|
||||
let (_, m) = model();
|
||||
let meta: Metadata = serde_json::from_value(serde_json::json!({
|
||||
"project": "ACME <enclosures>", "author": "Jane O'Brien", "organization": "ACME", "license": "CC-BY-4.0",
|
||||
"description": "Vented Gehäuse\nfor a Pi", "revision": 7, "com.acme.secret": {"token": "SECRET-VALUE"},
|
||||
}))
|
||||
.unwrap();
|
||||
let step = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Step, "t", &meta).unwrap()).unwrap();
|
||||
assert!(step.contains("FILE_DESCRIPTION(('Vented Geh\\X2\\00E4\\X0\\use for a Pi'),'2;1');"), "{}", &step[..300]);
|
||||
assert!(step.contains("FILE_NAME('t.step','',('Jane O''Brien'),('ACME'),"), "{}", &step[..300]);
|
||||
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["Body1"], "still reads back");
|
||||
let iges = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Iges, "t", &meta).unwrap()).unwrap();
|
||||
assert!(iges.contains("12HJane O'Brien") && iges.contains("4HACME"));
|
||||
let model_xml = |bytes: Vec<u8>| {
|
||||
use std::io::Read as _;
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut x = String::new();
|
||||
zip.by_name("3D/3dmodel.model").unwrap().read_to_string(&mut x).unwrap();
|
||||
x
|
||||
};
|
||||
let x = model_xml(export_with_metadata(&m.state(), &[], Format::ThreeMf, "t", &meta).unwrap());
|
||||
for want in [
|
||||
"<metadata name=\"Application\">SolidCraft</metadata>\n <metadata name=\"Title\">ACME <enclosures></metadata>",
|
||||
"<metadata name=\"Designer\">Jane O'Brien</metadata>",
|
||||
"<metadata name=\"Description\">Vented Gehäuse\nfor a Pi</metadata>",
|
||||
"<metadata name=\"LicenseTerms\">CC-BY-4.0</metadata>",
|
||||
] {
|
||||
assert!(x.contains(want), "{want}\n{}", &x[..400]);
|
||||
}
|
||||
assert!(write_3mf_with(&[], &[("Title", "x")]).is_err(), "no objects is still an error");
|
||||
for out in [step.as_str(), iges.as_str(), x.as_str()] {
|
||||
assert!(!out.contains("SECRET-VALUE") && !out.contains("com.acme"));
|
||||
}
|
||||
// Without properties the files are as they always were.
|
||||
let plain = String::from_utf8(export(&m.state(), &[], Format::Step, "t").unwrap()).unwrap();
|
||||
assert!(plain.contains("FILE_DESCRIPTION(('SolidCraft model'),'2;1');\nFILE_NAME('t.step','',(''),(''),"));
|
||||
assert!(!model_xml(export(&m.state(), &[], Format::ThreeMf, "t").unwrap()).contains("Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_and_design() {
|
||||
let (doc, m) = model();
|
||||
|
||||
@@ -239,11 +239,21 @@ pub fn weld_tagged(positions: &[Vec3], triangles: &[[u32; 3]], tags: &[u32], eps
|
||||
|
||||
/// The model part's XML.
|
||||
pub fn model_xml(objects: &[MeshObject]) -> String {
|
||||
model_xml_with(objects, &[])
|
||||
}
|
||||
|
||||
/// The model part's XML with model metadata after `Application`: (3MF metadata name, such as
|
||||
/// `Title` or `Designer`, and its text).
|
||||
pub fn model_xml_with(objects: &[MeshObject], metadata: &[(&str, &str)]) -> String {
|
||||
let mut x = String::new();
|
||||
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
let ft = if objects.iter().any(MeshObject::tagged) { format!(" xmlns:ft=\"{FACE_TAGS_NS}\"") } else { String::new() };
|
||||
let _ = writeln!(x, "<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"{CORE_NS}\"{ft}>");
|
||||
x.push_str(" <metadata name=\"Application\">SolidCraft</metadata>\n <resources>\n");
|
||||
x.push_str(" <metadata name=\"Application\">SolidCraft</metadata>\n");
|
||||
for (name, text) in metadata {
|
||||
let _ = writeln!(x, " <metadata name=\"{}\">{}</metadata>", esc(name), esc(text));
|
||||
}
|
||||
x.push_str(" <resources>\n");
|
||||
let colored = objects.iter().any(|o| o.color.is_some() || o.transparency > 0.0 || !o.looks.is_empty());
|
||||
// Base material group id 1 (each object's own colour, then its faces' colours); objects
|
||||
// from 2. A see-through colour carries its alpha (#RRGGBBAA).
|
||||
@@ -308,6 +318,11 @@ pub fn model_xml(objects: &[MeshObject]) -> String {
|
||||
|
||||
/// A 3MF package of meshes.
|
||||
pub fn write_3mf(objects: &[MeshObject]) -> Result<Vec<u8>> {
|
||||
write_3mf_with(objects, &[])
|
||||
}
|
||||
|
||||
/// A 3MF package of meshes with model metadata (see [`model_xml_with`]).
|
||||
pub fn write_3mf_with(objects: &[MeshObject], metadata: &[(&str, &str)]) -> Result<Vec<u8>> {
|
||||
if objects.is_empty() {
|
||||
return Err(IoError::Empty);
|
||||
}
|
||||
@@ -315,7 +330,7 @@ pub fn write_3mf(objects: &[MeshObject]) -> Result<Vec<u8>> {
|
||||
let rels = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n <Relationship Target=\"/3D/3dmodel.model\" Id=\"rel0\" Type=\"{MODEL_REL}\"/>\n</Relationships>\n"
|
||||
);
|
||||
let model = model_xml(objects);
|
||||
let model = model_xml_with(objects, metadata);
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in [("[Content_Types].xml", content_types.as_bytes()), ("_rels/.rels", rels.as_bytes()), ("3D/3dmodel.model", model.as_bytes())]
|
||||
{
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct StepHeader {
|
||||
pub time_stamp: String,
|
||||
pub author: String,
|
||||
pub organization: String,
|
||||
/// FILE_DESCRIPTION text; `SolidCraft model` when empty.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Entities whose identity is topological: never merged even when their text is equal.
|
||||
@@ -1531,7 +1533,8 @@ fn products_text(products: &[ExportProduct], root: usize, header: &StepHeader, m
|
||||
|
||||
let mut s = String::new();
|
||||
s.push_str("ISO-10303-21;\nHEADER;\n");
|
||||
s.push_str("FILE_DESCRIPTION(('SolidCraft model'),'2;1');\n");
|
||||
let description = if header.description.is_empty() { "SolidCraft model" } else { header.description.as_str() };
|
||||
let _ = writeln!(s, "FILE_DESCRIPTION(({}),'2;1');", string(description));
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"FILE_NAME({},{},({}),({}),'SolidCraft','SolidCraft','');",
|
||||
|
||||
@@ -242,6 +242,18 @@ fn parameters_drive_the_design() {
|
||||
assert!(d["params"].as_array().unwrap().iter().any(|p| p["name"] == "len" && p["value"] == 50.0), "{d}");
|
||||
}
|
||||
|
||||
/// Design properties (#1015) are set through `execute` and come back in `inspect_design`.
|
||||
#[test]
|
||||
fn design_properties_show_in_inspect_design() {
|
||||
let mut s = server();
|
||||
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||||
assert_eq!(d["metadata"], json!({}), "{d}");
|
||||
let params = json!({"values": {"author": "Agent", "com.example.agent": {"session": 7}}});
|
||||
tool(&mut s, "execute", json!({"command": "document.metadata.set", "params": params}));
|
||||
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||||
assert_eq!(d["metadata"], json!({"author": "Agent", "com.example.agent": {"session": 7}}), "{d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_stops_at_first_error_and_rolls_back() {
|
||||
let mut s = server();
|
||||
|
||||
@@ -798,6 +798,11 @@ fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
||||
if draw_row(ui, ui.id().with("params"), &Row { depth: depth + 1, icon: "params", label: ¶ms, ..Default::default() }).clicked {
|
||||
app.start("parameters.change");
|
||||
}
|
||||
if draw_row(ui, ui.id().with("design_properties"), &Row { depth: depth + 1, icon: "settings", label: "Design Properties", ..Default::default() })
|
||||
.clicked
|
||||
{
|
||||
app.start("document.metadata");
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
||||
|
||||
@@ -653,6 +653,9 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
.on(id != 0 || !bodies.is_empty()),
|
||||
);
|
||||
v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id })));
|
||||
if id == 0 {
|
||||
v.push(act("ui.designProperties", "Design Properties", "settings"));
|
||||
}
|
||||
v.push(Item::sep());
|
||||
v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
|
||||
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
|
||||
@@ -881,6 +884,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
Err(e) => app.set_status(e.to_string(), true),
|
||||
}
|
||||
}
|
||||
"ui.designProperties" => app.start("document.metadata"),
|
||||
"ui.export" => {
|
||||
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
|
||||
app.pick_save_command(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"], "file.export", json!({"bodies": bodies}));
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! The Design Properties window (#1015): the design's project, part number, revision, author,
|
||||
//! organization, description and licence, and a read-only list of the data other tools keep in
|
||||
//! the design. OK applies the edited fields through one `document.metadata.set` (one undo step);
|
||||
//! Cancel and the close button change nothing. File → Design Properties…, the Browser's
|
||||
//! Document Settings, the root component's menu and `ui.start` of `document.metadata` or
|
||||
//! `document.metadata.set` open it.
|
||||
|
||||
use egui::RichText;
|
||||
use serde_json::{Map, Value};
|
||||
use solidcraft_engine::doc::metadata::{DESCRIPTION, WELL_KNOWN};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Longest preview of another tool's value in the list.
|
||||
const PREVIEW_CHARS: usize = 80;
|
||||
|
||||
/// What is being typed; the properties themselves live in the document.
|
||||
pub struct DesignProperties {
|
||||
/// Each well-known key's text when the window opened, in [`WELL_KNOWN`] order.
|
||||
pub opened: [String; WELL_KNOWN.len()],
|
||||
/// The fields as edited.
|
||||
pub fields: [String; WELL_KNOWN.len()],
|
||||
/// The last refusal from `document.metadata.set`.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl DesignProperties {
|
||||
pub fn new(app: &SolveApp) -> Self {
|
||||
let m = &app.session.doc.metadata;
|
||||
let opened = WELL_KNOWN.map(|k| m.text(k).unwrap_or_default().to_string());
|
||||
DesignProperties { fields: opened.clone(), opened, error: None }
|
||||
}
|
||||
|
||||
/// The fields that differ from what the window opened with, as `document.metadata.set`
|
||||
/// takes them (empty text removes the key).
|
||||
pub fn changes(&self) -> Map<String, Value> {
|
||||
WELL_KNOWN
|
||||
.iter()
|
||||
.zip(self.opened.iter().zip(&self.fields))
|
||||
.filter(|(_, (was, now))| was != now)
|
||||
.map(|(k, (_, now))| (k.to_string(), Value::String(now.clone())))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the window (a fresh copy of the design's properties).
|
||||
pub fn open(app: &mut SolveApp) {
|
||||
app.design_properties = Some(DesignProperties::new(app));
|
||||
}
|
||||
|
||||
/// Apply the edits; the window stays open with the reason when they are refused.
|
||||
fn apply(app: &mut SolveApp, d: &mut DesignProperties) -> bool {
|
||||
let values = d.changes();
|
||||
if values.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match app.run("document.metadata.set", serde_json::json!({ "values": values })) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
d.error = Some(e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn label(key: &str) -> &'static str {
|
||||
match key {
|
||||
"project" => crate::i18n::tr("ui.design_properties.project"),
|
||||
"part_number" => crate::i18n::tr("ui.design_properties.part_number"),
|
||||
"revision" => crate::i18n::tr("ui.design_properties.revision"),
|
||||
"author" => crate::i18n::tr("ui.design_properties.author"),
|
||||
"organization" => crate::i18n::tr("ui.design_properties.organization"),
|
||||
"description" => crate::i18n::tr("ui.design_properties.description"),
|
||||
_ => crate::i18n::tr("ui.design_properties.license"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A short one-line view of a value another tool keeps.
|
||||
fn preview(v: &Value) -> String {
|
||||
let text = serde_json::to_string(v).unwrap_or_default();
|
||||
if text.chars().count() <= PREVIEW_CHARS { text } else { format!("{}…", text.chars().take(PREVIEW_CHARS).collect::<String>()) }
|
||||
}
|
||||
|
||||
/// Draw the window when it is open. Called once per frame.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.design_properties.take() else { return };
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
let metadata = app.session.doc.metadata.clone();
|
||||
let title = RichText::new(crate::i18n::tr_label("Design Properties")).strong().size(13.0);
|
||||
crate::frame::window(ctx, title, crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_design_properties"))
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.min_width(420.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("sc_design_properties_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for (i, key) in WELL_KNOWN.iter().enumerate() {
|
||||
ui.label(label(key));
|
||||
let Some(field) = d.fields.get_mut(i) else { continue };
|
||||
ui.vertical(|ui| {
|
||||
let edit =
|
||||
if *key == DESCRIPTION { egui::TextEdit::multiline(field).desired_rows(3) } else { egui::TextEdit::singleline(field) };
|
||||
ui.add(edit.desired_width(280.0));
|
||||
// Saved by another tool as something other than text: kept unless typed over.
|
||||
if metadata.get(key).is_some() && metadata.text(key).is_none() && d.opened.get(i).is_some_and(String::is_empty) {
|
||||
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.not_text")).small().color(t.text_dim));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.exports")).small().color(t.text_dim));
|
||||
let others: Vec<(&String, &Value)> = metadata.others().collect();
|
||||
if !others.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
egui::CollapsingHeader::new(format!("{} ({})", crate::i18n::tr("ui.design_properties.others"), others.len()))
|
||||
.id_salt("sc_design_properties_others")
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.others_note")).small().color(t.text_dim));
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
for (k, v) in others {
|
||||
ui.label(RichText::new(k).monospace().size(12.0).color(t.text));
|
||||
ui.label(RichText::new(preview(v)).monospace().small().color(t.text_dim));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(e) = &d.error {
|
||||
ui.label(RichText::new(e).color(t.error));
|
||||
}
|
||||
ui.separator();
|
||||
// OK and Cancel flush right, as in every dialog.
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
cancel = ui.button(crate::i18n::tr("ui.cancel")).clicked();
|
||||
ok = ui.button(crate::i18n::tr("ui.heading_ok")).clicked();
|
||||
});
|
||||
});
|
||||
});
|
||||
if !open || cancel || (ok && apply(app, &mut d)) {
|
||||
return;
|
||||
}
|
||||
app.design_properties = Some(d);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
#[test]
|
||||
fn the_window_edits_properties_in_one_undo_step_and_keeps_other_tools_data() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("document.metadata.set", json!({"values": {"author": "Jane", "com.acme.tool": {"id": 7}}})).unwrap();
|
||||
// A well-known key another tool saved as a number.
|
||||
h.app.session.doc_mut().metadata.0.insert("revision".into(), json!(3));
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start": "document.metadata.set"})).unwrap();
|
||||
assert!(h.app.design_properties.is_some(), "ui.start opens the window, it doesn't run the command");
|
||||
assert!(h.widget("Jane").is_some(), "the author's field");
|
||||
assert!(h.widget("Other tools' data (1)").is_some());
|
||||
let d = h.app.design_properties.as_mut().unwrap();
|
||||
assert_eq!(d.opened[2], "", "a number is not a revision");
|
||||
d.fields[0] = "ACME enclosures".into();
|
||||
d.fields[3] = String::new();
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget": "OK"})).unwrap();
|
||||
assert!(h.app.design_properties.is_none());
|
||||
let m = &h.app.session.doc.metadata;
|
||||
assert_eq!(m.project(), Some("ACME enclosures"));
|
||||
assert_eq!(m.author(), None, "an emptied field removes the key");
|
||||
assert_eq!(m.get("revision"), Some(&json!(3)), "an untouched field keeps what another tool saved");
|
||||
assert_eq!(m.get("com.acme.tool"), Some(&json!({"id": 7})));
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step");
|
||||
|
||||
// Cancel changes nothing; a refused value keeps the window open with the reason.
|
||||
h.step(&json!({"start": "document.metadata"})).unwrap();
|
||||
h.app.design_properties.as_mut().unwrap().fields[0] = "Other".into();
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget": "Cancel"})).unwrap();
|
||||
assert!(h.app.design_properties.is_none());
|
||||
assert_eq!(h.app.session.doc.metadata.project(), Some("ACME enclosures"));
|
||||
h.step(&json!({"start": "document.metadata.set"})).unwrap();
|
||||
h.app.design_properties.as_mut().unwrap().fields[1] = "bad\u{7}".into();
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget": "OK"})).unwrap();
|
||||
let d = h.app.design_properties.as_ref().expect("still open");
|
||||
assert!(d.error.as_deref().is_some_and(|e| e.contains("control character")), "{:?}", d.error);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_menu_browser_and_root_component_open_the_window() {
|
||||
let mut h = Harness::new();
|
||||
let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Component { id: 0 });
|
||||
let item = items.iter().find(|i| i.id == "ui.designProperties").expect("root component menu item");
|
||||
assert_eq!(item.label, "Design Properties");
|
||||
crate::context_menu::run_item(&mut h.app, item, egui::pos2(0.0, 0.0));
|
||||
assert!(h.app.design_properties.is_some());
|
||||
h.app.design_properties = None;
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), true));
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget": "Design Properties…"})).unwrap();
|
||||
assert!(h.app.design_properties.is_some());
|
||||
h.app.design_properties = None;
|
||||
// Browser → Document Settings → Design Properties.
|
||||
h.step(&json!({"click": {"chevron": "Document Settings"}})).unwrap();
|
||||
h.step(&json!({"click": {"browser": "Design Properties"}})).unwrap();
|
||||
assert!(h.app.design_properties.is_some());
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
crate::dim_view::cancel();
|
||||
app.tool = None;
|
||||
app.params = None;
|
||||
app.design_properties = None;
|
||||
app.cancel_view_animation();
|
||||
app.viewport.orbit_snap = None;
|
||||
app.viewport.orbit_free = None;
|
||||
|
||||
@@ -295,6 +295,7 @@ mod tests {
|
||||
include_str!("dialogs.rs"),
|
||||
include_str!("browser.rs"),
|
||||
include_str!("sketch_palette.rs"),
|
||||
include_str!("design_properties.rs"),
|
||||
] {
|
||||
for call in [".tr(\"", "i18n::tr(\"", "i18n::format(\""] {
|
||||
for tail in source.split(call).skip(1) {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod context_menu;
|
||||
pub mod control;
|
||||
pub mod crash_ui;
|
||||
pub mod delete;
|
||||
pub mod design_properties;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_appearance;
|
||||
pub mod dialogs_assembly;
|
||||
@@ -250,6 +251,8 @@ pub struct SolveApp {
|
||||
pub dialog: Option<dialogs::Dialog>,
|
||||
/// The Change Parameters window, when open.
|
||||
pub params: Option<params_dialog::ParamsDialog>,
|
||||
/// The Design Properties window, when open.
|
||||
pub design_properties: Option<design_properties::DesignProperties>,
|
||||
pub palette: palette::Palette,
|
||||
/// Context menus, rename box and Properties window.
|
||||
pub menu: context_menu::MenuState,
|
||||
@@ -343,6 +346,7 @@ impl SolveApp {
|
||||
tool: None,
|
||||
dialog: None,
|
||||
params: None,
|
||||
design_properties: None,
|
||||
palette: palette::Palette::default(),
|
||||
menu: context_menu::MenuState::default(),
|
||||
tree: browser::TreeState::default(),
|
||||
@@ -631,6 +635,10 @@ impl SolveApp {
|
||||
params_dialog::open(self);
|
||||
return;
|
||||
}
|
||||
if matches!(id, "document.metadata" | "document.metadata.set") {
|
||||
design_properties::open(self);
|
||||
return;
|
||||
}
|
||||
if id == "sketch.finish" {
|
||||
self.finish_sketch();
|
||||
return;
|
||||
@@ -1158,6 +1166,7 @@ impl SolveApp {
|
||||
sketch_palette::show(self, ui.ctx());
|
||||
self.resolve_fit(ui.ctx());
|
||||
params_dialog::show(self, ui.ctx());
|
||||
design_properties::show(self, ui.ctx());
|
||||
recovery_ui::show(self, ui.ctx());
|
||||
stand_up::show(self, ui.ctx());
|
||||
prefs::show_reset_notice(self, ui.ctx());
|
||||
|
||||
@@ -405,6 +405,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item_if(ui, &format!("{}…", app.tr_label("Design Properties")), "", shown) {
|
||||
app.start("document.metadata");
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
|
||||
app.prefs_window.open = true;
|
||||
close = true;
|
||||
|
||||
@@ -52,6 +52,7 @@ fn command_dialogs_shrink_to_their_content_within_limits() {
|
||||
}
|
||||
h.app.dialog = None;
|
||||
h.app.params = None;
|
||||
h.app.design_properties = None;
|
||||
h.app.tool = None;
|
||||
if h.app.session.active_sketch.is_some() {
|
||||
h.app.finish_sketch();
|
||||
@@ -83,6 +84,7 @@ fn floating_windows_stay_within_limits() {
|
||||
h.app.help.about = true;
|
||||
h.app.ui.palette_open = true;
|
||||
solidcraft_ui_egui::params_dialog::open(&mut h.app);
|
||||
solidcraft_ui_egui::design_properties::open(&mut h.app);
|
||||
h.frames(6);
|
||||
let ws = window_widths(&h);
|
||||
// Keyboard Shortcuts is a page of Preferences (one window, #36).
|
||||
|
||||
Reference in new issue
Block a user