Face-tagged 3MF exchange: format spec, reader, geometry.import.mesh

docs/formats/tagged-mesh.md defines the exchange: 3MF Core with an optional
ft:face attribute per triangle (and ft:faces per object) in a ModelCraft
namespace. modelcraft_geometry::tagged_mesh::read_3mf reads it from bytes
(wasm), capping every size and count and range-checking every index and
coordinate. geometry.import.mesh adds the surfaces to the session, which keeps
them beside the open design and restores them when a command fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-13 14:48:22 -04:00
1 parent f36b1d9a69
commit f181c98b75
13 files changed
+791 -6

No files matched your search

+1
View File
@@ -22,3 +22,4 @@ is committed and listed here with its exports.
| xtask/src/layers.rs | SolidCraft `xtask/src/layers.rs` (itself adapted from CADCraft's xtask) | MIT OR Apache-2.0 |
| xtask/src/assets.rs, xtask/src/licences.rs, xtask/src/test.rs, xtask/src/main.rs | SolidCraft `xtask/src/` | MIT OR Apache-2.0 |
| clippy.toml, rustfmt.toml | SolidCraft | MIT OR Apache-2.0 |
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
Generated
+3
View File
@@ -459,17 +459,20 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "modelcraft-geometry"
version = "0.1.0"
dependencies = [
"quick-xml",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
"solidcraft-kernel",
"thiserror 2.0.21",
"zip",
]
[[package]]
+4
View File
@@ -23,6 +23,10 @@ serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# 3MF packages (docs/formats/tagged-mesh.md): the same versions SolidCraft uses.
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
# Geometry: SolidCraft's B-rep kernel (MIT OR Apache-2.0), pinned. Only crates/geometry uses it
# (plan/adr/0001-libraries.md).
solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
+1 -1
View File
@@ -6,7 +6,7 @@ LS-PrePost, with hexahedral meshing as the centre of gravity, plus its own linea
| Milestone | Scope | Status |
|---|---|---|
| M0 Foundation | Workspace, quality gates (`cargo xtask ci`), FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft's kernel and tessellator | done |
| M1 Surface preparation | Geometry in: open `.solidcraft` designs, rebuilt by SolidCraft, with persistent face names (done); robust adaptive-precision predicates; weld, closed/manifold/orientation checks and feature edges on the tessellated boundary; mesh quality metrics | next |
| M1 Surface preparation | Geometry in: open `.solidcraft` designs, rebuilt by SolidCraft, with persistent face names (done); face-tagged 3MF files (`geometry.import.mesh`, docs/formats/tagged-mesh.md, done); robust adaptive-precision predicates; weld, closed/manifold/orientation checks and feature edges on the tessellated boundary; mesh quality metrics | in progress |
| M2 Tetrahedral meshing | Bowyer–Watson Delaunay, robust constrained Delaunay boundary recovery (Diazzi et al. 2023), Delaunay refinement to a size field, ODT smoothing, Klingner–Shewchuk sliver removal; linear and quadratic tets | planned |
| M3 Hexahedral meshing (core) | Octree/grid dual all-hex (valid on any input), then hex-dominant by tet recombination, then polycube for blocky parts; see docs/meshing/literature.md | planned |
| M4 Model definition | Materials, sections, sets and surfaces, boundary conditions, loads, steps, output requests | planned |
+3
View File
@@ -15,3 +15,6 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
+70
View File
@@ -1,8 +1,10 @@
use std::sync::Arc;
use modelcraft_geometry::tagged_mesh::{self, MAX_FILE_BYTES};
use modelcraft_geometry::{Shape, open_design_file};
use serde_json::{Value, json};
use super::read_capped;
use crate::{CommandSpec, EngineError, Result, Session};
pub const OPEN: CommandSpec = CommandSpec {
@@ -26,6 +28,13 @@ pub const TESSELLATE: CommandSpec = CommandSpec {
run: tessellate,
};
pub const IMPORT_MESH: CommandSpec = CommandSpec {
id: "geometry.import.mesh",
label: "Import Tagged Mesh",
params: r#"{"path": "part.3mf"} — adds each build item of a 3MF package as a surface; triangles keep their B-rep face tags (docs/formats/tagged-mesh.md)"#,
run: import_mesh,
};
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = p
.get("path")
@@ -77,3 +86,64 @@ fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
let faces: Vec<Value> = shape.faces().iter().zip(per_face).map(|(f, n)| json!({"name": f.name, "triangles": n})).collect();
Ok(json!({"body": shape.name, "vertices": mesh.positions.len(), "triangles": mesh.triangles.len(), "faces": faces}))
}
fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let path = p
.get("path")
.and_then(Value::as_str)
.filter(|x| !x.trim().is_empty() && x.len() < 4096)
.ok_or_else(|| EngineError::Params("`path` must name a .3mf file".into()))?;
let surfaces = tagged_mesh::read_3mf(&read_capped(path, MAX_FILE_BYTES)?)?;
let first = s.surfaces.len();
let added: Vec<Value> = surfaces
.iter()
.enumerate()
.map(|(k, t)| json!({"index": first + k, "name": t.name, "vertices": t.mesh.positions.len(), "triangles": t.mesh.triangles.len(), "faces": t.faces}))
.collect();
s.surfaces.extend(surfaces.into_iter().map(Arc::new));
Ok(json!({"path": path, "surfaces": added}))
}
#[cfg(test)]
mod tests {
use std::io::{Cursor, Write};
use super::*;
fn package(model: &str) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(model.as_bytes()).unwrap();
zip.finish().unwrap().into_inner()
}
#[test]
fn import_adds_tagged_surfaces_and_failures_change_nothing() {
let dir = std::env::temp_dir().join(format!("modelcraft-import-mesh-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let model = format!(
r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:ft="{}"><resources>
<object id="1" name="Tet" ft:faces="2"><mesh><vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/><vertex x="0" y="0" z="1"/></vertices>
<triangles><triangle v1="0" v2="2" v3="1" ft:face="0"/><triangle v1="0" v2="1" v3="3" ft:face="1"/><triangle v1="1" v2="2" v3="3" ft:face="1"/><triangle v1="0" v2="3" v3="2" ft:face="1"/></triangles></mesh></object>
</resources><build><item objectid="1"/></build></model>"#,
tagged_mesh::FACE_TAGS_NS
);
let good = dir.join("tet.3mf");
std::fs::write(&good, package(&model)).unwrap();
let junk = dir.join("junk.3mf");
std::fs::write(&junk, b"PK\x03\x04 not really").unwrap();
let mut s = Session::default();
let r = s.execute("geometry.import.mesh", &json!({"path": good.to_str().unwrap()})).unwrap();
assert_eq!(r["surfaces"][0], json!({"index": 0, "name": "Tet", "vertices": 4, "triangles": 4, "faces": 2}));
assert_eq!(s.surfaces[0].mesh.tri_face, vec![0, 1, 1, 1]);
assert_eq!(s.journal.len(), 1);
for p in [json!({"path": junk.to_str().unwrap()}), json!({"path": dir.join("missing.3mf").to_str().unwrap()}), json!({"path": ""})] {
assert!(s.execute("geometry.import.mesh", &p).is_err(), "{p}");
}
assert_eq!(s.surfaces.len(), 1);
assert_eq!(s.journal.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
}
+15
View File
@@ -13,6 +13,7 @@ pub static ALL: &[CommandSpec] = &[
geometry::OPEN,
geometry::BODIES,
geometry::TESSELLATE,
geometry::IMPORT_MESH,
mesh::NODE_ADD,
mesh::NODE_REMOVE,
mesh::ELEMENT_ADD,
@@ -37,3 +38,17 @@ pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> {
}
Ok(out)
}
/// A file's bytes, refusing anything over `cap`.
pub(crate) fn read_capped(path: &str, cap: usize) -> Result<Vec<u8>> {
use std::io::Read;
let err = |e: std::io::Error| EngineError::File(format!("{path}: {e}"));
let f = std::fs::File::open(path).map_err(err)?;
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0).min(cap));
f.take(cap as u64 + 1).read_to_end(&mut bytes).map_err(err)?;
if bytes.len() > cap {
return Err(EngineError::File(format!("{path}: larger than {} MB", cap >> 20)));
}
Ok(bytes)
}
+16 -4
View File
@@ -2,8 +2,8 @@
//!
//! Everything a user can do is a command ([`CommandSpec`]) with a JSON form. The desktop UI,
//! scripts, the CLI and the control channel all run the same commands through
//! [`Session::execute`], which never lets a failure or a panic damage the model: the model is
//! restored and an [`EngineError`] comes back.
//! [`Session::execute`], which never lets a failure or a panic damage the session: the model and
//! the surfaces are restored and an [`EngineError`] comes back.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -12,6 +12,7 @@ pub mod cmd;
use std::sync::Arc;
use modelcraft_geometry::Design;
use modelcraft_geometry::tagged_mesh::TaggedSurface;
use modelcraft_model::Model;
use serde_json::Value;
@@ -25,6 +26,9 @@ pub enum EngineError {
Model(#[from] modelcraft_model::Error),
#[error(transparent)]
Geometry(#[from] modelcraft_geometry::Error),
/// A file could not be read or written.
#[error("{0}")]
File(String),
/// A command panicked; the model is unchanged.
#[error("internal error in {command}: {message}")]
Internal { command: String, message: String },
@@ -57,16 +61,21 @@ pub struct Session {
pub model: Model,
/// The open SolidCraft design (`geometry.open`); shared, so a snapshot costs nothing.
pub design: Option<Arc<Design>>,
/// Face-tagged boundary surfaces (`geometry.import.mesh`): the input to volume meshing.
/// Each is shared, so the per-command snapshot is cheap.
pub surfaces: Vec<Arc<TaggedSurface>>,
/// Commands that changed the model, in order (a script that rebuilds it).
pub journal: Vec<(String, Value)>,
}
impl Session {
/// Run a command. On an error or a panic the model and the design are left as they were.
/// Run a command. On an error or a panic the model, the design and the surfaces are left as
/// they were.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let before = self.model.clone();
let design_before = self.design.clone();
let surfaces_before = self.surfaces.clone();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let result = match outcome {
Ok(r) => r,
@@ -77,12 +86,15 @@ impl Session {
}
};
let design_changed = self.design.as_ref().map(Arc::as_ptr) != design_before.as_ref().map(Arc::as_ptr);
let surfaces_changed =
self.surfaces.len() != surfaces_before.len() || self.surfaces.iter().zip(&surfaces_before).any(|(a, b)| !Arc::ptr_eq(a, b));
match &result {
Ok(_) if self.model != before || design_changed => self.journal.push((id.to_string(), params.clone())),
Ok(_) if self.model != before || design_changed || surfaces_changed => self.journal.push((id.to_string(), params.clone())),
Ok(_) => {}
Err(_) => {
self.model = before;
self.design = design_before;
self.surfaces = surfaces_before;
}
}
result
+2
View File
@@ -14,3 +14,5 @@ solidcraft-kernel = { workspace = true }
solidcraft-doc = { workspace = true }
solidcraft-io = { workspace = true }
thiserror = { workspace = true }
zip = { workspace = true }
quick-xml = { workspace = true }
+1
View File
@@ -21,6 +21,7 @@ mod design;
pub mod sample;
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design, open_design_file};
pub mod tagged_mesh;
use solidcraft_kernel::{Body, KernelError, guard};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
+575
View File
@@ -0,0 +1,575 @@
//! Face-tagged meshes in 3MF packages (`docs/formats/tagged-mesh.md`).
//!
//! A 3MF package (3MF Core Specification 1.3) whose triangles may carry `ft:face`, the B-rep face
//! they lie on, in ModelCraft's face-tag namespace. SolidCraft writes it; any other 3MF reads as
//! untagged surfaces (one face each). Reading is plain parsing from bytes (no kernel call, no file
//! system), so it also runs in the browser.
//!
//! Packages are untrusted: the file, its parts, zip entries, XML nesting, vertex and triangle
//! counts and build items are capped, coordinates must be finite and bounded, and every index
//! is range-checked. Every problem is an [`Error::Invalid`].
use std::borrow::Cow;
use std::collections::HashMap;
use std::io::{Cursor, Read};
use quick_xml::NsReader;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::ResolveResult;
use crate::{Error, Result, SurfaceMesh};
/// Largest package read.
pub const MAX_FILE_BYTES: usize = 512 << 20;
/// Largest uncompressed part read (zip bombs stop here).
const MAX_PART: u64 = 1 << 30;
const MAX_ENTRIES: usize = 10_000;
/// Most vertices, and most triangles, in a file and in all the surfaces read from it.
pub const MAX_ELEMENTS: usize = 20_000_000;
const MAX_BUILD_ITEMS: usize = 10_000;
const MAX_XML_DEPTH: usize = 256;
/// Largest coordinate magnitude accepted, after units and transforms (mm).
const MAX_COORD: f64 = 1e12;
const CORE_NS: &[u8] = b"http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
/// The face-tag namespace, version 1.
pub const FACE_TAGS_NS: &str = "https://github.com/jyviko/modelcraft/formats/face-tags/2026/10";
/// One build item of a package: a named surface whose `tri_face` holds the face tags.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TaggedSurface {
pub name: String,
pub mesh: SurfaceMesh,
/// Number of faces: `ft:faces` when given, else the largest tag + 1 (1 when untagged).
pub faces: u32,
}
fn invalid(msg: impl std::fmt::Display) -> Error {
Error::Invalid(format!("3MF: {msg}"))
}
fn bad<T>(msg: impl std::fmt::Display) -> Result<T> {
Err(invalid(msg))
}
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
let want = name.trim_start_matches('/').to_ascii_lowercase();
let Some(idx) =
(0..zip.len()).find(|i| zip.name_for_index(*i).is_some_and(|n| n.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase() == want))
else {
return Ok(None);
};
let mut f = zip.by_index(idx).map_err(|e| invalid(format!("{name}: {e}")))?;
// The declared size only sizes the first allocation (it may lie); the read itself is capped.
let mut out = Vec::with_capacity(usize::try_from(f.size().min(16 << 20)).unwrap_or(0));
(&mut f).take(MAX_PART + 1).read_to_end(&mut out).map_err(|e| invalid(format!("{name}: {e}")))?;
if out.len() as u64 > MAX_PART {
return bad(format!("{name} expands to more than 1 GB"));
}
Ok(Some(out))
}
/// Which namespace an element or attribute name is in.
#[derive(Clone, Copy, PartialEq)]
enum Ns {
/// 3MF Core, or no namespace at all (accepted for lenience).
Core,
FaceTags,
Other,
}
fn ns_of(r: &ResolveResult) -> Ns {
match r {
ResolveResult::Bound(n) if n.as_ref() == CORE_NS => Ns::Core,
ResolveResult::Bound(n) if n.as_ref() == FACE_TAGS_NS.as_bytes() => Ns::FaceTags,
ResolveResult::Unbound => Ns::Core,
_ => Ns::Other,
}
}
/// An element's attributes as (namespace, local name, value), borrowed from the event (values
/// are copied only when they hold entity references). Unprefixed attributes are Core.
type Attrs<'a> = Vec<(Ns, &'a [u8], Cow<'a, str>)>;
fn attrs<'a, R>(rd: &NsReader<R>, e: &'a BytesStart) -> Result<Attrs<'a>> {
let mut out = Vec::with_capacity(4);
for a in e.attributes() {
let a = a.map_err(|err| invalid(format!("model XML: {err}")))?;
let (ns, local) = rd.resolver().resolve_attribute(a.key);
let value = a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map_err(|err| invalid(format!("model XML: {err}")))?;
out.push((ns_of(&ns), local.into_inner(), value));
}
Ok(out)
}
fn get<'s>(a: &'s Attrs, ns: Ns, key: &str) -> Option<&'s str> {
a.iter().find(|(n, k, _)| *n == ns && *k == key.as_bytes()).map(|(_, _, v)| v.as_ref())
}
fn int(a: &Attrs, ns: Ns, key: &str, what: &str) -> Result<Option<u32>> {
match get(a, ns, key) {
None => Ok(None),
Some(v) => v.trim().parse::<u32>().map(Some).map_err(|_| invalid(format!("{what} `{key}` is not a whole number: `{v}`"))),
}
}
fn coord(a: &Attrs, key: &str) -> Result<f64> {
let v = get(a, Ns::Core, key).ok_or_else(|| invalid(format!("vertex without `{key}`")))?;
v.trim().parse::<f64>().ok().filter(|x| x.is_finite() && x.abs() <= MAX_COORD).ok_or_else(|| invalid(format!("bad coordinate `{v}`")))
}
/// Row-vector affine transform `[m00 m01 m02 m10 … m32]` (3MF order).
type Xf = [f64; 12];
const IDENTITY: Xf = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0];
fn parse_xf(s: Option<&str>) -> Result<Xf> {
let Some(s) = s else { return Ok(IDENTITY) };
let v: Vec<f64> = s
.split_whitespace()
.take(13)
.map(|t| t.parse::<f64>().ok().filter(|x| x.is_finite() && x.abs() <= MAX_COORD))
.collect::<Option<_>>()
.ok_or_else(|| invalid(format!("bad transform `{s}`")))?;
<Xf>::try_from(v.as_slice()).map_err(|_| invalid(format!("a transform has 12 numbers, got `{s}`")))
}
fn apply(m: &Xf, p: [f64; 3]) -> [f64; 3] {
[p[0] * m[0] + p[1] * m[3] + p[2] * m[6] + m[9], p[0] * m[1] + p[1] * m[4] + p[2] * m[7] + m[10], p[0] * m[2] + p[1] * m[5] + p[2] * m[8] + m[11]]
}
fn det(m: &Xf) -> f64 {
m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6])
}
fn unit_mm(u: &str) -> Option<f64> {
Some(match u {
"micron" => 1e-3,
"millimeter" => 1.0,
"centimeter" => 10.0,
"inch" => 25.4,
"foot" => 304.8,
"meter" => 1000.0,
_ => return None,
})
}
#[derive(Clone, Default)]
struct Object {
name: String,
positions: Vec<[f64; 3]>,
triangles: Vec<[u32; 3]>,
/// One per triangle, or empty when the object is untagged.
tags: Vec<u32>,
faces: Option<u32>,
components: bool,
}
impl Object {
/// Checks that need the whole object: tagging is all or nothing, indices exist.
fn check(&self, id: u32) -> Result<()> {
if !self.tags.is_empty() && self.tags.len() != self.triangles.len() {
return bad(format!("object {id}: {} of {} triangles have a face tag", self.tags.len(), self.triangles.len()));
}
let n = self.positions.len();
if let Some(v) = self.triangles.iter().flatten().find(|v| **v as usize >= n) {
return bad(format!("object {id}: a triangle refers to vertex {v}, but there are {n}"));
}
Ok(())
}
}
#[derive(Default)]
struct Model {
unit: f64,
objects: HashMap<u32, Object>,
build: Vec<(u32, Xf)>,
}
impl Model {
fn add(&mut self, id: u32, o: Object) -> Result<()> {
o.check(id)?;
self.objects.insert(id, o);
Ok(())
}
}
fn parse_model(xml: &[u8]) -> Result<Model> {
let mut rd = NsReader::from_reader(xml);
rd.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut m = Model { unit: 1.0, ..Default::default() };
let mut depth = 0usize;
let mut cur: Option<(u32, Object)> = None;
let (mut vertices, mut triangles) = (0usize, 0usize);
let mut seen_model = false;
loop {
buf.clear();
let (ns, ev) = rd.read_resolved_event_into(&mut buf).map_err(|e| invalid(format!("model XML: {e}")))?;
let ns = ns_of(&ns);
let (e, empty) = match ev {
Event::Start(e) => (e, false),
Event::Empty(e) => (e, true),
Event::End(end) => {
depth = depth.saturating_sub(1);
if ns == Ns::Core
&& end.local_name().as_ref() == b"object"
&& let Some((id, o)) = cur.take()
{
m.add(id, o)?;
}
continue;
}
Event::Eof => break,
_ => continue,
};
if !empty {
depth += 1;
if depth > MAX_XML_DEPTH {
return bad("XML nested too deeply");
}
}
if ns != Ns::Core {
continue;
}
let a = attrs(&rd, &e)?;
match e.local_name().as_ref() {
b"model" => {
seen_model = true;
if let Some(u) = get(&a, Ns::Core, "unit") {
m.unit = unit_mm(u).ok_or_else(|| invalid(format!("unknown unit `{u}`")))?;
}
}
b"object" => {
if cur.is_some() {
return bad("an object inside an object");
}
let id = int(&a, Ns::Core, "id", "object")?.ok_or_else(|| invalid("object without id"))?;
if m.objects.contains_key(&id) {
return bad(format!("two objects with id {id}"));
}
let faces = int(&a, Ns::FaceTags, "faces", "object")?;
if faces == Some(0) {
return bad(format!("object {id} has ft:faces=\"0\""));
}
let o = Object { name: get(&a, Ns::Core, "name").unwrap_or_default().trim().to_string(), faces, ..Default::default() };
if empty {
m.add(id, o)?;
} else {
cur = Some((id, o));
}
}
b"vertex" => {
let Some((_, o)) = cur.as_mut() else { return bad("vertex outside an object") };
vertices += 1;
if vertices > MAX_ELEMENTS {
return bad(format!("more than {MAX_ELEMENTS} vertices"));
}
o.positions.push([coord(&a, "x")?, coord(&a, "y")?, coord(&a, "z")?]);
}
b"triangle" => {
let Some((id, o)) = cur.as_mut() else { return bad("triangle outside an object") };
triangles += 1;
if triangles > MAX_ELEMENTS {
return bad(format!("more than {MAX_ELEMENTS} triangles"));
}
let mut t = [0u32; 3];
for (slot, k) in t.iter_mut().zip(["v1", "v2", "v3"]) {
*slot = int(&a, Ns::Core, k, "triangle")?.ok_or_else(|| invalid(format!("triangle without `{k}`")))?;
}
if let Some(f) = int(&a, Ns::FaceTags, "face", "triangle")? {
if let Some(n) = o.faces.filter(|n| f >= *n) {
return bad(format!("object {id}: face tag {f} is not below ft:faces=\"{n}\""));
}
o.tags.push(f);
}
o.triangles.push(t);
}
b"component" => {
let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") };
o.components = true;
}
b"item" => {
if m.build.len() >= MAX_BUILD_ITEMS {
return bad(format!("more than {MAX_BUILD_ITEMS} build items"));
}
if get(&a, Ns::Other, "path").is_some() {
return bad("not supported yet: build items in other model parts (production extension)");
}
let target = int(&a, Ns::Core, "objectid", "build item")?.ok_or_else(|| invalid("build item without objectid"))?;
m.build.push((target, parse_xf(get(&a, Ns::Core, "transform"))?));
}
_ => {}
}
}
if !seen_model {
return bad("no <model> element");
}
if cur.is_some() {
return bad("the model ends inside an object");
}
Ok(m)
}
/// Build item `k` as a surface: object `o` (checked) placed by `xf` and scaled to millimetres.
fn surface(mut o: Object, k: usize, id: u32, xf: &Xf, unit: f64) -> Result<TaggedSurface> {
if o.components {
return bad(format!("not supported yet: object {id} is made of components"));
}
if o.triangles.is_empty() {
return bad(format!("object {id} has no triangles"));
}
for p in &mut o.positions {
*p = apply(xf, *p).map(|c| c * unit);
if !p.iter().all(|c| c.is_finite() && c.abs() <= MAX_COORD) {
return bad(format!("object {id}: a placed vertex is out of range"));
}
}
if det(xf) < 0.0 {
o.triangles.iter_mut().for_each(|t| t.swap(1, 2));
}
if o.tags.is_empty() {
o.tags = vec![0; o.triangles.len()];
}
let faces = o.faces.unwrap_or_else(|| o.tags.iter().max().map_or(1, |f| f.saturating_add(1)));
let name = if o.name.is_empty() { format!("Mesh{}", k + 1) } else { o.name };
Ok(TaggedSurface { name, mesh: SurfaceMesh { positions: o.positions, triangles: o.triangles, tri_face: o.tags }, faces })
}
/// The surfaces of a 3MF package, one per build item, in millimetres.
pub fn read_3mf(bytes: &[u8]) -> Result<Vec<TaggedSurface>> {
if bytes.len() > MAX_FILE_BYTES {
return bad(format!("file too large ({} MB)", bytes.len() >> 20));
}
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| invalid(format!("not a zip package: {e}")))?;
if zip.len() > MAX_ENTRIES {
return bad(format!("{} zip entries", zip.len()));
}
// The model part named by the package relationships, else the usual name.
let mut root = "3D/3dmodel.model".to_string();
if let Some(rels) = read_part(&mut zip, "_rels/.rels")? {
let mut rd = NsReader::from_reader(rels.as_slice());
let mut buf = Vec::new();
loop {
match rd.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) if e.local_name().as_ref() == b"Relationship" => {
let a = attrs(&rd, &e)?;
if get(&a, Ns::Core, "Type").is_some_and(|t| t.ends_with("/3dmodel"))
&& let Some(t) = get(&a, Ns::Core, "Target")
{
root = t.trim_start_matches('/').to_string();
break;
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
}
let xml = read_part(&mut zip, &root)?.ok_or_else(|| invalid(format!("no model part `{root}`")))?;
let mut m = parse_model(&xml)?;
drop(xml);
if m.build.is_empty() {
return bad("no build items");
}
// An object's last placement takes it; earlier ones copy it. Every placement counts against
// one budget, so placing a big object many times stops at the cap.
let mut uses: HashMap<u32, usize> = HashMap::new();
for (id, _) in &m.build {
*uses.entry(*id).or_default() += 1;
}
let mut budget = 0usize;
let mut out = Vec::with_capacity(m.build.len());
for (k, (id, xf)) in m.build.iter().enumerate() {
let left = uses.get_mut(id).map(|n| std::mem::replace(n, n.saturating_sub(1))).unwrap_or(0);
let o = if left > 1 { m.objects.get(id).cloned() } else { m.objects.remove(id) };
let o = o.ok_or_else(|| invalid(format!("build item {} refers to missing object {id}", k + 1)))?;
budget = budget.saturating_add(o.positions.len().max(o.triangles.len()));
if budget > MAX_ELEMENTS {
return bad(format!("the build items place more than {MAX_ELEMENTS} vertices or triangles"));
}
out.push(surface(o, k, *id, xf, m.unit)?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use std::fmt::Write as _;
use std::io::Write as _;
use super::*;
const HEAD: &str = r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:ft="https://github.com/jyviko/modelcraft/formats/face-tags/2026/10"><resources>"#;
/// A writer built from docs/formats/tagged-mesh.md alone (SolidCraft's is a separate
/// program): shortest round-trip coordinates, one object and build item per surface.
fn write_tagged(surfaces: &[(&str, &SurfaceMesh, u32)]) -> Vec<u8> {
let mut x = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let _ = writeln!(
x,
"<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"http://schemas.microsoft.com/3dmanufacturing/core/2015/02\" xmlns:ft=\"{FACE_TAGS_NS}\">"
);
x.push_str(" <resources>\n");
for (k, (name, m, faces)) in surfaces.iter().enumerate() {
let _ = writeln!(x, " <object id=\"{}\" type=\"model\" name=\"{name}\" ft:faces=\"{faces}\">\n <mesh>\n <vertices>", k + 1);
for p in &m.positions {
let _ = writeln!(x, " <vertex x=\"{}\" y=\"{}\" z=\"{}\"/>", p[0], p[1], p[2]);
}
x.push_str(" </vertices>\n <triangles>\n");
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let _ = writeln!(x, " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\" ft:face=\"{f}\"/>", t[0], t[1], t[2]);
}
x.push_str(" </triangles>\n </mesh>\n </object>\n");
}
x.push_str(" </resources>\n <build>\n");
for k in 0..surfaces.len() {
let _ = writeln!(x, " <item objectid=\"{}\"/>", k + 1);
}
x.push_str(" </build>\n</model>\n");
let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Target=\"/3D/3dmodel.model\" Id=\"rel0\" Type=\"http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel\"/></Relationships>\n";
let types = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/></Types>\n";
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, data) in [("[Content_Types].xml", types), ("_rels/.rels", rels), ("3D/3dmodel.model", x.as_str())] {
let o = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.last_modified_time(zip::DateTime::default());
zip.start_file(name, o).unwrap();
zip.write_all(data.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// A package holding only a model part (no relationships: the usual name is used).
fn package(model: &str) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(model.as_bytes()).unwrap();
zip.finish().unwrap().into_inner()
}
fn tet(extra: &str) -> String {
format!(
"{HEAD}<object id=\"1\" name=\"Tet\"><mesh><vertices><vertex x=\"0\" y=\"0\" z=\"0\"/><vertex x=\"1\" y=\"0\" z=\"0\"/><vertex x=\"0\" y=\"1\" z=\"0\"/><vertex x=\"0\" y=\"0\" z=\"1\"/></vertices>\
<triangles><triangle v1=\"0\" v2=\"2\" v3=\"1\"/><triangle v1=\"0\" v2=\"1\" v3=\"3\"/><triangle v1=\"1\" v2=\"2\" v3=\"3\"/><triangle v1=\"0\" v2=\"3\" v3=\"2\"/></triangles></mesh></object>\
</resources><build><item objectid=\"1\"{extra}/></build></model>"
)
}
/// A SolidCraft block's tessellation goes out and comes back bit for bit, tags included.
#[test]
fn tessellation_round_trips_exactly() {
let block = crate::block([0.1, -0.3, 1.0 / 3.0], [2.0, 3.7, 4.0 + 1e-9]).unwrap();
let mesh = block.tessellate(0.01).unwrap();
let faces = u32::try_from(block.measure().unwrap().faces).unwrap();
let bytes = write_tagged(&[("Block", &mesh, faces), ("Again", &mesh, faces)]);
assert_eq!(bytes, write_tagged(&[("Block", &mesh, faces), ("Again", &mesh, faces)]), "deterministic bytes");
let got = read_3mf(&bytes).unwrap();
assert_eq!(got.len(), 2);
assert_eq!(got[0], TaggedSurface { name: "Block".into(), mesh: mesh.clone(), faces: 6 });
assert_eq!(got[1].name, "Again");
assert_eq!(got[1].mesh, mesh);
}
/// 3MF from other programs: no tags is one face; units and item transforms apply, and a
/// mirroring transform keeps the winding outward.
#[test]
fn untagged_units_and_transforms() {
let s = read_3mf(&package(&tet(""))).unwrap();
assert_eq!((s[0].faces, s[0].mesh.tri_face.clone()), (1, vec![0; 4]));
let cm = tet(" transform=\"-1 0 0 0 1 0 0 0 1 0 0 2\"").replace("millimeter", "centimeter");
let s = read_3mf(&package(&cm)).unwrap();
let m = &s[0].mesh;
assert_eq!(m.positions[1], [-10.0, 0.0, 20.0]);
let p = |i: u32| m.positions[i as usize];
let six_v: f64 = m
.triangles
.iter()
.map(|t| {
let (a, b, c) = (p(t[0]), p(t[1]), p(t[2]));
a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])
})
.sum();
assert!((six_v / 6.0 - 1000.0 / 6.0).abs() < 1e-9, "{}", six_v / 6.0);
}
/// Tags in another namespace, or under another prefix bound to ours, are read by namespace.
#[test]
fn tags_are_matched_by_namespace() {
let other =
tet("").replace("<triangle v1=\"0\" v2=\"2\" v3=\"1\"/>", "<triangle v1=\"0\" v2=\"2\" v3=\"1\" x:face=\"3\" xmlns:x=\"urn:else\"/>");
assert_eq!(read_3mf(&package(&other)).unwrap()[0].faces, 1);
let prefixed = tet("")
.replace("xmlns:ft=", "xmlns:q=")
.replace("<object id=\"1\"", "<object q:faces=\"4\" id=\"1\"")
.replace("\"/><triangle", "\" q:face=\"2\"/><triangle")
.replace("v3=\"2\"/></triangles>", "v3=\"2\" q:face=\"3\"/></triangles>");
let s = read_3mf(&package(&prefixed)).unwrap();
assert_eq!((s[0].faces, s[0].mesh.tri_face.clone()), (4, vec![2, 2, 2, 3]));
}
#[test]
fn hostile_packages_are_errors() {
let tagged = |a: &str, b: &str| {
tet("")
.replace("<triangle v1=\"0\" v2=\"2\" v3=\"1\"/>", &format!("<triangle v1=\"0\" v2=\"2\" v3=\"1\" {a}/>"))
.replace("name=\"Tet\"", b)
};
let cases = [
// truncated, not XML, nested too deep, no model
tet("")[..300].to_string(),
"not xml at all".to_string(),
format!("{}{}", "<a>".repeat(1000), "</a>".repeat(1000)),
"<other/>".to_string(),
// out-of-range index, NaN, infinite and huge coordinates
tet("").replace("v3=\"3\"/>", "v3=\"4\"/>"),
tet("").replace("<vertex x=\"1\"", "<vertex x=\"NaN\""),
tet("").replace("<vertex x=\"1\"", "<vertex x=\"inf\""),
tet("").replace("<vertex x=\"1\"", "<vertex x=\"1e300\""),
tet("").replace("v1=\"0\" v2=\"2\"", "v1=\"-1\" v2=\"2\""),
tet("").replace("v1=\"0\" v2=\"2\"", "v1=\"99999999999\" v2=\"2\""),
// missing attributes and objects
tet("").replace(" y=\"0\" z=\"0\"/><vertex x=\"1\"", " z=\"0\"/><vertex x=\"1\""),
tet("").replace("objectid=\"1\"", "objectid=\"9\""),
tet("").replace("<item objectid=\"1\"/>", ""),
// tags: partial, above ft:faces, zero faces, not a number
tagged("ft:face=\"0\"", "name=\"Tet\""),
tet("").replace("<triangle ", "<triangle ft:face=\"5\" ").replace("name=\"Tet\"", "name=\"Tet\" ft:faces=\"2\""),
tet("").replace("name=\"Tet\"", "name=\"Tet\" ft:faces=\"0\""),
tet("").replace("<triangle ", "<triangle ft:face=\"-2\" "),
// transforms, units, components, duplicates
tet(" transform=\"1 2\""),
tet(" transform=\"1e300 0 0 0 1 0 0 0 1 0 0 0\""),
tet(" transform=\"1e12 0 0 0 1 0 0 0 1 0 0 0\"").replace("millimeter", "meter"),
tet("").replace("millimeter", "parsec"),
format!(
"{HEAD}<object id=\"1\"><components><component objectid=\"1\"/></components></object></resources><build><item objectid=\"1\"/></build></model>"
),
format!("{HEAD}<object id=\"1\"/><object id=\"1\"/></resources><build><item objectid=\"1\"/></build></model>"),
format!("{HEAD}<object id=\"1\"><mesh></mesh></object></resources><build><item objectid=\"1\"/></build></model>"),
];
for c in &cases {
assert!(read_3mf(&package(c)).is_err(), "{c}");
}
assert!(read_3mf(b"").is_err());
assert!(read_3mf(b"PK\x03\x04garbage").is_err());
let good = write_tagged(&[("T", &SurfaceMesh { positions: vec![[0.0; 3]; 3], triangles: vec![[0, 1, 2]], tri_face: vec![0] }, 1)]);
for cut in [10, good.len() / 2, good.len() - 1] {
assert!(read_3mf(&good[..cut]).is_err(), "truncated at {cut}");
}
}
/// Placements share one budget, so a big object placed many times stops at the cap.
#[test]
fn many_placements_stay_bounded() {
let verts: String = (0..3_000).map(|i| format!("<vertex x=\"{i}\" y=\"0\" z=\"0\"/>")).collect();
let model = format!(
"{HEAD}<object id=\"1\"><mesh><vertices>{verts}</vertices><triangles><triangle v1=\"0\" v2=\"1\" v3=\"2\"/></triangles></mesh></object></resources><build>{}</build></model>",
"<item objectid=\"1\"/>".repeat(MAX_BUILD_ITEMS)
);
let e = read_3mf(&package(&model)).unwrap_err();
assert!(e.to_string().contains("place more than"), "{e}");
let too_many = format!("{HEAD}</resources><build>{}</build></model>", "<item objectid=\"1\"/>".repeat(MAX_BUILD_ITEMS + 1));
assert!(read_3mf(&package(&too_many)).unwrap_err().to_string().contains("build items"));
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ crates in strictly lower layers.
| Layer | Crate | Role | Status |
|---|---|---|---|
| L0 | `modelcraft-model` | Solver-neutral FE model: nodes, elements by label; materials, sections, sets, BCs, loads and steps as they land. Knows no file format. | started |
| L1 | `modelcraft-geometry` | The only door to SolidCraft: opens `.solidcraft` designs (rebuilt from their timeline, bodies with persistent face names), B-rep bodies, measures, tessellation into a face-tagged `SurfaceMesh`. No SolidCraft type in its API. | started |
| L1 | `modelcraft-geometry` | The only door to SolidCraft: opens `.solidcraft` designs (rebuilt from their timeline, bodies with persistent face names), B-rep bodies, measures, tessellation into a face-tagged `SurfaceMesh`; face-tagged 3MF files read into the same type without the kernel (docs/formats/tagged-mesh.md). No SolidCraft type in its API. | started |
| L1 | `modelcraft-mesh` | Surface preparation (weld, watertightness, feature edges), robust predicates, Delaunay tetrahedralization and refinement, Voronoi-based optimization, hex meshing, quality metrics. | started |
| L1 | `modelcraft-solver` | Element formulations, assembly, sparse linear static and modal solution. | planned |
| L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. | planned |
+99
View File
@@ -0,0 +1,99 @@
# Face-tagged mesh exchange (3MF with face tags)
Version 1 (2026-10-13). Namespace `https://github.com/jyviko/modelcraft/formats/face-tags/2026/10`.
The second way geometry enters ModelCraft, beside opening a `.solidcraft` design: a triangulated
boundary surface written by another program, each triangle tagged with the B-rep face it lies on.
ModelCraft reads it into a `SurfaceMesh` (`modelcraft_geometry::tagged_mesh`) with no kernel
involved. SolidCraft writes it (`file.export` with `format: "3mf-tagged"`).
## Decision: 3MF Core plus one optional attribute
The file is an ordinary **3MF package** (3MF Consortium, *3D Manufacturing Format Core
Specification* 1.3, publicly available). The face tags are one attribute in a namespace of our
own, which the Core specification allows on `<triangle>` and `<object>` and which any 3MF consumer
ignores unless the namespace is listed in `requiredextensions` (it is not).
Why not a small binary format of our own (versioned header, f64 positions, u32 triangles, u32
tags):
- **It is already written and read.** SolidCraft exports and imports 3MF with hostile-input caps;
slicers, viewers and other CAD programs write it too. A tagged file opens in all of them, and
ModelCraft reads untagged 3MF from those programs through the same code ("geometry from other
tools later").
- **One specification to maintain, published by a consortium**, not a byte layout we would have to
version, document and keep two implementations of in step.
- **Nothing is lost.** Coordinates are decimal text; written in shortest round-trip form (Rust's
`{}` for `f64`) every `f64` comes back bit for bit. Triangles and tags are integers.
- The costs (a zip and an XML parser, larger files, slower parsing than raw bytes) are acceptable
for an exchange file. Both libraries (`zip`, `quick-xml`) are MIT, pure Rust, build for wasm and
are the ones SolidCraft already uses.
## The package
As 3MF Core: a zip holding `[Content_Types].xml`, `_rels/.rels` (a relationship of type
`http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel` to the model part) and the model
part, conventionally `3D/3dmodel.model`.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter" xml:lang="en-US"
xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
xmlns:ft="https://github.com/jyviko/modelcraft/formats/face-tags/2026/10">
<metadata name="Application">SolidCraft</metadata>
<resources>
<object id="2" type="model" name="Body1" ft:faces="6">
<mesh>
<vertices>
<vertex x="0" y="0" z="0"/>
<vertex x="10" y="0" z="0"/>
...
</vertices>
<triangles>
<triangle v1="0" v2="2" v3="1" ft:face="0"/>
...
</triangles>
</mesh>
</object>
</resources>
<build>
<item objectid="2"/>
</build>
</model>
```
### Face tags
| Attribute | On | Value | Meaning |
|---|---|---|---|
| `ft:faces` | `<object>` | integer ≥ 1 | Number of B-rep faces of the body. Optional; when present every `ft:face` of the object is below it. Faces without triangles still count. |
| `ft:face` | `<triangle>` | integer ≥ 0 | The B-rep face (0-based, the body's own face order) the triangle lies on. |
An object's triangles are either **all tagged or none**. A body whose triangles carry no tags reads
as one face (tag 0). A file mixing tagged and untagged triangles in one object is rejected.
### Writer rules (version 1)
- `unit="millimeter"`, Z up.
- One `<object type="model">` with a `<mesh>` per body, named after the body, one `<item>` per
object, no `transform`, no components.
- Vertices shared within an object (coincident positions merged), triangles counter-clockwise
seen from outside, no degenerate triangles; coordinates in shortest round-trip decimal form.
- Colours, if written, use 3MF's own `basematerials` (`pid`/`p1`); they are independent of the tags.
- Deterministic bytes: fixed part order (`[Content_Types].xml`, `_rels/.rels`, the model),
fixed zip timestamps, no hash-ordered output.
### Reader rules (ModelCraft)
- Every build item becomes one `SurfaceMesh`, in millimetres (`unit` converted), named after its
object (`Mesh<n>` when unnamed). An item's `transform` is applied; a mirroring transform reverses
the winding. Components are not read yet (an error that says so).
- Untrusted input: file ≤ 512 MB, a part ≤ 1 GB uncompressed, ≤ 10 000 zip entries, ≤ 20 million
vertices and 20 million triangles in total, XML nesting ≤ 256. Coordinates must be finite with
magnitude ≤ 10¹²; vertex indices must exist; tags must be below `ft:faces` when it is given.
Any violation is an error naming the problem; nothing panics.
## Versions
A later version gets a new namespace (`…/face-tags/<year>/<month>`); readers accept the versions
they know and treat an unknown face-tag namespace as untagged.