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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user