brep_kernel/io/three_mf/mod.rs
1//! 3MF (3D Manufacturing Format) import — the core specification only.
2//!
3//! [`read_3mf`] reads a 3MF package (an OPC ZIP container) and returns the
4//! meshes its `<resources>` define plus the flattened list of instances the
5//! `<build>` places, in millimetres. It is a READER: the triangle soup it
6//! produces goes into the same mesh-to-solid chain STL and OBJ use
7//! (`brep_reconstruction::stl_conversion::convert_stl_mesh_to_step`); no
8//! geometry is built here.
9//!
10//! # What is supported
11//!
12//! The core specification (3MF Core Specification 1.x,
13//! `http://schemas.microsoft.com/3dmanufacturing/core/2015/02`):
14//!
15//! * `<model unit>` — `micron`, `millimeter`, `centimeter`, `inch`, `foot`,
16//! `meter`, defaulting to `millimeter`; every coordinate and translation is
17//! converted to millimetres, the unit the kernel works in.
18//! * `<resources>` with `<object id type>` carrying either a `<mesh>`
19//! (`<vertices><vertex x y z>`, `<triangles><triangle v1 v2 v3>`) or a
20//! `<components>` list of `<component objectid transform>`.
21//! * `<build>` with `<item objectid transform>`.
22//! * The 3x4 row-vector `transform` attribute on components and items, composed
23//! inner-to-outer, with the winding of a mirrored instance reversed so the
24//! soup stays outward-facing.
25//!
26//! # What is ignored, by design
27//!
28//! Everything outside the core namespace: the materials-and-properties, slice,
29//! production, beam-lattice and secure-content extensions. Extension elements
30//! are skipped as whole subtrees and prefixed attributes (`p:UUID`, a
31//! triangle's `pid`/`p1..p3` property indices) are never read, so a file that
32//! carries them imports as pure geometry rather than failing. A package that
33//! declares such an extension REQUIRED (`requiredextensions`) is refused
34//! instead, because honouring it is exactly what this reader cannot do.
35//! `<metadata>`, thumbnails and object names carry no geometry and are skipped.
36//!
37//! Anything the core specification makes required and the file omits — the
38//! start-part relationship, an object id, a triangle's vertex indices, a
39//! component's or item's `objectid` — is refused with a [`ThreeMfError`]
40//! naming the package part it was read from.
41//!
42//! # Bounds
43//!
44//! Nothing here grows to fit its input. The container refuses encryption,
45//! ZIP64, multi-disk packages and any compression but stored and deflate; a
46//! part is refused before decompression when it DECLARES more than
47//! [`MAX_PART_BYTES`], the deflate output stops at the declared size, and every
48//! part's CRC-32 and length are verified after it. The XML reader refuses a
49//! `<!DOCTYPE>` outright (so no entity is expanded, internal or external) and
50//! caps nesting. The model is held to [`MAX_MESH_VERTICES`],
51//! [`MAX_MESH_TRIANGLES`], [`MAX_INSTANCES`], [`MAX_TOTAL_TRIANGLES`] and
52//! [`MAX_COMPONENT_DEPTH`]. Every offset in the container is computed with
53//! `checked_add`, because the kernel's release profile sets
54//! `overflow-checks = false`.
55//!
56//! # Dependency review (why the container is hand-rolled)
57//!
58//! `BREP_kernel` publishes to crates.io and compiles to wasm on nine declared
59//! dependencies, seven of them unconditional; `zip` and `flate2` appear nowhere
60//! in `BREP_kernel/Cargo.lock` (they are in `BREP_app`'s lock only, pulled in by
61//! the egui/image stack, not by anything the kernel links). `zip` with
62//! `default-features = false, features = ["deflate"]` still adds `flate2`,
63//! `miniz_oxide`, `crc32fast`, `adler2` and their support crates to a published
64//! geometry kernel. The container here is ~1000 lines of reader for a format the
65//! kernel needs in one place, matching how STEP, IGES, STL and OBJ are already
66//! read: [`zip`] parses the central directory, [`inflate`] implements RFC 1951,
67//! [`xml`] is a bounded pull parser. Every one of them refuses rather than grows.
68
69mod inflate;
70mod model;
71mod xml;
72mod zip;
73
74
75use serde::Serialize;
76
77/// The 3MF core-specification namespace this reader accepts elements from.
78pub const CORE_NAMESPACE: &str = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
79/// The OPC relationships namespace of `_rels/.rels`.
80const RELATIONSHIP_NAMESPACE: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
81/// The relationship type naming the package's primary 3D model part.
82const MODEL_RELATIONSHIP: &str = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel";
83/// The package-level relationships part; required of every OPC package.
84const RELATIONSHIP_PART: &str = "_rels/.rels";
85
86/// Largest part this reader will expand, compressed or stored. A 3MF model part
87/// is XML: 256 MiB is roughly five million triangles of it.
88pub const MAX_PART_BYTES: usize = 256 * 1024 * 1024;
89/// Largest vertex and triangle counts accepted for one `<mesh>`.
90pub const MAX_MESH_VERTICES: usize = 8_000_000;
91pub const MAX_MESH_TRIANGLES: usize = 16_000_000;
92/// Largest number of placements the `<build>` may flatten to.
93pub const MAX_INSTANCES: usize = 65_536;
94/// Largest flattened triangle total across all instances.
95pub const MAX_TOTAL_TRIANGLES: usize = 32_000_000;
96/// Deepest `<components>` nesting followed when flattening. The core spec's
97/// "an object must be defined before it is referenced" already forbids cycles;
98/// this bounds the work a legal but pathological file can ask for.
99pub const MAX_COMPONENT_DEPTH: usize = 32;
100
101/// Why a 3MF package was refused. Every variant that belongs to a package part
102/// names it, so the message says where in the container the fault is.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum ThreeMfError {
105 /// The bytes are not a readable OPC/ZIP container at all.
106 Container(String),
107 /// A part is in the container but its bytes cannot be recovered: a bad
108 /// local header, a short read, a failed CRC-32.
109 Damaged { part: String, detail: String },
110 /// A part the core specification requires is absent.
111 MissingPart { part: String, detail: String },
112 /// A container or format feature this reader refuses on purpose.
113 Unsupported { part: String, detail: String },
114 /// A part exceeds one of this reader's documented bounds.
115 Bounds { part: String, detail: String },
116 /// A part is not well-formed XML.
117 Xml { part: String, detail: String },
118 /// A part is well-formed XML but violates the core specification.
119 Model { part: String, detail: String },
120}
121
122impl ThreeMfError {
123 /// The package part the failure was found in, when it belongs to one.
124 pub fn part(&self) -> Option<&str> {
125 match self {
126 Self::Container(_) => None,
127 Self::Damaged { part, .. }
128 | Self::MissingPart { part, .. }
129 | Self::Unsupported { part, .. }
130 | Self::Bounds { part, .. }
131 | Self::Xml { part, .. }
132 | Self::Model { part, .. } => Some(part),
133 }
134 }
135}
136
137impl core::fmt::Display for ThreeMfError {
138 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
139 match self {
140 Self::Container(detail) => write!(f, "3MF package: {detail}"),
141 Self::Damaged { part, detail } => write!(f, "3MF '{part}' is damaged: {detail}"),
142 Self::MissingPart { part, detail } => write!(f, "3MF '{part}' is missing: {detail}"),
143 Self::Unsupported { part, detail } => write!(f, "3MF '{part}': {detail}"),
144 Self::Bounds { part, detail } => write!(f, "3MF '{part}' exceeds a bound: {detail}"),
145 Self::Xml { part, detail } => write!(f, "3MF '{part}' is not well-formed: {detail}"),
146 Self::Model { part, detail } => write!(f, "3MF '{part}': {detail}"),
147 }
148 }
149}
150
151impl std::error::Error for ThreeMfError {}
152
153/// The `unit` a model part declares, with its conversion to millimetres.
154#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
155pub enum ThreeMfUnit {
156 Micron,
157 Millimeter,
158 Centimeter,
159 Inch,
160 Foot,
161 Meter,
162}
163
164impl ThreeMfUnit {
165 /// The spec's unit table (3MF Core, `<model unit>`), in millimetres.
166 pub fn millimetres(self) -> f64 {
167 match self {
168 Self::Micron => 0.001,
169 Self::Millimeter => 1.0,
170 Self::Centimeter => 10.0,
171 Self::Inch => 25.4,
172 Self::Foot => 304.8,
173 Self::Meter => 1000.0,
174 }
175 }
176
177 pub fn name(self) -> &'static str {
178 match self {
179 Self::Micron => "micron",
180 Self::Millimeter => "millimeter",
181 Self::Centimeter => "centimeter",
182 Self::Inch => "inch",
183 Self::Foot => "foot",
184 Self::Meter => "meter",
185 }
186 }
187
188 fn parse(text: &str) -> Option<Self> {
189 Some(match text {
190 "micron" => Self::Micron,
191 "millimeter" => Self::Millimeter,
192 "centimeter" => Self::Centimeter,
193 "inch" => Self::Inch,
194 "foot" => Self::Foot,
195 "meter" => Self::Meter,
196 _ => return None,
197 })
198 }
199}
200
201/// One `<object>`'s mesh, in millimetres and in the object's own coordinates.
202/// Shared: an object placed three times is ONE entry here and three
203/// [`ThreeMfInstance`]s.
204#[derive(Clone, Debug, Default, PartialEq, Serialize)]
205pub struct ThreeMfMesh {
206 /// The id of the `<object>` this mesh belongs to.
207 pub object_id: u32,
208 /// Vertex coordinates as xyz triples, millimetres.
209 pub positions: Vec<f64>,
210 /// Triangle corners, three indices into `positions`/3.
211 pub indices: Vec<u32>,
212}
213
214impl ThreeMfMesh {
215 pub fn vertex_count(&self) -> usize {
216 self.positions.len() / 3
217 }
218 pub fn triangle_count(&self) -> usize {
219 self.indices.len() / 3
220 }
221}
222
223/// One placement of one mesh: a `<build><item>`, or a `<component>` reached
224/// through one, with the transforms from the item down composed.
225#[derive(Clone, Debug, PartialEq, Serialize)]
226pub struct ThreeMfInstance {
227 /// The mesh-bearing object placed (not the item's `objectid` when the item
228 /// pointed at a `<components>` object).
229 pub object_id: u32,
230 /// Index into [`ThreeMfReadResult::meshes`].
231 pub mesh: usize,
232 /// The composed 3x4 row-vector transform, in 3MF attribute order
233 /// (`m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32`), translation in mm.
234 pub transform: [f64; 12],
235 /// Where this instance's geometry sits in the flattened buffers: its
236 /// vertices are `positions[first_vertex * 3 ..][.. vertex_count * 3]` and
237 /// its triangles `indices[first_triangle * 3 ..][.. triangle_count * 3]`.
238 /// Instances are laid out in order and never share a vertex, which is what
239 /// lets a consumer take one body out without re-flattening the build.
240 pub first_vertex: usize,
241 pub vertex_count: usize,
242 pub first_triangle: usize,
243 pub triangle_count: usize,
244}
245
246impl ThreeMfInstance {
247 /// Place an object-local point: `x' = x·m00 + y·m10 + z·m20 + m30`.
248 pub fn transform_point(&self, point: [f64; 3]) -> [f64; 3] {
249 transform_point(&self.transform, point)
250 }
251}
252
253/// Everything [`read_3mf`] recovered from a package.
254#[derive(Clone, Debug, Serialize)]
255pub struct ThreeMfReadResult {
256 /// The part the model was read from (from the start-part relationship).
257 pub model_part: String,
258 /// The unit the file declared, before conversion.
259 pub unit: ThreeMfUnit,
260 /// One entry per mesh-bearing `<object>` in `<resources>`, in document
261 /// order — including any the build never places.
262 pub meshes: Vec<ThreeMfMesh>,
263 /// The `<build>`, flattened through `<components>`: one entry per placed
264 /// mesh. Objects of a type other than `model` are not placed.
265 pub instances: Vec<ThreeMfInstance>,
266 /// Every instance's triangles in world coordinates, millimetres — the soup
267 /// the mesh-to-solid chain reads.
268 pub positions: Vec<f64>,
269 /// Triangle corners into `positions`/3, winding preserved (reversed for a
270 /// mirrored instance so the result stays outward-facing).
271 pub indices: Vec<u32>,
272}
273
274impl ThreeMfReadResult {
275 pub fn vertex_count(&self) -> usize {
276 self.positions.len() / 3
277 }
278 pub fn triangle_count(&self) -> usize {
279 self.indices.len() / 3
280 }
281
282 /// ONE instance's triangle soup: its world-space vertices and its triangles
283 /// rebased onto them. A multi-instance build is several bodies in one
284 /// buffer — this is how a consumer takes them apart by PLACEMENT without
285 /// knowing how `read_3mf` laid them out. The importer does not need it: the
286 /// mesh-to-solid chain splits the whole soup into its vertex-connected
287 /// components and reconstructs each as its own body. It is the answer when
288 /// the question is which instance a body came from, which the geometry
289 /// alone cannot say (two instances that touch are one component).
290 pub fn instance_mesh(&self, instance: usize) -> Option<(Vec<f64>, Vec<u32>)> {
291 let instance = self.instances.get(instance)?;
292 let positions = self
293 .positions
294 .get(instance.first_vertex * 3..(instance.first_vertex + instance.vertex_count) * 3)?
295 .to_vec();
296 let base = instance.first_vertex as u32;
297 let indices = self
298 .indices
299 .get(
300 instance.first_triangle * 3
301 ..(instance.first_triangle + instance.triangle_count) * 3,
302 )?
303 .iter()
304 .map(|index| index - base)
305 .collect();
306 Some((positions, indices))
307 }
308}
309
310/// The identity 3x4 transform, in 3MF attribute order.
311pub const IDENTITY: [f64; 12] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0];
312
313/// Place a point with a 3MF row-vector transform.
314pub fn transform_point(transform: &[f64; 12], point: [f64; 3]) -> [f64; 3] {
315 let [x, y, z] = point;
316 [
317 x * transform[0] + y * transform[3] + z * transform[6] + transform[9],
318 x * transform[1] + y * transform[4] + z * transform[7] + transform[10],
319 x * transform[2] + y * transform[5] + z * transform[8] + transform[11],
320 ]
321}
322
323/// Compose `inner` then `outer` (row vectors: `p · inner · outer`) — a
324/// component's transform inside the item that placed it.
325pub fn compose(inner: &[f64; 12], outer: &[f64; 12]) -> [f64; 12] {
326 let mut out = [0.0; 12];
327 for row in 0..4 {
328 for column in 0..3 {
329 let mut sum = if row == 3 { outer[9 + column] } else { 0.0 };
330 for k in 0..3 {
331 sum += inner[row * 3 + k] * outer[k * 3 + column];
332 }
333 out[row * 3 + column] = sum;
334 }
335 }
336 out
337}
338
339/// The determinant of the transform's linear part; negative means the instance
340/// is mirrored and its triangle winding has to be reversed.
341fn determinant(transform: &[f64; 12]) -> f64 {
342 let m = transform;
343 m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6])
344 + m[2] * (m[3] * m[7] - m[4] * m[6])
345}
346
347/// Read a 3MF package: locate the model part through the OPC relationships,
348/// parse the core model, and flatten the build into a millimetre triangle soup.
349///
350/// See the module documentation for what is read, what is ignored, and the
351/// bounds every part is held to.
352pub fn read_3mf(bytes: &[u8]) -> Result<ThreeMfReadResult, ThreeMfError> {
353 let archive = zip::ZipArchive::open(bytes).map_err(map_zip)?;
354 let relationships = archive.find(RELATIONSHIP_PART).ok_or_else(|| {
355 ThreeMfError::MissingPart {
356 part: RELATIONSHIP_PART.into(),
357 detail: "an OPC package must carry the package relationships part".into(),
358 }
359 })?;
360 let relationships_text = read_text(&archive, relationships, RELATIONSHIP_PART)?;
361 let model_part = start_part(&relationships_text)?;
362 let model_entry = archive.find(&model_part).ok_or_else(|| ThreeMfError::MissingPart {
363 part: model_part.clone(),
364 detail: format!("'{RELATIONSHIP_PART}' names it as the 3D model start part"),
365 })?;
366 let model_text = read_text(&archive, model_entry, &model_part)?;
367 let document = model::parse_model(&model_part, &model_text)?;
368 flatten(model_part, document)
369}
370
371/// Map a container fault onto the typed refusal, keeping the part name.
372fn map_zip(error: zip::ZipError) -> ThreeMfError {
373 let part = error.entry.clone().unwrap_or_else(|| "[package]".into());
374 match error.fault {
375 zip::ZipFault::Container => match &error.entry {
376 Some(entry) => ThreeMfError::Container(format!("{entry}: {}", error.message)),
377 None => ThreeMfError::Container(error.message),
378 },
379 zip::ZipFault::Damaged => ThreeMfError::Damaged { part, detail: error.message },
380 zip::ZipFault::Unsupported => ThreeMfError::Unsupported { part, detail: error.message },
381 zip::ZipFault::Bounds => ThreeMfError::Bounds { part, detail: error.message },
382 }
383}
384
385fn read_text(
386 archive: &zip::ZipArchive<'_>,
387 entry: &zip::ZipEntry,
388 part: &str,
389) -> Result<String, ThreeMfError> {
390 let bytes = archive.read(entry, MAX_PART_BYTES).map_err(map_zip)?;
391 String::from_utf8(bytes).map_err(|_| ThreeMfError::Xml {
392 part: part.to_owned(),
393 detail: "the part is not UTF-8 text".into(),
394 })
395}
396
397/// The `Target` of the package's 3D-model start-part relationship.
398fn start_part(text: &str) -> Result<String, ThreeMfError> {
399 let mut reader = xml::XmlReader::new(text);
400 loop {
401 let event = reader.next().map_err(|error| ThreeMfError::Xml {
402 part: RELATIONSHIP_PART.into(),
403 detail: error.to_string(),
404 })?;
405 match event {
406 xml::Event::Start(tag) => {
407 if tag.namespace != RELATIONSHIP_NAMESPACE {
408 reader.skip_subtree(&tag).map_err(|error| ThreeMfError::Xml {
409 part: RELATIONSHIP_PART.into(),
410 detail: error.to_string(),
411 })?;
412 continue;
413 }
414 if tag.name != "Relationship" {
415 continue;
416 }
417 if tag.attribute("Type") != Some(MODEL_RELATIONSHIP) {
418 continue;
419 }
420 let target = tag.attribute("Target").ok_or_else(|| ThreeMfError::Model {
421 part: RELATIONSHIP_PART.into(),
422 detail: "the 3D model relationship has no Target".into(),
423 })?;
424 let target = target.trim_start_matches('/');
425 if target.is_empty() {
426 return Err(ThreeMfError::Model {
427 part: RELATIONSHIP_PART.into(),
428 detail: "the 3D model relationship has an empty Target".into(),
429 });
430 }
431 return Ok(target.to_owned());
432 }
433 xml::Event::End { .. } => {}
434 xml::Event::Eof => break,
435 }
436 }
437 Err(ThreeMfError::MissingPart {
438 part: RELATIONSHIP_PART.into(),
439 detail: format!("no relationship of type '{MODEL_RELATIONSHIP}' names a 3D model part"),
440 })
441}
442
443/// Walk the build, composing transforms, into instances and one triangle soup.
444fn flatten(
445 model_part: String,
446 document: model::ModelDocument,
447) -> Result<ThreeMfReadResult, ThreeMfError> {
448 let scale = document.unit.millimetres();
449 let meshes = document
450 .meshes
451 .iter()
452 .map(|mesh| ThreeMfMesh {
453 object_id: mesh.object_id,
454 positions: mesh.positions.iter().map(|value| value * scale).collect(),
455 indices: mesh.indices.clone(),
456 })
457 .collect::<Vec<_>>();
458 let mut instances = Vec::new();
459 for item in &document.items {
460 let mut transform = item.transform;
461 for value in &mut transform[9..12] {
462 *value *= scale;
463 }
464 place(&document, &model_part, item.object_id, transform, 0, &mut instances)?;
465 }
466 let mut positions = Vec::new();
467 let mut indices = Vec::new();
468 let mut triangles = 0usize;
469 for instance in &mut instances {
470 let mesh = &meshes[instance.mesh];
471 triangles += mesh.triangle_count();
472 if triangles > MAX_TOTAL_TRIANGLES {
473 return Err(ThreeMfError::Bounds {
474 part: model_part,
475 detail: format!(
476 "the build places more than {MAX_TOTAL_TRIANGLES} triangles in total"
477 ),
478 });
479 }
480 instance.first_vertex = positions.len() / 3;
481 instance.vertex_count = mesh.vertex_count();
482 instance.first_triangle = indices.len() / 3;
483 instance.triangle_count = mesh.triangle_count();
484 let base = instance.first_vertex as u32;
485 for point in mesh.positions.chunks_exact(3) {
486 let placed = instance.transform_point([point[0], point[1], point[2]]);
487 positions.extend(placed);
488 }
489 // A mirrored placement turns the mesh inside out; reversing the winding
490 // keeps every triangle facing out of the body, which is what the
491 // mesh-to-solid chain reads.
492 let mirrored = determinant(&instance.transform) < 0.0;
493 for triangle in mesh.indices.chunks_exact(3) {
494 if mirrored {
495 indices.extend([base + triangle[0], base + triangle[2], base + triangle[1]]);
496 } else {
497 indices.extend([base + triangle[0], base + triangle[1], base + triangle[2]]);
498 }
499 }
500 }
501 Ok(ThreeMfReadResult {
502 model_part,
503 unit: document.unit,
504 meshes,
505 instances,
506 positions,
507 indices,
508 })
509}
510
511/// Place `object_id` under `transform`, recursing through `<components>`.
512fn place(
513 document: &model::ModelDocument,
514 model_part: &str,
515 object_id: u32,
516 transform: [f64; 12],
517 depth: usize,
518 instances: &mut Vec<ThreeMfInstance>,
519) -> Result<(), ThreeMfError> {
520 if depth > MAX_COMPONENT_DEPTH {
521 return Err(ThreeMfError::Bounds {
522 part: model_part.to_owned(),
523 detail: format!("components nest deeper than {MAX_COMPONENT_DEPTH} levels"),
524 });
525 }
526 let object = document.object(object_id).ok_or_else(|| ThreeMfError::Model {
527 part: model_part.to_owned(),
528 detail: format!("object {object_id} is placed but never defined"),
529 })?;
530 match &object.content {
531 model::ObjectContent::Mesh(mesh) => {
532 // Only PRINTABLE geometry reaches the soup: support, surface and
533 // "other" objects describe the print job, not the part.
534 if object.model_type {
535 if instances.len() + 1 > MAX_INSTANCES {
536 return Err(ThreeMfError::Bounds {
537 part: model_part.to_owned(),
538 detail: format!("the build places more than {MAX_INSTANCES} instances"),
539 });
540 }
541 instances.push(ThreeMfInstance {
542 object_id: object.id,
543 mesh: *mesh,
544 transform,
545 // Filled by `flatten` when the soup is laid out.
546 first_vertex: 0,
547 vertex_count: 0,
548 first_triangle: 0,
549 triangle_count: 0,
550 });
551 }
552 Ok(())
553 }
554 model::ObjectContent::Components(components) => {
555 for component in components {
556 let mut inner = component.transform;
557 // The component's translation is in the file's unit; the
558 // enclosing transform is already in millimetres, so scaling
559 // here (not in `flatten`) keeps one conversion per level.
560 let scale = document.unit.millimetres();
561 for value in &mut inner[9..12] {
562 *value *= scale;
563 }
564 let composed = compose(&inner, &transform);
565 place(
566 document,
567 model_part,
568 component.object_id,
569 composed,
570 depth + 1,
571 instances,
572 )?;
573 }
574 Ok(())
575 }
576 }
577}