Design properties: project, author and licence in the design, its exports and inspect_design (#1015) (#1189)

A design carries metadata (format 21): project, part number, revision, author, organization, description and license, plus other tools' data under reverse-DNS keys, kept as written. Edited in the Design Properties window (Document Settings), set by document.metadata.set, reported by inspect_design, and written into STEP, IGES and 3MF where the format has a place for them. Translated, with tests.
This commit is contained in:
Lasse Lian authored and GitHub committed 2026-10-14 18:25:35 -04:00
1 parent 78079587ad
commit a4a8b48567
33 files changed
+1106 -21

No files matched your search

+5
View File
@@ -798,6 +798,11 @@ fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
if draw_row(ui, ui.id().with("params"), &Row { depth: depth + 1, icon: "params", label: &params, ..Default::default() }).clicked {
app.start("parameters.change");
}
if draw_row(ui, ui.id().with("design_properties"), &Row { depth: depth + 1, icon: "settings", label: "Design Properties", ..Default::default() })
.clicked
{
app.start("document.metadata");
}
}
fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
+4
View File
@@ -653,6 +653,9 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
.on(id != 0 || !bodies.is_empty()),
);
v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id })));
if id == 0 {
v.push(act("ui.designProperties", "Design Properties", "settings"));
}
v.push(Item::sep());
v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
@@ -881,6 +884,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
Err(e) => app.set_status(e.to_string(), true),
}
}
"ui.designProperties" => app.start("document.metadata"),
"ui.export" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
app.pick_save_command(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"], "file.export", json!({"bodies": bodies}));
+216
View File
@@ -0,0 +1,216 @@
//! The Design Properties window (#1015): the design's project, part number, revision, author,
//! organization, description and licence, and a read-only list of the data other tools keep in
//! the design. OK applies the edited fields through one `document.metadata.set` (one undo step);
//! Cancel and the close button change nothing. File → Design Properties…, the Browser's
//! Document Settings, the root component's menu and `ui.start` of `document.metadata` or
//! `document.metadata.set` open it.
use egui::RichText;
use serde_json::{Map, Value};
use solidcraft_engine::doc::metadata::{DESCRIPTION, WELL_KNOWN};
use crate::SolveApp;
use crate::theme::Tokens;
/// Longest preview of another tool's value in the list.
const PREVIEW_CHARS: usize = 80;
/// What is being typed; the properties themselves live in the document.
pub struct DesignProperties {
/// Each well-known key's text when the window opened, in [`WELL_KNOWN`] order.
pub opened: [String; WELL_KNOWN.len()],
/// The fields as edited.
pub fields: [String; WELL_KNOWN.len()],
/// The last refusal from `document.metadata.set`.
pub error: Option<String>,
}
impl DesignProperties {
pub fn new(app: &SolveApp) -> Self {
let m = &app.session.doc.metadata;
let opened = WELL_KNOWN.map(|k| m.text(k).unwrap_or_default().to_string());
DesignProperties { fields: opened.clone(), opened, error: None }
}
/// The fields that differ from what the window opened with, as `document.metadata.set`
/// takes them (empty text removes the key).
pub fn changes(&self) -> Map<String, Value> {
WELL_KNOWN
.iter()
.zip(self.opened.iter().zip(&self.fields))
.filter(|(_, (was, now))| was != now)
.map(|(k, (_, now))| (k.to_string(), Value::String(now.clone())))
.collect()
}
}
/// Open the window (a fresh copy of the design's properties).
pub fn open(app: &mut SolveApp) {
app.design_properties = Some(DesignProperties::new(app));
}
/// Apply the edits; the window stays open with the reason when they are refused.
fn apply(app: &mut SolveApp, d: &mut DesignProperties) -> bool {
let values = d.changes();
if values.is_empty() {
return true;
}
match app.run("document.metadata.set", serde_json::json!({ "values": values })) {
Ok(_) => true,
Err(e) => {
d.error = Some(e);
false
}
}
}
fn label(key: &str) -> &'static str {
match key {
"project" => crate::i18n::tr("ui.design_properties.project"),
"part_number" => crate::i18n::tr("ui.design_properties.part_number"),
"revision" => crate::i18n::tr("ui.design_properties.revision"),
"author" => crate::i18n::tr("ui.design_properties.author"),
"organization" => crate::i18n::tr("ui.design_properties.organization"),
"description" => crate::i18n::tr("ui.design_properties.description"),
_ => crate::i18n::tr("ui.design_properties.license"),
}
}
/// A short one-line view of a value another tool keeps.
fn preview(v: &Value) -> String {
let text = serde_json::to_string(v).unwrap_or_default();
if text.chars().count() <= PREVIEW_CHARS { text } else { format!("{}…", text.chars().take(PREVIEW_CHARS).collect::<String>()) }
}
/// Draw the window when it is open. Called once per frame.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(mut d) = app.design_properties.take() else { return };
let t = Tokens::get();
let mut open = true;
let (mut ok, mut cancel) = (false, false);
let metadata = app.session.doc.metadata.clone();
let title = RichText::new(crate::i18n::tr_label("Design Properties")).strong().size(13.0);
crate::frame::window(ctx, title, crate::frame::Width::Normal)
.id(egui::Id::new("sc_design_properties"))
.open(&mut open)
.resizable(false)
.min_width(420.0)
.show(ctx, |ui| {
egui::Grid::new("sc_design_properties_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
for (i, key) in WELL_KNOWN.iter().enumerate() {
ui.label(label(key));
let Some(field) = d.fields.get_mut(i) else { continue };
ui.vertical(|ui| {
let edit =
if *key == DESCRIPTION { egui::TextEdit::multiline(field).desired_rows(3) } else { egui::TextEdit::singleline(field) };
ui.add(edit.desired_width(280.0));
// Saved by another tool as something other than text: kept unless typed over.
if metadata.get(key).is_some() && metadata.text(key).is_none() && d.opened.get(i).is_some_and(String::is_empty) {
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.not_text")).small().color(t.text_dim));
}
});
ui.end_row();
}
});
ui.add_space(4.0);
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.exports")).small().color(t.text_dim));
let others: Vec<(&String, &Value)> = metadata.others().collect();
if !others.is_empty() {
ui.add_space(4.0);
egui::CollapsingHeader::new(format!("{} ({})", crate::i18n::tr("ui.design_properties.others"), others.len()))
.id_salt("sc_design_properties_others")
.show(ui, |ui| {
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.others_note")).small().color(t.text_dim));
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for (k, v) in others {
ui.label(RichText::new(k).monospace().size(12.0).color(t.text));
ui.label(RichText::new(preview(v)).monospace().small().color(t.text_dim));
}
});
});
}
if let Some(e) = &d.error {
ui.label(RichText::new(e).color(t.error));
}
ui.separator();
// OK and Cancel flush right, as in every dialog.
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
cancel = ui.button(crate::i18n::tr("ui.cancel")).clicked();
ok = ui.button(crate::i18n::tr("ui.heading_ok")).clicked();
});
});
});
if !open || cancel || (ok && apply(app, &mut d)) {
return;
}
app.design_properties = Some(d);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::scenario::Harness;
#[test]
fn the_window_edits_properties_in_one_undo_step_and_keeps_other_tools_data() {
let mut h = Harness::new();
h.app.run("document.metadata.set", json!({"values": {"author": "Jane", "com.acme.tool": {"id": 7}}})).unwrap();
// A well-known key another tool saved as a number.
h.app.session.doc_mut().metadata.0.insert("revision".into(), json!(3));
let undo = h.app.session.undo.len();
h.step(&json!({"start": "document.metadata.set"})).unwrap();
assert!(h.app.design_properties.is_some(), "ui.start opens the window, it doesn't run the command");
assert!(h.widget("Jane").is_some(), "the author's field");
assert!(h.widget("Other tools' data (1)").is_some());
let d = h.app.design_properties.as_mut().unwrap();
assert_eq!(d.opened[2], "", "a number is not a revision");
d.fields[0] = "ACME enclosures".into();
d.fields[3] = String::new();
h.frames(2);
h.step(&json!({"widget": "OK"})).unwrap();
assert!(h.app.design_properties.is_none());
let m = &h.app.session.doc.metadata;
assert_eq!(m.project(), Some("ACME enclosures"));
assert_eq!(m.author(), None, "an emptied field removes the key");
assert_eq!(m.get("revision"), Some(&json!(3)), "an untouched field keeps what another tool saved");
assert_eq!(m.get("com.acme.tool"), Some(&json!({"id": 7})));
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step");
// Cancel changes nothing; a refused value keeps the window open with the reason.
h.step(&json!({"start": "document.metadata"})).unwrap();
h.app.design_properties.as_mut().unwrap().fields[0] = "Other".into();
h.frames(2);
h.step(&json!({"widget": "Cancel"})).unwrap();
assert!(h.app.design_properties.is_none());
assert_eq!(h.app.session.doc.metadata.project(), Some("ACME enclosures"));
h.step(&json!({"start": "document.metadata.set"})).unwrap();
h.app.design_properties.as_mut().unwrap().fields[1] = "bad\u{7}".into();
h.frames(2);
h.step(&json!({"widget": "OK"})).unwrap();
let d = h.app.design_properties.as_ref().expect("still open");
assert!(d.error.as_deref().is_some_and(|e| e.contains("control character")), "{:?}", d.error);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn the_file_menu_browser_and_root_component_open_the_window() {
let mut h = Harness::new();
let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Component { id: 0 });
let item = items.iter().find(|i| i.id == "ui.designProperties").expect("root component menu item");
assert_eq!(item.label, "Design Properties");
crate::context_menu::run_item(&mut h.app, item, egui::pos2(0.0, 0.0));
assert!(h.app.design_properties.is_some());
h.app.design_properties = None;
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), true));
h.frames(2);
h.step(&json!({"widget": "Design Properties…"})).unwrap();
assert!(h.app.design_properties.is_some());
h.app.design_properties = None;
// Browser → Document Settings → Design Properties.
h.step(&json!({"click": {"chevron": "Document Settings"}})).unwrap();
h.step(&json!({"click": {"browser": "Design Properties"}})).unwrap();
assert!(h.app.design_properties.is_some());
}
}
+1
View File
@@ -87,6 +87,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
crate::dim_view::cancel();
app.tool = None;
app.params = None;
app.design_properties = None;
app.cancel_view_animation();
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
+1
View File
@@ -295,6 +295,7 @@ mod tests {
include_str!("dialogs.rs"),
include_str!("browser.rs"),
include_str!("sketch_palette.rs"),
include_str!("design_properties.rs"),
] {
for call in [".tr(\"", "i18n::tr(\"", "i18n::format(\""] {
for tail in source.split(call).skip(1) {
+9
View File
@@ -20,6 +20,7 @@ pub mod context_menu;
pub mod control;
pub mod crash_ui;
pub mod delete;
pub mod design_properties;
pub mod dialogs;
pub mod dialogs_appearance;
pub mod dialogs_assembly;
@@ -250,6 +251,8 @@ pub struct SolveApp {
pub dialog: Option<dialogs::Dialog>,
/// The Change Parameters window, when open.
pub params: Option<params_dialog::ParamsDialog>,
/// The Design Properties window, when open.
pub design_properties: Option<design_properties::DesignProperties>,
pub palette: palette::Palette,
/// Context menus, rename box and Properties window.
pub menu: context_menu::MenuState,
@@ -343,6 +346,7 @@ impl SolveApp {
tool: None,
dialog: None,
params: None,
design_properties: None,
palette: palette::Palette::default(),
menu: context_menu::MenuState::default(),
tree: browser::TreeState::default(),
@@ -631,6 +635,10 @@ impl SolveApp {
params_dialog::open(self);
return;
}
if matches!(id, "document.metadata" | "document.metadata.set") {
design_properties::open(self);
return;
}
if id == "sketch.finish" {
self.finish_sketch();
return;
@@ -1158,6 +1166,7 @@ impl SolveApp {
sketch_palette::show(self, ui.ctx());
self.resolve_fit(ui.ctx());
params_dialog::show(self, ui.ctx());
design_properties::show(self, ui.ctx());
recovery_ui::show(self, ui.ctx());
stand_up::show(self, ui.ctx());
prefs::show_reset_notice(self, ui.ctx());
+5
View File
@@ -405,6 +405,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
close = true;
}
ui.separator();
if item_if(ui, &format!("{}…", app.tr_label("Design Properties")), "", shown) {
app.start("document.metadata");
close = true;
}
ui.separator();
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
app.prefs_window.open = true;
close = true;