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