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 using
362    /// Quadric Error Metric (QEM) edge-collapse.
363    ///
364    /// Implements the Garland–Heckbert (1997) algorithm:
365    /// 1. Compute per-face error quadrics from plane equations.
366    /// 2. Sum quadrics at each vertex.
367    /// 3. For every edge, compute the optimal collapse target position and
368    ///    its associated cost.
369    /// 4. Greedily collapse the minimum-cost edge until `target_faces` is
370    ///    reached, using a lazy-deletion min-heap.
371    pub fn decimate(mesh: &ObjMesh, target_faces: usize) -> ObjMesh {
372        if mesh.faces.len() <= target_faces {
373            return mesh.clone();
374        }
375        decimate_qem_impl(mesh, target_faces)
376    }
377}
378/// A transform applied when instancing a mesh.
379#[allow(dead_code)]
380#[derive(Debug, Clone)]
381pub struct MeshTransform {
382    /// Translation vector.
383    pub translation: [f64; 3],
384    /// Uniform scale factor.
385    pub scale: f64,
386    /// Rotation axis (unit vector).
387    pub axis: [f64; 3],
388    /// Rotation angle in radians.
389    pub angle: f64,
390}
391#[allow(dead_code)]
392impl MeshTransform {
393    /// Identity transform.
394    pub fn identity() -> Self {
395        Self {
396            translation: [0.0; 3],
397            scale: 1.0,
398            axis: [0.0, 0.0, 1.0],
399            angle: 0.0,
400        }
401    }
402    /// Translation-only transform.
403    pub fn from_translation(tx: f64, ty: f64, tz: f64) -> Self {
404        Self {
405            translation: [tx, ty, tz],
406            scale: 1.0,
407            axis: [0.0, 0.0, 1.0],
408            angle: 0.0,
409        }
410    }
411    /// Apply this transform to a single point.
412    ///
413    /// Applies scale, then rotation (Rodrigues formula), then translation.
414    pub fn apply(&self, p: [f64; 3]) -> [f64; 3] {
415        let s = [p[0] * self.scale, p[1] * self.scale, p[2] * self.scale];
416        let (ax, ay, az) = (self.axis[0], self.axis[1], self.axis[2]);
417        let (sin_a, cos_a) = (self.angle.sin(), self.angle.cos());
418        let dot = ax * s[0] + ay * s[1] + az * s[2];
419        let cross = [
420            ay * s[2] - az * s[1],
421            az * s[0] - ax * s[2],
422            ax * s[1] - ay * s[0],
423        ];
424        let rx = s[0] * cos_a + cross[0] * sin_a + ax * dot * (1.0 - cos_a);
425        let ry = s[1] * cos_a + cross[1] * sin_a + ay * dot * (1.0 - cos_a);
426        let rz = s[2] * cos_a + cross[2] * sin_a + az * dot * (1.0 - cos_a);
427        [
428            rx + self.translation[0],
429            ry + self.translation[1],
430            rz + self.translation[2],
431        ]
432    }
433}
434/// Statistics computed from an `ObjMesh`.
435#[allow(dead_code)]
436#[derive(Debug, Clone)]
437pub struct ObjMeshStats {
438    /// Number of vertices.
439    pub vertex_count: usize,
440    /// Number of faces.
441    pub face_count: usize,
442    /// Number of triangles (after fan-triangulation).
443    pub triangle_count: usize,
444    /// Number of unique materials.
445    pub material_count: usize,
446    /// Number of groups.
447    pub group_count: usize,
448    /// Number of faces with normals.
449    pub faces_with_normals: usize,
450    /// Number of faces with UVs.
451    pub faces_with_uvs: usize,
452    /// Surface area (sum of triangle areas).
453    pub surface_area: f64,
454    /// Axis-aligned bounding box (min, max).
455    pub bbox: Option<([f64; 3], [f64; 3])>,
456}
457/// A Wavefront OBJ mesh with vertices, normals, UVs, and faces.
458#[allow(dead_code)]
459#[derive(Debug, Clone, Default)]
460pub struct ObjMesh {
461    /// 3D vertex positions.
462    pub vertices: Vec<[f64; 3]>,
463    /// Per-vertex normals.
464    pub normals: Vec<[f64; 3]>,
465    /// UV (texture) coordinates.
466    pub uvs: Vec<[f64; 2]>,
467    /// Face list.
468    pub faces: Vec<ObjFace>,
469    /// Named groups.
470    pub groups: Vec<ObjGroup>,
471}
472#[allow(dead_code)]
473impl ObjMesh {
474    /// Extract a flat triangle soup from the mesh.
475    ///
476    /// Each triangle is `[[v0\], [v1], [v2]]` in world-space coordinates.
477    /// Quad faces are triangulated as (0,1,2) and (0,2,3).
478    pub fn to_triangle_soup(&self) -> Vec<[[f64; 3]; 3]> {
479        let mut soup = Vec::new();
480        for face in &self.faces {
481            let verts = &face.vertex_indices;
482            if verts.len() < 3 {
483                continue;
484            }
485            for i in 1..(verts.len() - 1) {
486                let v0 = self.vertices[verts[0]];
487                let v1 = self.vertices[verts[i]];
488                let v2 = self.vertices[verts[i + 1]];
489                soup.push([v0, v1, v2]);
490            }
491        }
492        soup
493    }
494    /// Return faces belonging to a specific group.
495    pub fn faces_in_group(&self, group_name: &str) -> Vec<&ObjFace> {
496        if let Some(group) = self.groups.iter().find(|g| g.name == group_name) {
497            let end = group.face_start + group.face_count;
498            let end = end.min(self.faces.len());
499            self.faces[group.face_start..end].iter().collect()
500        } else {
501            Vec::new()
502        }
503    }
504    /// Return faces belonging to a specific smoothing group.
505    pub fn faces_in_smoothing_group(&self, sg: u32) -> Vec<&ObjFace> {
506        self.faces
507            .iter()
508            .filter(|f| f.smoothing_group == sg)
509            .collect()
510    }
511    /// Return faces with a specific material.
512    pub fn faces_with_material(&self, mat_name: &str) -> Vec<&ObjFace> {
513        self.faces
514            .iter()
515            .filter(|f| f.material.as_deref() == Some(mat_name))
516            .collect()
517    }
518    /// Total number of triangles after fan-triangulation.
519    pub fn triangle_count(&self) -> usize {
520        self.faces
521            .iter()
522            .map(|f| {
523                if f.vertex_indices.len() >= 3 {
524                    f.vertex_indices.len() - 2
525                } else {
526                    0
527                }
528            })
529            .sum()
530    }
531    /// Compute flat face normal for a triangular face.
532    pub fn face_normal(&self, face_idx: usize) -> Option<[f64; 3]> {
533        let face = self.faces.get(face_idx)?;
534        if face.vertex_indices.len() < 3 {
535            return None;
536        }
537        let v0 = self.vertices[face.vertex_indices[0]];
538        let v1 = self.vertices[face.vertex_indices[1]];
539        let v2 = self.vertices[face.vertex_indices[2]];
540        let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
541        let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
542        let n = [
543            e1[1] * e2[2] - e1[2] * e2[1],
544            e1[2] * e2[0] - e1[0] * e2[2],
545            e1[0] * e2[1] - e1[1] * e2[0],
546        ];
547        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
548        if len < 1e-30 {
549            return None;
550        }
551        Some([n[0] / len, n[1] / len, n[2] / len])
552    }
553    /// Compute axis-aligned bounding box of all vertices.
554    pub fn bounding_box(&self) -> Option<([f64; 3], [f64; 3])> {
555        if self.vertices.is_empty() {
556            return None;
557        }
558        let mut min = self.vertices[0];
559        let mut max = self.vertices[0];
560        for v in &self.vertices[1..] {
561            for k in 0..3 {
562                if v[k] < min[k] {
563                    min[k] = v[k];
564                }
565                if v[k] > max[k] {
566                    max[k] = v[k];
567                }
568            }
569        }
570        Some((min, max))
571    }
572}
573/// An instance of an `ObjMesh` with a transform and optional name override.
574#[allow(dead_code)]
575#[derive(Debug, Clone)]
576pub struct MeshInstance {
577    /// Name of this instance.
578    pub name: String,
579    /// Transform to apply.
580    pub transform: MeshTransform,
581}
582/// A named group/object within an OBJ file.
583#[allow(dead_code)]
584#[derive(Debug, Clone)]
585pub struct ObjGroup {
586    /// Group or object name.
587    pub name: String,
588    /// Index of the first face belonging to this group (into the mesh face list).
589    pub face_start: usize,
590    /// Number of faces in this group.
591    pub face_count: usize,
592}
593/// A single face in a Wavefront OBJ mesh.
594///
595/// All index arrays are optional to support the various OBJ face formats:
596/// `f v`, `f v/vt`, `f v//vn`, `f v/vt/vn`.
597#[allow(dead_code)]
598#[derive(Debug, Clone)]
599pub struct ObjFace {
600    /// Vertex indices (0-based).
601    pub vertex_indices: Vec<usize>,
602    /// Normal indices (0-based), if present.
603    pub normal_indices: Option<Vec<usize>>,
604    /// UV/texture-coordinate indices (0-based), if present.
605    pub uv_indices: Option<Vec<usize>>,
606    /// Smoothing group this face belongs to (0 = off).
607    pub smoothing_group: u32,
608    /// Material name assigned to this face.
609    pub material: Option<String>,
610}
611/// Writer for `ObjMesh` structures.
612#[allow(dead_code)]
613pub struct ObjWriter;
614#[allow(dead_code)]
615impl ObjWriter {
616    /// Serialize an `ObjMesh` to a Wavefront OBJ string.
617    pub fn write(mesh: &ObjMesh) -> String {
618        Self::write_with_groups(mesh, false)
619    }
620    /// Serialize an `ObjMesh` with optional group headers.
621    pub fn write_with_groups(mesh: &ObjMesh, emit_groups: bool) -> String {
622        let mut s = String::from("# OxiPhysics OBJ export\n");
623        for v in &mesh.vertices {
624            s.push_str(&format!("v {} {} {}\n", v[0], v[1], v[2]));
625        }
626        for vn in &mesh.normals {
627            s.push_str(&format!("vn {} {} {}\n", vn[0], vn[1], vn[2]));
628        }
629        for vt in &mesh.uvs {
630            s.push_str(&format!("vt {} {}\n", vt[0], vt[1]));
631        }
632        let mut current_group: Option<&str> = None;
633        let mut current_material: Option<&str> = None;
634        let mut current_sg: u32 = 0;
635        for (fi, face) in mesh.faces.iter().enumerate() {
636            if emit_groups {
637                for group in &mesh.groups {
638                    if fi == group.face_start && current_group != Some(&group.name) {
639                        s.push_str(&format!("g {}\n", group.name));
640                        current_group = Some(&group.name);
641                    }
642                }
643            }
644            if let Some(ref mat) = face.material
645                && current_material != Some(mat.as_str())
646            {
647                s.push_str(&format!("usemtl {}\n", mat));
648                current_material = Some(mat);
649            }
650            if face.smoothing_group != current_sg {
651                current_sg = face.smoothing_group;
652                if current_sg == 0 {
653                    s.push_str("s off\n");
654                } else {
655                    s.push_str(&format!("s {}\n", current_sg));
656                }
657            }
658            s.push('f');
659            for i in 0..face.vertex_indices.len() {
660                let vi = face.vertex_indices[i] + 1;
661                let vt_idx = face.uv_indices.as_ref().map(|uvs| uvs[i] + 1);
662                let vn_idx = face.normal_indices.as_ref().map(|ns| ns[i] + 1);
663                let token = match (vt_idx, vn_idx) {
664                    (Some(vt), Some(vn)) => format!(" {}/{}/{}", vi, vt, vn),
665                    (None, Some(vn)) => format!(" {}//{}", vi, vn),
666                    (Some(vt), None) => format!(" {}/{}", vi, vt),
667                    (None, None) => format!(" {}", vi),
668                };
669                s.push_str(&token);
670            }
671            s.push('\n');
672        }
673        s
674    }
675    /// Write an `ObjMesh` directly to a file at `path`.
676    pub fn write_to_file(path: &str, mesh: &ObjMesh) -> Result<()> {
677        let file = File::create(Path::new(path))?;
678        let mut w = BufWriter::new(file);
679        write!(w, "{}", Self::write(mesh))?;
680        w.flush()?;
681        Ok(())
682    }
683    /// Write a triangle mesh (legacy API -- vertices + triangle index arrays).
684    ///
685    /// Optionally includes per-vertex normals.
686    pub fn write_legacy(
687        path: &str,
688        vertices: &[Vec3],
689        triangles: &[[usize; 3]],
690        normals: Option<&[Vec3]>,
691    ) -> Result<()> {
692        let file = File::create(Path::new(path))?;
693        let mut w = BufWriter::new(file);
694        writeln!(w, "# OxiPhysics OBJ export")?;
695        for v in vertices {
696            writeln!(w, "v {} {} {}", v.x, v.y, v.z)?;
697        }
698        if let Some(norms) = normals {
699            for n in norms {
700                writeln!(w, "vn {} {} {}", n.x, n.y, n.z)?;
701            }
702            for t in triangles {
703                writeln!(
704                    w,
705                    "f {}//{} {}//{} {}//{}",
706                    t[0] + 1,
707                    t[0] + 1,
708                    t[1] + 1,
709                    t[1] + 1,
710                    t[2] + 1,
711                    t[2] + 1,
712                )?;
713            }
714        } else {
715            for t in triangles {
716                writeln!(w, "f {} {} {}", t[0] + 1, t[1] + 1, t[2] + 1)?;
717            }
718        }
719        w.flush()?;
720        Ok(())
721    }
722    /// Write a mesh with texture coordinates (legacy API).
723    pub fn write_with_uvs(
724        path: &str,
725        vertices: &[Vec3],
726        uvs: &[[f64; 2]],
727        triangles: &[[usize; 3]],
728    ) -> Result<()> {
729        let file = File::create(Path::new(path))?;
730        let mut w = BufWriter::new(file);
731        writeln!(w, "# OxiPhysics OBJ export")?;
732        for v in vertices {
733            writeln!(w, "v {} {} {}", v.x, v.y, v.z)?;
734        }
735        for uv in uvs {
736            writeln!(w, "vt {} {}", uv[0], uv[1])?;
737        }
738        for t in triangles {
739            writeln!(
740                w,
741                "f {}/{} {}/{} {}/{}",
742                t[0] + 1,
743                t[0] + 1,
744                t[1] + 1,
745                t[1] + 1,
746                t[2] + 1,
747                t[2] + 1,
748            )?;
749        }
750        w.flush()?;
751        Ok(())
752    }
753}
754/// A node in an OBJ scene hierarchy.
755#[allow(dead_code)]
756#[derive(Debug, Clone)]
757pub struct ObjSceneNode {
758    /// Name of this node.
759    pub name: String,
760    /// Local transform.
761    pub transform: MeshTransform,
762    /// Mesh index (if this node has geometry).
763    pub mesh_index: Option<usize>,
764    /// Child node indices.
765    pub children: Vec<usize>,
766}
767/// Per-vertex RGBA colour.
768#[allow(dead_code)]
769#[derive(Debug, Clone, Copy, PartialEq)]
770pub struct ObjVertexColor {
771    /// Red channel \[0, 1\].
772    pub r: f64,
773    /// Green channel \[0, 1\].
774    pub g: f64,
775    /// Blue channel \[0, 1\].
776    pub b: f64,
777    /// Alpha channel \[0, 1\].
778    pub a: f64,
779}
780#[allow(dead_code)]
781impl ObjVertexColor {
782    /// Construct a colour from (r, g, b, a).
783    pub fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
784        Self { r, g, b, a }
785    }
786    /// Construct an opaque colour from (r, g, b).
787    pub fn rgb(r: f64, g: f64, b: f64) -> Self {
788        Self { r, g, b, a: 1.0 }
789    }
790    /// Return as `[r, g, b, a]` array.
791    pub fn to_array(self) -> [f64; 4] {
792        [self.r, self.g, self.b, self.a]
793    }
794    /// Blend two colours with weight `t ∈ [0,1]`.
795    pub fn lerp(self, other: Self, t: f64) -> Self {
796        Self {
797            r: self.r + (other.r - self.r) * t,
798            g: self.g + (other.g - self.g) * t,
799            b: self.b + (other.b - self.b) * t,
800            a: self.a + (other.a - self.a) * t,
801        }
802    }
803}
804/// A simple OBJ surface (tensor-product B-spline stub).
805#[allow(dead_code)]
806#[derive(Debug, Clone)]
807pub struct ObjSurface {
808    /// Surface name.
809    pub name: String,
810    /// Degree in u direction.
811    pub degree_u: usize,
812    /// Degree in v direction.
813    pub degree_v: usize,
814    /// Control point indices (row-major, n_u x n_v).
815    pub control_points: Vec<usize>,
816    /// Number of control points in u direction.
817    pub n_u: usize,
818    /// Knot vector in u direction.
819    pub knots_u: Vec<f64>,
820    /// Knot vector in v direction.
821    pub knots_v: Vec<f64>,
822}
823/// Writer for Wavefront MTL material library files.
824#[allow(dead_code)]
825pub struct MtlWriter;
826#[allow(dead_code)]
827impl MtlWriter {
828    /// Generate an MTL file string for the given materials.
829    pub fn write(materials: &[ObjMaterial]) -> String {
830        let mut s = String::from("# OxiPhysics MTL export\n");
831        for mat in materials {
832            s.push_str(&format!("\nnewmtl {}\n", mat.name));
833            s.push_str(&format!("Ka {} {} {}\n", mat.ka[0], mat.ka[1], mat.ka[2]));
834            s.push_str(&format!("Kd {} {} {}\n", mat.kd[0], mat.kd[1], mat.kd[2]));
835            s.push_str(&format!("Ks {} {} {}\n", mat.ks[0], mat.ks[1], mat.ks[2]));
836            s.push_str(&format!("Ns {}\n", mat.ns));
837            s.push_str(&format!("d {}\n", mat.dissolve));
838            if let Some(ref tex) = mat.map_kd {
839                s.push_str(&format!("map_Kd {}\n", tex));
840            }
841        }
842        s
843    }
844}
845/// OBJ mesh extended with per-vertex colours (non-standard OBJ extension).
846///
847/// Some exporters write vertex colours as extra columns on `v` lines:
848/// `v x y z r g b` or `v x y z r g b a`.
849#[allow(dead_code)]
850#[derive(Debug, Clone, Default)]
851pub struct ObjVertexColorMesh {
852    /// Underlying geometry.
853    pub mesh: ObjMesh,
854    /// Per-vertex colours (same length as `mesh.vertices` if present).
855    pub colors: Vec<ObjVertexColor>,
856}
857#[allow(dead_code)]
858impl ObjVertexColorMesh {
859    /// Parse an OBJ string that may contain vertex colour data.
860    pub fn from_str(data: &str) -> std::result::Result<Self, String> {
861        let mut vcmesh = ObjVertexColorMesh::default();
862        let mut smoothing_group: u32 = 0;
863        let mut current_material: Option<String> = None;
864        let mut current_group_name: Option<String> = None;
865        let mut current_group_start: usize = 0;
866        for raw in data.lines() {
867            let line = raw.trim();
868            if line.is_empty() || line.starts_with('#') {
869                continue;
870            }
871            if line.starts_with("g ") || line.starts_with("o ") {
872                if let Some(ref name) = current_group_name {
873                    let count = vcmesh.mesh.faces.len() - current_group_start;
874                    if count > 0 {
875                        vcmesh.mesh.groups.push(ObjGroup {
876                            name: name.clone(),
877                            face_start: current_group_start,
878                            face_count: count,
879                        });
880                    }
881                }
882                current_group_name = Some(line[2..].trim().to_string());
883                current_group_start = vcmesh.mesh.faces.len();
884            } else if line.starts_with("usemtl ") {
885                current_material = Some(line[7..].trim().to_string());
886            } else if line.starts_with("s ") {
887                let val = line[2..].trim();
888                smoothing_group = if val == "off" || val == "0" {
889                    0
890                } else {
891                    val.parse::<u32>().unwrap_or(0)
892                };
893            } else if line.starts_with("v ") {
894                let p: Vec<&str> = line.split_whitespace().collect();
895                if p.len() < 4 {
896                    return Err(format!("Vertex line too short: {}", line));
897                }
898                let x: f64 = p[1]
899                    .parse()
900                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
901                let y: f64 = p[2]
902                    .parse()
903                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
904                let z: f64 = p[3]
905                    .parse()
906                    .map_err(|e: std::num::ParseFloatError| e.to_string())?;
907                vcmesh.mesh.vertices.push([x, y, z]);
908                let r: f64 = p.get(4).and_then(|v| v.parse().ok()).unwrap_or(1.0);
909                let g: f64 = p.get(5).and_then(|v| v.parse().ok()).unwrap_or(1.0);
910                let b: f64 = p.get(6).and_then(|v| v.parse().ok()).unwrap_or(1.0);
911                let a: f64 = p.get(7).and_then(|v| v.parse().ok()).unwrap_or(1.0);
912                vcmesh.colors.push(ObjVertexColor { r, g, b, a });
913            } else if line.starts_with("vn ") {
914                let p: Vec<&str> = line.split_whitespace().collect();
915                if p.len() >= 4 {
916                    let x: f64 = p[1]
917                        .parse()
918                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
919                    let y: f64 = p[2]
920                        .parse()
921                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
922                    let z: f64 = p[3]
923                        .parse()
924                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
925                    vcmesh.mesh.normals.push([x, y, z]);
926                }
927            } else if line.starts_with("vt ") {
928                let p: Vec<&str> = line.split_whitespace().collect();
929                if p.len() >= 3 {
930                    let u: f64 = p[1]
931                        .parse()
932                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
933                    let v: f64 = p[2]
934                        .parse()
935                        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
936                    vcmesh.mesh.uvs.push([u, v]);
937                }
938            } else if line.starts_with("f ") {
939                let p: Vec<&str> = line.split_whitespace().collect();
940                let mut vis = Vec::new();
941                let mut vts = Vec::new();
942                let mut vns = Vec::new();
943                let mut has_vt = false;
944                let mut has_vn = false;
945                for tok in &p[1..] {
946                    let parts: Vec<&str> = tok.split('/').collect();
947                    let vi: usize = parts[0].parse::<usize>().map_err(|e| e.to_string())? - 1;
948                    vis.push(vi);
949                    if parts.len() >= 2 && !parts[1].is_empty() {
950                        has_vt = true;
951                        let vt: usize = parts[1].parse::<usize>().map_err(|e| e.to_string())? - 1;
952                        vts.push(vt);
953                    }
954                    if parts.len() >= 3 && !parts[2].is_empty() {
955                        has_vn = true;
956                        let vn: usize = parts[2].parse::<usize>().map_err(|e| e.to_string())? - 1;
957                        vns.push(vn);
958                    }
959                }
960                vcmesh.mesh.faces.push(ObjFace {
961                    vertex_indices: vis,
962                    normal_indices: if has_vn { Some(vns) } else { None },
963                    uv_indices: if has_vt { Some(vts) } else { None },
964                    smoothing_group,
965                    material: current_material.clone(),
966                });
967            }
968        }
969        if let Some(ref name) = current_group_name {
970            let count = vcmesh.mesh.faces.len() - current_group_start;
971            if count > 0 {
972                vcmesh.mesh.groups.push(ObjGroup {
973                    name: name.clone(),
974                    face_start: current_group_start,
975                    face_count: count,
976                });
977            }
978        }
979        Ok(vcmesh)
980    }
981    /// Serialize this coloured mesh to an OBJ string (with colour extension).
982    pub fn to_obj_str(&self) -> String {
983        let mut s = String::from("# OxiPhysics OBJ export (vertex colours)\n");
984        for (i, v) in self.mesh.vertices.iter().enumerate() {
985            if let Some(c) = self.colors.get(i) {
986                s.push_str(&format!(
987                    "v {} {} {} {} {} {}\n",
988                    v[0], v[1], v[2], c.r, c.g, c.b
989                ));
990            } else {
991                s.push_str(&format!("v {} {} {}\n", v[0], v[1], v[2]));
992            }
993        }
994        for vn in &self.mesh.normals {
995            s.push_str(&format!("vn {} {} {}\n", vn[0], vn[1], vn[2]));
996        }
997        for vt in &self.mesh.uvs {
998            s.push_str(&format!("vt {} {}\n", vt[0], vt[1]));
999        }
1000        for face in &self.mesh.faces {
1001            s.push('f');
1002            for i in 0..face.vertex_indices.len() {
1003                let vi = face.vertex_indices[i] + 1;
1004                let vt_idx = face.uv_indices.as_ref().map(|uvs| uvs[i] + 1);
1005                let vn_idx = face.normal_indices.as_ref().map(|ns| ns[i] + 1);
1006                let tok = match (vt_idx, vn_idx) {
1007                    (Some(vt), Some(vn)) => format!(" {}/{}/{}", vi, vt, vn),
1008                    (None, Some(vn)) => format!(" {}//{}", vi, vn),
1009                    (Some(vt), None) => format!(" {}/{}", vi, vt),
1010                    (None, None) => format!(" {}", vi),
1011                };
1012                s.push_str(&tok);
1013            }
1014            s.push('\n');
1015        }
1016        s
1017    }
1018}
1019
1020// ─────────────────────────────────────────────────────────────────────────────
1021// QEM (Quadric Error Metric) mesh decimation β€” Garland & Heckbert 1997
1022// ─────────────────────────────────────────────────────────────────────────────
1023
1024/// Symmetric 4Γ—4 error quadric stored as the 10-element upper triangle.
1025///
1026/// Layout: `[a2, ab, ac, ad, b2, bc, bd, c2, cd, d2]` where the plane is
1027/// `ax + by + cz + d = 0`.
1028#[derive(Clone, Copy)]
1029struct Quadric {
1030    m: [f64; 10],
1031}
1032
1033impl Quadric {
1034    const ZERO: Self = Self { m: [0.0; 10] };
1035
1036    /// Build the fundamental-error quadric `K_p = pp^T` for plane `(a,b,c,d)`.
1037    fn from_plane(a: f64, b: f64, c: f64, d: f64) -> Self {
1038        Self {
1039            m: [
1040                a * a,
1041                a * b,
1042                a * c,
1043                a * d,
1044                b * b,
1045                b * c,
1046                b * d,
1047                c * c,
1048                c * d,
1049                d * d,
1050            ],
1051        }
1052    }
1053
1054    /// Add two quadrics (element-wise).
1055    fn add(&self, other: &Self) -> Self {
1056        let mut m = [0.0f64; 10];
1057        for (i, val) in m.iter_mut().enumerate() {
1058            *val = self.m[i] + other.m[i];
1059        }
1060        Self { m }
1061    }
1062
1063    /// Evaluate the quadric error at position `p`.
1064    fn eval(&self, p: [f64; 3]) -> f64 {
1065        let [x, y, z] = p;
1066        let m = &self.m;
1067        m[0] * x * x
1068            + 2.0 * m[1] * x * y
1069            + 2.0 * m[2] * x * z
1070            + 2.0 * m[3] * x
1071            + m[4] * y * y
1072            + 2.0 * m[5] * y * z
1073            + 2.0 * m[6] * y
1074            + m[7] * z * z
1075            + 2.0 * m[8] * z
1076            + m[9]
1077    }
1078
1079    /// Solve for the optimal collapse position using Cramer's rule on the
1080    /// upper-left 3Γ—3 block.  Falls back to `midpoint` for singular systems.
1081    fn optimal_pos(&self, midpoint: [f64; 3]) -> [f64; 3] {
1082        let m = &self.m;
1083        let a00 = m[0];
1084        let a01 = m[1];
1085        let a02 = m[2];
1086        let a11 = m[4];
1087        let a12 = m[5];
1088        let a22 = m[7];
1089        let bx = -m[3];
1090        let by = -m[6];
1091        let bz = -m[8];
1092
1093        let det = a00 * (a11 * a22 - a12 * a12) - a01 * (a01 * a22 - a12 * a02)
1094            + a02 * (a01 * a12 - a11 * a02);
1095
1096        if det.abs() < 1.0e-12 {
1097            return midpoint;
1098        }
1099        let inv = 1.0 / det;
1100        let x = inv
1101            * (bx * (a11 * a22 - a12 * a12) - a01 * (by * a22 - a12 * bz)
1102                + a02 * (by * a12 - a11 * bz));
1103        let y = inv
1104            * (a00 * (by * a22 - a12 * bz) - bx * (a01 * a22 - a12 * a02)
1105                + a02 * (a01 * bz - by * a02));
1106        let z = inv
1107            * (a00 * (a11 * bz - by * a12) - a01 * (a01 * bz - by * a02)
1108                + bx * (a01 * a12 - a11 * a02));
1109        [x, y, z]
1110    }
1111}
1112
1113/// Entry in the collapse priority queue.
1114#[derive(Clone)]
1115struct EdgeEntry {
1116    cost: f64,
1117    v0: usize,
1118    v1: usize,
1119    pos: [f64; 3],
1120    /// Epoch stamp for lazy deletion: entry is stale when it no longer
1121    /// matches `vertex_epochs[v0] ^ (vertex_epochs[v1] << 32)`.
1122    epoch: u64,
1123}
1124
1125impl PartialEq for EdgeEntry {
1126    fn eq(&self, other: &Self) -> bool {
1127        self.cost == other.cost
1128    }
1129}
1130impl Eq for EdgeEntry {}
1131impl PartialOrd for EdgeEntry {
1132    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1133        Some(self.cmp(other))
1134    }
1135}
1136impl Ord for EdgeEntry {
1137    /// Reverse order so `BinaryHeap` acts as a min-heap.
1138    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1139        other
1140            .cost
1141            .partial_cmp(&self.cost)
1142            .unwrap_or(std::cmp::Ordering::Equal)
1143    }
1144}
1145
1146/// Core QEM decimation implementation.
1147fn decimate_qem_impl(mesh: &ObjMesh, target_faces: usize) -> ObjMesh {
1148    use std::collections::{BinaryHeap, HashSet};
1149
1150    let nv = mesh.vertices.len();
1151    if nv == 0 || mesh.faces.is_empty() {
1152        return mesh.clone();
1153    }
1154
1155    // ── 1. Collect triangles ─────────────────────────────────────────────────
1156    let mut triangles: Vec<[usize; 3]> = Vec::new();
1157    for face in &mesh.faces {
1158        let vi = &face.vertex_indices;
1159        if vi.len() < 3 {
1160            continue;
1161        }
1162        for k in 1..(vi.len() - 1) {
1163            if vi[0] < nv && vi[k] < nv && vi[k + 1] < nv {
1164                triangles.push([vi[0], vi[k], vi[k + 1]]);
1165            }
1166        }
1167    }
1168    if triangles.is_empty() {
1169        return mesh.clone();
1170    }
1171
1172    // ── 2. Compute per-vertex quadrics ───────────────────────────────────────
1173    let mut quadrics = vec![Quadric::ZERO; nv];
1174    for &[i0, i1, i2] in &triangles {
1175        let v0 = mesh.vertices[i0];
1176        let v1 = mesh.vertices[i1];
1177        let v2 = mesh.vertices[i2];
1178        let ex = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
1179        let fy = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
1180        let nx = ex[1] * fy[2] - ex[2] * fy[1];
1181        let ny = ex[2] * fy[0] - ex[0] * fy[2];
1182        let nz = ex[0] * fy[1] - ex[1] * fy[0];
1183        let len = (nx * nx + ny * ny + nz * nz).sqrt();
1184        if len < 1.0e-15 {
1185            continue;
1186        }
1187        let (a, b, c) = (nx / len, ny / len, nz / len);
1188        let d = -(a * v0[0] + b * v0[1] + c * v0[2]);
1189        let q = Quadric::from_plane(a, b, c, d);
1190        quadrics[i0] = quadrics[i0].add(&q);
1191        quadrics[i1] = quadrics[i1].add(&q);
1192        quadrics[i2] = quadrics[i2].add(&q);
1193    }
1194
1195    // ── 3. Collect unique undirected edges ───────────────────────────────────
1196    let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
1197    for &[i0, i1, i2] in &triangles {
1198        for (a, b) in [(i0, i1), (i1, i2), (i0, i2)] {
1199            edge_set.insert((a.min(b), a.max(b)));
1200        }
1201    }
1202
1203    // ── 4. State arrays ──────────────────────────────────────────────────────
1204    let mut vertices: Vec<Option<[f64; 3]>> = mesh.vertices.iter().map(|&v| Some(v)).collect();
1205    let mut vertex_epochs: Vec<u64> = vec![0u64; nv];
1206    let mut remap: Vec<usize> = (0..nv).collect();
1207    let mut heap: BinaryHeap<EdgeEntry> = BinaryHeap::new();
1208    let mut faces: Vec<Option<[usize; 3]>> = triangles.iter().map(|&t| Some(t)).collect();
1209    let mut active_face_count = faces.len();
1210
1211    // ── Helper: resolve remap chain ──────────────────────────────────────────
1212    fn resolve(remap: &[usize], mut v: usize) -> usize {
1213        while remap[v] != v {
1214            v = remap[v];
1215        }
1216        v
1217    }
1218
1219    // ── Helper: push an edge into the heap ───────────────────────────────────
1220    let push_edge = |heap: &mut BinaryHeap<EdgeEntry>,
1221                     v0: usize,
1222                     v1: usize,
1223                     qv: &[Quadric],
1224                     verts: &[Option<[f64; 3]>],
1225                     ve: &[u64]| {
1226        let Some(p0) = verts[v0] else { return };
1227        let Some(p1) = verts[v1] else { return };
1228        let combined = qv[v0].add(&qv[v1]);
1229        let mid = [
1230            (p0[0] + p1[0]) * 0.5,
1231            (p0[1] + p1[1]) * 0.5,
1232            (p0[2] + p1[2]) * 0.5,
1233        ];
1234        let pos = combined.optimal_pos(mid);
1235        let cost = combined.eval(pos);
1236        heap.push(EdgeEntry {
1237            cost,
1238            v0,
1239            v1,
1240            pos,
1241            epoch: ve[v0] ^ (ve[v1] << 32),
1242        });
1243    };
1244
1245    for &(a, b) in &edge_set {
1246        push_edge(&mut heap, a, b, &quadrics, &vertices, &vertex_epochs);
1247    }
1248
1249    // ── 5. Greedy collapse loop ───────────────────────────────────────────────
1250    while active_face_count > target_faces {
1251        let entry = match heap.pop() {
1252            Some(e) => e,
1253            None => break,
1254        };
1255        let v0 = resolve(&remap, entry.v0);
1256        let v1 = resolve(&remap, entry.v1);
1257        if v0 == v1 {
1258            continue;
1259        }
1260        if entry.epoch != vertex_epochs[v0] ^ (vertex_epochs[v1] << 32) {
1261            continue;
1262        }
1263        if vertices[v0].is_none() || vertices[v1].is_none() {
1264            continue;
1265        }
1266
1267        // Collapse v1 into v0.
1268        vertices[v0] = Some(entry.pos);
1269        vertices[v1] = None;
1270        quadrics[v0] = quadrics[v0].add(&quadrics[v1]);
1271        remap[v1] = v0;
1272        vertex_epochs[v0] += 1;
1273        vertex_epochs[v1] += 1;
1274
1275        // Degenerate face removal.
1276        for face in faces.iter_mut() {
1277            let tri = match face {
1278                Some(t) => t,
1279                None => continue,
1280            };
1281            let mut changed = false;
1282            for idx in tri.iter_mut() {
1283                let r = resolve(&remap, *idx);
1284                if r != *idx {
1285                    *idx = r;
1286                    changed = true;
1287                }
1288            }
1289            if changed && (tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2]) {
1290                *face = None;
1291                active_face_count -= 1;
1292            }
1293        }
1294
1295        // Re-queue edges incident to the surviving vertex.
1296        for &(a, b) in &edge_set {
1297            let ra = resolve(&remap, a);
1298            let rb = resolve(&remap, b);
1299            if ra == rb {
1300                continue;
1301            }
1302            if ra == v0 || rb == v0 {
1303                push_edge(&mut heap, ra, rb, &quadrics, &vertices, &vertex_epochs);
1304            }
1305        }
1306    }
1307
1308    // ── 6. Rebuild ObjMesh ───────────────────────────────────────────────────
1309    let mut new_vertices: Vec<[f64; 3]> = Vec::new();
1310    let mut old_to_new: Vec<Option<usize>> = vec![None; nv];
1311    for (i, v) in vertices.iter().enumerate() {
1312        if let Some(pos) = v {
1313            old_to_new[i] = Some(new_vertices.len());
1314            new_vertices.push(*pos);
1315        }
1316    }
1317
1318    let mut new_faces: Vec<ObjFace> = Vec::new();
1319    for face in faces.iter().flatten() {
1320        let a = resolve(&remap, face[0]);
1321        let b = resolve(&remap, face[1]);
1322        let c = resolve(&remap, face[2]);
1323        if a == b || b == c || a == c {
1324            continue;
1325        }
1326        let Some(na) = old_to_new[a] else { continue };
1327        let Some(nb) = old_to_new[b] else { continue };
1328        let Some(nc) = old_to_new[c] else { continue };
1329        new_faces.push(ObjFace {
1330            vertex_indices: vec![na, nb, nc],
1331            normal_indices: None,
1332            uv_indices: None,
1333            smoothing_group: 0,
1334            material: None,
1335        });
1336        if new_faces.len() >= target_faces {
1337            break;
1338        }
1339    }
1340
1341    ObjMesh {
1342        vertices: new_vertices,
1343        normals: Vec::new(),
1344        uvs: Vec::new(),
1345        faces: new_faces,
1346        groups: Vec::new(),
1347    }
1348}