Skip to main content

lib3mf_core/model/
resolver.rs

1use crate::archive::ArchiveReader;
2use crate::error::{Lib3mfError, Result};
3use crate::model::{Geometry, Mesh, Model, Object, ObjectType, ResourceId, Unit};
4use crate::parser::model_parser::parse_model;
5use std::collections::HashMap;
6use std::io::Cursor;
7
8const ROOT_PATH: &str = "ROOT";
9const MAIN_MODEL_PART: &str = "3D/3dmodel.model";
10
11/// Resolves resources across multiple model parts in a 3MF package.
12pub struct PartResolver<'a, A: ArchiveReader> {
13    archive: &'a mut A,
14    models: HashMap<String, Model>,
15}
16
17impl<'a, A: ArchiveReader> PartResolver<'a, A> {
18    /// Creates a new `PartResolver` with the given archive and root model.
19    pub fn new(archive: &'a mut A, root_model: Model) -> Self {
20        let mut models = HashMap::new();
21        models.insert(ROOT_PATH.to_string(), root_model);
22        Self { archive, models }
23    }
24
25    /// Resolves an object by ID and optional model part path.
26    pub fn resolve_object(
27        &mut self,
28        id: ResourceId,
29        path: Option<&str>,
30    ) -> Result<Option<(&Model, &Object)>> {
31        let model = self.get_or_load_model(path)?;
32        Ok(model.resources.get_object(id).map(|obj| (model, obj)))
33    }
34
35    /// Resolves a base materials group by ID and optional model part path.
36    pub fn resolve_base_materials(
37        &mut self,
38        id: ResourceId,
39        path: Option<&str>,
40    ) -> Result<Option<&crate::model::BaseMaterialsGroup>> {
41        let model = self.get_or_load_model(path)?;
42        Ok(model.resources.get_base_materials(id))
43    }
44
45    /// Resolves a color group by ID and optional model part path.
46    pub fn resolve_color_group(
47        &mut self,
48        id: ResourceId,
49        path: Option<&str>,
50    ) -> Result<Option<&crate::model::ColorGroup>> {
51        let model = self.get_or_load_model(path)?;
52        Ok(model.resources.get_color_group(id))
53    }
54
55    fn get_or_load_model(&mut self, path: Option<&str>) -> Result<&Model> {
56        let part_path = match path {
57            Some(p) => {
58                let p = p.trim_start_matches('/');
59                if p.is_empty() || p.eq_ignore_ascii_case(MAIN_MODEL_PART) {
60                    ROOT_PATH
61                } else {
62                    p
63                }
64            }
65            None => ROOT_PATH,
66        };
67
68        if !self.models.contains_key(part_path) {
69            let data = self.archive.read_entry(part_path).or_else(|_| {
70                let alt = format!("/{}", part_path);
71                self.archive.read_entry(&alt)
72            })?;
73
74            let model = parse_model(Cursor::new(data))?;
75            self.models.insert(part_path.to_string(), model);
76        }
77
78        Ok(self.models.get(part_path).unwrap())
79    }
80
81    /// Returns a reference to the root model.
82    pub fn get_root_model(&self) -> &Model {
83        self.models.get("ROOT").unwrap()
84    }
85
86    /// Returns a mutable reference to the underlying archive reader.
87    pub fn archive_mut(&mut self) -> &mut A {
88        self.archive
89    }
90
91    /// Resolves all printable meshes from the build, flattening component hierarchies.
92    ///
93    /// Walks build items → component trees → sub-model files (via Production Extension
94    /// `p:path` references), accumulates transforms, and returns a flat `Vec<ResolvedMesh>`.
95    ///
96    /// # Filtering
97    ///
98    /// - `options.filter_non_printable` (default `true`): skip `BuildItem.printable == Some(false)`
99    /// - `options.filter_other_objects` (default `true`): skip leaf objects with `ObjectType::Other`
100    ///
101    /// # Transform accumulation
102    ///
103    /// Each `ResolvedMesh.transform` is the accumulated product of transforms along the
104    /// build item → component chain: `build_item.transform * comp1.transform * comp2.transform ...`
105    /// Transforms are NOT pre-applied to vertex positions; the consumer applies them in their
106    /// own coordinate space and precision.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if:
111    /// - A component references an object that does not exist in the target model
112    /// - A missing sub-model file is referenced
113    /// - A component cycle is detected (same `(object_id, path)` in the current ancestry)
114    /// - The component nesting depth exceeds `options.max_depth`
115    pub fn resolve_meshes(&mut self, options: &ResolveOptions) -> Result<Vec<ResolvedMesh>> {
116        // Clone build items to release the immutable borrow on self before calling
117        // methods that require &mut self (same pattern as stats_impl.rs:15-23).
118        let build_items = self.get_root_model().build.items.clone();
119
120        let mut out = Vec::new();
121        let mut ancestry: Vec<(u32, String)> = Vec::new();
122
123        for item in &build_items {
124            if options.filter_non_printable && item.printable == Some(false) {
125                continue;
126            }
127
128            resolve_recursive(
129                item.object_id,
130                item.path.as_deref(),
131                item.transform,
132                0,
133                &mut ancestry,
134                options,
135                self,
136                &mut out,
137            )?;
138        }
139
140        Ok(out)
141    }
142}
143
144/// A single resolved mesh instance with its accumulated world transform.
145///
146/// Produced by [`PartResolver::resolve_meshes`]. Each entry corresponds to one leaf
147/// mesh object in the component hierarchy.
148///
149/// # Mesh ownership
150///
151/// The mesh is owned (not a reference). The borrow checker prevents returning `&'a Mesh`
152/// because `get_or_load_model()` requires `&mut self` on `PartResolver`, which conflicts
153/// with holding shared references into previously loaded model data. Cloning is the
154/// standard pattern used throughout this codebase (see `stats_impl.rs` lines 237-260).
155/// A future refactor to `Rc<Model>` inside `PartResolver` would enable zero-copy returns.
156#[derive(Debug, Clone)]
157pub struct ResolvedMesh {
158    /// The actual mesh geometry (owned clone — see struct-level doc for rationale).
159    pub mesh: Mesh,
160    /// Accumulated transform from the build item and component chain.
161    /// This is the product of all transforms from root to this leaf:
162    /// `build_item.transform * comp1.transform * ... * compN.transform`.
163    /// Transforms are NOT pre-applied to vertex positions.
164    pub transform: glam::Mat4,
165    /// Object type from the source object (Model, Support, SolidSupport, Surface, Other).
166    pub object_type: ObjectType,
167    /// Human-readable name of the source object, if set.
168    pub name: Option<String>,
169    /// Unit of measurement from the source model.
170    /// Not converted — the consumer uses [`Unit::convert`] to reach their target unit.
171    pub unit: Unit,
172}
173
174/// Options controlling the behavior of [`PartResolver::resolve_meshes`].
175#[derive(Debug, Clone)]
176pub struct ResolveOptions {
177    /// Skip build items where `printable == Some(false)`. Default: `true`.
178    ///
179    /// When `true`, only items that are either unspecified or explicitly printable are included.
180    pub filter_non_printable: bool,
181    /// Skip leaf objects whose `object_type` is [`ObjectType::Other`]. Default: `true`.
182    ///
183    /// BambuStudio/OrcaSlicer 3MF files include modifier volumes as `type="other"` objects.
184    /// Enabling this filter (the default) omits modifier volumes from results.
185    pub filter_other_objects: bool,
186    /// Maximum component nesting depth before returning an error. Default: `16`.
187    ///
188    /// Protects against malformed files with excessively deep or infinite component trees.
189    pub max_depth: u32,
190}
191
192impl Default for ResolveOptions {
193    fn default() -> Self {
194        Self {
195            filter_non_printable: true,
196            filter_other_objects: true,
197            max_depth: 16,
198        }
199    }
200}
201
202/// Normalizes a component path to a canonical string for cycle detection and path inheritance.
203///
204/// Mirrors the normalization logic in `PartResolver::get_or_load_model()` (lines 52–62).
205/// - `None`, `"ROOT"`, `"3D/3dmodel.model"`, and `"/3D/3dmodel.model"` all map to `"ROOT"`.
206/// - All other paths have leading `/` stripped.
207fn canonical_path(path: Option<&str>) -> String {
208    match path {
209        None | Some(ROOT_PATH) => ROOT_PATH.to_string(),
210        Some(p) => {
211            let p = p.trim_start_matches('/');
212            if p.is_empty() || p.eq_ignore_ascii_case(MAIN_MODEL_PART) {
213                ROOT_PATH.to_string()
214            } else {
215                p.to_string()
216            }
217        }
218    }
219}
220
221/// Recursively walks the component tree and collects [`ResolvedMesh`] entries.
222///
223/// This is the internal workhorse for [`PartResolver::resolve_meshes`]. It mirrors
224/// `Model::accumulate_object_stats()` from `stats_impl.rs` but collects resolved meshes
225/// instead of statistics.
226///
227/// # Parameters
228///
229/// - `id`: The object ID to resolve in the model at `path`.
230/// - `path`: The archive path of the model containing `id` (`None` means root model).
231/// - `transform`: Accumulated parent transform to multiply with component transforms.
232/// - `depth`: Current recursion depth (checked against `options.max_depth`).
233/// - `ancestry`: DFS stack of `(object_id, canonical_path)` pairs in the current tree path.
234///   Used for cycle detection (not a global visited set — instancing is legal).
235/// - `options`: Filtering and safety options.
236/// - `resolver`: The part resolver providing model access and sub-model loading.
237/// - `out`: Output collection for resolved meshes.
238#[allow(clippy::too_many_arguments)]
239fn resolve_recursive(
240    id: ResourceId,
241    path: Option<&str>,
242    transform: glam::Mat4,
243    depth: u32,
244    ancestry: &mut Vec<(u32, String)>,
245    options: &ResolveOptions,
246    resolver: &mut PartResolver<impl ArchiveReader>,
247    out: &mut Vec<ResolvedMesh>,
248) -> Result<()> {
249    // Depth guard — protects against deeply nested or infinite component trees.
250    if depth > options.max_depth {
251        return Err(Lib3mfError::InvalidStructure(format!(
252            "Component tree depth {} exceeds maximum of {}",
253            depth, options.max_depth
254        )));
255    }
256
257    // Cycle detection — uses DFS ancestry stack so instancing (same object in
258    // different subtrees) is correctly allowed (per RESEARCH.md Pitfall 1).
259    let canonical = canonical_path(path);
260    let key = (id.0, canonical.clone());
261    if ancestry.contains(&key) {
262        return Err(Lib3mfError::InvalidStructure(format!(
263            "Cycle detected: object {} in path {:?} appears in current ancestry",
264            id.0, path
265        )));
266    }
267    ancestry.push(key.clone());
268
269    // Resolve the object and clone data to escape the borrow on `resolver`.
270    // The borrow checker prevents holding `&Object` (immutable) from `resolve_object`
271    // while also calling `get_or_load_model` (&mut self) for child components.
272    // Cloning geometry/type/name/unit follows the same pattern as stats_impl.rs:237-260.
273    let (geom, inherited_path, obj_type, obj_name, obj_unit) = {
274        let resolved = resolver.resolve_object(id, path)?;
275        match resolved {
276            None => {
277                return Err(Lib3mfError::InvalidStructure(format!(
278                    "Object {} not found in path {:?}",
279                    id.0, path
280                )));
281            }
282            Some((model, object)) => {
283                let geom = object.geometry.clone();
284                let obj_type = object.object_type;
285                let obj_name = object.name.clone();
286                let obj_unit = model.unit;
287                // Compute the inherited path for children.
288                // Root-context objects (None, ROOT, or main model path) do NOT propagate
289                // their path — children that need a sub-model path must specify it explicitly
290                // via their own `component.path`. Sub-file objects DO propagate their path.
291                // This mirrors stats_impl.rs:243-251.
292                let inherited = if canonical == ROOT_PATH {
293                    None
294                } else {
295                    Some(canonical.clone())
296                };
297                (geom, inherited, obj_type, obj_name, obj_unit)
298            }
299        }
300    };
301
302    match geom {
303        Geometry::Mesh(mesh) => {
304            // ObjectType filtering happens at the leaf (Mesh) level, not at the Component level.
305            // A Components object may contain a mix of model and other sub-objects.
306            if !options.filter_other_objects || obj_type != ObjectType::Other {
307                out.push(ResolvedMesh {
308                    mesh,
309                    transform,
310                    object_type: obj_type,
311                    name: obj_name,
312                    unit: obj_unit,
313                });
314            }
315        }
316        Geometry::Components(comps) => {
317            for comp in comps.components {
318                // Path priority: component's own path (1) > inherited from parent (2) > None (root).
319                // This matches stats_impl.rs:299.
320                let next_path = comp.path.as_deref().or(inherited_path.as_deref());
321
322                // Transform accumulation: parent * child (parent applied first).
323                // This matches stats_impl.rs:304.
324                resolve_recursive(
325                    comp.object_id,
326                    next_path,
327                    transform * comp.transform,
328                    depth + 1,
329                    ancestry,
330                    options,
331                    resolver,
332                    out,
333                )?;
334            }
335        }
336        // SliceStack, VolumetricStack, BooleanShape, DisplacementMesh:
337        // These are not triangle meshes — skip silently.
338        _ => {}
339    }
340
341    // Backtrack: remove from ancestry so the same object can appear in other subtrees
342    // (instancing). The ancestry stack represents only the CURRENT path from root.
343    ancestry.pop();
344    Ok(())
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::model::{
351        BuildItem, Component, Components, Geometry, Mesh, Model, Object, ObjectType, ResourceId,
352        Unit,
353    };
354    use std::collections::HashMap;
355    use std::io::{Cursor, Read, Seek, SeekFrom};
356
357    // ---------------------------------------------------------------------------
358    // MockArchive: in-memory ArchiveReader for testing without real ZIP files
359    // ---------------------------------------------------------------------------
360
361    struct MockArchive {
362        entries: HashMap<String, Vec<u8>>,
363        cursor: Cursor<Vec<u8>>,
364    }
365
366    impl MockArchive {
367        fn new() -> Self {
368            Self {
369                entries: HashMap::new(),
370                cursor: Cursor::new(Vec::new()),
371            }
372        }
373
374        fn add_entry(&mut self, path: &str, data: Vec<u8>) {
375            self.entries.insert(path.to_string(), data);
376        }
377    }
378
379    impl Read for MockArchive {
380        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
381            self.cursor.read(buf)
382        }
383    }
384
385    impl Seek for MockArchive {
386        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
387            self.cursor.seek(pos)
388        }
389    }
390
391    impl ArchiveReader for MockArchive {
392        fn read_entry(&mut self, name: &str) -> Result<Vec<u8>> {
393            self.entries.get(name).cloned().ok_or_else(|| {
394                Lib3mfError::Io(std::io::Error::new(
395                    std::io::ErrorKind::NotFound,
396                    format!("Entry not found: {}", name),
397                ))
398            })
399        }
400
401        fn entry_exists(&mut self, name: &str) -> bool {
402            self.entries.contains_key(name)
403        }
404
405        fn list_entries(&mut self) -> Result<Vec<String>> {
406            Ok(self.entries.keys().cloned().collect())
407        }
408    }
409
410    // ---------------------------------------------------------------------------
411    // Helpers: build simple models and objects for tests
412    // ---------------------------------------------------------------------------
413
414    /// Serialize a Model to XML bytes (for use as a sub-model entry in MockArchive).
415    fn model_to_xml_bytes(model: &Model) -> Vec<u8> {
416        let mut buf = Vec::new();
417        model.write_xml(&mut buf, None).expect("write_xml failed");
418        buf
419    }
420
421    /// Create a simple triangle mesh with 3 vertices and 1 triangle.
422    fn simple_mesh() -> Mesh {
423        let mut mesh = Mesh::new();
424        mesh.add_vertex(0.0, 0.0, 0.0);
425        mesh.add_vertex(1.0, 0.0, 0.0);
426        mesh.add_vertex(0.0, 1.0, 0.0);
427        mesh.add_triangle(0, 1, 2);
428        mesh
429    }
430
431    /// Create an Object with a mesh geometry.
432    fn mesh_object(id: u32, object_type: ObjectType, name: Option<&str>) -> Object {
433        Object {
434            id: ResourceId(id),
435            object_type,
436            name: name.map(|s| s.to_string()),
437            part_number: None,
438            uuid: None,
439            pid: None,
440            pindex: None,
441            thumbnail: None,
442            geometry: Geometry::Mesh(simple_mesh()),
443        }
444    }
445
446    /// Create an Object with a components geometry.
447    fn components_object(id: u32, components: Vec<Component>) -> Object {
448        Object {
449            id: ResourceId(id),
450            object_type: ObjectType::Model,
451            name: None,
452            part_number: None,
453            uuid: None,
454            pid: None,
455            pindex: None,
456            thumbnail: None,
457            geometry: Geometry::Components(Components { components }),
458        }
459    }
460
461    /// Create a Component reference to an object (no path, identity transform).
462    fn component(object_id: u32) -> Component {
463        Component {
464            object_id: ResourceId(object_id),
465            path: None,
466            uuid: None,
467            transform: glam::Mat4::IDENTITY,
468        }
469    }
470
471    /// Create a Component with a specific transform.
472    fn component_with_transform(object_id: u32, transform: glam::Mat4) -> Component {
473        Component {
474            object_id: ResourceId(object_id),
475            path: None,
476            uuid: None,
477            transform,
478        }
479    }
480
481    /// Create a Component with an external path.
482    fn component_with_path(object_id: u32, path: &str, transform: glam::Mat4) -> Component {
483        Component {
484            object_id: ResourceId(object_id),
485            path: Some(path.to_string()),
486            uuid: None,
487            transform,
488        }
489    }
490
491    /// Create a BuildItem referencing an object.
492    fn build_item(object_id: u32) -> BuildItem {
493        BuildItem {
494            object_id: ResourceId(object_id),
495            uuid: None,
496            path: None,
497            part_number: None,
498            transform: glam::Mat4::IDENTITY,
499            printable: None,
500        }
501    }
502
503    /// Create a BuildItem with a specific transform.
504    fn build_item_with_transform(object_id: u32, transform: glam::Mat4) -> BuildItem {
505        BuildItem {
506            object_id: ResourceId(object_id),
507            uuid: None,
508            path: None,
509            part_number: None,
510            transform,
511            printable: None,
512        }
513    }
514
515    /// Create a BuildItem with a printable flag.
516    fn build_item_printable(object_id: u32, printable: Option<bool>) -> BuildItem {
517        BuildItem {
518            object_id: ResourceId(object_id),
519            uuid: None,
520            path: None,
521            part_number: None,
522            transform: glam::Mat4::IDENTITY,
523            printable,
524        }
525    }
526
527    // ---------------------------------------------------------------------------
528    // Tests
529    // ---------------------------------------------------------------------------
530
531    #[test]
532    fn test_resolve_same_file_components() {
533        // Object id=1: Mesh with 3 vertices, 1 triangle
534        // Object id=2: Components referencing id=1 at identity transform
535        // Build item referencing id=2
536        let mut model = Model::default();
537        let obj1 = mesh_object(1, ObjectType::Model, None);
538        let obj2 = components_object(2, vec![component(1)]);
539        model.resources.add_object(obj1).unwrap();
540        model.resources.add_object(obj2).unwrap();
541        model.build.items.push(build_item(2));
542
543        let mut archive = MockArchive::new();
544        let mut resolver = PartResolver::new(&mut archive, model);
545        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
546
547        assert_eq!(meshes.len(), 1);
548        assert_eq!(meshes[0].mesh.vertices.len(), 3);
549        assert_eq!(meshes[0].mesh.triangles.len(), 1);
550        assert_eq!(meshes[0].transform, glam::Mat4::IDENTITY);
551    }
552
553    #[test]
554    fn test_resolve_transform_accumulation() {
555        // Object id=1: Mesh
556        // Object id=2: Components referencing id=1 with translation (0,0,10)
557        // Build item referencing id=2 with translation (5,0,0)
558        let comp_transform = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, 10.0));
559        let build_transform = glam::Mat4::from_translation(glam::Vec3::new(5.0, 0.0, 0.0));
560
561        let mut model = Model::default();
562        let obj1 = mesh_object(1, ObjectType::Model, None);
563        let obj2 = components_object(2, vec![component_with_transform(1, comp_transform)]);
564        model.resources.add_object(obj1).unwrap();
565        model.resources.add_object(obj2).unwrap();
566        model
567            .build
568            .items
569            .push(build_item_with_transform(2, build_transform));
570
571        let mut archive = MockArchive::new();
572        let mut resolver = PartResolver::new(&mut archive, model);
573        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
574
575        assert_eq!(meshes.len(), 1);
576        let expected_transform = build_transform * comp_transform;
577        assert_eq!(meshes[0].transform, expected_transform);
578    }
579
580    #[test]
581    fn test_resolve_filters_other_objects() {
582        // Object id=1: Mesh, type=Model
583        // Object id=2: Mesh, type=Other
584        // Object id=3: Components referencing both id=1 and id=2
585        // Build item referencing id=3
586        let mut model = Model::default();
587        let obj1 = mesh_object(1, ObjectType::Model, None);
588        let obj2 = mesh_object(2, ObjectType::Other, None);
589        let obj3 = components_object(3, vec![component(1), component(2)]);
590        model.resources.add_object(obj1).unwrap();
591        model.resources.add_object(obj2).unwrap();
592        model.resources.add_object(obj3).unwrap();
593        model.build.items.push(build_item(3));
594
595        let mut archive = MockArchive::new();
596
597        // Default options: filter_other_objects = true → 1 mesh
598        let mut resolver = PartResolver::new(&mut archive, model.clone());
599        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
600        assert_eq!(meshes.len(), 1);
601        assert_eq!(meshes[0].object_type, ObjectType::Model);
602
603        // filter_other_objects = false → 2 meshes
604        let mut resolver = PartResolver::new(&mut archive, model);
605        let opts = ResolveOptions {
606            filter_other_objects: false,
607            ..Default::default()
608        };
609        let meshes = resolver.resolve_meshes(&opts).unwrap();
610        assert_eq!(meshes.len(), 2);
611    }
612
613    #[test]
614    fn test_resolve_filters_non_printable() {
615        // Object id=1: Mesh
616        // Build item A: printable=Some(true)
617        // Build item B: printable=Some(false)
618        let mut model = Model::default();
619        let obj1 = mesh_object(1, ObjectType::Model, None);
620        model.resources.add_object(obj1).unwrap();
621        model.build.items.push(build_item_printable(1, Some(true)));
622        model.build.items.push(build_item_printable(1, Some(false)));
623
624        let mut archive = MockArchive::new();
625
626        // Default options: filter_non_printable = true → 1 mesh
627        let mut resolver = PartResolver::new(&mut archive, model.clone());
628        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
629        assert_eq!(meshes.len(), 1);
630
631        // filter_non_printable = false → 2 meshes (instancing)
632        let mut resolver = PartResolver::new(&mut archive, model);
633        let opts = ResolveOptions {
634            filter_non_printable: false,
635            ..Default::default()
636        };
637        let meshes = resolver.resolve_meshes(&opts).unwrap();
638        assert_eq!(meshes.len(), 2);
639    }
640
641    #[test]
642    fn test_resolve_cycle_detection() {
643        // Object id=1: Components referencing id=2
644        // Object id=2: Components referencing id=1 (cycle!)
645        // Build item referencing id=1
646        let mut model = Model::default();
647        let obj1 = components_object(1, vec![component(2)]);
648        let obj2 = components_object(2, vec![component(1)]);
649        model.resources.add_object(obj1).unwrap();
650        model.resources.add_object(obj2).unwrap();
651        model.build.items.push(build_item(1));
652
653        let mut archive = MockArchive::new();
654        let mut resolver = PartResolver::new(&mut archive, model);
655        let result = resolver.resolve_meshes(&ResolveOptions::default());
656
657        assert!(result.is_err());
658        let msg = result.unwrap_err().to_string();
659        assert!(msg.contains("Cycle"), "Expected 'Cycle' in error: {}", msg);
660    }
661
662    #[test]
663    fn test_resolve_depth_limit() {
664        // Create a chain: id=1 → id=2 → ... → id=18 (mesh)
665        // With max_depth=16 it should error (depth exceeds limit at id=17 or deeper).
666        // With max_depth=20 it should succeed.
667        let mut model = Model::default();
668
669        // Build chain: object 1 references 2, 2 references 3, ... 17 references 18
670        for i in 1u32..18 {
671            let obj = components_object(i, vec![component(i + 1)]);
672            model.resources.add_object(obj).unwrap();
673        }
674        // Object 18: leaf mesh
675        let leaf = mesh_object(18, ObjectType::Model, None);
676        model.resources.add_object(leaf).unwrap();
677        model.build.items.push(build_item(1));
678
679        let mut archive = MockArchive::new();
680
681        // With default max_depth=16, depth of 17 components should error
682        let mut resolver = PartResolver::new(&mut archive, model.clone());
683        let result = resolver.resolve_meshes(&ResolveOptions::default());
684        assert!(
685            result.is_err(),
686            "Expected depth limit error with 17-level chain and max_depth=16"
687        );
688
689        // With max_depth=20, the 17-level chain should succeed
690        let mut resolver = PartResolver::new(&mut archive, model);
691        let opts = ResolveOptions {
692            max_depth: 20,
693            ..Default::default()
694        };
695        let meshes = resolver.resolve_meshes(&opts).unwrap();
696        assert_eq!(meshes.len(), 1);
697    }
698
699    #[test]
700    fn test_resolve_dangling_reference() {
701        // Object id=1: Components referencing id=999 (doesn't exist)
702        // Build item referencing id=1
703        let mut model = Model::default();
704        let obj1 = components_object(1, vec![component(999)]);
705        model.resources.add_object(obj1).unwrap();
706        model.build.items.push(build_item(1));
707
708        let mut archive = MockArchive::new();
709        let mut resolver = PartResolver::new(&mut archive, model);
710        let result = resolver.resolve_meshes(&ResolveOptions::default());
711
712        assert!(result.is_err());
713        let msg = result.unwrap_err().to_string();
714        assert!(
715            msg.contains("not found"),
716            "Expected 'not found' in error: {}",
717            msg
718        );
719    }
720
721    #[test]
722    fn test_resolve_instancing_not_false_cycle() {
723        // Object id=1: Mesh (used twice via instancing)
724        // Object id=2: Components with TWO refs both pointing to id=1
725        // Build item referencing id=2
726        // Instancing is legal — both references should produce ResolvedMesh entries.
727        let mut model = Model::default();
728        let obj1 = mesh_object(1, ObjectType::Model, None);
729        let obj2 = components_object(2, vec![component(1), component(1)]);
730        model.resources.add_object(obj1).unwrap();
731        model.resources.add_object(obj2).unwrap();
732        model.build.items.push(build_item(2));
733
734        let mut archive = MockArchive::new();
735        let mut resolver = PartResolver::new(&mut archive, model);
736        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
737
738        assert_eq!(
739            meshes.len(),
740            2,
741            "Instancing (same object referenced twice) should produce 2 ResolvedMesh entries"
742        );
743    }
744
745    #[test]
746    fn test_resolve_unit_carried() {
747        // Model with unit=Inch containing a mesh.
748        // Assert: ResolvedMesh.unit == Unit::Inch.
749        let mut model = Model::default();
750        model.unit = Unit::Inch;
751        let obj1 = mesh_object(1, ObjectType::Model, None);
752        model.resources.add_object(obj1).unwrap();
753        model.build.items.push(build_item(1));
754
755        let mut archive = MockArchive::new();
756        let mut resolver = PartResolver::new(&mut archive, model);
757        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
758
759        assert_eq!(meshes.len(), 1);
760        assert_eq!(meshes[0].unit, Unit::Inch);
761    }
762
763    #[test]
764    fn test_resolve_empty_build() {
765        // Model with no build items → should return empty Vec.
766        let model = Model::default();
767        let mut archive = MockArchive::new();
768        let mut resolver = PartResolver::new(&mut archive, model);
769        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
770        assert!(meshes.is_empty());
771    }
772
773    #[test]
774    fn test_resolve_object_name_carried() {
775        // Create a named object: name = "MyObject".
776        // Assert: ResolvedMesh.name == Some("MyObject").
777        let mut model = Model::default();
778        let obj1 = mesh_object(1, ObjectType::Model, Some("MyObject"));
779        model.resources.add_object(obj1).unwrap();
780        model.build.items.push(build_item(1));
781
782        let mut archive = MockArchive::new();
783        let mut resolver = PartResolver::new(&mut archive, model);
784        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
785
786        assert_eq!(meshes.len(), 1);
787        assert_eq!(meshes[0].name, Some("MyObject".to_string()));
788    }
789
790    #[test]
791    fn test_resolve_cross_file_components() {
792        // Root model: object id=8, type=model, Components referencing id=1 in sub-model
793        // Sub-model at "3D/Objects/object_1.model":
794        //   - id=1: Mesh, type=model
795        //   - id=2: Mesh, type=other
796        // Build item referencing id=8
797
798        // Build the sub-model
799        let mut sub_model = Model::default();
800        let sub_obj1 = mesh_object(1, ObjectType::Model, None);
801        let sub_obj2 = mesh_object(2, ObjectType::Other, None);
802        sub_model.resources.add_object(sub_obj1).unwrap();
803        sub_model.resources.add_object(sub_obj2).unwrap();
804        let sub_xml = model_to_xml_bytes(&sub_model);
805
806        // Build the root model
807        let sub_path = "3D/Objects/object_1.model";
808        let mut root_model = Model::default();
809        let comp1 = component_with_path(1, sub_path, glam::Mat4::IDENTITY);
810        let comp2 = component_with_path(2, sub_path, glam::Mat4::IDENTITY);
811        let root_obj = components_object(8, vec![comp1, comp2]);
812        root_model.resources.add_object(root_obj).unwrap();
813        root_model.build.items.push(build_item(8));
814
815        // Set up MockArchive with the sub-model
816        let mut archive = MockArchive::new();
817        archive.add_entry(sub_path, sub_xml);
818
819        let mut resolver = PartResolver::new(&mut archive, root_model);
820
821        // Default options: filter_other_objects=true → only id=1 (type=model) returned
822        let meshes = resolver.resolve_meshes(&ResolveOptions::default()).unwrap();
823        assert_eq!(
824            meshes.len(),
825            1,
826            "Expected 1 mesh (type=other filtered out), got {}",
827            meshes.len()
828        );
829
830        // With filter_other_objects=false → both id=1 and id=2 returned
831        let mut archive2 = MockArchive::new();
832        let sub_xml2 = model_to_xml_bytes(&sub_model);
833        archive2.add_entry(sub_path, sub_xml2);
834        let mut resolver2 = PartResolver::new(&mut archive2, {
835            let sub_path2 = "3D/Objects/object_1.model";
836            let mut root_model2 = Model::default();
837            let comp1b = component_with_path(1, sub_path2, glam::Mat4::IDENTITY);
838            let comp2b = component_with_path(2, sub_path2, glam::Mat4::IDENTITY);
839            let root_obj2 = components_object(8, vec![comp1b, comp2b]);
840            root_model2.resources.add_object(root_obj2).unwrap();
841            root_model2.build.items.push(build_item(8));
842            root_model2
843        });
844        let opts = ResolveOptions {
845            filter_other_objects: false,
846            ..Default::default()
847        };
848        let meshes2 = resolver2.resolve_meshes(&opts).unwrap();
849        assert_eq!(
850            meshes2.len(),
851            2,
852            "Expected 2 meshes when filter_other_objects=false"
853        );
854    }
855}