Skip to main content

brep_kernel/io/three_mf/
mod.rs

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