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}