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
@@ -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())]
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user