Skip to main content

loam_physics/
euclidean_r3.rs

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
13// `(s, xy, yz, zx)` maps to `(w, z, x, y)`, matching `Rotor3::apply`.
14fn 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
413/// `normal` points to the side the world is on; the plane is `dot(p, normal) = offset`.
414pub 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
424/// Mean of the principal-axis tensor's diagonal; exact for a cube.
425pub 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
432/// Vertices are centred at the origin; support queries do not use winding.
433pub 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
465/// Uses a bounding-sphere inertia approximation.
466pub 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}