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
@@ -453,6 +453,15 @@ editing. Construction planes and points resolve drawn 3D wires without applying
|
||||
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
|
||||
and reference-object placement remain separate work, as do loft rails and centerlines.
|
||||
|
||||
## Design properties (#1015)
|
||||
|
||||
- Designs carry project, part number, revision, author, organization, description and licence
|
||||
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
|
||||
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
|
||||
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
|
||||
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
|
||||
Per-component properties (a part number per component) can follow with the same type.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
|
||||
|
||||
@@ -381,6 +381,17 @@
|
||||
"ui.pinToShortcuts": "An Verknüpfungen anheften",
|
||||
"ui.pinToToolbar": "An Symbolleiste anheften",
|
||||
"ui.properties": "Eigenschaften",
|
||||
"ui.design_properties.project": "Projekt",
|
||||
"ui.design_properties.part_number": "Teilenummer",
|
||||
"ui.design_properties.revision": "Revision",
|
||||
"ui.design_properties.author": "Autor",
|
||||
"ui.design_properties.organization": "Organisation",
|
||||
"ui.design_properties.description": "Beschreibung",
|
||||
"ui.design_properties.license": "Lizenz",
|
||||
"ui.design_properties.not_text": "Nicht als Text gespeichert; eine Eingabe hier ersetzt den Wert.",
|
||||
"ui.design_properties.exports": "STEP-, IGES- und 3MF-Exporte übernehmen diese Angaben, wo das Format ein Feld dafür hat.",
|
||||
"ui.design_properties.others": "Daten anderer Werkzeuge",
|
||||
"ui.design_properties.others_note": "Bleiben unverändert in der Konstruktion und werden nie exportiert.",
|
||||
"ui.removeSection": "Schnitt entfernen",
|
||||
"ui.removeFromToolbar": "Aus der Symbolleiste entfernen",
|
||||
"ui.repeat": "Wiederholen",
|
||||
@@ -478,6 +489,9 @@
|
||||
"cmd.doc_recovery_list": "Wiederherstellbare Konstruktionen",
|
||||
"cmd.document_inspect": "Konstruktion prüfen",
|
||||
"cmd.document_units": "Einheiten der Konstruktion ändern",
|
||||
"cmd.document_metadata": "Konstruktionseigenschaften",
|
||||
"cmd.document_metadata_set": "Konstruktionseigenschaften ändern",
|
||||
"cmd.document_metadata_remove": "Konstruktionseigenschaft entfernen",
|
||||
"cmd.edit_checkpoint": "Prüfpunkt",
|
||||
"cmd.edit_restore_checkpoint": "Prüfpunkt wiederherstellen",
|
||||
"cmd.engine_commands": "Befehle auflisten",
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"cmd.doc_recovery_list": "Recoverable Designs",
|
||||
"cmd.document_inspect": "Inspect Design",
|
||||
"cmd.document_units": "Change Design Units",
|
||||
"cmd.document_metadata": "Design Properties",
|
||||
"cmd.document_metadata_set": "Change Design Properties",
|
||||
"cmd.document_metadata_remove": "Remove Design Property",
|
||||
"cmd.edit_checkpoint": "Checkpoint",
|
||||
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
|
||||
"cmd.engine_commands": "List Commands",
|
||||
@@ -934,6 +937,17 @@
|
||||
"ui.pinToShortcuts": "Pin to Shortcuts",
|
||||
"ui.pinToToolbar": "Pin to Toolbar",
|
||||
"ui.properties": "Properties",
|
||||
"ui.design_properties.project": "Project",
|
||||
"ui.design_properties.part_number": "Part number",
|
||||
"ui.design_properties.revision": "Revision",
|
||||
"ui.design_properties.author": "Author",
|
||||
"ui.design_properties.organization": "Organization",
|
||||
"ui.design_properties.description": "Description",
|
||||
"ui.design_properties.license": "License",
|
||||
"ui.design_properties.not_text": "Saved as something other than text; typing here replaces it.",
|
||||
"ui.design_properties.exports": "STEP, IGES and 3MF exports carry these where the format has a place for them.",
|
||||
"ui.design_properties.others": "Other tools' data",
|
||||
"ui.design_properties.others_note": "Kept in the design as it is and never exported.",
|
||||
"ui.removeSection": "Remove Section",
|
||||
"ui.removeFromToolbar": "Remove from Toolbar",
|
||||
"ui.repeat": "Repeat",
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"cmd.doc_recovery_list": "復元可能なデザイン",
|
||||
"cmd.document_inspect": "デザインを検査",
|
||||
"cmd.document_units": "デザインの単位を変更",
|
||||
"cmd.document_metadata": "デザインのプロパティ",
|
||||
"cmd.document_metadata_set": "デザインのプロパティを変更",
|
||||
"cmd.document_metadata_remove": "デザインのプロパティを削除",
|
||||
"cmd.edit_checkpoint": "チェックポイント",
|
||||
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
|
||||
"cmd.engine_commands": "コマンドを一覧表示",
|
||||
@@ -934,6 +937,17 @@
|
||||
"ui.pinToShortcuts": "ショートカットに固定",
|
||||
"ui.pinToToolbar": "ツールバーに固定",
|
||||
"ui.properties": "プロパティ",
|
||||
"ui.design_properties.project": "プロジェクト",
|
||||
"ui.design_properties.part_number": "部品番号",
|
||||
"ui.design_properties.revision": "リビジョン",
|
||||
"ui.design_properties.author": "作成者",
|
||||
"ui.design_properties.organization": "組織",
|
||||
"ui.design_properties.description": "説明",
|
||||
"ui.design_properties.license": "ライセンス",
|
||||
"ui.design_properties.not_text": "テキスト以外の値で保存されています。ここに入力すると置き換わります。",
|
||||
"ui.design_properties.exports": "STEP、IGES、3MF のエクスポートでは、形式に対応する項目があればこれらが書き込まれます。",
|
||||
"ui.design_properties.others": "他のツールのデータ",
|
||||
"ui.design_properties.others_note": "デザイン内にそのまま保持され、エクスポートされることはありません。",
|
||||
"ui.removeSection": "セクションを解除",
|
||||
"ui.removeFromToolbar": "ツールバーから削除",
|
||||
"ui.repeat": "繰り返し",
|
||||
|
||||
@@ -381,6 +381,17 @@
|
||||
"ui.pinToShortcuts": "바로 가기에 고정",
|
||||
"ui.pinToToolbar": "도구 모음에 고정",
|
||||
"ui.properties": "속성",
|
||||
"ui.design_properties.project": "프로젝트",
|
||||
"ui.design_properties.part_number": "부품 번호",
|
||||
"ui.design_properties.revision": "리비전",
|
||||
"ui.design_properties.author": "작성자",
|
||||
"ui.design_properties.organization": "조직",
|
||||
"ui.design_properties.description": "설명",
|
||||
"ui.design_properties.license": "라이선스",
|
||||
"ui.design_properties.not_text": "텍스트가 아닌 값으로 저장되어 있습니다. 여기에 입력하면 대체됩니다.",
|
||||
"ui.design_properties.exports": "STEP, IGES, 3MF 내보내기는 형식에 해당 항목이 있으면 이 속성을 기록합니다.",
|
||||
"ui.design_properties.others": "다른 도구의 데이터",
|
||||
"ui.design_properties.others_note": "설계에 그대로 유지되며 내보내지지 않습니다.",
|
||||
"ui.removeSection": "단면 제거",
|
||||
"ui.removeFromToolbar": "도구 모음에서 제거",
|
||||
"ui.repeat": "반복",
|
||||
@@ -478,6 +489,9 @@
|
||||
"cmd.doc_recovery_list": "복구 가능한 설계",
|
||||
"cmd.document_inspect": "설계 검사",
|
||||
"cmd.document_units": "설계 단위 변경",
|
||||
"cmd.document_metadata": "설계 속성",
|
||||
"cmd.document_metadata_set": "설계 속성 변경",
|
||||
"cmd.document_metadata_remove": "설계 속성 제거",
|
||||
"cmd.edit_checkpoint": "체크포인트",
|
||||
"cmd.edit_restore_checkpoint": "체크포인트 복원",
|
||||
"cmd.engine_commands": "명령 목록",
|
||||
|
||||
@@ -77,6 +77,9 @@
|
||||
"cmd.doc_recovery_list": "Восстановимые дизайны",
|
||||
"cmd.document_inspect": "Инспекция дизайна",
|
||||
"cmd.document_units": "Изменить единицы дизайна",
|
||||
"cmd.document_metadata": "Свойства дизайна",
|
||||
"cmd.document_metadata_set": "Изменить свойства дизайна",
|
||||
"cmd.document_metadata_remove": "Удалить свойство дизайна",
|
||||
"cmd.edit_checkpoint": "Контрольная точка",
|
||||
"cmd.edit_restore_checkpoint": "Вернуть контрольную точку",
|
||||
"cmd.engine_commands": "Список команд",
|
||||
@@ -926,6 +929,17 @@
|
||||
"ui.pinToShortcuts": "Закрепить в сочетаниях клавиш",
|
||||
"ui.pinToToolbar": "Закрепить на панели",
|
||||
"ui.properties": "Свойства",
|
||||
"ui.design_properties.project": "Проект",
|
||||
"ui.design_properties.part_number": "Номер детали",
|
||||
"ui.design_properties.revision": "Редакция",
|
||||
"ui.design_properties.author": "Автор",
|
||||
"ui.design_properties.organization": "Организация",
|
||||
"ui.design_properties.description": "Описание",
|
||||
"ui.design_properties.license": "Лицензия",
|
||||
"ui.design_properties.not_text": "Сохранено не как текст; ввод здесь заменит значение.",
|
||||
"ui.design_properties.exports": "Экспорт в STEP, IGES и 3MF записывает эти свойства, если в формате для них есть поле.",
|
||||
"ui.design_properties.others": "Данные других программ",
|
||||
"ui.design_properties.others_note": "Хранятся в дизайне без изменений и никогда не экспортируются.",
|
||||
"ui.removeSection": "Убрать сечение",
|
||||
"ui.removeFromToolbar": "Убрать с панели",
|
||||
"ui.repeat": "Повторить",
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
"cmd.doc_recovery_list": "可恢复的设计",
|
||||
"cmd.document_inspect": "检查设计",
|
||||
"cmd.document_units": "更改设计单位",
|
||||
"cmd.document_metadata": "设计属性",
|
||||
"cmd.document_metadata_set": "更改设计属性",
|
||||
"cmd.document_metadata_remove": "删除设计属性",
|
||||
"cmd.edit_checkpoint": "检查点",
|
||||
"cmd.edit_restore_checkpoint": "恢复检查点",
|
||||
"cmd.engine_commands": "列出命令",
|
||||
@@ -932,6 +935,17 @@
|
||||
"ui.pinToShortcuts": "固定到快捷方式",
|
||||
"ui.pinToToolbar": "固定到工具栏",
|
||||
"ui.properties": "属性",
|
||||
"ui.design_properties.project": "项目",
|
||||
"ui.design_properties.part_number": "零件号",
|
||||
"ui.design_properties.revision": "版本",
|
||||
"ui.design_properties.author": "作者",
|
||||
"ui.design_properties.organization": "组织",
|
||||
"ui.design_properties.description": "描述",
|
||||
"ui.design_properties.license": "许可证",
|
||||
"ui.design_properties.not_text": "已保存为非文本值;在此输入将替换它。",
|
||||
"ui.design_properties.exports": "导出 STEP、IGES 和 3MF 时,若格式有对应字段,会写入这些属性。",
|
||||
"ui.design_properties.others": "其他工具的数据",
|
||||
"ui.design_properties.others_note": "按原样保存在设计中,从不导出。",
|
||||
"ui.removeSection": "移除剖面",
|
||||
"ui.removeFromToolbar": "从工具栏移除",
|
||||
"ui.repeat": "重复",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -73,7 +73,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
|
||||
The default `cache: false` omits the portable thumbnail too.
|
||||
The repository's `docs/design-container.md` describes limits and encoding details.
|
||||
|
||||
Current designs use format 20, including thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
Current designs use format 21, including design properties, named render setups, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
links, persistent
|
||||
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
|
||||
association profile references, durable sketch face anchors, periodic fit splines and versioned
|
||||
@@ -86,6 +86,61 @@ retain world coordinates. A lost cross-component association refreshed by an old
|
||||
need an explicit relink or refresh when its source returns. Loading never guesses a numerical
|
||||
correction. Association records, coordinate arrays and placements are bounded and checked.
|
||||
|
||||
## Design properties
|
||||
|
||||
**File → Design Properties…** (also under **Document Settings** in the Browser, and on the
|
||||
top component's right-click menu) records who made a design and what it is: project, part
|
||||
number, revision, author, organization, description and licence. **OK** applies the changes as
|
||||
one undo step. Scripts and agents use the same commands: `document.metadata` reads them,
|
||||
`document.metadata.set` (`key` and `value`, or `values: {key: value, …}`) and
|
||||
`document.metadata.remove` (`key` or `keys`) change them. `document.inspect` and the MCP
|
||||
`inspect_design` tool include them.
|
||||
|
||||
Exports carry them where the format has a place for them:
|
||||
|
||||
| Format | Written |
|
||||
|---|---|
|
||||
| STEP | `author` and `organization` in `FILE_NAME`, `description` in `FILE_DESCRIPTION` (on one line) |
|
||||
| IGES | `author` and `organization` in the global section |
|
||||
| 3MF | `project` as `Title`, `author` as `Designer`, `description` as `Description`, `license` as `LicenseTerms` |
|
||||
|
||||
The part number and revision have no standard place in these formats and stay in the design.
|
||||
|
||||
In the file they are one optional top-level `metadata` object; a design without properties has
|
||||
none, and is saved exactly as before:
|
||||
|
||||
```json
|
||||
"metadata": {
|
||||
"project": "ACME enclosures",
|
||||
"part_number": "ACME-1042",
|
||||
"revision": "B",
|
||||
"author": "Jane Doe",
|
||||
"organization": "ACME",
|
||||
"description": "Vented case for Raspberry Pi 4",
|
||||
"license": "CC-BY-4.0",
|
||||
"com.acme.description": { "shop_id": 88123 },
|
||||
"org.printfarm": { "slicer_profile": "PETG-0.2", "internal_id": 4 }
|
||||
}
|
||||
```
|
||||
|
||||
- **SolidCraft's keys are unprefixed** and hold text. Every other unprefixed key is reserved for
|
||||
later SolidCraft versions.
|
||||
- **Other tools keep their data under one reverse-DNS key** of their own (`com.example.tool`:
|
||||
two or more dot-separated labels of letters, digits, `-` and `_`), holding an object. A tool
|
||||
writes only under its own key and leaves the others alone.
|
||||
- **SolidCraft keeps every key as it is** through open, edit and save, in both the container
|
||||
and plain JSON, and never checks what another tool wrote: a well-known key holding something
|
||||
other than text (`"author": 42`) is kept, reads as absent, and never stops the design from
|
||||
opening. Typing over it in Design Properties replaces it.
|
||||
- **Other tools' keys are never exported**; Design Properties lists them read-only.
|
||||
- **Keep values small**: `document.metadata.set` refuses text over 4 KiB, an object over 64 KiB
|
||||
or nested more than 32 levels deep, and more than 256 keys or 256 KiB in all. Binary data
|
||||
belongs in a file next to the design.
|
||||
|
||||
Design properties are first saved in format 21. Inserting or linking another design doesn't
|
||||
copy its properties; Save As keeps them. A command script of the design
|
||||
(`timeline.export_script`) sets them in one step.
|
||||
|
||||
## Saving in the web app
|
||||
|
||||
In the web app, **Save** and **Save As** keep designs in this browser's private storage;
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
# Features
|
||||
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **353 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **354 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## Sketch (99)
|
||||
|
||||
@@ -401,11 +401,14 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Delete Crash Reports | `diagnostics.crash_reports.clear` | – |
|
||||
| Crash Test | `diagnostics.crash_test` | – |
|
||||
|
||||
## `document.*` (2)
|
||||
## `document.*` (5)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
| Change Design Units | `document.units` | – |
|
||||
| Design Properties | `document.metadata` | – |
|
||||
| Change Design Properties | `document.metadata.set` | – |
|
||||
| Remove Design Property | `document.metadata.remove` | – |
|
||||
| Inspect Design | `document.inspect` | – |
|
||||
|
||||
## `engine.*` (1)
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t
|
||||
| `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. |
|
||||
| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. |
|
||||
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. |
|
||||
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
|
||||
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
|
||||
| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. |
|
||||
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. |
|
||||
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
|
||||
|
||||
Reference in new issue
Block a user