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
5use super::functions::*;
6
7use crate::{Error, Result};
8use oxiphysics_core::math::Vec3;
9use std::fs::File;
10use std::io::{BufRead, BufReader, BufWriter, Write};
11use std::path::Path;
12
13/// A simple scene graph for OBJ scenes.
14#[derive(Debug, Clone, Default)]
15pub struct ObjScene {
16    /// All nodes in the scene.
17    pub nodes: Vec<ObjSceneNode>,
18    /// All meshes in the scene.
19    pub meshes: Vec<ObjMesh>,
20    /// All materials in the scene.
21    pub materials: Vec<ObjMaterial>,
22}
23impl ObjScene {
24    /// Create an empty scene.
25    pub fn new() -> Self {
26        Self::default()
27    }
28    /// Add a mesh to the scene and return its index.
29    pub fn add_mesh(&mut self, mesh: ObjMesh) -> usize {
30        let idx = self.meshes.len();
31        self.meshes.push(mesh);
32        idx
33    }
34    /// Add a material to the scene and return its index.
35    pub fn add_material(&mut self, mat: ObjMaterial) -> usize {
36        let idx = self.materials.len();
37        self.materials.push(mat);
38        idx
39    }
40    /// Add a node to the scene and return its index.
41    pub fn add_node(
42        &mut self,
43        name: &str,
44        mesh_index: Option<usize>,
45        transform: MeshTransform,
46    ) -> usize {
47        let idx = self.nodes.len();
48        self.nodes.push(ObjSceneNode {
49            name: name.to_string(),
50            transform,
51            mesh_index,
52            children: Vec::new(),
53        });
54        idx
55    }
56    /// Set a node as a child of another node.
57    pub fn add_child(&mut self, parent_idx: usize, child_idx: usize) {
58        if parent_idx < self.nodes.len() {
59            self.nodes[parent_idx].children.push(child_idx);
60        }
61    }
62    /// Flatten the scene hierarchy into a single merged `ObjMesh`.
63    ///
64    /// Each node's mesh is transformed by the node's local transform.
65    pub fn flatten(&self) -> ObjMesh {
66        let mut result = ObjMesh::default();
67        for node in &self.nodes {
68            if let Some(mi) = node.mesh_index
69                && let Some(mesh) = self.meshes.get(mi)
70            {
71                let inst = MeshInstance {
72                    name: node.name.clone(),
73                    transform: node.transform.clone(),
74                };
75                let xformed = instantiate_mesh(mesh, &inst);
76                result = merge_obj_meshes(&result, &xformed);
77            }
78        }
79        result
80    }
81    /// Total number of vertices across all meshes.
82    pub fn total_vertices(&self) -> usize {
83        self.meshes.iter().map(|m| m.vertices.len()).sum()
84    }
85    /// Total number of faces across all meshes.
86    pub fn total_faces(&self) -> usize {
87        self.meshes.iter().map(|m| m.faces.len()).sum()
88    }
89}
90/// A simple OBJ curve (polyline or rational B-spline stub).
91#[derive(Debug, Clone)]
92pub struct ObjCurve {
93    /// Curve name.
94    pub name: String,
95    /// Degree of the curve (1 = polyline).
96    pub degree: usize,
97    /// Control point indices into the vertex array.
98    pub control_points: Vec<usize>,
99    /// Knot vector (empty for polylines).
100    pub knots: Vec<f64>,
101}
102/// Reader for Wavefront OBJ files.
103pub struct ObjReader;
104impl ObjReader {
105    /// Parse an OBJ string into an [`ObjMesh`].
106    pub fn parse(data: &str) -> Result<ObjMesh> {
107        let mut mesh = ObjMesh::default();
108        let mut current_group_name: Option<String> = None;
109        let mut current_group_start: usize = 0;
110        let mut current_smoothing_group: u32 = 0;
111        let mut current_material: Option<String> = None;
112        for raw in data.lines() {
113            let line = raw.trim();
114            if line.is_empty() || line.starts_with('#') {
115                continue;
116            }
117            if let Some(rest) = line.strip_prefix("g ").or_else(|| line.strip_prefix("o ")) {
118                if let Some(ref name) = current_group_name {
119                    let count = mesh.faces.len() - current_group_start;
120                    if count > 0 {
121                        mesh.groups.push(ObjGroup {
122                            name: name.clone(),
123                            face_start: current_group_start,
124                            face_count: count,
125                        });
126                    }
127                }
128                let name = rest.trim().to_string();
129                current_group_name = Some(name);
130                current_group_start = mesh.faces.len();
131            } else if let Some(val) = line.strip_prefix("s ") {
132                let val = val.trim();
133                current_smoothing_group = if val == "off" || val == "0" {
134                    0
135                } else {
136                    val.parse::<u32>().unwrap_or(0)
137                };
138            } else if let Some(rest) = line.strip_prefix("usemtl ") {
139                current_material = Some(rest.trim().to_string());
140            } else {
141                Self::parse_line_extended(
142                    line,
143                    &mut mesh,
144                    current_smoothing_group,
145                    &current_material,
146                )?;
147            }
148        }
149        if let Some(ref name) = current_group_name {
150            let count = mesh.faces.len() - current_group_start;
151            if count > 0 {
152                mesh.groups.push(ObjGroup {
153                    name: name.clone(),
154                    face_start: current_group_start,
155                    face_count: count,
156                });
157            }
158        }
159        Ok(mesh)
160    }
161    fn parse_line_extended(
162        line: &str,
163        mesh: &mut ObjMesh,
164        smoothing_group: u32,
165        material: &Option<String>,
166    ) -> Result<()> {
167        if line.starts_with("vn ") {
168            let p: Vec<&str> = line.split_whitespace().collect();
169            if p.len() >= 4 {
170                let x = p[1]
171                    .parse::<f64>()
172                    .map_err(|e| Error::Parse(e.to_string()))?;
173                let y = p[2]
174                    .parse::<f64>()
175                    .map_err(|e| Error::Parse(e.to_string()))?;
176                let z = p[3]
177                    .parse::<f64>()
178                    .map_err(|e| Error::Parse(e.to_string()))?;
179                mesh.normals.push([x, y, z]);
180            }
181        } else if line.starts_with("vt ") {
182            let p: Vec<&str> = line.split_whitespace().collect();
183            if p.len() >= 3 {
184                let u = p[1]
185                    .parse::<f64>()
186                    .map_err(|e| Error::Parse(e.to_string()))?;
187                let v = p[2]
188                    .parse::<f64>()
189                    .map_err(|e| Error::Parse(e.to_string()))?;
190                mesh.uvs.push([u, v]);
191            }
192        } else if line.starts_with("v ") {
193            let p: Vec<&str> = line.split_whitespace().collect();
194            if p.len() >= 4 {
195                let x = p[1]
196                    .parse::<f64>()
197                    .map_err(|e| Error::Parse(e.to_string()))?;
198                let y = p[2]
199                    .parse::<f64>()
200                    .map_err(|e| Error::Parse(e.to_string()))?;
201                let z = p[3]
202                    .parse::<f64>()
203                    .map_err(|e| Error::Parse(e.to_string()))?;
204                mesh.vertices.push([x, y, z]);
205            }
206        } else if line.starts_with("f ") {
207            let p: Vec<&str> = line.split_whitespace().collect();
208            let mut vis = Vec::new();
209            let mut vts = Vec::new();
210            let mut vns = Vec::new();
211            let mut has_vt = false;
212            let mut has_vn = false;
213            for tok in &p[1..] {
214                let parts: Vec<&str> = tok.split('/').collect();
215                let vi = parts[0]
216                    .parse::<usize>()
217                    .map_err(|e| Error::Parse(e.to_string()))?
218                    - 1;
219                vis.push(vi);
220                if parts.len() >= 2 && !parts[1].is_empty() {
221                    has_vt = true;
222                    let vt = parts[1]
223                        .parse::<usize>()
224                        .map_err(|e| Error::Parse(e.to_string()))?
225                        - 1;
226                    vts.push(vt);
227                }
228                if parts.len() >= 3 && !parts[2].is_empty() {
229                    has_vn = true;
230                    let vn = parts[2]
231                        .parse::<usize>()
232                        .map_err(|e| Error::Parse(e.to_string()))?
233                        - 1;
234                    vns.push(vn);
235                }
236            }
237            mesh.faces.push(ObjFace {
238                vertex_indices: vis,
239                normal_indices: if has_vn { Some(vns) } else { None },
240                uv_indices: if has_vt { Some(vts) } else { None },
241                smoothing_group,
242                material: material.clone(),
243            });
244        }
245        Ok(())
246    }
247    /// Read an OBJ file and parse into an [`ObjMesh`].
248    pub fn from_file(path: &str) -> Result<ObjMesh> {
249        let file = File::open(Path::new(path))?;
250        let reader = BufReader::new(file);
251        let mut data = String::new();
252        for raw in reader.lines() {
253            let raw = raw?;
254            data.push_str(&raw);
255            data.push('\n');
256        }
257        Self::parse(&data)
258    }
259    /// Read vertices and triangle faces from an OBJ file (legacy API).
260    ///
261    /// Returns `(vertices, faces)`. Face indices are converted to 0-based.
262    pub fn read(path: &str) -> Result<(Vec<Vec3>, Vec<[usize; 3]>)> {
263        let mesh = Self::from_file(path)?;
264        let vertices: Vec<Vec3> = mesh
265            .vertices
266            .iter()
267            .map(|v| Vec3::new(v[0], v[1], v[2]))
268            .collect();
269        let faces: Vec<[usize; 3]> = mesh
270            .faces
271            .iter()
272            .filter(|f| f.vertex_indices.len() >= 3)
273            .map(|f| {
274                [
275                    f.vertex_indices[0],
276                    f.vertex_indices[1],
277                    f.vertex_indices[2],
278                ]
279            })
280            .collect();
281        Ok((vertices, faces))
282    }
283}
284/// A simple OBJ material definition.
285#[derive(Debug, Clone)]
286pub struct ObjMaterial {
287    /// Material name.
288    pub name: String,
289    /// Diffuse colour (Kd).
290    pub kd: [f64; 3],
291    /// Specular colour (Ks).
292    pub ks: [f64; 3],
293    /// Shininess exponent (Ns).
294    pub ns: f64,
295    /// Ambient colour (Ka).
296    pub ka: [f64; 3],
297    /// Dissolve / transparency (d, 1.0 = opaque).
298    pub dissolve: f64,
299    /// Diffuse texture map filename.
300    pub map_kd: Option<String>,
301}
302impl ObjMaterial {
303    /// Create a basic material with just a name and diffuse color.
304    pub fn basic(name: &str, kd: [f64; 3]) -> Self {
305        Self {
306            name: name.to_string(),
307            kd,
308            ks: [0.0; 3],
309            ns: 1.0,
310            ka: [0.0; 3],
311            dissolve: 1.0,
312            map_kd: None,
313        }
314    }
315}
316/// A level-of-detail collection: multiple meshes at decreasing resolution.
317#[derive(Debug, Clone, Default)]
318pub struct ObjLod {
319    /// LOD levels, index 0 = highest detail.
320    pub levels: Vec<ObjMesh>,
321    /// Distance thresholds at which to switch to the next LOD level.
322    /// `thresholds[i]` is the max distance for `levels[i]`.
323    pub thresholds: Vec<f64>,
324}
325impl ObjLod {
326    /// Create an empty LOD set.
327    pub fn new() -> Self {
328        Self::default()
329    }
330    /// Add a LOD level with the given switch distance.
331    pub fn push(&mut self, mesh: ObjMesh, threshold: f64) {
332        self.levels.push(mesh);
333        self.thresholds.push(threshold);
334    }
335    /// Select the appropriate LOD level for a viewing distance.
336    pub fn select(&self, distance: f64) -> Option<&ObjMesh> {
337        for (i, &t) in self.thresholds.iter().enumerate() {
338            if distance <= t {
339                return self.levels.get(i);
340            }
341        }
342        self.levels.last()
343    }
344    /// Number of LOD levels.
345    pub fn num_levels(&self) -> usize {
346        self.levels.len()
347    }
348    /// Simplify a mesh to approximately `target_faces` triangles using
349    /// Quadric Error Metric (QEM) edge-collapse.
350    ///
351    /// Implements the Garland–Heckbert (1997) algorithm:
352    /// 1. Compute per-face error quadrics from plane equations.
353    /// 2. Sum quadrics at each vertex.
354    /// 3. For every edge, compute the optimal collapse target position and
355    ///    its associated cost.
356    /// 4. Greedily collapse the minimum-cost edge until `target_faces` is
357    ///    reached, using a lazy-deletion min-heap.
358    pub fn decimate(mesh: &ObjMesh, target_faces: usize) -> ObjMesh {
359        if mesh.faces.len() <= target_faces {
360            return mesh.clone();
361        }
362        decimate_qem_impl(mesh, target_faces)
363    }
364}
365/// A transform applied when instancing a mesh.
366#[derive(Debug, Clone)]
367pub struct MeshTransform {
368    /// Translation vector.
369    pub translation: [f64; 3],
370    /// Uniform scale factor.
371    pub scale: f64,
372    /// Rotation axis (unit vector).
373    pub axis: [f64; 3],
374    /// Rotation angle in radians.
375    pub angle: f64,
376}
377impl MeshTransform {
378    /// Identity transform.
379    pub fn identity() -> Self {
380        Self {
381            translation: [0.0; 3],
382            scale: 1.0,
383            axis: [0.0, 0.0, 1.0],
384            angle: 0.0,
385        }
386    }
387    /// Translation-only transform.
388    pub fn from_translation(tx: f64, ty: f64, tz: f64) -> Self {
389        Self {
390            translation: [tx, ty, tz],
391            scale: 1.0,
392            axis: [0.0, 0.0, 1.0],
393            angle: 0.0,
394        }
395    }
396    /// Apply this transform to a single point.
397    ///
398    /// Applies scale, then rotation (Rodrigues formula), then translation.
399    pub fn apply(&self, p: [f64; 3]) -> [f64; 3] {
400        let s = [p[0] * self.scale, p[1] * self.scale, p[2] * self.scale];
401        let (ax, ay, az) = (self.axis[0], self.axis[1], self.axis[2]);
402        let (sin_a, cos_a) = (self.angle.sin(), self.angle.cos());
403        let dot = ax * s[0] + ay * s[1] + az * s[2];
404        let cross = [
405            ay * s[2] - az * s[1],
406            az * s[0] - ax * s[2],
407            ax * s[1] - ay * s[0],
408        ];
409        let rx = s[0] * cos_a + cross[0] * sin_a + ax * dot * (1.0 - cos_a);
410        let ry = s[1] * cos_a + cross[1] * sin_a + ay * dot * (1.0 - cos_a);
411        let rz = s[2] * cos_a + cross[2] * sin_a + az * dot * (1.0 - cos_a);
412        [
413            rx + self.translation[0],
414            ry + self.translation[1],
415            rz + self.translation[2],
416        ]
417    }
418}
419/// Statistics computed from an `ObjMesh`.
420#[derive(Debug, Clone)]
421pub struct ObjMeshStats {
422    /// Number of vertices.
423    pub vertex_count: usize,
424    /// Number of faces.
425    pub face_count: usize,
426    /// Number of triangles (after fan-triangulation).
427    pub triangle_count: usize,
428    /// Number of unique materials.
429    pub material_count: usize,
430    /// Number of groups.
431    pub group_count: usize,
432    /// Number of faces with normals.
433    pub faces_with_normals: usize,
434    /// Number of faces with UVs.
435    pub faces_with_uvs: usize,
436    /// Surface area (sum of triangle areas).
437    pub surface_area: f64,
438    /// Axis-aligned bounding box (min, max).
439    pub bbox: Option<([f64; 3], [f64; 3])>,
440}
441/// A Wavefront OBJ mesh with vertices, normals, UVs, and faces.
442#[derive(Debug, Clone, Default)]
443pub struct ObjMesh {
444    /// 3D vertex positions.
445    pub vertices: Vec<[f64; 3]>,
446    /// Per-vertex normals.
447    pub normals: Vec<[f64; 3]>,
448    /// UV (texture) coordinates.
449    pub uvs: Vec<[f64; 2]>,
450    /// Face list.
451    pub faces: Vec<ObjFace>,
452    /// Named groups.
453    pub groups: Vec<ObjGroup>,
454}
455impl ObjMesh {
456    /// Extract a flat triangle soup from the mesh.
457    ///
458    /// Each triangle is `[[v0\], [v1], [v2]]` in world-space coordinates.
459    /// Quad faces are triangulated as (0,1,2) and (0,2,3).
460    pub fn to_triangle_soup(&self) -> Vec<[[f64; 3]; 3]> {
461        let mut soup = Vec::new();
462        for face in &self.faces {
463            let verts = &face.vertex_indices;
464            if verts.len() < 3 {
465                continue;
466            }
467            for i in 1..(verts.len() - 1) {
468                let v0 = self.vertices[verts[0]];
469                let v1 = self.vertices[verts[i]];
470                let v2 = self.vertices[verts[i + 1]];
471                soup.push([v0, v1, v2]);
472            }
473        }
474        soup
475    }
476    /// Return faces belonging to a specific group.
477    pub fn faces_in_group(&self, group_name: &str) -> Vec<&ObjFace> {
478        if let Some(group) = self.groups.iter().find(|g| g.name == group_name) {
479            let end = group.face_start + group.face_count;
480            let end = end.min(self.faces.len());
481            self.faces[group.face_start..end].iter().collect()
482        } else {
483            Vec::new()
484        }
485    }
486    /// Return faces belonging to a specific smoothing group.
487    pub fn faces_in_smoothing_group(&self, sg: u32) -> Vec<&ObjFace> {
488        self.faces
489            .iter()
490            .filter(|f| f.smoothing_group == sg)
491            .collect()
492    }
493    /// Return faces with a specific material.
494    pub fn faces_with_material(&self, mat_name: &str) -> Vec<&ObjFace> {
495        self.faces
496            .iter()
497            .filter(|f| f.material.as_deref() == Some(mat_name))
498            .collect()
499    }
500    /// Total number of triangles after fan-triangulation.
501    pub fn triangle_count(&self) -> usize {
502        self.faces
503            .iter()
504            .map(|f| {
505                if f.vertex_indices.len() >= 3 {
506                    f.vertex_indices.len() - 2
507                } else {
508                    0
509                }
510            })
511            .sum()
512    }
513    /// Compute flat face normal for a triangular face.
514    pub fn face_normal(&self, face_idx: usize) -> Option<[f64; 3]> {
515        let face = self.faces.get(face_idx)?;
516        if face.vertex_indices.len() < 3 {
517            return None;
518        }
519        let v0 = self.vertices[face.vertex_indices[0]];
520        let v1 = self.vertices[face.vertex_indices[1]];
521        let v2 = self.vertices[face.vertex_indices[2]];
522        let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
523        let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
524        let n = [
525            e1[1] * e2[2] - e1[2] * e2[1],
526            e1[2] * e2[0] - e1[0] * e2[2],
527            e1[0] * e2[1] - e1[1] * e2[0],
528        ];
529        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
530        if len < 1e-30 {
531            return None;
532        }
533        Some([n[0] / len, n[1] / len, n[2] / len])
534    }
535    /// Compute axis-aligned bounding box of all vertices.
536    pub fn bounding_box(&self) -> Option<([f64; 3], [f64; 3])> {
537        if self.vertices.is_empty() {
538            return None;
539        }
540        let mut min = self.vertices[0];
541        let mut max = self.vertices[0];
542        for v in &self.vertices[1..] {
543            for k in 0..3 {
544                if v[k] < min[k] {
545                    min[k] = v[k];
546                }
547                if v[k] > max[k] {
548                    max[k] = v[k];
549                }
550            }
551        }
552        Some((min, max))
553    }
554}
555/// An instance of an `ObjMesh` with a transform and optional name override.
556#[derive(Debug, Clone)]
557pub struct MeshInstance {
558    /// Name of this instance.
559    pub name: String,
560    /// Transform to apply.
561    pub transform: MeshTransform,
562}
563/// A named group/object within an OBJ file.
564#[derive(Debug, Clone)]
565pub struct ObjGroup {
566    /// Group or object name.
567    pub name: String,
568    /// Index of the first face belonging to this group (into the mesh face list).
569    pub face_start: usize,
570    /// Number of faces in this group.
571    pub face_count: usize,
572}
573/// A single face in a Wavefront OBJ mesh.
574///
575/// All index arrays are optional to support the various OBJ face formats:
576/// `f v`, `f v/vt`, `f v//vn`, `f v/vt/vn`.
577#[derive(Debug, Clone)]
578pub struct ObjFace {
579    /// Vertex indices (0-based).
580    pub vertex_indices: Vec<usize>,
581    /// Normal indices (0-based), if present.
582    pub normal_indices: Option<Vec<usize>>,
583    /// UV/texture-coordinate indices (0-based), if present.
584    pub uv_indices: Option<Vec<usize>>,
585    /// Smoothing group this face belongs to (0 = off).
586    pub smoothing_group: u32,
587    /// Material name assigned to this face.
588    pub material: Option<String>,
589}
590/// Writer for `ObjMesh` structures.
591pub struct ObjWriter;
592impl ObjWriter {
593    /// Serialize an `ObjMesh` to a Wavefront OBJ string.
594    pub fn write(mesh: &ObjMesh) -> String {
595        Self::write_with_groups(mesh, false)
596    }
597    /// Serialize an `ObjMesh` with optional group headers.
598    pub fn write_with_groups(mesh: &ObjMesh, emit_groups: bool) -> String {
599        let mut s = String::from("# OxiPhysics OBJ export\n");
600        for v in &mesh.vertices {
601            s.push_str(&format!("v {} {} {}\n", v[0], v[1], v[2]));
602        }
603        for vn in &mesh.normals {
604            s.push_str(&format!("vn {} {} {}\n", vn[0], vn[1], vn[2]));
605        }
606        for vt in &mesh.uvs {
607            s.push_str(&format!("vt {} {}\n", vt[0], vt[1]));
608        }
609        let mut current_group: Option<&str> = None;
610        let mut current_material: Option<&str> = None;
611        let mut current_sg: u32 = 0;
612        for (fi, face) in mesh.faces.iter().enumerate() {
613            if emit_groups {
614                for group in &mesh.groups {
615                    if fi == group.face_start && current_group != Some(&group.name) {
616                        s.push_str(&format!("g {}\n", group.name));
617                        current_group = Some(&group.name);
618                    }
619                }
620            }
621            if let Some(ref mat) = face.material
622                && current_material != Some(mat.as_str())
623            {
624                s.push_str(&format!("usemtl {}\n", mat));
625                current_material = Some(mat);
626            }
627            if face.smoothing_group != current_sg {
628                current_sg = face.smoothing_group;
629                if current_sg == 0 {
630                    s.push_str("s off\n");
631                } else {
632                    s.push_str(&format!("s {}\n", current_sg));
633                }
634            }
635            s.push('f');
636            for i in 0..face.vertex_indices.len() {
637                let vi = face.vertex_indices[i] + 1;
638                let vt_idx = face.uv_indices.as_ref().map(|uvs| uvs[i] + 1);
639                let vn_idx = face.normal_indices.as_ref().map(|ns| ns[i] + 1);
640                let token = match (vt_idx, vn_idx) {
641                    (Some(vt), Some(vn)) => format!(" {}/{}/{}", vi, vt, vn),
642                    (None, Some(vn)) => format!(" {}//{}", vi, vn),
643                    (Some(vt), None) => format!(" {}/{}", vi, vt),
644                    (None, None) => format!(" {}", vi),
645                };
646                s.push_str(&token);
647            }
648            s.push('\n');
649        }
650        s
651    }
652    /// Write an `ObjMesh` directly to a file at `path`.
653    pub fn write_to_file(path: &str, mesh: &ObjMesh) -> Result<()> {
654        let file = File::create(Path::new(path))?;
655        let mut w = BufWriter::new(file);
656        write!(w, "{}", Self::write(mesh))?;
657        w.flush()?;
658        Ok(())
659    }
660    /// Write a triangle mesh (legacy API -- vertices + triangle index arrays).
661    ///
662    /// Optionally includes per-vertex normals.
663    pub fn write_legacy(
664        path: &str,
665        vertices: &[Vec3],
666        triangles: &[[usize; 3]],
667        normals: Option<&[Vec3]>,
668    ) -> Result<()> {
669        let file = File::create(Path::new(path))?;
670        let mut w = BufWriter::new(file);
671        writeln!(w, "# OxiPhysics OBJ export")?;
672        for v in vertices {
673            writeln!(w, "v {} {} {}", v.x, v.y, v.z)?;
674        }
675        if let Some(norms) = normals {
676            for n in norms {
677                writeln!(w, "vn {} {} {}", n.x, n.y, n.z)?;
678            }
679            for t in triangles {
680                writeln!(
681                    w,
682                    "f {}//{} {}//{} {}//{}",
683                    t[0] + 1,
684                    t[0] + 1,
685                    t[1] + 1,
686                    t[1] + 1,
687                    t[2] + 1,
688                    t[2] + 1,
689                )?;
690            }
691        } else {
692            for t in triangles {
693                writeln!(w, "f {} {} {}", t[0] + 1, t[1] + 1, t[2] + 1)?;
694            }
695        }
696        w.flush()?;
697        Ok(())
698    }
699    /// Write a mesh with texture coordinates (legacy API).
700    pub fn write_with_uvs(
701        path: &str,
702        vertices: &[Vec3],
703        uvs: &[[f64; 2]],
704        triangles: &[[usize; 3]],
705    ) -> Result<()> {
706        let file = File::create(Path::new(path))?;
707        let mut w = BufWriter::new(file);
708        writeln!(w, "# OxiPhysics OBJ export")?;
709        for v in vertices {
710            writeln!(w, "v {} {} {}", v.x, v.y, v.z)?;
711        }
712        for uv in uvs {
713            writeln!(w, "vt {} {}", uv[0], uv[1])?;
714        }
715        for t in triangles {
716            writeln!(
717                w,
718                "f {}/{} {}/{} {}/{}",
719                t[0] + 1,
720                t[0] + 1,
721                t[1] + 1,
722                t[1] + 1,
723                t[2] + 1,
724                t[2] + 1,
725            )?;
726        }
727        w.flush()?;
728        Ok(())
729    }
730}
731/// A node in an OBJ scene hierarchy.
732#[derive(Debug, Clone)]
733pub struct ObjSceneNode {
734    /// Name of this node.
735    pub name: String,
736    /// Local transform.
737    pub transform: MeshTransform,
738    /// Mesh index (if this node has geometry).
739    pub mesh_index: Option<usize>,
740    /// Child node indices.
741    pub children: Vec<usize>,
742}
743/// Per-vertex RGBA colour.
744#[derive(Debug, Clone, Copy, PartialEq)]
745pub struct ObjVertexColor {
746    /// Red channel \[0, 1\].
747    pub r: f64,
748    /// Green channel \[0, 1\].
749    pub g: f64,
750    /// Blue channel \[0, 1\].
751    pub b: f64,
752    /// Alpha channel \[0, 1\].
753    pub a: f64,
754}
755impl ObjVertexColor {
756    /// Construct a colour from (r, g, b, a).
757    pub fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
758        Self { r, g, b, a }
759    }
760    /// Construct an opaque colour from (r, g, b).
761    pub fn rgb(r: f64, g: f64, b: f64) -> Self {
762        Self { r, g, b, a: 1.0 }
763    }
764    /// Return as `[r, g, b, a]` array.
765    pub fn to_array(self) -> [f64; 4] {
766        [self.r, self.g, self.b, self.a]
767    }
768    /// Blend two colours with weight `t ∈ [0,1]`.
769    pub fn lerp(self, other: Self, t: f64) -> Self {
770        Self {
771            r: self.r + (other.r - self.r) * t,
772            g: self.g + (other.g - self.g) * t,
773            b: self.b + (other.b - self.b) * t,
774            a: self.a + (other.a - self.a) * t,
775        }
776    }
777}
778/// A simple OBJ surface (tensor-product B-spline stub).
779#[derive(Debug, Clone)]
780pub struct ObjSurface {
781    /// Surface name.
782    pub name: String,
783    /// Degree in u direction.
784    pub degree_u: usize,
785    /// Degree in v direction.
786    pub degree_v: usize,
787    /// Control point indices (row-major, n_u x n_v).
788    pub control_points: Vec<usize>,
789    /// Number of control points in u direction.
790    pub n_u: usize,
791    /// Knot vector in u direction.
792    pub knots_u: Vec<f64>,
793    /// Knot vector in v direction.
794    pub knots_v: Vec<f64>,
795}
796/// Writer for Wavefront MTL material library files.
797pub struct MtlWriter;
798impl MtlWriter {
799    /// Generate an MTL file string for the given materials.
800    pub fn write(materials: &[ObjMaterial]) -> String {
801        let mut s = String::from("# OxiPhysics MTL export\n");
802        for mat in materials {
803            s.push_str(&format!("\nnewmtl {}\n", mat.name));
804            s.push_str(&format!("Ka {} {} {}\n", mat.ka[0], mat.ka[1], mat.ka[2]));
805            s.push_str(&format!("Kd {} {} {}\n", mat.kd[0], mat.kd[1], mat.kd[2]));
806            s.push_str(&format!("Ks {} {} {}\n", mat.ks[0], mat.ks[1], mat.ks[2]));
807            s.push_str(&format!("Ns {}\n", mat.ns));
808            s.push_str(&format!("d {}\n", mat.dissolve));
809            if let Some(ref tex) = mat.map_kd {
810                s.push_str(&format!("map_Kd {}\n", tex));
811            }
812        }
813        s
814    }
815}
816/// OBJ mesh extended with per-vertex colours (non-standard OBJ extension).
817///
818/// Some exporters write vertex colours as extra columns on `v` lines:
819/// `v x y z r g b` or `v x y z r g b a`.
820#[derive(Debug, Clone, Default)]
821pub struct ObjVertexColorMesh {
822    /// Underlying geometry.
823    pub mesh: ObjMesh,
824    /// Per-vertex colours (same length as `mesh.vertices` if present).
825    pub colors: Vec<ObjVertexColor>,
826}
827impl ObjVertexColorMesh {
828    /// Parse an OBJ string that may contain vertex colour data.
829    pub fn parse(data: &str) -> std::result::Result<Self, String> {
830        let mut vcmesh = ObjVertexColorMesh::default();
831        let mut smoothing_group: u32 = 0;
832        let mut current_material: Option<String> = None;
833        let mut current_group_name: Option<String> = None;
834        let mut current_group_start: usize = 0;
835        for raw in data.lines() {
836            let line = raw.trim();
837            if line.is_empty() || line.starts_with('#') {
838                continue;
839            }
840            if let Some(rest) = line.strip_prefix("g ").or_else(|| line.strip_prefix("o ")) {
841                if let Some(ref name) = current_group_name {
842                    let count = vcmesh.mesh.faces.len() - current_group_start;
843                    if count > 0 {
844                        vcmesh.mesh.groups.push(ObjGroup {
845                            name: name.clone(),
846                            face_start: current_group_start,
847                            face_count: count,
848                        });
849                    }
850                }
851                current_group_name = Some(rest.trim().to_string());
852                current_group_start = vcmesh.mesh.faces.len();
853            } else if let Some(rest) = line.strip_prefix("usemtl ") {
854                current_material = Some(rest.trim().to_string());
855            } else if let Some(val_str) = line.strip_prefix("s ") {
856                let val = val_str.trim();
857                smoothing_group = if val == "off" || val == "0" {
858                    0
859                } else {
860                    val.parse::<u32>().unwrap_or(0)
861                };
862            } else if line.starts_with("v ") {
863                let p: Vec<&str> = line.split_whitespace().collect();
864                if p.len() < 4 {
865                    return Err(format!("Vertex line too short: {}", line));
866                }
867                let x: f64 = p[1]
868                    .parse()
869                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
870                let y: f64 = p[2]
871                    .parse()
872                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
873                let z: f64 = p[3]
874                    .parse()
875                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
876                vcmesh.mesh.vertices.push([x, y, z]);
877                let r: f64 = p.get(4).and_then(|v| v.parse().ok()).unwrap_or(1.0);
878                let g: f64 = p.get(5).and_then(|v| v.parse().ok()).unwrap_or(1.0);
879                let b: f64 = p.get(6).and_then(|v| v.parse().ok()).unwrap_or(1.0);
880                let a: f64 = p.get(7).and_then(|v| v.parse().ok()).unwrap_or(1.0);
881                vcmesh.colors.push(ObjVertexColor { r, g, b, a });
882            } else if line.starts_with("vn ") {
883                let p: Vec<&str> = line.split_whitespace().collect();
884                if p.len() >= 4 {
885                    let x: f64 = p[1]
886                        .parse()
887                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
888                    let y: f64 = p[2]
889                        .parse()
890                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
891                    let z: f64 = p[3]
892                        .parse()
893                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
894                    vcmesh.mesh.normals.push([x, y, z]);
895                }
896            } else if line.starts_with("vt ") {
897                let p: Vec<&str> = line.split_whitespace().collect();
898                if p.len() >= 3 {
899                    let u: f64 = p[1]
900                        .parse()
901                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
902                    let v: f64 = p[2]
903                        .parse()
904                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
905                    vcmesh.mesh.uvs.push([u, v]);
906                }
907            } else if line.starts_with("f ") {
908                let p: Vec<&str> = line.split_whitespace().collect();
909                let mut vis = Vec::new();
910                let mut vts = Vec::new();
911                let mut vns = Vec::new();
912                let mut has_vt = false;
913                let mut has_vn = false;
914                for tok in &p[1..] {
915                    let parts: Vec<&str> = tok.split('/').collect();
916                    let vi: usize = parts[0].parse::<usize>().map_err(|e| e.to_string())? - 1;
917                    vis.push(vi);
918                    if parts.len() >= 2 && !parts[1].is_empty() {
919                        has_vt = true;
920                        let vt: usize = parts[1].parse::<usize>().map_err(|e| e.to_string())? - 1;
921                        vts.push(vt);
922                    }
923                    if parts.len() >= 3 && !parts[2].is_empty() {
924                        has_vn = true;
925                        let vn: usize = parts[2].parse::<usize>().map_err(|e| e.to_string())? - 1;
926                        vns.push(vn);
927                    }
928                }
929                vcmesh.mesh.faces.push(ObjFace {
930                    vertex_indices: vis,
931                    normal_indices: if has_vn { Some(vns) } else { None },
932                    uv_indices: if has_vt { Some(vts) } else { None },
933                    smoothing_group,
934                    material: current_material.clone(),
935                });
936            }
937        }
938        if let Some(ref name) = current_group_name {
939            let count = vcmesh.mesh.faces.len() - current_group_start;
940            if count > 0 {
941                vcmesh.mesh.groups.push(ObjGroup {
942                    name: name.clone(),
943                    face_start: current_group_start,
944                    face_count: count,
945                });
946            }
947        }
948        Ok(vcmesh)
949    }
950    /// Serialize this coloured mesh to an OBJ string (with colour extension).
951    pub fn to_obj_str(&self) -> String {
952        let mut s = String::from("# OxiPhysics OBJ export (vertex colours)\n");
953        for (i, v) in self.mesh.vertices.iter().enumerate() {
954            if let Some(c) = self.colors.get(i) {
955                s.push_str(&format!(
956                    "v {} {} {} {} {} {}\n",
957                    v[0], v[1], v[2], c.r, c.g, c.b
958                ));
959            } else {
960                s.push_str(&format!("v {} {} {}\n", v[0], v[1], v[2]));
961            }
962        }
963        for vn in &self.mesh.normals {
964            s.push_str(&format!("vn {} {} {}\n", vn[0], vn[1], vn[2]));
965        }
966        for vt in &self.mesh.uvs {
967            s.push_str(&format!("vt {} {}\n", vt[0], vt[1]));
968        }
969        for face in &self.mesh.faces {
970            s.push('f');
971            for i in 0..face.vertex_indices.len() {
972                let vi = face.vertex_indices[i] + 1;
973                let vt_idx = face.uv_indices.as_ref().map(|uvs| uvs[i] + 1);
974                let vn_idx = face.normal_indices.as_ref().map(|ns| ns[i] + 1);
975                let tok = match (vt_idx, vn_idx) {
976                    (Some(vt), Some(vn)) => format!(" {}/{}/{}", vi, vt, vn),
977                    (None, Some(vn)) => format!(" {}//{}", vi, vn),
978                    (Some(vt), None) => format!(" {}/{}", vi, vt),
979                    (None, None) => format!(" {}", vi),
980                };
981                s.push_str(&tok);
982            }
983            s.push('\n');
984        }
985        s
986    }
987}
988
989impl std::str::FromStr for ObjVertexColorMesh {
990    type Err = String;
991    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
992        Self::parse(s)
993    }
994}
995
996// ─────────────────────────────────────────────────────────────────────────────
997// QEM (Quadric Error Metric) mesh decimation β€” Garland & Heckbert 1997
998// ─────────────────────────────────────────────────────────────────────────────
999
1000/// Symmetric 4Γ—4 error quadric stored as the 10-element upper triangle.
1001///
1002/// Layout: `[a2, ab, ac, ad, b2, bc, bd, c2, cd, d2]` where the plane is
1003/// `ax + by + cz + d = 0`.
1004#[derive(Clone, Copy)]
1005struct Quadric {
1006    m: [f64; 10],
1007}
1008
1009impl Quadric {
1010    const ZERO: Self = Self { m: [0.0; 10] };
1011
1012    /// Build the fundamental-error quadric `K_p = pp^T` for plane `(a,b,c,d)`.
1013    fn from_plane(a: f64, b: f64, c: f64, d: f64) -> Self {
1014        Self {
1015            m: [
1016                a * a,
1017                a * b,
1018                a * c,
1019                a * d,
1020                b * b,
1021                b * c,
1022                b * d,
1023                c * c,
1024                c * d,
1025                d * d,
1026            ],
1027        }
1028    }
1029
1030    /// Add two quadrics (element-wise).
1031    fn add(&self, other: &Self) -> Self {
1032        let mut m = [0.0f64; 10];
1033        for (i, val) in m.iter_mut().enumerate() {
1034            *val = self.m[i] + other.m[i];
1035        }
1036        Self { m }
1037    }
1038
1039    /// Evaluate the quadric error at position `p`.
1040    fn eval(&self, p: [f64; 3]) -> f64 {
1041        let [x, y, z] = p;
1042        let m = &self.m;
1043        m[0] * x * x
1044            + 2.0 * m[1] * x * y
1045            + 2.0 * m[2] * x * z
1046            + 2.0 * m[3] * x
1047            + m[4] * y * y
1048            + 2.0 * m[5] * y * z
1049            + 2.0 * m[6] * y
1050            + m[7] * z * z
1051            + 2.0 * m[8] * z
1052            + m[9]
1053    }
1054
1055    /// Solve for the optimal collapse position using Cramer's rule on the
1056    /// upper-left 3Γ—3 block.  Falls back to `midpoint` for singular systems.
1057    fn optimal_pos(&self, midpoint: [f64; 3]) -> [f64; 3] {
1058        let m = &self.m;
1059        let a00 = m[0];
1060        let a01 = m[1];
1061        let a02 = m[2];
1062        let a11 = m[4];
1063        let a12 = m[5];
1064        let a22 = m[7];
1065        let bx = -m[3];
1066        let by = -m[6];
1067        let bz = -m[8];
1068
1069        let det = a00 * (a11 * a22 - a12 * a12) - a01 * (a01 * a22 - a12 * a02)
1070            + a02 * (a01 * a12 - a11 * a02);
1071
1072        if det.abs() < 1.0e-12 {
1073            return midpoint;
1074        }
1075        let inv = 1.0 / det;
1076        let x = inv
1077            * (bx * (a11 * a22 - a12 * a12) - a01 * (by * a22 - a12 * bz)
1078                + a02 * (by * a12 - a11 * bz));
1079        let y = inv
1080            * (a00 * (by * a22 - a12 * bz) - bx * (a01 * a22 - a12 * a02)
1081                + a02 * (a01 * bz - by * a02));
1082        let z = inv
1083            * (a00 * (a11 * bz - by * a12) - a01 * (a01 * bz - by * a02)
1084                + bx * (a01 * a12 - a11 * a02));
1085        [x, y, z]
1086    }
1087}
1088
1089/// Entry in the collapse priority queue.
1090#[derive(Clone)]
1091struct EdgeEntry {
1092    cost: f64,
1093    v0: usize,
1094    v1: usize,
1095    pos: [f64; 3],
1096    /// Epoch stamp for lazy deletion: entry is stale when it no longer
1097    /// matches `vertex_epochs[v0] ^ (vertex_epochs[v1] << 32)`.
1098    epoch: u64,
1099}
1100
1101impl PartialEq for EdgeEntry {
1102    fn eq(&self, other: &Self) -> bool {
1103        self.cost == other.cost
1104    }
1105}
1106impl Eq for EdgeEntry {}
1107impl PartialOrd for EdgeEntry {
1108    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1109        Some(self.cmp(other))
1110    }
1111}
1112impl Ord for EdgeEntry {
1113    /// Reverse order so `BinaryHeap` acts as a min-heap.
1114    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1115        other
1116            .cost
1117            .partial_cmp(&self.cost)
1118            .unwrap_or(std::cmp::Ordering::Equal)
1119    }
1120}
1121
1122/// Core QEM decimation implementation.
1123fn decimate_qem_impl(mesh: &ObjMesh, target_faces: usize) -> ObjMesh {
1124    use std::collections::{BinaryHeap, HashSet};
1125
1126    let nv = mesh.vertices.len();
1127    if nv == 0 || mesh.faces.is_empty() {
1128        return mesh.clone();
1129    }
1130
1131    // ── 1. Collect triangles ─────────────────────────────────────────────────
1132    let mut triangles: Vec<[usize; 3]> = Vec::new();
1133    for face in &mesh.faces {
1134        let vi = &face.vertex_indices;
1135        if vi.len() < 3 {
1136            continue;
1137        }
1138        for k in 1..(vi.len() - 1) {
1139            if vi[0] < nv && vi[k] < nv && vi[k + 1] < nv {
1140                triangles.push([vi[0], vi[k], vi[k + 1]]);
1141            }
1142        }
1143    }
1144    if triangles.is_empty() {
1145        return mesh.clone();
1146    }
1147
1148    // ── 2. Compute per-vertex quadrics ───────────────────────────────────────
1149    let mut quadrics = vec![Quadric::ZERO; nv];
1150    for &[i0, i1, i2] in &triangles {
1151        let v0 = mesh.vertices[i0];
1152        let v1 = mesh.vertices[i1];
1153        let v2 = mesh.vertices[i2];
1154        let ex = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
1155        let fy = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
1156        let nx = ex[1] * fy[2] - ex[2] * fy[1];
1157        let ny = ex[2] * fy[0] - ex[0] * fy[2];
1158        let nz = ex[0] * fy[1] - ex[1] * fy[0];
1159        let len = (nx * nx + ny * ny + nz * nz).sqrt();
1160        if len < 1.0e-15 {
1161            continue;
1162        }
1163        let (a, b, c) = (nx / len, ny / len, nz / len);
1164        let d = -(a * v0[0] + b * v0[1] + c * v0[2]);
1165        let q = Quadric::from_plane(a, b, c, d);
1166        quadrics[i0] = quadrics[i0].add(&q);
1167        quadrics[i1] = quadrics[i1].add(&q);
1168        quadrics[i2] = quadrics[i2].add(&q);
1169    }
1170
1171    // ── 3. Collect unique undirected edges ───────────────────────────────────
1172    let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
1173    for &[i0, i1, i2] in &triangles {
1174        for (a, b) in [(i0, i1), (i1, i2), (i0, i2)] {
1175            edge_set.insert((a.min(b), a.max(b)));
1176        }
1177    }
1178
1179    // ── 4. State arrays ──────────────────────────────────────────────────────
1180    let mut vertices: Vec<Option<[f64; 3]>> = mesh.vertices.iter().map(|&v| Some(v)).collect();
1181    let mut vertex_epochs: Vec<u64> = vec![0u64; nv];
1182    let mut remap: Vec<usize> = (0..nv).collect();
1183    let mut heap: BinaryHeap<EdgeEntry> = BinaryHeap::new();
1184    let mut faces: Vec<Option<[usize; 3]>> = triangles.iter().map(|&t| Some(t)).collect();
1185    let mut active_face_count = faces.len();
1186
1187    // ── Helper: resolve remap chain ──────────────────────────────────────────
1188    fn resolve(remap: &[usize], mut v: usize) -> usize {
1189        while remap[v] != v {
1190            v = remap[v];
1191        }
1192        v
1193    }
1194
1195    // ── Helper: push an edge into the heap ───────────────────────────────────
1196    let push_edge = |heap: &mut BinaryHeap<EdgeEntry>,
1197                     v0: usize,
1198                     v1: usize,
1199                     qv: &[Quadric],
1200                     verts: &[Option<[f64; 3]>],
1201                     ve: &[u64]| {
1202        let Some(p0) = verts[v0] else { return };
1203        let Some(p1) = verts[v1] else { return };
1204        let combined = qv[v0].add(&qv[v1]);
1205        let mid = [
1206            (p0[0] + p1[0]) * 0.5,
1207            (p0[1] + p1[1]) * 0.5,
1208            (p0[2] + p1[2]) * 0.5,
1209        ];
1210        let pos = combined.optimal_pos(mid);
1211        let cost = combined.eval(pos);
1212        heap.push(EdgeEntry {
1213            cost,
1214            v0,
1215            v1,
1216            pos,
1217            epoch: ve[v0] ^ (ve[v1] << 32),
1218        });
1219    };
1220
1221    for &(a, b) in &edge_set {
1222        push_edge(&mut heap, a, b, &quadrics, &vertices, &vertex_epochs);
1223    }
1224
1225    // ── 5. Greedy collapse loop ───────────────────────────────────────────────
1226    while active_face_count > target_faces {
1227        let entry = match heap.pop() {
1228            Some(e) => e,
1229            None => break,
1230        };
1231        let v0 = resolve(&remap, entry.v0);
1232        let v1 = resolve(&remap, entry.v1);
1233        if v0 == v1 {
1234            continue;
1235        }
1236        if entry.epoch != vertex_epochs[v0] ^ (vertex_epochs[v1] << 32) {
1237            continue;
1238        }
1239        if vertices[v0].is_none() || vertices[v1].is_none() {
1240            continue;
1241        }
1242
1243        // Collapse v1 into v0.
1244        vertices[v0] = Some(entry.pos);
1245        vertices[v1] = None;
1246        quadrics[v0] = quadrics[v0].add(&quadrics[v1]);
1247        remap[v1] = v0;
1248        vertex_epochs[v0] += 1;
1249        vertex_epochs[v1] += 1;
1250
1251        // Degenerate face removal.
1252        for face in faces.iter_mut() {
1253            let tri = match face {
1254                Some(t) => t,
1255                None => continue,
1256            };
1257            let mut changed = false;
1258            for idx in tri.iter_mut() {
1259                let r = resolve(&remap, *idx);
1260                if r != *idx {
1261                    *idx = r;
1262                    changed = true;
1263                }
1264            }
1265            if changed && (tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2]) {
1266                *face = None;
1267                active_face_count -= 1;
1268            }
1269        }
1270
1271        // Re-queue edges incident to the surviving vertex.
1272        for &(a, b) in &edge_set {
1273            let ra = resolve(&remap, a);
1274            let rb = resolve(&remap, b);
1275            if ra == rb {
1276                continue;
1277            }
1278            if ra == v0 || rb == v0 {
1279                push_edge(&mut heap, ra, rb, &quadrics, &vertices, &vertex_epochs);
1280            }
1281        }
1282    }
1283
1284    // ── 6. Rebuild ObjMesh ───────────────────────────────────────────────────
1285    let mut new_vertices: Vec<[f64; 3]> = Vec::new();
1286    let mut old_to_new: Vec<Option<usize>> = vec![None; nv];
1287    for (i, v) in vertices.iter().enumerate() {
1288        if let Some(pos) = v {
1289            old_to_new[i] = Some(new_vertices.len());
1290            new_vertices.push(*pos);
1291        }
1292    }
1293
1294    let mut new_faces: Vec<ObjFace> = Vec::new();
1295    for face in faces.iter().flatten() {
1296        let a = resolve(&remap, face[0]);
1297        let b = resolve(&remap, face[1]);
1298        let c = resolve(&remap, face[2]);
1299        if a == b || b == c || a == c {
1300            continue;
1301        }
1302        let Some(na) = old_to_new[a] else { continue };
1303        let Some(nb) = old_to_new[b] else { continue };
1304        let Some(nc) = old_to_new[c] else { continue };
1305        new_faces.push(ObjFace {
1306            vertex_indices: vec![na, nb, nc],
1307            normal_indices: None,
1308            uv_indices: None,
1309            smoothing_group: 0,
1310            material: None,
1311        });
1312        if new_faces.len() >= target_faces {
1313            break;
1314        }
1315    }
1316
1317    ObjMesh {
1318        vertices: new_vertices,
1319        normals: Vec::new(),
1320        uvs: Vec::new(),
1321        faces: new_faces,
1322        groups: Vec::new(),
1323    }
1324}