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:
Lasse Lian authored and GitHub committed 2026-10-14 18:25:35 -04:00
1 parent 78079587ad
commit a4a8b48567
33 files changed
+1106 -21

No files matched your search

+4
View File
@@ -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,
+17 -1
View File
@@ -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<()> {
+2
View File
@@ -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;
+263
View File
@@ -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");
}
}
+3 -2
View File
@@ -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:?}")}))
+1 -1
View File
@@ -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,
+261
View File
@@ -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"}));
}
}
+2
View File
@@ -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());
+16
View File
@@ -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())
+17
View File
@@ -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();
+8 -1
View File
@@ -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
View File
@@ -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 &lt;enclosures&gt;</metadata>",
"<metadata name=\"Designer\">Jane O&apos;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();
+17 -2
View File
@@ -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())]
{
+4 -1
View File
@@ -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','');",
+12
View File
@@ -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();
+5
View File
@@ -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: &params, ..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) {
+4
View File
@@ -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}));
+216
View File
@@ -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());
}
}
+1
View File
@@ -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;
+1
View File
@@ -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) {
+9
View File
@@ -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());
+5
View File
@@ -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;
+2
View File
@@ -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).