Skip to main content

oxiphysics_io/obj/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::manual_strip, clippy::should_implement_trait)]
6#[allow(unused_imports)]
7use super::functions::*;
8#[allow(unused_imports)]
9use super::functions_2::*;
10
11use crate::{Error, Result};
12use oxiphysics_core::math::Vec3;
13use std::fs::File;
14use std::io::{BufRead, BufReader, BufWriter, Write};
15use std::path::Path;
16
17/// A simple scene graph for OBJ scenes.
18#[allow(dead_code)]
19#[derive(Debug, Clone, Default)]
20pub struct ObjScene {
21    /// All nodes in the scene.
22    pub nodes: Vec<ObjSceneNode>,
23    /// All meshes in the scene.
24    pub meshes: Vec<ObjMesh>,
25    /// All materials in the scene.
26    pub materials: Vec<ObjMaterial>,
27}
28#[allow(dead_code)]
29impl ObjScene {
30    /// Create an empty scene.
31    pub fn new() -> Self {
32        Self::default()
33    }
34    /// Add a mesh to the scene and return its index.
35    pub fn add_mesh(&mut self, mesh: ObjMesh) -> usize {
36        let idx = self.meshes.len();
37        self.meshes.push(mesh);
38        idx
39    }
40    /// Add a material to the scene and return its index.
41    pub fn add_material(&mut self, mat: ObjMaterial) -> usize {
42        let idx = self.materials.len();
43        self.materials.push(mat);
44        idx
45    }
46    /// Add a node to the scene and return its index.
47    pub fn add_node(
48        &mut self,
49        name: &str,
50        mesh_index: Option<usize>,
51        transform: MeshTransform,
52    ) -> usize {
53        let idx = self.nodes.len();
54        self.nodes.push(ObjSceneNode {
55            name: name.to_string(),
56            transform,
57            mesh_index,
58            children: Vec::new(),
59        });
60        idx
61    }
62    /// Set a node as a child of another node.
63    pub fn add_child(&mut self, parent_idx: usize, child_idx: usize) {
64        if parent_idx < self.nodes.len() {
65            self.nodes[parent_idx].children.push(child_idx);
66        }
67    }
68    /// Flatten the scene hierarchy into a single merged `ObjMesh`.
69    ///
70    /// Each node's mesh is transformed by the node's local transform.
71    pub fn flatten(&self) -> ObjMesh {
72        let mut result = ObjMesh::default();
73        for node in &self.nodes {
74            if let Some(mi) = node.mesh_index
75                && let Some(mesh) = self.meshes.get(mi)
76            {
77                let inst = MeshInstance {
78                    name: node.name.clone(),
79                    transform: node.transform.clone(),
80                };
81                let xformed = instantiate_mesh(mesh, &inst);
82                result = merge_obj_meshes(&result, &xformed);
83            }
84        }
85        result
86    }
87    /// Total number of vertices across all meshes.
88    pub fn total_vertices(&self) -> usize {
89        self.meshes.iter().map(|m| m.vertices.len()).sum()
90    }
91    /// Total number of faces across all meshes.
92    pub fn total_faces(&self) -> usize {
93        self.meshes.iter().map(|m| m.faces.len()).sum()
94    }
95}
96/// A simple OBJ curve (polyline or rational B-spline stub).
97#[allow(dead_code)]
98#[derive(Debug, Clone)]
99pub struct ObjCurve {
100    /// Curve name.
101    pub name: String,
102    /// Degree of the curve (1 = polyline).
103    pub degree: usize,
104    /// Control point indices into the vertex array.
105    pub control_points: Vec<usize>,
106    /// Knot vector (empty for polylines).
107    pub knots: Vec<f64>,
108}
109/// Reader for Wavefront OBJ files.
110#[allow(dead_code)]
111pub struct ObjReader;
112#[allow(dead_code)]
113impl ObjReader {
114    /// Parse an OBJ string into an [`ObjMesh`].
115    pub fn from_str(data: &str) -> Result<ObjMesh> {
116        let mut mesh = ObjMesh::default();
117        let mut current_group_name: Option<String> = None;
118        let mut current_group_start: usize = 0;
119        let mut current_smoothing_group: u32 = 0;
120        let mut current_material: Option<String> = None;
121        for raw in data.lines() {
122            let line = raw.trim();
123            if line.is_empty() || line.starts_with('#') {
124                continue;
125            }
126            if line.starts_with("g ") || line.starts_with("o ") {
127                if let Some(ref name) = current_group_name {
128                    let count = mesh.faces.len() - current_group_start;
129                    if count > 0 {
130                        mesh.groups.push(ObjGroup {
131                            name: name.clone(),
132                            face_start: current_group_start,
133                            face_count: count,
134                        });
135                    }
136                }
137                let name = line[2..].trim().to_string();
138                current_group_name = Some(name);
139                current_group_start = mesh.faces.len();
140            } else if line.starts_with("s ") {
141                let val = line[2..].trim();
142                current_smoothing_group = if val == "off" || val == "0" {
143                    0
144                } else {
145                    val.parse::<u32>().unwrap_or(0)
146                };
147            } else if line.starts_with("usemtl ") {
148                current_material = Some(line[7..].trim().to_string());
149            } else {
150                Self::parse_line_extended(
151                    line,
152                    &mut mesh,
153                    current_smoothing_group,
154                    &current_material,
155                )?;
156            }
157        }
158        if let Some(ref name) = current_group_name {
159            let count = mesh.faces.len() - current_group_start;
160            if count > 0 {
161                mesh.groups.push(ObjGroup {
162                    name: name.clone(),
163                    face_start: current_group_start,
164                    face_count: count,
165                });
166            }
167        }
168        Ok(mesh)
169    }
170    fn parse_line_extended(
171        line: &str,
172        mesh: &mut ObjMesh,
173        smoothing_group: u32,
174        material: &Option<String>,
175    ) -> Result<()> {
176        if line.starts_with("vn ") {
177            let p: Vec<&str> = line.split_whitespace().collect();
178            if p.len() >= 4 {
179                let x = p[1]
180                    .parse::<f64>()
181                    .map_err(|e| Error::Parse(e.to_string()))?;
182                let y = p[2]
183                    .parse::<f64>()
184                    .map_err(|e| Error::Parse(e.to_string()))?;
185                let z = p[3]
186                    .parse::<f64>()
187                    .map_err(|e| Error::Parse(e.to_string()))?;
188                mesh.normals.push([x, y, z]);
189            }
190        } else if line.starts_with("vt ") {
191            let p: Vec<&str> = line.split_whitespace().collect();
192            if p.len() >= 3 {
193                let u = p[1]
194                    .parse::<f64>()
195                    .map_err(|e| Error::Parse(e.to_string()))?;
196                let v = p[2]
197                    .parse::<f64>()
198                    .map_err(|e| Error::Parse(e.to_string()))?;
199                mesh.uvs.push([u, v]);
200            }
201        } else if line.starts_with("v ") {
202            let p: Vec<&str> = line.split_whitespace().collect();
203            if p.len() >= 4 {
204                let x = p[1]
205                    .parse::<f64>()
206                    .map_err(|e| Error::Parse(e.to_string()))?;
207                let y = p[2]
208                    .parse::<f64>()
209                    .map_err(|e| Error::Parse(e.to_string()))?;
210                let z = p[3]
211                    .parse::<f64>()
212                    .map_err(|e| Error::Parse(e.to_string()))?;
213                mesh.vertices.push([x, y, z]);
214            }
215        } else if line.starts_with("f ") {
216            let p: Vec<&str> = line.split_whitespace().collect();
217            let mut vis = Vec::new();
218            let mut vts = Vec::new();
219            let mut vns = Vec::new();
220            let mut has_vt = false;
221            let mut has_vn = false;
222            for tok in &p[1..] {
223                let parts: Vec<&str> = tok.split('/').collect();
224                let vi = parts[0]
225                    .parse::<usize>()
226                    .map_err(|e| Error::Parse(e.to_string()))?
227                    - 1;
228                vis.push(vi);
229                if parts.len() >= 2 && !parts[1].is_empty() {
230                    has_vt = true;
231                    let vt = parts[1]
232                        .parse::<usize>()
233                        .map_err(|e| Error::Parse(e.to_string()))?
234                        - 1;
235                    vts.push(vt);
236                }
237                if parts.len() >= 3 && !parts[2].is_empty() {
238                    has_vn = true;
239                    let vn = parts[2]
240                        .parse::<usize>()
241                        .map_err(|e| Error::Parse(e.to_string()))?
242                        - 1;
243                    vns.push(vn);
244                }
245            }
246            mesh.faces.push(ObjFace {
247                vertex_indices: vis,
248                normal_indices: if has_vn { Some(vns) } else { None },
249                uv_indices: if has_vt { Some(vts) } else { None },
250                smoothing_group,
251                material: material.clone(),
252            });
253        }
254        Ok(())
255    }
256    /// Read an OBJ file and parse into an [`ObjMesh`].
257    pub fn from_file(path: &str) -> Result<ObjMesh> {
258        let file = File::open(Path::new(path))?;
259        let reader = BufReader::new(file);
260        let mut data = String::new();
261        for raw in reader.lines() {
262            let raw = raw?;
263            data.push_str(&raw);
264            data.push('\n');
265        }
266        Self::from_str(&data)
267    }
268    /// Read vertices and triangle faces from an OBJ file (legacy API).
269    ///
270    /// Returns `(vertices, faces)`. Face indices are converted to 0-based.
271    pub fn read(path: &str) -> Result<(Vec<Vec3>, Vec<[usize; 3]>)> {
272        let mesh = Self::from_file(path)?;
273        let vertices: Vec<Vec3> = mesh
274            .vertices
275            .iter()
276            .map(|v| Vec3::new(v[0], v[1], v[2]))
277            .collect();
278        let faces: Vec<[usize; 3]> = mesh
279            .faces
280            .iter()
281            .filter(|f| f.vertex_indices.len() >= 3)
282            .map(|f| {
283                [
284                    f.vertex_indices[0],
285                    f.vertex_indices[1],
286                    f.vertex_indices[2],
287                ]
288            })
289            .collect();
290        Ok((vertices, faces))
291    }
292}
293/// A simple OBJ material definition.
294#[allow(dead_code)]
295#[derive(Debug, Clone)]
296pub struct ObjMaterial {
297    /// Material name.
298    pub name: String,
299    /// Diffuse colour (Kd).
300    pub kd: [f64; 3],
301    /// Specular colour (Ks).
302    pub ks: [f64; 3],
303    /// Shininess exponent (Ns).
304    pub ns: f64,
305    /// Ambient colour (Ka).
306    pub ka: [f64; 3],
307    /// Dissolve / transparency (d, 1.0 = opaque).
308    pub dissolve: f64,
309    /// Diffuse texture map filename.
310    pub map_kd: Option<String>,
311}
312#[allow(dead_code)]
313impl ObjMaterial {
314    /// Create a basic material with just a name and diffuse color.
315    pub fn basic(name: &str, kd: [f64; 3]) -> Self {
316        Self {
317            name: name.to_string(),
318            kd,
319            ks: [0.0; 3],
320            ns: 1.0,
321            ka: [0.0; 3],
322            dissolve: 1.0,
323            map_kd: None,
324        }
325    }
326}
327/// A level-of-detail collection: multiple meshes at decreasing resolution.
328#[allow(dead_code)]
329#[derive(Debug, Clone, Default)]
330pub struct ObjLod {
331    /// LOD levels, index 0 = highest detail.
332    pub levels: Vec<ObjMesh>,
333    /// Distance thresholds at which to switch to the next LOD level.
334    /// `thresholds[i]` is the max distance for `levels[i]`.
335    pub thresholds: Vec<f64>,
336}
337#[allow(dead_code)]
338impl ObjLod {
339    /// Create an empty LOD set.
340    pub fn new() -> Self {
341        Self::default()
342    }
343    /// Add a LOD level with the given switch distance.
344    pub fn push(&mut self, mesh: ObjMesh, threshold: f64) {
345        self.levels.push(mesh);
346        self.thresholds.push(threshold);
347    }
348    /// Select the appropriate LOD level for a viewing distance.
349    pub fn select(&self, distance: f64) -> Option<&ObjMesh> {
350        for (i, &t) in self.thresholds.iter().enumerate() {
351            if distance <= t {
352                return self.levels.get(i);
353            }
354        }
355        self.levels.last()
356    }
357    /// Number of LOD levels.
358    pub fn num_levels(&self) -> usize {
359        self.levels.len()
360    }
361    /// Simplify a mesh to approximately `target_faces` triangles by
362    /// removing every other face (simple decimation placeholder).
363    pub fn decimate(mesh: &ObjMesh, target_faces: usize) -> ObjMesh {
364        if mesh.faces.len() <= target_faces {
365            return mesh.clone();
366        }
367        let keep_ratio = target_faces as f64 / mesh.faces.len() as f64;
368        let mut out = ObjMesh {
369            vertices: mesh.vertices.clone(),
370            normals: mesh.normals.clone(),
371            uvs: mesh.uvs.clone(),
372            ..Default::default()
373        };
374        let _total = mesh.faces.len();
375        let step = (1.0 / keep_ratio).round() as usize;
376        let step = step.max(2);
377        for (i, face) in mesh.faces.iter().enumerate() {
378            if i % step != 0 {
379                out.faces.push(face.clone());
380            }
381            if out.faces.len() >= target_faces {
382                break;
383            }
384        }
385        out
386    }
387}
388/// A transform applied when instancing a mesh.
389#[allow(dead_code)]
390#[derive(Debug, Clone)]
391pub struct MeshTransform {
392    /// Translation vector.
393    pub translation: [f64; 3],
394    /// Uniform scale factor.
395    pub scale: f64,
396    /// Rotation axis (unit vector).
397    pub axis: [f64; 3],
398    /// Rotation angle in radians.
399    pub angle: f64,
400}
401#[allow(dead_code)]
402impl MeshTransform {
403    /// Identity transform.
404    pub fn identity() -> Self {
405        Self {
406            translation: [0.0; 3],
407            scale: 1.0,
408            axis: [0.0, 0.0, 1.0],
409            angle: 0.0,
410        }
411    }
412    /// Translation-only transform.
413    pub fn from_translation(tx: f64, ty: f64, tz: f64) -> Self {
414        Self {
415            translation: [tx, ty, tz],
416            scale: 1.0,
417            axis: [0.0, 0.0, 1.0],
418            angle: 0.0,
419        }
420    }
421    /// Apply this transform to a single point.
422    ///
423    /// Applies scale, then rotation (Rodrigues formula), then translation.
424    pub fn apply(&self, p: [f64; 3]) -> [f64; 3] {
425        let s = [p[0] * self.scale, p[1] * self.scale, p[2] * self.scale];
426        let (ax, ay, az) = (self.axis[0], self.axis[1], self.axis[2]);
427        let (sin_a, cos_a) = (self.angle.sin(), self.angle.cos());
428        let dot = ax * s[0] + ay * s[1] + az * s[2];
429        let cross = [
430            ay * s[2] - az * s[1],
431            az * s[0] - ax * s[2],
432            ax * s[1] - ay * s[0],
433        ];
434        let rx = s[0] * cos_a + cross[0] * sin_a + ax * dot * (1.0 - cos_a);
435        let ry = s[1] * cos_a + cross[1] * sin_a + ay * dot * (1.0 - cos_a);
436        let rz = s[2] * cos_a + cross[2] * sin_a + az * dot * (1.0 - cos_a);
437        [
438            rx + self.translation[0],
439            ry + self.translation[1],
440            rz + self.translation[2],
441        ]
442    }
443}
444/// Statistics computed from an `ObjMesh`.
445#[allow(dead_code)]
446#[derive(Debug, Clone)]
447pub struct ObjMeshStats {
448    /// Number of vertices.
449    pub vertex_count: usize,
450    /// Number of faces.
451    pub face_count: usize,
452    /// Number of triangles (after fan-triangulation).
453    pub triangle_count: usize,
454    /// Number of unique materials.
455    pub material_count: usize,
456    /// Number of groups.
457    pub group_count: usize,
458    /// Number of faces with normals.
459    pub faces_with_normals: usize,
460    /// Number of faces with UVs.
461    pub faces_with_uvs: usize,
462    /// Surface area (sum of triangle areas).
463    pub surface_area: f64,
464    /// Axis-aligned bounding box (min, max).
465    pub bbox: Option<([f64; 3], [f64; 3])>,
466}
467/// A Wavefront OBJ mesh with vertices, normals, UVs, and faces.
468#[allow(dead_code)]
469#[derive(Debug, Clone, Default)]
470pub struct ObjMesh {
471    /// 3D vertex positions.
472    pub vertices: Vec<[f64; 3]>,
473    /// Per-vertex normals.
474    pub normals: Vec<[f64; 3]>,
475    /// UV (texture) coordinates.
476    pub uvs: Vec<[f64; 2]>,
477    /// Face list.
478    pub faces: Vec<ObjFace>,
479    /// Named groups.
480    pub groups: Vec<ObjGroup>,
481}
482#[allow(dead_code)]
483impl ObjMesh {
484    /// Extract a flat triangle soup from the mesh.
485    ///
486    /// Each triangle is `[[v0\], [v1], [v2]]` in world-space coordinates.
487    /// Quad faces are triangulated as (0,1,2) and (0,2,3).
488    pub fn to_triangle_soup(&self) -> Vec<[[f64; 3]; 3]> {
489        let mut soup = Vec::new();
490        for face in &self.faces {
491            let verts = &face.vertex_indices;
492            if verts.len() < 3 {
493                continue;
494            }
495            for i in 1..(verts.len() - 1) {
496                let v0 = self.vertices[verts[0]];
497                let v1 = self.vertices[verts[i]];
498                let v2 = self.vertices[verts[i + 1]];
499                soup.push([v0, v1, v2]);
500            }
501        }
502        soup
503    }
504    /// Return faces belonging to a specific group.
505    pub fn faces_in_group(&self, group_name: &str) -> Vec<&ObjFace> {
506        if let Some(group) = self.groups.iter().find(|g| g.name == group_name) {
507            let end = group.face_start + group.face_count;
508            let end = end.min(self.faces.len());
509            self.faces[group.face_start..end].iter().collect()
510        } else {
511            Vec::new()
512        }
513    }
514    /// Return faces belonging to a specific smoothing group.
515    pub fn faces_in_smoothing_group(&self, sg: u32) -> Vec<&ObjFace> {
516        self.faces
517            .iter()
518            .filter(|f| f.smoothing_group == sg)
519            .collect()
520    }
521    /// Return faces with a specific material.
522    pub fn faces_with_material(&self, mat_name: &str) -> Vec<&ObjFace> {
523        self.faces
524            .iter()
525            .filter(|f| f.material.as_deref() == Some(mat_name))
526            .collect()
527    }
528    /// Total number of triangles after fan-triangulation.
529    pub fn triangle_count(&self) -> usize {
530        self.faces
531            .iter()
532            .map(|f| {
533                if f.vertex_indices.len() >= 3 {
534                    f.vertex_indices.len() - 2
535                } else {
536                    0
537                }
538            })
539            .sum()
540    }
541    /// Compute flat face normal for a triangular face.
542    pub fn face_normal(&self, face_idx: usize) -> Option<[f64; 3]> {
543        let face = self.faces.get(face_idx)?;
544        if face.vertex_indices.len() < 3 {
545            return None;
546        }
547        let v0 = self.vertices[face.vertex_indices[0]];
548        let v1 = self.vertices[face.vertex_indices[1]];
549        let v2 = self.vertices[face.vertex_indices[2]];
550        let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
551        let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
552        let n = [
553            e1[1] * e2[2] - e1[2] * e2[1],
554            e1[2] * e2[0] - e1[0] * e2[2],
555            e1[0] * e2[1] - e1[1] * e2[0],
556        ];
557        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
558        if len < 1e-30 {
559            return None;
560        }
561        Some([n[0] / len, n[1] / len, n[2] / len])
562    }
563    /// Compute axis-aligned bounding box of all vertices.
564    pub fn bounding_box(&self) -> Option<([f64; 3], [f64; 3])> {
565        if self.vertices.is_empty() {
566            return None;
567        }
568        let mut min = self.vertices[0];
569        let mut max = self.vertices[0];
570        for v in &self.vertices[1..] {
571            for k in 0..3 {
572                if v[k] < min[k] {
573                    min[k] = v[k];
574                }
575                if v[k] > max[k] {
576                    max[k] = v[k];
577                }
578            }
579        }
580        Some((min, max))
581    }
582}
583/// An instance of an `ObjMesh` with a transform and optional name override.
584#[allow(dead_code)]
585#[derive(Debug, Clone)]
586pub struct MeshInstance {
587    /// Name of this instance.
588    pub name: String,
589    /// Transform to apply.
590    pub transform: MeshTransform,
591}
592/// A named group/object within an OBJ file.
593#[allow(dead_code)]
594#[derive(Debug, Clone)]
595pub struct ObjGroup {
596    /// Group or object name.
597    pub name: String,
598    /// Index of the first face belonging to this group (into the mesh face list).
599    pub face_start: usize,
600    /// Number of faces in this group.
601    pub face_count: usize,
602}
603/// A single face in a Wavefront OBJ mesh.
604///
605/// All index arrays are optional to support the various OBJ face formats:
606/// `f v`, `f v/vt`, `f v//vn`, `f v/vt/vn`.
607#[allow(dead_code)]
608#[derive(Debug, Clone)]
609pub struct ObjFace {
610    /// Vertex indices (0-based).
611    pub vertex_indices: Vec<usize>,
612    /// Normal indices (0-based), if present.
613    pub normal_indices: Option<Vec<usize>>,
614    /// UV/texture-coordinate indices (0-based), if present.
615    pub uv_indices: Option<Vec<usize>>,
616    /// Smoothing group this face belongs to (0 = off).
617    pub smoothing_group: u32,
618    /// Material name assigned to this face.
619    pub material: Option<String>,
620}
621/// Writer for `ObjMesh` structures.
622#[allow(dead_code)]
623pub struct ObjWriter;
624#[allow(dead_code)]
625impl ObjWriter {
626    /// Serialize an `ObjMesh` to a Wavefront OBJ string.
627    pub fn write(mesh: &ObjMesh) -> String {
628        Self::write_with_groups(mesh, false)
629    }
630    /// Serialize an `ObjMesh` with optional group headers.
631    pub fn write_with_groups(mesh: &ObjMesh, emit_groups: bool) -> String {
632        let mut s = String::from("# OxiPhysics OBJ export\n");
633        for v in &mesh.vertices {
634            s.push_str(&format!("v {} {} {}\n", v[0], v[1], v[2]));
635        }
636        for vn in &mesh.normals {
637            s.push_str(&format!("vn {} {} {}\n", vn[0], vn[1], vn[2]));
638        }
639        for vt in &mesh.uvs {
640            s.push_str(&format!("vt {} {}\n", vt[0], vt[1]));
641        }
642        let mut current_group: Option<&str> = None;
643        let mut current_material: Option<&str> = None;
644        let mut current_sg: u32 = 0;
645        for (fi, face) in mesh.faces.iter().enumerate() {
646            if emit_groups {
647                for group in &mesh.groups {
648                    if fi == group.face_start && current_group != Some(&group.name) {
649                        s.push_str(&format!("g {}\n", group.name));
650                        current_group = Some(&group.name);
651                    }
652                }
653            }
654            if let Some(ref mat) = face.material
655                && current_material != Some(mat.as_str())
656            {
657                s.push_str(&format!("usemtl {}\n", mat));
658                current_material = Some(mat);
659            }
660            if face.smoothing_group != current_sg {
661                current_sg = face.smoothing_group;
662                if current_sg == 0 {
663                    s.push_str("s off\n");
664                } else {
665                    s.push_str(&format!("s {}\n", current_sg));
666                }
667            }
668            s.push('f');
669            for i in 0..face.vertex_indices.len() {
670                let vi = face.vertex_indices[i] + 1;
671                let vt_idx = face.uv_indices.as_ref().map(|uvs| uvs[i] + 1);
672                let vn_idx = face.normal_indices.as_ref().map(|ns| ns[i] + 1);
673                let token = match (vt_idx, vn_idx) {
674                    (Some(vt), Some(vn)) => format!(" {}/{}/{}", vi, vt, vn),
675                    (None, Some(vn)) => format!(" {}//{}", vi, vn),
676                    (Some(vt), None) => format!(" {}/{}", vi, vt),
677                    (None, None) => format!(" {}", vi),
678                };
679                s.push_str(&token);
680            }
681            s.push('\n');
682        }
683        s
684    }
685    /// Write an `ObjMesh` directly to a file at `path`.
686    pub fn write_to_file(path: &str, mesh: &ObjMesh) -> Result<()> {
687        let file = File::create(Path::new(path))?;
688        let mut w = BufWriter::new(file);
689        write!(w, "{}", Self::write(mesh))?;
690        w.flush()?;
691        Ok(())
692    }
693    /// Write a triangle mesh (legacy API -- vertices + triangle index arrays).
694    ///
695    /// Optionally includes per-vertex normals.
696    pub fn write_legacy(
697        path: &str,
698        vertices: &[Vec3],
699        triangles: &[[usize; 3]],
700        normals: Option<&[Vec3]>,
701    ) -> Result<()> {
702        let file = File::create(Path::new(path))?;
703        let mut w = BufWriter::new(file);
704        writeln!(w, "# OxiPhysics OBJ export")?;
705        for v in vertices {
706            writeln!(w, "v {} {} {}", v.x, v.y, v.z)?;
707        }
708        if let Some(norms) = normals {
709            for n in norms {
710                writeln!(w, "vn {} {} {}", n.x, n.y, n.z)?;
711            }
712            for t in triangles {
713                writeln!(
714                    w,
715                    "f {}//{} {}//{} {}//{}",
716                    t[0] + 1,
717                    t[0] + 1,
718                    t[1] + 1,
719                    t[1] + 1,
720                    t[2] + 1,
721                    t[2] + 1,
722                )?;
723            }
724        } else {
725            for t in triangles {
726                writeln!(w, "f {} {} {}", t[0] + 1, t[1] + 1, t[2] + 1)?;
727            }
728        }
729        w.flush()?;
730        Ok(())
731    }
732    /// Write a mesh with texture coordinates (legacy API).
733    pub fn write_with_uvs(
734        path: &str,
735        vertices: &[Vec3],
736        uvs: &[[f64; 2]],
737        triangles: &[[usize; 3]],
738    ) -> Result<()> {
739        let file = File::create(Path::new(path))?;
740        let mut w = BufWriter::new(file);
741        writeln!(w, "# OxiPhysics OBJ export")?;
742        for v in vertices {
743            writeln!(w, "v {} {} {}", v.x, v.y, v.z)?;
744        }
745        for uv in uvs {
746            writeln!(w, "vt {} {}", uv[0], uv[1])?;
747        }
748        for t in triangles {
749            writeln!(
750                w,
751                "f {}/{} {}/{} {}/{}",
752                t[0] + 1,
753                t[0] + 1,
754                t[1] + 1,
755                t[1] + 1,
756                t[2] + 1,
757                t[2] + 1,
758            )?;
759        }
760        w.flush()?;
761        Ok(())
762    }
763}
764/// A node in an OBJ scene hierarchy.
765#[allow(dead_code)]
766#[derive(Debug, Clone)]
767pub struct ObjSceneNode {
768    /// Name of this node.
769    pub name: String,
770    /// Local transform.
771    pub transform: MeshTransform,
772    /// Mesh index (if this node has geometry).
773    pub mesh_index: Option<usize>,
774    /// Child node indices.
775    pub children: Vec<usize>,
776}
777/// Per-vertex RGBA colour.
778#[allow(dead_code)]
779#[derive(Debug, Clone, Copy, PartialEq)]
780pub struct ObjVertexColor {
781    /// Red channel \[0, 1\].
782    pub r: f64,
783    /// Green channel \[0, 1\].
784    pub g: f64,
785    /// Blue channel \[0, 1\].
786    pub b: f64,
787    /// Alpha channel \[0, 1\].
788    pub a: f64,
789}
790#[allow(dead_code)]
791impl ObjVertexColor {
792    /// Construct a colour from (r, g, b, a).
793    pub fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
794        Self { r, g, b, a }
795    }
796    /// Construct an opaque colour from (r, g, b).
797    pub fn rgb(r: f64, g: f64, b: f64) -> Self {
798        Self { r, g, b, a: 1.0 }
799    }
800    /// Return as `[r, g, b, a]` array.
801    pub fn to_array(self) -> [f64; 4] {
802        [self.r, self.g, self.b, self.a]
803    }
804    /// Blend two colours with weight `t ∈ [0,1]`.
805    pub fn lerp(self, other: Self, t: f64) -> Self {
806        Self {
807            r: self.r + (other.r - self.r) * t,
808            g: self.g + (other.g - self.g) * t,
809            b: self.b + (other.b - self.b) * t,
810            a: self.a + (other.a - self.a) * t,
811        }
812    }
813}
814/// A simple OBJ surface (tensor-product B-spline stub).
815#[allow(dead_code)]
816#[derive(Debug, Clone)]
817pub struct ObjSurface {
818    /// Surface name.
819    pub name: String,
820    /// Degree in u direction.
821    pub degree_u: usize,
822    /// Degree in v direction.
823    pub degree_v: usize,
824    /// Control point indices (row-major, n_u x n_v).
825    pub control_points: Vec<usize>,
826    /// Number of control points in u direction.
827    pub n_u: usize,
828    /// Knot vector in u direction.
829    pub knots_u: Vec<f64>,
830    /// Knot vector in v direction.
831    pub knots_v: Vec<f64>,
832}
833/// Writer for Wavefront MTL material library files.
834#[allow(dead_code)]
835pub struct MtlWriter;
836#[allow(dead_code)]
837impl MtlWriter {
838    /// Generate an MTL file string for the given materials.
839    pub fn write(materials: &[ObjMaterial]) -> String {
840        let mut s = String::from("# OxiPhysics MTL export\n");
841        for mat in materials {
842            s.push_str(&format!("\nnewmtl {}\n", mat.name));
843            s.push_str(&format!("Ka {} {} {}\n", mat.ka[0], mat.ka[1], mat.ka[2]));
844            s.push_str(&format!("Kd {} {} {}\n", mat.kd[0], mat.kd[1], mat.kd[2]));
845            s.push_str(&format!("Ks {} {} {}\n", mat.ks[0], mat.ks[1], mat.ks[2]));
846            s.push_str(&format!("Ns {}\n", mat.ns));
847            s.push_str(&format!("d {}\n", mat.dissolve));
848            if let Some(ref tex) = mat.map_kd {
849                s.push_str(&format!("map_Kd {}\n", tex));
850            }
851        }
852        s
853    }
854}
855/// OBJ mesh extended with per-vertex colours (non-standard OBJ extension).
856///
857/// Some exporters write vertex colours as extra columns on `v` lines:
858/// `v x y z r g b` or `v x y z r g b a`.
859#[allow(dead_code)]
860#[derive(Debug, Clone, Default)]
861pub struct ObjVertexColorMesh {
862    /// Underlying geometry.
863    pub mesh: ObjMesh,
864    /// Per-vertex colours (same length as `mesh.vertices` if present).
865    pub colors: Vec<ObjVertexColor>,
866}
867#[allow(dead_code)]
868impl ObjVertexColorMesh {
869    /// Parse an OBJ string that may contain vertex colour data.
870    pub fn from_str(data: &str) -> std::result::Result<Self, String> {
871        let mut vcmesh = ObjVertexColorMesh::default();
872        let mut smoothing_group: u32 = 0;
873        let mut current_material: Option<String> = None;
874        let mut current_group_name: Option<String> = None;
875        let mut current_group_start: usize = 0;
876        for raw in data.lines() {
877            let line = raw.trim();
878            if line.is_empty() || line.starts_with('#') {
879                continue;
880            }
881            if line.starts_with("g ") || line.starts_with("o ") {
882                if let Some(ref name) = current_group_name {
883                    let count = vcmesh.mesh.faces.len() - current_group_start;
884                    if count > 0 {
885                        vcmesh.mesh.groups.push(ObjGroup {
886                            name: name.clone(),
887                            face_start: current_group_start,
888                            face_count: count,
889                        });
890                    }
891                }
892                current_group_name = Some(line[2..].trim().to_string());
893                current_group_start = vcmesh.mesh.faces.len();
894            } else if line.starts_with("usemtl ") {
895                current_material = Some(line[7..].trim().to_string());
896            } else if line.starts_with("s ") {
897                let val = line[2..].trim();
898                smoothing_group = if val == "off" || val == "0" {
899                    0
900                } else {
901                    val.parse::<u32>().unwrap_or(0)
902                };
903            } else if line.starts_with("v ") {
904                let p: Vec<&str> = line.split_whitespace().collect();
905                if p.len() < 4 {
906                    return Err(format!("Vertex line too short: {}", line));
907                }
908                let x: f64 = p[1]
909                    .parse()
910                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
911                let y: f64 = p[2]
912                    .parse()
913                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
914                let z: f64 = p[3]
915                    .parse()
916                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
917                vcmesh.mesh.vertices.push([x, y, z]);
918                let r: f64 = p.get(4).and_then(|v| v.parse().ok()).unwrap_or(1.0);
919                let g: f64 = p.get(5).and_then(|v| v.parse().ok()).unwrap_or(1.0);
920                let b: f64 = p.get(6).and_then(|v| v.parse().ok()).unwrap_or(1.0);
921                let a: f64 = p.get(7).and_then(|v| v.parse().ok()).unwrap_or(1.0);
922                vcmesh.colors.push(ObjVertexColor { r, g, b, a });
923            } else if line.starts_with("vn ") {
924                let p: Vec<&str> = line.split_whitespace().collect();
925                if p.len() >= 4 {
926                    let x: f64 = p[1]
927                        .parse()
928                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
929                    let y: f64 = p[2]
930                        .parse()
931                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
932                    let z: f64 = p[3]
933                        .parse()
934                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
935                    vcmesh.mesh.normals.push([x, y, z]);
936                }
937            } else if line.starts_with("vt ") {
938                let p: Vec<&str> = line.split_whitespace().collect();
939                if p.len() >= 3 {
940                    let u: f64 = p[1]
941                        .parse()
942                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
943                    let v: f64 = p[2]
944                        .parse()
945                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
946                    vcmesh.mesh.uvs.push([u, v]);
947                }
948            } else if line.starts_with("f ") {
949                let p: Vec<&str> = line.split_whitespace().collect();
950                let mut vis = Vec::new();
951                let mut vts = Vec::new();
952                let mut vns = Vec::new();
953                let mut has_vt = false;
954                let mut has_vn = false;
955                for tok in &p[1..] {
956                    let parts: Vec<&str> = tok.split('/').collect();
957                    let vi: usize = parts[0].parse::<usize>().map_err(|e| e.to_string())? - 1;
958                    vis.push(vi);
959                    if parts.len() >= 2 && !parts[1].is_empty() {
960                        has_vt = true;
961                        let vt: usize = parts[1].parse::<usize>().map_err(|e| e.to_string())? - 1;
962                        vts.push(vt);
963                    }
964                    if parts.len() >= 3 && !parts[2].is_empty() {
965                        has_vn = true;
966                        let vn: usize = parts[2].parse::<usize>().map_err(|e| e.to_string())? - 1;
967                        vns.push(vn);
968                    }
969                }
970                vcmesh.mesh.faces.push(ObjFace {
971                    vertex_indices: vis,
972                    normal_indices: if has_vn { Some(vns) } else { None },
973                    uv_indices: if has_vt { Some(vts) } else { None },
974                    smoothing_group,
975                    material: current_material.clone(),
976                });
977            }
978        }
979        if let Some(ref name) = current_group_name {
980            let count = vcmesh.mesh.faces.len() - current_group_start;
981            if count > 0 {
982                vcmesh.mesh.groups.push(ObjGroup {
983                    name: name.clone(),
984                    face_start: current_group_start,
985                    face_count: count,
986                });
987            }
988        }
989        Ok(vcmesh)
990    }
991    /// Serialize this coloured mesh to an OBJ string (with colour extension).
992    pub fn to_obj_str(&self) -> String {
993        let mut s = String::from("# OxiPhysics OBJ export (vertex colours)\n");
994        for (i, v) in self.mesh.vertices.iter().enumerate() {
995            if let Some(c) = self.colors.get(i) {
996                s.push_str(&format!(
997                    "v {} {} {} {} {} {}\n",
998                    v[0], v[1], v[2], c.r, c.g, c.b
999                ));
1000            } else {
1001                s.push_str(&format!("v {} {} {}\n", v[0], v[1], v[2]));
1002            }
1003        }
1004        for vn in &self.mesh.normals {
1005            s.push_str(&format!("vn {} {} {}\n", vn[0], vn[1], vn[2]));
1006        }
1007        for vt in &self.mesh.uvs {
1008            s.push_str(&format!("vt {} {}\n", vt[0], vt[1]));
1009        }
1010        for face in &self.mesh.faces {
1011            s.push('f');
1012            for i in 0..face.vertex_indices.len() {
1013                let vi = face.vertex_indices[i] + 1;
1014                let vt_idx = face.uv_indices.as_ref().map(|uvs| uvs[i] + 1);
1015                let vn_idx = face.normal_indices.as_ref().map(|ns| ns[i] + 1);
1016                let tok = match (vt_idx, vn_idx) {
1017                    (Some(vt), Some(vn)) => format!(" {}/{}/{}", vi, vt, vn),
1018                    (None, Some(vn)) => format!(" {}//{}", vi, vn),
1019                    (Some(vt), None) => format!(" {}/{}", vi, vt),
1020                    (None, None) => format!(" {}", vi),
1021                };
1022                s.push_str(&tok);
1023            }
1024            s.push('\n');
1025        }
1026        s
1027    }
1028}