diff --git a/ROADMAP.md b/ROADMAP.md
index 860d2c97..7add72c6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
diff --git a/assets/locales/de.json b/assets/locales/de.json
index 3cf101e3..dd1396d3 100644
--- a/assets/locales/de.json
+++ b/assets/locales/de.json
@@ -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",
diff --git a/assets/locales/en.json b/assets/locales/en.json
index ab15d6aa..e53f0478 100644
--- a/assets/locales/en.json
+++ b/assets/locales/en.json
@@ -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",
diff --git a/assets/locales/ja.json b/assets/locales/ja.json
index 5fa01f16..3dfdeaa6 100644
--- a/assets/locales/ja.json
+++ b/assets/locales/ja.json
@@ -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": "繰り返し",
diff --git a/assets/locales/ko.json b/assets/locales/ko.json
index e675c7f3..8f13da4c 100644
--- a/assets/locales/ko.json
+++ b/assets/locales/ko.json
@@ -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": "명령 목록",
diff --git a/assets/locales/ru.json b/assets/locales/ru.json
index 19999f8f..ca8a9314 100644
--- a/assets/locales/ru.json
+++ b/assets/locales/ru.json
@@ -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": "Повторить",
diff --git a/assets/locales/zh.json b/assets/locales/zh.json
index a6936c56..7d758d80 100644
--- a/assets/locales/zh.json
+++ b/assets/locales/zh.json
@@ -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": "重复",
diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs
index 861b357a..38739e03 100644
--- a/crates/doc/src/document.rs
+++ b/crates/doc/src/document.rs
@@ -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,
pub features: Vec,
/// 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,
diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs
index ffb8377b..164804c1 100644
--- a/crates/doc/src/format.rs
+++ b/crates/doc/src/format.rs
@@ -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 {
"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<()> {
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 0e1ffcb7..8c595313 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -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;
diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs
new file mode 100644
index 00000000..44bbb870
--- /dev/null
+++ b/crates/doc/src/metadata.rs
@@ -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);
+
+/// 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- {
+ 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 {
+ 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