1#[derive(Debug, Clone, PartialEq)]
15pub struct PhysicsObjVertex {
16 pub pos: [f64; 3],
18 pub velocity: Option<[f64; 3]>,
20}
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct PhysicsObjFace {
25 pub indices: [usize; 3],
27 pub material: String,
29}
30
31#[derive(Debug, Clone, PartialEq)]
33pub struct PhysicsObjMaterial {
34 pub name: String,
36 pub density: f64,
38}
39
40#[derive(Debug, Clone, Default)]
42pub struct PhysicsObjMesh {
43 pub vertices: Vec<PhysicsObjVertex>,
45 pub faces: Vec<PhysicsObjFace>,
47 pub materials: Vec<PhysicsObjMaterial>,
49}
50
51impl PhysicsObjMesh {
52 pub fn new() -> Self {
54 Self::default()
55 }
56
57 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#[derive(Debug, Clone)]
69pub struct RigidBodyData {
70 pub mass: f64,
72 pub com: [f64; 3],
74 pub inertia: [f64; 6],
76}
77
78fn 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 let v_str = s.split('/').next()?;
93 let idx: isize = v_str.parse().ok()?;
94 if idx > 0 {
95 Some((idx - 1) as usize) } else {
97 None
98 }
99}
100
101pub 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 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 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
176pub fn write_physics_obj(mesh: &PhysicsObjMesh) -> String {
183 let mut out = String::new();
184
185 for mat in &mesh.materials {
187 out.push_str(&material_density_annotation(&mat.name, mat.density));
188 out.push('\n');
189 }
190
191 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 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 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
221pub fn vertex_velocity_annotation(vel: [f64; 3]) -> String {
223 format!("# phys_vel {} {} {}", vel[0], vel[1], vel[2])
224}
225
226pub fn material_density_annotation(name: &str, density: f64) -> String {
228 format!("# phys_density {name} {density}")
229}
230
231pub 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 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 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]); inertia[1] += mass * (r[0] * r[0] + r[2] * r[2]); inertia[2] += mass * (r[0] * r[0] + r[1] * r[1]); inertia[3] -= mass * r[0] * r[1]; inertia[4] -= mass * r[0] * r[2]; inertia[5] -= mass * r[1] * r[2]; }
298
299 RigidBodyData {
300 mass: total_mass,
301 com,
302 inertia,
303 }
304}
305
306fn 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#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[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 #[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 #[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 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 #[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 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 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}