1use glam::{Quat, Vec3};
2
3use loam_math::{Bivector, Bivector3, EuclideanR3, Iso3};
4
5use crate::body::{BodyDef, RigidBody};
6use crate::collider::{Collider, ColliderKind};
7use crate::collision::{epa, gjk_intersect, GjkResult, PosedHull, Sphere as GjkSphere};
8use crate::geometry::GeometryStore;
9use crate::integrator::{BroadphaseBound, PhysicsSpace};
10use crate::narrowphase::Narrowphase;
11use crate::response::Contact;
12
13fn rotor_to_quat(r: loam_math::Rotor3) -> Quat {
15 Quat::from_xyzw(r.yz, r.zx, r.xy, r.s)
16}
17
18fn omega_cross_r(w: Bivector3, r: Vec3) -> Vec3 {
19 Vec3::new(
20 w.zx * r.z - w.xy * r.y,
21 w.xy * r.x - w.yz * r.z,
22 w.yz * r.y - w.zx * r.x,
23 )
24}
25
26fn wedge(r: Vec3, f: Vec3) -> Bivector3 {
27 Bivector3::new(
28 r.x * f.y - r.y * f.x,
29 r.y * f.z - r.z * f.y,
30 r.z * f.x - r.x * f.z,
31 )
32}
33
34fn bivec_mag_sq(b: Bivector3) -> f32 {
35 b.xy * b.xy + b.yz * b.yz + b.zx * b.zx
36}
37
38fn inv_inertia(body: &RigidBody<EuclideanR3>) -> f32 {
39 if body.inv_mass() > 0.0 && body.inertia > 0.0 {
40 1.0 / body.inertia
41 } else {
42 0.0
43 }
44}
45
46impl PhysicsSpace for EuclideanR3 {
47 type AngVel = Bivector3;
48 type Inertia = f32;
49
50 fn broadphase_bound(&self) -> BroadphaseBound {
51 BroadphaseBound::Certified
52 }
53
54 fn supports_collider(&self, kind: ColliderKind) -> bool {
55 matches!(
56 kind,
57 ColliderKind::Sphere | ColliderKind::ConvexPolytope3D | ColliderKind::HalfSpace
58 )
59 }
60
61 fn valid_vector(&self, vector: Vec3) -> bool {
62 vector.is_finite()
63 }
64
65 fn valid_orientation(&self, orientation: Iso3) -> bool {
66 orientation.rotation.is_finite()
67 && (orientation.rotation.length_squared() - 1.0).abs() <= 1e-4
68 && orientation.translation.is_finite()
69 }
70
71 fn valid_angular_velocity(&self, angular_velocity: Bivector3) -> bool {
72 angular_velocity.xy.is_finite()
73 && angular_velocity.yz.is_finite()
74 && angular_velocity.zx.is_finite()
75 }
76
77 fn valid_inertia(&self, inertia: f32) -> bool {
78 inertia.is_finite() && inertia >= 0.0 && (inertia == 0.0 || inertia.recip().is_finite())
79 }
80
81 fn integrate_orientation(&self, iso: Iso3, omega: Bivector3, dt: f32) -> Iso3 {
82 debug_assert!(
83 omega.xy.is_finite() && omega.yz.is_finite() && omega.zx.is_finite(),
84 "non-finite Bivector3 angular velocity in integrate_orientation",
85 );
86 let delta_rotor = (omega * dt).exp();
87 let delta_quat = rotor_to_quat(delta_rotor);
88 let composed = delta_quat * iso.rotation;
89 let rotation = composed.normalize();
90 Iso3 {
91 rotation,
92 translation: iso.translation,
93 }
94 }
95
96 fn apply_inv_inertia(&self, inertia: f32, torque: Bivector3) -> Bivector3 {
97 if inertia > 0.0 {
98 torque * (1.0 / inertia)
99 } else {
100 Bivector3::ZERO
101 }
102 }
103
104 fn wedge(&self, a: Vec3, b: Vec3) -> Bivector3 {
105 wedge(a, b)
106 }
107
108 fn velocity_at_point(&self, body: &RigidBody<EuclideanR3>, p: Vec3) -> Vec3 {
109 let r = p - body.position;
110 body.velocity + omega_cross_r(body.angular_velocity, r)
111 }
112
113 fn effective_mass_inv(
114 &self,
115 a: &RigidBody<EuclideanR3>,
116 b: &RigidBody<EuclideanR3>,
117 contact_point: Vec3,
118 direction: Vec3,
119 ) -> f32 {
120 let ra = contact_point - a.position;
121 let rb = contact_point - b.position;
122 let ra_wedge = wedge(ra, direction);
123 let rb_wedge = wedge(rb, direction);
124 a.inv_mass()
125 + b.inv_mass()
126 + bivec_mag_sq(ra_wedge) * inv_inertia(a)
127 + bivec_mag_sq(rb_wedge) * inv_inertia(b)
128 }
129
130 fn apply_contact_impulse(
131 &self,
132 a: &mut RigidBody<EuclideanR3>,
133 b: &mut RigidBody<EuclideanR3>,
134 contact_point: Vec3,
135 direction: Vec3,
136 magnitude: f32,
137 ) {
138 let ra = contact_point - a.position;
139 let rb = contact_point - b.position;
140 let lin = direction * magnitude;
141 a.velocity -= lin * a.inv_mass();
142 b.velocity += lin * b.inv_mass();
143 let inv_i_a = inv_inertia(a);
144 let inv_i_b = inv_inertia(b);
145 a.angular_velocity = a.angular_velocity + wedge(ra, lin) * (-inv_i_a);
146 b.angular_velocity = b.angular_velocity + wedge(rb, lin) * inv_i_b;
147 }
148}
149
150fn sphere_sphere_r3(
151 a: &RigidBody<EuclideanR3>,
152 b: &RigidBody<EuclideanR3>,
153 geometry: &GeometryStore,
154 space: &EuclideanR3,
155) -> Option<Contact<EuclideanR3>> {
156 let Some(&Collider::Sphere { radius: ra, .. }) = geometry.get(a.collider()) else {
157 return None;
158 };
159 let Some(&Collider::Sphere { radius: rb, .. }) = geometry.get(b.collider()) else {
160 return None;
161 };
162
163 use loam_math::Space;
164 let d = space.distance(a.position, b.position);
165 let combined = ra + rb;
166 if d >= combined {
167 return None;
168 }
169 let log = space.log(a.position, b.position);
170 let len = log.length();
171 let normal = if len > 1e-8 { log / len } else { Vec3::Y };
172
173 let surface_a = a.position + normal * ra;
174 let surface_b = b.position - normal * rb;
175 let point = (surface_a + surface_b) * 0.5;
176
177 Some(Contact {
178 normal,
179 point,
180 penetration: combined - d,
181 restitution: (a.restitution + b.restitution) * 0.5,
182 })
183}
184
185fn sphere_halfspace_r3(
186 a: &RigidBody<EuclideanR3>,
187 b: &RigidBody<EuclideanR3>,
188 geometry: &GeometryStore,
189 _space: &EuclideanR3,
190) -> Option<Contact<EuclideanR3>> {
191 let Some(&Collider::Sphere { radius, .. }) = geometry.get(a.collider()) else {
192 return None;
193 };
194 let Some(&Collider::HalfSpace { normal, offset }) = geometry.get(b.collider()) else {
195 return None;
196 };
197 let normal = b.orientation.rotation * normal;
198 let offset = offset + normal.dot(b.position);
199 let signed = a.position.dot(normal) - offset;
200 let penetration = radius - signed;
201 if penetration <= 0.0 {
202 return None;
203 }
204 let contact_normal = -normal;
205 let point = a.position - normal * radius;
206 Some(Contact {
207 normal: contact_normal,
208 point,
209 penetration,
210 restitution: (a.restitution + b.restitution) * 0.5,
211 })
212}
213
214fn validate_contact(
215 info: &crate::collision::ContactInfo,
216 a: &RigidBody<EuclideanR3>,
217 b: &RigidBody<EuclideanR3>,
218) -> Option<Contact<EuclideanR3>> {
219 if !info.penetration.is_finite()
220 || info.penetration <= 0.0
221 || !info.normal.is_finite()
222 || !info.point.is_finite()
223 {
224 return None;
225 }
226 let n2 = info.normal.length_squared();
227 if !(0.5..=1.5).contains(&n2) {
228 return None;
229 }
230 Some(Contact {
231 normal: info.normal,
232 point: info.point,
233 penetration: info.penetration,
234 restitution: (a.restitution + b.restitution) * 0.5,
235 })
236}
237
238fn polytope_bounding_radius(local_vertices: &[Vec3]) -> f32 {
239 local_vertices
240 .iter()
241 .map(|v| v.length_squared())
242 .fold(0.0_f32, f32::max)
243 .sqrt()
244}
245
246fn polytope_polytope_r3(
247 a: &RigidBody<EuclideanR3>,
248 b: &RigidBody<EuclideanR3>,
249 geometry: &GeometryStore,
250 _space: &EuclideanR3,
251) -> Option<Contact<EuclideanR3>> {
252 let Some(Collider::ConvexPolytope3D { vertices: va_local }) = geometry.get(a.collider()) else {
253 return None;
254 };
255 let Some(Collider::ConvexPolytope3D { vertices: vb_local }) = geometry.get(b.collider()) else {
256 return None;
257 };
258
259 let ra = polytope_bounding_radius(va_local);
260 let rb = polytope_bounding_radius(vb_local);
261 let center_dist_sq = (b.position - a.position).length_squared();
262 let combined = ra + rb;
263 if center_dist_sq > combined * combined {
264 return None;
265 }
266
267 let hull_a = PosedHull {
268 local: va_local,
269 position: a.position,
270 rotation: a.orientation.rotation,
271 };
272 let hull_b = PosedHull {
273 local: vb_local,
274 position: b.position,
275 rotation: b.orientation.rotation,
276 };
277
278 let initial_dir = b.position - a.position;
279 let simplex = match gjk_intersect(&hull_a, &hull_b, initial_dir) {
280 GjkResult::Intersecting { simplex } => simplex,
281 GjkResult::Separated => return None,
282 };
283 let info = epa(&hull_a, &hull_b, simplex, combined)?;
284 validate_contact(&info, a, b)
285}
286
287fn sphere_polytope_r3(
288 a: &RigidBody<EuclideanR3>,
289 b: &RigidBody<EuclideanR3>,
290 geometry: &GeometryStore,
291 _space: &EuclideanR3,
292) -> Option<Contact<EuclideanR3>> {
293 let Some(&Collider::Sphere { radius, .. }) = geometry.get(a.collider()) else {
294 return None;
295 };
296 let Some(Collider::ConvexPolytope3D { vertices: vb_local }) = geometry.get(b.collider()) else {
297 return None;
298 };
299
300 let rb = polytope_bounding_radius(vb_local);
301 let center_dist_sq = (b.position - a.position).length_squared();
302 let combined = radius + rb;
303 if center_dist_sq > combined * combined {
304 return None;
305 }
306
307 let support_a = GjkSphere {
308 center: a.position,
309 radius,
310 };
311 let support_b = PosedHull {
312 local: vb_local,
313 position: b.position,
314 rotation: b.orientation.rotation,
315 };
316
317 let initial_dir = b.position - a.position;
318 let simplex = match gjk_intersect(&support_a, &support_b, initial_dir) {
319 GjkResult::Intersecting { simplex } => simplex,
320 GjkResult::Separated => return None,
321 };
322 let info = epa(&support_a, &support_b, simplex, combined)?;
323 validate_contact(&info, a, b)
324}
325
326fn polytope_halfspace_r3(
327 a: &RigidBody<EuclideanR3>,
328 b: &RigidBody<EuclideanR3>,
329 geometry: &GeometryStore,
330 _space: &EuclideanR3,
331) -> Option<Contact<EuclideanR3>> {
332 let Some(Collider::ConvexPolytope3D { vertices: va_local }) = geometry.get(a.collider()) else {
333 return None;
334 };
335 let Some(&Collider::HalfSpace {
336 normal: plane_n,
337 offset,
338 }) = geometry.get(b.collider())
339 else {
340 return None;
341 };
342 let plane_n = b.orientation.rotation * plane_n;
343 let offset = offset + plane_n.dot(b.position);
344
345 let mut deepest = Vec3::ZERO;
346 let mut deepest_depth = 0.0_f32;
347 for &v_local in va_local {
348 let v_world = a.orientation.rotation * v_local + a.position;
349 let signed = v_world.dot(plane_n) - offset;
350 let depth = -signed;
351 if depth > deepest_depth {
352 deepest_depth = depth;
353 deepest = v_world;
354 }
355 }
356
357 if deepest_depth <= 0.0 {
358 return None;
359 }
360
361 Some(Contact {
362 normal: -plane_n,
363 point: deepest,
364 penetration: deepest_depth,
365 restitution: (a.restitution + b.restitution) * 0.5,
366 })
367}
368
369pub fn register_default_narrowphase(np: &mut Narrowphase<EuclideanR3>) {
370 np.register(ColliderKind::Sphere, ColliderKind::Sphere, sphere_sphere_r3);
371 np.register(
372 ColliderKind::Sphere,
373 ColliderKind::HalfSpace,
374 sphere_halfspace_r3,
375 );
376 np.register(
377 ColliderKind::ConvexPolytope3D,
378 ColliderKind::ConvexPolytope3D,
379 polytope_polytope_r3,
380 );
381 np.register(
382 ColliderKind::Sphere,
383 ColliderKind::ConvexPolytope3D,
384 sphere_polytope_r3,
385 );
386 np.register(
387 ColliderKind::ConvexPolytope3D,
388 ColliderKind::HalfSpace,
389 polytope_halfspace_r3,
390 );
391}
392
393pub fn sphere_inertia(mass: f32, radius: f32) -> f32 {
394 (2.0 / 5.0) * mass * radius * radius
395}
396
397pub fn sphere_body_r3(
398 position: Vec3,
399 velocity: Vec3,
400 radius: f32,
401 mass: f32,
402) -> Option<BodyDef<EuclideanR3>> {
403 BodyDef::new(
404 position,
405 velocity,
406 Collider::sphere_at_origin(radius),
407 mass,
408 sphere_inertia(mass, radius),
409 &EuclideanR3,
410 )
411}
412
413pub fn halfspace_body_r3(normal: Vec3, offset: f32) -> Option<BodyDef<EuclideanR3>> {
415 let n = normal.try_normalize()?;
416 BodyDef::fixed(
417 Vec3::ZERO,
418 Collider::HalfSpace { normal: n, offset },
419 1.0,
420 &EuclideanR3,
421 )
422}
423
424pub fn box_inertia(mass: f32, half_extents: Vec3) -> f32 {
426 let w = half_extents.x * 2.0;
427 let h = half_extents.y * 2.0;
428 let d = half_extents.z * 2.0;
429 mass * (w * w + h * h + d * d) / 18.0
430}
431
432pub fn box_vertices(half_extents: Vec3) -> Vec<Vec3> {
434 let (hx, hy, hz) = (half_extents.x, half_extents.y, half_extents.z);
435 vec![
436 Vec3::new(-hx, -hy, -hz),
437 Vec3::new(hx, -hy, -hz),
438 Vec3::new(hx, hy, -hz),
439 Vec3::new(-hx, hy, -hz),
440 Vec3::new(-hx, -hy, hz),
441 Vec3::new(hx, -hy, hz),
442 Vec3::new(hx, hy, hz),
443 Vec3::new(-hx, hy, hz),
444 ]
445}
446
447pub fn box_body(
448 position: Vec3,
449 velocity: Vec3,
450 half_extents: Vec3,
451 mass: f32,
452) -> Option<BodyDef<EuclideanR3>> {
453 BodyDef::new(
454 position,
455 velocity,
456 Collider::ConvexPolytope3D {
457 vertices: box_vertices(half_extents),
458 },
459 mass,
460 box_inertia(mass, half_extents),
461 &EuclideanR3,
462 )
463}
464
465pub fn polytope_body(
467 position: Vec3,
468 velocity: Vec3,
469 vertices: Vec<Vec3>,
470 mass: f32,
471) -> Option<BodyDef<EuclideanR3>> {
472 let bounding_r_sq = vertices
473 .iter()
474 .map(|v| v.length_squared())
475 .fold(0.0, f32::max);
476 let inertia = (2.0 / 5.0) * mass * bounding_r_sq;
477 BodyDef::new(
478 position,
479 velocity,
480 Collider::ConvexPolytope3D { vertices },
481 mass,
482 inertia,
483 &EuclideanR3,
484 )
485}
486
487pub fn tetrahedron_vertices(r: f32) -> Vec<Vec3> {
488 let k = r / 3.0_f32.sqrt();
489 vec![
490 Vec3::new(k, k, k),
491 Vec3::new(k, -k, -k),
492 Vec3::new(-k, k, -k),
493 Vec3::new(-k, -k, k),
494 ]
495}
496
497pub fn cube_vertices(r: f32) -> Vec<Vec3> {
498 let h = r / 3.0_f32.sqrt();
499 box_vertices(Vec3::splat(h))
500}
501
502pub fn octahedron_vertices(r: f32) -> Vec<Vec3> {
503 vec![
504 Vec3::new(r, 0.0, 0.0),
505 Vec3::new(-r, 0.0, 0.0),
506 Vec3::new(0.0, r, 0.0),
507 Vec3::new(0.0, -r, 0.0),
508 Vec3::new(0.0, 0.0, r),
509 Vec3::new(0.0, 0.0, -r),
510 ]
511}
512
513pub fn icosahedron_vertices(r: f32) -> Vec<Vec3> {
514 let phi = (1.0 + 5.0_f32.sqrt()) * 0.5;
515 let norm = (1.0 + phi * phi).sqrt();
516 let s = r / norm;
517 let p = phi * s;
518 vec![
519 Vec3::new(0.0, s, p),
520 Vec3::new(0.0, s, -p),
521 Vec3::new(0.0, -s, p),
522 Vec3::new(0.0, -s, -p),
523 Vec3::new(s, p, 0.0),
524 Vec3::new(s, -p, 0.0),
525 Vec3::new(-s, p, 0.0),
526 Vec3::new(-s, -p, 0.0),
527 Vec3::new(p, 0.0, s),
528 Vec3::new(p, 0.0, -s),
529 Vec3::new(-p, 0.0, s),
530 Vec3::new(-p, 0.0, -s),
531 ]
532}
533
534pub fn dodecahedron_vertices(r: f32) -> Vec<Vec3> {
535 let phi = (1.0 + 5.0_f32.sqrt()) * 0.5;
536 let inv_phi = 1.0 / phi;
537 let norm = 3.0_f32.sqrt();
538 let s = r / norm;
539 let a = s * inv_phi;
540 let b = s * phi;
541 let mut v = Vec::with_capacity(20);
542 for &x in &[-s, s] {
543 for &y in &[-s, s] {
544 for &z in &[-s, s] {
545 v.push(Vec3::new(x, y, z));
546 }
547 }
548 }
549 for &y in &[-a, a] {
550 for &z in &[-b, b] {
551 v.push(Vec3::new(0.0, y, z));
552 }
553 }
554 for &x in &[-a, a] {
555 for &z in &[-b, b] {
556 v.push(Vec3::new(z, 0.0, x));
557 }
558 }
559 for &x in &[-a, a] {
560 for &y in &[-b, b] {
561 v.push(Vec3::new(y, x, 0.0));
562 }
563 }
564 v
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 use crate::world::World;
572
573 fn assert_close(a: f32, b: f32, tol: f32) {
574 assert!(
575 (a - b).abs() <= tol,
576 "expected {a} close to {b} (tol {tol})"
577 );
578 }
579
580 #[test]
581 fn head_on_sphere_collision_is_elastic_only() {
582 let mut world = World::new(EuclideanR3);
583 register_default_narrowphase(&mut world.narrowphase);
584
585 world.push_body(
586 sphere_body_r3(
587 Vec3::new(-1.0, 0.0, 0.0),
588 Vec3::new(2.0, 0.0, 0.0),
589 0.5,
590 1.0,
591 )
592 .unwrap(),
593 );
594 world.push_body(
595 sphere_body_r3(
596 Vec3::new(1.0, 0.0, 0.0),
597 Vec3::new(-2.0, 0.0, 0.0),
598 0.5,
599 1.0,
600 )
601 .unwrap(),
602 );
603
604 for _ in 0..120 {
605 world.step(1.0 / 120.0).unwrap();
606 }
607 let a = &world.bodies[0];
608 let b = &world.bodies[1];
609 assert_close(a.angular_velocity.magnitude(), 0.0, 1e-3);
610 assert_close(b.angular_velocity.magnitude(), 0.0, 1e-3);
611 assert!(a.velocity.x < 0.0, "a.velocity.x = {}", a.velocity.x);
612 assert!(b.velocity.x > 0.0, "b.velocity.x = {}", b.velocity.x);
613 }
614
615 #[test]
616 fn off_center_glancing_hit_produces_angular_velocity() {
617 let mut world = World::new(EuclideanR3);
618 register_default_narrowphase(&mut world.narrowphase);
619
620 let target_id = world.push_body(sphere_body_r3(Vec3::ZERO, Vec3::ZERO, 0.5, 10.0).unwrap());
621 let projectile_id = world.push_body(
622 sphere_body_r3(
623 Vec3::new(2.0, 0.4, 0.0),
624 Vec3::new(-5.0, 0.0, 0.0),
625 0.3,
626 1.0,
627 )
628 .unwrap(),
629 );
630
631 for _ in 0..120 {
632 world.step(1.0 / 240.0).unwrap();
633 }
634
635 let target_omega = world.bodies[target_id].angular_velocity.magnitude();
636 let proj_omega = world.bodies[projectile_id].angular_velocity.magnitude();
637 assert!(
638 target_omega > 1e-3,
639 "target gained no angular velocity: ω mag = {target_omega}"
640 );
641 assert!(
642 proj_omega > 1e-3,
643 "projectile gained no angular velocity: ω mag = {proj_omega}"
644 );
645 }
646
647 #[test]
648 fn integrated_orientation_advances_a_body_point_along_the_world_frame_omega() {
649 let space = EuclideanR3;
650 let start = Iso3 {
651 rotation: rotor_to_quat(Bivector3::new(0.8, 1.2, 0.9).exp()),
652 translation: Vec3::ZERO,
653 };
654 let omega = Bivector3::new(0.7, 0.0, 0.5);
655 let local = Vec3::new(0.5, -0.5, 0.5);
656 let dt = 1e-3;
657
658 let before = start.rotation * local;
659 let after = space.integrate_orientation(start, omega, dt).rotation * local;
660 let residual = (after - (before + omega_cross_r(omega, before) * dt)).length();
661 assert!(
662 residual < 1e-5,
663 "integrated orientation left the world-frame field ω⌋r: residual \
664 {residual} over a step of {}",
665 (after - before).length()
666 );
667 }
668
669 #[test]
670 fn integration_preserves_unit_rotor() {
671 let space = EuclideanR3;
672 let mut iso = Iso3::IDENTITY;
673 let omega = Bivector3::new(0.2, 0.3, -0.1);
674 for _ in 0..1000 {
675 iso = space.integrate_orientation(iso, omega, 1.0 / 60.0);
676 }
677 let len = iso.rotation.length();
678 assert!(
679 (len - 1.0).abs() < 1e-3,
680 "orientation drifted off the unit manifold: |q| = {len}"
681 );
682 }
683}