Skip to main content

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}