From f181c98b754c31304e77d8860b0306881c4917d6 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:45:48 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --- ATTRIBUTION.md | 1 + Cargo.lock | 3 + Cargo.toml | 4 + ROADMAP.md | 2 +- crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/geometry.rs | 70 ++++ crates/engine/src/cmd/mod.rs | 15 + crates/engine/src/lib.rs | 20 +- crates/geometry/Cargo.toml | 2 + crates/geometry/src/lib.rs | 1 + crates/geometry/src/tagged_mesh.rs | 575 +++++++++++++++++++++++++++++ docs/architecture.md | 2 +- docs/formats/tagged-mesh.md | 99 +++++ 13 files changed, 791 insertions(+), 6 deletions(-) create mode 100644 crates/geometry/src/tagged_mesh.rs create mode 100644 docs/formats/tagged-mesh.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 7402518..5861894 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index 76726ad..5316062 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 7fe486b..1193b67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/ROADMAP.md b/ROADMAP.md index 255d34d..a2bf6cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c778e9e..54ab18d 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -15,3 +15,6 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } + +[dev-dependencies] +zip = { workspace = true } diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index 27e026f..345a38f 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -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 { let path = p .get("path") @@ -77,3 +86,64 @@ fn tessellate(s: &mut Session, p: &Value) -> Result { let faces: Vec = 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 { + 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 = 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 { + 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#" + + +"#, + 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); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6d508ce..3b3e142 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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> { + 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) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 60dd90b..9c4e82a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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>, + /// 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>, /// 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 { 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 diff --git a/crates/geometry/Cargo.toml b/crates/geometry/Cargo.toml index 8dbeaa3..64cd73f 100644 --- a/crates/geometry/Cargo.toml +++ b/crates/geometry/Cargo.toml @@ -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 } diff --git a/crates/geometry/src/lib.rs b/crates/geometry/src/lib.rs index 5937f7d..d377eda 100644 --- a/crates/geometry/src/lib.rs +++ b/crates/geometry/src/lib.rs @@ -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)] diff --git a/crates/geometry/src/tagged_mesh.rs b/crates/geometry/src/tagged_mesh.rs new file mode 100644 index 0000000..c8d767e --- /dev/null +++ b/crates/geometry/src/tagged_mesh.rs @@ -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(msg: impl std::fmt::Display) -> Result { + Err(invalid(msg)) +} + +fn read_part(zip: &mut zip::ZipArchive>, name: &str) -> Result>> { + 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, e: &'a BytesStart) -> Result> { + 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> { + match get(a, ns, key) { + None => Ok(None), + Some(v) => v.trim().parse::().map(Some).map_err(|_| invalid(format!("{what} `{key}` is not a whole number: `{v}`"))), + } +} + +fn coord(a: &Attrs, key: &str) -> Result { + let v = get(a, Ns::Core, key).ok_or_else(|| invalid(format!("vertex without `{key}`")))?; + v.trim().parse::().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 { + let Some(s) = s else { return Ok(IDENTITY) }; + let v: Vec = s + .split_whitespace() + .take(13) + .map(|t| t.parse::().ok().filter(|x| x.is_finite() && x.abs() <= MAX_COORD)) + .collect::>() + .ok_or_else(|| invalid(format!("bad transform `{s}`")))?; + ::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 { + 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, + faces: Option, + 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, + 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 { + 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 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 { + 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> { + 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 = 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#""#; + + /// 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 { + let mut x = String::from("\n"); + let _ = writeln!( + x, + "" + ); + x.push_str(" \n"); + for (k, (name, m, faces)) in surfaces.iter().enumerate() { + let _ = writeln!(x, " \n \n ", k + 1); + for p in &m.positions { + let _ = writeln!(x, " ", p[0], p[1], p[2]); + } + x.push_str(" \n \n"); + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let _ = writeln!(x, " ", t[0], t[1], t[2]); + } + x.push_str(" \n \n \n"); + } + x.push_str(" \n \n"); + for k in 0..surfaces.len() { + let _ = writeln!(x, " ", k + 1); + } + x.push_str(" \n\n"); + let rels = "\n\n"; + let types = "\n\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 { + 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}\ + \ + " + ) + } + + /// 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("", ""); + assert_eq!(read_3mf(&package(&other)).unwrap()[0].faces, 1); + let prefixed = tet("") + .replace("xmlns:ft=", "xmlns:q=") + .replace("", "v3=\"2\" q:face=\"3\"/>"); + 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("", &format!("")) + .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!("{}{}", "".repeat(1000), "".repeat(1000)), + "".to_string(), + // out-of-range index, NaN, infinite and huge coordinates + tet("").replace("v3=\"3\"/>", "v3=\"4\"/>"), + tet("").replace("", ""), + // tags: partial, above ft:faces, zero faces, not a number + tagged("ft:face=\"0\"", "name=\"Tet\""), + tet("").replace("" + ), + format!("{HEAD}"), + format!("{HEAD}"), + ]; + 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!("")).collect(); + let model = format!( + "{HEAD}{verts}{}", + "".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}{}", "".repeat(MAX_BUILD_ITEMS + 1)); + assert!(read_3mf(&package(&too_many)).unwrap_err().to_string().contains("build items")); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 0396437..f807fc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/formats/tagged-mesh.md b/docs/formats/tagged-mesh.md new file mode 100644 index 0000000..700d061 --- /dev/null +++ b/docs/formats/tagged-mesh.md @@ -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 `` and `` 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 + + + SolidCraft + + + + + + + ... + + + + ... + + + + + + + + +``` + +### Face tags + +| Attribute | On | Value | Meaning | +|---|---|---|---| +| `ft:faces` | `` | 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` | `` | 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 `` with a `` per body, named after the body, one `` 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` 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//`); readers accept the versions +they know and treat an unknown face-tag namespace as untagged.