Skip to main content

alice_physics/
physics2d.rs

1//! 2D Physics Subsystem — Deterministic XPBD with 128-bit Fixed-Point Arithmetic
2//!
3//! A complete 2D rigid-body physics module built on [`Fix128`] (I64F64).
4//! Every operation is deterministic and produces bit-exact results across all
5//! platforms.
6//!
7//! # Features
8//!
9//! - **Vec2Fix**: 2D vector with full operator overloading
10//! - **Shape2D**: Circle, convex polygon, capsule, and edge shapes
11//! - **RigidBody2D**: Dynamic, static, and kinematic rigid bodies
12//! - **Collision Detection**: SAT for polygon-polygon, analytic for circles/capsules
13//! - **XPBD Solver**: Extended Position Based Dynamics with substeps
14//! - **Joint2D**: Revolute, distance, weld, and mouse joints
15//!
16//! # Determinism
17//!
18//! All arithmetic uses [`Fix128`]. No `f32`/`f64` anywhere. Iteration counts
19//! are fixed. No `HashMap` or non-deterministic data structures.
20
21#[cfg(not(feature = "std"))]
22use alloc::{vec, vec::Vec};
23
24use core::ops::{Add, Div, Mul, Neg, Sub};
25
26use crate::math::Fix128;
27
28// ============================================================================
29// Vec2Fix — 2D Vector
30// ============================================================================
31
32/// 2D vector using [`Fix128`] components.
33///
34/// Provides full operator overloading, geometric utilities, and deterministic
35/// arithmetic for 2D physics simulation.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37#[repr(C)]
38pub struct Vec2Fix {
39    /// X component
40    pub x: Fix128,
41    /// Y component
42    pub y: Fix128,
43}
44
45impl Vec2Fix {
46    /// Zero vector (0, 0)
47    pub const ZERO: Self = Self {
48        x: Fix128::ZERO,
49        y: Fix128::ZERO,
50    };
51
52    /// One vector (1, 1)
53    pub const ONE: Self = Self {
54        x: Fix128::ONE,
55        y: Fix128::ONE,
56    };
57
58    /// Unit X vector (1, 0)
59    pub const UNIT_X: Self = Self {
60        x: Fix128::ONE,
61        y: Fix128::ZERO,
62    };
63
64    /// Unit Y vector (0, 1)
65    pub const UNIT_Y: Self = Self {
66        x: Fix128::ZERO,
67        y: Fix128::ONE,
68    };
69
70    /// Create a new 2D vector.
71    #[inline]
72    #[must_use]
73    pub const fn new(x: Fix128, y: Fix128) -> Self {
74        Self { x, y }
75    }
76
77    /// Create from integer components.
78    #[inline]
79    #[must_use]
80    pub const fn from_int(x: i64, y: i64) -> Self {
81        Self {
82            x: Fix128::from_int(x),
83            y: Fix128::from_int(y),
84        }
85    }
86
87    /// Squared length (avoids sqrt).
88    #[inline]
89    #[must_use]
90    pub fn length_squared(self) -> Fix128 {
91        self.x * self.x + self.y * self.y
92    }
93
94    /// Length (magnitude).
95    #[inline]
96    #[must_use]
97    pub fn length(self) -> Fix128 {
98        self.length_squared().sqrt()
99    }
100
101    /// Normalize to unit length. Returns `ZERO` for zero-length vectors.
102    #[inline]
103    #[must_use]
104    pub fn normalize(self) -> Self {
105        let len = self.length();
106        if len.is_zero() {
107            Self::ZERO
108        } else {
109            self / len
110        }
111    }
112
113    /// Dot product.
114    #[inline]
115    #[must_use]
116    pub fn dot(self, rhs: Self) -> Fix128 {
117        self.x * rhs.x + self.y * rhs.y
118    }
119
120    /// 2D cross product (returns a scalar: `a.x * b.y - a.y * b.x`).
121    ///
122    /// This is the z-component of the 3D cross product when both vectors
123    /// are embedded in the XY plane.
124    #[inline]
125    #[must_use]
126    pub fn cross_scalar(self, rhs: Self) -> Fix128 {
127        self.x * rhs.y - self.y * rhs.x
128    }
129
130    /// Rotate this vector by an angle (radians, counter-clockwise).
131    #[must_use]
132    pub fn rotate(self, angle: Fix128) -> Self {
133        let (sin_a, cos_a) = angle.sin_cos();
134        Self {
135            x: self.x * cos_a - self.y * sin_a,
136            y: self.x * sin_a + self.y * cos_a,
137        }
138    }
139
140    /// Return the perpendicular vector (90 degrees counter-clockwise): `(-y, x)`.
141    #[inline]
142    #[must_use]
143    pub fn perpendicular(self) -> Self {
144        Self {
145            x: -self.y,
146            y: self.x,
147        }
148    }
149
150    /// Distance to another point.
151    #[inline]
152    #[must_use]
153    pub fn distance_to(self, other: Self) -> Fix128 {
154        (other - self).length()
155    }
156
157    /// Linear interpolation: `self + (other - self) * t`.
158    #[inline]
159    #[must_use]
160    pub fn lerp(self, other: Self, t: Fix128) -> Self {
161        self + (other - self) * t
162    }
163
164    /// Scale by a scalar.
165    #[inline]
166    #[must_use]
167    pub fn scale(self, s: Fix128) -> Self {
168        Self {
169            x: self.x * s,
170            y: self.y * s,
171        }
172    }
173}
174
175impl Add for Vec2Fix {
176    type Output = Self;
177
178    #[inline]
179    fn add(self, rhs: Self) -> Self {
180        Self {
181            x: self.x + rhs.x,
182            y: self.y + rhs.y,
183        }
184    }
185}
186
187impl Sub for Vec2Fix {
188    type Output = Self;
189
190    #[inline]
191    fn sub(self, rhs: Self) -> Self {
192        Self {
193            x: self.x - rhs.x,
194            y: self.y - rhs.y,
195        }
196    }
197}
198
199impl Mul<Fix128> for Vec2Fix {
200    type Output = Self;
201
202    #[inline]
203    fn mul(self, rhs: Fix128) -> Self {
204        self.scale(rhs)
205    }
206}
207
208impl Div<Fix128> for Vec2Fix {
209    type Output = Self;
210
211    #[inline]
212    fn div(self, rhs: Fix128) -> Self {
213        Self {
214            x: self.x / rhs,
215            y: self.y / rhs,
216        }
217    }
218}
219
220impl Neg for Vec2Fix {
221    type Output = Self;
222
223    #[inline]
224    fn neg(self) -> Self {
225        Self {
226            x: -self.x,
227            y: -self.y,
228        }
229    }
230}
231
232// ============================================================================
233// Shape2D — Collision Shapes
234// ============================================================================
235
236/// 2D collision shape.
237///
238/// All shapes are defined in local space relative to the body's center of mass.
239#[derive(Clone, Debug)]
240pub enum Shape2D {
241    /// Circle defined by its radius.
242    Circle {
243        /// Radius of the circle.
244        radius: Fix128,
245    },
246    /// Convex polygon defined by vertices in CCW winding order.
247    Polygon {
248        /// Vertices in counter-clockwise order. Must form a convex hull.
249        vertices: Vec<Vec2Fix>,
250    },
251    /// Capsule defined by a radius and half-length along the local X axis.
252    Capsule {
253        /// Radius of the capsule's hemicircles.
254        radius: Fix128,
255        /// Half of the segment length between hemicircle centers.
256        half_length: Fix128,
257    },
258    /// Line segment (edge) from start to end.
259    ///
260    /// Zero thickness and two-sided: it collides with circles, capsules and
261    /// polygons from either side, but not with other edges (two segments
262    /// enclose no area, so parallel edges never overlap).
263    Edge {
264        /// Start point in local space.
265        start: Vec2Fix,
266        /// End point in local space.
267        end: Vec2Fix,
268    },
269}
270
271// ============================================================================
272// BodyType2D
273// ============================================================================
274
275/// Type of a 2D rigid body.
276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
277pub enum BodyType2D {
278    /// Fully simulated body affected by forces and collisions.
279    Dynamic,
280    /// Immovable body (infinite mass, zero velocity).
281    Static,
282    /// User-controlled body that affects dynamics but is not affected by forces.
283    Kinematic,
284}
285
286// ============================================================================
287// RigidBody2D
288// ============================================================================
289
290/// 2D rigid body with position, orientation, velocity, and shape.
291#[derive(Clone, Debug)]
292pub struct RigidBody2D {
293    /// World-space position of the center of mass.
294    pub position: Vec2Fix,
295    /// Orientation angle in radians (counter-clockwise from +X).
296    pub angle: Fix128,
297    /// Linear velocity.
298    pub velocity: Vec2Fix,
299    /// Angular velocity (radians per second, positive = CCW).
300    pub angular_velocity: Fix128,
301    /// Inverse mass (0 for static/kinematic bodies).
302    pub inv_mass: Fix128,
303    /// Inverse moment of inertia (0 for static/kinematic bodies).
304    pub inv_inertia: Fix128,
305    /// Collision shape.
306    pub shape: Shape2D,
307    /// Coefficient of restitution (bounciness, 0..1).
308    pub restitution: Fix128,
309    /// Friction coefficient.
310    pub friction: Fix128,
311    /// Body type.
312    pub body_type: BodyType2D,
313    /// Previous position (used by XPBD solver).
314    pub prev_position: Vec2Fix,
315    /// Previous angle (used by XPBD solver).
316    pub prev_angle: Fix128,
317}
318
319impl RigidBody2D {
320    /// Create a new dynamic body with a circle shape.
321    ///
322    /// Mass is derived from the given value. Inertia is computed for the circle
323    /// shape: `I = 0.5 * m * r^2`.
324    #[must_use]
325    pub fn new_dynamic(position: Vec2Fix, mass: Fix128, shape: Shape2D) -> Self {
326        let inv_mass = if mass.is_zero() {
327            Fix128::ZERO
328        } else {
329            Fix128::ONE / mass
330        };
331        let inertia = compute_inertia(&shape, mass);
332        let inv_inertia = if inertia.is_zero() {
333            Fix128::ZERO
334        } else {
335            Fix128::ONE / inertia
336        };
337        Self {
338            position,
339            angle: Fix128::ZERO,
340            velocity: Vec2Fix::ZERO,
341            angular_velocity: Fix128::ZERO,
342            inv_mass,
343            inv_inertia,
344            shape,
345            restitution: Fix128::from_ratio(5, 10),
346            friction: Fix128::from_ratio(3, 10),
347            body_type: BodyType2D::Dynamic,
348            prev_position: position,
349            prev_angle: Fix128::ZERO,
350        }
351    }
352
353    /// Create a new static (immovable) body.
354    #[must_use]
355    pub fn new_static(position: Vec2Fix, shape: Shape2D) -> Self {
356        Self {
357            position,
358            angle: Fix128::ZERO,
359            velocity: Vec2Fix::ZERO,
360            angular_velocity: Fix128::ZERO,
361            inv_mass: Fix128::ZERO,
362            inv_inertia: Fix128::ZERO,
363            shape,
364            restitution: Fix128::from_ratio(5, 10),
365            friction: Fix128::from_ratio(5, 10),
366            body_type: BodyType2D::Static,
367            prev_position: position,
368            prev_angle: Fix128::ZERO,
369        }
370    }
371
372    /// Create a new kinematic body.
373    #[must_use]
374    pub fn new_kinematic(position: Vec2Fix, shape: Shape2D) -> Self {
375        Self {
376            position,
377            angle: Fix128::ZERO,
378            velocity: Vec2Fix::ZERO,
379            angular_velocity: Fix128::ZERO,
380            inv_mass: Fix128::ZERO,
381            inv_inertia: Fix128::ZERO,
382            shape,
383            restitution: Fix128::from_ratio(5, 10),
384            friction: Fix128::from_ratio(3, 10),
385            body_type: BodyType2D::Kinematic,
386            prev_position: position,
387            prev_angle: Fix128::ZERO,
388        }
389    }
390
391    /// Apply a linear impulse at the center of mass.
392    #[inline]
393    pub fn apply_impulse(&mut self, impulse: Vec2Fix) {
394        if self.body_type != BodyType2D::Dynamic {
395            return;
396        }
397        self.velocity = self.velocity + impulse * self.inv_mass;
398    }
399
400    /// Apply a linear impulse at a world-space point, generating both linear
401    /// and angular impulse.
402    pub fn apply_impulse_at_point(&mut self, impulse: Vec2Fix, world_point: Vec2Fix) {
403        if self.body_type != BodyType2D::Dynamic {
404            return;
405        }
406        self.velocity = self.velocity + impulse * self.inv_mass;
407        let r = world_point - self.position;
408        self.angular_velocity = self.angular_velocity + r.cross_scalar(impulse) * self.inv_inertia;
409    }
410
411    /// Apply a force (will be integrated over the next timestep).
412    #[inline]
413    pub fn apply_force(&mut self, force: Vec2Fix, dt: Fix128) {
414        if self.body_type != BodyType2D::Dynamic {
415            return;
416        }
417        self.velocity = self.velocity + force * self.inv_mass * dt;
418    }
419
420    /// Transform a local-space point to world space.
421    #[must_use]
422    pub fn world_point(&self, local: Vec2Fix) -> Vec2Fix {
423        self.position + local.rotate(self.angle)
424    }
425
426    /// Returns `true` if this body has zero inverse mass (static or kinematic).
427    #[inline]
428    #[must_use]
429    pub fn is_static_or_kinematic(&self) -> bool {
430        self.body_type != BodyType2D::Dynamic
431    }
432}
433
434/// Compute moment of inertia for a 2D shape with given mass.
435fn compute_inertia(shape: &Shape2D, mass: Fix128) -> Fix128 {
436    match shape {
437        Shape2D::Circle { radius } => {
438            // I = 0.5 * m * r^2
439            mass * *radius * *radius / Fix128::from_int(2)
440        }
441        Shape2D::Capsule {
442            radius,
443            half_length,
444        } => {
445            // Approximate as rectangle + two semicircles
446            // I_rect = m_rect * (w^2 + h^2) / 12
447            // I_circle = m_circle * r^2 / 2
448            // Simplified: I ~ m * (r^2 / 2 + half_length^2 / 3)
449            let r2 = *radius * *radius;
450            let h2 = *half_length * *half_length;
451            mass * (r2 / Fix128::from_int(2) + h2 / Fix128::from_int(3))
452        }
453        Shape2D::Polygon { vertices } => {
454            // Use the polygon moment of inertia formula
455            if vertices.len() < 3 {
456                return Fix128::ZERO;
457            }
458            let mut numerator = Fix128::ZERO;
459            let mut denominator = Fix128::ZERO;
460            let n = vertices.len();
461            for i in 0..n {
462                let a = vertices[i];
463                let b = vertices[(i + 1) % n];
464                let cross = a.cross_scalar(b).abs();
465                numerator = numerator + cross * (a.dot(a) + a.dot(b) + b.dot(b));
466                denominator = denominator + cross;
467            }
468            if denominator.is_zero() {
469                return Fix128::ZERO;
470            }
471            mass * numerator / (denominator * Fix128::from_int(6))
472        }
473        Shape2D::Edge { start, end } => {
474            // Treat as thin rod: I = m * L^2 / 12
475            let l2 = (*end - *start).length_squared();
476            mass * l2 / Fix128::from_int(12)
477        }
478    }
479}
480
481// ============================================================================
482// Contact2D
483// ============================================================================
484
485/// Contact point between two 2D bodies.
486#[derive(Clone, Copy, Debug, PartialEq, Eq)]
487pub struct Contact2D {
488    /// World-space contact point.
489    pub point: Vec2Fix,
490    /// Contact normal (points from body_a toward body_b).
491    pub normal: Vec2Fix,
492    /// Penetration depth (positive means overlapping).
493    pub depth: Fix128,
494    /// Index of the first body.
495    pub body_a: usize,
496    /// Index of the second body.
497    pub body_b: usize,
498}
499
500// ============================================================================
501// PhysicsConfig2D
502// ============================================================================
503
504/// Configuration for the 2D physics world.
505#[derive(Clone, Debug)]
506pub struct PhysicsConfig2D {
507    /// Gravitational acceleration vector.
508    pub gravity: Vec2Fix,
509    /// Number of substeps per `step()` call.
510    pub substeps: usize,
511    /// Number of constraint solver iterations per substep.
512    pub iterations: usize,
513    /// Linear velocity damping factor (applied per step, 0..1).
514    pub damping: Fix128,
515}
516
517impl Default for PhysicsConfig2D {
518    fn default() -> Self {
519        Self {
520            gravity: Vec2Fix::new(Fix128::ZERO, Fix128::from_int(-10)),
521            substeps: 4,
522            iterations: 8,
523            damping: Fix128::from_ratio(99, 100),
524        }
525    }
526}
527
528// ============================================================================
529// PhysicsWorld2D
530// ============================================================================
531
532/// 2D physics world containing bodies, joints, and solver state.
533pub struct PhysicsWorld2D {
534    /// All rigid bodies in the world.
535    pub bodies: Vec<RigidBody2D>,
536    /// Gravity vector.
537    pub gravity: Vec2Fix,
538    /// Solver configuration.
539    pub config: PhysicsConfig2D,
540    /// Active joints.
541    pub joints: Vec<Joint2D>,
542}
543
544impl PhysicsWorld2D {
545    /// Create a new empty 2D physics world.
546    #[must_use]
547    pub const fn new(config: PhysicsConfig2D) -> Self {
548        let gravity = config.gravity;
549        Self {
550            bodies: Vec::new(),
551            gravity,
552            config,
553            joints: Vec::new(),
554        }
555    }
556
557    /// Add a body to the world. Returns the body index.
558    pub fn add_body(&mut self, body: RigidBody2D) -> usize {
559        let idx = self.bodies.len();
560        self.bodies.push(body);
561        idx
562    }
563
564    /// Remove a body by index. Swaps with last to preserve indices of other
565    /// bodies (except the last one). Returns the removed body if the index
566    /// was valid.
567    pub fn remove_body(&mut self, index: usize) -> Option<RigidBody2D> {
568        if index >= self.bodies.len() {
569            return None;
570        }
571        Some(self.bodies.swap_remove(index))
572    }
573
574    /// Add a joint to the world.
575    pub fn add_joint(&mut self, joint: Joint2D) {
576        self.joints.push(joint);
577    }
578
579    /// Step the simulation by `dt` seconds.
580    ///
581    /// Internally runs `config.substeps` substeps, each with collision
582    /// detection, XPBD position correction, and velocity derivation.
583    pub fn step(&mut self, dt: Fix128) {
584        self.step_with_tethers(dt, &Tethers2D::new());
585    }
586
587    /// [`step`](Self::step) that also solves the angular tethers and kinematic drives in
588    /// `tethers` inside every substep (drives right after the velocity integration,
589    /// tethers in the constraint iterations next to the joints). With an empty set this is
590    /// `step`, bit for bit.
591    pub fn step_with_tethers(&mut self, dt: Fix128, tethers: &Tethers2D) {
592        let substeps = self.config.substeps;
593        if substeps == 0 {
594            return;
595        }
596        let sub_dt = dt / Fix128::from_int(substeps as i64);
597
598        for _ in 0..substeps {
599            self.substep(sub_dt, tethers);
600        }
601
602        // Apply damping
603        let damping = self.config.damping;
604        for body in &mut self.bodies {
605            if body.body_type == BodyType2D::Dynamic {
606                body.velocity = body.velocity * damping;
607                body.angular_velocity = body.angular_velocity * damping;
608            }
609        }
610    }
611
612    /// Perform one substep of the XPBD solver.
613    fn substep(&mut self, sub_dt: Fix128, tethers: &Tethers2D) {
614        let gravity = self.gravity;
615        let iterations = self.config.iterations;
616
617        // 1. Store previous state and integrate velocity
618        for body in &mut self.bodies {
619            if body.body_type != BodyType2D::Dynamic {
620                // Kinematic: update prev but don't apply gravity
621                body.prev_position = body.position;
622                body.prev_angle = body.angle;
623                if body.body_type == BodyType2D::Kinematic {
624                    body.position = body.position + body.velocity * sub_dt;
625                    body.angle = body.angle + body.angular_velocity * sub_dt;
626                }
627                continue;
628            }
629            body.prev_position = body.position;
630            body.prev_angle = body.angle;
631
632            // Apply gravity
633            body.velocity = body.velocity + gravity * sub_dt;
634
635            // Predict position
636            body.position = body.position + body.velocity * sub_dt;
637            body.angle = body.angle + body.angular_velocity * sub_dt;
638        }
639        for drive in tethers.live_drives() {
640            apply_kinematic_drive(&mut self.bodies, drive, sub_dt);
641        }
642
643        // 2. Detect collisions (pairs + the pre-solve normal velocity each
644        //    pair approaches with, which the restitution pass needs)
645        let contacts = self.detect_all_contacts();
646        let pre_normal_velocity: Vec<Fix128> = contacts
647            .iter()
648            .map(|c| self.contact_normal_velocity(c))
649            .collect();
650        let mut lambda_n = vec![Fix128::ZERO; contacts.len()];
651        let mut joint_lambda = vec![Vec2Fix::ZERO; self.joints.len()];
652        let mut tether_lambda = vec![Fix128::ZERO; tethers.angular.len()];
653
654        // 3. Solve constraints (position-based). The penetration is
655        //    re-evaluated from the current positions in every iteration
656        //    (Macklin 2016 / 2020) instead of re-applying the depth measured
657        //    before the solve: before 1.2.0 each iteration pushed the same
658        //    stale depth again, so `iterations = 8` separated a 2 m/s head-on
659        //    collision at 14 m/s.
660        for _ in 0..iterations {
661            for (k, pair) in contacts.iter().enumerate() {
662                let Some(current) = self.check_collision_2d(pair.body_a, pair.body_b) else {
663                    continue;
664                };
665                lambda_n[k] = lambda_n[k] + solve_contact_position(&mut self.bodies, &current);
666            }
667
668            // Solve joints
669            solve_joints_2d_accumulated(&mut self.bodies, &self.joints, sub_dt, &mut joint_lambda);
670
671            for (slot, lambda) in tethers.angular.iter().zip(tether_lambda.iter_mut()) {
672                if let Some(tether) = slot {
673                    solve_angular_tether(&mut self.bodies, tether, sub_dt, lambda);
674                }
675            }
676        }
677
678        // 4. Derive velocity from position change
679        if sub_dt.is_zero() {
680            return;
681        }
682        let inv_dt = Fix128::ONE / sub_dt;
683        for body in &mut self.bodies {
684            if body.body_type != BodyType2D::Dynamic {
685                continue;
686            }
687            body.velocity = (body.position - body.prev_position) * inv_dt;
688            body.angular_velocity = (body.angle - body.prev_angle) * inv_dt;
689        }
690
691        // 5. Velocity pass (Macklin et al. 2020 "Detailed rigid body
692        //    simulation with XPBD" §3.6): Newton restitution
693        //    `v_n' = max(−e v̄_n, 0)` against the pre-solve approach speed
694        //    and Coulomb friction `|Δv_t| ≤ μ λ_n / h`. Before 1.2.0
695        //    `restitution` / `friction` were never read.
696        let restitution_threshold = self.gravity.length() * sub_dt * Fix128::from_int(2);
697        for (k, pair) in contacts.iter().enumerate() {
698            if lambda_n[k] <= Fix128::ZERO {
699                continue;
700            }
701            self.apply_contact_velocity_pass(
702                pair,
703                pre_normal_velocity[k],
704                lambda_n[k] * inv_dt,
705                restitution_threshold,
706            );
707        }
708    }
709
710    /// Relative normal velocity of the two bodies at the contact point,
711    /// `(v_b − v_a) · n` with `n` pointing from A to B (negative while
712    /// approaching).
713    fn contact_normal_velocity(&self, c: &Contact2D) -> Fix128 {
714        let (va, vb) = (
715            self.point_velocity(c.body_a, c.point),
716            self.point_velocity(c.body_b, c.point),
717        );
718        (vb - va).dot(c.normal)
719    }
720
721    /// Velocity of a body's material point at world position `p`.
722    fn point_velocity(&self, body: usize, p: Vec2Fix) -> Vec2Fix {
723        let b = &self.bodies[body];
724        let r = p - b.position;
725        b.velocity + r.perpendicular() * b.angular_velocity
726    }
727
728    /// Restitution + friction impulses for one contact after the velocity
729    /// derivation. `friction_bound` is `μ λ_n / h` before the `μ` factor
730    /// (i.e. `λ_n / h`), `pre_vn` the approach speed before the solve.
731    fn apply_contact_velocity_pass(
732        &mut self,
733        c: &Contact2D,
734        pre_vn: Fix128,
735        lambda_n_over_h: Fix128,
736        restitution_threshold: Fix128,
737    ) {
738        let (a, b, n) = (c.body_a, c.body_b, c.normal);
739        let dyn_a = self.bodies[a].body_type == BodyType2D::Dynamic;
740        let dyn_b = self.bodies[b].body_type == BodyType2D::Dynamic;
741        if !dyn_a && !dyn_b {
742            return;
743        }
744        let r_a = c.point - self.bodies[a].position;
745        let r_b = c.point - self.bodies[b].position;
746        let inv_m_a = if dyn_a {
747            self.bodies[a].inv_mass
748        } else {
749            Fix128::ZERO
750        };
751        let inv_m_b = if dyn_b {
752            self.bodies[b].inv_mass
753        } else {
754            Fix128::ZERO
755        };
756        let inv_i_a = if dyn_a {
757            self.bodies[a].inv_inertia
758        } else {
759            Fix128::ZERO
760        };
761        let inv_i_b = if dyn_b {
762            self.bodies[b].inv_inertia
763        } else {
764            Fix128::ZERO
765        };
766        // generalised inverse mass along a direction d at the contact point
767        let w_along = |d: Vec2Fix| {
768            let ra_d = r_a.cross_scalar(d);
769            let rb_d = r_b.cross_scalar(d);
770            inv_m_a + inv_m_b + inv_i_a * ra_d * ra_d + inv_i_b * rb_d * rb_d
771        };
772        // apply an impulse `p` along `d` (+ on B, − on A)
773        let apply = |bodies: &mut [RigidBody2D], d: Vec2Fix, p: Fix128| {
774            if dyn_a {
775                bodies[a].velocity = bodies[a].velocity - d * (p * inv_m_a);
776                bodies[a].angular_velocity =
777                    bodies[a].angular_velocity - r_a.cross_scalar(d) * p * inv_i_a;
778            }
779            if dyn_b {
780                bodies[b].velocity = bodies[b].velocity + d * (p * inv_m_b);
781                bodies[b].angular_velocity =
782                    bodies[b].angular_velocity + r_b.cross_scalar(d) * p * inv_i_b;
783            }
784        };
785
786        // --- restitution ---
787        let v_rel = self.point_velocity(b, c.point) - self.point_velocity(a, c.point);
788        let vn = v_rel.dot(n);
789        // only a genuine approach bounces; resting contacts (|v̄_n| below
790        // 2 g h) get e = 0 so stacks do not jitter
791        let e = if pre_vn < -restitution_threshold {
792            let ea = self.bodies[a].restitution;
793            let eb = self.bodies[b].restitution;
794            if ea > eb {
795                ea
796            } else {
797                eb
798            }
799        } else {
800            Fix128::ZERO
801        };
802        let target = -e * pre_vn;
803        let target = if target.is_negative() {
804            Fix128::ZERO
805        } else {
806            target
807        };
808        let w_n = w_along(n);
809        if vn < target && !w_n.is_zero() {
810            apply(&mut self.bodies, n, (target - vn) / w_n);
811        }
812
813        // --- Coulomb friction ---
814        let v_rel = self.point_velocity(b, c.point) - self.point_velocity(a, c.point);
815        let vt = v_rel - n * v_rel.dot(n);
816        let vt_len = vt.length();
817        if vt_len.is_zero() {
818            return;
819        }
820        let mu = (self.bodies[a].friction * self.bodies[b].friction).sqrt();
821        let max_dv = mu * lambda_n_over_h;
822        let dv = if vt_len < max_dv { vt_len } else { max_dv };
823        if dv.is_zero() {
824            return;
825        }
826        let t = vt * (Fix128::ONE / vt_len);
827        let w_t = w_along(t);
828        if !w_t.is_zero() {
829            apply(&mut self.bodies, t, -dv / w_t);
830        }
831    }
832
833    /// Detect all contact pairs via brute-force N^2 broad phase.
834    fn detect_all_contacts(&self) -> Vec<Contact2D> {
835        let mut contacts = Vec::new();
836        let n = self.bodies.len();
837        for i in 0..n {
838            for j in (i + 1)..n {
839                // Skip static-static pairs
840                if self.bodies[i].is_static_or_kinematic()
841                    && self.bodies[j].is_static_or_kinematic()
842                {
843                    continue;
844                }
845                if let Some(contact) = self.check_collision_2d(i, j) {
846                    contacts.push(contact);
847                }
848            }
849        }
850        contacts
851    }
852
853    /// Check collision between two bodies by index.
854    #[must_use]
855    pub fn check_collision_2d(&self, idx_a: usize, idx_b: usize) -> Option<Contact2D> {
856        let body_a = &self.bodies[idx_a];
857        let body_b = &self.bodies[idx_b];
858
859        let result = match (&body_a.shape, &body_b.shape) {
860            (Shape2D::Circle { radius: ra }, Shape2D::Circle { radius: rb }) => {
861                circle_vs_circle(body_a.position, *ra, body_b.position, *rb)
862            }
863            (Shape2D::Circle { radius }, Shape2D::Polygon { vertices }) => {
864                circle_vs_polygon(body_a.position, *radius, body_b, vertices)
865            }
866            (Shape2D::Polygon { vertices }, Shape2D::Circle { radius }) => {
867                circle_vs_polygon(body_b.position, *radius, body_a, vertices).map(|mut c| {
868                    c.normal = -c.normal;
869                    c
870                })
871            }
872            (Shape2D::Polygon { vertices: va }, Shape2D::Polygon { vertices: vb }) => {
873                polygon_vs_polygon(body_a, va, body_b, vb)
874            }
875            (
876                Shape2D::Circle { radius: rc },
877                Shape2D::Capsule {
878                    radius: rcap,
879                    half_length,
880                },
881            ) => {
882                // capsule_vs_circle returns capsule -> circle; here A is the circle
883                capsule_vs_circle(body_b, *rcap, *half_length, body_a.position, *rc).map(|mut c| {
884                    c.normal = -c.normal;
885                    c
886                })
887            }
888            (
889                Shape2D::Capsule {
890                    radius: rcap,
891                    half_length,
892                },
893                Shape2D::Circle { radius: rc },
894            ) => capsule_vs_circle(body_a, *rcap, *half_length, body_b.position, *rc),
895            (Shape2D::Edge { start, end }, Shape2D::Circle { radius }) => {
896                edge_vs_circle(body_a, *start, *end, body_b.position, *radius)
897            }
898            (Shape2D::Circle { radius }, Shape2D::Edge { start, end }) => {
899                edge_vs_circle(body_b, *start, *end, body_a.position, *radius).map(|mut c| {
900                    c.normal = -c.normal;
901                    c
902                })
903            }
904            // Two zero-thickness segments enclose no area: parallel edges never
905            // overlap (a falling edge passes a parallel one between substeps)
906            // and crossing edges have no orientation-independent penetration,
907            // so edges deliberately do not collide with each other.
908            (Shape2D::Edge { .. }, Shape2D::Edge { .. }) => None,
909            (
910                Shape2D::Capsule { .. } | Shape2D::Edge { .. } | Shape2D::Polygon { .. },
911                Shape2D::Capsule { .. } | Shape2D::Edge { .. } | Shape2D::Polygon { .. },
912            ) => match (rounded_core(body_a), rounded_core(body_b)) {
913                (Some((core_a, ra)), Some((core_b, rb))) => {
914                    rounded_core_vs_rounded_core(&core_a, ra, &core_b, rb)
915                }
916                _ => None,
917            },
918        };
919
920        result.map(|mut c| {
921            c.body_a = idx_a;
922            c.body_b = idx_b;
923            c
924        })
925    }
926}
927
928// ============================================================================
929// Collision Detection — Internal Functions
930// ============================================================================
931
932/// Circle vs circle collision test.
933fn circle_vs_circle(
934    pos_a: Vec2Fix,
935    radius_a: Fix128,
936    pos_b: Vec2Fix,
937    radius_b: Fix128,
938) -> Option<Contact2D> {
939    let delta = pos_b - pos_a;
940    let dist_sq = delta.length_squared();
941    let sum_r = radius_a + radius_b;
942    let sum_r_sq = sum_r * sum_r;
943
944    if dist_sq > sum_r_sq || dist_sq.is_zero() {
945        return None;
946    }
947
948    let dist = dist_sq.sqrt();
949    let normal = if dist.is_zero() {
950        Vec2Fix::UNIT_Y
951    } else {
952        delta / dist
953    };
954
955    let depth = sum_r - dist;
956    let point = pos_a + normal * (radius_a - depth.half());
957
958    Some(Contact2D {
959        point,
960        normal,
961        depth,
962        body_a: 0,
963        body_b: 0,
964    })
965}
966
967/// Transform polygon vertices from local space to world space.
968fn transform_vertices(body: &RigidBody2D, local_verts: &[Vec2Fix]) -> Vec<Vec2Fix> {
969    local_verts.iter().map(|v| body.world_point(*v)).collect()
970}
971
972/// Circle vs convex polygon collision using SAT with Voronoi regions.
973///
974/// The returned normal points from the circle (first argument) toward the
975/// polygon, i.e. against the polygon's outward face / vertex direction, so the
976/// `(Circle, Polygon)` arm of `check_collision_2d` uses it as body_a -> body_b.
977fn circle_vs_polygon(
978    circle_pos: Vec2Fix,
979    circle_radius: Fix128,
980    poly_body: &RigidBody2D,
981    poly_verts: &[Vec2Fix],
982) -> Option<Contact2D> {
983    if poly_verts.len() < 3 {
984        return None;
985    }
986
987    let world_verts = transform_vertices(poly_body, poly_verts);
988    let n = world_verts.len();
989
990    // Find the closest edge and check separation
991    let mut best_dist = Fix128::from_int(-999_999);
992    let mut best_normal = Vec2Fix::ZERO;
993    let mut best_idx = 0;
994
995    for i in 0..n {
996        let a = world_verts[i];
997        let b = world_verts[(i + 1) % n];
998        let edge = b - a;
999        // Outward normal (for CCW winding)
1000        let normal = Vec2Fix::new(edge.y, -edge.x).normalize();
1001        let d = (circle_pos - a).dot(normal);
1002
1003        if d > best_dist {
1004            best_dist = d;
1005            best_normal = normal;
1006            best_idx = i;
1007        }
1008    }
1009
1010    // Check if circle center is outside the polygon beyond its radius
1011    if best_dist > circle_radius {
1012        return None;
1013    }
1014
1015    // Determine Voronoi region: vertex or edge
1016    let a = world_verts[best_idx];
1017    let b = world_verts[(best_idx + 1) % n];
1018    let edge = b - a;
1019    let edge_len_sq = edge.length_squared();
1020
1021    let t = if edge_len_sq.is_zero() {
1022        Fix128::ZERO
1023    } else {
1024        (circle_pos - a).dot(edge) / edge_len_sq
1025    };
1026
1027    if t < Fix128::ZERO {
1028        // Vertex A region
1029        let delta = circle_pos - a;
1030        let dist = delta.length();
1031        if dist > circle_radius || dist.is_zero() {
1032            return None;
1033        }
1034        let normal = delta / dist;
1035        let depth = circle_radius - dist;
1036        Some(Contact2D {
1037            point: a,
1038            normal: -normal,
1039            depth,
1040            body_a: 0,
1041            body_b: 0,
1042        })
1043    } else if t > Fix128::ONE {
1044        // Vertex B region
1045        let delta = circle_pos - b;
1046        let dist = delta.length();
1047        if dist > circle_radius || dist.is_zero() {
1048            return None;
1049        }
1050        let normal = delta / dist;
1051        let depth = circle_radius - dist;
1052        Some(Contact2D {
1053            point: b,
1054            normal: -normal,
1055            depth,
1056            body_a: 0,
1057            body_b: 0,
1058        })
1059    } else {
1060        // Edge region
1061        let depth = circle_radius - best_dist;
1062        if depth.is_negative() {
1063            return None;
1064        }
1065        let point = circle_pos - best_normal * best_dist;
1066        Some(Contact2D {
1067            point,
1068            normal: -best_normal,
1069            depth,
1070            body_a: 0,
1071            body_b: 0,
1072        })
1073    }
1074}
1075
1076/// Convex polygon vs convex polygon collision using SAT (Separating Axis Theorem).
1077fn polygon_vs_polygon(
1078    body_a: &RigidBody2D,
1079    verts_a: &[Vec2Fix],
1080    body_b: &RigidBody2D,
1081    verts_b: &[Vec2Fix],
1082) -> Option<Contact2D> {
1083    if verts_a.len() < 3 || verts_b.len() < 3 {
1084        return None;
1085    }
1086
1087    let world_a = transform_vertices(body_a, verts_a);
1088    let world_b = transform_vertices(body_b, verts_b);
1089
1090    let mut min_depth = Fix128::from_int(999_999);
1091    let mut best_normal = Vec2Fix::ZERO;
1092
1093    // Test axes from polygon A
1094    if let Some((depth, normal)) = sat_test_axes(&world_a, &world_b) {
1095        if depth < min_depth {
1096            min_depth = depth;
1097            best_normal = normal;
1098        }
1099    } else {
1100        return None; // Separating axis found
1101    }
1102
1103    // Test axes from polygon B
1104    if let Some((depth, normal)) = sat_test_axes(&world_b, &world_a) {
1105        if depth < min_depth {
1106            min_depth = depth;
1107            best_normal = normal;
1108        }
1109    } else {
1110        return None; // Separating axis found
1111    }
1112
1113    // Ensure normal points from A to B
1114    let center_a = polygon_centroid(&world_a);
1115    let center_b = polygon_centroid(&world_b);
1116    let ab = center_b - center_a;
1117    if ab.dot(best_normal).is_negative() {
1118        best_normal = -best_normal;
1119    }
1120
1121    // Compute contact point (midpoint of overlap)
1122    let point = (center_a + center_b) * Fix128::from_ratio(1, 2);
1123
1124    Some(Contact2D {
1125        point,
1126        normal: best_normal,
1127        depth: min_depth,
1128        body_a: 0,
1129        body_b: 0,
1130    })
1131}
1132
1133/// Test all edge normals of `poly_ref` as separating axes against `poly_test`.
1134/// Returns the minimum overlap depth and corresponding normal, or `None` if
1135/// a separating axis is found.
1136fn sat_test_axes(poly_ref: &[Vec2Fix], poly_test: &[Vec2Fix]) -> Option<(Fix128, Vec2Fix)> {
1137    let n = poly_ref.len();
1138    let mut min_depth = Fix128::from_int(999_999);
1139    let mut best_normal = Vec2Fix::ZERO;
1140
1141    for i in 0..n {
1142        let a = poly_ref[i];
1143        let b = poly_ref[(i + 1) % n];
1144        let edge = b - a;
1145        let normal = Vec2Fix::new(edge.y, -edge.x).normalize();
1146
1147        // Project both polygons onto this axis
1148        let (min_a, max_a) = project_polygon(poly_ref, normal);
1149        let (min_b, max_b) = project_polygon(poly_test, normal);
1150
1151        // Check overlap
1152        if max_a < min_b || max_b < min_a {
1153            return None; // Separating axis found
1154        }
1155
1156        // Compute overlap depth
1157        let overlap1 = max_a - min_b;
1158        let overlap2 = max_b - min_a;
1159        let depth = if overlap1 < overlap2 {
1160            overlap1
1161        } else {
1162            overlap2
1163        };
1164
1165        if depth < min_depth {
1166            min_depth = depth;
1167            best_normal = normal;
1168        }
1169    }
1170
1171    Some((min_depth, best_normal))
1172}
1173
1174/// Project a polygon onto an axis and return (min, max) projections.
1175fn project_polygon(verts: &[Vec2Fix], axis: Vec2Fix) -> (Fix128, Fix128) {
1176    let mut min_proj = verts[0].dot(axis);
1177    let mut max_proj = min_proj;
1178
1179    for v in verts.iter().skip(1) {
1180        let p = v.dot(axis);
1181        if p < min_proj {
1182            min_proj = p;
1183        }
1184        if p > max_proj {
1185            max_proj = p;
1186        }
1187    }
1188
1189    (min_proj, max_proj)
1190}
1191
1192/// Compute centroid of a polygon.
1193fn polygon_centroid(verts: &[Vec2Fix]) -> Vec2Fix {
1194    let mut sum = Vec2Fix::ZERO;
1195    for v in verts {
1196        sum = sum + *v;
1197    }
1198    let n = Fix128::from_int(verts.len() as i64);
1199    sum / n
1200}
1201
1202/// Capsule vs circle collision.
1203///
1204/// The returned normal points from the capsule (first argument) toward the
1205/// circle. The capsule is centered at `body_cap.position` with its segment along the
1206/// local X axis (from `-half_length` to `+half_length`).
1207fn capsule_vs_circle(
1208    body_cap: &RigidBody2D,
1209    cap_radius: Fix128,
1210    cap_half_length: Fix128,
1211    circle_pos: Vec2Fix,
1212    circle_radius: Fix128,
1213) -> Option<Contact2D> {
1214    // Capsule segment endpoints in world space
1215    let local_a = Vec2Fix::new(-cap_half_length, Fix128::ZERO);
1216    let local_b = Vec2Fix::new(cap_half_length, Fix128::ZERO);
1217    let seg_a = body_cap.world_point(local_a);
1218    let seg_b = body_cap.world_point(local_b);
1219
1220    // Find closest point on segment to circle center
1221    let closest = closest_point_on_segment(seg_a, seg_b, circle_pos);
1222
1223    // Now it's a circle-circle test between closest point and circle center
1224    let sum_r = cap_radius + circle_radius;
1225    let delta = circle_pos - closest;
1226    let dist_sq = delta.length_squared();
1227    let sum_r_sq = sum_r * sum_r;
1228
1229    if dist_sq > sum_r_sq {
1230        return None;
1231    }
1232
1233    let dist = dist_sq.sqrt();
1234    let normal = if dist.is_zero() {
1235        Vec2Fix::UNIT_Y
1236    } else {
1237        delta / dist
1238    };
1239
1240    let depth = sum_r - dist;
1241    let point = closest + normal * cap_radius;
1242
1243    Some(Contact2D {
1244        point,
1245        normal,
1246        depth,
1247        body_a: 0,
1248        body_b: 0,
1249    })
1250}
1251
1252/// Edge vs circle collision.
1253///
1254/// The returned normal points from the edge (first argument) toward the circle.
1255fn edge_vs_circle(
1256    edge_body: &RigidBody2D,
1257    local_start: Vec2Fix,
1258    local_end: Vec2Fix,
1259    circle_pos: Vec2Fix,
1260    circle_radius: Fix128,
1261) -> Option<Contact2D> {
1262    let world_start = edge_body.world_point(local_start);
1263    let world_end = edge_body.world_point(local_end);
1264
1265    let closest = closest_point_on_segment(world_start, world_end, circle_pos);
1266    let delta = circle_pos - closest;
1267    let dist_sq = delta.length_squared();
1268    let r_sq = circle_radius * circle_radius;
1269
1270    if dist_sq > r_sq {
1271        return None;
1272    }
1273
1274    let dist = dist_sq.sqrt();
1275    let normal = if dist.is_zero() {
1276        // Use edge normal
1277        let edge = world_end - world_start;
1278        Vec2Fix::new(edge.y, -edge.x).normalize()
1279    } else {
1280        delta / dist
1281    };
1282
1283    let depth = circle_radius - dist;
1284
1285    Some(Contact2D {
1286        point: closest,
1287        normal,
1288        depth,
1289        body_a: 0,
1290        body_b: 0,
1291    })
1292}
1293
1294/// Closest point on a line segment to a given point.
1295fn closest_point_on_segment(seg_a: Vec2Fix, seg_b: Vec2Fix, point: Vec2Fix) -> Vec2Fix {
1296    let ab = seg_b - seg_a;
1297    let len_sq = ab.length_squared();
1298    if len_sq.is_zero() {
1299        return seg_a;
1300    }
1301    let t = (point - seg_a).dot(ab) / len_sq;
1302    // Clamp t to [0, 1]
1303    let t_clamped = if t.is_negative() {
1304        Fix128::ZERO
1305    } else if t > Fix128::ONE {
1306        Fix128::ONE
1307    } else {
1308        t
1309    };
1310    seg_a + ab * t_clamped
1311}
1312
1313/// World-space core and radius of a capsule, edge or convex polygon: the shape
1314/// is the core (a segment, or a CCW convex polygon) inflated by the radius.
1315///
1316/// A capsule is its local-X segment `[-half_length, +half_length]` inflated by
1317/// its radius, an edge its segment with radius 0 (zero thickness, two-sided),
1318/// a polygon its vertices with radius 0. Circles are handled by the dedicated
1319/// pair functions and return `None`, as do polygons with fewer than 3 vertices.
1320fn rounded_core(body: &RigidBody2D) -> Option<(Vec<Vec2Fix>, Fix128)> {
1321    match &body.shape {
1322        Shape2D::Capsule {
1323            radius,
1324            half_length,
1325        } => Some((
1326            vec![
1327                body.world_point(Vec2Fix::new(-*half_length, Fix128::ZERO)),
1328                body.world_point(Vec2Fix::new(*half_length, Fix128::ZERO)),
1329            ],
1330            *radius,
1331        )),
1332        Shape2D::Edge { start, end } => Some((
1333            vec![body.world_point(*start), body.world_point(*end)],
1334            Fix128::ZERO,
1335        )),
1336        Shape2D::Polygon { vertices } if vertices.len() >= 3 => {
1337            Some((transform_vertices(body, vertices), Fix128::ZERO))
1338        }
1339        _ => None,
1340    }
1341}
1342
1343/// Edges `(p, q)` of a core: one for a segment, `n` (closing) for a polygon.
1344fn core_edges(core: &[Vec2Fix]) -> Vec<(Vec2Fix, Vec2Fix)> {
1345    if core.len() == 2 {
1346        return vec![(core[0], core[1])];
1347    }
1348    (0..core.len())
1349        .map(|i| (core[i], core[(i + 1) % core.len()]))
1350        .collect()
1351}
1352
1353/// Contact between two rounded convex shapes, each a core (segment or convex
1354/// polygon) inflated by a radius. The normal points from A toward B and the
1355/// depth is the exact penetration of the inflated shapes:
1356///
1357/// * cores disjoint: `depth = r_a + r_b - dist(core_a, core_b)` along the
1358///   direction between the closest points (no contact when negative);
1359/// * cores overlapping: `depth = r_a + r_b + PD(core_a, core_b)`, where the
1360///   core penetration `PD` is the smallest SAT overlap over the edge normals of
1361///   both cores (the face normals of their Minkowski difference).
1362///
1363/// The contact point lies in the middle of the overlap region: along the
1364/// normal halfway between A's surface and B's surface, along the tangent at
1365/// the centre of the shared extent of the two supporting features (so a
1366/// segment lying flat on a face gets the midpoint of the touching interval).
1367fn rounded_core_vs_rounded_core(
1368    core_a: &[Vec2Fix],
1369    radius_a: Fix128,
1370    core_b: &[Vec2Fix],
1371    radius_b: Fix128,
1372) -> Option<Contact2D> {
1373    let edges_a = core_edges(core_a);
1374    let edges_b = core_edges(core_b);
1375    let radius_sum = radius_a + radius_b;
1376
1377    // Closest points of the cores (on their boundaries).
1378    let mut closest: Option<(Fix128, Vec2Fix, Vec2Fix)> = None;
1379    for &(pa, qa) in &edges_a {
1380        for &(pb, qb) in &edges_b {
1381            let (ca, cb) = closest_points_segment_segment(pa, qa, pb, qb);
1382            let dist_sq = (cb - ca).length_squared();
1383            if closest.is_none_or(|(d, _, _)| dist_sq < d) {
1384                closest = Some((dist_sq, ca, cb));
1385            }
1386        }
1387    }
1388    let (dist_sq, ca, cb) = closest?;
1389
1390    // SAT over the edge normals of both cores plus the closest-point direction.
1391    // The edge normals are the face normals of the Minkowski difference, so the
1392    // smallest overlap over them is the core penetration; the closest-point
1393    // direction separates disjoint cores even when a core is degenerate (a
1394    // point, or collinear segments), where the edge normals alone are
1395    // incomplete. Any extra axis only adds overlaps that are >= the true one.
1396    let mut axes: Vec<Vec2Fix> = edges_a
1397        .iter()
1398        .chain(edges_b.iter())
1399        .map(|&(p, q)| (q - p).perpendicular().normalize())
1400        .collect();
1401    axes.push((cb - ca).normalize());
1402    let mut separated = false;
1403    let mut best: Option<(Fix128, Vec2Fix)> = None;
1404    for axis in axes {
1405        if axis == Vec2Fix::ZERO {
1406            continue;
1407        }
1408        let (min_a, max_a) = project_polygon(core_a, axis);
1409        let (min_b, max_b) = project_polygon(core_b, axis);
1410        if max_a < min_b || max_b < min_a {
1411            separated = true;
1412            break;
1413        }
1414        // overlap removed by moving B along +axis, resp. along -axis
1415        for (overlap, dir) in [(max_a - min_b, axis), (max_b - min_a, -axis)] {
1416            if best.is_none_or(|(d, _)| overlap < d) {
1417                best = Some((overlap, dir));
1418            }
1419        }
1420    }
1421
1422    let (normal, depth) = if separated {
1423        if dist_sq > radius_sum * radius_sum {
1424            return None;
1425        }
1426        let dist = dist_sq.sqrt();
1427        if dist.is_zero() {
1428            return None;
1429        }
1430        ((cb - ca) / dist, radius_sum - dist)
1431    } else {
1432        match best {
1433            Some((overlap, dir)) => (dir, overlap + radius_sum),
1434            // both cores are the same point: no direction is preferred
1435            None => (Vec2Fix::UNIT_Y, radius_sum),
1436        }
1437    };
1438    if depth.is_negative() {
1439        return None;
1440    }
1441
1442    Some(Contact2D {
1443        point: overlap_midpoint(core_a, radius_a, core_b, radius_b, normal),
1444        normal,
1445        depth,
1446        body_a: 0,
1447        body_b: 0,
1448    })
1449}
1450
1451/// Middle of the overlap region of two rounded cores along `normal` (A -> B).
1452fn overlap_midpoint(
1453    core_a: &[Vec2Fix],
1454    radius_a: Fix128,
1455    core_b: &[Vec2Fix],
1456    radius_b: Fix128,
1457    normal: Vec2Fix,
1458) -> Vec2Fix {
1459    // vertices within `eps` of the extreme projection form the supporting
1460    // feature (one vertex, or the two ends of an edge parallel to the tangent)
1461    let eps = Fix128::from_ratio(1, 1 << 30);
1462    let tangent = normal.perpendicular();
1463    let (_, top_a) = project_polygon(core_a, normal);
1464    let (bottom_b, _) = project_polygon(core_b, normal);
1465    let extent = |core: &[Vec2Fix], on_feature: &dyn Fn(Fix128) -> bool| {
1466        let mut lo: Option<Fix128> = None;
1467        let mut hi: Option<Fix128> = None;
1468        for v in core {
1469            if on_feature(v.dot(normal)) {
1470                let t = v.dot(tangent);
1471                lo = Some(lo.map_or(t, |l| if t < l { t } else { l }));
1472                hi = Some(hi.map_or(t, |h| if t > h { t } else { h }));
1473            }
1474        }
1475        (lo.unwrap_or(Fix128::ZERO), hi.unwrap_or(Fix128::ZERO))
1476    };
1477    let (lo_a, hi_a) = extent(core_a, &|d| d >= top_a - eps);
1478    let (lo_b, hi_b) = extent(core_b, &|d| d <= bottom_b + eps);
1479    let lo = if lo_a > lo_b { lo_a } else { lo_b };
1480    let hi = if hi_a < hi_b { hi_a } else { hi_b };
1481    let along_tangent = (lo + hi).half();
1482    let along_normal = ((top_a + radius_a) + (bottom_b - radius_b)).half();
1483    tangent * along_tangent + normal * along_normal
1484}
1485
1486/// Closest points between segments `p1 q1` and `p2 q2` (Ericson, Real-Time
1487/// Collision Detection §5.1.9), handling zero-length segments.
1488fn closest_points_segment_segment(
1489    p1: Vec2Fix,
1490    q1: Vec2Fix,
1491    p2: Vec2Fix,
1492    q2: Vec2Fix,
1493) -> (Vec2Fix, Vec2Fix) {
1494    let clamp01 = |x: Fix128| {
1495        if x.is_negative() {
1496            Fix128::ZERO
1497        } else if x > Fix128::ONE {
1498            Fix128::ONE
1499        } else {
1500            x
1501        }
1502    };
1503    let d1 = q1 - p1;
1504    let d2 = q2 - p2;
1505    let r = p1 - p2;
1506    let a = d1.length_squared();
1507    let e = d2.length_squared();
1508    let f = d2.dot(r);
1509    let (s, t) = if a.is_zero() && e.is_zero() {
1510        (Fix128::ZERO, Fix128::ZERO)
1511    } else if a.is_zero() {
1512        (Fix128::ZERO, clamp01(f / e))
1513    } else {
1514        let c = d1.dot(r);
1515        if e.is_zero() {
1516            (clamp01(-c / a), Fix128::ZERO)
1517        } else {
1518            let b = d1.dot(d2);
1519            let denom = a * e - b * b;
1520            let s = if denom.is_zero() {
1521                Fix128::ZERO
1522            } else {
1523                clamp01((b * f - c * e) / denom)
1524            };
1525            let t = (b * s + f) / e;
1526            if t.is_negative() {
1527                (clamp01(-c / a), Fix128::ZERO)
1528            } else if t > Fix128::ONE {
1529                (clamp01((b - c) / a), Fix128::ONE)
1530            } else {
1531                (s, t)
1532            }
1533        }
1534    };
1535    (p1 + d1 * s, p2 + d2 * t)
1536}
1537
1538// ============================================================================
1539// XPBD Contact Solver
1540// ============================================================================
1541
1542/// Resolve one contact's current penetration by a positional correction
1543/// (rigid XPBD contact, compliance 0) and return the positional multiplier
1544/// applied (the depth removed), which the velocity pass turns into the
1545/// normal force bound for friction.
1546fn solve_contact_position(bodies: &mut [RigidBody2D], contact: &Contact2D) -> Fix128 {
1547    {
1548        let a = contact.body_a;
1549        let b = contact.body_b;
1550        let normal = contact.normal;
1551        let depth = contact.depth;
1552
1553        if depth.is_negative() || depth.is_zero() {
1554            return Fix128::ZERO;
1555        }
1556
1557        let inv_mass_a = bodies[a].inv_mass;
1558        let inv_mass_b = bodies[b].inv_mass;
1559        let total_inv_mass = inv_mass_a + inv_mass_b;
1560
1561        if total_inv_mass.is_zero() {
1562            return Fix128::ZERO;
1563        }
1564
1565        let correction = normal * depth;
1566        let ratio_a = inv_mass_a / total_inv_mass;
1567        let ratio_b = inv_mass_b / total_inv_mass;
1568
1569        if bodies[a].body_type == BodyType2D::Dynamic {
1570            bodies[a].position = bodies[a].position - correction * ratio_a;
1571        }
1572        if bodies[b].body_type == BodyType2D::Dynamic {
1573            bodies[b].position = bodies[b].position + correction * ratio_b;
1574        }
1575
1576        // Angular correction
1577        let r_a = contact.point - bodies[a].position;
1578        let r_b = contact.point - bodies[b].position;
1579        let rn_a = r_a.cross_scalar(normal);
1580        let rn_b = r_b.cross_scalar(normal);
1581
1582        let ang_inv_a = bodies[a].inv_inertia * rn_a * rn_a;
1583        let ang_inv_b = bodies[b].inv_inertia * rn_b * rn_b;
1584        let total_ang = ang_inv_a + ang_inv_b;
1585
1586        if !total_ang.is_zero() {
1587            let ang_correction = depth * Fix128::from_ratio(1, 4);
1588            if bodies[a].body_type == BodyType2D::Dynamic {
1589                let da =
1590                    rn_a * ang_correction * bodies[a].inv_inertia / (total_inv_mass + total_ang);
1591                bodies[a].angle = bodies[a].angle - da;
1592            }
1593            if bodies[b].body_type == BodyType2D::Dynamic {
1594                let db =
1595                    rn_b * ang_correction * bodies[b].inv_inertia / (total_inv_mass + total_ang);
1596                bodies[b].angle = bodies[b].angle + db;
1597            }
1598        }
1599        depth
1600    }
1601}
1602
1603// ============================================================================
1604// Joint2D
1605// ============================================================================
1606
1607/// 2D joint constraint connecting one or two bodies.
1608#[derive(Clone, Debug)]
1609pub enum Joint2D {
1610    /// Revolute (pin/hinge) joint: constrains two bodies to share a point.
1611    Revolute {
1612        /// First body index.
1613        body_a: usize,
1614        /// Second body index.
1615        body_b: usize,
1616        /// Anchor point in body A's local space.
1617        local_anchor_a: Vec2Fix,
1618        /// Anchor point in body B's local space.
1619        local_anchor_b: Vec2Fix,
1620        /// Compliance (inverse stiffness). 0 = perfectly rigid.
1621        compliance: Fix128,
1622    },
1623    /// Distance joint: maintains a fixed distance between two anchor points.
1624    Distance {
1625        /// First body index.
1626        body_a: usize,
1627        /// Second body index.
1628        body_b: usize,
1629        /// Anchor point in body A's local space.
1630        local_anchor_a: Vec2Fix,
1631        /// Anchor point in body B's local space.
1632        local_anchor_b: Vec2Fix,
1633        /// Target distance between anchors.
1634        target_distance: Fix128,
1635        /// Compliance (inverse stiffness). 0 = perfectly rigid.
1636        compliance: Fix128,
1637    },
1638    /// Weld joint: constrains two bodies to maintain relative position and angle.
1639    Weld {
1640        /// First body index.
1641        body_a: usize,
1642        /// Second body index.
1643        body_b: usize,
1644        /// Anchor point in body A's local space.
1645        local_anchor_a: Vec2Fix,
1646        /// Anchor point in body B's local space.
1647        local_anchor_b: Vec2Fix,
1648        /// Reference angle (relative angle at rest).
1649        reference_angle: Fix128,
1650        /// Compliance (inverse stiffness). 0 = perfectly rigid.
1651        compliance: Fix128,
1652    },
1653    /// Mouse joint: drags a body toward a world-space target point with the
1654    /// spring-damper `F = stiffness·(target − x) − damping·v`, `|F| <= max_force`.
1655    ///
1656    /// Solved inside the step as an XPBD constraint with damping (backward Euler per
1657    /// substep, independent of `iterations`). For a body of mass `m`, angular frequency
1658    /// `ω` and damping ratio `ζ` use `stiffness = m ω²` and `damping = 2 ζ m ω`; `ζ = 1`
1659    /// is the critically damped return `x(t) = (x0 + (v0 + ω x0) t)·e^(−ωt)`. Each joint
1660    /// carries its own `stiffness` / `damping`, so mouse joints of different `ω` can share
1661    /// one world. `PhysicsConfig2D::damping` is applied on top (set it to 1 for the pure law).
1662    Mouse {
1663        /// Body index.
1664        body: usize,
1665        /// World-space target position.
1666        target: Vec2Fix,
1667        /// Maximum force the joint can apply, in N (`|F| <= max_force`); `<= 0` applies
1668        /// no force.
1669        max_force: Fix128,
1670        /// Spring stiffness `k`, in N/m (formerly a dimensionless fraction applied per
1671        /// solver iteration, see CHANGELOG). `k = m ω²` for angular frequency `ω`.
1672        stiffness: Fix128,
1673        /// Damping coefficient `c`, in N·s/m, acting on the body's velocity.
1674        /// `c = 2·sqrt(k·m)` is critically damped (no overshoot), smaller oscillates,
1675        /// larger creeps.
1676        damping: Fix128,
1677    },
1678}
1679
1680/// Solve all 2D joints using XPBD position-level constraints.
1681///
1682/// One call is one solver iteration starting from zero accumulated multipliers.
1683/// [`PhysicsWorld2D::step`] keeps the multipliers across the iterations of a substep, so
1684/// compliant joints there do not stiffen with `iterations`.
1685pub fn solve_joints_2d(bodies: &mut [RigidBody2D], joints: &[Joint2D], sub_dt: Fix128) {
1686    let mut lambdas = vec![Vec2Fix::ZERO; joints.len()];
1687    solve_joints_2d_accumulated(bodies, joints, sub_dt, &mut lambdas);
1688}
1689
1690/// One XPBD iteration over `joints`, accumulating each joint's multiplier in
1691/// `lambdas[i]` (Distance uses `.x`, Mouse the vector). `lambdas` is zeroed by the caller at
1692/// the start of every substep.
1693fn solve_joints_2d_accumulated(
1694    bodies: &mut [RigidBody2D],
1695    joints: &[Joint2D],
1696    sub_dt: Fix128,
1697    lambdas: &mut [Vec2Fix],
1698) {
1699    let alpha = if sub_dt.is_zero() {
1700        Fix128::ZERO
1701    } else {
1702        Fix128::ONE / (sub_dt * sub_dt)
1703    };
1704
1705    for (joint, lambda) in joints.iter().zip(lambdas.iter_mut()) {
1706        match joint {
1707            Joint2D::Revolute {
1708                body_a,
1709                body_b,
1710                local_anchor_a,
1711                local_anchor_b,
1712                compliance,
1713            } => {
1714                solve_revolute(
1715                    bodies,
1716                    *body_a,
1717                    *body_b,
1718                    *local_anchor_a,
1719                    *local_anchor_b,
1720                    *compliance,
1721                    alpha,
1722                );
1723            }
1724            Joint2D::Distance {
1725                body_a,
1726                body_b,
1727                local_anchor_a,
1728                local_anchor_b,
1729                target_distance,
1730                compliance,
1731            } => {
1732                solve_distance(
1733                    bodies,
1734                    *body_a,
1735                    *body_b,
1736                    *local_anchor_a,
1737                    *local_anchor_b,
1738                    *target_distance,
1739                    *compliance,
1740                    alpha,
1741                    &mut lambda.x,
1742                );
1743            }
1744            Joint2D::Weld {
1745                body_a,
1746                body_b,
1747                local_anchor_a,
1748                local_anchor_b,
1749                reference_angle,
1750                compliance,
1751            } => {
1752                solve_weld(
1753                    bodies,
1754                    *body_a,
1755                    *body_b,
1756                    *local_anchor_a,
1757                    *local_anchor_b,
1758                    *reference_angle,
1759                    *compliance,
1760                    alpha,
1761                );
1762            }
1763            Joint2D::Mouse {
1764                body,
1765                target,
1766                max_force,
1767                stiffness,
1768                damping,
1769            } => {
1770                solve_mouse(
1771                    bodies, *body, *target, *max_force, *stiffness, *damping, sub_dt, lambda,
1772                );
1773            }
1774        }
1775    }
1776}
1777
1778/// Solve a revolute joint (two anchors must coincide).
1779fn solve_revolute(
1780    bodies: &mut [RigidBody2D],
1781    a: usize,
1782    b: usize,
1783    local_a: Vec2Fix,
1784    local_b: Vec2Fix,
1785    compliance: Fix128,
1786    alpha: Fix128,
1787) {
1788    let world_a = bodies[a].world_point(local_a);
1789    let world_b = bodies[b].world_point(local_b);
1790    let delta = world_b - world_a;
1791    let dist_sq = delta.length_squared();
1792    if dist_sq.is_zero() {
1793        return;
1794    }
1795    let dist = dist_sq.sqrt();
1796    let n = delta / dist;
1797
1798    let inv_mass_a = bodies[a].inv_mass;
1799    let inv_mass_b = bodies[b].inv_mass;
1800
1801    let r_a = world_a - bodies[a].position;
1802    let r_b = world_b - bodies[b].position;
1803    let rn_a = r_a.cross_scalar(n);
1804    let rn_b = r_b.cross_scalar(n);
1805
1806    let w = inv_mass_a
1807        + inv_mass_b
1808        + rn_a * rn_a * bodies[a].inv_inertia
1809        + rn_b * rn_b * bodies[b].inv_inertia
1810        + compliance * alpha;
1811
1812    if w.is_zero() {
1813        return;
1814    }
1815
1816    let lambda = -dist / w;
1817    let p = n * lambda;
1818
1819    if bodies[a].body_type == BodyType2D::Dynamic {
1820        bodies[a].position = bodies[a].position - p * inv_mass_a;
1821        bodies[a].angle = bodies[a].angle - rn_a * lambda * bodies[a].inv_inertia;
1822    }
1823    if bodies[b].body_type == BodyType2D::Dynamic {
1824        bodies[b].position = bodies[b].position + p * inv_mass_b;
1825        bodies[b].angle = bodies[b].angle + rn_b * lambda * bodies[b].inv_inertia;
1826    }
1827}
1828
1829/// Solve a distance joint.
1830#[allow(clippy::too_many_arguments)]
1831fn solve_distance(
1832    bodies: &mut [RigidBody2D],
1833    a: usize,
1834    b: usize,
1835    local_a: Vec2Fix,
1836    local_b: Vec2Fix,
1837    target_distance: Fix128,
1838    compliance: Fix128,
1839    alpha: Fix128,
1840    accumulated: &mut Fix128,
1841) {
1842    let world_a = bodies[a].world_point(local_a);
1843    let world_b = bodies[b].world_point(local_b);
1844    let delta = world_b - world_a;
1845    let dist = delta.length();
1846
1847    if dist.is_zero() {
1848        return;
1849    }
1850    let n = delta / dist;
1851
1852    let c = dist - target_distance;
1853
1854    let inv_mass_a = bodies[a].inv_mass;
1855    let inv_mass_b = bodies[b].inv_mass;
1856
1857    let r_a = world_a - bodies[a].position;
1858    let r_b = world_b - bodies[b].position;
1859    let rn_a = r_a.cross_scalar(n);
1860    let rn_b = r_b.cross_scalar(n);
1861
1862    let w = inv_mass_a
1863        + inv_mass_b
1864        + rn_a * rn_a * bodies[a].inv_inertia
1865        + rn_b * rn_b * bodies[b].inv_inertia
1866        + compliance * alpha;
1867
1868    if w.is_zero() {
1869        return;
1870    }
1871
1872    // XPBD (Macklin 2016): the compliance term sees the multiplier accumulated over
1873    // the substep, so the effective stiffness 1/compliance does not grow with iterations
1874    let lambda = (-c - compliance * alpha * *accumulated) / w;
1875    *accumulated = *accumulated + lambda;
1876    let p = n * lambda;
1877
1878    if bodies[a].body_type == BodyType2D::Dynamic {
1879        bodies[a].position = bodies[a].position - p * inv_mass_a;
1880        bodies[a].angle = bodies[a].angle - rn_a * lambda * bodies[a].inv_inertia;
1881    }
1882    if bodies[b].body_type == BodyType2D::Dynamic {
1883        bodies[b].position = bodies[b].position + p * inv_mass_b;
1884        bodies[b].angle = bodies[b].angle + rn_b * lambda * bodies[b].inv_inertia;
1885    }
1886}
1887
1888/// Solve a weld joint (position + angle constraint).
1889#[allow(clippy::too_many_arguments)]
1890fn solve_weld(
1891    bodies: &mut [RigidBody2D],
1892    a: usize,
1893    b: usize,
1894    local_a: Vec2Fix,
1895    local_b: Vec2Fix,
1896    reference_angle: Fix128,
1897    compliance: Fix128,
1898    alpha: Fix128,
1899) {
1900    // Position constraint (same as revolute)
1901    solve_revolute(bodies, a, b, local_a, local_b, compliance, alpha);
1902
1903    // Angular constraint
1904    let angle_error = bodies[b].angle - bodies[a].angle - reference_angle;
1905    if angle_error.is_zero() {
1906        return;
1907    }
1908
1909    let inv_i_a = bodies[a].inv_inertia;
1910    let inv_i_b = bodies[b].inv_inertia;
1911    let w = inv_i_a + inv_i_b + compliance * alpha;
1912
1913    if w.is_zero() {
1914        return;
1915    }
1916
1917    let lambda = -angle_error / w;
1918
1919    if bodies[a].body_type == BodyType2D::Dynamic {
1920        bodies[a].angle = bodies[a].angle - lambda * inv_i_a;
1921    }
1922    if bodies[b].body_type == BodyType2D::Dynamic {
1923        bodies[b].angle = bodies[b].angle + lambda * inv_i_b;
1924    }
1925}
1926
1927/// Solve a mouse joint (pull body toward target).
1928#[allow(clippy::too_many_arguments)]
1929fn solve_mouse(
1930    bodies: &mut [RigidBody2D],
1931    body_idx: usize,
1932    target: Vec2Fix,
1933    max_force: Fix128,
1934    stiffness: Fix128,
1935    damping: Fix128,
1936    sub_dt: Fix128,
1937    accumulated: &mut Vec2Fix,
1938) {
1939    let body = &bodies[body_idx];
1940    if body.body_type != BodyType2D::Dynamic || sub_dt.is_zero() {
1941        return;
1942    }
1943    // XPBD with damping (Macklin et al. 2016, eq. 26) on the vector constraint
1944    // C = x − target (gradient I), multiplied through by k h² so that k = 0 (a pure
1945    // damper) needs no division:
1946    //   Δλ = (−k h² C − λ − c h (x − x_prev)) / ((k h² + c h) w + 1),   x += w Δλ
1947    // The force is λ / h², so |F| <= max_force is |λ| <= max_force · h².
1948    let h = sub_dt;
1949    let kh2 = stiffness * h * h;
1950    let ch = damping * h;
1951    let w = body.inv_mass;
1952    let c = body.position - target;
1953    let moved = body.position - body.prev_position;
1954    let denom = (kh2 + ch) * w + Fix128::ONE;
1955    if denom.is_zero() {
1956        return;
1957    }
1958    let numer = c * (Fix128::ZERO - kh2) - *accumulated - moved * ch;
1959    let mut total = *accumulated + numer / denom;
1960    let limit = if max_force > Fix128::ZERO {
1961        max_force * h * h
1962    } else {
1963        Fix128::ZERO
1964    };
1965    let len = total.length();
1966    if len > limit {
1967        total = if len.is_zero() {
1968            Vec2Fix::ZERO
1969        } else {
1970            total * (limit / len)
1971        };
1972    }
1973    let dlambda = total - *accumulated;
1974    *accumulated = total;
1975    bodies[body_idx].position = bodies[body_idx].position + dlambda * w;
1976}
1977
1978// ============================================================================
1979// Tethers2D (angular spring-damper + kinematic drive)
1980// ============================================================================
1981
1982/// Angular spring-damper pulling a dynamic body's angle toward `target_angle`:
1983/// `τ = stiffness·(target_angle − θ) − damping·ω`.
1984///
1985/// Solved inside [`PhysicsWorld2D::step_with_tethers`] as an XPBD constraint with damping
1986/// (Macklin et al. 2016, eq. 26) on `C = θ − target_angle`, one multiplier per tether
1987/// accumulated over the iterations of a substep, so the law is backward Euler per substep
1988/// and independent of `iterations`. For moment of inertia `I`, angular frequency `ω` and
1989/// damping ratio `ζ` use `stiffness = I ω²`, `damping = 2 ζ I ω`
1990/// ([`AngularTether2D::critically_damped`] is `ζ = 1`, the return
1991/// `θ(t) = (θ0 + (ω0 + ω θ0) t)·e^(−ωt)` about the target, without overshoot).
1992///
1993/// The error `θ − target_angle` is not wrapped to `(−π, π]`: a body that has spun one full
1994/// turn is pulled back through that turn. Combine with [`Joint2D::Mouse`] to return both
1995/// position and orientation. Only `Dynamic` bodies are affected (use
1996/// [`KinematicDrive2D`] for kinematic ones). `PhysicsConfig2D::damping` is applied on top
1997/// (set it to 1 for the pure law).
1998#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1999#[non_exhaustive]
2000pub struct AngularTether2D {
2001    /// Body index.
2002    pub body: usize,
2003    /// Target angle in radians (same unwrapped convention as `RigidBody2D::angle`).
2004    pub target_angle: Fix128,
2005    /// Angular stiffness `k_θ`, in N·m/rad.
2006    pub stiffness: Fix128,
2007    /// Angular damping `c_θ`, in N·m·s/rad, acting on the body's angular velocity.
2008    pub damping: Fix128,
2009}
2010
2011impl AngularTether2D {
2012    /// Tether with explicit angular stiffness (N·m/rad) and damping (N·m·s/rad).
2013    #[must_use]
2014    pub const fn new(
2015        body: usize,
2016        target_angle: Fix128,
2017        stiffness: Fix128,
2018        damping: Fix128,
2019    ) -> Self {
2020        Self {
2021            body,
2022            target_angle,
2023            stiffness,
2024            damping,
2025        }
2026    }
2027
2028    /// Critically damped tether for a body of moment of inertia `inertia`:
2029    /// `stiffness = I ω²`, `damping = 2 I ω`.
2030    #[must_use]
2031    pub fn critically_damped(
2032        body: usize,
2033        target_angle: Fix128,
2034        inertia: Fix128,
2035        omega: Fix128,
2036    ) -> Self {
2037        Self::new(
2038            body,
2039            target_angle,
2040            inertia * omega * omega,
2041            Fix128::from_int(2) * inertia * omega,
2042        )
2043    }
2044}
2045
2046/// Drives a `Kinematic` body toward `target_position` / `target_angle` along the critically
2047/// damped trajectory of angular frequency `omega`, through its velocity.
2048///
2049/// A kinematic body has infinite mass, so this is not a constraint: at every substep of
2050/// length `h` the position error `e = x − target` and velocity `v` are advanced by the exact
2051/// solution of `ë = −2ω ė − ω² e`,
2052/// `e' = (e + (v + ω e) h)·E`, `v' = (v − ω h (v + ω e))·E`, `E = e^(−ωh)`,
2053/// and likewise for the angle. Composing substeps reproduces the continuous
2054/// `e(t) = (e0 + (v0 + ω e0) t)·e^(−ωt)` for any `substeps` (up to `Fix128` rounding), so the
2055/// body reaches the target without overshoot when it starts at rest. The body's `velocity`
2056/// / `angular_velocity` hold `v'`, so contacts see the true velocity, and the body keeps
2057/// pushing dynamic bodies it meets. The drive overrides any velocity set by hand; with
2058/// several drives on one body the one added last wins. Non-kinematic bodies are ignored.
2059#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2060#[non_exhaustive]
2061pub struct KinematicDrive2D {
2062    /// Body index (must be `BodyType2D::Kinematic`).
2063    pub body: usize,
2064    /// Target position.
2065    pub target_position: Vec2Fix,
2066    /// Target angle in radians (unwrapped, like `AngularTether2D::target_angle`).
2067    pub target_angle: Fix128,
2068    /// Angular frequency `ω` (1/s) of the critically damped return; `<= 0` holds the body
2069    /// where it is with its current velocity unchanged (the drive is inactive).
2070    pub omega: Fix128,
2071}
2072
2073impl KinematicDrive2D {
2074    /// Drive toward `target_position` / `target_angle` with angular frequency `omega`.
2075    #[must_use]
2076    pub const fn new(
2077        body: usize,
2078        target_position: Vec2Fix,
2079        target_angle: Fix128,
2080        omega: Fix128,
2081    ) -> Self {
2082        Self {
2083            body,
2084            target_position,
2085            target_angle,
2086            omega,
2087        }
2088    }
2089}
2090
2091/// Handle of an [`AngularTether2D`] in a [`Tethers2D`] set.
2092#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
2093pub struct AngularTetherId(usize);
2094
2095/// Handle of a [`KinematicDrive2D`] in a [`Tethers2D`] set.
2096#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
2097pub struct KinematicDriveId(usize);
2098
2099/// Set of angular tethers and kinematic drives, solved by
2100/// [`PhysicsWorld2D::step_with_tethers`].
2101///
2102/// Kept outside `PhysicsWorld2D` so the world's public layout is unchanged. Handles stay
2103/// valid until their own removal (slots are not reused), and entries are solved in the
2104/// order they were added, so a replay is bit-identical.
2105#[derive(Clone, Debug, Default)]
2106pub struct Tethers2D {
2107    angular: Vec<Option<AngularTether2D>>,
2108    drives: Vec<Option<KinematicDrive2D>>,
2109}
2110
2111impl Tethers2D {
2112    /// Empty set. `step_with_tethers` with an empty set is `step`.
2113    #[must_use]
2114    pub const fn new() -> Self {
2115        Self {
2116            angular: Vec::new(),
2117            drives: Vec::new(),
2118        }
2119    }
2120
2121    /// Add an angular tether.
2122    pub fn add_angular(&mut self, tether: AngularTether2D) -> AngularTetherId {
2123        self.angular.push(Some(tether));
2124        AngularTetherId(self.angular.len() - 1)
2125    }
2126
2127    /// Remove an angular tether, returning it if the handle was live.
2128    pub fn remove_angular(&mut self, id: AngularTetherId) -> Option<AngularTether2D> {
2129        self.angular.get_mut(id.0).and_then(Option::take)
2130    }
2131
2132    /// The angular tether behind `id`, if live.
2133    #[must_use]
2134    pub fn angular(&self, id: AngularTetherId) -> Option<&AngularTether2D> {
2135        self.angular.get(id.0).and_then(Option::as_ref)
2136    }
2137
2138    /// Mutable access (e.g. to move `target_angle`), if live.
2139    pub fn angular_mut(&mut self, id: AngularTetherId) -> Option<&mut AngularTether2D> {
2140        self.angular.get_mut(id.0).and_then(Option::as_mut)
2141    }
2142
2143    /// Add a kinematic drive.
2144    pub fn add_drive(&mut self, drive: KinematicDrive2D) -> KinematicDriveId {
2145        self.drives.push(Some(drive));
2146        KinematicDriveId(self.drives.len() - 1)
2147    }
2148
2149    /// Remove a kinematic drive, returning it if the handle was live. The body keeps the
2150    /// velocity the drive last gave it.
2151    pub fn remove_drive(&mut self, id: KinematicDriveId) -> Option<KinematicDrive2D> {
2152        self.drives.get_mut(id.0).and_then(Option::take)
2153    }
2154
2155    /// The kinematic drive behind `id`, if live.
2156    #[must_use]
2157    pub fn drive(&self, id: KinematicDriveId) -> Option<&KinematicDrive2D> {
2158        self.drives.get(id.0).and_then(Option::as_ref)
2159    }
2160
2161    /// Mutable access (e.g. to move the targets), if live.
2162    pub fn drive_mut(&mut self, id: KinematicDriveId) -> Option<&mut KinematicDrive2D> {
2163        self.drives.get_mut(id.0).and_then(Option::as_mut)
2164    }
2165
2166    /// Number of live angular tethers.
2167    #[must_use]
2168    pub fn angular_count(&self) -> usize {
2169        self.angular.iter().filter(|t| t.is_some()).count()
2170    }
2171
2172    /// Number of live kinematic drives.
2173    #[must_use]
2174    pub fn drive_count(&self) -> usize {
2175        self.drives.iter().filter(|d| d.is_some()).count()
2176    }
2177
2178    fn live_drives(&self) -> impl Iterator<Item = &KinematicDrive2D> {
2179        self.drives.iter().flatten()
2180    }
2181}
2182
2183/// `e^(−x)` for `x >= 0`, accurate to a few units of 2⁻⁶⁴: Taylor series on `x / 2^k <= 1/2`
2184/// followed by `k` squarings. (`Fix128::exp` is ≲ 1e-6 relative, which compounds over the
2185/// substeps of a kinematic drive.)
2186pub(crate) fn exp_neg(x: Fix128) -> Fix128 {
2187    if x <= Fix128::ZERO {
2188        return Fix128::ONE;
2189    }
2190    if x.hi >= 44 {
2191        return Fix128::ZERO;
2192    }
2193    let half = Fix128::from_ratio(1, 2);
2194    let mut y = x;
2195    let mut k = 0_u32;
2196    while y > half {
2197        y = y * half;
2198        k += 1;
2199    }
2200    // Σ (−y)ⁿ / n!, |y| <= 1/2: 26 terms leave a remainder below 2⁻⁶⁴·2⁻³⁰
2201    let mut sum = Fix128::ONE;
2202    let mut term = Fix128::ONE;
2203    for n in 1..=26_i64 {
2204        term = Fix128::ZERO - term * y / Fix128::from_int(n);
2205        sum = sum + term;
2206    }
2207    for _ in 0..k {
2208        sum = sum * sum;
2209    }
2210    sum
2211}
2212
2213/// Advance a kinematic drive by one substep (closed-form critically damped step).
2214fn apply_kinematic_drive(bodies: &mut [RigidBody2D], drive: &KinematicDrive2D, h: Fix128) {
2215    let Some(body) = bodies.get_mut(drive.body) else {
2216        return;
2217    };
2218    if body.body_type != BodyType2D::Kinematic || drive.omega <= Fix128::ZERO {
2219        return;
2220    }
2221    let w = drive.omega;
2222    let e_factor = exp_neg(w * h);
2223    // linear: start of the substep is prev_position with the velocity it began with
2224    let e0 = body.prev_position - drive.target_position;
2225    let v0 = body.velocity;
2226    let s = v0 + e0 * w;
2227    body.position = drive.target_position + (e0 + s * h) * e_factor;
2228    body.velocity = (v0 - s * (w * h)) * e_factor;
2229    // angular
2230    let a0 = body.prev_angle - drive.target_angle;
2231    let om0 = body.angular_velocity;
2232    let sa = om0 + w * a0;
2233    body.angle = drive.target_angle + (a0 + sa * h) * e_factor;
2234    body.angular_velocity = (om0 - sa * (w * h)) * e_factor;
2235}
2236
2237/// One XPBD-with-damping iteration of an angular tether, accumulating its multiplier.
2238fn solve_angular_tether(
2239    bodies: &mut [RigidBody2D],
2240    tether: &AngularTether2D,
2241    h: Fix128,
2242    accumulated: &mut Fix128,
2243) {
2244    let Some(body) = bodies.get_mut(tether.body) else {
2245        return;
2246    };
2247    if body.body_type != BodyType2D::Dynamic || h.is_zero() {
2248        return;
2249    }
2250    // Same scalar update as `solve_mouse`, multiplied through by k h²:
2251    //   Δλ = (−k h² C − λ − c h (θ − θ_prev)) / ((k h² + c h) w + 1),   θ += w Δλ
2252    let kh2 = tether.stiffness * h * h;
2253    let ch = tether.damping * h;
2254    let w = body.inv_inertia;
2255    let c = body.angle - tether.target_angle;
2256    let moved = body.angle - body.prev_angle;
2257    let denom = (kh2 + ch) * w + Fix128::ONE;
2258    if denom.is_zero() {
2259        return;
2260    }
2261    let numer = Fix128::ZERO - kh2 * c - *accumulated - ch * moved;
2262    let dlambda = numer / denom;
2263    *accumulated = *accumulated + dlambda;
2264    body.angle = body.angle + dlambda * w;
2265}
2266
2267impl core::fmt::Debug for PhysicsWorld2D {
2268    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2269        f.debug_struct("PhysicsWorld2D")
2270            .field("bodies", &format_args!("[{} items]", self.bodies.len()))
2271            .field("gravity", &self.gravity)
2272            .field("config", &self.config)
2273            .field("joints", &format_args!("[{} items]", self.joints.len()))
2274            .finish()
2275    }
2276}
2277
2278// ============================================================================
2279// Tests
2280// ============================================================================
2281
2282#[cfg(all(test, feature = "std"))]
2283mod tests {
2284    use super::*;
2285
2286    // ---- Vec2Fix arithmetic ----
2287
2288    #[test]
2289    fn test_vec2_new_and_constants() {
2290        let v = Vec2Fix::new(Fix128::from_int(3), Fix128::from_int(4));
2291        assert_eq!(v.x.hi, 3);
2292        assert_eq!(v.y.hi, 4);
2293        assert!(Vec2Fix::ZERO.x.is_zero());
2294        assert!(Vec2Fix::ZERO.y.is_zero());
2295        assert_eq!(Vec2Fix::ONE.x.hi, 1);
2296        assert_eq!(Vec2Fix::ONE.y.hi, 1);
2297        assert_eq!(Vec2Fix::UNIT_X.x.hi, 1);
2298        assert!(Vec2Fix::UNIT_X.y.is_zero());
2299        assert!(Vec2Fix::UNIT_Y.x.is_zero());
2300        assert_eq!(Vec2Fix::UNIT_Y.y.hi, 1);
2301    }
2302
2303    #[test]
2304    fn test_vec2_from_int() {
2305        let v = Vec2Fix::from_int(7, -3);
2306        assert_eq!(v.x.hi, 7);
2307        assert_eq!(v.y.hi, -3);
2308    }
2309
2310    #[test]
2311    fn test_vec2_add_sub() {
2312        let a = Vec2Fix::from_int(3, 5);
2313        let b = Vec2Fix::from_int(1, 2);
2314        let sum = a + b;
2315        assert_eq!(sum.x.hi, 4);
2316        assert_eq!(sum.y.hi, 7);
2317        let diff = a - b;
2318        assert_eq!(diff.x.hi, 2);
2319        assert_eq!(diff.y.hi, 3);
2320    }
2321
2322    #[test]
2323    fn test_vec2_mul_div_scalar() {
2324        let v = Vec2Fix::from_int(6, 8);
2325        let scaled = v * Fix128::from_int(3);
2326        assert_eq!(scaled.x.hi, 18);
2327        assert_eq!(scaled.y.hi, 24);
2328        let halved = v / Fix128::from_int(2);
2329        assert_eq!(halved.x.hi, 3);
2330        assert_eq!(halved.y.hi, 4);
2331    }
2332
2333    #[test]
2334    fn test_vec2_neg() {
2335        let v = Vec2Fix::from_int(5, -3);
2336        let neg_v = -v;
2337        assert_eq!(neg_v.x.hi, -5);
2338        assert_eq!(neg_v.y.hi, 3);
2339    }
2340
2341    #[test]
2342    fn test_vec2_dot() {
2343        let a = Vec2Fix::from_int(3, 4);
2344        let b = Vec2Fix::from_int(2, 5);
2345        let d = a.dot(b);
2346        // 3*2 + 4*5 = 26
2347        assert_eq!(d.hi, 26);
2348    }
2349
2350    #[test]
2351    fn test_vec2_cross_scalar() {
2352        let a = Vec2Fix::from_int(3, 4);
2353        let b = Vec2Fix::from_int(2, 5);
2354        let c = a.cross_scalar(b);
2355        // 3*5 - 4*2 = 15 - 8 = 7
2356        assert_eq!(c.hi, 7);
2357    }
2358
2359    #[test]
2360    fn test_vec2_length_squared() {
2361        let v = Vec2Fix::from_int(3, 4);
2362        let len_sq = v.length_squared();
2363        // 9 + 16 = 25
2364        assert_eq!(len_sq.hi, 25);
2365    }
2366
2367    #[test]
2368    fn test_vec2_length() {
2369        let v = Vec2Fix::from_int(3, 4);
2370        let len = v.length();
2371        // sqrt(25) = 5
2372        assert_eq!(len.hi, 5);
2373    }
2374
2375    #[test]
2376    fn test_vec2_normalize() {
2377        let v = Vec2Fix::from_int(0, 5);
2378        let n = v.normalize();
2379        assert!(n.x.is_zero());
2380        assert_eq!(n.y.hi, 1);
2381
2382        // Zero vector normalizes to zero
2383        let z = Vec2Fix::ZERO.normalize();
2384        assert!(z.x.is_zero());
2385        assert!(z.y.is_zero());
2386    }
2387
2388    #[test]
2389    fn test_vec2_perpendicular() {
2390        let v = Vec2Fix::from_int(3, 4);
2391        let p = v.perpendicular();
2392        assert_eq!(p.x.hi, -4);
2393        assert_eq!(p.y.hi, 3);
2394        // Perpendicular should have zero dot product
2395        assert!(v.dot(p).is_zero());
2396    }
2397
2398    #[test]
2399    fn test_vec2_distance_to() {
2400        let a = Vec2Fix::from_int(0, 0);
2401        let b = Vec2Fix::from_int(3, 4);
2402        let d = a.distance_to(b);
2403        assert_eq!(d.hi, 5);
2404    }
2405
2406    #[test]
2407    fn test_vec2_lerp() {
2408        let a = Vec2Fix::from_int(0, 0);
2409        let b = Vec2Fix::from_int(10, 20);
2410        let half = Fix128::from_ratio(1, 2);
2411        let mid = a.lerp(b, half);
2412        assert_eq!(mid.x.hi, 5);
2413        assert_eq!(mid.y.hi, 10);
2414
2415        // t=0 -> a, t=1 -> b
2416        let at0 = a.lerp(b, Fix128::ZERO);
2417        assert_eq!(at0.x.hi, 0);
2418        assert_eq!(at0.y.hi, 0);
2419        let at1 = a.lerp(b, Fix128::ONE);
2420        assert_eq!(at1.x.hi, 10);
2421        assert_eq!(at1.y.hi, 20);
2422    }
2423
2424    #[test]
2425    fn test_vec2_rotate() {
2426        // Rotate UNIT_X by pi/2 should give approximately UNIT_Y
2427        let v = Vec2Fix::UNIT_X;
2428        let rotated = v.rotate(Fix128::HALF_PI);
2429        // Allow small tolerance due to CORDIC precision
2430        assert!(rotated.x.abs() < Fix128::from_ratio(1, 1000));
2431        assert!((rotated.y - Fix128::ONE).abs() < Fix128::from_ratio(1, 1000));
2432    }
2433
2434    // ---- Shape and body creation ----
2435
2436    #[test]
2437    fn test_body_creation_dynamic() {
2438        let shape = Shape2D::Circle {
2439            radius: Fix128::ONE,
2440        };
2441        let body = RigidBody2D::new_dynamic(Vec2Fix::from_int(5, 10), Fix128::from_int(2), shape);
2442        assert_eq!(body.position.x.hi, 5);
2443        assert_eq!(body.position.y.hi, 10);
2444        assert_eq!(body.body_type, BodyType2D::Dynamic);
2445        assert!(!body.inv_mass.is_zero());
2446    }
2447
2448    #[test]
2449    fn test_body_creation_static() {
2450        let shape = Shape2D::Circle {
2451            radius: Fix128::from_int(5),
2452        };
2453        let body = RigidBody2D::new_static(Vec2Fix::ZERO, shape);
2454        assert_eq!(body.body_type, BodyType2D::Static);
2455        assert!(body.inv_mass.is_zero());
2456        assert!(body.inv_inertia.is_zero());
2457    }
2458
2459    #[test]
2460    fn test_body_creation_kinematic() {
2461        let shape = Shape2D::Circle {
2462            radius: Fix128::ONE,
2463        };
2464        let body = RigidBody2D::new_kinematic(Vec2Fix::from_int(1, 2), shape);
2465        assert_eq!(body.body_type, BodyType2D::Kinematic);
2466        assert!(body.inv_mass.is_zero());
2467    }
2468
2469    #[test]
2470    fn test_body_apply_impulse() {
2471        let shape = Shape2D::Circle {
2472            radius: Fix128::ONE,
2473        };
2474        let mut body = RigidBody2D::new_dynamic(Vec2Fix::ZERO, Fix128::ONE, shape);
2475        body.apply_impulse(Vec2Fix::from_int(10, 0));
2476        assert_eq!(body.velocity.x.hi, 10);
2477        assert!(body.velocity.y.is_zero());
2478    }
2479
2480    #[test]
2481    fn test_static_body_ignores_impulse() {
2482        let shape = Shape2D::Circle {
2483            radius: Fix128::ONE,
2484        };
2485        let mut body = RigidBody2D::new_static(Vec2Fix::ZERO, shape);
2486        body.apply_impulse(Vec2Fix::from_int(100, 100));
2487        assert!(body.velocity.x.is_zero());
2488        assert!(body.velocity.y.is_zero());
2489    }
2490
2491    #[test]
2492    fn test_body_world_point() {
2493        let shape = Shape2D::Circle {
2494            radius: Fix128::ONE,
2495        };
2496        let mut body = RigidBody2D::new_dynamic(Vec2Fix::from_int(10, 20), Fix128::ONE, shape);
2497        body.angle = Fix128::ZERO;
2498        let wp = body.world_point(Vec2Fix::from_int(1, 0));
2499        // After rotation by angle=0, local (1,0) -> approximately (1, 0).
2500        // CORDIC sin(0) has sub-integer precision error, so check integer part
2501        // with tolerance.
2502        let dx = (wp.x - Fix128::from_int(11)).abs();
2503        let dy = (wp.y - Fix128::from_int(20)).abs();
2504        assert!(dx < Fix128::ONE, "world_point x off by more than 1");
2505        assert!(dy < Fix128::ONE, "world_point y off by more than 1");
2506    }
2507
2508    // ---- Collision detection ----
2509
2510    #[test]
2511    fn test_circle_vs_circle_collision() {
2512        let c = circle_vs_circle(
2513            Vec2Fix::from_int(0, 0),
2514            Fix128::ONE,
2515            Vec2Fix::from_int(1, 0),
2516            Fix128::ONE,
2517        );
2518        assert!(c.is_some());
2519        let contact = c.unwrap();
2520        assert_eq!(contact.depth.hi, 1); // overlap = 2 - 1 = 1
2521        assert_eq!(contact.normal.x.hi, 1); // pointing from A to B
2522        assert!(contact.normal.y.is_zero());
2523    }
2524
2525    #[test]
2526    fn test_circle_vs_circle_no_collision() {
2527        let c = circle_vs_circle(
2528            Vec2Fix::from_int(0, 0),
2529            Fix128::ONE,
2530            Vec2Fix::from_int(5, 0),
2531            Fix128::ONE,
2532        );
2533        assert!(c.is_none());
2534    }
2535
2536    #[test]
2537    fn test_polygon_vs_polygon_collision() {
2538        // Two overlapping unit squares
2539        let verts_a = vec![
2540            Vec2Fix::from_int(-1, -1),
2541            Vec2Fix::from_int(1, -1),
2542            Vec2Fix::from_int(1, 1),
2543            Vec2Fix::from_int(-1, 1),
2544        ];
2545        let verts_b = vec![
2546            Vec2Fix::from_int(-1, -1),
2547            Vec2Fix::from_int(1, -1),
2548            Vec2Fix::from_int(1, 1),
2549            Vec2Fix::from_int(-1, 1),
2550        ];
2551
2552        let body_a = RigidBody2D::new_dynamic(
2553            Vec2Fix::from_int(0, 0),
2554            Fix128::ONE,
2555            Shape2D::Polygon {
2556                vertices: verts_a.clone(),
2557            },
2558        );
2559        let body_b = RigidBody2D::new_dynamic(
2560            Vec2Fix::from_int(1, 0),
2561            Fix128::ONE,
2562            Shape2D::Polygon {
2563                vertices: verts_b.clone(),
2564            },
2565        );
2566
2567        let result = polygon_vs_polygon(&body_a, &verts_a, &body_b, &verts_b);
2568        assert!(result.is_some());
2569        let contact = result.unwrap();
2570        assert!(contact.depth > Fix128::ZERO);
2571    }
2572
2573    #[test]
2574    fn test_polygon_vs_polygon_no_collision() {
2575        let verts_a = vec![
2576            Vec2Fix::from_int(-1, -1),
2577            Vec2Fix::from_int(1, -1),
2578            Vec2Fix::from_int(1, 1),
2579            Vec2Fix::from_int(-1, 1),
2580        ];
2581        let verts_b = vec![
2582            Vec2Fix::from_int(-1, -1),
2583            Vec2Fix::from_int(1, -1),
2584            Vec2Fix::from_int(1, 1),
2585            Vec2Fix::from_int(-1, 1),
2586        ];
2587
2588        let body_a = RigidBody2D::new_dynamic(
2589            Vec2Fix::from_int(0, 0),
2590            Fix128::ONE,
2591            Shape2D::Polygon {
2592                vertices: verts_a.clone(),
2593            },
2594        );
2595        let body_b = RigidBody2D::new_dynamic(
2596            Vec2Fix::from_int(10, 0),
2597            Fix128::ONE,
2598            Shape2D::Polygon {
2599                vertices: verts_b.clone(),
2600            },
2601        );
2602
2603        let result = polygon_vs_polygon(&body_a, &verts_a, &body_b, &verts_b);
2604        assert!(result.is_none());
2605    }
2606
2607    #[test]
2608    fn test_circle_vs_polygon_collision() {
2609        let verts = vec![
2610            Vec2Fix::from_int(-2, -2),
2611            Vec2Fix::from_int(2, -2),
2612            Vec2Fix::from_int(2, 2),
2613            Vec2Fix::from_int(-2, 2),
2614        ];
2615        let poly_body = RigidBody2D::new_dynamic(
2616            Vec2Fix::from_int(0, 0),
2617            Fix128::ONE,
2618            Shape2D::Polygon {
2619                vertices: verts.clone(),
2620            },
2621        );
2622
2623        let result = circle_vs_polygon(Vec2Fix::from_int(2, 0), Fix128::ONE, &poly_body, &verts);
2624        assert!(result.is_some());
2625    }
2626
2627    #[test]
2628    fn test_circle_vs_polygon_no_collision() {
2629        let verts = vec![
2630            Vec2Fix::from_int(-1, -1),
2631            Vec2Fix::from_int(1, -1),
2632            Vec2Fix::from_int(1, 1),
2633            Vec2Fix::from_int(-1, 1),
2634        ];
2635        let poly_body = RigidBody2D::new_dynamic(
2636            Vec2Fix::from_int(0, 0),
2637            Fix128::ONE,
2638            Shape2D::Polygon {
2639                vertices: verts.clone(),
2640            },
2641        );
2642
2643        let result = circle_vs_polygon(Vec2Fix::from_int(10, 10), Fix128::ONE, &poly_body, &verts);
2644        assert!(result.is_none());
2645    }
2646
2647    #[test]
2648    fn test_capsule_vs_circle_collision() {
2649        let cap_body = RigidBody2D::new_dynamic(
2650            Vec2Fix::from_int(0, 0),
2651            Fix128::ONE,
2652            Shape2D::Capsule {
2653                radius: Fix128::ONE,
2654                half_length: Fix128::from_int(2),
2655            },
2656        );
2657        let result = capsule_vs_circle(
2658            &cap_body,
2659            Fix128::ONE,
2660            Fix128::from_int(2),
2661            Vec2Fix::from_int(3, 0),
2662            Fix128::ONE,
2663        );
2664        assert!(result.is_some());
2665    }
2666
2667    #[test]
2668    fn test_capsule_vs_circle_no_collision() {
2669        let cap_body = RigidBody2D::new_dynamic(
2670            Vec2Fix::from_int(0, 0),
2671            Fix128::ONE,
2672            Shape2D::Capsule {
2673                radius: Fix128::ONE,
2674                half_length: Fix128::from_int(2),
2675            },
2676        );
2677        let result = capsule_vs_circle(
2678            &cap_body,
2679            Fix128::ONE,
2680            Fix128::from_int(2),
2681            Vec2Fix::from_int(10, 10),
2682            Fix128::ONE,
2683        );
2684        assert!(result.is_none());
2685    }
2686
2687    #[test]
2688    fn test_edge_vs_circle_collision() {
2689        let edge_body = RigidBody2D::new_static(
2690            Vec2Fix::ZERO,
2691            Shape2D::Edge {
2692                start: Vec2Fix::from_int(-5, 0),
2693                end: Vec2Fix::from_int(5, 0),
2694            },
2695        );
2696        let result = edge_vs_circle(
2697            &edge_body,
2698            Vec2Fix::from_int(-5, 0),
2699            Vec2Fix::from_int(5, 0),
2700            Vec2Fix::new(Fix128::ZERO, Fix128::from_ratio(1, 2)),
2701            Fix128::ONE,
2702        );
2703        assert!(result.is_some());
2704        let contact = result.unwrap();
2705        assert!(contact.depth > Fix128::ZERO);
2706    }
2707
2708    // ---- Basic simulation ----
2709
2710    #[test]
2711    fn test_falling_body() {
2712        let config = PhysicsConfig2D::default();
2713        let mut world = PhysicsWorld2D::new(config);
2714
2715        let shape = Shape2D::Circle {
2716            radius: Fix128::ONE,
2717        };
2718        let body = RigidBody2D::new_dynamic(Vec2Fix::from_int(0, 100), Fix128::ONE, shape);
2719        let id = world.add_body(body);
2720
2721        let dt = Fix128::from_ratio(1, 60);
2722        for _ in 0..60 {
2723            world.step(dt);
2724        }
2725
2726        // Body should have fallen under gravity
2727        assert!(
2728            world.bodies[id].position.y < Fix128::from_int(100),
2729            "Body should have fallen"
2730        );
2731    }
2732
2733    #[test]
2734    fn test_static_body_does_not_move() {
2735        let config = PhysicsConfig2D::default();
2736        let mut world = PhysicsWorld2D::new(config);
2737
2738        let shape = Shape2D::Circle {
2739            radius: Fix128::from_int(5),
2740        };
2741        let body = RigidBody2D::new_static(Vec2Fix::from_int(10, 20), shape);
2742        let id = world.add_body(body);
2743
2744        let dt = Fix128::from_ratio(1, 60);
2745        for _ in 0..120 {
2746            world.step(dt);
2747        }
2748
2749        assert_eq!(world.bodies[id].position.x.hi, 10);
2750        assert_eq!(world.bodies[id].position.y.hi, 20);
2751    }
2752
2753    #[test]
2754    fn test_kinematic_body_moves_by_velocity() {
2755        let config = PhysicsConfig2D::default();
2756        let mut world = PhysicsWorld2D::new(config);
2757
2758        let shape = Shape2D::Circle {
2759            radius: Fix128::ONE,
2760        };
2761        let mut body = RigidBody2D::new_kinematic(Vec2Fix::ZERO, shape);
2762        body.velocity = Vec2Fix::from_int(10, 0);
2763        let id = world.add_body(body);
2764
2765        let dt = Fix128::from_ratio(1, 60);
2766        for _ in 0..60 {
2767            world.step(dt);
2768        }
2769
2770        // Should have moved ~10 units in x
2771        let x = world.bodies[id].position.x;
2772        assert!(x > Fix128::from_int(5));
2773    }
2774
2775    // ---- Determinism ----
2776
2777    #[test]
2778    fn test_determinism_2d() {
2779        fn run_sim() -> (Vec2Fix, Fix128) {
2780            let config = PhysicsConfig2D {
2781                gravity: Vec2Fix::new(Fix128::ZERO, Fix128::from_int(-10)),
2782                substeps: 4,
2783                iterations: 8,
2784                damping: Fix128::from_ratio(99, 100),
2785            };
2786            let mut world = PhysicsWorld2D::new(config);
2787
2788            let shape = Shape2D::Circle {
2789                radius: Fix128::ONE,
2790            };
2791            let body =
2792                RigidBody2D::new_dynamic(Vec2Fix::from_int(5, 50), Fix128::from_ratio(3, 2), shape);
2793            world.add_body(body);
2794
2795            let dt = Fix128::from_ratio(1, 60);
2796            for _ in 0..120 {
2797                world.step(dt);
2798            }
2799
2800            (world.bodies[0].position, world.bodies[0].angle)
2801        }
2802
2803        let (pos1, angle1) = run_sim();
2804        let (pos2, angle2) = run_sim();
2805
2806        // Bit-exact
2807        assert_eq!(pos1.x.hi, pos2.x.hi);
2808        assert_eq!(pos1.x.lo, pos2.x.lo);
2809        assert_eq!(pos1.y.hi, pos2.y.hi);
2810        assert_eq!(pos1.y.lo, pos2.y.lo);
2811        assert_eq!(angle1.hi, angle2.hi);
2812        assert_eq!(angle1.lo, angle2.lo);
2813    }
2814
2815    // ---- Joint constraints ----
2816
2817    #[test]
2818    fn test_distance_joint() {
2819        let config = PhysicsConfig2D::default();
2820        let mut world = PhysicsWorld2D::new(config);
2821
2822        let shape = Shape2D::Circle {
2823            radius: Fix128::ONE,
2824        };
2825        let body_a = RigidBody2D::new_dynamic(Vec2Fix::from_int(0, 10), Fix128::ONE, shape.clone());
2826        let body_b = RigidBody2D::new_dynamic(Vec2Fix::from_int(5, 10), Fix128::ONE, shape);
2827        let id_a = world.add_body(body_a);
2828        let id_b = world.add_body(body_b);
2829
2830        world.add_joint(Joint2D::Distance {
2831            body_a: id_a,
2832            body_b: id_b,
2833            local_anchor_a: Vec2Fix::ZERO,
2834            local_anchor_b: Vec2Fix::ZERO,
2835            target_distance: Fix128::from_int(5),
2836            compliance: Fix128::ZERO,
2837        });
2838
2839        let dt = Fix128::from_ratio(1, 60);
2840        for _ in 0..60 {
2841            world.step(dt);
2842        }
2843
2844        let pos_a = world.bodies[id_a].position;
2845        let pos_b = world.bodies[id_b].position;
2846        let dist = (pos_b - pos_a).length();
2847        let target = Fix128::from_int(5);
2848        let error = (dist - target).abs();
2849
2850        // Should maintain roughly 5 units distance
2851        assert!(
2852            error < Fix128::from_int(2),
2853            "Distance joint error too large"
2854        );
2855    }
2856
2857    #[test]
2858    fn test_revolute_joint() {
2859        let config = PhysicsConfig2D {
2860            gravity: Vec2Fix::ZERO,
2861            substeps: 4,
2862            iterations: 8,
2863            damping: Fix128::from_ratio(99, 100),
2864        };
2865        let mut world = PhysicsWorld2D::new(config);
2866
2867        let shape = Shape2D::Circle {
2868            radius: Fix128::ONE,
2869        };
2870        let body_a = RigidBody2D::new_static(Vec2Fix::ZERO, shape.clone());
2871        let body_b = RigidBody2D::new_dynamic(Vec2Fix::from_int(3, 0), Fix128::ONE, shape);
2872        let id_a = world.add_body(body_a);
2873        let id_b = world.add_body(body_b);
2874
2875        world.add_joint(Joint2D::Revolute {
2876            body_a: id_a,
2877            body_b: id_b,
2878            local_anchor_a: Vec2Fix::ZERO,
2879            local_anchor_b: Vec2Fix::from_int(-3, 0),
2880            compliance: Fix128::ZERO,
2881        });
2882
2883        let dt = Fix128::from_ratio(1, 60);
2884        for _ in 0..60 {
2885            world.step(dt);
2886        }
2887
2888        // The anchor points should stay close
2889        let wa = world.bodies[id_a].world_point(Vec2Fix::ZERO);
2890        let wb = world.bodies[id_b].world_point(Vec2Fix::from_int(-3, 0));
2891        let separation = wa.distance_to(wb);
2892        assert!(
2893            separation < Fix128::ONE,
2894            "Revolute joint anchor separation too large"
2895        );
2896    }
2897
2898    #[test]
2899    fn test_weld_joint() {
2900        let config = PhysicsConfig2D {
2901            gravity: Vec2Fix::ZERO,
2902            substeps: 4,
2903            iterations: 8,
2904            damping: Fix128::from_ratio(99, 100),
2905        };
2906        let mut world = PhysicsWorld2D::new(config);
2907
2908        let shape = Shape2D::Circle {
2909            radius: Fix128::ONE,
2910        };
2911        let body_a = RigidBody2D::new_static(Vec2Fix::ZERO, shape.clone());
2912        let mut body_b = RigidBody2D::new_dynamic(Vec2Fix::from_int(2, 0), Fix128::ONE, shape);
2913        body_b.angular_velocity = Fix128::from_int(5); // spin it
2914        let id_a = world.add_body(body_a);
2915        let id_b = world.add_body(body_b);
2916
2917        world.add_joint(Joint2D::Weld {
2918            body_a: id_a,
2919            body_b: id_b,
2920            local_anchor_a: Vec2Fix::from_int(2, 0),
2921            local_anchor_b: Vec2Fix::ZERO,
2922            reference_angle: Fix128::ZERO,
2923            compliance: Fix128::ZERO,
2924        });
2925
2926        let dt = Fix128::from_ratio(1, 60);
2927        for _ in 0..120 {
2928            world.step(dt);
2929        }
2930
2931        // Weld joint should resist angle difference
2932        let angle_diff = (world.bodies[id_b].angle - world.bodies[id_a].angle).abs();
2933        assert!(
2934            angle_diff < Fix128::from_int(2),
2935            "Weld joint should constrain angle"
2936        );
2937    }
2938
2939    #[test]
2940    fn test_mouse_joint() {
2941        let config = PhysicsConfig2D {
2942            gravity: Vec2Fix::ZERO,
2943            substeps: 4,
2944            iterations: 8,
2945            damping: Fix128::from_ratio(99, 100),
2946        };
2947        let mut world = PhysicsWorld2D::new(config);
2948
2949        let shape = Shape2D::Circle {
2950            radius: Fix128::ONE,
2951        };
2952        let body = RigidBody2D::new_dynamic(Vec2Fix::ZERO, Fix128::ONE, shape);
2953        let id = world.add_body(body);
2954
2955        let target = Vec2Fix::from_int(10, 10);
2956        world.add_joint(Joint2D::Mouse {
2957            body: id,
2958            target,
2959            max_force: Fix128::from_int(100),
2960            // k = 100 N/m and c = 20 N·s/m on a 1 kg body: omega = 10 rad/s,
2961            // c = 2·sqrt(k·m) (critically damped)
2962            stiffness: Fix128::from_int(100),
2963            damping: Fix128::from_int(20),
2964        });
2965
2966        let dt = Fix128::from_ratio(1, 60);
2967        for _ in 0..300 {
2968            world.step(dt);
2969        }
2970
2971        // Body should have moved toward target
2972        let pos = world.bodies[id].position;
2973        // critically damped from 14.14 m: 14.14·(1 + 50)·e^(−50) ≈ 1e-19 m after 5 s; with
2974        // the damping ignored the frame damping alone leaves ≈ 0.86 m, so the bound is 1 cm
2975        let dist = pos.distance_to(target);
2976        assert!(
2977            dist < Fix128::from_ratio(1, 100),
2978            "Mouse joint should pull body toward target"
2979        );
2980    }
2981
2982    // ---- Contact resolution ----
2983
2984    #[test]
2985    fn test_circle_circle_contact_resolution() {
2986        let config = PhysicsConfig2D {
2987            gravity: Vec2Fix::ZERO,
2988            substeps: 4,
2989            iterations: 8,
2990            damping: Fix128::ONE, // no damping
2991        };
2992        let mut world = PhysicsWorld2D::new(config);
2993
2994        let shape_a = Shape2D::Circle {
2995            radius: Fix128::ONE,
2996        };
2997        let shape_b = Shape2D::Circle {
2998            radius: Fix128::ONE,
2999        };
3000        // Overlapping circles
3001        let body_a = RigidBody2D::new_dynamic(Vec2Fix::from_int(0, 0), Fix128::ONE, shape_a);
3002        let body_b = RigidBody2D::new_dynamic(Vec2Fix::from_int(1, 0), Fix128::ONE, shape_b);
3003        world.add_body(body_a);
3004        world.add_body(body_b);
3005
3006        let dt = Fix128::from_ratio(1, 60);
3007        for _ in 0..60 {
3008            world.step(dt);
3009        }
3010
3011        // Bodies should have separated
3012        let dist = world.bodies[0]
3013            .position
3014            .distance_to(world.bodies[1].position);
3015        assert!(
3016            dist >= Fix128::ONE,
3017            "Circles should have been pushed apart by contact solver"
3018        );
3019    }
3020
3021    #[test]
3022    fn test_body_rests_on_static_edge() {
3023        let config = PhysicsConfig2D::default();
3024        let mut world = PhysicsWorld2D::new(config);
3025
3026        // Static ground edge
3027        let ground = RigidBody2D::new_static(
3028            Vec2Fix::ZERO,
3029            Shape2D::Edge {
3030                start: Vec2Fix::from_int(-100, 0),
3031                end: Vec2Fix::from_int(100, 0),
3032            },
3033        );
3034        world.add_body(ground);
3035
3036        // Falling circle
3037        let ball = RigidBody2D::new_dynamic(
3038            Vec2Fix::from_int(0, 5),
3039            Fix128::ONE,
3040            Shape2D::Circle {
3041                radius: Fix128::ONE,
3042            },
3043        );
3044        let ball_id = world.add_body(ball);
3045
3046        let dt = Fix128::from_ratio(1, 60);
3047        for _ in 0..300 {
3048            world.step(dt);
3049        }
3050
3051        // Ball should not have fallen through the ground
3052        let y = world.bodies[ball_id].position.y;
3053        assert!(
3054            y > Fix128::from_int(-5),
3055            "Ball should rest on or above the edge"
3056        );
3057    }
3058
3059    // ---- World management ----
3060
3061    #[test]
3062    fn test_add_remove_body() {
3063        let config = PhysicsConfig2D::default();
3064        let mut world = PhysicsWorld2D::new(config);
3065
3066        let shape = Shape2D::Circle {
3067            radius: Fix128::ONE,
3068        };
3069        let id0 = world.add_body(RigidBody2D::new_dynamic(
3070            Vec2Fix::from_int(0, 0),
3071            Fix128::ONE,
3072            shape.clone(),
3073        ));
3074        let _id1 = world.add_body(RigidBody2D::new_dynamic(
3075            Vec2Fix::from_int(5, 5),
3076            Fix128::ONE,
3077            shape,
3078        ));
3079        assert_eq!(world.bodies.len(), 2);
3080
3081        let removed = world.remove_body(id0);
3082        assert!(removed.is_some());
3083        assert_eq!(world.bodies.len(), 1);
3084    }
3085
3086    #[test]
3087    fn test_remove_body_invalid_index() {
3088        let config = PhysicsConfig2D::default();
3089        let mut world = PhysicsWorld2D::new(config);
3090        let removed = world.remove_body(999);
3091        assert!(removed.is_none());
3092    }
3093
3094    // ---- Inertia computation ----
3095
3096    #[test]
3097    fn test_circle_inertia() {
3098        let shape = Shape2D::Circle {
3099            radius: Fix128::from_int(2),
3100        };
3101        let mass = Fix128::from_int(4);
3102        let inertia = compute_inertia(&shape, mass);
3103        // I = 0.5 * 4 * 4 = 8
3104        assert_eq!(inertia.hi, 8);
3105    }
3106
3107    #[test]
3108    fn test_polygon_inertia_nonzero() {
3109        let verts = vec![
3110            Vec2Fix::from_int(-1, -1),
3111            Vec2Fix::from_int(1, -1),
3112            Vec2Fix::from_int(1, 1),
3113            Vec2Fix::from_int(-1, 1),
3114        ];
3115        let shape = Shape2D::Polygon { vertices: verts };
3116        let inertia = compute_inertia(&shape, Fix128::from_int(4));
3117        assert!(!inertia.is_zero());
3118        assert!(!inertia.is_negative());
3119    }
3120
3121    // ---- Multiple collisions ----
3122
3123    #[test]
3124    fn test_multiple_bodies_simulation() {
3125        let config = PhysicsConfig2D::default();
3126        let mut world = PhysicsWorld2D::new(config);
3127
3128        let shape = Shape2D::Circle {
3129            radius: Fix128::ONE,
3130        };
3131
3132        // Add 5 bodies at different heights
3133        for i in 0..5 {
3134            let body = RigidBody2D::new_dynamic(
3135                Vec2Fix::from_int(i as i64 * 3, (i as i64 + 1) * 10),
3136                Fix128::ONE,
3137                shape.clone(),
3138            );
3139            world.add_body(body);
3140        }
3141
3142        let dt = Fix128::from_ratio(1, 60);
3143        for _ in 0..60 {
3144            world.step(dt);
3145        }
3146
3147        // All bodies should have fallen
3148        for body in &world.bodies {
3149            assert!(body.velocity.y.is_negative() || body.position.y < Fix128::from_int(50));
3150        }
3151    }
3152
3153    // ---- World with collision ----
3154
3155    #[test]
3156    fn test_world_check_collision_2d() {
3157        let config = PhysicsConfig2D::default();
3158        let mut world = PhysicsWorld2D::new(config);
3159
3160        let shape_a = Shape2D::Circle {
3161            radius: Fix128::ONE,
3162        };
3163        let shape_b = Shape2D::Circle {
3164            radius: Fix128::ONE,
3165        };
3166        world.add_body(RigidBody2D::new_dynamic(
3167            Vec2Fix::from_int(0, 0),
3168            Fix128::ONE,
3169            shape_a,
3170        ));
3171        world.add_body(RigidBody2D::new_dynamic(
3172            Vec2Fix::from_int(1, 0),
3173            Fix128::ONE,
3174            shape_b,
3175        ));
3176
3177        let result = world.check_collision_2d(0, 1);
3178        assert!(result.is_some());
3179    }
3180
3181    #[test]
3182    fn test_world_no_collision_far_apart() {
3183        let config = PhysicsConfig2D::default();
3184        let mut world = PhysicsWorld2D::new(config);
3185
3186        let shape = Shape2D::Circle {
3187            radius: Fix128::ONE,
3188        };
3189        world.add_body(RigidBody2D::new_dynamic(
3190            Vec2Fix::from_int(0, 0),
3191            Fix128::ONE,
3192            shape.clone(),
3193        ));
3194        world.add_body(RigidBody2D::new_dynamic(
3195            Vec2Fix::from_int(100, 100),
3196            Fix128::ONE,
3197            shape,
3198        ));
3199
3200        let result = world.check_collision_2d(0, 1);
3201        assert!(result.is_none());
3202    }
3203
3204    // ---- Zero dt edge case ----
3205
3206    #[test]
3207    fn test_zero_dt_step() {
3208        let config = PhysicsConfig2D::default();
3209        let mut world = PhysicsWorld2D::new(config);
3210
3211        let shape = Shape2D::Circle {
3212            radius: Fix128::ONE,
3213        };
3214        let body = RigidBody2D::new_dynamic(Vec2Fix::from_int(0, 10), Fix128::ONE, shape);
3215        let id = world.add_body(body);
3216
3217        // Stepping with zero dt should not crash or move the body
3218        world.step(Fix128::ZERO);
3219        assert_eq!(world.bodies[id].position.x.hi, 0);
3220        assert_eq!(world.bodies[id].position.y.hi, 10);
3221    }
3222
3223    // ---- PhysicsConfig2D default ----
3224
3225    #[test]
3226    fn test_config_default() {
3227        let config = PhysicsConfig2D::default();
3228        assert!(config.gravity.x.is_zero());
3229        assert_eq!(config.gravity.y.hi, -10);
3230        assert_eq!(config.substeps, 4);
3231        assert_eq!(config.iterations, 8);
3232    }
3233
3234    // ---- Closest point on segment ----
3235
3236    #[test]
3237    fn test_closest_point_on_segment_middle() {
3238        let a = Vec2Fix::from_int(0, 0);
3239        let b = Vec2Fix::from_int(10, 0);
3240        let p = Vec2Fix::from_int(5, 5);
3241        let closest = closest_point_on_segment(a, b, p);
3242        assert_eq!(closest.x.hi, 5);
3243        assert!(closest.y.is_zero());
3244    }
3245
3246    #[test]
3247    fn test_closest_point_on_segment_endpoint_a() {
3248        let a = Vec2Fix::from_int(0, 0);
3249        let b = Vec2Fix::from_int(10, 0);
3250        let p = Vec2Fix::from_int(-5, 0);
3251        let closest = closest_point_on_segment(a, b, p);
3252        assert_eq!(closest.x.hi, 0);
3253        assert!(closest.y.is_zero());
3254    }
3255
3256    #[test]
3257    fn test_closest_point_on_segment_endpoint_b() {
3258        let a = Vec2Fix::from_int(0, 0);
3259        let b = Vec2Fix::from_int(10, 0);
3260        let p = Vec2Fix::from_int(15, 0);
3261        let closest = closest_point_on_segment(a, b, p);
3262        assert_eq!(closest.x.hi, 10);
3263        assert!(closest.y.is_zero());
3264    }
3265
3266    // ---- Impulse at point ----
3267
3268    #[test]
3269    fn test_apply_impulse_at_point() {
3270        let shape = Shape2D::Circle {
3271            radius: Fix128::from_int(2),
3272        };
3273        let mut body = RigidBody2D::new_dynamic(Vec2Fix::ZERO, Fix128::ONE, shape);
3274        let point = Vec2Fix::from_int(2, 0);
3275        let impulse = Vec2Fix::from_int(0, 10);
3276        body.apply_impulse_at_point(impulse, point);
3277
3278        // Should have both linear and angular velocity
3279        assert_eq!(body.velocity.y.hi, 10);
3280        assert!(!body.angular_velocity.is_zero());
3281    }
3282
3283    // ---- Force application ----
3284
3285    #[test]
3286    fn test_apply_force() {
3287        let shape = Shape2D::Circle {
3288            radius: Fix128::ONE,
3289        };
3290        let mut body = RigidBody2D::new_dynamic(Vec2Fix::ZERO, Fix128::ONE, shape);
3291        let force = Vec2Fix::from_int(100, 0);
3292        let dt = Fix128::from_ratio(1, 60);
3293        body.apply_force(force, dt);
3294        assert!(body.velocity.x > Fix128::ZERO);
3295    }
3296
3297    // ---- Tethers2D (closed-form checks; the full oracles are in
3298    // tests/analytic_physics2d_tethers.rs) ----
3299
3300    fn tether_world(substeps: usize) -> PhysicsWorld2D {
3301        PhysicsWorld2D::new(PhysicsConfig2D {
3302            gravity: Vec2Fix::ZERO,
3303            substeps,
3304            iterations: 2,
3305            damping: Fix128::ONE,
3306        })
3307    }
3308
3309    #[test]
3310    fn exp_neg_matches_reference_values() {
3311        // e^-1, e^-0.25, e^-5 against f64 to well below f64 resolution of Fix128 use
3312        for (x, want) in [
3313            (1.0, 0.367_879_441_171_442_3),
3314            (0.25, 0.778_800_783_071_404_9),
3315            (5.0, 0.006_737_946_999_085_467),
3316        ] {
3317            let got = exp_neg(Fix128::from_f64(x)).to_f64();
3318            assert!((got - want).abs() < 1e-15, "e^-{x}: {got} vs {want}");
3319        }
3320        assert_eq!(exp_neg(Fix128::ZERO), Fix128::ONE);
3321        assert_eq!(exp_neg(Fix128::from_int(-3)), Fix128::ONE);
3322        assert_eq!(exp_neg(Fix128::from_int(50)), Fix128::ZERO);
3323    }
3324
3325    #[test]
3326    fn angular_tether_one_substep_is_backward_euler() {
3327        // I = 2, ω = 6, ζ = 1, h = 1/60: θ' = θ (1 + 2a) / (1 + a)², a = ω h
3328        let mut w = tether_world(1);
3329        let mut b = RigidBody2D::new_dynamic(
3330            Vec2Fix::ZERO,
3331            Fix128::ONE,
3332            Shape2D::Circle {
3333                radius: Fix128::ONE,
3334            },
3335        );
3336        b.inv_inertia = Fix128::from_ratio(1, 2);
3337        b.angle = Fix128::ONE;
3338        let idx = w.add_body(b);
3339        let mut set = Tethers2D::new();
3340        let id = set.add_angular(AngularTether2D::critically_damped(
3341            idx,
3342            Fix128::ZERO,
3343            Fix128::from_int(2),
3344            Fix128::from_int(6),
3345        ));
3346        assert_eq!(
3347            set.angular(id).map(|t| t.stiffness),
3348            Some(Fix128::from_int(72))
3349        );
3350        w.step_with_tethers(Fix128::from_ratio(1, 60), &set);
3351        let a = 0.1_f64;
3352        let want = (1.0 + 2.0 * a) / ((1.0 + a) * (1.0 + a));
3353        assert!((w.bodies[idx].angle.to_f64() - want).abs() < 1e-15);
3354        assert!((w.bodies[idx].angular_velocity.to_f64() - (want - 1.0) * 60.0).abs() < 1e-12);
3355        // retarget, then remove: the set is empty again
3356        set.angular_mut(id).unwrap().target_angle = Fix128::ONE;
3357        assert_eq!(set.angular_count(), 1);
3358        assert!(set.remove_angular(id).is_some());
3359        assert!(set.angular(id).is_none() && set.angular_mut(id).is_none());
3360        assert_eq!(set.angular_count(), 0);
3361    }
3362
3363    #[test]
3364    fn kinematic_drive_one_frame_is_the_closed_form() {
3365        // e(t) = (e0 + (v0 + ω e0) t) e^(−ωt), composed over 4 substeps
3366        let mut w = tether_world(4);
3367        let mut b = RigidBody2D::new_kinematic(
3368            Vec2Fix::from_int(2, -1),
3369            Shape2D::Circle {
3370                radius: Fix128::ONE,
3371            },
3372        );
3373        b.angle = Fix128::from_int(3);
3374        b.velocity = Vec2Fix::from_int(1, 0);
3375        let idx = w.add_body(b);
3376        let mut set = Tethers2D::new();
3377        let id = set.add_drive(KinematicDrive2D::new(
3378            idx,
3379            Vec2Fix::ZERO,
3380            Fix128::ONE,
3381            Fix128::from_int(5),
3382        ));
3383        assert_eq!(set.drive_count(), 1);
3384        w.step_with_tethers(Fix128::from_ratio(1, 10), &set);
3385        let (om, t) = (5.0_f64, 0.1_f64);
3386        let ex = |e0: f64, v0: f64| (e0 + (v0 + om * e0) * t) * crate::det_math::exp64(-om * t);
3387        let body = &w.bodies[idx];
3388        assert!((body.position.x.to_f64() - ex(2.0, 1.0)).abs() < 1e-14);
3389        assert!((body.position.y.to_f64() - ex(-1.0, 0.0)).abs() < 1e-14);
3390        assert!((body.angle.to_f64() - 1.0 - ex(2.0, 0.0)).abs() < 1e-14);
3391        set.drive_mut(id).unwrap().omega = Fix128::ZERO;
3392        assert_eq!(set.drive(id).map(|d| d.omega), Some(Fix128::ZERO));
3393        assert!(set.remove_drive(id).is_some());
3394        assert!(
3395            set.drive(id).is_none()
3396                && set.drive_mut(id).is_none()
3397                && set.remove_drive(id).is_none()
3398        );
3399        assert_eq!(set.drive_count(), 0);
3400    }
3401
3402    #[test]
3403    fn tethers_skip_out_of_range_and_wrong_body_types() {
3404        let mut w = tether_world(1);
3405        let d = w.add_body(RigidBody2D::new_dynamic(
3406            Vec2Fix::ZERO,
3407            Fix128::ONE,
3408            Shape2D::Circle {
3409                radius: Fix128::ONE,
3410            },
3411        ));
3412        let mut k = RigidBody2D::new_kinematic(
3413            Vec2Fix::from_int(10, 0),
3414            Shape2D::Circle {
3415                radius: Fix128::ONE,
3416            },
3417        );
3418        k.inv_inertia = Fix128::ONE;
3419        k.angle = Fix128::ONE;
3420        let k = w.add_body(k);
3421        let mut set = Tethers2D::new();
3422        set.add_angular(AngularTether2D::new(
3423            7,
3424            Fix128::ONE,
3425            Fix128::ONE,
3426            Fix128::ONE,
3427        ));
3428        set.add_angular(AngularTether2D::new(
3429            k,
3430            Fix128::ZERO,
3431            Fix128::from_int(9),
3432            Fix128::ONE,
3433        ));
3434        set.add_drive(KinematicDrive2D::new(
3435            7,
3436            Vec2Fix::ZERO,
3437            Fix128::ZERO,
3438            Fix128::ONE,
3439        ));
3440        set.add_drive(KinematicDrive2D::new(
3441            d,
3442            Vec2Fix::from_int(1, 1),
3443            Fix128::ZERO,
3444            Fix128::ONE,
3445        ));
3446        w.step_with_tethers(Fix128::from_ratio(1, 60), &set);
3447        assert_eq!(w.bodies[d].position, Vec2Fix::ZERO);
3448        assert_eq!(w.bodies[k].angle, Fix128::ONE);
3449        // dt = 0: tethers see h = 0 and leave the state alone
3450        let before = w.bodies[k].position;
3451        w.step_with_tethers(Fix128::ZERO, &set);
3452        assert_eq!(w.bodies[k].position, before);
3453    }
3454}