1use glam::Vec4;
4
5use loam_math::{Bivector, Bivector4, EuclideanR4, Iso4Flat, Rotor};
6use loam_shape::polytope::Polytope4;
7
8use crate::body::{BodyDef, RigidBody};
9use crate::collider::{Collider, ColliderKind};
10use crate::collision::{epa_r4, gjk_intersect_r4, GjkResult4, PosedHull4, Sphere4 as GjkSphere4};
11use crate::geometry::GeometryStore;
12use crate::integrator::{BroadphaseBound, PhysicsSpace};
13use crate::narrowphase::Narrowphase;
14use crate::response::Contact;
15
16pub fn omega_cross_r(omega: Bivector4, r: glam::Vec4) -> glam::Vec4 {
17 -omega.contract_vec(r)
18}
19
20fn inv_inertia(body: &RigidBody<EuclideanR4>) -> f32 {
21 if body.inv_mass() > 0.0 && body.inertia > 0.0 {
22 1.0 / body.inertia
23 } else {
24 0.0
25 }
26}
27
28impl PhysicsSpace for EuclideanR4 {
29 type AngVel = Bivector4;
30 type Inertia = f32;
31
32 fn broadphase_bound(&self) -> BroadphaseBound {
33 BroadphaseBound::Certified
34 }
35
36 fn supports_collider(&self, kind: ColliderKind) -> bool {
37 matches!(
38 kind,
39 ColliderKind::Sphere | ColliderKind::ConvexPolytope4D | ColliderKind::HalfSpace4D
40 )
41 }
42
43 fn valid_vector(&self, vector: Vec4) -> bool {
44 vector.is_finite()
45 }
46
47 fn valid_orientation(&self, orientation: Iso4Flat) -> bool {
48 let r = orientation.rotation;
49 let product = r * r.inverse();
50 r.s.is_finite()
51 && r.xy.is_finite()
52 && r.xz.is_finite()
53 && r.xw.is_finite()
54 && r.yz.is_finite()
55 && r.yw.is_finite()
56 && r.zw.is_finite()
57 && r.xyzw.is_finite()
58 && (product.s - 1.0).abs() <= 1e-4
59 && product.xy.abs() <= 1e-4
60 && product.xz.abs() <= 1e-4
61 && product.xw.abs() <= 1e-4
62 && product.yz.abs() <= 1e-4
63 && product.yw.abs() <= 1e-4
64 && product.zw.abs() <= 1e-4
65 && product.xyzw.abs() <= 1e-4
66 && orientation.translation.is_finite()
67 }
68
69 fn valid_angular_velocity(&self, angular_velocity: Bivector4) -> bool {
70 angular_velocity.xy.is_finite()
71 && angular_velocity.xz.is_finite()
72 && angular_velocity.xw.is_finite()
73 && angular_velocity.yz.is_finite()
74 && angular_velocity.yw.is_finite()
75 && angular_velocity.zw.is_finite()
76 }
77
78 fn valid_inertia(&self, inertia: f32) -> bool {
79 inertia.is_finite() && inertia >= 0.0 && (inertia == 0.0 || inertia.recip().is_finite())
80 }
81
82 fn integrate_orientation(&self, iso: Iso4Flat, omega: Bivector4, dt: f32) -> Iso4Flat {
83 debug_assert!(
84 omega.xy.is_finite()
85 && omega.xz.is_finite()
86 && omega.xw.is_finite()
87 && omega.yz.is_finite()
88 && omega.yw.is_finite()
89 && omega.zw.is_finite(),
90 "non-finite Bivector4 angular velocity in integrate_orientation",
91 );
92 let delta = (omega * dt).exp();
93 let composed = iso.rotation * delta;
94 Iso4Flat {
95 rotation: composed.normalize(),
96 translation: iso.translation,
97 }
98 }
99
100 fn apply_inv_inertia(&self, inertia: f32, torque: Bivector4) -> Bivector4 {
101 if inertia > 0.0 {
102 torque * (1.0 / inertia)
103 } else {
104 Bivector4::ZERO
105 }
106 }
107
108 fn wedge(&self, a: Vec4, b: Vec4) -> Bivector4 {
109 Bivector4::wedge(a, b)
110 }
111
112 fn velocity_at_point(&self, body: &RigidBody<EuclideanR4>, p: Vec4) -> Vec4 {
113 let r = p - body.position;
114 body.velocity + omega_cross_r(body.angular_velocity, r)
115 }
116
117 fn effective_mass_inv(
118 &self,
119 a: &RigidBody<EuclideanR4>,
120 b: &RigidBody<EuclideanR4>,
121 contact_point: Vec4,
122 direction: Vec4,
123 ) -> f32 {
124 let ra = contact_point - a.position;
125 let rb = contact_point - b.position;
126 let ra_wedge = Bivector4::wedge(ra, direction);
127 let rb_wedge = Bivector4::wedge(rb, direction);
128 a.inv_mass()
129 + b.inv_mass()
130 + ra_wedge.magnitude_squared() * inv_inertia(a)
131 + rb_wedge.magnitude_squared() * inv_inertia(b)
132 }
133
134 fn apply_contact_impulse(
135 &self,
136 a: &mut RigidBody<EuclideanR4>,
137 b: &mut RigidBody<EuclideanR4>,
138 contact_point: Vec4,
139 direction: Vec4,
140 magnitude: f32,
141 ) {
142 let ra = contact_point - a.position;
143 let rb = contact_point - b.position;
144 let lin = direction * magnitude;
145 a.velocity -= lin * a.inv_mass();
146 b.velocity += lin * b.inv_mass();
147
148 let inv_i_a = inv_inertia(a);
149 let inv_i_b = inv_inertia(b);
150 a.angular_velocity = a.angular_velocity + Bivector4::wedge(ra, lin) * (-inv_i_a);
151 b.angular_velocity = b.angular_velocity + Bivector4::wedge(rb, lin) * inv_i_b;
152 }
153}
154
155fn sphere_sphere_r4(
156 a: &RigidBody<EuclideanR4>,
157 b: &RigidBody<EuclideanR4>,
158 geometry: &GeometryStore,
159 space: &EuclideanR4,
160) -> Option<Contact<EuclideanR4>> {
161 let Some(&Collider::Sphere { radius: ra, .. }) = geometry.get(a.collider()) else {
162 return None;
163 };
164 let Some(&Collider::Sphere { radius: rb, .. }) = geometry.get(b.collider()) else {
165 return None;
166 };
167
168 use loam_math::Space;
169 let d = space.distance(a.position, b.position);
170 let combined = ra + rb;
171 if d >= combined {
172 return None;
173 }
174 let log = space.log(a.position, b.position);
175 let len = log.length();
176 let normal = if len > 1e-8 { log / len } else { Vec4::Y };
177
178 let surface_a = a.position + normal * ra;
179 let surface_b = b.position - normal * rb;
180 let point = (surface_a + surface_b) * 0.5;
181
182 Some(Contact {
183 normal,
184 point,
185 penetration: combined - d,
186 restitution: (a.restitution + b.restitution) * 0.5,
187 })
188}
189
190fn sphere_halfspace_r4(
191 a: &RigidBody<EuclideanR4>,
192 b: &RigidBody<EuclideanR4>,
193 geometry: &GeometryStore,
194 _space: &EuclideanR4,
195) -> Option<Contact<EuclideanR4>> {
196 let Some(&Collider::Sphere { radius, .. }) = geometry.get(a.collider()) else {
197 return None;
198 };
199 let Some(&Collider::HalfSpace4D { normal, offset }) = geometry.get(b.collider()) else {
200 return None;
201 };
202 let normal = b.orientation.rotation.apply(normal);
203 let offset = offset + normal.dot(b.position);
204 let signed = a.position.dot(normal) - offset;
205 let penetration = radius - signed;
206 if penetration <= 0.0 {
207 return None;
208 }
209 let contact_normal = -normal;
210 let point = a.position - normal * radius;
211 Some(Contact {
212 normal: contact_normal,
213 point,
214 penetration,
215 restitution: (a.restitution + b.restitution) * 0.5,
216 })
217}
218
219fn polytope_halfspace_r4(
220 a: &RigidBody<EuclideanR4>,
221 b: &RigidBody<EuclideanR4>,
222 geometry: &GeometryStore,
223 _space: &EuclideanR4,
224) -> Option<Contact<EuclideanR4>> {
225 let Some(Collider::ConvexPolytope4D { vertices: va_local }) = geometry.get(a.collider()) else {
226 return None;
227 };
228 let Some(&Collider::HalfSpace4D {
229 normal: plane_n,
230 offset,
231 }) = geometry.get(b.collider())
232 else {
233 return None;
234 };
235 let plane_n = b.orientation.rotation.apply(plane_n);
236 let offset = offset + plane_n.dot(b.position);
237
238 let mut deepest = Vec4::ZERO;
239 let mut deepest_depth = 0.0_f32;
240 for &v_local in va_local {
241 let v_world = a.orientation.rotation.apply(v_local) + a.position;
242 let signed = v_world.dot(plane_n) - offset;
243 let depth = -signed;
244 if depth > deepest_depth {
245 deepest_depth = depth;
246 deepest = v_world;
247 }
248 }
249 if deepest_depth <= 0.0 {
250 return None;
251 }
252 Some(Contact {
253 normal: -plane_n,
254 point: deepest,
255 penetration: deepest_depth,
256 restitution: (a.restitution + b.restitution) * 0.5,
257 })
258}
259
260fn polytope4_bounding_radius(local_vertices: &[Vec4]) -> f32 {
261 local_vertices
262 .iter()
263 .map(|v| v.length_squared())
264 .fold(0.0_f32, f32::max)
265 .sqrt()
266}
267
268fn validate_contact4(
269 info: &crate::collision::ContactInfo4,
270 a: &RigidBody<EuclideanR4>,
271 b: &RigidBody<EuclideanR4>,
272) -> Option<Contact<EuclideanR4>> {
273 if !info.penetration.is_finite()
274 || info.penetration <= 0.0
275 || !info.normal.is_finite()
276 || !info.point.is_finite()
277 {
278 return None;
279 }
280 let n2 = info.normal.length_squared();
281 if !(0.5..=1.5).contains(&n2) {
282 return None;
283 }
284 Some(Contact {
285 normal: info.normal,
286 point: info.point,
287 penetration: info.penetration,
288 restitution: (a.restitution + b.restitution) * 0.5,
289 })
290}
291
292fn polytope_polytope_r4(
293 a: &RigidBody<EuclideanR4>,
294 b: &RigidBody<EuclideanR4>,
295 geometry: &GeometryStore,
296 _space: &EuclideanR4,
297) -> Option<Contact<EuclideanR4>> {
298 let Some(Collider::ConvexPolytope4D { vertices: va_local }) = geometry.get(a.collider()) else {
299 return None;
300 };
301 let Some(Collider::ConvexPolytope4D { vertices: vb_local }) = geometry.get(b.collider()) else {
302 return None;
303 };
304
305 let ra = polytope4_bounding_radius(va_local);
306 let rb = polytope4_bounding_radius(vb_local);
307 let center_dist_sq = (b.position - a.position).length_squared();
308 let combined = ra + rb;
309 if center_dist_sq > combined * combined {
310 return None;
311 }
312
313 let hull_a = PosedHull4 {
314 local: va_local,
315 position: a.position,
316 rotation: a.orientation.rotation,
317 };
318 let hull_b = PosedHull4 {
319 local: vb_local,
320 position: b.position,
321 rotation: b.orientation.rotation,
322 };
323
324 let initial_dir = b.position - a.position;
325 let simplex = match gjk_intersect_r4(&hull_a, &hull_b, initial_dir) {
326 GjkResult4::Intersecting { simplex } => simplex,
327 GjkResult4::Separated => return None,
328 };
329 let info = epa_r4(&hull_a, &hull_b, simplex, combined)?;
330 validate_contact4(&info, a, b)
331}
332
333fn sphere_polytope_r4(
334 a: &RigidBody<EuclideanR4>,
335 b: &RigidBody<EuclideanR4>,
336 geometry: &GeometryStore,
337 _space: &EuclideanR4,
338) -> Option<Contact<EuclideanR4>> {
339 let Some(&Collider::Sphere { radius, .. }) = geometry.get(a.collider()) else {
340 return None;
341 };
342 let Some(Collider::ConvexPolytope4D { vertices: vb_local }) = geometry.get(b.collider()) else {
343 return None;
344 };
345
346 let rb = polytope4_bounding_radius(vb_local);
347 let center_dist_sq = (b.position - a.position).length_squared();
348 let combined = radius + rb;
349 if center_dist_sq > combined * combined {
350 return None;
351 }
352
353 let support_a = GjkSphere4 {
354 center: a.position,
355 radius,
356 };
357 let support_b = PosedHull4 {
358 local: vb_local,
359 position: b.position,
360 rotation: b.orientation.rotation,
361 };
362 let initial_dir = b.position - a.position;
363 let simplex = match gjk_intersect_r4(&support_a, &support_b, initial_dir) {
364 GjkResult4::Intersecting { simplex } => simplex,
365 GjkResult4::Separated => return None,
366 };
367 let info = epa_r4(&support_a, &support_b, simplex, combined)?;
368 validate_contact4(&info, a, b)
369}
370
371pub fn register_default_narrowphase(np: &mut Narrowphase<EuclideanR4>) {
372 np.register(ColliderKind::Sphere, ColliderKind::Sphere, sphere_sphere_r4);
373 np.register(
374 ColliderKind::Sphere,
375 ColliderKind::HalfSpace4D,
376 sphere_halfspace_r4,
377 );
378 np.register(
379 ColliderKind::ConvexPolytope4D,
380 ColliderKind::ConvexPolytope4D,
381 polytope_polytope_r4,
382 );
383 np.register(
384 ColliderKind::Sphere,
385 ColliderKind::ConvexPolytope4D,
386 sphere_polytope_r4,
387 );
388 np.register(
389 ColliderKind::ConvexPolytope4D,
390 ColliderKind::HalfSpace4D,
391 polytope_halfspace_r4,
392 );
393}
394
395pub fn ball4_inertia(mass: f32, radius: f32) -> f32 {
396 mass * radius * radius / 3.0
397}
398
399pub fn regular_polytope4_inertia(shape: Polytope4, mass: f32, circumradius: f32) -> f32 {
401 const SQRT_5: f32 = 2.236_068;
402 let mean_radius_sq = match shape {
404 Polytope4::Pentatope => 1.0 / 6.0,
405 Polytope4::Tesseract => 1.0 / 3.0,
406 Polytope4::Cell16 => 4.0 / 15.0,
407 Polytope4::Cell24 => 13.0 / 30.0,
408 Polytope4::Cell600 => (11.0 + 3.0 * SQRT_5) / 30.0,
409 Polytope4::Cell120 => (215.0 + 69.0 * SQRT_5) / 600.0,
410 };
411 0.5 * mass * circumradius * circumradius * mean_radius_sq
412}
413
414pub fn sphere_body_r4(
415 position: Vec4,
416 velocity: Vec4,
417 radius: f32,
418 mass: f32,
419) -> Option<BodyDef<EuclideanR4>> {
420 BodyDef::new(
421 position,
422 velocity,
423 Collider::sphere_at_origin(radius),
424 mass,
425 ball4_inertia(mass, radius),
426 &EuclideanR4,
427 )
428}
429
430pub fn halfspace4_body_r4(normal: Vec4, offset: f32) -> Option<BodyDef<EuclideanR4>> {
432 let n = normal.try_normalize()?;
433 BodyDef::fixed(
434 Vec4::ZERO,
435 Collider::HalfSpace4D { normal: n, offset },
436 1.0,
437 &EuclideanR4,
438 )
439}
440
441pub fn polytope_body_r4(
443 position: Vec4,
444 velocity: Vec4,
445 vertices: Vec<Vec4>,
446 mass: f32,
447) -> Option<BodyDef<EuclideanR4>> {
448 let bounding_r_sq = vertices
449 .iter()
450 .map(|v| v.length_squared())
451 .fold(0.0, f32::max);
452 let inertia = mass * bounding_r_sq / 3.0;
453 BodyDef::new(
454 position,
455 velocity,
456 Collider::ConvexPolytope4D { vertices },
457 mass,
458 inertia,
459 &EuclideanR4,
460 )
461}
462
463pub use loam_shape::polytope_geom::*;
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 use crate::world::World;
470
471 fn assert_close(a: f32, b: f32, tol: f32) {
472 assert!(
473 (a - b).abs() <= tol,
474 "expected {a} close to {b} (tol {tol})"
475 );
476 }
477
478 #[test]
479 fn wall_contact_leaves_through_the_near_face_in_either_pair_order() {
480 let mut np = Narrowphase::<EuclideanR4>::new();
481 register_default_narrowphase(&mut np);
482 let mut geometry = crate::geometry::GeometryStore::default();
483
484 let wall = BodyDef::fixed(
485 Vec4::ZERO,
486 Collider::ConvexPolytope4D {
487 vertices: tesseract_vertices(0.2)
488 .into_iter()
489 .map(|v| v * Vec4::new(1.0, 20.0, 20.0, 20.0))
490 .collect(),
491 },
492 1.0,
493 &EuclideanR4,
494 )
495 .unwrap()
496 .into_row(&mut geometry, &EuclideanR4);
497 let ball = sphere_body_r4(Vec4::new(-0.05, 0.0, 0.0, 0.0), Vec4::ZERO, 0.2, 1.0)
498 .unwrap()
499 .into_row(&mut geometry, &EuclideanR4);
500
501 let forward = np
502 .test(&ball, &wall, &geometry, &EuclideanR4)
503 .expect("overlapping");
504 assert!(
505 (-forward.normal).dot(Vec4::X) < -0.99,
506 "ball leaves along {:?}, not back out of the near face",
507 -forward.normal
508 );
509 let reversed = np
510 .test(&wall, &ball, &geometry, &EuclideanR4)
511 .expect("overlapping");
512 assert!(
513 reversed.normal.dot(Vec4::X) < -0.99,
514 "flipped pair leaves the ball along {:?}",
515 reversed.normal
516 );
517 assert_close(forward.penetration, reversed.penetration, 1e-6);
518 }
519
520 #[test]
521 fn sphere_settles_on_4d_floor() {
522 let mut world = World::new(EuclideanR4);
523 register_default_narrowphase(&mut world.narrowphase);
524 world
525 .set_gravity(Some(Vec4::new(0.0, -9.8, 0.0, 0.0)))
526 .unwrap();
527 let _floor = world.push_body(halfspace4_body_r4(Vec4::Y, 0.0).unwrap());
528 let ball = world.push_body(
529 sphere_body_r4(Vec4::new(0.0, 2.0, 0.0, 0.0), Vec4::ZERO, 0.5, 1.0).unwrap(),
530 );
531 for _ in 0..300 {
532 world.step(1.0 / 60.0).unwrap();
533 }
534 let body = &world.bodies[ball];
535 let lowest = body.position.y - 0.5;
536 assert!(
537 (-0.05..=0.05).contains(&lowest),
538 "ball tunneled through 4D floor: y_bottom = {lowest}"
539 );
540 assert!(
541 body.velocity.length() < 0.5,
542 "ball still moving: |v| = {}",
543 body.velocity.length()
544 );
545 }
546
547 #[test]
548 fn pentatope_settles_on_4d_floor() {
549 let mut world = World::new(EuclideanR4);
550 register_default_narrowphase(&mut world.narrowphase);
551 world
552 .set_gravity(Some(Vec4::new(0.0, -9.8, 0.0, 0.0)))
553 .unwrap();
554 let floor = world.push_body(halfspace4_body_r4(Vec4::Y, 0.0).unwrap());
555 let body_id = world.push_body(
556 polytope_body_r4(
557 Vec4::new(0.0, 3.0, 0.0, 0.0),
558 Vec4::ZERO,
559 pentatope_vertices(0.5),
560 1.0,
561 )
562 .unwrap(),
563 );
564 world.bodies[floor].restitution = 0.0;
565 world.bodies[body_id].restitution = 0.0;
566
567 for _ in 0..600 {
568 world.step(1.0 / 60.0).unwrap();
569 }
570 let body = &world.bodies[body_id];
571
572 assert!(
573 body.position.y.is_finite() && (-0.5..=1.0).contains(&body.position.y),
574 "pentatope position out of expected resting band: y = {}",
575 body.position.y
576 );
577 assert!(
578 body.position.x.abs() < 5.0
579 && body.position.z.abs() < 5.0
580 && body.position.w.abs() < 5.0,
581 "pentatope drifted too far horizontally: pos = {:?}",
582 body.position
583 );
584
585 assert!(
586 body.velocity.length() < 1.0,
587 "pentatope still moving after 10 s: |v| = {}, v = {:?}",
588 body.velocity.length(),
589 body.velocity
590 );
591
592 let omega = body.angular_velocity;
593 let omega_mag2 = omega.xy * omega.xy
594 + omega.xz * omega.xz
595 + omega.xw * omega.xw
596 + omega.yz * omega.yz
597 + omega.yw * omega.yw
598 + omega.zw * omega.zw;
599 assert!(
600 omega_mag2.is_finite() && omega_mag2 < 4.0,
601 "pentatope angular velocity blew up: |ω|² = {omega_mag2}, ω = {omega:?}"
602 );
603 }
604
605 #[test]
606 fn a_flat_landing_bounces_to_its_restitution_height_at_any_step_size() {
607 const HALF: f32 = 0.225;
608 const RESTITUTION: f32 = 0.05;
609 for dt in [1.0 / 240.0, 1.0 / 960.0, 1.0 / 1920.0] {
610 let mut world = World::new(EuclideanR4);
611 register_default_narrowphase(&mut world.narrowphase);
612 world
613 .set_gravity(Some(Vec4::new(0.0, -9.8, 0.0, 0.0)))
614 .unwrap();
615 let floor = world.push_body(halfspace4_body_r4(Vec4::Y, 0.0).unwrap());
616 let body = world.push_body(
617 polytope_body_r4(
618 Vec4::new(0.0, 4.0, 0.0, 0.0),
619 Vec4::ZERO,
620 tesseract_vertices(HALF),
621 1.0,
622 )
623 .unwrap(),
624 );
625 world.bodies[floor].restitution = RESTITUTION;
626 world.bodies[body].restitution = RESTITUTION;
627 let mut landed = false;
628 let mut apex = f32::MIN;
629 for _ in 0..(2.0 / dt) as usize {
630 world.step(dt).unwrap();
631 let body = &world.bodies[body];
632 landed |= body.velocity.y > 0.0;
633 if landed {
634 apex = apex.max(body.position.y);
635 }
636 }
637 assert!(
639 apex - HALF < 0.03,
640 "at dt {dt} the landing bounced {} above rest",
641 apex - HALF
642 );
643 }
644 }
645
646 const CORNER_DROP_DT: f32 = 1.0 / 240.0;
647 const CORNER_DROP_CIRCUMRADIUS: f32 = 0.45;
648 const CORNER_DROP_GRAVITY: f32 = -9.8;
649 const CORNER_DROP_DRAG: f32 = 1.2;
650
651 struct CornerDrop {
652 world: World<EuclideanR4>,
653 body: crate::body::BodyId,
654 decay: f32,
655 }
656
657 impl CornerDrop {
658 fn new() -> Self {
659 let mut world = World::new(EuclideanR4);
660 register_default_narrowphase(&mut world.narrowphase);
661 world
662 .set_gravity(Some(Vec4::new(0.0, CORNER_DROP_GRAVITY, 0.0, 0.0)))
663 .unwrap();
664 let floor = world.push_body(halfspace4_body_r4(Vec4::Y, 0.0).unwrap());
665 world.bodies[floor].restitution = 0.05;
666 let body = world.push_body(
667 polytope_body_r4(
668 Vec4::new(0.0, 1.6, 0.0, 0.0),
669 Vec4::ZERO,
670 cell24_vertices(CORNER_DROP_CIRCUMRADIUS),
671 1.0,
672 )
673 .unwrap(),
674 );
675 world.bodies[body].restitution = 0.05;
676 world.bodies[body].orientation.rotation = Bivector4::new(0.0, 0.0, 0.0, 0.6, 0.4, 0.0)
677 .exp()
678 .normalize();
679 world.bodies[body].inertia =
680 regular_polytope4_inertia(Polytope4::Cell24, 1.0, CORNER_DROP_CIRCUMRADIUS);
681 Self {
682 world,
683 body,
684 decay: (-CORNER_DROP_DRAG * CORNER_DROP_DT).exp(),
685 }
686 }
687
688 fn step(&mut self) {
689 self.world.step(CORNER_DROP_DT).unwrap();
690 let decay = self.decay;
691 let body = &mut self.world.bodies[self.body];
692 body.angular_velocity = body.angular_velocity * decay;
693 }
694
695 fn height(&self) -> f32 {
696 self.world.bodies[self.body].position.y
697 }
698
699 fn angular_speed(&self) -> f32 {
700 self.world.bodies[self.body].angular_velocity.magnitude()
701 }
702
703 fn energy(&self) -> f32 {
704 let body = &self.world.bodies[self.body];
705 0.5 * body.velocity.length_squared()
706 + 0.5 * body.inertia * body.angular_velocity.magnitude().powi(2)
707 - CORNER_DROP_GRAVITY * body.position.y
708 }
709 }
710
711 #[test]
712 fn a_hull_dropped_on_a_corner_settles_without_climbing_its_own_contacts() {
713 const LANDING_STEPS: usize = 400;
714 const RESTING_STEPS: usize = 800;
715 const CLIMB_TOLERANCE: f32 = 5e-4;
716
717 let mut drop = CornerDrop::new();
718 let start_energy = drop.energy();
719 let mut landing_spin = 0.0_f32;
720 for _ in 0..LANDING_STEPS {
721 drop.step();
722 landing_spin = landing_spin.max(drop.angular_speed());
723 }
724 assert!(
725 landing_spin > 1.0,
726 "the hull never rocked over, so a settle proves nothing: peak |ω| \
727 was {landing_spin} rad/s"
728 );
729
730 let resting_height = drop.height();
731 let mut peak_height = resting_height;
732 for _ in 0..RESTING_STEPS {
733 drop.step();
734 peak_height = peak_height.max(drop.height());
735 }
736
737 assert!(
738 peak_height - resting_height < CLIMB_TOLERANCE,
739 "a resting hull climbed {} against gravity over {RESTING_STEPS} \
740 steps",
741 peak_height - resting_height,
742 );
743 assert!(
744 drop.angular_speed() < 0.02,
745 "a resting hull held {} rad/s against a {CORNER_DROP_DRAG}/s \
746 damper, so the contact solve is re-injecting it",
747 drop.angular_speed(),
748 );
749 assert!(
750 drop.energy() < start_energy,
751 "the drop ended with {} of energy against the {start_energy} it \
752 started with",
753 drop.energy(),
754 );
755 }
756
757 #[test]
758 fn falling_sphere_accelerates_in_r4() {
759 let mut world = World::new(EuclideanR4);
760 register_default_narrowphase(&mut world.narrowphase);
761 world
762 .set_gravity(Some(Vec4::new(0.0, -9.8, 0.0, 0.0)))
763 .unwrap();
764
765 let id = world.push_body(
766 sphere_body_r4(Vec4::new(0.0, 5.0, 0.0, 0.0), Vec4::ZERO, 0.5, 1.0).unwrap(),
767 );
768 world.step(1.0 / 60.0).unwrap();
769 let body = &world.bodies[id];
770 assert!(body.velocity.y < -0.1 && body.velocity.y > -0.2);
771 assert_close(body.velocity.x, 0.0, 1e-6);
772 assert_close(body.velocity.z, 0.0, 1e-6);
773 assert_close(body.velocity.w, 0.0, 1e-6);
774 }
775
776 #[test]
777 fn head_on_sphere_collision_reverses_velocity() {
778 let mut world = World::new(EuclideanR4);
779 register_default_narrowphase(&mut world.narrowphase);
780
781 world.push_body(
782 sphere_body_r4(
783 Vec4::new(-1.0, 0.0, 0.0, 0.0),
784 Vec4::new(2.0, 0.0, 0.0, 0.0),
785 0.5,
786 1.0,
787 )
788 .unwrap(),
789 );
790 world.push_body(
791 sphere_body_r4(
792 Vec4::new(1.0, 0.0, 0.0, 0.0),
793 Vec4::new(-2.0, 0.0, 0.0, 0.0),
794 0.5,
795 1.0,
796 )
797 .unwrap(),
798 );
799
800 for _ in 0..120 {
801 world.step(1.0 / 120.0).unwrap();
802 }
803 let a = &world.bodies[0];
804 let b = &world.bodies[1];
805 assert!(
806 a.velocity.x < 0.0,
807 "body 0 should bounce back: v.x = {}",
808 a.velocity.x
809 );
810 assert!(
811 b.velocity.x > 0.0,
812 "body 1 should bounce back: v.x = {}",
813 b.velocity.x
814 );
815 assert_close(a.velocity.y, 0.0, 1e-4);
816 assert_close(a.velocity.z, 0.0, 1e-4);
817 assert_close(a.velocity.w, 0.0, 1e-4);
818 }
819
820 #[test]
821 fn sphere_sphere_off_plane_contact_resolves_along_line_of_centers() {
822 let mut world = World::new(EuclideanR4);
823 register_default_narrowphase(&mut world.narrowphase);
824 let a_pos = Vec4::new(-0.8, -0.4, 0.3, 0.2);
825 let b_pos = Vec4::new(0.8, 0.4, -0.3, -0.2);
826 let a = world
827 .push_body(sphere_body_r4(a_pos, (b_pos - a_pos).normalize() * 2.0, 0.5, 1.0).unwrap());
828 let b = world
829 .push_body(sphere_body_r4(b_pos, (a_pos - b_pos).normalize() * 2.0, 0.5, 1.0).unwrap());
830 for _ in 0..120 {
831 world.step(1.0 / 120.0).unwrap();
832 }
833 let rel = world.bodies[b].velocity - world.bodies[a].velocity;
834 let axis = (b_pos - a_pos).normalize();
835 let v_along = rel.dot(axis);
836 assert!((rel - axis * v_along).length() < 1e-4);
837 assert!(
838 v_along > 0.0,
839 "relative velocity should now be separating: {v_along}"
840 );
841 }
842
843 #[test]
844 fn sphere_inside_tesseract_produces_contact() {
845 let mut world = World::new(EuclideanR4);
846 register_default_narrowphase(&mut world.narrowphase);
847 let _a = world.push_body(sphere_body_r4(Vec4::ZERO, Vec4::ZERO, 0.3, 1.0).unwrap());
848 let _b = world.push_body(
849 polytope_body_r4(Vec4::ZERO, Vec4::ZERO, tesseract_vertices(0.8), 0.0).unwrap(),
850 );
851 let pair_found = {
852 let geometry = world.geometry().clone();
853 let (a, b) = world.bodies.dense_mut().split_at_mut(1);
854 world
855 .narrowphase
856 .test(&a[0], &b[0], &geometry, &EuclideanR4)
857 .is_some()
858 };
859 assert!(
860 pair_found,
861 "sphere inside tesseract should produce a contact"
862 );
863 }
864
865 #[test]
866 fn separated_pentatopes_produce_no_contact() {
867 let mut world = World::new(EuclideanR4);
868 register_default_narrowphase(&mut world.narrowphase);
869 let _a = world.push_body(
870 polytope_body_r4(Vec4::ZERO, Vec4::ZERO, pentatope_vertices(1.0), 1.0).unwrap(),
871 );
872 let _b = world.push_body(
873 polytope_body_r4(
874 Vec4::new(10.0, 0.0, 0.0, 0.0),
875 Vec4::ZERO,
876 pentatope_vertices(1.0),
877 1.0,
878 )
879 .unwrap(),
880 );
881 let geometry = world.geometry().clone();
882 let (a, b) = world.bodies.dense_mut().split_at_mut(1);
883 assert!(world
884 .narrowphase
885 .test(&a[0], &b[0], &geometry, &EuclideanR4)
886 .is_none());
887 }
888
889 #[test]
890 fn integrated_orientation_advances_a_body_point_along_the_world_frame_omega() {
891 let space = EuclideanR4;
892 let start = Iso4Flat {
893 rotation: Bivector4::new(0.8, 0.0, 0.0, std::f32::consts::FRAC_PI_2, 0.0, 0.9).exp(),
894 translation: Vec4::ZERO,
895 };
896 let omega = Bivector4::new(0.7, 0.0, 0.0, 0.0, 0.5, 0.0);
897 let local = Vec4::new(0.5, -0.5, 0.5, 0.5);
898 let dt = 1e-3;
899
900 let before = start.rotation.apply(local);
901 let after = space
902 .integrate_orientation(start, omega, dt)
903 .rotation
904 .apply(local);
905 let residual = (after - (before + omega_cross_r(omega, before) * dt)).length();
906 assert!(
907 residual < 1e-5,
908 "integrated orientation left the world-frame field ω⌋r: residual \
909 {residual} over a step of {}",
910 (after - before).length()
911 );
912 }
913
914 #[test]
915 fn orientation_integration_preserves_unit_rotor() {
916 let space = EuclideanR4;
917 let mut iso = Iso4Flat::IDENTITY;
918 let omega = Bivector4::new(0.2, 0.0, 0.0, 0.0, 0.0, 0.15);
919 for _ in 0..1000 {
920 iso = space.integrate_orientation(iso, omega, 1.0 / 60.0);
921 }
922 let n = iso.rotation.norm_squared();
923 assert!(
924 (n - 1.0).abs() < 1e-3,
925 "rotor drifted off the unit manifold: |R|² = {n}"
926 );
927 }
928}