Skip to main content

oxiphysics_io/obj/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::items_after_test_module)]
6#![allow(clippy::field_reassign_with_default)]
7
8use super::types::{
9    MeshInstance, MeshTransform, ObjFace, ObjGroup, ObjMaterial, ObjMesh, ObjMeshStats,
10};
11
12#[cfg(test)]
13mod tests {
14
15    use crate::obj::types::*;
16    use oxiphysics_core::math::Vec3;
17    fn make_default_face(vis: Vec<usize>) -> ObjFace {
18        ObjFace {
19            vertex_indices: vis,
20            normal_indices: None,
21            uv_indices: None,
22            smoothing_group: 0,
23            material: None,
24        }
25    }
26    #[test]
27    fn test_obj_write_and_read_roundtrip() {
28        let path = "/tmp/oxiphy_test.obj";
29        let verts = vec![
30            Vec3::new(0.0, 0.0, 0.0),
31            Vec3::new(1.0, 0.0, 0.0),
32            Vec3::new(0.0, 1.0, 0.0),
33        ];
34        let tris = vec![[0, 1, 2]];
35        ObjWriter::write_legacy(path, &verts, &tris, None).unwrap();
36        let (read_verts, read_tris) = ObjReader::read(path).unwrap();
37        assert_eq!(read_verts.len(), 3);
38        assert_eq!(read_tris.len(), 1);
39        assert_eq!(read_tris[0], [0, 1, 2]);
40        assert!((read_verts[1].x - 1.0).abs() < 1e-10);
41        std::fs::remove_file(path).ok();
42    }
43    #[test]
44    fn test_obj_write_wavefront() {
45        let path = "/tmp/oxiphy_test_wavefront.obj";
46        let verts = vec![
47            Vec3::new(0.0, 0.0, 0.0),
48            Vec3::new(1.0, 0.0, 0.0),
49            Vec3::new(0.5, 1.0, 0.0),
50        ];
51        let tris = vec![[0, 1, 2]];
52        ObjWriter::write_legacy(path, &verts, &tris, None).unwrap();
53        let content = std::fs::read_to_string(path).unwrap();
54        assert!(content.lines().any(|l| l.starts_with("v ")));
55        assert!(content.lines().any(|l| l.starts_with("f ")));
56        let v_count = content.lines().filter(|l| l.starts_with("v ")).count();
57        let f_count = content.lines().filter(|l| l.starts_with("f ")).count();
58        assert_eq!(v_count, 3);
59        assert_eq!(f_count, 1);
60        std::fs::remove_file(path).ok();
61    }
62    #[test]
63    fn test_obj_reader_handles_vertex_face_parsing() {
64        let path = "/tmp/oxiphy_test_parse.obj";
65        let verts = vec![
66            Vec3::new(0.0, 0.0, 0.0),
67            Vec3::new(1.0, 0.0, 0.0),
68            Vec3::new(0.0, 1.0, 0.0),
69        ];
70        let norms = vec![
71            Vec3::new(0.0, 0.0, 1.0),
72            Vec3::new(0.0, 0.0, 1.0),
73            Vec3::new(0.0, 0.0, 1.0),
74        ];
75        let tris = vec![[0, 1, 2]];
76        ObjWriter::write_legacy(path, &verts, &tris, Some(&norms)).unwrap();
77        let (read_verts, read_tris) = ObjReader::read(path).unwrap();
78        assert_eq!(read_verts.len(), 3);
79        assert_eq!(read_tris.len(), 1);
80        assert_eq!(read_tris[0], [0, 1, 2]);
81        std::fs::remove_file(path).ok();
82    }
83    #[test]
84    fn test_obj_mesh_write_read_vertex_roundtrip() {
85        let mut mesh = ObjMesh::default();
86        mesh.vertices = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
87        mesh.faces.push(make_default_face(vec![0, 1, 2]));
88        let s = ObjWriter::write(&mesh);
89        let parsed = ObjReader::from_str(&s).unwrap();
90        assert_eq!(parsed.vertices.len(), 3);
91        assert!((parsed.vertices[0][0] - 1.0).abs() < 1e-10);
92        assert!((parsed.vertices[2][2] - 9.0).abs() < 1e-10);
93    }
94    #[test]
95    fn test_obj_mesh_face_format() {
96        let mut mesh = ObjMesh::default();
97        mesh.vertices = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
98        mesh.faces.push(make_default_face(vec![0, 1, 2]));
99        let s = ObjWriter::write(&mesh);
100        assert!(s.contains("f 1 2 3"), "face line not found in: {s}");
101    }
102    #[test]
103    fn test_obj_mesh_normal_export() {
104        let mut mesh = ObjMesh::default();
105        mesh.vertices = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
106        mesh.normals = vec![[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]];
107        mesh.faces.push(ObjFace {
108            vertex_indices: vec![0, 1, 2],
109            normal_indices: Some(vec![0, 1, 2]),
110            uv_indices: None,
111            smoothing_group: 0,
112            material: None,
113        });
114        let s = ObjWriter::write(&mesh);
115        assert!(s.contains("vn"), "normals not exported: {s}");
116        assert!(s.contains("//"), "face should use v//vn format: {s}");
117    }
118    #[test]
119    fn test_multi_object_groups() {
120        let g1 = ObjGroup {
121            name: "body".into(),
122            face_start: 0,
123            face_count: 4,
124        };
125        let g2 = ObjGroup {
126            name: "wheel".into(),
127            face_start: 4,
128            face_count: 2,
129        };
130        assert_eq!(g1.name, "body");
131        assert_eq!(g2.face_start, 4);
132        assert_eq!(g1.face_count + g2.face_count, 6);
133    }
134    #[test]
135    fn test_triangle_soup_count() {
136        let mut mesh = ObjMesh::default();
137        mesh.vertices = vec![
138            [0.0, 0.0, 0.0],
139            [1.0, 0.0, 0.0],
140            [0.0, 1.0, 0.0],
141            [1.0, 1.0, 0.0],
142        ];
143        mesh.faces.push(make_default_face(vec![0, 1, 2]));
144        mesh.faces.push(make_default_face(vec![1, 3, 2]));
145        let soup = mesh.to_triangle_soup();
146        assert_eq!(soup.len(), 2);
147    }
148    #[test]
149    fn test_mtl_writer_output() {
150        let mat = ObjMaterial {
151            name: "Red".into(),
152            kd: [1.0, 0.0, 0.0],
153            ks: [0.5, 0.5, 0.5],
154            ns: 32.0,
155            ka: [0.1, 0.0, 0.0],
156            dissolve: 1.0,
157            map_kd: None,
158        };
159        let s = MtlWriter::write(&[mat]);
160        assert!(s.contains("newmtl Red"), "material name missing: {s}");
161        assert!(s.contains("Kd 1"), "diffuse colour missing: {s}");
162        assert!(s.contains("Ns 32"), "shininess missing: {s}");
163    }
164    #[test]
165    fn test_triangle_soup_quad_triangulation() {
166        let mut mesh = ObjMesh::default();
167        mesh.vertices = vec![
168            [0.0, 0.0, 0.0],
169            [1.0, 0.0, 0.0],
170            [1.0, 1.0, 0.0],
171            [0.0, 1.0, 0.0],
172        ];
173        mesh.faces.push(make_default_face(vec![0, 1, 2, 3]));
174        let soup = mesh.to_triangle_soup();
175        assert_eq!(
176            soup.len(),
177            2,
178            "quad should triangulate to 2 triangles, got {}",
179            soup.len()
180        );
181    }
182    #[test]
183    fn test_group_parsing() {
184        let data = "\
185v 0 0 0
186v 1 0 0
187v 0 1 0
188v 1 1 0
189g group1
190f 1 2 3
191g group2
192f 2 4 3
193";
194        let mesh = ObjReader::from_str(data).unwrap();
195        assert_eq!(mesh.groups.len(), 2);
196        assert_eq!(mesh.groups[0].name, "group1");
197        assert_eq!(mesh.groups[0].face_count, 1);
198        assert_eq!(mesh.groups[1].name, "group2");
199        assert_eq!(mesh.groups[1].face_count, 1);
200    }
201    #[test]
202    fn test_smoothing_group_parsing() {
203        let data = "\
204v 0 0 0
205v 1 0 0
206v 0 1 0
207v 1 1 0
208s 1
209f 1 2 3
210s 2
211f 2 4 3
212";
213        let mesh = ObjReader::from_str(data).unwrap();
214        assert_eq!(mesh.faces[0].smoothing_group, 1);
215        assert_eq!(mesh.faces[1].smoothing_group, 2);
216    }
217    #[test]
218    fn test_smoothing_group_off() {
219        let data = "\
220v 0 0 0
221v 1 0 0
222v 0 1 0
223s off
224f 1 2 3
225";
226        let mesh = ObjReader::from_str(data).unwrap();
227        assert_eq!(mesh.faces[0].smoothing_group, 0);
228    }
229    #[test]
230    fn test_material_parsing() {
231        let data = "\
232v 0 0 0
233v 1 0 0
234v 0 1 0
235v 1 1 0
236usemtl Red
237f 1 2 3
238usemtl Blue
239f 2 4 3
240";
241        let mesh = ObjReader::from_str(data).unwrap();
242        assert_eq!(mesh.faces[0].material.as_deref(), Some("Red"));
243        assert_eq!(mesh.faces[1].material.as_deref(), Some("Blue"));
244    }
245    #[test]
246    fn test_faces_in_group() {
247        let mut mesh = ObjMesh::default();
248        mesh.vertices = vec![
249            [0.0, 0.0, 0.0],
250            [1.0, 0.0, 0.0],
251            [0.0, 1.0, 0.0],
252            [1.0, 1.0, 0.0],
253        ];
254        mesh.faces.push(make_default_face(vec![0, 1, 2]));
255        mesh.faces.push(make_default_face(vec![1, 3, 2]));
256        mesh.faces.push(make_default_face(vec![0, 3, 2]));
257        mesh.groups.push(ObjGroup {
258            name: "A".into(),
259            face_start: 0,
260            face_count: 2,
261        });
262        mesh.groups.push(ObjGroup {
263            name: "B".into(),
264            face_start: 2,
265            face_count: 1,
266        });
267        assert_eq!(mesh.faces_in_group("A").len(), 2);
268        assert_eq!(mesh.faces_in_group("B").len(), 1);
269        assert_eq!(mesh.faces_in_group("C").len(), 0);
270    }
271    #[test]
272    fn test_faces_in_smoothing_group() {
273        let mut mesh = ObjMesh::default();
274        mesh.vertices = vec![[0.0; 3]; 4];
275        mesh.faces.push(ObjFace {
276            vertex_indices: vec![0, 1, 2],
277            normal_indices: None,
278            uv_indices: None,
279            smoothing_group: 1,
280            material: None,
281        });
282        mesh.faces.push(ObjFace {
283            vertex_indices: vec![1, 3, 2],
284            normal_indices: None,
285            uv_indices: None,
286            smoothing_group: 2,
287            material: None,
288        });
289        mesh.faces.push(ObjFace {
290            vertex_indices: vec![0, 3, 2],
291            normal_indices: None,
292            uv_indices: None,
293            smoothing_group: 1,
294            material: None,
295        });
296        assert_eq!(mesh.faces_in_smoothing_group(1).len(), 2);
297        assert_eq!(mesh.faces_in_smoothing_group(2).len(), 1);
298    }
299    #[test]
300    fn test_faces_with_material() {
301        let mut mesh = ObjMesh::default();
302        mesh.vertices = vec![[0.0; 3]; 4];
303        mesh.faces.push(ObjFace {
304            vertex_indices: vec![0, 1, 2],
305            normal_indices: None,
306            uv_indices: None,
307            smoothing_group: 0,
308            material: Some("Red".into()),
309        });
310        mesh.faces.push(ObjFace {
311            vertex_indices: vec![1, 3, 2],
312            normal_indices: None,
313            uv_indices: None,
314            smoothing_group: 0,
315            material: Some("Blue".into()),
316        });
317        assert_eq!(mesh.faces_with_material("Red").len(), 1);
318        assert_eq!(mesh.faces_with_material("Blue").len(), 1);
319        assert_eq!(mesh.faces_with_material("Green").len(), 0);
320    }
321    #[test]
322    fn test_triangle_count() {
323        let mut mesh = ObjMesh::default();
324        mesh.vertices = vec![[0.0; 3]; 5];
325        mesh.faces.push(make_default_face(vec![0, 1, 2]));
326        mesh.faces.push(make_default_face(vec![0, 1, 2, 3]));
327        mesh.faces.push(make_default_face(vec![0, 1, 2, 3, 4]));
328        assert_eq!(mesh.triangle_count(), 6);
329    }
330    #[test]
331    fn test_face_normal() {
332        let mut mesh = ObjMesh::default();
333        mesh.vertices = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
334        mesh.faces.push(make_default_face(vec![0, 1, 2]));
335        let n = mesh.face_normal(0).unwrap();
336        assert!((n[0] - 0.0).abs() < 1e-10);
337        assert!((n[1] - 0.0).abs() < 1e-10);
338        assert!((n[2] - 1.0).abs() < 1e-10);
339    }
340    #[test]
341    fn test_bounding_box() {
342        let mut mesh = ObjMesh::default();
343        mesh.vertices = vec![[1.0, 2.0, 3.0], [-1.0, -2.0, -3.0], [0.0, 0.0, 0.0]];
344        let (min, max) = mesh.bounding_box().unwrap();
345        assert!((min[0] - (-1.0)).abs() < 1e-10);
346        assert!((min[1] - (-2.0)).abs() < 1e-10);
347        assert!((min[2] - (-3.0)).abs() < 1e-10);
348        assert!((max[0] - 1.0).abs() < 1e-10);
349        assert!((max[1] - 2.0).abs() < 1e-10);
350        assert!((max[2] - 3.0).abs() < 1e-10);
351    }
352    #[test]
353    fn test_bounding_box_empty() {
354        let mesh = ObjMesh::default();
355        assert!(mesh.bounding_box().is_none());
356    }
357    #[test]
358    fn test_texture_coordinate_roundtrip() {
359        let mut mesh = ObjMesh::default();
360        mesh.vertices = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
361        mesh.uvs = vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]];
362        mesh.faces.push(ObjFace {
363            vertex_indices: vec![0, 1, 2],
364            normal_indices: None,
365            uv_indices: Some(vec![0, 1, 2]),
366            smoothing_group: 0,
367            material: None,
368        });
369        let s = ObjWriter::write(&mesh);
370        let parsed = ObjReader::from_str(&s).unwrap();
371        assert_eq!(parsed.uvs.len(), 3);
372        assert!((parsed.uvs[2][1] - 1.0).abs() < 1e-10);
373        assert!(parsed.faces[0].uv_indices.is_some());
374    }
375    #[test]
376    fn test_curve_struct() {
377        let curve = ObjCurve {
378            name: "test_curve".into(),
379            degree: 3,
380            control_points: vec![0, 1, 2, 3],
381            knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0],
382        };
383        assert_eq!(curve.degree, 3);
384        assert_eq!(curve.control_points.len(), 4);
385        assert_eq!(curve.knots.len(), 8);
386    }
387    #[test]
388    fn test_surface_struct() {
389        let surface = ObjSurface {
390            name: "test_surface".into(),
391            degree_u: 2,
392            degree_v: 2,
393            control_points: vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
394            n_u: 3,
395            knots_u: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
396            knots_v: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
397        };
398        assert_eq!(surface.degree_u, 2);
399        assert_eq!(surface.n_u, 3);
400        assert_eq!(surface.control_points.len(), 9);
401    }
402    #[test]
403    fn test_material_basic() {
404        let mat = ObjMaterial::basic("test_mat", [0.5, 0.5, 0.5]);
405        assert_eq!(mat.name, "test_mat");
406        assert!((mat.kd[0] - 0.5).abs() < 1e-10);
407        assert!((mat.dissolve - 1.0).abs() < 1e-10);
408    }
409    #[test]
410    fn test_write_with_groups_roundtrip() {
411        let mut mesh = ObjMesh::default();
412        mesh.vertices = vec![
413            [0.0, 0.0, 0.0],
414            [1.0, 0.0, 0.0],
415            [0.0, 1.0, 0.0],
416            [1.0, 1.0, 0.0],
417        ];
418        mesh.faces.push(make_default_face(vec![0, 1, 2]));
419        mesh.faces.push(make_default_face(vec![1, 3, 2]));
420        mesh.groups.push(ObjGroup {
421            name: "grp1".into(),
422            face_start: 0,
423            face_count: 1,
424        });
425        mesh.groups.push(ObjGroup {
426            name: "grp2".into(),
427            face_start: 1,
428            face_count: 1,
429        });
430        let s = ObjWriter::write_with_groups(&mesh, true);
431        assert!(s.contains("g grp1"));
432        assert!(s.contains("g grp2"));
433        let parsed = ObjReader::from_str(&s).unwrap();
434        assert_eq!(parsed.groups.len(), 2);
435    }
436    #[test]
437    fn test_write_with_uvs() {
438        let path = "/tmp/oxiphy_test_uvs.obj";
439        let verts = vec![
440            Vec3::new(0.0, 0.0, 0.0),
441            Vec3::new(1.0, 0.0, 0.0),
442            Vec3::new(0.0, 1.0, 0.0),
443        ];
444        let uvs = vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]];
445        let tris = vec![[0, 1, 2]];
446        ObjWriter::write_with_uvs(path, &verts, &uvs, &tris).unwrap();
447        let content = std::fs::read_to_string(path).unwrap();
448        assert!(content.lines().any(|l| l.starts_with("vt ")));
449        std::fs::remove_file(path).ok();
450    }
451    #[test]
452    fn test_mtl_writer_with_texture() {
453        let mat = ObjMaterial {
454            name: "Textured".into(),
455            kd: [1.0, 1.0, 1.0],
456            ks: [0.0; 3],
457            ns: 1.0,
458            ka: [0.1, 0.1, 0.1],
459            dissolve: 0.8,
460            map_kd: Some("diffuse.png".into()),
461        };
462        let s = MtlWriter::write(&[mat]);
463        assert!(s.contains("map_Kd diffuse.png"));
464        assert!(s.contains("d 0.8"));
465    }
466    #[test]
467    fn test_write_smoothing_groups() {
468        let mut mesh = ObjMesh::default();
469        mesh.vertices = vec![
470            [0.0, 0.0, 0.0],
471            [1.0, 0.0, 0.0],
472            [0.0, 1.0, 0.0],
473            [1.0, 1.0, 0.0],
474        ];
475        mesh.faces.push(ObjFace {
476            vertex_indices: vec![0, 1, 2],
477            normal_indices: None,
478            uv_indices: None,
479            smoothing_group: 1,
480            material: None,
481        });
482        mesh.faces.push(ObjFace {
483            vertex_indices: vec![1, 3, 2],
484            normal_indices: None,
485            uv_indices: None,
486            smoothing_group: 2,
487            material: None,
488        });
489        let s = ObjWriter::write(&mesh);
490        assert!(s.contains("s 1"), "smoothing group 1 missing: {s}");
491        assert!(s.contains("s 2"), "smoothing group 2 missing: {s}");
492    }
493    #[test]
494    fn test_write_material_headers() {
495        let mut mesh = ObjMesh::default();
496        mesh.vertices = vec![[0.0; 3]; 4];
497        mesh.faces.push(ObjFace {
498            vertex_indices: vec![0, 1, 2],
499            normal_indices: None,
500            uv_indices: None,
501            smoothing_group: 0,
502            material: Some("Mat1".into()),
503        });
504        mesh.faces.push(ObjFace {
505            vertex_indices: vec![1, 3, 2],
506            normal_indices: None,
507            uv_indices: None,
508            smoothing_group: 0,
509            material: Some("Mat2".into()),
510        });
511        let s = ObjWriter::write(&mesh);
512        assert!(s.contains("usemtl Mat1"));
513        assert!(s.contains("usemtl Mat2"));
514    }
515
516    // ── G4: QEM decimation tests ──────────────────────────────────────────────
517
518    /// Build a UV-sphere-like triangulated mesh with `n_lat` latitude bands and
519    /// `n_lon` longitude bands.  Returns an [`ObjMesh`].
520    fn make_sphere_mesh(n_lat: usize, n_lon: usize) -> ObjMesh {
521        use std::f64::consts::PI;
522        let mut vertices: Vec<[f64; 3]> = Vec::new();
523        // Stack of latitude rings (poles included via degenerate rings).
524        for lat in 0..=n_lat {
525            let theta = PI * lat as f64 / n_lat as f64;
526            for lon in 0..n_lon {
527                let phi = 2.0 * PI * lon as f64 / n_lon as f64;
528                vertices.push([
529                    theta.sin() * phi.cos(),
530                    theta.cos(),
531                    theta.sin() * phi.sin(),
532                ]);
533            }
534        }
535        let mut faces: Vec<ObjFace> = Vec::new();
536        let idx = |lat: usize, lon: usize| lat * n_lon + (lon % n_lon);
537        for lat in 0..n_lat {
538            for lon in 0..n_lon {
539                let a = idx(lat, lon);
540                let b = idx(lat + 1, lon);
541                let c = idx(lat + 1, lon + 1);
542                let d = idx(lat, lon + 1);
543                faces.push(ObjFace {
544                    vertex_indices: vec![a, b, c],
545                    normal_indices: None,
546                    uv_indices: None,
547                    smoothing_group: 0,
548                    material: None,
549                });
550                faces.push(ObjFace {
551                    vertex_indices: vec![a, c, d],
552                    normal_indices: None,
553                    uv_indices: None,
554                    smoothing_group: 0,
555                    material: None,
556                });
557            }
558        }
559        ObjMesh {
560            vertices,
561            normals: Vec::new(),
562            uvs: Vec::new(),
563            faces,
564            groups: Vec::new(),
565        }
566    }
567
568    #[test]
569    fn test_decimate_below_target_returns_clone() {
570        let mesh = make_sphere_mesh(4, 8); // 64 faces
571        let result = ObjLod::decimate(&mesh, 200);
572        assert_eq!(result.faces.len(), mesh.faces.len());
573    }
574
575    #[test]
576    fn test_decimate_qem_reduces_face_count() {
577        let mesh = make_sphere_mesh(8, 16); // 256 faces
578        let target = 64;
579        let result = ObjLod::decimate(&mesh, target);
580        assert!(
581            result.faces.len() <= target,
582            "expected ≤{target} faces, got {}",
583            result.faces.len()
584        );
585        assert!(
586            !result.faces.is_empty(),
587            "decimated mesh must have at least one face"
588        );
589    }
590
591    #[test]
592    fn test_decimate_vertex_count_decreases() {
593        let mesh = make_sphere_mesh(8, 16); // 256 faces, (8+1)*16=144 vertices
594        let original_verts = mesh.vertices.len();
595        let result = ObjLod::decimate(&mesh, 32);
596        assert!(
597            result.vertices.len() < original_verts,
598            "QEM should reduce vertex count ({} vs {})",
599            result.vertices.len(),
600            original_verts
601        );
602    }
603}
604/// Instantiate `mesh` with a given transform, returning a new transformed `ObjMesh`.
605#[allow(dead_code)]
606pub fn instantiate_mesh(mesh: &ObjMesh, instance: &MeshInstance) -> ObjMesh {
607    let mut out = mesh.clone();
608    for v in &mut out.vertices {
609        let p = instance.transform.apply(*v);
610        *v = p;
611    }
612    for n in &mut out.normals {
613        let rot_only = MeshTransform {
614            translation: [0.0; 3],
615            scale: 1.0,
616            ..instance.transform.clone()
617        };
618        *n = rot_only.apply(*n);
619    }
620    out
621}
622/// Weld (deduplicate) vertices that are closer than `tolerance`.
623///
624/// Returns the de-duplicated mesh with remapped face indices.
625#[allow(dead_code)]
626pub fn weld_vertices(mesh: &ObjMesh, tolerance: f64) -> ObjMesh {
627    let tol2 = tolerance * tolerance;
628    let mut new_verts: Vec<[f64; 3]> = Vec::new();
629    let mut remap: Vec<usize> = Vec::with_capacity(mesh.vertices.len());
630    for &v in &mesh.vertices {
631        let found = new_verts.iter().position(|&u| {
632            let dx = u[0] - v[0];
633            let dy = u[1] - v[1];
634            let dz = u[2] - v[2];
635            dx * dx + dy * dy + dz * dz <= tol2
636        });
637        if let Some(idx) = found {
638            remap.push(idx);
639        } else {
640            remap.push(new_verts.len());
641            new_verts.push(v);
642        }
643    }
644    let mut out = ObjMesh {
645        vertices: new_verts,
646        normals: mesh.normals.clone(),
647        uvs: mesh.uvs.clone(),
648        groups: mesh.groups.clone(),
649        ..Default::default()
650    };
651    for face in &mesh.faces {
652        let new_vis: Vec<usize> = face.vertex_indices.iter().map(|&i| remap[i]).collect();
653        out.faces.push(ObjFace {
654            vertex_indices: new_vis,
655            normal_indices: face.normal_indices.clone(),
656            uv_indices: face.uv_indices.clone(),
657            smoothing_group: face.smoothing_group,
658            material: face.material.clone(),
659        });
660    }
661    out
662}
663/// Merge two `ObjMesh` instances together.
664///
665/// Vertices and faces from `b` are appended to `a` with adjusted indices.
666#[allow(dead_code)]
667pub fn merge_obj_meshes(a: &ObjMesh, b: &ObjMesh) -> ObjMesh {
668    let mut out = a.clone();
669    let v_offset = a.vertices.len();
670    let n_offset = a.normals.len();
671    let uv_offset = a.uvs.len();
672    out.vertices.extend_from_slice(&b.vertices);
673    out.normals.extend_from_slice(&b.normals);
674    out.uvs.extend_from_slice(&b.uvs);
675    for face in &b.faces {
676        let new_vis: Vec<usize> = face.vertex_indices.iter().map(|&i| i + v_offset).collect();
677        let new_ns = face
678            .normal_indices
679            .as_ref()
680            .map(|ns| ns.iter().map(|&i| i + n_offset).collect::<Vec<_>>());
681        let new_uvs = face
682            .uv_indices
683            .as_ref()
684            .map(|uvs| uvs.iter().map(|&i| i + uv_offset).collect::<Vec<_>>());
685        out.faces.push(ObjFace {
686            vertex_indices: new_vis,
687            normal_indices: new_ns,
688            uv_indices: new_uvs,
689            smoothing_group: face.smoothing_group,
690            material: face.material.clone(),
691        });
692    }
693    let face_offset = a.faces.len();
694    for g in &b.groups {
695        out.groups.push(ObjGroup {
696            name: g.name.clone(),
697            face_start: g.face_start + face_offset,
698            face_count: g.face_count,
699        });
700    }
701    out
702}
703/// Recompute per-vertex normals using area-weighted face normals.
704///
705/// The result is stored in `mesh.normals` and all face `normal_indices`
706/// are set to match the corresponding `vertex_indices`.
707#[allow(dead_code)]
708pub fn recompute_normals(mesh: &mut ObjMesh) {
709    let n = mesh.vertices.len();
710    let mut accum = vec![[0.0_f64; 3]; n];
711    let mut weights = vec![0.0_f64; n];
712    for face in &mesh.faces {
713        let vis = &face.vertex_indices;
714        if vis.len() < 3 {
715            continue;
716        }
717        for i in 1..(vis.len() - 1) {
718            let v0 = mesh.vertices[vis[0]];
719            let v1 = mesh.vertices[vis[i]];
720            let v2 = mesh.vertices[vis[i + 1]];
721            let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
722            let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
723            let nx = e1[1] * e2[2] - e1[2] * e2[1];
724            let ny = e1[2] * e2[0] - e1[0] * e2[2];
725            let nz = e1[0] * e2[1] - e1[1] * e2[0];
726            let area = (nx * nx + ny * ny + nz * nz).sqrt() * 0.5;
727            for &vi in &[vis[0], vis[i], vis[i + 1]] {
728                accum[vi][0] += nx;
729                accum[vi][1] += ny;
730                accum[vi][2] += nz;
731                weights[vi] += area;
732            }
733        }
734    }
735    mesh.normals = accum
736        .iter()
737        .zip(weights.iter())
738        .map(|(n, &w)| {
739            if w < 1e-30 {
740                [0.0, 0.0, 1.0]
741            } else {
742                let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
743                if len < 1e-30 {
744                    [0.0, 0.0, 1.0]
745                } else {
746                    [n[0] / len, n[1] / len, n[2] / len]
747                }
748            }
749        })
750        .collect();
751    for face in &mut mesh.faces {
752        let n_idx: Vec<usize> = face.vertex_indices.clone();
753        face.normal_indices = Some(n_idx);
754    }
755}
756/// Parse a Wavefront MTL file content string into a list of materials.
757#[allow(dead_code)]
758pub fn parse_mtl(data: &str) -> Vec<ObjMaterial> {
759    let mut materials: Vec<ObjMaterial> = Vec::new();
760    let mut current: Option<ObjMaterial> = None;
761    for raw in data.lines() {
762        let line = raw.trim();
763        if line.is_empty() || line.starts_with('#') {
764            continue;
765        }
766        let tokens: Vec<&str> = line.splitn(2, ' ').collect();
767        if tokens.is_empty() {
768            continue;
769        }
770        match tokens[0] {
771            "newmtl" => {
772                if let Some(mat) = current.take() {
773                    materials.push(mat);
774                }
775                let name = tokens
776                    .get(1)
777                    .map(|s| s.trim().to_string())
778                    .unwrap_or_default();
779                current = Some(ObjMaterial {
780                    name,
781                    kd: [0.8, 0.8, 0.8],
782                    ks: [0.0; 3],
783                    ns: 1.0,
784                    ka: [0.0; 3],
785                    dissolve: 1.0,
786                    map_kd: None,
787                });
788            }
789            "Kd" | "kd" => {
790                if let Some(ref mut mat) = current
791                    && let Some(rest) = tokens.get(1)
792                {
793                    let p: Vec<&str> = rest.split_whitespace().collect();
794                    if p.len() >= 3 {
795                        mat.kd[0] = p[0].parse().unwrap_or(0.8);
796                        mat.kd[1] = p[1].parse().unwrap_or(0.8);
797                        mat.kd[2] = p[2].parse().unwrap_or(0.8);
798                    }
799                }
800            }
801            "Ks" | "ks" => {
802                if let Some(ref mut mat) = current
803                    && let Some(rest) = tokens.get(1)
804                {
805                    let p: Vec<&str> = rest.split_whitespace().collect();
806                    if p.len() >= 3 {
807                        mat.ks[0] = p[0].parse().unwrap_or(0.0);
808                        mat.ks[1] = p[1].parse().unwrap_or(0.0);
809                        mat.ks[2] = p[2].parse().unwrap_or(0.0);
810                    }
811                }
812            }
813            "Ka" | "ka" => {
814                if let Some(ref mut mat) = current
815                    && let Some(rest) = tokens.get(1)
816                {
817                    let p: Vec<&str> = rest.split_whitespace().collect();
818                    if p.len() >= 3 {
819                        mat.ka[0] = p[0].parse().unwrap_or(0.0);
820                        mat.ka[1] = p[1].parse().unwrap_or(0.0);
821                        mat.ka[2] = p[2].parse().unwrap_or(0.0);
822                    }
823                }
824            }
825            "Ns" | "ns" => {
826                if let Some(ref mut mat) = current
827                    && let Some(rest) = tokens.get(1)
828                {
829                    mat.ns = rest.trim().parse().unwrap_or(1.0);
830                }
831            }
832            "d" => {
833                if let Some(ref mut mat) = current
834                    && let Some(rest) = tokens.get(1)
835                {
836                    mat.dissolve = rest.trim().parse().unwrap_or(1.0);
837                }
838            }
839            "Tr" => {
840                if let Some(ref mut mat) = current
841                    && let Some(rest) = tokens.get(1)
842                {
843                    let tr: f64 = rest.trim().parse().unwrap_or(0.0);
844                    mat.dissolve = 1.0 - tr;
845                }
846            }
847            "map_Kd" | "map_kd" => {
848                if let Some(ref mut mat) = current
849                    && let Some(rest) = tokens.get(1)
850                {
851                    mat.map_kd = Some(rest.trim().to_string());
852                }
853            }
854            _ => {}
855        }
856    }
857    if let Some(mat) = current {
858        materials.push(mat);
859    }
860    materials
861}
862/// Compute mesh statistics for an `ObjMesh`.
863#[allow(dead_code)]
864pub fn compute_mesh_stats(mesh: &ObjMesh) -> ObjMeshStats {
865    let mut mat_names: Vec<&str> = Vec::new();
866    let mut faces_with_normals = 0;
867    let mut faces_with_uvs = 0;
868    let mut surface_area = 0.0_f64;
869    for face in &mesh.faces {
870        if face.normal_indices.is_some() {
871            faces_with_normals += 1;
872        }
873        if face.uv_indices.is_some() {
874            faces_with_uvs += 1;
875        }
876        if let Some(ref m) = face.material
877            && !mat_names.contains(&m.as_str())
878        {
879            mat_names.push(m.as_str());
880        }
881        let vis = &face.vertex_indices;
882        for i in 1..(vis.len().saturating_sub(1)) {
883            if vis[0] < mesh.vertices.len()
884                && vis[i] < mesh.vertices.len()
885                && vis[i + 1] < mesh.vertices.len()
886            {
887                let v0 = mesh.vertices[vis[0]];
888                let v1 = mesh.vertices[vis[i]];
889                let v2 = mesh.vertices[vis[i + 1]];
890                let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
891                let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
892                let cx = e1[1] * e2[2] - e1[2] * e2[1];
893                let cy = e1[2] * e2[0] - e1[0] * e2[2];
894                let cz = e1[0] * e2[1] - e1[1] * e2[0];
895                surface_area += (cx * cx + cy * cy + cz * cz).sqrt() * 0.5;
896            }
897        }
898    }
899    ObjMeshStats {
900        vertex_count: mesh.vertices.len(),
901        face_count: mesh.faces.len(),
902        triangle_count: mesh.triangle_count(),
903        material_count: mat_names.len(),
904        group_count: mesh.groups.len(),
905        faces_with_normals,
906        faces_with_uvs,
907        surface_area,
908        bbox: mesh.bounding_box(),
909    }
910}