lib3mf_converters/
obj.rs

1//! Wavefront OBJ format import and export.
2//!
3//! This module provides conversion between OBJ files and 3MF [`Model`] structures.
4//!
5//! ## OBJ Format
6//!
7//! The Wavefront OBJ format is a text-based 3D geometry format. This implementation supports:
8//!
9//! **Supported features:**
10//! - `v` - Vertex positions (x, y, z)
11//! - `f` - Faces (vertex indices, with automatic fan triangulation for polygons)
12//! - `g` / `o` - Group/object directives (each creates a separate 3MF Object)
13//! - `usemtl` - Material assignment (maps to per-triangle `pid`/`p1`/`p2`/`p3`)
14//! - `mtllib` - Material library file reference (parsed via [`mtl`] module)
15//!
16//! **Ignored features:**
17//! - `vt` - Texture coordinates
18//! - `vn` - Vertex normals
19//!
20//! ## Material Import
21//!
22//! When using [`ObjImporter::read_from_path`], the importer resolves `mtllib` directives
23//! relative to the OBJ file's directory. MTL `Kd` (diffuse color) maps to 3MF
24//! [`BaseMaterial`] display colors. Materials are collected into a single
25//! [`BaseMaterialsGroup`] resource.
26//!
27//! When using [`ObjImporter::read`], no MTL resolution is possible and materials
28//! are not imported (geometry-only mode for backward compatibility).
29//!
30//! ## Examples
31//!
32//! ### Importing OBJ with materials
33//!
34//! ```no_run
35//! use lib3mf_converters::obj::ObjImporter;
36//! use std::path::Path;
37//!
38//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
39//! let model = ObjImporter::read_from_path(Path::new("model.obj"))?;
40//! println!("Imported model with {} build items", model.build.items.len());
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! ### Importing OBJ (geometry only)
46//!
47//! ```no_run
48//! use lib3mf_converters::obj::ObjImporter;
49//! use std::fs::File;
50//!
51//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
52//! let file = File::open("model.obj")?;
53//! let model = ObjImporter::read(file)?;
54//! println!("Imported model with {} build items", model.build.items.len());
55//! # Ok(())
56//! # }
57//! ```
58//!
59//! ### Exporting OBJ
60//!
61//! ```no_run
62//! use lib3mf_converters::obj::ObjExporter;
63//! use lib3mf_core::model::Model;
64//! use std::fs::File;
65//!
66//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
67//! # let model = Model::default();
68//! let file = File::create("output.obj")?;
69//! ObjExporter::write(&model, file)?;
70//! # Ok(())
71//! # }
72//! ```
73//!
74//! [`Model`]: lib3mf_core::model::Model
75//! [`BaseMaterial`]: lib3mf_core::model::BaseMaterial
76//! [`BaseMaterialsGroup`]: lib3mf_core::model::BaseMaterialsGroup
77
78use crate::mtl;
79use lib3mf_core::error::{Lib3mfError, Result};
80use lib3mf_core::model::resources::ResourceId;
81use lib3mf_core::model::{
82    BaseMaterial, BaseMaterialsGroup, BuildItem, Color, Mesh, Model, Object, ObjectType, Triangle,
83    Vertex,
84};
85use std::collections::HashMap;
86use std::io::{BufRead, BufReader, Read, Write};
87use std::path::Path;
88
89/// Default gray color for undefined materials.
90const DEFAULT_GRAY: Color = Color {
91    r: 128,
92    g: 128,
93    b: 128,
94    a: 255,
95};
96
97// ---------------------------------------------------------------------------
98// Intermediate representation for OBJ parsing
99// ---------------------------------------------------------------------------
100
101/// A face parsed from OBJ, storing global 0-based vertex indices.
102struct ObjFace {
103    indices: Vec<u32>,
104    material_name: Option<String>,
105}
106
107/// A group/object parsed from OBJ.
108struct ObjGroup {
109    name: Option<String>,
110    faces: Vec<ObjFace>,
111}
112
113/// Complete intermediate representation of a parsed OBJ file.
114struct ObjIntermediate {
115    global_vertices: Vec<(f32, f32, f32)>,
116    groups: Vec<ObjGroup>,
117    mtllib: Option<String>,
118    had_explicit_group: bool,
119}
120
121/// Imports Wavefront OBJ files into 3MF [`Model`] structures.
122///
123/// Supports two modes:
124/// - [`read_from_path`](Self::read_from_path): Full import with MTL material resolution
125/// - [`read`](Self::read): Geometry-only import (backward compatible, no materials)
126///
127/// [`Model`]: lib3mf_core::model::Model
128pub struct ObjImporter;
129
130impl ObjImporter {
131    /// Reads an OBJ file from a filesystem path, resolving MTL files relative to it.
132    ///
133    /// This is the recommended entry point for OBJ import. It resolves `mtllib`
134    /// directives relative to the OBJ file's parent directory, parses materials,
135    /// splits groups/objects into separate 3MF Objects, and assigns per-triangle
136    /// material properties.
137    ///
138    /// # Arguments
139    ///
140    /// * `path` - Path to the OBJ file on disk
141    ///
142    /// # Returns
143    ///
144    /// A [`Model`] containing:
145    /// - One [`BaseMaterialsGroup`] (if materials are referenced)
146    /// - One [`Object`] per OBJ group/object (or single Object if no groups)
147    /// - Per-triangle `pid`/`p1`/`p2`/`p3` material assignment
148    /// - One [`BuildItem`] per Object
149    ///
150    /// # Errors
151    ///
152    /// Returns errors for I/O failures or invalid OBJ syntax (see [`read`](Self::read)).
153    ///
154    /// [`Model`]: lib3mf_core::model::Model
155    pub fn read_from_path(path: &Path) -> Result<Model> {
156        let dir = path.parent().unwrap_or(Path::new("."));
157        let file = std::fs::File::open(path).map_err(Lib3mfError::Io)?;
158        let intermediate = Self::parse_obj(BufReader::new(file))?;
159
160        // Resolve MTL file
161        let materials = if let Some(ref mtl_filename) = intermediate.mtllib {
162            let mtl_path = dir.join(mtl_filename);
163            mtl::parse_mtl_file(&mtl_path)
164        } else {
165            HashMap::new()
166        };
167
168        Self::build_model(intermediate, &materials)
169    }
170
171    /// Reads an OBJ file and converts it to a 3MF [`Model`].
172    ///
173    /// This is the backward-compatible entry point. No MTL file resolution is
174    /// performed -- material directives (`usemtl`, `mtllib`) are ignored.
175    /// Group directives (`g`, `o`) are also ignored for full backward compatibility.
176    ///
177    /// For material-aware import, use [`read_from_path`](Self::read_from_path).
178    ///
179    /// # Arguments
180    ///
181    /// * `reader` - Any type implementing [`Read`] containing OBJ text data
182    ///
183    /// # Returns
184    ///
185    /// A [`Model`] containing:
186    /// - Single mesh object with ResourceId(1) named "OBJ Import"
187    /// - All triangles from the OBJ file (polygons triangulated via fan method)
188    /// - All vertices from the OBJ file
189    /// - Single build item referencing the mesh object
190    ///
191    /// # Errors
192    ///
193    /// Returns [`Lib3mfError::Validation`] if:
194    /// - Vertex line has fewer than 4 fields (v x y z)
195    /// - Face line has fewer than 4 fields (f v1 v2 v3...)
196    /// - Float parsing fails for vertex coordinates
197    /// - Integer parsing fails for face indices
198    /// - Relative indices (negative values) are used (not supported)
199    ///
200    /// Returns [`Lib3mfError::Io`] if reading from the input fails.
201    ///
202    /// # Examples
203    ///
204    /// ```no_run
205    /// use lib3mf_converters::obj::ObjImporter;
206    /// use std::fs::File;
207    ///
208    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
209    /// let file = File::open("cube.obj")?;
210    /// let model = ObjImporter::read(file)?;
211    ///
212    /// // Access the imported mesh
213    /// let obj = model.resources.get_object(lib3mf_core::model::resources::ResourceId(1))
214    ///     .expect("OBJ import creates object with ID 1");
215    /// if let lib3mf_core::model::Geometry::Mesh(mesh) = &obj.geometry {
216    ///     println!("Imported {} vertices, {} triangles",
217    ///         mesh.vertices.len(), mesh.triangles.len());
218    /// }
219    /// # Ok(())
220    /// # }
221    /// ```
222    ///
223    /// [`Model`]: lib3mf_core::model::Model
224    /// [`Lib3mfError::Validation`]: lib3mf_core::error::Lib3mfError::Validation
225    /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
226    pub fn read<R: Read>(reader: R) -> Result<Model> {
227        let intermediate = Self::parse_obj(BufReader::new(reader))?;
228        // Backward-compatible: no materials, collapse all groups into one object
229        Self::build_model_compat(intermediate)
230    }
231
232    /// Parse OBJ text into the intermediate representation.
233    fn parse_obj<R: BufRead>(mut reader: R) -> Result<ObjIntermediate> {
234        let mut global_vertices: Vec<(f32, f32, f32)> = Vec::new();
235        let mut groups: Vec<ObjGroup> = Vec::new();
236        let mut current_group = ObjGroup {
237            name: None,
238            faces: Vec::new(),
239        };
240        let mut current_material: Option<String> = None;
241        let mut mtllib: Option<String> = None;
242        let mut had_explicit_group = false;
243
244        let mut line = String::new();
245
246        while reader.read_line(&mut line).map_err(Lib3mfError::Io)? > 0 {
247            let trimmed = line.trim();
248            if trimmed.is_empty() || trimmed.starts_with('#') {
249                line.clear();
250                continue;
251            }
252
253            let parts: Vec<&str> = trimmed.split_whitespace().collect();
254            if parts.is_empty() {
255                line.clear();
256                continue;
257            }
258
259            match parts[0] {
260                "v" => {
261                    if parts.len() < 4 {
262                        return Err(Lib3mfError::Validation("Invalid OBJ vertex".to_string()));
263                    }
264                    let x = parts[1]
265                        .parse::<f32>()
266                        .map_err(|_| Lib3mfError::Validation("Invalid float".to_string()))?;
267                    let y = parts[2]
268                        .parse::<f32>()
269                        .map_err(|_| Lib3mfError::Validation("Invalid float".to_string()))?;
270                    let z = parts[3]
271                        .parse::<f32>()
272                        .map_err(|_| Lib3mfError::Validation("Invalid float".to_string()))?;
273                    global_vertices.push((x, y, z));
274                }
275                "f" => {
276                    if parts.len() < 4 {
277                        // Skip point/line elements
278                        line.clear();
279                        continue;
280                    }
281
282                    let mut indices = Vec::new();
283                    for part in &parts[1..] {
284                        // Format: v, v/vt, v/vt/vn, v//vn
285                        let subparts: Vec<&str> = part.split('/').collect();
286                        let v_idx = subparts[0]
287                            .parse::<i32>()
288                            .map_err(|_| Lib3mfError::Validation("Invalid index".to_string()))?;
289
290                        let idx = if v_idx > 0 {
291                            (v_idx - 1) as u32
292                        } else {
293                            return Err(Lib3mfError::Validation(
294                                "Relative OBJ indices not supported yet".to_string(),
295                            ));
296                        };
297                        indices.push(idx);
298                    }
299
300                    // Fan-triangulate
301                    if indices.len() >= 3 {
302                        for i in 1..indices.len() - 1 {
303                            current_group.faces.push(ObjFace {
304                                indices: vec![indices[0], indices[i], indices[i + 1]],
305                                material_name: current_material.clone(),
306                            });
307                        }
308                    }
309                }
310                "g" | "o" => {
311                    had_explicit_group = true;
312                    // Flush current group if it has faces
313                    if !current_group.faces.is_empty() {
314                        groups.push(current_group);
315                    }
316                    let name = if parts.len() >= 2 {
317                        Some(parts[1..].join(" "))
318                    } else {
319                        None
320                    };
321                    current_group = ObjGroup {
322                        name,
323                        faces: Vec::new(),
324                    };
325                }
326                "usemtl" => {
327                    if parts.len() >= 2 {
328                        current_material = Some(parts[1..].join(" "));
329                    }
330                }
331                "mtllib" => {
332                    if parts.len() >= 2 {
333                        mtllib = Some(parts[1..].join(" "));
334                    }
335                }
336                _ => {} // Ignore vt, vn, comments, etc.
337            }
338
339            line.clear();
340        }
341
342        // Flush last group
343        if !current_group.faces.is_empty() {
344            groups.push(current_group);
345        }
346
347        Ok(ObjIntermediate {
348            global_vertices,
349            groups,
350            mtllib,
351            had_explicit_group,
352        })
353    }
354
355    /// Build a 3MF Model from the intermediate representation with material support.
356    fn build_model(
357        intermediate: ObjIntermediate,
358        materials_map: &HashMap<String, mtl::MtlMaterial>,
359    ) -> Result<Model> {
360        let mut model = Model::default();
361
362        if intermediate.groups.is_empty() {
363            return Ok(model);
364        }
365
366        // Collect all referenced material names across all groups
367        let mut referenced_materials: Vec<String> = Vec::new();
368        let mut material_seen: HashMap<String, u32> = HashMap::new();
369        for group in &intermediate.groups {
370            for face in &group.faces {
371                if let Some(ref mat_name) = face.material_name
372                    && !material_seen.contains_key(mat_name)
373                {
374                    let idx = referenced_materials.len() as u32;
375                    material_seen.insert(mat_name.clone(), idx);
376                    referenced_materials.push(mat_name.clone());
377                }
378            }
379        }
380
381        let has_materials = !referenced_materials.is_empty();
382        let mut next_id: u32 = 1;
383
384        // Create BaseMaterialsGroup if materials are referenced
385        let materials_group_id = if has_materials {
386            let group_id = ResourceId(next_id);
387            next_id += 1;
388
389            let mut base_materials = Vec::new();
390            for mat_name in &referenced_materials {
391                let base_mat = if let Some(mtl_mat) = materials_map.get(mat_name) {
392                    BaseMaterial {
393                        name: mtl_mat.name.clone(),
394                        display_color: mtl_mat.display_color,
395                    }
396                } else {
397                    // Undefined material -- warn and use gray
398                    eprintln!(
399                        "Warning: undefined material '{}', using default gray",
400                        mat_name
401                    );
402                    BaseMaterial {
403                        name: mat_name.clone(),
404                        display_color: DEFAULT_GRAY,
405                    }
406                };
407                base_materials.push(base_mat);
408            }
409
410            model.resources.add_base_materials(BaseMaterialsGroup {
411                id: group_id,
412                materials: base_materials,
413            })?;
414
415            Some(group_id)
416        } else {
417            None
418        };
419
420        // Determine if we're in single-object backward-compat mode
421        let single_object_mode = !intermediate.had_explicit_group && intermediate.groups.len() == 1;
422
423        if single_object_mode && !has_materials {
424            // Full backward compatibility: single object with ResourceId(1), no materials
425            let group = &intermediate.groups[0];
426            let mesh = Self::build_mesh_full(&intermediate.global_vertices, group, None, None);
427
428            let resource_id = ResourceId(next_id);
429            let object = Object {
430                id: resource_id,
431                object_type: ObjectType::Model,
432                name: Some("OBJ Import".to_string()),
433                part_number: None,
434                uuid: None,
435                pid: None,
436                pindex: None,
437                thumbnail: None,
438                geometry: lib3mf_core::model::Geometry::Mesh(mesh),
439            };
440            model.resources.add_object(object)?;
441            model.build.items.push(BuildItem {
442                object_id: resource_id,
443                transform: glam::Mat4::IDENTITY,
444                part_number: None,
445                uuid: None,
446                path: None,
447                printable: None,
448            });
449        } else {
450            // Multi-object or single-object-with-materials path
451            for group in &intermediate.groups {
452                let obj_id = ResourceId(next_id);
453                next_id += 1;
454
455                let mesh = Self::build_mesh_full(
456                    &intermediate.global_vertices,
457                    group,
458                    materials_group_id,
459                    Some(&material_seen),
460                );
461
462                let name = group
463                    .name
464                    .clone()
465                    .unwrap_or_else(|| "OBJ Import".to_string());
466
467                let object = Object {
468                    id: obj_id,
469                    object_type: ObjectType::Model,
470                    name: Some(name),
471                    part_number: None,
472                    uuid: None,
473                    pid: None,
474                    pindex: None,
475                    thumbnail: None,
476                    geometry: lib3mf_core::model::Geometry::Mesh(mesh),
477                };
478                model.resources.add_object(object)?;
479                model.build.items.push(BuildItem {
480                    object_id: obj_id,
481                    transform: glam::Mat4::IDENTITY,
482                    part_number: None,
483                    uuid: None,
484                    path: None,
485                    printable: None,
486                });
487            }
488        }
489
490        Ok(model)
491    }
492
493    /// Build a 3MF Model in backward-compatible mode (no materials, no group splitting).
494    fn build_model_compat(intermediate: ObjIntermediate) -> Result<Model> {
495        let mut model = Model::default();
496
497        // Collapse all groups into a single mesh
498        let mut mesh = Mesh::default();
499        for &(x, y, z) in &intermediate.global_vertices {
500            mesh.add_vertex(x, y, z);
501        }
502        for group in &intermediate.groups {
503            for face in &group.faces {
504                if face.indices.len() == 3 {
505                    mesh.triangles.push(Triangle {
506                        v1: face.indices[0],
507                        v2: face.indices[1],
508                        v3: face.indices[2],
509                        ..Default::default()
510                    });
511                }
512            }
513        }
514
515        if mesh.vertices.is_empty() && mesh.triangles.is_empty() {
516            // Still return a model with a single empty object for backward compat
517        }
518
519        let resource_id = ResourceId(1);
520        let object = Object {
521            id: resource_id,
522            object_type: ObjectType::Model,
523            name: Some("OBJ Import".to_string()),
524            part_number: None,
525            uuid: None,
526            pid: None,
527            pindex: None,
528            thumbnail: None,
529            geometry: lib3mf_core::model::Geometry::Mesh(mesh),
530        };
531        let _ = model.resources.add_object(object);
532        model.build.items.push(BuildItem {
533            object_id: resource_id,
534            transform: glam::Mat4::IDENTITY,
535            part_number: None,
536            uuid: None,
537            path: None,
538            printable: None,
539        });
540
541        Ok(model)
542    }
543
544    /// Build a Mesh for a single OBJ group, remapping vertices to local indices.
545    fn build_mesh_full(
546        global_vertices: &[(f32, f32, f32)],
547        group: &ObjGroup,
548        materials_group_id: Option<ResourceId>,
549        material_index_map: Option<&HashMap<String, u32>>,
550    ) -> Mesh {
551        let mut mesh = Mesh::default();
552        let mut local_map: HashMap<u32, u32> = HashMap::new();
553
554        for face in &group.faces {
555            // Remap vertex indices to local
556            let mut local_indices = Vec::with_capacity(face.indices.len());
557            for &global_idx in &face.indices {
558                let local_idx = if let Some(&li) = local_map.get(&global_idx) {
559                    li
560                } else {
561                    let li = mesh.vertices.len() as u32;
562                    local_map.insert(global_idx, li);
563                    let (x, y, z) = global_vertices[global_idx as usize];
564                    mesh.vertices.push(Vertex { x, y, z });
565                    li
566                };
567                local_indices.push(local_idx);
568            }
569
570            // Build triangle with material assignment
571            if local_indices.len() == 3 {
572                let (pid, p1, p2, p3) = if let (Some(group_id), Some(index_map), Some(mat_name)) =
573                    (materials_group_id, material_index_map, &face.material_name)
574                {
575                    if let Some(&mat_idx) = index_map.get(mat_name.as_str()) {
576                        (
577                            Some(group_id.0),
578                            Some(mat_idx),
579                            Some(mat_idx),
580                            Some(mat_idx),
581                        )
582                    } else {
583                        (None, None, None, None)
584                    }
585                } else {
586                    (None, None, None, None)
587                };
588
589                mesh.triangles.push(Triangle {
590                    v1: local_indices[0],
591                    v2: local_indices[1],
592                    v3: local_indices[2],
593                    pid,
594                    p1,
595                    p2,
596                    p3,
597                });
598            }
599        }
600
601        mesh
602    }
603}
604
605/// Exports 3MF [`Model`] structures to Wavefront OBJ files.
606///
607/// The exporter writes all mesh objects from build items to OBJ format, creating
608/// separate groups for each object and applying build item transformations.
609///
610/// [`Model`]: lib3mf_core::model::Model
611pub struct ObjExporter;
612
613impl ObjExporter {
614    /// Writes a 3MF [`Model`] to OBJ text format.
615    ///
616    /// # Arguments
617    ///
618    /// * `model` - The 3MF model to export
619    /// * `writer` - Any type implementing [`Write`] to receive OBJ text data
620    ///
621    /// # Returns
622    ///
623    /// `Ok(())` on successful export.
624    ///
625    /// # Errors
626    ///
627    /// Returns [`Lib3mfError::Io`] if any write operation fails.
628    ///
629    /// # Format Details
630    ///
631    /// - **Groups**: Each mesh object creates an OBJ group (`g`) with the object's name or "Object"
632    /// - **Vertex indices**: Written as 1-based indices (OBJ convention)
633    /// - **Transformations**: Build item transforms are applied to vertex coordinates
634    /// - **Materials**: Not exported (OBJ output is geometry-only)
635    /// - **Normals/UVs**: Not exported
636    ///
637    /// # Behavior
638    ///
639    /// - Only mesh objects from `model.build.items` are exported
640    /// - Non-mesh geometries (Components, BooleanShape, etc.) are skipped
641    /// - Vertex indices are offset correctly across multiple objects
642    /// - Each object's vertices and faces are written in sequence
643    ///
644    /// # Examples
645    ///
646    /// ```no_run
647    /// use lib3mf_converters::obj::ObjExporter;
648    /// use lib3mf_core::model::Model;
649    /// use std::fs::File;
650    ///
651    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
652    /// # let model = Model::default();
653    /// let output = File::create("exported.obj")?;
654    /// ObjExporter::write(&model, output)?;
655    /// println!("Model exported successfully");
656    /// # Ok(())
657    /// # }
658    /// ```
659    ///
660    /// [`Model`]: lib3mf_core::model::Model
661    /// [`Lib3mfError::Io`]: lib3mf_core::error::Lib3mfError::Io
662    pub fn write<W: Write>(model: &Model, mut writer: W) -> Result<()> {
663        let mut vertex_offset = 1;
664
665        for item in &model.build.items {
666            if let Some(object) = model.resources.get_object(item.object_id)
667                && let lib3mf_core::model::Geometry::Mesh(mesh) = &object.geometry
668            {
669                let transform = item.transform;
670
671                writeln!(writer, "g {}", object.name.as_deref().unwrap_or("Object"))
672                    .map_err(Lib3mfError::Io)?;
673
674                // Write vertices
675                for v in &mesh.vertices {
676                    let p = transform.transform_point3(glam::Vec3::new(v.x, v.y, v.z));
677                    writeln!(writer, "v {} {} {}", p.x, p.y, p.z).map_err(Lib3mfError::Io)?;
678                }
679
680                // Write faces
681                for tri in &mesh.triangles {
682                    writeln!(
683                        writer,
684                        "f {} {} {}",
685                        tri.v1 + vertex_offset,
686                        tri.v2 + vertex_offset,
687                        tri.v3 + vertex_offset
688                    )
689                    .map_err(Lib3mfError::Io)?;
690                }
691
692                vertex_offset += mesh.vertices.len() as u32;
693            }
694        }
695        Ok(())
696    }
697
698    /// Writes a 3MF [`Model`] to OBJ text format, resolving Production Extension
699    /// component references across model parts via [`PartResolver`].
700    ///
701    /// This method should be used when exporting multi-model 3MF files (e.g. those
702    /// created by Bambu Studio) where build items may reference objects defined in
703    /// child model part files.
704    ///
705    /// [`PartResolver`]: lib3mf_core::model::resolver::PartResolver
706    pub fn write_with_resolver<W: Write, A: lib3mf_core::archive::ArchiveReader>(
707        model: &Model,
708        mut resolver: lib3mf_core::model::resolver::PartResolver<A>,
709        mut writer: W,
710    ) -> Result<()> {
711        let mut objects: Vec<(String, glam::Mat4, Mesh)> = Vec::new();
712
713        for item in &model.build.items {
714            let name = model
715                .resources
716                .get_object(item.object_id)
717                .and_then(|o| o.name.clone());
718            collect_obj_objects(
719                &mut resolver,
720                item.object_id,
721                item.transform,
722                None,
723                name,
724                &mut objects,
725            )?;
726        }
727
728        let mut vertex_offset: u32 = 1;
729        for (name, transform, mesh) in &objects {
730            writeln!(writer, "g {name}").map_err(Lib3mfError::Io)?;
731
732            for v in &mesh.vertices {
733                let p = transform.transform_point3(glam::Vec3::new(v.x, v.y, v.z));
734                writeln!(writer, "v {} {} {}", p.x, p.y, p.z).map_err(Lib3mfError::Io)?;
735            }
736
737            for tri in &mesh.triangles {
738                writeln!(
739                    writer,
740                    "f {} {} {}",
741                    tri.v1 + vertex_offset,
742                    tri.v2 + vertex_offset,
743                    tri.v3 + vertex_offset
744                )
745                .map_err(Lib3mfError::Io)?;
746            }
747
748            vertex_offset += mesh.vertices.len() as u32;
749        }
750        Ok(())
751    }
752}
753
754fn collect_obj_objects<A: lib3mf_core::archive::ArchiveReader>(
755    resolver: &mut lib3mf_core::model::resolver::PartResolver<A>,
756    object_id: ResourceId,
757    transform: glam::Mat4,
758    path: Option<&str>,
759    name: Option<String>,
760    objects: &mut Vec<(String, glam::Mat4, Mesh)>,
761) -> Result<()> {
762    let (resolved_name, geometry) = {
763        let res = resolver.resolve_object(object_id, path)?;
764        if let Some((_, obj)) = res {
765            (obj.name.clone().or(name), Some(obj.geometry.clone()))
766        } else {
767            (name, None)
768        }
769    };
770
771    if let Some(geo) = geometry {
772        match geo {
773            lib3mf_core::model::Geometry::Mesh(mesh) => {
774                objects.push((
775                    resolved_name.unwrap_or_else(|| "Object".to_string()),
776                    transform,
777                    mesh,
778                ));
779            }
780            lib3mf_core::model::Geometry::Components(comps) => {
781                for comp in comps.components {
782                    let new_transform = transform * comp.transform;
783                    let next_path_store = comp.path.clone();
784                    let next_path = next_path_store.as_deref().or(path);
785                    collect_obj_objects(
786                        resolver,
787                        comp.object_id,
788                        new_transform,
789                        next_path,
790                        None,
791                        objects,
792                    )?;
793                }
794            }
795            _ => {}
796        }
797    }
798
799    Ok(())
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use lib3mf_core::model::Geometry;
806
807    /// Helper: create a simple OBJ string with a triangle.
808    fn bare_triangle_obj() -> &'static [u8] {
809        b"v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.0 1.0 0.0\nf 1 2 3\n"
810    }
811
812    #[test]
813    fn test_backward_compat_bare_obj() {
814        // A bare OBJ (no groups, no materials) must produce identical output to the
815        // original importer: single object with ResourceId(1), name "OBJ Import",
816        // no BaseMaterialsGroup, no pid/p1/p2/p3.
817        let model = ObjImporter::read(&bare_triangle_obj()[..]).unwrap();
818
819        // Single object with ID 1
820        assert_eq!(model.build.items.len(), 1);
821        assert_eq!(model.build.items[0].object_id, ResourceId(1));
822
823        let obj = model.resources.get_object(ResourceId(1)).unwrap();
824        assert_eq!(obj.name.as_deref(), Some("OBJ Import"));
825        assert_eq!(obj.object_type, ObjectType::Model);
826
827        if let Geometry::Mesh(mesh) = &obj.geometry {
828            assert_eq!(mesh.vertices.len(), 3);
829            assert_eq!(mesh.triangles.len(), 1);
830            // No material assignment
831            assert!(mesh.triangles[0].pid.is_none());
832            assert!(mesh.triangles[0].p1.is_none());
833            assert!(mesh.triangles[0].p2.is_none());
834            assert!(mesh.triangles[0].p3.is_none());
835        } else {
836            panic!("Expected mesh geometry");
837        }
838
839        // No base materials
840        assert_eq!(model.resources.base_material_groups_count(), 0);
841    }
842
843    #[test]
844    fn test_single_group_with_material() {
845        // OBJ with mtllib+usemtl but parsed via build_model (simulating read_from_path)
846        let obj_data = b"mtllib test.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl Red\nf 1 2 3\n";
847        let intermediate = ObjImporter::parse_obj(BufReader::new(&obj_data[..])).unwrap();
848
849        // Simulate material data
850        let mut materials = HashMap::new();
851        materials.insert(
852            "Red".to_string(),
853            mtl::MtlMaterial {
854                name: "Red".to_string(),
855                display_color: Color::new(255, 0, 0, 255),
856            },
857        );
858
859        let model = ObjImporter::build_model(intermediate, &materials).unwrap();
860
861        // Should have BaseMaterialsGroup with ID 1
862        assert_eq!(model.resources.base_material_groups_count(), 1);
863        let bmg = model.resources.get_base_materials(ResourceId(1)).unwrap();
864        assert_eq!(bmg.materials.len(), 1);
865        assert_eq!(bmg.materials[0].name, "Red");
866        assert_eq!(bmg.materials[0].display_color, Color::new(255, 0, 0, 255));
867
868        // One object (single group, no explicit g/o directive)
869        assert_eq!(model.build.items.len(), 1);
870        let obj = model
871            .resources
872            .get_object(model.build.items[0].object_id)
873            .unwrap();
874
875        if let Geometry::Mesh(mesh) = &obj.geometry {
876            assert_eq!(mesh.triangles.len(), 1);
877            let tri = &mesh.triangles[0];
878            assert_eq!(tri.pid, Some(1)); // BaseMaterialsGroup ID
879            assert_eq!(tri.p1, Some(0)); // Index 0 in materials array
880            assert_eq!(tri.p2, Some(0));
881            assert_eq!(tri.p3, Some(0));
882        } else {
883            panic!("Expected mesh geometry");
884        }
885    }
886
887    #[test]
888    fn test_multiple_groups_creates_separate_objects() {
889        let obj_data = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nv 2 0 0\nv 2 1 0\nv 3 0 0\ng GroupA\nf 1 2 3\ng GroupB\nf 4 5 6\n";
890        let intermediate = ObjImporter::parse_obj(BufReader::new(&obj_data[..])).unwrap();
891        let model = ObjImporter::build_model(intermediate, &HashMap::new()).unwrap();
892
893        // Two objects (two groups)
894        assert_eq!(model.build.items.len(), 2);
895
896        // First object: GroupA
897        let obj_a = model
898            .resources
899            .get_object(model.build.items[0].object_id)
900            .unwrap();
901        assert_eq!(obj_a.name.as_deref(), Some("GroupA"));
902
903        // Second object: GroupB
904        let obj_b = model
905            .resources
906            .get_object(model.build.items[1].object_id)
907            .unwrap();
908        assert_eq!(obj_b.name.as_deref(), Some("GroupB"));
909    }
910
911    #[test]
912    fn test_vertex_remapping_per_group() {
913        // Group A uses vertices 1,2,3 (0-based: 0,1,2)
914        // Group B uses vertices 4,5,6 (0-based: 3,4,5)
915        // After remapping, each group should have local indices 0,1,2
916        let obj_data = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nv 10 0 0\nv 11 0 0\nv 10 1 0\ng A\nf 1 2 3\ng B\nf 4 5 6\n";
917        let intermediate = ObjImporter::parse_obj(BufReader::new(&obj_data[..])).unwrap();
918        let model = ObjImporter::build_model(intermediate, &HashMap::new()).unwrap();
919
920        // Group A
921        let obj_a = model
922            .resources
923            .get_object(model.build.items[0].object_id)
924            .unwrap();
925        if let Geometry::Mesh(mesh) = &obj_a.geometry {
926            assert_eq!(mesh.vertices.len(), 3);
927            assert_eq!(mesh.triangles[0].v1, 0);
928            assert_eq!(mesh.triangles[0].v2, 1);
929            assert_eq!(mesh.triangles[0].v3, 2);
930            // Check actual coordinates
931            assert!((mesh.vertices[0].x - 0.0).abs() < f32::EPSILON);
932            assert!((mesh.vertices[1].x - 1.0).abs() < f32::EPSILON);
933        } else {
934            panic!("Expected mesh");
935        }
936
937        // Group B
938        let obj_b = model
939            .resources
940            .get_object(model.build.items[1].object_id)
941            .unwrap();
942        if let Geometry::Mesh(mesh) = &obj_b.geometry {
943            assert_eq!(mesh.vertices.len(), 3);
944            assert_eq!(mesh.triangles[0].v1, 0);
945            assert_eq!(mesh.triangles[0].v2, 1);
946            assert_eq!(mesh.triangles[0].v3, 2);
947            // Check actual coordinates -- should be the 10,11,10 vertices
948            assert!((mesh.vertices[0].x - 10.0).abs() < f32::EPSILON);
949            assert!((mesh.vertices[1].x - 11.0).abs() < f32::EPSILON);
950        } else {
951            panic!("Expected mesh");
952        }
953    }
954
955    #[test]
956    fn test_empty_groups_are_skipped() {
957        // Group "Empty" has no faces between it and GroupB
958        let obj_data = b"v 0 0 0\nv 1 0 0\nv 0 1 0\ng Empty\ng HasFaces\nf 1 2 3\n";
959        let intermediate = ObjImporter::parse_obj(BufReader::new(&obj_data[..])).unwrap();
960        let model = ObjImporter::build_model(intermediate, &HashMap::new()).unwrap();
961
962        // Only one object (the empty group is skipped)
963        assert_eq!(model.build.items.len(), 1);
964        let obj = model
965            .resources
966            .get_object(model.build.items[0].object_id)
967            .unwrap();
968        assert_eq!(obj.name.as_deref(), Some("HasFaces"));
969    }
970
971    #[test]
972    fn test_undefined_material_gets_gray() {
973        let obj_data = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl Unknown\nf 1 2 3\n";
974        let intermediate = ObjImporter::parse_obj(BufReader::new(&obj_data[..])).unwrap();
975
976        // Empty materials map -- "Unknown" is not defined
977        let model = ObjImporter::build_model(intermediate, &HashMap::new()).unwrap();
978
979        // Should still have a BaseMaterialsGroup with gray fallback
980        assert_eq!(model.resources.base_material_groups_count(), 1);
981        let bmg = model.resources.get_base_materials(ResourceId(1)).unwrap();
982        assert_eq!(bmg.materials.len(), 1);
983        assert_eq!(bmg.materials[0].name, "Unknown");
984        assert_eq!(
985            bmg.materials[0].display_color,
986            Color::new(128, 128, 128, 255)
987        );
988    }
989
990    #[test]
991    fn test_polygon_fan_triangulation() {
992        // A quad face (4 vertices) should produce 2 triangles
993        let obj_data = b"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n";
994        let model = ObjImporter::read(&obj_data[..]).unwrap();
995        let obj = model.resources.get_object(ResourceId(1)).unwrap();
996        if let Geometry::Mesh(mesh) = &obj.geometry {
997            assert_eq!(mesh.triangles.len(), 2);
998            // First triangle: 0,1,2
999            assert_eq!(mesh.triangles[0].v1, 0);
1000            assert_eq!(mesh.triangles[0].v2, 1);
1001            assert_eq!(mesh.triangles[0].v3, 2);
1002            // Second triangle: 0,2,3
1003            assert_eq!(mesh.triangles[1].v1, 0);
1004            assert_eq!(mesh.triangles[1].v2, 2);
1005            assert_eq!(mesh.triangles[1].v3, 3);
1006        } else {
1007            panic!("Expected mesh");
1008        }
1009    }
1010
1011    #[test]
1012    fn test_face_with_vt_vn_format() {
1013        // v/vt/vn format -- should extract vertex index only
1014        let obj_data = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nf 1/1/1 2/2/1 3/3/1\n";
1015        let model = ObjImporter::read(&obj_data[..]).unwrap();
1016        let obj = model.resources.get_object(ResourceId(1)).unwrap();
1017        if let Geometry::Mesh(mesh) = &obj.geometry {
1018            assert_eq!(mesh.triangles.len(), 1);
1019            assert_eq!(mesh.triangles[0].v1, 0);
1020            assert_eq!(mesh.triangles[0].v2, 1);
1021            assert_eq!(mesh.triangles[0].v3, 2);
1022        } else {
1023            panic!("Expected mesh");
1024        }
1025    }
1026
1027    #[test]
1028    fn test_multiple_materials_in_one_group() {
1029        // A single group with two usemtl directives
1030        let obj_data = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nv 2 0 0\nv 2 1 0\nv 3 0 0\nusemtl Red\nf 1 2 3\nusemtl Blue\nf 4 5 6\n";
1031        let intermediate = ObjImporter::parse_obj(BufReader::new(&obj_data[..])).unwrap();
1032
1033        let mut materials = HashMap::new();
1034        materials.insert(
1035            "Red".to_string(),
1036            mtl::MtlMaterial {
1037                name: "Red".to_string(),
1038                display_color: Color::new(255, 0, 0, 255),
1039            },
1040        );
1041        materials.insert(
1042            "Blue".to_string(),
1043            mtl::MtlMaterial {
1044                name: "Blue".to_string(),
1045                display_color: Color::new(0, 0, 255, 255),
1046            },
1047        );
1048
1049        let model = ObjImporter::build_model(intermediate, &materials).unwrap();
1050
1051        // One BaseMaterialsGroup with 2 materials
1052        let bmg = model.resources.get_base_materials(ResourceId(1)).unwrap();
1053        assert_eq!(bmg.materials.len(), 2);
1054
1055        // One object (no explicit group directive)
1056        assert_eq!(model.build.items.len(), 1);
1057        let obj = model
1058            .resources
1059            .get_object(model.build.items[0].object_id)
1060            .unwrap();
1061
1062        if let Geometry::Mesh(mesh) = &obj.geometry {
1063            assert_eq!(mesh.triangles.len(), 2);
1064            // First triangle: Red (index 0)
1065            assert_eq!(mesh.triangles[0].pid, Some(1));
1066            assert_eq!(mesh.triangles[0].p1, Some(0));
1067            // Second triangle: Blue (index 1)
1068            assert_eq!(mesh.triangles[1].pid, Some(1));
1069            assert_eq!(mesh.triangles[1].p1, Some(1));
1070        } else {
1071            panic!("Expected mesh");
1072        }
1073    }
1074
1075    #[test]
1076    fn test_faces_before_first_group() {
1077        // Faces before any g/o directive go into default group
1078        let obj_data =
1079            b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\ng Named\nv 2 0 0\nv 2 1 0\nv 3 0 0\nf 4 5 6\n";
1080        let intermediate = ObjImporter::parse_obj(BufReader::new(&obj_data[..])).unwrap();
1081        let model = ObjImporter::build_model(intermediate, &HashMap::new()).unwrap();
1082
1083        assert_eq!(model.build.items.len(), 2);
1084        // First group: default (unnamed)
1085        let obj0 = model
1086            .resources
1087            .get_object(model.build.items[0].object_id)
1088            .unwrap();
1089        assert_eq!(obj0.name.as_deref(), Some("OBJ Import")); // unnamed defaults
1090
1091        // Second group: Named
1092        let obj1 = model
1093            .resources
1094            .get_object(model.build.items[1].object_id)
1095            .unwrap();
1096        assert_eq!(obj1.name.as_deref(), Some("Named"));
1097    }
1098
1099    #[test]
1100    fn test_o_directive_treated_like_g() {
1101        let obj_data = b"v 0 0 0\nv 1 0 0\nv 0 1 0\no MyObject\nf 1 2 3\n";
1102        let intermediate = ObjImporter::parse_obj(BufReader::new(&obj_data[..])).unwrap();
1103        let model = ObjImporter::build_model(intermediate, &HashMap::new()).unwrap();
1104
1105        assert_eq!(model.build.items.len(), 1);
1106        let obj = model
1107            .resources
1108            .get_object(model.build.items[0].object_id)
1109            .unwrap();
1110        assert_eq!(obj.name.as_deref(), Some("MyObject"));
1111    }
1112}