Skip to main content

oxiphysics_io/
wavefront_extended.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Extended Wavefront OBJ format with physics metadata.
5//!
6//! Supports per-vertex velocity annotations and per-material density
7//! annotations stored as `# phys_*` comment lines, as well as rigid-body
8//! data extraction (centre of mass, inertia tensor) from a mesh with known
9//! material densities.
10
11// ── Data types ────────────────────────────────────────────────────────────────
12
13/// A vertex in the extended OBJ mesh.
14#[derive(Debug, Clone, PartialEq)]
15pub struct PhysicsObjVertex {
16    /// 3-D position `[x, y, z]`.
17    pub pos: [f64; 3],
18    /// Optional per-vertex velocity `[vx, vy, vz]`, from `# phys_vel` annotations.
19    pub velocity: Option<[f64; 3]>,
20}
21
22/// A triangular face referencing three 0-based vertex indices.
23#[derive(Debug, Clone, PartialEq)]
24pub struct PhysicsObjFace {
25    /// Vertex indices (0-based).
26    pub indices: [usize; 3],
27    /// Material name for this face (empty string = no material).
28    pub material: String,
29}
30
31/// A material entry with an associated density.
32#[derive(Debug, Clone, PartialEq)]
33pub struct PhysicsObjMaterial {
34    /// Material name.
35    pub name: String,
36    /// Mass density in kg/m³.
37    pub density: f64,
38}
39
40/// Extended OBJ mesh holding geometry and physics metadata.
41#[derive(Debug, Clone, Default)]
42pub struct PhysicsObjMesh {
43    /// Vertex list.
44    pub vertices: Vec<PhysicsObjVertex>,
45    /// Triangle face list.
46    pub faces: Vec<PhysicsObjFace>,
47    /// Material table.
48    pub materials: Vec<PhysicsObjMaterial>,
49}
50
51impl PhysicsObjMesh {
52    /// Create an empty mesh.
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Look up density for a material name, returning `1.0` if not found.
58    pub fn density_for(&self, name: &str) -> f64 {
59        self.materials
60            .iter()
61            .find(|m| m.name == name)
62            .map(|m| m.density)
63            .unwrap_or(1.0)
64    }
65}
66
67/// Rigid body properties extracted from a mesh.
68#[derive(Debug, Clone)]
69pub struct RigidBodyData {
70    /// Total mass in kg.
71    pub mass: f64,
72    /// Centre of mass in world space.
73    pub com: [f64; 3],
74    /// Symmetric inertia tensor (6 components: Ixx, Iyy, Izz, Ixy, Ixz, Iyz).
75    pub inertia: [f64; 6],
76}
77
78// ── Parsing helpers ───────────────────────────────────────────────────────────
79
80fn parse_f64_triple(parts: &[&str], offset: usize) -> Option<[f64; 3]> {
81    if parts.len() < offset + 3 {
82        return None;
83    }
84    let x = parts[offset].parse::<f64>().ok()?;
85    let y = parts[offset + 1].parse::<f64>().ok()?;
86    let z = parts[offset + 2].parse::<f64>().ok()?;
87    Some([x, y, z])
88}
89
90fn parse_face_index(s: &str) -> Option<usize> {
91    // OBJ face indices can be "v", "v/t", "v/t/n", "v//n" — take the first
92    let v_str = s.split('/').next()?;
93    let idx: isize = v_str.parse().ok()?;
94    if idx > 0 {
95        Some((idx - 1) as usize) // 1-based → 0-based
96    } else {
97        None
98    }
99}
100
101// ── I/O functions ─────────────────────────────────────────────────────────────
102
103/// Parse an extended Wavefront OBJ string with `# phys_*` annotations.
104///
105/// Recognised annotations (must appear immediately after the vertex/material
106/// they annotate):
107/// - `# phys_vel vx vy vz` — velocity for the last declared vertex
108/// - `# phys_density name value` — density for material `name`
109///
110/// Standard OBJ keywords handled: `v`, `f`, `usemtl`.
111pub fn read_physics_obj(src: &str) -> PhysicsObjMesh {
112    let mut mesh = PhysicsObjMesh::new();
113    let mut current_material = String::new();
114
115    for line in src.lines() {
116        let line = line.trim();
117        if line.is_empty() {
118            continue;
119        }
120
121        let parts: Vec<&str> = line.split_whitespace().collect();
122
123        match parts[0] {
124            "v" => {
125                if let Some(pos) = parse_f64_triple(&parts, 1) {
126                    mesh.vertices.push(PhysicsObjVertex {
127                        pos,
128                        velocity: None,
129                    });
130                }
131            }
132            "usemtl"
133                if parts.len() > 1 => {
134                    current_material = parts[1].to_string();
135                }
136            "f"
137                // Only handle triangles (exactly 3 indices)
138                if parts.len() >= 4 => {
139                    let a = parse_face_index(parts[1]);
140                    let b = parse_face_index(parts[2]);
141                    let c = parse_face_index(parts[3]);
142                    if let (Some(a), Some(b), Some(c)) = (a, b, c) {
143                        mesh.faces.push(PhysicsObjFace {
144                            indices: [a, b, c],
145                            material: current_material.clone(),
146                        });
147                    }
148                }
149            "#" if parts.len() >= 2 && parts[1] == "phys_vel" => {
150                if let Some(vel) = parse_f64_triple(&parts, 2)
151                    && let Some(last) = mesh.vertices.last_mut() {
152                        last.velocity = Some(vel);
153                    }
154            }
155            "#" if parts.len() >= 4 && parts[1] == "phys_density" => {
156                let mat_name = parts[2].to_string();
157                if let Ok(density) = parts[3].parse::<f64>() {
158                    // Update existing or push new
159                    if let Some(m) = mesh.materials.iter_mut().find(|m| m.name == mat_name) {
160                        m.density = density;
161                    } else {
162                        mesh.materials.push(PhysicsObjMaterial {
163                            name: mat_name,
164                            density,
165                        });
166                    }
167                }
168            }
169            _ => {}
170        }
171    }
172
173    mesh
174}
175
176/// Serialise a `PhysicsObjMesh` to the extended OBJ text format.
177///
178/// Writes:
179/// - `v x y z` for each vertex, optionally followed by `# phys_vel vx vy vz`
180/// - `# phys_density name value` for each material
181/// - `usemtl name` / `f i j k` for each face
182pub fn write_physics_obj(mesh: &PhysicsObjMesh) -> String {
183    let mut out = String::new();
184
185    // Material density annotations
186    for mat in &mesh.materials {
187        out.push_str(&material_density_annotation(&mat.name, mat.density));
188        out.push('\n');
189    }
190
191    // Vertices
192    for v in &mesh.vertices {
193        out.push_str(&format!("v {} {} {}\n", v.pos[0], v.pos[1], v.pos[2]));
194        if let Some(vel) = v.velocity {
195            out.push_str(&vertex_velocity_annotation(vel));
196            out.push('\n');
197        }
198    }
199
200    // Faces (grouped by material for readability)
201    let mut last_mat = "";
202    for face in &mesh.faces {
203        if face.material != last_mat {
204            if !face.material.is_empty() {
205                out.push_str(&format!("usemtl {}\n", face.material));
206            }
207            last_mat = &face.material;
208        }
209        // OBJ is 1-based
210        out.push_str(&format!(
211            "f {} {} {}\n",
212            face.indices[0] + 1,
213            face.indices[1] + 1,
214            face.indices[2] + 1
215        ));
216    }
217
218    out
219}
220
221/// Format a per-vertex velocity as an OBJ comment annotation.
222pub fn vertex_velocity_annotation(vel: [f64; 3]) -> String {
223    format!("# phys_vel {} {} {}", vel[0], vel[1], vel[2])
224}
225
226/// Format a per-material density as an OBJ comment annotation.
227pub fn material_density_annotation(name: &str, density: f64) -> String {
228    format!("# phys_density {name} {density}")
229}
230
231/// Compute rigid-body properties (mass, COM, inertia) from a mesh.
232///
233/// Each triangle is treated as a thin planar element.  Mass is proportional
234/// to triangle area times the density of the face material.  The inertia
235/// tensor is approximated from the point masses at triangle centroids.
236pub fn extract_rigid_body_data(mesh: &PhysicsObjMesh) -> RigidBodyData {
237    let mut total_mass = 0.0_f64;
238    let mut com = [0.0_f64; 3];
239
240    // First pass: total mass and COM numerator
241    for face in &mesh.faces {
242        let [a, b, c] = face.indices;
243        if a >= mesh.vertices.len() || b >= mesh.vertices.len() || c >= mesh.vertices.len() {
244            continue;
245        }
246        let pa = mesh.vertices[a].pos;
247        let pb = mesh.vertices[b].pos;
248        let pc = mesh.vertices[c].pos;
249
250        let area = triangle_area(pa, pb, pc);
251        let density = mesh.density_for(&face.material);
252        let mass = area * density;
253        let centroid = [
254            (pa[0] + pb[0] + pc[0]) / 3.0,
255            (pa[1] + pb[1] + pc[1]) / 3.0,
256            (pa[2] + pb[2] + pc[2]) / 3.0,
257        ];
258
259        total_mass += mass;
260        com[0] += mass * centroid[0];
261        com[1] += mass * centroid[1];
262        com[2] += mass * centroid[2];
263    }
264
265    if total_mass > 0.0 {
266        com[0] /= total_mass;
267        com[1] /= total_mass;
268        com[2] /= total_mass;
269    }
270
271    // Second pass: inertia tensor (Ixx, Iyy, Izz, Ixy, Ixz, Iyz)
272    let mut inertia = [0.0_f64; 6];
273    for face in &mesh.faces {
274        let [a, b, c] = face.indices;
275        if a >= mesh.vertices.len() || b >= mesh.vertices.len() || c >= mesh.vertices.len() {
276            continue;
277        }
278        let pa = mesh.vertices[a].pos;
279        let pb = mesh.vertices[b].pos;
280        let pc = mesh.vertices[c].pos;
281
282        let area = triangle_area(pa, pb, pc);
283        let density = mesh.density_for(&face.material);
284        let mass = area * density;
285        let r = [
286            (pa[0] + pb[0] + pc[0]) / 3.0 - com[0],
287            (pa[1] + pb[1] + pc[1]) / 3.0 - com[1],
288            (pa[2] + pb[2] + pc[2]) / 3.0 - com[2],
289        ];
290
291        inertia[0] += mass * (r[1] * r[1] + r[2] * r[2]); // Ixx
292        inertia[1] += mass * (r[0] * r[0] + r[2] * r[2]); // Iyy
293        inertia[2] += mass * (r[0] * r[0] + r[1] * r[1]); // Izz
294        inertia[3] -= mass * r[0] * r[1]; // Ixy
295        inertia[4] -= mass * r[0] * r[2]; // Ixz
296        inertia[5] -= mass * r[1] * r[2]; // Iyz
297    }
298
299    RigidBodyData {
300        mass: total_mass,
301        com,
302        inertia,
303    }
304}
305
306// ── Internal helper ───────────────────────────────────────────────────────────
307
308fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
309    [
310        a[1] * b[2] - a[2] * b[1],
311        a[2] * b[0] - a[0] * b[2],
312        a[0] * b[1] - a[1] * b[0],
313    ]
314}
315
316fn length3(v: [f64; 3]) -> f64 {
317    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
318}
319
320fn triangle_area(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
321    let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
322    let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
323    0.5 * length3(cross3(ab, ac))
324}
325
326// ── Tests ─────────────────────────────────────────────────────────────────────
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    // --- Annotation helpers ---
333
334    #[test]
335    fn test_vertex_velocity_annotation_format() {
336        let ann = vertex_velocity_annotation([1.0, 2.0, 3.0]);
337        assert!(ann.contains("phys_vel"));
338        assert!(ann.contains("1"));
339        assert!(ann.contains("2"));
340        assert!(ann.contains("3"));
341    }
342
343    #[test]
344    fn test_material_density_annotation_format() {
345        let ann = material_density_annotation("steel", 7800.0);
346        assert!(ann.contains("phys_density"));
347        assert!(ann.contains("steel"));
348        assert!(ann.contains("7800"));
349    }
350
351    // --- read_physics_obj ---
352
353    #[test]
354    fn test_read_empty_string() {
355        let mesh = read_physics_obj("");
356        assert!(mesh.vertices.is_empty());
357        assert!(mesh.faces.is_empty());
358    }
359
360    #[test]
361    fn test_read_single_vertex() {
362        let src = "v 1.0 2.0 3.0\n";
363        let mesh = read_physics_obj(src);
364        assert_eq!(mesh.vertices.len(), 1);
365        assert_eq!(mesh.vertices[0].pos, [1.0, 2.0, 3.0]);
366    }
367
368    #[test]
369    fn test_read_vertex_no_velocity_by_default() {
370        let src = "v 0.0 0.0 0.0\n";
371        let mesh = read_physics_obj(src);
372        assert!(mesh.vertices[0].velocity.is_none());
373    }
374
375    #[test]
376    fn test_read_phys_vel_annotation() {
377        let src = "v 0.0 0.0 0.0\n# phys_vel 1.0 2.0 3.0\n";
378        let mesh = read_physics_obj(src);
379        assert_eq!(mesh.vertices[0].velocity, Some([1.0, 2.0, 3.0]));
380    }
381
382    #[test]
383    fn test_read_triangle_face() {
384        let src = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
385        let mesh = read_physics_obj(src);
386        assert_eq!(mesh.faces.len(), 1);
387        assert_eq!(mesh.faces[0].indices, [0, 1, 2]);
388    }
389
390    #[test]
391    fn test_read_usemtl_sets_material() {
392        let src = "v 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl steel\nf 1 2 3\n";
393        let mesh = read_physics_obj(src);
394        assert_eq!(mesh.faces[0].material, "steel");
395    }
396
397    #[test]
398    fn test_read_phys_density() {
399        let src = "# phys_density wood 500.0\n";
400        let mesh = read_physics_obj(src);
401        assert_eq!(mesh.materials.len(), 1);
402        assert_eq!(mesh.materials[0].name, "wood");
403        assert!((mesh.materials[0].density - 500.0).abs() < 1e-9);
404    }
405
406    #[test]
407    fn test_density_for_unknown_material_returns_one() {
408        let mesh = PhysicsObjMesh::new();
409        assert!((mesh.density_for("unknown") - 1.0).abs() < 1e-9);
410    }
411
412    #[test]
413    fn test_density_for_known_material() {
414        let mut mesh = PhysicsObjMesh::new();
415        mesh.materials.push(PhysicsObjMaterial {
416            name: "steel".into(),
417            density: 7800.0,
418        });
419        assert!((mesh.density_for("steel") - 7800.0).abs() < 1e-9);
420    }
421
422    // --- write_physics_obj ---
423
424    #[test]
425    fn test_write_contains_vertex() {
426        let mut mesh = PhysicsObjMesh::new();
427        mesh.vertices.push(PhysicsObjVertex {
428            pos: [1.0, 2.0, 3.0],
429            velocity: None,
430        });
431        let out = write_physics_obj(&mesh);
432        assert!(out.contains("v 1"));
433    }
434
435    #[test]
436    fn test_write_contains_velocity_annotation() {
437        let mut mesh = PhysicsObjMesh::new();
438        mesh.vertices.push(PhysicsObjVertex {
439            pos: [0.0; 3],
440            velocity: Some([4.0, 5.0, 6.0]),
441        });
442        let out = write_physics_obj(&mesh);
443        assert!(out.contains("phys_vel"));
444    }
445
446    #[test]
447    fn test_write_contains_face() {
448        let mut mesh = PhysicsObjMesh::new();
449        mesh.vertices.push(PhysicsObjVertex {
450            pos: [0.0; 3],
451            velocity: None,
452        });
453        mesh.vertices.push(PhysicsObjVertex {
454            pos: [1.0, 0.0, 0.0],
455            velocity: None,
456        });
457        mesh.vertices.push(PhysicsObjVertex {
458            pos: [0.0, 1.0, 0.0],
459            velocity: None,
460        });
461        mesh.faces.push(PhysicsObjFace {
462            indices: [0, 1, 2],
463            material: String::new(),
464        });
465        let out = write_physics_obj(&mesh);
466        assert!(out.contains("f 1 2 3"));
467    }
468
469    #[test]
470    fn test_write_contains_density_annotation() {
471        let mut mesh = PhysicsObjMesh::new();
472        mesh.materials.push(PhysicsObjMaterial {
473            name: "iron".into(),
474            density: 7874.0,
475        });
476        let out = write_physics_obj(&mesh);
477        assert!(out.contains("phys_density"));
478        assert!(out.contains("iron"));
479    }
480
481    // --- Roundtrip ---
482
483    fn simple_triangle_mesh() -> PhysicsObjMesh {
484        let mut mesh = PhysicsObjMesh::new();
485        mesh.materials.push(PhysicsObjMaterial {
486            name: "mat".into(),
487            density: 1000.0,
488        });
489        mesh.vertices.push(PhysicsObjVertex {
490            pos: [0.0, 0.0, 0.0],
491            velocity: Some([1.0, 0.0, 0.0]),
492        });
493        mesh.vertices.push(PhysicsObjVertex {
494            pos: [1.0, 0.0, 0.0],
495            velocity: None,
496        });
497        mesh.vertices.push(PhysicsObjVertex {
498            pos: [0.0, 1.0, 0.0],
499            velocity: None,
500        });
501        mesh.faces.push(PhysicsObjFace {
502            indices: [0, 1, 2],
503            material: "mat".into(),
504        });
505        mesh
506    }
507
508    #[test]
509    fn test_roundtrip_vertex_count() {
510        let mesh = simple_triangle_mesh();
511        let text = write_physics_obj(&mesh);
512        let mesh2 = read_physics_obj(&text);
513        assert_eq!(mesh2.vertices.len(), mesh.vertices.len());
514    }
515
516    #[test]
517    fn test_roundtrip_face_count() {
518        let mesh = simple_triangle_mesh();
519        let text = write_physics_obj(&mesh);
520        let mesh2 = read_physics_obj(&text);
521        assert_eq!(mesh2.faces.len(), mesh.faces.len());
522    }
523
524    #[test]
525    fn test_roundtrip_velocity() {
526        let mesh = simple_triangle_mesh();
527        let text = write_physics_obj(&mesh);
528        let mesh2 = read_physics_obj(&text);
529        assert_eq!(mesh2.vertices[0].velocity, Some([1.0, 0.0, 0.0]));
530    }
531
532    #[test]
533    fn test_roundtrip_material_density() {
534        let mesh = simple_triangle_mesh();
535        let text = write_physics_obj(&mesh);
536        let mesh2 = read_physics_obj(&text);
537        assert!((mesh2.density_for("mat") - 1000.0).abs() < 1e-6);
538    }
539
540    #[test]
541    fn test_roundtrip_face_indices() {
542        let mesh = simple_triangle_mesh();
543        let text = write_physics_obj(&mesh);
544        let mesh2 = read_physics_obj(&text);
545        assert_eq!(mesh2.faces[0].indices, [0, 1, 2]);
546    }
547
548    // --- extract_rigid_body_data ---
549
550    #[test]
551    fn test_com_single_triangle_at_origin() {
552        let mut mesh = PhysicsObjMesh::new();
553        mesh.vertices.push(PhysicsObjVertex {
554            pos: [0.0, 0.0, 0.0],
555            velocity: None,
556        });
557        mesh.vertices.push(PhysicsObjVertex {
558            pos: [2.0, 0.0, 0.0],
559            velocity: None,
560        });
561        mesh.vertices.push(PhysicsObjVertex {
562            pos: [0.0, 2.0, 0.0],
563            velocity: None,
564        });
565        mesh.faces.push(PhysicsObjFace {
566            indices: [0, 1, 2],
567            material: String::new(),
568        });
569        let rb = extract_rigid_body_data(&mesh);
570        // Centroid of equilateral-like triangle with these vertices
571        assert!(
572            (rb.com[0] - 2.0 / 3.0).abs() < 1e-9,
573            "com_x = {}",
574            rb.com[0]
575        );
576        assert!(
577            (rb.com[1] - 2.0 / 3.0).abs() < 1e-9,
578            "com_y = {}",
579            rb.com[1]
580        );
581    }
582
583    #[test]
584    fn test_mass_proportional_to_area_and_density() {
585        let mut mesh = PhysicsObjMesh::new();
586        mesh.materials.push(PhysicsObjMaterial {
587            name: "m".into(),
588            density: 2.0,
589        });
590        mesh.vertices.push(PhysicsObjVertex {
591            pos: [0.0, 0.0, 0.0],
592            velocity: None,
593        });
594        mesh.vertices.push(PhysicsObjVertex {
595            pos: [1.0, 0.0, 0.0],
596            velocity: None,
597        });
598        mesh.vertices.push(PhysicsObjVertex {
599            pos: [0.0, 1.0, 0.0],
600            velocity: None,
601        });
602        mesh.faces.push(PhysicsObjFace {
603            indices: [0, 1, 2],
604            material: "m".into(),
605        });
606        let rb = extract_rigid_body_data(&mesh);
607        // Area = 0.5, density = 2.0 => mass = 1.0
608        assert!((rb.mass - 1.0).abs() < 1e-9, "mass = {}", rb.mass);
609    }
610
611    #[test]
612    fn test_empty_mesh_zero_mass() {
613        let mesh = PhysicsObjMesh::new();
614        let rb = extract_rigid_body_data(&mesh);
615        assert!((rb.mass).abs() < 1e-12);
616    }
617
618    #[test]
619    fn test_inertia_tensor_has_six_components() {
620        let mesh = PhysicsObjMesh::new();
621        let rb = extract_rigid_body_data(&mesh);
622        assert_eq!(rb.inertia.len(), 6);
623    }
624
625    #[test]
626    fn test_inertia_diagonal_nonnegative() {
627        let mesh = simple_triangle_mesh();
628        let rb = extract_rigid_body_data(&mesh);
629        assert!(rb.inertia[0] >= 0.0, "Ixx negative");
630        assert!(rb.inertia[1] >= 0.0, "Iyy negative");
631        assert!(rb.inertia[2] >= 0.0, "Izz negative");
632    }
633
634    #[test]
635    fn test_triangle_area_helper() {
636        let a = [0.0; 3];
637        let b = [1.0, 0.0, 0.0];
638        let c = [0.0, 1.0, 0.0];
639        let area = triangle_area(a, b, c);
640        assert!((area - 0.5).abs() < 1e-12, "area = {area}");
641    }
642}