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

+8 -1
View File
@@ -1130,8 +1130,15 @@ mod tests {
doc.param_comments.insert("test".into(), "metadata".into());
let setup = solidcraft_doc::render_settings::RenderSetup { exposure_ev: 1.5, ..Default::default() };
std::sync::Arc::make_mut(&mut doc.render).setups.insert("Studio".into(), setup);
// Design properties and other tools' data (#1015), kept as they are in both forms.
doc.metadata = serde_json::from_value(json!({
"author": 42,
"revision": "B",
"com.acme.description": {"shop_id": 88123, "list": [1, 2.5, null, true, "x", {"deep": []}]},
}))
.unwrap();
let expected = crate::read_design(&crate::write_design(&doc)).unwrap();
assert_eq!(expected.render, doc.render);
assert_eq!(expected, doc);
assert_eq!(crate::read_design(&write_design_container(&doc).unwrap()).unwrap(), expected);
}
+81 -9
View File
@@ -33,10 +33,10 @@ pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
pub use svg::read_svg;
mod obj;
pub use obj::read_obj;
pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, read_3mf, weld, write_3mf};
pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, model_xml_with, read_3mf, weld, write_3mf, write_3mf_with};
pub use urdf::{UrdfOptions, UrdfPackage, urdf};
use solidcraft_doc::{Document, ModelState};
use solidcraft_doc::{Document, Metadata, ModelState};
use solidcraft_geom::{Mesh, Vec3};
/// Design file extension.
@@ -310,6 +310,39 @@ pub fn obj(meshes: &[(String, Mesh)]) -> String {
/// Export the given bodies of a model (all when `bodies` is empty) in a format.
pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str) -> Result<Vec<u8>> {
export_with_metadata(state, bodies, format, name, &Metadata::default())
}
/// The STEP or IGES header of an export named `file_name`: the design's author, organization
/// and description (#1015).
fn step_header(file_name: String, meta: &Metadata) -> solidcraft_kernel::StepHeader {
solidcraft_kernel::StepHeader {
file_name,
author: header_line(meta.author()),
organization: header_line(meta.organization()),
description: header_line(meta.description()),
..Default::default()
}
}
/// A header field is one line: each run of white space (line breaks too) becomes one space.
fn header_line(text: Option<&str>) -> String {
text.map(|t| t.split_whitespace().collect::<Vec<_>>().join(" ")).unwrap_or_default()
}
/// 3MF model metadata from the design properties, by the names the 3MF core specification
/// gives them. Other tools' keys are never exported.
fn threemf_metadata(meta: &Metadata) -> Vec<(&'static str, &str)> {
[("Title", meta.project()), ("Designer", meta.author()), ("Description", meta.description()), ("LicenseTerms", meta.license())]
.into_iter()
.filter_map(|(name, text)| Some((name, text?)))
.collect()
}
/// [`export`], with the design properties `meta` in the formats that have a place for them:
/// STEP and IGES headers (author, organization; STEP also the description) and 3MF model
/// metadata (project as the title, author as the designer, description, licence).
pub fn export_with_metadata(state: &ModelState, bodies: &[String], format: Format, name: &str, meta: &Metadata) -> Result<Vec<u8>> {
let sel: Vec<&solidcraft_doc::ModelBody> = state.bodies.iter().filter(|b| bodies.is_empty() || bodies.contains(&b.name)).collect();
if sel.is_empty() {
return Err(IoError::Empty);
@@ -335,18 +368,16 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
Format::Step => {
let bs: Vec<solidcraft_kernel::ExportBody> =
sel.iter().map(|b| solidcraft_kernel::ExportBody { name: b.name.clone(), body: &b.body, color: b.body.color() }).collect();
let header = solidcraft_kernel::StepHeader { file_name: format!("{name}.step"), ..Default::default() };
solidcraft_kernel::step_export_bodies(&bs, &header)?.into_bytes()
solidcraft_kernel::step_export_bodies(&bs, &step_header(format!("{name}.step"), meta))?.into_bytes()
}
Format::Iges => {
let bs: Vec<solidcraft_kernel::ExportBody> =
sel.iter().map(|b| solidcraft_kernel::ExportBody { name: b.name.clone(), body: &b.body, color: b.body.color() }).collect();
let header = solidcraft_kernel::StepHeader { file_name: format!("{name}.igs"), ..Default::default() };
solidcraft_kernel::iges_export_bodies(&bs, &header)?.into_bytes()
solidcraft_kernel::iges_export_bodies(&bs, &step_header(format!("{name}.igs"), meta))?.into_bytes()
}
Format::ThreeMf | Format::ThreeMfTagged => {
let objs: Vec<MeshObject> = sel.iter().map(|b| mesh_object(b, format == Format::ThreeMfTagged)).collect::<Result<_>>()?;
write_3mf(&objs)?
write_3mf_with(&objs, &threemf_metadata(meta))?
}
Format::Design => return Err(IoError::Format("solidcraft (save the document instead)".into())),
Format::Urdf => return Err(IoError::Format("urdf (an assembly package; use file.export)".into())),
@@ -408,8 +439,7 @@ pub fn step_assembly(doc: &Document, local: &ModelState, name: &str) -> Result<V
p.children.push((child, o.transform, o.name.clone()));
}
}
let header = solidcraft_kernel::StepHeader { file_name: format!("{name}.step"), ..Default::default() };
Ok(solidcraft_kernel::step_export_products(&products, 0, &header)?.into_bytes())
Ok(solidcraft_kernel::step_export_products(&products, 0, &step_header(format!("{name}.step"), &doc.metadata))?.into_bytes())
}
/// Read a design file's bytes.
@@ -595,6 +625,48 @@ mod tests {
assert!(o.contains("o Body1") && o.contains("\nf "));
}
/// The design properties land in each format's own metadata (#1015): STEP and IGES headers,
/// 3MF model metadata. Other tools' keys never leave the design file.
#[test]
fn exports_write_design_properties() {
let (_, m) = model();
let meta: Metadata = serde_json::from_value(serde_json::json!({
"project": "ACME <enclosures>", "author": "Jane O'Brien", "organization": "ACME", "license": "CC-BY-4.0",
"description": "Vented Gehäuse\nfor a Pi", "revision": 7, "com.acme.secret": {"token": "SECRET-VALUE"},
}))
.unwrap();
let step = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Step, "t", &meta).unwrap()).unwrap();
assert!(step.contains("FILE_DESCRIPTION(('Vented Geh\\X2\\00E4\\X0\\use for a Pi'),'2;1');"), "{}", &step[..300]);
assert!(step.contains("FILE_NAME('t.step','',('Jane O''Brien'),('ACME'),"), "{}", &step[..300]);
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["Body1"], "still reads back");
let iges = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Iges, "t", &meta).unwrap()).unwrap();
assert!(iges.contains("12HJane O'Brien") && iges.contains("4HACME"));
let model_xml = |bytes: Vec<u8>| {
use std::io::Read as _;
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut x = String::new();
zip.by_name("3D/3dmodel.model").unwrap().read_to_string(&mut x).unwrap();
x
};
let x = model_xml(export_with_metadata(&m.state(), &[], Format::ThreeMf, "t", &meta).unwrap());
for want in [
"<metadata name=\"Application\">SolidCraft</metadata>\n <metadata name=\"Title\">ACME &lt;enclosures&gt;</metadata>",
"<metadata name=\"Designer\">Jane O&apos;Brien</metadata>",
"<metadata name=\"Description\">Vented Gehäuse\nfor a Pi</metadata>",
"<metadata name=\"LicenseTerms\">CC-BY-4.0</metadata>",
] {
assert!(x.contains(want), "{want}\n{}", &x[..400]);
}
assert!(write_3mf_with(&[], &[("Title", "x")]).is_err(), "no objects is still an error");
for out in [step.as_str(), iges.as_str(), x.as_str()] {
assert!(!out.contains("SECRET-VALUE") && !out.contains("com.acme"));
}
// Without properties the files are as they always were.
let plain = String::from_utf8(export(&m.state(), &[], Format::Step, "t").unwrap()).unwrap();
assert!(plain.contains("FILE_DESCRIPTION(('SolidCraft model'),'2;1');\nFILE_NAME('t.step','',(''),(''),"));
assert!(!model_xml(export(&m.state(), &[], Format::ThreeMf, "t").unwrap()).contains("Title"));
}
#[test]
fn step_and_design() {
let (doc, m) = model();
+17 -2
View File
@@ -239,11 +239,21 @@ pub fn weld_tagged(positions: &[Vec3], triangles: &[[u32; 3]], tags: &[u32], eps
/// The model part's XML.
pub fn model_xml(objects: &[MeshObject]) -> String {
model_xml_with(objects, &[])
}
/// The model part's XML with model metadata after `Application`: (3MF metadata name, such as
/// `Title` or `Designer`, and its text).
pub fn model_xml_with(objects: &[MeshObject], metadata: &[(&str, &str)]) -> String {
let mut x = String::new();
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let ft = if objects.iter().any(MeshObject::tagged) { format!(" xmlns:ft=\"{FACE_TAGS_NS}\"") } else { String::new() };
let _ = writeln!(x, "<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"{CORE_NS}\"{ft}>");
x.push_str(" <metadata name=\"Application\">SolidCraft</metadata>\n <resources>\n");
x.push_str(" <metadata name=\"Application\">SolidCraft</metadata>\n");
for (name, text) in metadata {
let _ = writeln!(x, " <metadata name=\"{}\">{}</metadata>", esc(name), esc(text));
}
x.push_str(" <resources>\n");
let colored = objects.iter().any(|o| o.color.is_some() || o.transparency > 0.0 || !o.looks.is_empty());
// Base material group id 1 (each object's own colour, then its faces' colours); objects
// from 2. A see-through colour carries its alpha (#RRGGBBAA).
@@ -308,6 +318,11 @@ pub fn model_xml(objects: &[MeshObject]) -> String {
/// A 3MF package of meshes.
pub fn write_3mf(objects: &[MeshObject]) -> Result<Vec<u8>> {
write_3mf_with(objects, &[])
}
/// A 3MF package of meshes with model metadata (see [`model_xml_with`]).
pub fn write_3mf_with(objects: &[MeshObject], metadata: &[(&str, &str)]) -> Result<Vec<u8>> {
if objects.is_empty() {
return Err(IoError::Empty);
}
@@ -315,7 +330,7 @@ pub fn write_3mf(objects: &[MeshObject]) -> Result<Vec<u8>> {
let rels = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n <Relationship Target=\"/3D/3dmodel.model\" Id=\"rel0\" Type=\"{MODEL_REL}\"/>\n</Relationships>\n"
);
let model = model_xml(objects);
let model = model_xml_with(objects, metadata);
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, data) in [("[Content_Types].xml", content_types.as_bytes()), ("_rels/.rels", rels.as_bytes()), ("3D/3dmodel.model", model.as_bytes())]
{