Skip to main content

alice_physics/
joint.rs

1//! Joint System for Rigid Body Connections
2//!
3//! XPBD-based joint constraints with angular limits and compliance.
4//!
5//! # Joint Types
6//!
7//! - **`BallJoint`**: 3-DOF rotation (shoulder, hip)
8//! - **`HingeJoint`**: 1-DOF rotation with angle limits (knee, door)
9//! - **`FixedJoint`**: 0-DOF (weld)
10//! - **`SliderJoint`**: 1-DOF translation along an axis (piston)
11//! - **`SpringJoint`**: Distance spring with damping
12//! - **`D6Joint`**: 6-DOF configurable joint with per-axis locking
13//! - **`ConeTwistJoint`**: Cone-twist constraint for ragdoll shoulders/hips
14
15use crate::math::{Fix128, QuatFix, Vec3Fix};
16
17#[cfg(not(feature = "std"))]
18use alloc::vec::Vec;
19
20/// Joint type enumeration
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum JointType {
23    /// Ball-and-socket (3 rotational DOF)
24    Ball,
25    /// Hinge (1 rotational DOF)
26    Hinge,
27    /// Fixed / weld (0 DOF)
28    Fixed,
29    /// Slider / prismatic (1 translational DOF)
30    Slider,
31    /// Spring with damping
32    Spring,
33    /// 6-DOF configurable joint
34    D6,
35    /// Cone-twist (ragdoll)
36    ConeTwist,
37}
38
39/// Ball-and-socket joint (3 rotational DOF)
40///
41/// Constrains two anchor points to coincide while allowing free rotation.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct BallJoint {
44    /// Index of the first body
45    pub body_a: usize,
46    /// Index of the second body
47    pub body_b: usize,
48    /// Anchor point in body A's local space
49    pub local_anchor_a: Vec3Fix,
50    /// Anchor point in body B's local space
51    pub local_anchor_b: Vec3Fix,
52    /// Compliance (inverse stiffness, 0 = rigid)
53    pub compliance: Fix128,
54    /// Maximum force before the joint breaks (None = unbreakable)
55    pub break_force: Option<Fix128>,
56}
57
58impl BallJoint {
59    /// Create a new ball joint
60    #[inline]
61    #[must_use]
62    pub const fn new(body_a: usize, body_b: usize, anchor_a: Vec3Fix, anchor_b: Vec3Fix) -> Self {
63        Self {
64            body_a,
65            body_b,
66            local_anchor_a: anchor_a,
67            local_anchor_b: anchor_b,
68            compliance: Fix128::ZERO,
69            break_force: None,
70        }
71    }
72
73    /// Set compliance (inverse stiffness)
74    #[must_use]
75    pub const fn with_compliance(mut self, compliance: Fix128) -> Self {
76        self.compliance = compliance;
77        self
78    }
79
80    /// Set break force threshold
81    #[must_use]
82    pub const fn with_break_force(mut self, force: Fix128) -> Self {
83        self.break_force = Some(force);
84        self
85    }
86}
87
88/// `a > b` for `Fix128`, usable in a `const fn` (the `PartialOrd` impl is
89/// not `const`). `Fix128` is `(hi << 64) | lo` as a signed two's-complement
90/// 128-bit integer (src/math.rs), so comparing the high word first and
91/// breaking ties on the low word is exact.
92const fn fix128_min_max_violated(min: Fix128, max: Fix128) -> bool {
93    min.hi > max.hi || (min.hi == max.hi && min.lo > max.lo)
94}
95
96/// Hinge joint (1 rotational DOF around an axis, with optional angle limits)
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub struct HingeJoint {
99    /// Index of the first body
100    pub body_a: usize,
101    /// Index of the second body
102    pub body_b: usize,
103    /// Anchor point in body A's local space
104    pub local_anchor_a: Vec3Fix,
105    /// Anchor point in body B's local space
106    pub local_anchor_b: Vec3Fix,
107    /// Hinge axis in body A's local space
108    pub local_axis_a: Vec3Fix,
109    /// Hinge axis in body B's local space
110    pub local_axis_b: Vec3Fix,
111    /// Minimum angle (radians, None = no limit)
112    pub angle_min: Option<Fix128>,
113    /// Maximum angle (radians, None = no limit)
114    pub angle_max: Option<Fix128>,
115    /// Positional compliance
116    pub compliance: Fix128,
117    /// Angular compliance
118    pub angular_compliance: Fix128,
119    /// Maximum force before the joint breaks (None = unbreakable)
120    pub break_force: Option<Fix128>,
121}
122
123impl HingeJoint {
124    /// Create a new hinge joint
125    #[inline]
126    #[must_use]
127    pub const fn new(
128        body_a: usize,
129        body_b: usize,
130        anchor_a: Vec3Fix,
131        anchor_b: Vec3Fix,
132        axis_a: Vec3Fix,
133        axis_b: Vec3Fix,
134    ) -> Self {
135        Self {
136            body_a,
137            body_b,
138            local_anchor_a: anchor_a,
139            local_anchor_b: anchor_b,
140            local_axis_a: axis_a,
141            local_axis_b: axis_b,
142            angle_min: None,
143            angle_max: None,
144            compliance: Fix128::ZERO,
145            angular_compliance: Fix128::ZERO,
146            break_force: None,
147        }
148    }
149
150    /// Set angular limits (radians)
151    ///
152    /// # Panics
153    ///
154    /// When `min > max`. `min == max` is allowed (a locked joint).
155    #[must_use]
156    pub const fn with_limits(mut self, min: Fix128, max: Fix128) -> Self {
157        if fix128_min_max_violated(min, max) {
158            panic!("HingeJoint::with_limits: min must not exceed max");
159        }
160        self.angle_min = Some(min);
161        self.angle_max = Some(max);
162        self
163    }
164
165    /// Set positional compliance (inverse stiffness)
166    #[must_use]
167    pub const fn with_compliance(mut self, compliance: Fix128) -> Self {
168        self.compliance = compliance;
169        self
170    }
171
172    /// Set break force threshold
173    #[must_use]
174    pub const fn with_break_force(mut self, force: Fix128) -> Self {
175        self.break_force = Some(force);
176        self
177    }
178}
179
180/// Fixed joint (0 DOF, weld two bodies)
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182pub struct FixedJoint {
183    /// Index of the first body
184    pub body_a: usize,
185    /// Index of the second body
186    pub body_b: usize,
187    /// Anchor point in body A's local space
188    pub local_anchor_a: Vec3Fix,
189    /// Anchor point in body B's local space
190    pub local_anchor_b: Vec3Fix,
191    /// Relative rotation at the time of creation (to maintain)
192    pub relative_rotation: QuatFix,
193    /// Positional compliance (inverse stiffness)
194    pub compliance: Fix128,
195    /// Angular compliance (inverse stiffness)
196    pub angular_compliance: Fix128,
197    /// Maximum force before the joint breaks (None = unbreakable)
198    pub break_force: Option<Fix128>,
199}
200
201impl FixedJoint {
202    /// Create a new fixed joint (weld)
203    #[inline]
204    #[must_use]
205    pub const fn new(
206        body_a: usize,
207        body_b: usize,
208        anchor_a: Vec3Fix,
209        anchor_b: Vec3Fix,
210        relative_rotation: QuatFix,
211    ) -> Self {
212        Self {
213            body_a,
214            body_b,
215            local_anchor_a: anchor_a,
216            local_anchor_b: anchor_b,
217            relative_rotation,
218            compliance: Fix128::ZERO,
219            angular_compliance: Fix128::ZERO,
220            break_force: None,
221        }
222    }
223
224    /// Set break force threshold
225    #[must_use]
226    pub const fn with_break_force(mut self, force: Fix128) -> Self {
227        self.break_force = Some(force);
228        self
229    }
230}
231
232/// Slider (prismatic) joint: translation along a single axis
233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234pub struct SliderJoint {
235    /// Index of the first body
236    pub body_a: usize,
237    /// Index of the second body
238    pub body_b: usize,
239    /// Slide axis in body A's local space
240    pub local_axis: Vec3Fix,
241    /// Anchor point in body A's local space
242    pub local_anchor_a: Vec3Fix,
243    /// Anchor point in body B's local space
244    pub local_anchor_b: Vec3Fix,
245    /// Minimum translation distance (None = no limit)
246    pub limit_min: Option<Fix128>,
247    /// Maximum translation distance (None = no limit)
248    pub limit_max: Option<Fix128>,
249    /// Positional compliance (inverse stiffness)
250    pub compliance: Fix128,
251    /// Maximum force before the joint breaks (None = unbreakable)
252    pub break_force: Option<Fix128>,
253}
254
255impl SliderJoint {
256    /// Create a new slider joint along an axis
257    #[inline]
258    #[must_use]
259    pub const fn new(
260        body_a: usize,
261        body_b: usize,
262        axis: Vec3Fix,
263        anchor_a: Vec3Fix,
264        anchor_b: Vec3Fix,
265    ) -> Self {
266        Self {
267            body_a,
268            body_b,
269            local_axis: axis,
270            local_anchor_a: anchor_a,
271            local_anchor_b: anchor_b,
272            limit_min: None,
273            limit_max: None,
274            compliance: Fix128::ZERO,
275            break_force: None,
276        }
277    }
278
279    /// Set translation limits
280    ///
281    /// # Panics
282    ///
283    /// When `min > max`. `min == max` is allowed (a locked joint).
284    #[must_use]
285    pub const fn with_limits(mut self, min: Fix128, max: Fix128) -> Self {
286        if fix128_min_max_violated(min, max) {
287            panic!("SliderJoint::with_limits: min must not exceed max");
288        }
289        self.limit_min = Some(min);
290        self.limit_max = Some(max);
291        self
292    }
293
294    /// Set break force threshold
295    #[must_use]
296    pub const fn with_break_force(mut self, force: Fix128) -> Self {
297        self.break_force = Some(force);
298        self
299    }
300}
301
302/// Spring joint: a spring-damper between two anchor points
303///
304/// The joint applies the force (on B; A receives the opposite)
305///
306/// - `rest_length == 0`: `F = −k d − c u`, with `d = anchor_b − anchor_a`
307///   and `u = d'` the relative anchor velocity. Spring and damper are both
308///   isotropic, so the damper removes the tangential relative velocity as
309///   well as the radial one and a body held by the spring does not orbit
310///   its anchor.
311/// - `rest_length > 0`: `F = −(k (|d| − L) + c (u · n)) n`, `n = d / |d|`,
312///   the usual spring with a dashpot along its axis. The direction across
313///   the axis is free (a pendulum stays a pendulum), as for a physical
314///   spring-damper element; use `rest_length = 0` to hold a body at a point.
315///
316/// For a body of mass `m` held against a static or kinematic anchor (or
317/// two bodies with `m_eff = 1 / (1/m_a + 1/m_b)`), angular frequency `ω`
318/// and damping ratio `ζ`, use `stiffness = m_eff ω²` and
319/// `damping = 2 ζ m_eff ω`; `ζ = 1` (`damping = 2 √(k m_eff)`) is critical
320/// damping, `x(t) = (x0 + (v0 + ω x0) t) e^(−ωt)` with no overshoot.
321///
322/// It is solved once per substep as an XPBD constraint with damping
323/// (Macklin, Müller, Chentanez 2016, eq. 26), which is a backward Euler step
324/// of the force law: the result converges to the same continuous solution
325/// for any `substeps`, does not depend on `iterations`, and is stable for
326/// any `k` and `c`. Each joint carries its own `stiffness` / `damping`, so
327/// springs of different `ω` can share one world. `SolverConfig::damping`
328/// and the per-body damping factors are applied on top, once per frame.
329#[derive(Clone, Copy, Debug, PartialEq, Eq)]
330pub struct SpringJoint {
331    /// Index of the first body
332    pub body_a: usize,
333    /// Index of the second body
334    pub body_b: usize,
335    /// Anchor point in body A's local space
336    pub local_anchor_a: Vec3Fix,
337    /// Anchor point in body B's local space
338    pub local_anchor_b: Vec3Fix,
339    /// Rest length `L` of the spring in metres. `0` makes the spring and the
340    /// damper isotropic (see the type doc)
341    pub rest_length: Fix128,
342    /// Spring stiffness `k` in newtons per metre (`m_eff ω²` for angular
343    /// frequency `ω`). Independent of the step length and of `substeps`
344    pub stiffness: Fix128,
345    /// Damping coefficient `c` in newton-seconds per metre, acting on the
346    /// relative anchor velocity (every component for `rest_length = 0`, the
347    /// component along the spring otherwise). Critical damping is
348    /// `c = 2 √(k m_eff)`
349    pub damping: Fix128,
350    /// Maximum spring force `|k (|d| − L)|` in newtons before the joint
351    /// breaks (None = unbreakable), see [`solve_joints_breakable`]
352    pub break_force: Option<Fix128>,
353}
354
355impl SpringJoint {
356    /// Create a new spring joint
357    #[inline]
358    #[must_use]
359    pub const fn new(
360        body_a: usize,
361        body_b: usize,
362        anchor_a: Vec3Fix,
363        anchor_b: Vec3Fix,
364        rest_length: Fix128,
365        stiffness: Fix128,
366        damping: Fix128,
367    ) -> Self {
368        Self {
369            body_a,
370            body_b,
371            local_anchor_a: anchor_a,
372            local_anchor_b: anchor_b,
373            rest_length,
374            stiffness,
375            damping,
376            break_force: None,
377        }
378    }
379
380    /// Set break force threshold
381    #[must_use]
382    pub const fn with_break_force(mut self, force: Fix128) -> Self {
383        self.break_force = Some(force);
384        self
385    }
386}
387
388/// Axis freedom mode for D6 joints
389#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
390pub enum D6Motion {
391    /// Axis is locked (no motion allowed)
392    Locked,
393    /// Axis is free (unlimited motion)
394    #[default]
395    Free,
396    /// Axis is limited (within min/max bounds)
397    Limited,
398}
399
400/// D6 Joint (6-DOF configurable joint)
401///
402/// Each of the 6 axes (3 linear + 3 angular) can be independently
403/// set to Free, Locked, or Limited.
404#[derive(Clone, Copy, Debug, PartialEq, Eq)]
405pub struct D6Joint {
406    /// Index of the first body
407    pub body_a: usize,
408    /// Index of the second body
409    pub body_b: usize,
410    /// Anchor point in body A's local space
411    pub local_anchor_a: Vec3Fix,
412    /// Anchor point in body B's local space
413    pub local_anchor_b: Vec3Fix,
414    /// Reference frame for body A
415    pub local_frame_a: QuatFix,
416    /// Reference frame for body B
417    pub local_frame_b: QuatFix,
418    /// Linear X axis motion
419    pub linear_x: D6Motion,
420    /// Linear Y axis motion
421    pub linear_y: D6Motion,
422    /// Linear Z axis motion
423    pub linear_z: D6Motion,
424    /// Angular X (twist) motion
425    pub angular_x: D6Motion,
426    /// Angular Y (swing1) motion
427    pub angular_y: D6Motion,
428    /// Angular Z (swing2) motion
429    pub angular_z: D6Motion,
430    /// Linear limits (min/max for limited axes)
431    pub linear_limit_min: Vec3Fix,
432    /// Linear limit max
433    pub linear_limit_max: Vec3Fix,
434    /// Angular limits (min for limited axes, radians)
435    pub angular_limit_min: Vec3Fix,
436    /// Angular limit max
437    pub angular_limit_max: Vec3Fix,
438    /// Positional compliance
439    pub compliance: Fix128,
440    /// Angular compliance
441    pub angular_compliance: Fix128,
442    /// Maximum force before the joint breaks (None = unbreakable)
443    pub break_force: Option<Fix128>,
444}
445
446impl D6Joint {
447    /// Create a new D6 joint with all axes free
448    #[must_use]
449    pub fn new(body_a: usize, body_b: usize, anchor_a: Vec3Fix, anchor_b: Vec3Fix) -> Self {
450        Self {
451            body_a,
452            body_b,
453            local_anchor_a: anchor_a,
454            local_anchor_b: anchor_b,
455            local_frame_a: QuatFix::IDENTITY,
456            local_frame_b: QuatFix::IDENTITY,
457            linear_x: D6Motion::Free,
458            linear_y: D6Motion::Free,
459            linear_z: D6Motion::Free,
460            angular_x: D6Motion::Free,
461            angular_y: D6Motion::Free,
462            angular_z: D6Motion::Free,
463            linear_limit_min: Vec3Fix::new(-Fix128::ONE, -Fix128::ONE, -Fix128::ONE),
464            linear_limit_max: Vec3Fix::new(Fix128::ONE, Fix128::ONE, Fix128::ONE),
465            angular_limit_min: Vec3Fix::new(-Fix128::PI, -Fix128::PI, -Fix128::PI),
466            angular_limit_max: Vec3Fix::new(Fix128::PI, Fix128::PI, Fix128::PI),
467            compliance: Fix128::ZERO,
468            angular_compliance: Fix128::ZERO,
469            break_force: None,
470        }
471    }
472
473    /// Set linear motion on all axes
474    #[must_use]
475    pub const fn with_linear_motion(mut self, x: D6Motion, y: D6Motion, z: D6Motion) -> Self {
476        self.linear_x = x;
477        self.linear_y = y;
478        self.linear_z = z;
479        self
480    }
481
482    /// Set angular motion on all axes
483    #[must_use]
484    pub const fn with_angular_motion(mut self, x: D6Motion, y: D6Motion, z: D6Motion) -> Self {
485        self.angular_x = x;
486        self.angular_y = y;
487        self.angular_z = z;
488        self
489    }
490
491    /// Set linear limits
492    ///
493    /// # Panics
494    ///
495    /// When any axis has `min > max`. `min == max` is allowed (a locked
496    /// axis).
497    #[must_use]
498    pub const fn with_linear_limits(mut self, min: Vec3Fix, max: Vec3Fix) -> Self {
499        if fix128_min_max_violated(min.x, max.x)
500            || fix128_min_max_violated(min.y, max.y)
501            || fix128_min_max_violated(min.z, max.z)
502        {
503            panic!("D6Joint::with_linear_limits: min must not exceed max on any axis");
504        }
505        self.linear_limit_min = min;
506        self.linear_limit_max = max;
507        self
508    }
509
510    /// Set angular limits
511    ///
512    /// # Panics
513    ///
514    /// When any axis has `min > max`. `min == max` is allowed (a locked
515    /// axis).
516    #[must_use]
517    pub const fn with_angular_limits(mut self, min: Vec3Fix, max: Vec3Fix) -> Self {
518        if fix128_min_max_violated(min.x, max.x)
519            || fix128_min_max_violated(min.y, max.y)
520            || fix128_min_max_violated(min.z, max.z)
521        {
522            panic!("D6Joint::with_angular_limits: min must not exceed max on any axis");
523        }
524        self.angular_limit_min = min;
525        self.angular_limit_max = max;
526        self
527    }
528
529    /// Set break force threshold
530    #[must_use]
531    pub const fn with_break_force(mut self, force: Fix128) -> Self {
532        self.break_force = Some(force);
533        self
534    }
535}
536
537/// Cone-Twist joint (ball joint with cone angle limit + twist limit)
538///
539/// Used for ragdoll shoulders and hips where rotation is constrained
540/// to a cone around the twist axis, with an additional twist limit.
541#[derive(Clone, Copy, Debug, PartialEq, Eq)]
542pub struct ConeTwistJoint {
543    /// Index of the first body
544    pub body_a: usize,
545    /// Index of the second body
546    pub body_b: usize,
547    /// Anchor point in body A's local space
548    pub local_anchor_a: Vec3Fix,
549    /// Anchor point in body B's local space
550    pub local_anchor_b: Vec3Fix,
551    /// Twist axis in body A's local space
552    pub twist_axis_a: Vec3Fix,
553    /// Twist axis in body B's local space
554    pub twist_axis_b: Vec3Fix,
555    /// Maximum cone angle (radians, half-angle)
556    pub cone_limit: Fix128,
557    /// Maximum twist angle (radians)
558    pub twist_limit: Fix128,
559    /// Positional compliance
560    pub compliance: Fix128,
561    /// Angular compliance
562    pub angular_compliance: Fix128,
563    /// Maximum force before the joint breaks (None = unbreakable)
564    pub break_force: Option<Fix128>,
565}
566
567impl ConeTwistJoint {
568    /// Create a new cone-twist joint
569    #[must_use]
570    pub const fn new(
571        body_a: usize,
572        body_b: usize,
573        anchor_a: Vec3Fix,
574        anchor_b: Vec3Fix,
575        twist_axis_a: Vec3Fix,
576        twist_axis_b: Vec3Fix,
577    ) -> Self {
578        Self {
579            body_a,
580            body_b,
581            local_anchor_a: anchor_a,
582            local_anchor_b: anchor_b,
583            twist_axis_a,
584            twist_axis_b,
585            cone_limit: Fix128::HALF_PI,
586            twist_limit: Fix128::PI,
587            compliance: Fix128::ZERO,
588            angular_compliance: Fix128::ZERO,
589            break_force: None,
590        }
591    }
592
593    /// Set cone and twist limits
594    ///
595    /// # Panics
596    ///
597    /// When `cone` or `twist` is negative. `0` is allowed (no rotation on
598    /// that axis).
599    #[must_use]
600    pub const fn with_limits(mut self, cone: Fix128, twist: Fix128) -> Self {
601        if cone.is_negative() {
602            panic!("ConeTwistJoint::with_limits: cone must not be negative");
603        }
604        if twist.is_negative() {
605            panic!("ConeTwistJoint::with_limits: twist must not be negative");
606        }
607        self.cone_limit = cone;
608        self.twist_limit = twist;
609        self
610    }
611
612    /// Set break force threshold
613    #[must_use]
614    pub const fn with_break_force(mut self, force: Fix128) -> Self {
615        self.break_force = Some(force);
616        self
617    }
618}
619
620/// Unified joint enum for storage in `PhysicsWorld`
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622pub enum Joint {
623    /// Ball-and-socket joint
624    Ball(BallJoint),
625    /// Hinge joint
626    Hinge(HingeJoint),
627    /// Fixed / weld joint
628    Fixed(FixedJoint),
629    /// Slider / prismatic joint
630    Slider(SliderJoint),
631    /// Spring joint
632    Spring(SpringJoint),
633    /// 6-DOF configurable joint
634    D6(D6Joint),
635    /// Cone-twist joint (ragdoll)
636    ConeTwist(ConeTwistJoint),
637}
638
639impl Joint {
640    /// Get body indices for this joint
641    #[inline]
642    #[must_use]
643    pub const fn bodies(&self) -> (usize, usize) {
644        match self {
645            Self::Ball(j) => (j.body_a, j.body_b),
646            Self::Hinge(j) => (j.body_a, j.body_b),
647            Self::Fixed(j) => (j.body_a, j.body_b),
648            Self::Slider(j) => (j.body_a, j.body_b),
649            Self::Spring(j) => (j.body_a, j.body_b),
650            Self::D6(j) => (j.body_a, j.body_b),
651            Self::ConeTwist(j) => (j.body_a, j.body_b),
652        }
653    }
654
655    /// Get joint type
656    #[inline]
657    #[must_use]
658    pub const fn joint_type(&self) -> JointType {
659        match self {
660            Self::Ball(_) => JointType::Ball,
661            Self::Hinge(_) => JointType::Hinge,
662            Self::Fixed(_) => JointType::Fixed,
663            Self::Slider(_) => JointType::Slider,
664            Self::Spring(_) => JointType::Spring,
665            Self::D6(_) => JointType::D6,
666            Self::ConeTwist(_) => JointType::ConeTwist,
667        }
668    }
669
670    /// Get the break force threshold (None = unbreakable)
671    #[inline]
672    #[must_use]
673    pub const fn break_force(&self) -> Option<Fix128> {
674        match self {
675            Self::Ball(j) => j.break_force,
676            Self::Hinge(j) => j.break_force,
677            Self::Fixed(j) => j.break_force,
678            Self::Slider(j) => j.break_force,
679            Self::Spring(j) => j.break_force,
680            Self::D6(j) => j.break_force,
681            Self::ConeTwist(j) => j.break_force,
682        }
683    }
684
685    /// Current anchor separation of the joint's positional row (metres).
686    ///
687    /// For the spring joint this is the spring force `k · |d − rest|` (N); for
688    /// every other joint it is a length, not a force: the gap between the two
689    /// anchors (the slider measures only the part off its axis). Breaking uses
690    /// the reaction force of the solve ([`solve_joints_with_reaction_forces`]),
691    /// which is a force for every joint.
692    #[must_use]
693    pub fn compute_force(&self, bodies: &[crate::solver::RigidBody]) -> Fix128 {
694        let (a_idx, b_idx) = self.bodies();
695        let body_a = &bodies[a_idx];
696        let body_b = &bodies[b_idx];
697
698        match self {
699            Self::Ball(j) => {
700                let anchor_a = body_a.position + body_a.rotation.rotate_vec(j.local_anchor_a);
701                let anchor_b = body_b.position + body_b.rotation.rotate_vec(j.local_anchor_b);
702                (anchor_b - anchor_a).length()
703            }
704            Self::Hinge(j) => {
705                let anchor_a = body_a.position + body_a.rotation.rotate_vec(j.local_anchor_a);
706                let anchor_b = body_b.position + body_b.rotation.rotate_vec(j.local_anchor_b);
707                (anchor_b - anchor_a).length()
708            }
709            Self::Fixed(j) => {
710                let anchor_a = body_a.position + body_a.rotation.rotate_vec(j.local_anchor_a);
711                let anchor_b = body_b.position + body_b.rotation.rotate_vec(j.local_anchor_b);
712                (anchor_b - anchor_a).length()
713            }
714            Self::Slider(j) => {
715                let anchor_a = body_a.position + body_a.rotation.rotate_vec(j.local_anchor_a);
716                let anchor_b = body_b.position + body_b.rotation.rotate_vec(j.local_anchor_b);
717                let delta = anchor_b - anchor_a;
718                let world_axis = body_a.rotation.rotate_vec(j.local_axis).normalize();
719                let along = delta.dot(world_axis);
720                let perp = delta - world_axis * along;
721                perp.length()
722            }
723            Self::Spring(j) => {
724                let anchor_a = body_a.position + body_a.rotation.rotate_vec(j.local_anchor_a);
725                let anchor_b = body_b.position + body_b.rotation.rotate_vec(j.local_anchor_b);
726                let dist = (anchor_b - anchor_a).length();
727                let displacement = dist - j.rest_length;
728                (j.stiffness * displacement).abs()
729            }
730            Self::D6(j) => {
731                let anchor_a = body_a.position + body_a.rotation.rotate_vec(j.local_anchor_a);
732                let anchor_b = body_b.position + body_b.rotation.rotate_vec(j.local_anchor_b);
733                (anchor_b - anchor_a).length()
734            }
735            Self::ConeTwist(j) => {
736                let anchor_a = body_a.position + body_a.rotation.rotate_vec(j.local_anchor_a);
737                let anchor_b = body_b.position + body_b.rotation.rotate_vec(j.local_anchor_b);
738                (anchor_b - anchor_a).length()
739            }
740        }
741    }
742}
743
744/// Solve all joints for one XPBD iteration
745///
746/// Modifies body positions/rotations in-place to satisfy constraints.
747///
748/// # Joint compliance contract
749///
750/// - One call is one XPBD solve of every joint starting from `λ = 0`
751///   (Macklin, Müller, Chentanez, MIG 2016, eq. 18): a compliant row moves by
752///   `Δλ = −C / (w + α̃)` with `α̃ = compliance / dt²`.
753/// - It is meant to be called once per substep, with `dt` the substep length.
754/// - Calling it several times at the same `dt` is not equivalent to a longer
755///   solve: no `λ` is carried between calls, so each call removes the fraction
756///   `w / (w + α̃)` of the gap again and leaves `d · (α̃ / (w + α̃))^k` after `k`
757///   calls. The effective stiffness therefore grows with the number of calls.
758pub fn solve_joints(joints: &[Joint], bodies: &mut [crate::solver::RigidBody], dt: Fix128) {
759    for joint in joints {
760        let _ = solve_one(joint, bodies, dt);
761    }
762}
763
764/// Solve one joint. A joint whose two bodies are the same body constrains
765/// nothing a motion could satisfy (a body cannot move relative to itself), so
766/// it is skipped and the body stays a free body; solving it would apply both
767/// sides' corrections to that one body and inject motion.
768/// Solve one joint and return the linear impulse of its positional rows,
769/// `Σ λ · n` (Macklin, Müller, Chentanez, MIG 2016: the row multiplier `λ`
770/// along its unit direction `n`; body A moved by `+w_a λ n`, body B by
771/// `−w_b λ n`). Angular rows are not included.
772fn solve_one(joint: &Joint, bodies: &mut [crate::solver::RigidBody], dt: Fix128) -> Vec3Fix {
773    let (a, b) = joint.bodies();
774    if a == b {
775        return Vec3Fix::ZERO;
776    }
777    match joint {
778        Joint::Ball(j) => solve_ball_joint(j, bodies, dt),
779        Joint::Hinge(j) => solve_hinge_joint(j, bodies, dt),
780        Joint::Fixed(j) => solve_fixed_joint(j, bodies, dt),
781        Joint::Slider(j) => solve_slider_joint(j, bodies, dt),
782        Joint::Spring(j) => solve_spring_joint(j, bodies, dt),
783        Joint::D6(j) => solve_d6_joint(j, bodies, dt),
784        Joint::ConeTwist(j) => solve_cone_twist_joint(j, bodies, dt),
785    }
786}
787
788/// Solve joints and return indices of joints that should be removed (broken),
789/// comparing each joint's **anchor separation** with its `break_force`.
790///
791/// Deprecated: the threshold here is the length [`Joint::compute_force`]
792/// returns (metres, `k · x` for a spring), not a force, although
793/// `break_force` is documented as a force. Use
794/// [`solve_joints_breaking_on_force`], which compares the reaction force in
795/// newtons, as [`crate::solver::PhysicsWorld::step`] does.
796///
797/// Checks each breakable joint's separation BEFORE solving.
798/// If the force exceeds the threshold, the joint is marked as broken
799/// and skipped during solving. Returns indices of broken joints in
800/// descending order (safe for sequential removal).
801///
802/// # Joint compliance contract
803///
804/// - One call is one XPBD solve of every joint starting from `λ = 0`
805///   (Macklin, Müller, Chentanez, MIG 2016, eq. 18): a compliant row moves by
806///   `Δλ = −C / (w + α̃)` with `α̃ = compliance / dt²`.
807/// - It is meant to be called once per substep, with `dt` the substep length.
808/// - Calling it several times at the same `dt` is not equivalent to a longer
809///   solve: no `λ` is carried between calls, so each call removes the fraction
810///   `w / (w + α̃)` of the gap again and leaves `d · (α̃ / (w + α̃))^k` after `k`
811///   calls. The effective stiffness therefore grows with the number of calls.
812#[deprecated(
813    since = "2.2.0",
814    note = "the threshold is the anchor separation (m), not a force; use solve_joints_breaking_on_force, whose break_force is the reaction force λ/dt² in N"
815)]
816pub fn solve_joints_breakable(
817    joints: &[Joint],
818    bodies: &mut [crate::solver::RigidBody],
819    dt: Fix128,
820) -> Vec<usize> {
821    // Check which joints exceeded their break force BEFORE solving
822    let mut broken = Vec::new();
823    for (i, joint) in joints.iter().enumerate() {
824        let (a, b) = joint.bodies();
825        if a == b {
826            // a joint on one body constrains nothing, so it carries no force
827            continue;
828        }
829        if let Some(max_force) = joint.break_force() {
830            let force = joint.compute_force(bodies);
831            if force > max_force {
832                broken.push(i);
833            }
834        }
835    }
836
837    // Sort for binary search lookup
838    broken.sort_unstable();
839
840    // Solve only non-broken joints
841    for (i, joint) in joints.iter().enumerate() {
842        if broken.binary_search(&i).is_ok() {
843            continue;
844        }
845        let _ = solve_one(joint, bodies, dt);
846    }
847
848    // Sort descending for safe removal
849    broken.sort_unstable_by(|a, b| b.cmp(a));
850    broken
851}
852
853/// Solve every joint once (as [`solve_joints`]) and return each joint's
854/// reaction force in newtons, in joint order.
855///
856/// The force of a joint is the length of the linear impulse its positional
857/// rows applied in this solve divided by `dt²`: `|Σ λ · n| / dt²`, where `λ` is
858/// the XPBD multiplier of a row along its unit direction `n` (Macklin, Müller,
859/// Chentanez, MIG 2016, eq. 10 and 18). It is the force the solve actually
860/// transmitted, so a joint further down a chain carries the load passed to it
861/// through the joints above in the same sweep. A body of mass `m` hung at rest
862/// from a static anchor by a rigid ball joint reports `m · g` per substep.
863///
864/// - Angular rows (hinge axis, fixed orientation, cone and twist limits, D6
865///   angular axes) produce torques and are not counted.
866/// - A joint on one body, or between two bodies that cannot move, applies
867///   nothing and reports 0.
868/// - `dt` is the substep length the joints are solved at.
869/// - The force is what the solve actually transmitted, transient included:
870///   joints are solved once per substep in one Gauss–Seidel sweep, so a chain
871///   starting from rest overshoots its static load before settling (2 kg +
872///   2 kg hung from a static anchor, `g = 8`: lower joint 8, 16, 20, 20, 18,
873///   16 N, upper 16, 32, 40, 40, 36, 32 N, the same on both backends and for
874///   any `substeps` / `iterations`). A `break_force` between the static load
875///   and that peak breaks during the start-up; set it above the peak.
876// LIMITATION(COV-RIGID-032): angular rows produce torques and are not reported; only the positional reaction force is.
877pub fn solve_joints_with_reaction_forces(
878    joints: &[Joint],
879    bodies: &mut [crate::solver::RigidBody],
880    dt: Fix128,
881) -> Vec<Fix128> {
882    let h2 = dt * dt;
883    joints
884        .iter()
885        .map(|joint| {
886            let impulse = solve_one(joint, bodies, dt);
887            if h2.is_zero() {
888                Fix128::ZERO
889            } else {
890                impulse.length() / h2
891            }
892        })
893        .collect()
894}
895
896/// Solve joints once and return the indices of those whose reaction force
897/// exceeded their `break_force` (N), in descending order (safe for sequential
898/// `swap_remove`).
899///
900/// Every joint is solved first ([`solve_joints_with_reaction_forces`]); a
901/// joint is broken when its reaction force from that solve is strictly
902/// greater than its `break_force`. The broken joint's correction from this
903/// solve has been applied: the caller removes it so that it no longer acts on
904/// later solves. This is the check [`crate::solver::PhysicsWorld::step`]
905/// applies to the world's joints after each substep's joint solve.
906pub fn solve_joints_breaking_on_force(
907    joints: &[Joint],
908    bodies: &mut [crate::solver::RigidBody],
909    dt: Fix128,
910) -> Vec<usize> {
911    let forces = solve_joints_with_reaction_forces(joints, bodies, dt);
912    let mut broken: Vec<usize> = joints
913        .iter()
914        .zip(&forces)
915        .enumerate()
916        .filter(|(_, (joint, force))| joint.break_force().is_some_and(|max| **force > max))
917        .map(|(i, _)| i)
918        .collect();
919    broken.reverse();
920    broken
921}
922
923/// Solve ball joint: constrain anchor points to coincide
924fn solve_ball_joint(
925    joint: &BallJoint,
926    bodies: &mut [crate::solver::RigidBody],
927    dt: Fix128,
928) -> Vec3Fix {
929    let mut impulse = Vec3Fix::ZERO;
930    let body_a = bodies[joint.body_a];
931    let body_b = bodies[joint.body_b];
932
933    // World-space anchor positions and lever arms (COM → anchor)
934    let r_a = body_a.rotation.rotate_vec(joint.local_anchor_a);
935    let r_b = body_b.rotation.rotate_vec(joint.local_anchor_b);
936    let anchor_a = body_a.position + r_a;
937    let anchor_b = body_b.position + r_b;
938
939    let delta = anchor_b - anchor_a;
940    let (normal, distance) = delta.normalize_with_length();
941
942    if distance.is_zero() {
943        return impulse;
944    }
945
946    let compliance_term = joint.compliance / (dt * dt);
947    let w_sum = point_w_sum(bodies, joint.body_a, joint.body_b, r_a, r_b, normal) + compliance_term;
948
949    if w_sum.is_zero() {
950        return impulse;
951    }
952
953    let lambda = distance / w_sum;
954    apply_point_correction(bodies, joint.body_a, joint.body_b, r_a, r_b, normal, lambda);
955    impulse = impulse + normal * lambda;
956    impulse
957}
958
959/// Solve hinge joint: positional + angular constraint along axis
960///
961/// # Claims
962/// - `angle_min` and `angle_max` are enforced independently: `None` leaves that side unbounded
963/// - A violated side moves the relative twist angle (radians) back onto the bound, split by inverse inertia
964/// - With both `None` no angle limit is applied
965fn solve_hinge_joint(
966    joint: &HingeJoint,
967    bodies: &mut [crate::solver::RigidBody],
968    dt: Fix128,
969) -> Vec3Fix {
970    let mut impulse = Vec3Fix::ZERO;
971    let body_a = bodies[joint.body_a];
972    let body_b = bodies[joint.body_b];
973
974    // 1. Positional constraint (same as ball joint)
975    let r_a = body_a.rotation.rotate_vec(joint.local_anchor_a);
976    let r_b = body_b.rotation.rotate_vec(joint.local_anchor_b);
977    let anchor_a = body_a.position + r_a;
978    let anchor_b = body_b.position + r_b;
979
980    let delta = anchor_b - anchor_a;
981    let (normal, distance) = delta.normalize_with_length();
982
983    if !distance.is_zero() {
984        let compliance_term = joint.compliance / (dt * dt);
985        let w_sum =
986            point_w_sum(bodies, joint.body_a, joint.body_b, r_a, r_b, normal) + compliance_term;
987
988        if !w_sum.is_zero() {
989            let lambda = distance / w_sum;
990            apply_point_correction(bodies, joint.body_a, joint.body_b, r_a, r_b, normal, lambda);
991            impulse = impulse + normal * lambda;
992        }
993    }
994
995    // 2. Angular constraint: align axes
996    let body_a = bodies[joint.body_a];
997    let body_b = bodies[joint.body_b];
998    let world_axis_a = body_a.rotation.rotate_vec(joint.local_axis_a);
999    let world_axis_b = body_b.rotation.rotate_vec(joint.local_axis_b);
1000
1001    let axis_error = world_axis_a.cross(world_axis_b);
1002    let (correction_axis, sin_err) = axis_error.normalize_with_length();
1003
1004    if !sin_err.is_zero() {
1005        // true misalignment angle, not its sine (exact in one rigid solve)
1006        let error_mag = Fix128::atan2(sin_err, world_axis_a.dot(world_axis_b));
1007        let angular_compliance = joint.angular_compliance / (dt * dt);
1008        let w_ang =
1009            angular_w_sum(bodies, joint.body_a, joint.body_b, correction_axis) + angular_compliance;
1010
1011        if !w_ang.is_zero() {
1012            let inv_w_ang = Fix128::ONE / w_ang;
1013            let angular_lambda = error_mag * inv_w_ang;
1014
1015            apply_angular_correction(
1016                bodies,
1017                joint.body_a,
1018                joint.body_b,
1019                correction_axis,
1020                angular_lambda,
1021            );
1022        }
1023    }
1024
1025    // 3. Angle limits (rigid: no angular compliance, a limit is a stop)
1026    if joint.angle_min.is_some() || joint.angle_max.is_some() {
1027        let body_a = bodies[joint.body_a];
1028        let body_b = bodies[joint.body_b];
1029        let world_axis_a = body_a.rotation.rotate_vec(joint.local_axis_a);
1030
1031        // Compute relative angle around hinge axis
1032        let rel_quat = body_b.rotation.mul(body_a.rotation.conjugate());
1033        let angle = compute_twist_angle(rel_quat, world_axis_a);
1034
1035        if let Some(min_angle) = joint.angle_min.filter(|m| angle < *m) {
1036            let error = min_angle - angle;
1037            let w_ang = angular_w_sum(bodies, joint.body_a, joint.body_b, world_axis_a);
1038            if !w_ang.is_zero() {
1039                let inv_w_ang = Fix128::ONE / w_ang;
1040                apply_angular_correction(
1041                    bodies,
1042                    joint.body_a,
1043                    joint.body_b,
1044                    world_axis_a,
1045                    -(error * inv_w_ang),
1046                );
1047            }
1048        } else if let Some(max_angle) = joint.angle_max.filter(|m| angle > *m) {
1049            let error = angle - max_angle;
1050            let w_ang = angular_w_sum(bodies, joint.body_a, joint.body_b, world_axis_a);
1051            if !w_ang.is_zero() {
1052                let inv_w_ang = Fix128::ONE / w_ang;
1053                apply_angular_correction(
1054                    bodies,
1055                    joint.body_a,
1056                    joint.body_b,
1057                    world_axis_a,
1058                    error * inv_w_ang,
1059                );
1060            }
1061        }
1062    }
1063    impulse
1064}
1065
1066/// Solve fixed joint: positional + full rotational lock
1067fn solve_fixed_joint(
1068    joint: &FixedJoint,
1069    bodies: &mut [crate::solver::RigidBody],
1070    dt: Fix128,
1071) -> Vec3Fix {
1072    let mut impulse = Vec3Fix::ZERO;
1073    let body_a = bodies[joint.body_a];
1074    let body_b = bodies[joint.body_b];
1075
1076    // 1. Positional constraint
1077    let r_a = body_a.rotation.rotate_vec(joint.local_anchor_a);
1078    let r_b = body_b.rotation.rotate_vec(joint.local_anchor_b);
1079    let anchor_a = body_a.position + r_a;
1080    let anchor_b = body_b.position + r_b;
1081
1082    let delta = anchor_b - anchor_a;
1083    let (normal, distance) = delta.normalize_with_length();
1084
1085    if !distance.is_zero() {
1086        let compliance_term = joint.compliance / (dt * dt);
1087        let w_sum =
1088            point_w_sum(bodies, joint.body_a, joint.body_b, r_a, r_b, normal) + compliance_term;
1089
1090        if !w_sum.is_zero() {
1091            let lambda = distance / w_sum;
1092            apply_point_correction(bodies, joint.body_a, joint.body_b, r_a, r_b, normal, lambda);
1093            impulse = impulse + normal * lambda;
1094        }
1095    }
1096
1097    // 2. Rotational constraint: maintain relative rotation
1098    let body_a = bodies[joint.body_a];
1099    let body_b = bodies[joint.body_b];
1100    let target_rot_b = body_a.rotation.mul(joint.relative_rotation);
1101    let rot_error = body_b.rotation.mul(target_rot_b.conjugate());
1102
1103    // Extract error as rotation vector (axis * angle)
1104    let error_vec = Vec3Fix::new(rot_error.x, rot_error.y, rot_error.z);
1105    let (correction_axis, sin_half) = error_vec.normalize_with_length();
1106
1107    if !sin_half.is_zero() {
1108        // rotation angle of the error quaternion (shortest arc: flip to w ≥ 0)
1109        let (sin_half, cos_half) = if rot_error.w.is_negative() {
1110            (-sin_half, -rot_error.w)
1111        } else {
1112            (sin_half, rot_error.w)
1113        };
1114        let error_mag = Fix128::atan2(sin_half, cos_half).double();
1115        let angular_compliance = joint.angular_compliance / (dt * dt);
1116        let w_ang =
1117            angular_w_sum(bodies, joint.body_a, joint.body_b, correction_axis) + angular_compliance;
1118
1119        if !w_ang.is_zero() {
1120            let inv_w_ang = Fix128::ONE / w_ang;
1121            let angular_lambda = error_mag * inv_w_ang;
1122
1123            apply_angular_correction(
1124                bodies,
1125                joint.body_a,
1126                joint.body_b,
1127                correction_axis,
1128                angular_lambda,
1129            );
1130        }
1131    }
1132    impulse
1133}
1134
1135/// Solve slider joint: constrain to 1-DOF translation along axis
1136///
1137/// # Claims
1138/// - `limit_min` and `limit_max` are enforced independently: `None` leaves that side unbounded
1139/// - A violated side moves the axial offset (metres) back onto the bound, split by inverse mass
1140/// - With both `None` no translation limit is applied
1141fn solve_slider_joint(
1142    joint: &SliderJoint,
1143    bodies: &mut [crate::solver::RigidBody],
1144    dt: Fix128,
1145) -> Vec3Fix {
1146    let mut impulse = Vec3Fix::ZERO;
1147    let body_a = bodies[joint.body_a];
1148    let body_b = bodies[joint.body_b];
1149
1150    let world_axis = body_a.rotation.rotate_vec(joint.local_axis).normalize();
1151
1152    let r_a = body_a.rotation.rotate_vec(joint.local_anchor_a);
1153    let r_b = body_b.rotation.rotate_vec(joint.local_anchor_b);
1154    let anchor_a = body_a.position + r_a;
1155    let anchor_b = body_b.position + r_b;
1156
1157    let delta = anchor_b - anchor_a;
1158
1159    // Project delta onto axis
1160    let along_axis = delta.dot(world_axis);
1161
1162    // Perpendicular error (must be zero)
1163    let perp = delta - world_axis * along_axis;
1164    let (perp_normal, perp_dist) = perp.normalize_with_length();
1165
1166    if !perp_dist.is_zero() {
1167        let compliance_term = joint.compliance / (dt * dt);
1168        let w_sum = point_w_sum(bodies, joint.body_a, joint.body_b, r_a, r_b, perp_normal)
1169            + compliance_term;
1170
1171        if !w_sum.is_zero() {
1172            let lambda = perp_dist / w_sum;
1173            apply_point_correction(
1174                bodies,
1175                joint.body_a,
1176                joint.body_b,
1177                r_a,
1178                r_b,
1179                perp_normal,
1180                lambda,
1181            );
1182            impulse = impulse + perp_normal * lambda;
1183        }
1184    }
1185
1186    // Enforce translation limits
1187    if joint.limit_min.is_some() || joint.limit_max.is_some() {
1188        if let Some(min_d) = joint.limit_min.filter(|m| along_axis < *m) {
1189            let error = min_d - along_axis;
1190            let w_sum = body_a.inv_mass + body_b.inv_mass;
1191            if !w_sum.is_zero() {
1192                let inv_w_sum = Fix128::ONE / w_sum;
1193                let correction = world_axis * (error * inv_w_sum);
1194                // A moves by −correction, the point-row convention with λ = −error / w
1195                impulse = impulse - correction;
1196                if !body_a.inv_mass.is_zero() {
1197                    bodies[joint.body_a].position =
1198                        bodies[joint.body_a].position - correction * body_a.inv_mass;
1199                }
1200                if !body_b.inv_mass.is_zero() {
1201                    bodies[joint.body_b].position =
1202                        bodies[joint.body_b].position + correction * body_b.inv_mass;
1203                }
1204            }
1205        } else if let Some(max_d) = joint.limit_max.filter(|m| along_axis > *m) {
1206            let error = along_axis - max_d;
1207            let w_sum = body_a.inv_mass + body_b.inv_mass;
1208            if !w_sum.is_zero() {
1209                let inv_w_sum = Fix128::ONE / w_sum;
1210                let correction = world_axis * (error * inv_w_sum);
1211                impulse = impulse + correction;
1212                if !body_a.inv_mass.is_zero() {
1213                    bodies[joint.body_a].position =
1214                        bodies[joint.body_a].position + correction * body_a.inv_mass;
1215                }
1216                if !body_b.inv_mass.is_zero() {
1217                    bodies[joint.body_b].position =
1218                        bodies[joint.body_b].position - correction * body_b.inv_mass;
1219                }
1220            }
1221        }
1222    }
1223    impulse
1224}
1225
1226/// Solve spring joint: XPBD with damping (Macklin, Müller, Chentanez 2016,
1227/// eq. 26), one solve per substep starting from `λ = 0`
1228///
1229/// With `h = dt`, `α̃ = 1/(k h²)` and `γ = c/(k h)`, a constraint row with
1230/// gradient `n`, error `C` and displacement `∇C · (x − x_n) = h (u · n)` moves
1231/// by `Δλ = −(C + γ h (u · n)) / ((1 + γ) w + α̃)`. Multiplied through by
1232/// `k h²`, so that `k = 0` (a pure damper) needs no division:
1233///
1234/// `λ = h² (k C + c (u · n)) / (1 + (k h² + c h) w)`
1235///
1236/// with `w` the generalised inverse mass along `n` (lever arms included,
1237/// see `point_w_sum`). Body A moves by `+w_a λ n`, body B by `−w_b λ n`.
1238///
1239/// - `rest_length == 0`: the three rows `C = d` (gradient `I`) are solved as
1240///   one row along `n = (k d + c u) / |k d + c u|`, exact when the anchors
1241///   are on the centres of mass (the inverse mass is isotropic). This is
1242///   the vector form of the force `−k d − c u`.
1243/// - `rest_length > 0`: the single row `C = |d| − L`, `n = d / |d|`.
1244///
1245/// `u` is the relative anchor velocity `(v_b + ω_b × r_b) − (v_a + ω_a × r_a)`
1246/// of the substep: the predicted velocity in the XPBD substep and the
1247/// velocity the TGS substep advanced the positions with, so that
1248/// `h u = x − x_n` in both. The velocity update that follows the solve
1249/// (`v = (x − x_n) / h`, or the TGS carry `v += Δx / h`) then makes one
1250/// substep the backward Euler step of `m x'' = F`. For a single body of
1251/// mass `m` against a static anchor, with `a = ω h`, `k = m ω²` and
1252/// `c = 2 ζ m ω`, the map is `x' = (x (1 + 2ζa) + h v) / (1 + 2ζa + a²)`.
1253///
1254/// # Claims
1255/// - The effective stiffness is `k` [N/m] and the damping `c` [N·s/m], for any
1256///   `dt`; a hanging body settles at the static extension `m g / k` exactly
1257/// - `rest_length = 0`: the damper acts on every component of the relative
1258///   velocity, so tangential motion decays like radial motion (no orbit)
1259/// - `rest_length > 0`: the damper acts along the spring only
1260/// - Two static bodies, or a coincident anchor pair at rest with
1261///   `rest_length > 0` (no direction), leave both bodies unchanged
1262/// - Unconditionally stable and free of overshoot for `ζ >= 1`
1263fn solve_spring_joint(
1264    joint: &SpringJoint,
1265    bodies: &mut [crate::solver::RigidBody],
1266    dt: Fix128,
1267) -> Vec3Fix {
1268    let mut impulse = Vec3Fix::ZERO;
1269    let body_a = bodies[joint.body_a];
1270    let body_b = bodies[joint.body_b];
1271    if body_a.inv_mass.is_zero() && body_b.inv_mass.is_zero() {
1272        return impulse;
1273    }
1274
1275    // World-space lever arms (COM → anchor) and anchors
1276    let r_a = body_a.rotation.rotate_vec(joint.local_anchor_a);
1277    let r_b = body_b.rotation.rotate_vec(joint.local_anchor_b);
1278    let delta = (body_b.position + r_b) - (body_a.position + r_a);
1279
1280    // Relative anchor velocity of the substep
1281    let vel_a = body_a.velocity + body_a.angular_velocity.cross(r_a);
1282    let vel_b = body_b.velocity + body_b.angular_velocity.cross(r_b);
1283    let rel_vel = vel_b - vel_a;
1284
1285    let k = joint.stiffness;
1286    let c = joint.damping;
1287    let h2 = dt * dt;
1288
1289    // `force` is the scalar `k C + c (u · n)` along `normal` (B is pulled
1290    // toward A for a positive value)
1291    let (normal, force) = if joint.rest_length.is_zero() {
1292        // Vector constraint C = d: the row along the force direction
1293        let (normal, force) = (delta * k + rel_vel * c).normalize_with_length();
1294        if force.is_zero() {
1295            return impulse;
1296        }
1297        (normal, force)
1298    } else {
1299        let (normal, distance) = delta.normalize_with_length();
1300        if distance.is_zero() {
1301            return impulse;
1302        }
1303        let displacement = distance - joint.rest_length;
1304        (normal, k * displacement + c * rel_vel.dot(normal))
1305    };
1306
1307    let w = point_w_sum(bodies, joint.body_a, joint.body_b, r_a, r_b, normal);
1308    let denom = Fix128::ONE + (k * h2 + c * dt) * w;
1309    if denom.is_zero() {
1310        return impulse;
1311    }
1312    let lambda = force * h2 / denom;
1313    apply_point_correction(bodies, joint.body_a, joint.body_b, r_a, r_b, normal, lambda);
1314    impulse = impulse + normal * lambda;
1315    impulse
1316}
1317
1318/// Solve D6 joint: per-axis locking/limiting for all 6 DOF
1319#[allow(clippy::too_many_lines)]
1320fn solve_d6_joint(joint: &D6Joint, bodies: &mut [crate::solver::RigidBody], dt: Fix128) -> Vec3Fix {
1321    let mut impulse = Vec3Fix::ZERO;
1322    let body_a = bodies[joint.body_a];
1323    let body_b = bodies[joint.body_b];
1324
1325    // World-space anchors (position gap used for `proj` below is computed
1326    // once up front and reused across the sequential per-axis corrections,
1327    // same pre-existing Gauss-Seidel approximation as before this fix)
1328    let anchor_a = body_a.position + body_a.rotation.rotate_vec(joint.local_anchor_a);
1329    let anchor_b = body_b.position + body_b.rotation.rotate_vec(joint.local_anchor_b);
1330    let delta = anchor_b - anchor_a;
1331
1332    // Get local frame axes in world space
1333    let frame_a = body_a.rotation.mul(joint.local_frame_a);
1334    let axis_x = frame_a.rotate_vec(Vec3Fix::UNIT_X);
1335    let axis_y = frame_a.rotate_vec(Vec3Fix::UNIT_Y);
1336    let axis_z = frame_a.rotate_vec(Vec3Fix::UNIT_Z);
1337
1338    let compliance_term = joint.compliance / (dt * dt);
1339
1340    // Linear constraints per axis
1341    let axes = [
1342        (
1343            joint.linear_x,
1344            axis_x,
1345            joint.linear_limit_min.x,
1346            joint.linear_limit_max.x,
1347        ),
1348        (
1349            joint.linear_y,
1350            axis_y,
1351            joint.linear_limit_min.y,
1352            joint.linear_limit_max.y,
1353        ),
1354        (
1355            joint.linear_z,
1356            axis_z,
1357            joint.linear_limit_min.z,
1358            joint.linear_limit_max.z,
1359        ),
1360    ];
1361
1362    for &(motion, axis, limit_min, limit_max) in &axes {
1363        let proj = delta.dot(axis);
1364        let error = match motion {
1365            D6Motion::Locked => proj,
1366            D6Motion::Limited => {
1367                if proj < limit_min {
1368                    proj - limit_min
1369                } else if proj > limit_max {
1370                    proj - limit_max
1371                } else {
1372                    Fix128::ZERO
1373                }
1374            }
1375            D6Motion::Free => Fix128::ZERO,
1376        };
1377
1378        if !error.is_zero() {
1379            // Lever arms are recomputed from each body's CURRENT rotation on every
1380            // iteration: an earlier axis in this same loop can rotate a body (the
1381            // lever-arm split this fix adds), and the arm is orientation-only, so a
1382            // value cached before the loop would go stale as soon as that happens.
1383            let r_a = bodies[joint.body_a]
1384                .rotation
1385                .rotate_vec(joint.local_anchor_a);
1386            let r_b = bodies[joint.body_b]
1387                .rotation
1388                .rotate_vec(joint.local_anchor_b);
1389            let w_sum =
1390                point_w_sum(bodies, joint.body_a, joint.body_b, r_a, r_b, axis) + compliance_term;
1391            if !w_sum.is_zero() {
1392                let lambda = error / w_sum;
1393                apply_point_correction(bodies, joint.body_a, joint.body_b, r_a, r_b, axis, lambda);
1394                impulse = impulse + axis * lambda;
1395            }
1396        }
1397    }
1398
1399    // Angular constraints per axis (w is axis-dependent: n · I⁻¹ n)
1400    //
1401    // Zero error is where each body's *frame* (body rotation composed with
1402    // its `local_frame_*`) lines up with the other's, not where the raw
1403    // body rotations coincide: `rel_quat` is frame_b_world ⊗ frame_a_world⁻¹
1404    // so a non-identity `local_frame_b` shifts the pose B is pulled toward.
1405    let angular_compliance = joint.angular_compliance / (dt * dt);
1406    {
1407        let frame_a_now = bodies[joint.body_a].rotation.mul(joint.local_frame_a);
1408        let frame_b_now = bodies[joint.body_b].rotation.mul(joint.local_frame_b);
1409        let rel_quat = frame_b_now.mul(frame_a_now.conjugate());
1410
1411        let ang_axes = [
1412            (
1413                joint.angular_x,
1414                axis_x,
1415                joint.angular_limit_min.x,
1416                joint.angular_limit_max.x,
1417            ),
1418            (
1419                joint.angular_y,
1420                axis_y,
1421                joint.angular_limit_min.y,
1422                joint.angular_limit_max.y,
1423            ),
1424            (
1425                joint.angular_z,
1426                axis_z,
1427                joint.angular_limit_min.z,
1428                joint.angular_limit_max.z,
1429            ),
1430        ];
1431
1432        for &(motion, axis, limit_min, limit_max) in &ang_axes {
1433            let angle = compute_twist_angle(rel_quat, axis);
1434
1435            let error = match motion {
1436                D6Motion::Locked => angle,
1437                D6Motion::Limited => {
1438                    if angle < limit_min {
1439                        angle - limit_min
1440                    } else if angle > limit_max {
1441                        angle - limit_max
1442                    } else {
1443                        Fix128::ZERO
1444                    }
1445                }
1446                D6Motion::Free => Fix128::ZERO,
1447            };
1448
1449            if !error.is_zero() {
1450                let w_ang =
1451                    angular_w_sum(bodies, joint.body_a, joint.body_b, axis) + angular_compliance;
1452                if !w_ang.is_zero() {
1453                    apply_angular_correction(
1454                        bodies,
1455                        joint.body_a,
1456                        joint.body_b,
1457                        axis,
1458                        error / w_ang,
1459                    );
1460                }
1461            }
1462        }
1463    }
1464    impulse
1465}
1466
1467/// Solve cone-twist joint: positional + cone + twist constraints
1468fn solve_cone_twist_joint(
1469    joint: &ConeTwistJoint,
1470    bodies: &mut [crate::solver::RigidBody],
1471    dt: Fix128,
1472) -> Vec3Fix {
1473    let mut impulse = Vec3Fix::ZERO;
1474    let body_a = bodies[joint.body_a];
1475    let body_b = bodies[joint.body_b];
1476
1477    // 1. Positional constraint (same as ball joint)
1478    let r_a = body_a.rotation.rotate_vec(joint.local_anchor_a);
1479    let r_b = body_b.rotation.rotate_vec(joint.local_anchor_b);
1480    let anchor_a = body_a.position + r_a;
1481    let anchor_b = body_b.position + r_b;
1482    let delta = anchor_b - anchor_a;
1483    let (normal, distance) = delta.normalize_with_length();
1484
1485    if !distance.is_zero() {
1486        let compliance_term = joint.compliance / (dt * dt);
1487        let w_sum =
1488            point_w_sum(bodies, joint.body_a, joint.body_b, r_a, r_b, normal) + compliance_term;
1489
1490        if !w_sum.is_zero() {
1491            let lambda = distance / w_sum;
1492            apply_point_correction(bodies, joint.body_a, joint.body_b, r_a, r_b, normal, lambda);
1493            impulse = impulse + normal * lambda;
1494        }
1495    }
1496
1497    // 2. Cone constraint
1498    let body_a = bodies[joint.body_a];
1499    let body_b = bodies[joint.body_b];
1500    let world_axis_a = body_a.rotation.rotate_vec(joint.twist_axis_a).normalize();
1501    let world_axis_b = body_b.rotation.rotate_vec(joint.twist_axis_b).normalize();
1502
1503    let dot = world_axis_a.dot(world_axis_b);
1504    let cross = world_axis_a.cross(world_axis_b);
1505    let (correction_axis, cross_len) = cross.normalize_with_length();
1506    // Use atan2(sin, cos) instead of acos for deterministic fixed-point
1507    let cone_angle = Fix128::atan2(cross_len, dot);
1508    let cone_angle = if cone_angle < Fix128::ZERO {
1509        -cone_angle
1510    } else {
1511        cone_angle
1512    };
1513
1514    if cone_angle > joint.cone_limit {
1515        let error = cone_angle - joint.cone_limit;
1516
1517        if !cross_len.is_zero() {
1518            let angular_compliance = joint.angular_compliance / (dt * dt);
1519            let w_ang = angular_w_sum(bodies, joint.body_a, joint.body_b, correction_axis)
1520                + angular_compliance;
1521
1522            if !w_ang.is_zero() {
1523                let inv_w_ang = Fix128::ONE / w_ang;
1524                apply_angular_correction(
1525                    bodies,
1526                    joint.body_a,
1527                    joint.body_b,
1528                    correction_axis,
1529                    error * inv_w_ang,
1530                );
1531            }
1532        }
1533    }
1534
1535    // 3. Twist constraint
1536    let rel_quat = bodies[joint.body_b]
1537        .rotation
1538        .mul(bodies[joint.body_a].rotation.conjugate());
1539    let twist_angle = compute_twist_angle(rel_quat, world_axis_a);
1540
1541    if twist_angle.abs() > joint.twist_limit {
1542        let error = if twist_angle > Fix128::ZERO {
1543            twist_angle - joint.twist_limit
1544        } else {
1545            twist_angle + joint.twist_limit
1546        };
1547
1548        let angular_compliance = joint.angular_compliance / (dt * dt);
1549        let w_ang =
1550            angular_w_sum(bodies, joint.body_a, joint.body_b, world_axis_a) + angular_compliance;
1551
1552        if !w_ang.is_zero() {
1553            let inv_w_ang = Fix128::ONE / w_ang;
1554            apply_angular_correction(
1555                bodies,
1556                joint.body_a,
1557                joint.body_b,
1558                world_axis_a,
1559                error * inv_w_ang,
1560            );
1561        }
1562    }
1563    impulse
1564}
1565
1566/// Generalised inverse mass of a body for a rotation about the world axis
1567/// `axis`: `n · I⁻¹ n` with the diagonal inverse inertia expressed in the
1568/// body frame (Macklin et al. 2020 eq. 3). Zero for static bodies. An
1569/// isotropic inverse inertia uses `axis` itself (no rotation into the body
1570/// frame), so the value does not depend on the orientation, as in
1571/// [`crate::solver::inv_inertia_world_apply`].
1572fn angular_inverse_mass(body: &crate::solver::RigidBody, axis: Vec3Fix) -> Fix128 {
1573    if body.inv_mass.is_zero() {
1574        return Fix128::ZERO;
1575    }
1576    let local = if crate::solver::inv_inertia_is_isotropic(body.inv_inertia) {
1577        axis
1578    } else {
1579        body.rotation.conjugate().rotate_vec(axis)
1580    };
1581    local.x * local.x * body.inv_inertia.x
1582        + local.y * local.y * body.inv_inertia.y
1583        + local.z * local.z * body.inv_inertia.z
1584}
1585
1586/// Sum of the two bodies' angular inverse masses about `axis` (the `w` of
1587/// an XPBD angular constraint before the compliance term).
1588fn angular_w_sum(
1589    bodies: &[crate::solver::RigidBody],
1590    idx_a: usize,
1591    idx_b: usize,
1592    axis: Vec3Fix,
1593) -> Fix128 {
1594    angular_inverse_mass(&bodies[idx_a], axis) + angular_inverse_mass(&bodies[idx_b], axis)
1595}
1596
1597/// Apply the angular XPBD correction `λ` about the world axis `axis`: body
1598/// A rotates by `w_a λ`, body B by `−w_b λ` (`w_i = n · I_i⁻¹ n`), so the
1599/// *relative* rotation changes by `(w_a + w_b) λ` — exactly the error when
1600/// the caller used `λ = error / (w_a + w_b + α̃)`. Before 1.2.0 both bodies
1601/// received the full `λ` regardless of inertia and `w` was the vector
1602/// length of the inverse-inertia diagonal, so a unit-inertia hinge removed
1603/// only `1/√3` of its error per step.
1604fn apply_angular_correction(
1605    bodies: &mut [crate::solver::RigidBody],
1606    idx_a: usize,
1607    idx_b: usize,
1608    axis: Vec3Fix,
1609    lambda: Fix128,
1610) {
1611    let w_a = angular_inverse_mass(&bodies[idx_a], axis);
1612    let w_b = angular_inverse_mass(&bodies[idx_b], axis);
1613    if !w_a.is_zero() {
1614        bodies[idx_a].rotation = rotate_by_angle(bodies[idx_a].rotation, axis, w_a * lambda);
1615    }
1616    if !w_b.is_zero() {
1617        bodies[idx_b].rotation = rotate_by_angle(bodies[idx_b].rotation, axis, -(w_b * lambda));
1618    }
1619}
1620
1621/// `q ← normalize(axis_angle(axis, θ) ⊗ q)`: rotation by exactly `θ` about
1622/// the world axis (CORDIC `sin_cos`, deterministic), so a rigid constraint
1623/// whose error is a true angle is satisfied in a single solve. (The
1624/// first-order `(n θ/2, 1)` update used before 1.2.0 rotates by
1625/// `2·atan(θ/2)`, 2 % short at 0.3 rad.)
1626fn rotate_by_angle(q: QuatFix, axis: Vec3Fix, theta: Fix128) -> QuatFix {
1627    if theta.is_zero() {
1628        return q;
1629    }
1630    QuatFix::from_axis_angle(axis, theta).mul(q).normalize()
1631}
1632
1633/// Lever-arm term of the XPBD generalised inverse mass for a point
1634/// constraint corrected along `normal`: `(r × n)^T I⁻¹ (r × n)`, where `r`
1635/// is the world-space vector from the body's centre of mass to the anchor
1636/// point (Macklin, Müller & Chentanez 2016, "XPBD", §3.4). Zero for a
1637/// static body or when the anchor sits exactly on the centre of mass.
1638fn point_angular_w(body: &crate::solver::RigidBody, r: Vec3Fix, normal: Vec3Fix) -> Fix128 {
1639    angular_inverse_mass(body, r.cross(normal))
1640}
1641
1642/// Generalised inverse mass `w_a + w_b` of a point (anchor-to-anchor)
1643/// constraint corrected along `normal`: the plain `inv_mass` of each body
1644/// plus its lever-arm term `point_angular_w`. This is the `w` XPBD uses
1645/// before the compliance term is added (`λ = C / (w_a + w_b + α̃)`). `r_a`
1646/// / `r_b` are the world-space vectors from each body's centre of mass to
1647/// its anchor (`rotation.rotate_vec(local_anchor)`), taken at the *start*
1648/// of the joint's solve: the lever arm depends only on orientation, not on
1649/// position, so it stays correct even when the caller applies several
1650/// corrections to the same bodies before the orientation itself changes
1651/// (e.g. the D6 joint's per-axis linear loop).
1652fn point_w_sum(
1653    bodies: &[crate::solver::RigidBody],
1654    idx_a: usize,
1655    idx_b: usize,
1656    r_a: Vec3Fix,
1657    r_b: Vec3Fix,
1658    normal: Vec3Fix,
1659) -> Fix128 {
1660    bodies[idx_a].inv_mass
1661        + bodies[idx_b].inv_mass
1662        + point_angular_w(&bodies[idx_a], r_a, normal)
1663        + point_angular_w(&bodies[idx_b], r_b, normal)
1664}
1665
1666/// Apply one body's share of the rotation half of a point-constraint
1667/// correction: the angular displacement `I⁻¹ (r × n) · λ` decomposed into
1668/// an axis and an exact angle, applied with `rotate_by_angle`. `λ` carries
1669/// the sign (positive for body A, negative for body B, matching the
1670/// translation convention below) so a static or infinite-inertia body
1671/// (whose `world_inv_inertia_apply` is the zero vector) is left untouched.
1672fn apply_point_rotation(
1673    bodies: &mut [crate::solver::RigidBody],
1674    idx: usize,
1675    r: Vec3Fix,
1676    normal: Vec3Fix,
1677    lambda: Fix128,
1678) {
1679    let body = bodies[idx];
1680    if body.inv_mass.is_zero() {
1681        return;
1682    }
1683    let omega = body.world_inv_inertia_apply(r.cross(normal)) * lambda;
1684    let (axis, angle) = omega.normalize_with_length();
1685    if !angle.is_zero() {
1686        bodies[idx].rotation = rotate_by_angle(bodies[idx].rotation, axis, angle);
1687    }
1688}
1689
1690/// Apply the XPBD point-constraint correction `λ` along `normal` to both
1691/// bodies, splitting it between translation (`± inv_m · λ · n`, the
1692/// pre-1.3.0 behaviour) and rotation (`± I⁻¹ (r × n) · λ`) by the lever arm
1693/// `r_a` / `r_b` (anchor minus centre of mass, see `point_w_sum` for why
1694/// these are passed in rather than recomputed from position). An anchor on
1695/// the centre of mass (`r = 0`) reduces exactly to the translation-only
1696/// update every positional joint used before.
1697fn apply_point_correction(
1698    bodies: &mut [crate::solver::RigidBody],
1699    idx_a: usize,
1700    idx_b: usize,
1701    r_a: Vec3Fix,
1702    r_b: Vec3Fix,
1703    normal: Vec3Fix,
1704    lambda: Fix128,
1705) {
1706    let body_a = bodies[idx_a];
1707    let body_b = bodies[idx_b];
1708
1709    if !body_a.inv_mass.is_zero() {
1710        bodies[idx_a].position = body_a.position + normal * (lambda * body_a.inv_mass);
1711    }
1712    if !body_b.inv_mass.is_zero() {
1713        bodies[idx_b].position = body_b.position - normal * (lambda * body_b.inv_mass);
1714    }
1715    apply_point_rotation(bodies, idx_a, r_a, normal, lambda);
1716    apply_point_rotation(bodies, idx_b, r_b, normal, -lambda);
1717}
1718
1719/// Signed twist angle of `q` about `axis` in `(−π, π]`: the swing–twist
1720/// decomposition keeps the component of the rotation vector along `axis`,
1721/// `angle = 2 · atan2(q_xyz · axis, q_w)` after flipping `q` to the
1722/// `w ≥ 0` cover. Before 1.2.0 this returned `2 · atan2(|proj|, w) ≥ 0`, so
1723/// a rotation of −1 rad measured as +1 and every angle limit pushed
1724/// negative rotations the wrong way.
1725pub(crate) fn compute_twist_angle(q: QuatFix, axis: Vec3Fix) -> Fix128 {
1726    let qv = Vec3Fix::new(q.x, q.y, q.z);
1727    let s = qv.dot(axis);
1728    let proj = axis * s;
1729    let twist = QuatFix::new(proj.x, proj.y, proj.z, q.w).normalize();
1730    let signed = Vec3Fix::new(twist.x, twist.y, twist.z).dot(axis);
1731    let (signed, w) = if twist.w.is_negative() {
1732        (-signed, -twist.w)
1733    } else {
1734        (signed, twist.w)
1735    };
1736    Fix128::atan2(signed, w).double()
1737}
1738
1739#[cfg(all(test, feature = "std"))]
1740mod tests {
1741    use super::*;
1742    use crate::solver::RigidBody;
1743
1744    #[test]
1745    fn test_ball_joint_holds() {
1746        let mut bodies = vec![
1747            RigidBody::new_static(Vec3Fix::ZERO),
1748            RigidBody::new(Vec3Fix::from_int(5, 0, 0), Fix128::ONE),
1749        ];
1750
1751        let joint = Joint::Ball(BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO));
1752        let dt = Fix128::from_ratio(1, 60);
1753
1754        for _ in 0..100 {
1755            solve_joints(&[joint], &mut bodies, dt);
1756        }
1757
1758        // Body 1 should be pulled toward body 0
1759        let dist = (bodies[1].position - bodies[0].position).length();
1760        assert!(
1761            dist < Fix128::from_int(5),
1762            "Ball joint should pull bodies together"
1763        );
1764    }
1765
1766    #[test]
1767    fn test_fixed_joint() {
1768        let mut bodies = vec![
1769            RigidBody::new_static(Vec3Fix::ZERO),
1770            RigidBody::new(Vec3Fix::from_int(3, 0, 0), Fix128::ONE),
1771        ];
1772
1773        let joint = Joint::Fixed(FixedJoint::new(
1774            0,
1775            1,
1776            Vec3Fix::from_int(1, 0, 0),
1777            Vec3Fix::ZERO,
1778            QuatFix::IDENTITY,
1779        ));
1780        let dt = Fix128::from_ratio(1, 60);
1781
1782        for _ in 0..100 {
1783            solve_joints(&[joint], &mut bodies, dt);
1784        }
1785
1786        // Body 1 should be near anchor_a (1,0,0)
1787        let dist = (bodies[1].position - Vec3Fix::from_int(1, 0, 0)).length();
1788        assert!(
1789            dist < Fix128::from_int(3),
1790            "Fixed joint should hold position"
1791        );
1792    }
1793
1794    #[test]
1795    fn test_spring_joint() {
1796        let mut bodies = vec![
1797            RigidBody::new_static(Vec3Fix::ZERO),
1798            RigidBody::new(Vec3Fix::from_int(10, 0, 0), Fix128::ONE),
1799        ];
1800
1801        let joint = Joint::Spring(SpringJoint::new(
1802            0,
1803            1,
1804            Vec3Fix::ZERO,
1805            Vec3Fix::ZERO,
1806            Fix128::from_int(3),  // rest length = 3
1807            Fix128::from_int(10), // stiffness = 10
1808            Fix128::from_int(1),  // damping = 1
1809        ));
1810        let dt = Fix128::from_ratio(1, 60);
1811
1812        for _ in 0..200 {
1813            solve_joints(&[joint], &mut bodies, dt);
1814        }
1815
1816        // Should oscillate toward rest length = 3
1817        let dist = bodies[1].position.length();
1818        assert!(
1819            dist < Fix128::from_int(10),
1820            "Spring should pull body closer"
1821        );
1822    }
1823
1824    #[test]
1825    fn test_slider_joint() {
1826        let mut bodies = vec![
1827            RigidBody::new_static(Vec3Fix::ZERO),
1828            RigidBody::new(Vec3Fix::from_int(3, 5, 0), Fix128::ONE),
1829        ];
1830
1831        let joint = Joint::Slider(SliderJoint::new(
1832            0,
1833            1,
1834            Vec3Fix::UNIT_X,
1835            Vec3Fix::ZERO,
1836            Vec3Fix::ZERO,
1837        ));
1838        let dt = Fix128::from_ratio(1, 60);
1839
1840        for _ in 0..100 {
1841            solve_joints(&[joint], &mut bodies, dt);
1842        }
1843
1844        // Y component should be constrained toward 0 (perpendicular to axis)
1845        let y_abs = bodies[1].position.y.abs();
1846        assert!(
1847            y_abs < Fix128::from_int(5),
1848            "Slider should constrain perpendicular motion"
1849        );
1850    }
1851
1852    #[test]
1853    fn test_joint_bodies() {
1854        let ball = Joint::Ball(BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO));
1855        assert_eq!(ball.bodies(), (0, 1));
1856        assert_eq!(ball.joint_type(), JointType::Ball);
1857    }
1858
1859    #[allow(deprecated)] // pins the separation-based solve_joints_breakable
1860    #[test]
1861    fn test_breakable_joint() {
1862        let mut bodies = vec![
1863            RigidBody::new_static(Vec3Fix::ZERO),
1864            RigidBody::new(Vec3Fix::from_int(20, 0, 0), Fix128::ONE), // far away
1865        ];
1866
1867        // Ball joint with low break force (should break immediately)
1868        let joint = Joint::Ball(
1869            BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO)
1870                .with_break_force(Fix128::from_int(5)),
1871        );
1872        let dt = Fix128::from_ratio(1, 60);
1873
1874        let broken = solve_joints_breakable(&[joint], &mut bodies, dt);
1875        // Distance is ~20, break force is 5 → should break
1876        assert_eq!(broken.len(), 1, "Joint should break under high force");
1877        assert_eq!(broken[0], 0);
1878    }
1879
1880    #[allow(deprecated)] // pins the separation-based solve_joints_breakable
1881    #[test]
1882    fn test_unbreakable_joint() {
1883        let mut bodies = vec![
1884            RigidBody::new_static(Vec3Fix::ZERO),
1885            RigidBody::new(Vec3Fix::from_int(1, 0, 0), Fix128::ONE),
1886        ];
1887
1888        // Ball joint with high break force (should NOT break)
1889        let joint = Joint::Ball(
1890            BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO)
1891                .with_break_force(Fix128::from_int(100)),
1892        );
1893        let dt = Fix128::from_ratio(1, 60);
1894
1895        let broken = solve_joints_breakable(&[joint], &mut bodies, dt);
1896        assert!(broken.is_empty(), "Joint should not break under low force");
1897    }
1898
1899    #[test]
1900    fn test_unbreakable_joint_on_force() {
1901        let mut bodies = vec![
1902            RigidBody::new_static(Vec3Fix::ZERO),
1903            RigidBody::new(Vec3Fix::from_int(1, 0, 0), Fix128::ONE),
1904        ];
1905
1906        // Ball joint with high break force (should NOT break)
1907        let joint = Joint::Ball(
1908            BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO)
1909                .with_break_force(Fix128::from_int(4000)),
1910        );
1911        let dt = Fix128::from_ratio(1, 60);
1912
1913        // reaction force = 1 m / (1 kg⁻¹ · dt²) = 3600 N < 4000 N
1914        let broken = solve_joints_breaking_on_force(&[joint], &mut bodies, dt);
1915        assert!(broken.is_empty(), "Joint should not break under low force");
1916    }
1917
1918    #[allow(deprecated)] // pins the separation-based solve_joints_breakable
1919    #[test]
1920    fn test_no_break_force() {
1921        let mut bodies = vec![
1922            RigidBody::new_static(Vec3Fix::ZERO),
1923            RigidBody::new(Vec3Fix::from_int(100, 0, 0), Fix128::ONE),
1924        ];
1925
1926        // No break force → never breaks
1927        let joint = Joint::Ball(BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO));
1928        let dt = Fix128::from_ratio(1, 60);
1929
1930        let broken = solve_joints_breakable(&[joint], &mut bodies, dt);
1931        assert!(
1932            broken.is_empty(),
1933            "Joint without break_force should never break"
1934        );
1935    }
1936
1937    // ---- mutation-score tests (2026-09-15) ----------------------------
1938    // dt = 1/4 (inv exact)、位置は整数、質量は inv_mass を直接設定して閉形式と bit 単位一致
1939
1940    fn fi(n: i64) -> Fix128 {
1941        Fix128::from_int(n)
1942    }
1943
1944    fn v3i(x: i64, y: i64, z: i64) -> Vec3Fix {
1945        Vec3Fix::from_int(x, y, z)
1946    }
1947
1948    const DT: Fix128 = Fix128 { hi: 0, lo: 1 << 62 }; // 1/4
1949
1950    /// A: inv_mass ia at pa、B: inv_mass ib at pb (inv_inertia は (1,1,1))
1951    fn pair(pa: Vec3Fix, ia: i64, pb: Vec3Fix, ib: i64) -> Vec<RigidBody> {
1952        let mut a = RigidBody::new_dynamic(pa, Fix128::ONE);
1953        let mut b = RigidBody::new_dynamic(pb, Fix128::ONE);
1954        a.inv_mass = fi(ia);
1955        b.inv_mass = fi(ib);
1956        a.inv_inertia = v3i(1, 1, 1);
1957        b.inv_inertia = v3i(1, 1, 1);
1958        if ia == 0 {
1959            a.body_type = crate::solver::BodyType::Static;
1960        }
1961        if ib == 0 {
1962            b.body_type = crate::solver::BodyType::Static;
1963        }
1964        vec![a, b]
1965    }
1966
1967    fn near(a: Fix128, b: Fix128) -> bool {
1968        (a - b).abs() < Fix128 { hi: 0, lo: 1 << 24 }
1969    }
1970
1971    fn near_v(a: Vec3Fix, b: Vec3Fix) -> bool {
1972        near(a.x, b.x) && near(a.y, b.y) && near(a.z, b.z)
1973    }
1974
1975    // ---- ball ----------------------------------------------------------
1976
1977    #[test]
1978    fn ball_joint_splits_gap_by_inverse_mass() {
1979        // A inv 1 at 0、B inv 3 at (4,0,0)、anchor 0 → gap 4、λ = 1 → A +1、B -3 → 両方 (1,0,0)
1980        let mut bodies = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 3);
1981        let j = BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
1982        solve_ball_joint(&j, &mut bodies, DT);
1983        assert_eq!(bodies[0].position, v3i(1, 0, 0));
1984        assert_eq!(bodies[1].position, v3i(1, 0, 0));
1985    }
1986
1987    #[test]
1988    fn ball_joint_local_anchors_are_rotated_and_static_side_fixed() {
1989        // A static at 0 with anchor (1,0,0) rotated 180° about z → world anchor (-1,0,0)
1990        // B inv 1 at (3,0,0) anchor (0,0,0) → gap 4 → B moves to (-1,0,0)
1991        let mut bodies = pair(Vec3Fix::ZERO, 0, v3i(3, 0, 0), 1);
1992        bodies[0].rotation = QuatFix::new(Fix128::ZERO, Fix128::ZERO, Fix128::ONE, Fix128::ZERO);
1993        let j = BallJoint::new(0, 1, v3i(1, 0, 0), Vec3Fix::ZERO);
1994        solve_ball_joint(&j, &mut bodies, DT);
1995        assert_eq!(bodies[0].position, Vec3Fix::ZERO);
1996        assert_eq!(bodies[1].position, v3i(-1, 0, 0));
1997        // B 側 anchor (0,2,0) (B の inv_inertia は pair() 既定の (1,1,1)、lever arm 非 0):
1998        // world anchor_a = (-1,0,0) (上と同じ、A static), world anchor_b = (3,0,0)+(0,2,0) = (3,2,0)
1999        // delta = (4,2,0), distance = sqrt(20), normal = (4,2,0)/sqrt(20)
2000        // r_b = (0,2,0) (B の rotation は identity なので local anchor そのもの)
2001        // r_b × normal = (0, 0, -8/sqrt(20)) → point_angular_w = (8/sqrt(20))^2 * 1 = 64/20 = 16/5
2002        // w_sum = inv_mass_a(0) + inv_mass_b(1) + 0 (A static) + 16/5 = 21/5
2003        // lambda = distance / w_sum = sqrt(20) / (21/5) = 10 sqrt(5) / 21 (sqrt(20) = 2 sqrt(5))
2004        // translation: normal * lambda = (4/sqrt(20), 2/sqrt(20), 0) * 10 sqrt(5)/21
2005        //   = (20/21, 10/21, 0) (sqrt(5) cancels) → B.position -= that → (3 - 20/21, -10/21, 0)
2006        //   = (43/21, -10/21, 0)
2007        // rotation (apply_point_rotation(.., r_b, normal, -lambda)):
2008        //   omega = world_inv_inertia_apply(r_b × normal) * (-lambda)
2009        //         = (0,0,-8/sqrt(20)) * (-10 sqrt(5)/21) = (0, 0, 80 sqrt(5) / (2 sqrt(5) * 21))
2010        //         = (0, 0, 40/21) → signed twist about +Z = 40/21 (positive, nonzero: the lever
2011        //   arm now produces a rotation, where the pre-fix code left B unrotated)
2012        let mut bodies2 = pair(Vec3Fix::ZERO, 0, v3i(3, 0, 0), 1);
2013        bodies2[0].rotation = QuatFix::new(Fix128::ZERO, Fix128::ZERO, Fix128::ONE, Fix128::ZERO);
2014        let j2 = BallJoint::new(0, 1, v3i(1, 0, 0), v3i(0, 2, 0));
2015        solve_ball_joint(&j2, &mut bodies2, DT);
2016        assert!(
2017            near_v(
2018                bodies2[1].position,
2019                Vec3Fix::new(
2020                    Fix128::from_ratio(43, 21),
2021                    Fix128::from_ratio(-10, 21),
2022                    Fix128::ZERO
2023                )
2024            ),
2025            "{:?}",
2026            bodies2[1].position
2027        );
2028        assert!(near(
2029            compute_twist_angle(bodies2[1].rotation, Vec3Fix::UNIT_Z),
2030            Fix128::from_ratio(40, 21)
2031        ));
2032    }
2033
2034    #[test]
2035    fn ball_joint_compliance_and_degenerate_cases() {
2036        // compliance 1/16 → term 1、A static、B inv 1 gap 4 → w 2 → λ 2 → B at (2,0,0)
2037        let mut bodies = pair(Vec3Fix::ZERO, 0, v3i(4, 0, 0), 1);
2038        let mut j = BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
2039        j.compliance = Fix128::from_ratio(1, 16);
2040        solve_ball_joint(&j, &mut bodies, DT);
2041        assert_eq!(bodies[1].position, v3i(2, 0, 0));
2042        // 一致点 / 両 static → 変化なし
2043        let mut same = pair(v3i(1, 1, 1), 1, v3i(1, 1, 1), 1);
2044        solve_ball_joint(
2045            &BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO),
2046            &mut same,
2047            DT,
2048        );
2049        assert_eq!(same[0].position, v3i(1, 1, 1));
2050        let mut statics = pair(Vec3Fix::ZERO, 0, v3i(4, 0, 0), 0);
2051        solve_ball_joint(
2052            &BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO),
2053            &mut statics,
2054            DT,
2055        );
2056        assert_eq!(statics[1].position, v3i(4, 0, 0));
2057    }
2058
2059    // ---- hinge ---------------------------------------------------------
2060
2061    #[test]
2062    fn hinge_joint_positional_part_matches_ball_joint() {
2063        let mut bodies = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 3);
2064        let j = HingeJoint::new(
2065            0,
2066            1,
2067            Vec3Fix::ZERO,
2068            Vec3Fix::ZERO,
2069            Vec3Fix::UNIT_Z,
2070            Vec3Fix::UNIT_Z,
2071        );
2072        solve_hinge_joint(&j, &mut bodies, DT);
2073        assert_eq!(bodies[0].position, v3i(1, 0, 0));
2074        assert_eq!(bodies[1].position, v3i(1, 0, 0));
2075        // 軸が揃っていれば回転は不変
2076        assert_eq!(bodies[0].rotation, QuatFix::IDENTITY);
2077        assert_eq!(bodies[1].rotation, QuatFix::IDENTITY);
2078    }
2079
2080    #[test]
2081    fn hinge_joint_aligns_axes_monotonically() {
2082        // B の軸を x 軸周りに 0.5 rad 傾ける → 反復で軸誤差が単調減少、A (static) は不動
2083        let mut bodies = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2084        bodies[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_X, Fix128::from_ratio(1, 2));
2085        let j = HingeJoint::new(
2086            0,
2087            1,
2088            Vec3Fix::ZERO,
2089            Vec3Fix::ZERO,
2090            Vec3Fix::UNIT_Z,
2091            Vec3Fix::UNIT_Z,
2092        );
2093        let err = |b: &[RigidBody]| {
2094            let a = b[0].rotation.rotate_vec(Vec3Fix::UNIT_Z);
2095            let c = b[1].rotation.rotate_vec(Vec3Fix::UNIT_Z);
2096            a.cross(c).length()
2097        };
2098        let mut prev = err(&bodies);
2099        assert!(prev > Fix128::from_ratio(1, 10));
2100        for i in 0..30 {
2101            solve_hinge_joint(&j, &mut bodies, DT);
2102            let e = err(&bodies);
2103            assert!(e <= prev, "iter {i}: {prev:?} -> {e:?}");
2104            prev = e;
2105        }
2106        assert!(prev < Fix128::from_ratio(1, 1000));
2107        assert_eq!(bodies[0].rotation, QuatFix::IDENTITY);
2108        // 両 dynamic なら両方が回る (A も IDENTITY から離れる)
2109        let mut both = pair(Vec3Fix::ZERO, 1, Vec3Fix::ZERO, 1);
2110        both[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_X, Fix128::from_ratio(1, 2));
2111        solve_hinge_joint(&j, &mut both, DT);
2112        assert!(both[0].rotation != QuatFix::IDENTITY);
2113    }
2114
2115    #[test]
2116    fn hinge_joint_limits_push_angle_back_into_range() {
2117        // 軸 z、B を z 周りに +1 rad 回す、limit [-0.5, 0.5] → max 超過 → 角度が減る
2118        let j = HingeJoint::new(
2119            0,
2120            1,
2121            Vec3Fix::ZERO,
2122            Vec3Fix::ZERO,
2123            Vec3Fix::UNIT_Z,
2124            Vec3Fix::UNIT_Z,
2125        )
2126        .with_limits(Fix128::from_ratio(-1, 2), Fix128::from_ratio(1, 2));
2127        let angle = |b: &[RigidBody]| {
2128            let rel = b[1].rotation.mul(b[0].rotation.conjugate());
2129            compute_twist_angle(rel, Vec3Fix::UNIT_Z)
2130        };
2131        let mut over = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2132        over[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::ONE);
2133        let a0 = angle(&over);
2134        assert!(near(a0, Fix128::ONE), "{a0:?}");
2135        solve_hinge_joint(&j, &mut over, DT);
2136        let a1 = angle(&over);
2137        assert!(
2138            a1 < a0 && a1 >= Fix128::from_ratio(1, 2) - Fix128::from_ratio(1, 100),
2139            "{a0:?} -> {a1:?}"
2140        );
2141        // min 側: -1 rad → 増える
2142        let mut under = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2143        under[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, fi(-1));
2144        let b0 = angle(&under);
2145        solve_hinge_joint(&j, &mut under, DT);
2146        let b1 = angle(&under);
2147        assert!(b1 > b0, "{b0:?} -> {b1:?}");
2148        // 範囲内 (0.25) は不変、limit なしも不変
2149        let mut inside = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2150        inside[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::from_ratio(1, 4));
2151        let r0 = inside[1].rotation;
2152        solve_hinge_joint(&j, &mut inside, DT);
2153        assert_eq!(inside[1].rotation, r0);
2154        let mut free = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2155        free[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::ONE);
2156        let f0 = free[1].rotation;
2157        let nolimit = HingeJoint::new(
2158            0,
2159            1,
2160            Vec3Fix::ZERO,
2161            Vec3Fix::ZERO,
2162            Vec3Fix::UNIT_Z,
2163            Vec3Fix::UNIT_Z,
2164        );
2165        solve_hinge_joint(&nolimit, &mut free, DT);
2166        assert_eq!(free[1].rotation, f0);
2167    }
2168
2169    // ---- fixed ---------------------------------------------------------
2170
2171    #[test]
2172    fn fixed_joint_restores_relative_rotation_and_position() {
2173        let mut bodies = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 3);
2174        let j = FixedJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, QuatFix::IDENTITY);
2175        solve_fixed_joint(&j, &mut bodies, DT);
2176        assert_eq!(bodies[0].position, v3i(1, 0, 0));
2177        assert_eq!(bodies[1].position, v3i(1, 0, 0));
2178        // 相対回転 identity で B が z 周り 0.5 rad ずれている → 反復で相対回転誤差が単調減少
2179        let mut rot = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2180        rot[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::from_ratio(1, 2));
2181        let err = |b: &[RigidBody]| {
2182            let e = b[1].rotation.mul(b[0].rotation.conjugate());
2183            Vec3Fix::new(e.x, e.y, e.z).length()
2184        };
2185        let mut prev = err(&rot);
2186        for i in 0..30 {
2187            solve_fixed_joint(&j, &mut rot, DT);
2188            let e = err(&rot);
2189            assert!(e <= prev, "iter {i}");
2190            prev = e;
2191        }
2192        assert!(prev < Fix128::from_ratio(1, 1000));
2193        // 目標相対回転が 90° なら、B が 90° 回っている状態は不変
2194        let target = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::HALF_PI);
2195        let j90 = FixedJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, target);
2196        let mut ok = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2197        ok[1].rotation = target;
2198        let before = ok[1].rotation;
2199        solve_fixed_joint(&j90, &mut ok, DT);
2200        assert!(
2201            near(ok[1].rotation.x, before.x)
2202                && near(ok[1].rotation.z, before.z)
2203                && near(ok[1].rotation.w, before.w)
2204        );
2205    }
2206
2207    // ---- slider --------------------------------------------------------
2208
2209    #[test]
2210    fn slider_joint_removes_perpendicular_offset_only() {
2211        // 軸 x、B が (3, 4, 0): along 3 は自由、perp (0,4,0) を除去 → inv 1 / static → B (3,0,0)
2212        let mut bodies = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2213        let j = SliderJoint::new(0, 1, Vec3Fix::UNIT_X, Vec3Fix::ZERO, Vec3Fix::ZERO);
2214        solve_slider_joint(&j, &mut bodies, DT);
2215        assert_eq!(bodies[1].position, v3i(3, 0, 0));
2216        // 両 dynamic inv 1 / 3: perp 4 → λ 1 → A +1 (y)、B -3 (y) → A (0,1,0)、B (3,1,0)
2217        let mut both = pair(Vec3Fix::ZERO, 1, v3i(3, 4, 0), 3);
2218        solve_slider_joint(&j, &mut both, DT);
2219        assert_eq!(both[0].position, v3i(0, 1, 0));
2220        assert_eq!(both[1].position, v3i(3, 1, 0));
2221        // 軸は A の回転で回る: A を z 周り 180° → world 軸 -x、perp は同じ y → 同結果
2222        let mut rot = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2223        rot[0].rotation = QuatFix::new(Fix128::ZERO, Fix128::ZERO, Fix128::ONE, Fix128::ZERO);
2224        solve_slider_joint(&j, &mut rot, DT);
2225        assert_eq!(rot[1].position, v3i(3, 0, 0));
2226    }
2227
2228    #[test]
2229    fn slider_joint_limits_clamp_travel_along_axis() {
2230        let j = SliderJoint::new(0, 1, Vec3Fix::UNIT_X, Vec3Fix::ZERO, Vec3Fix::ZERO)
2231            .with_limits(fi(-1), fi(2));
2232        // along 5 > max 2 → error 3、A static / B inv 1 → B -3 → (2,0,0)
2233        let mut over = pair(Vec3Fix::ZERO, 0, v3i(5, 0, 0), 1);
2234        solve_slider_joint(&j, &mut over, DT);
2235        assert_eq!(over[1].position, v3i(2, 0, 0));
2236        // along -4 < min -1 → error 3 → B +3 → (-1,0,0)
2237        let mut under = pair(Vec3Fix::ZERO, 0, v3i(-4, 0, 0), 1);
2238        solve_slider_joint(&j, &mut under, DT);
2239        assert_eq!(under[1].position, v3i(-1, 0, 0));
2240        // 両 dynamic inv 1/3、along 6 → error 4、λ 1 → A +1、B -3 → A (1,0,0)、B (3,0,0)
2241        let mut both = pair(Vec3Fix::ZERO, 1, v3i(6, 0, 0), 3);
2242        solve_slider_joint(&j, &mut both, DT);
2243        assert_eq!(both[0].position, v3i(1, 0, 0));
2244        assert_eq!(both[1].position, v3i(3, 0, 0));
2245        // 境界 (along == max) と範囲内は不変
2246        let mut edge = pair(Vec3Fix::ZERO, 0, v3i(2, 0, 0), 1);
2247        solve_slider_joint(&j, &mut edge, DT);
2248        assert_eq!(edge[1].position, v3i(2, 0, 0));
2249        let mut inside = pair(Vec3Fix::ZERO, 0, v3i(1, 0, 0), 1);
2250        solve_slider_joint(&j, &mut inside, DT);
2251        assert_eq!(inside[1].position, v3i(1, 0, 0));
2252    }
2253
2254    // ---- spring --------------------------------------------------------
2255
2256    #[test]
2257    fn spring_joint_force_is_stiffness_times_displacement_plus_damping() {
2258        // XPBD: lambda = dt^2 F / (1 + (k dt^2 + c dt) w)、dt = 1/4
2259        // rest 1、k 2、c 0、距離 4、inv (1, 3): F = 6、w = 4 → lambda = (6/16)/(1 + 2/16*4) = 1/4 → A +1/4、B -3/4
2260        let mut bodies = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 3);
2261        let j = SpringJoint::new(
2262            0,
2263            1,
2264            Vec3Fix::ZERO,
2265            Vec3Fix::ZERO,
2266            fi(1),
2267            fi(2),
2268            Fix128::ZERO,
2269        );
2270        solve_spring_joint(&j, &mut bodies, DT);
2271        assert_eq!(
2272            bodies[0].position,
2273            Vec3Fix::new(Fix128::from_ratio(1, 4), Fix128::ZERO, Fix128::ZERO)
2274        );
2275        assert_eq!(
2276            bodies[1].position,
2277            Vec3Fix::new(Fix128::from_ratio(13, 4), Fix128::ZERO, Fix128::ZERO)
2278        );
2279        // 圧縮 (距離 4 < rest 8): F = 2*(-4) = -8、w = 2 → lambda = (-8/16)/(1 + 2/16*2) = -2/5 → A -2/5、B +2/5
2280        let mut comp = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 1);
2281        let jc = SpringJoint::new(
2282            0,
2283            1,
2284            Vec3Fix::ZERO,
2285            Vec3Fix::ZERO,
2286            fi(8),
2287            fi(2),
2288            Fix128::ZERO,
2289        );
2290        solve_spring_joint(&jc, &mut comp, DT);
2291        assert!(near_v(
2292            comp[0].position,
2293            Vec3Fix::new(Fix128::from_ratio(-2, 5), Fix128::ZERO, Fix128::ZERO)
2294        ));
2295        assert!(near_v(
2296            comp[1].position,
2297            Vec3Fix::new(Fix128::from_ratio(22, 5), Fix128::ZERO, Fix128::ZERO)
2298        ));
2299        // damping: rest 4 (ばね力 0)、k 2、c 2、B が +x に 3 で離れる: F = 2*3 = 6、w = 2
2300        // 減衰は陰的 (Macklin 2016 eq. 26): lambda = dt^2 F / (1 + (k dt^2 + c dt) w)
2301        // → lambda = (6/16)/(1 + (2/16 + 2/4)*2) = 1/6 → A +1/6、B -1/6
2302        let mut damp = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 1);
2303        damp[1].velocity = v3i(3, 0, 0);
2304        let jd = SpringJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, fi(4), fi(2), fi(2));
2305        solve_spring_joint(&jd, &mut damp, DT);
2306        assert!(near_v(
2307            damp[0].position,
2308            Vec3Fix::new(Fix128::from_ratio(1, 6), Fix128::ZERO, Fix128::ZERO)
2309        ));
2310        assert!(near_v(
2311            damp[1].position,
2312            Vec3Fix::new(Fix128::from_ratio(23, 6), Fix128::ZERO, Fix128::ZERO)
2313        ));
2314        // rest にあり速度 0 → 不変、一致点 → 不変
2315        let mut rest = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 1);
2316        solve_spring_joint(&jd, &mut rest, DT);
2317        assert_eq!(rest[1].position, v3i(4, 0, 0));
2318    }
2319
2320    // ---- D6 ------------------------------------------------------------
2321
2322    #[test]
2323    fn d6_joint_linear_locked_limited_and_free_axes() {
2324        let mut j = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
2325        j.linear_x = D6Motion::Locked;
2326        j.linear_y = D6Motion::Limited; // [-1, 1]
2327        j.linear_z = D6Motion::Free;
2328        // B at (4, 3, 5): x locked → -4、y limited → -(3-1) = -2、z free → 0 ⇒ B (0, 1, 5) (A static / B inv 1)
2329        let mut bodies = pair(Vec3Fix::ZERO, 0, v3i(4, 3, 5), 1);
2330        solve_d6_joint(&j, &mut bodies, DT);
2331        assert_eq!(bodies[1].position, v3i(0, 1, 5));
2332        // y が下限側: (0, -6, 0) → error -6-(-1) = -5 → B +5 → (0,-1,0)
2333        let mut low = pair(Vec3Fix::ZERO, 0, v3i(0, -6, 0), 1);
2334        solve_d6_joint(&j, &mut low, DT);
2335        assert_eq!(low[1].position, v3i(0, -1, 0));
2336        // 両 dynamic inv 1/3、x locked、B (4,0,0) → λ 1 → A +1、B -3
2337        let mut both = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 3);
2338        solve_d6_joint(&j, &mut both, DT);
2339        assert_eq!(both[0].position, v3i(1, 0, 0));
2340        assert_eq!(both[1].position, v3i(1, 0, 0));
2341        // frame_a を z 周り 180° 回転 → world x 軸が反転しても locked の結果は同じ (符号が両方で反転)
2342        let mut jf = j;
2343        jf.local_frame_a = QuatFix::new(Fix128::ZERO, Fix128::ZERO, Fix128::ONE, Fix128::ZERO);
2344        let mut flipped = pair(Vec3Fix::ZERO, 0, v3i(4, 0, 0), 1);
2345        solve_d6_joint(&jf, &mut flipped, DT);
2346        assert_eq!(flipped[1].position, Vec3Fix::ZERO);
2347    }
2348
2349    /// With a nonzero lever arm, a Locked x axis followed by a Locked y axis is no
2350    /// longer two independent 1-D projections: the x-step's lever-arm rotation (part
2351    /// of this fix) changes where B's anchor sits before the y-step solves, so the
2352    /// y-step's lever arm must be recomputed from B's CURRENT rotation, not a value
2353    /// cached from before the loop.
2354    ///
2355    /// A static at origin (anchor 0, irrelevant). B at (4,3,0), inv_mass 1,
2356    /// inv_inertia (1,1,1), anchor (0,2,0). delta = (4,5,0) (cached once, as before
2357    /// this fix). x-step: r_b=(0,2,0), r_b×axis_x=(0,0,-2), w=1+4=5, lambda=4/5 ⇒
2358    /// B.x = 4 - 4/5 = 16/5, and B rotates by theta = 8/5 about z (same closed form as
2359    /// `every_joint_positional_part_uses_rotated_local_anchors`'s ball case).
2360    /// y-step (fresh r_b): r_b_fresh = Rz(theta).rotate((0,2,0)) = (-2 sin(theta),
2361    /// 2 cos(theta), 0); r_b_fresh × axis_y = (0,0,-2 sin(theta)); w = 1 + 4 sin(theta)^2;
2362    /// lambda_y = 5 / w ⇒ B.y = 3 - lambda_y. (A stale r_b=(0,2,0) would instead give
2363    /// r_b×axis_y = 0, w = 1, lambda_y = 5, B.y = -2 — a full, unmoderated jump.)
2364    #[test]
2365    fn d6_joint_second_locked_axis_uses_the_rotation_from_the_first() {
2366        let mut j = D6Joint::new(0, 1, Vec3Fix::ZERO, v3i(0, 2, 0));
2367        j.linear_x = D6Motion::Locked;
2368        j.linear_y = D6Motion::Locked;
2369        let mut bodies = pair(Vec3Fix::ZERO, 0, v3i(4, 3, 0), 1);
2370        solve_d6_joint(&j, &mut bodies, DT);
2371
2372        // sin/cos via Fix128's own CORDIC (not the bug under test: these are generic
2373        // trig primitives, not solve_d6_joint) so the expected value is bit-exact
2374        // comparable, and deterministic cross-platform (see clippy::disallowed_methods).
2375        let theta = Fix128::from_ratio(8, 5);
2376        let sin_theta = theta.sin();
2377        let want_y = Fix128::from_int(3)
2378            - Fix128::from_int(5) / (Fix128::ONE + Fix128::from_int(4) * sin_theta * sin_theta);
2379        assert!(
2380            near(bodies[1].position.x, Fix128::from_ratio(16, 5)),
2381            "x {:?}",
2382            bodies[1].position.x
2383        );
2384        assert!(
2385            near(bodies[1].position.y, want_y),
2386            "y {:?} (want {want_y:?})",
2387            bodies[1].position.y
2388        );
2389        // the stale (pre-fix-refinement) value a cached r_b would have produced
2390        assert!(
2391            !near(bodies[1].position.y, Fix128::from_int(-2)),
2392            "y landed on the stale-cache value: {:?}",
2393            bodies[1].position.y
2394        );
2395    }
2396
2397    #[test]
2398    fn d6_joint_angular_locked_axis_pulls_rotation_back() {
2399        let mut j = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
2400        j.angular_z = D6Motion::Locked;
2401        let angle = |b: &[RigidBody]| {
2402            let rel = b[1].rotation.mul(b[0].rotation.conjugate());
2403            compute_twist_angle(rel, Vec3Fix::UNIT_Z)
2404        };
2405        let mut bodies = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2406        bodies[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::from_ratio(1, 2));
2407        let mut prev = angle(&bodies).abs();
2408        // 1.2.0: the rigid solve is exact, so the angle is ~0 after one pass
2409        // and later passes only see ulp-level noise (2⁻⁴⁰ tolerance)
2410        let noise = Fix128::from_raw(0, 1 << 24);
2411        for i in 0..30 {
2412            solve_d6_joint(&j, &mut bodies, DT);
2413            let a = angle(&bodies).abs();
2414            assert!(a <= prev + noise, "iter {i}");
2415            prev = a;
2416        }
2417        assert!(prev < Fix128::from_ratio(1, 1000));
2418        // Limited [-0.25, 0.25] で 0.5 → 減る、0.1 → 不変、Free → 不変
2419        let mut jl = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
2420        jl.angular_z = D6Motion::Limited;
2421        jl.angular_limit_min = Vec3Fix::new(
2422            Fix128::from_ratio(-1, 4),
2423            Fix128::from_ratio(-1, 4),
2424            Fix128::from_ratio(-1, 4),
2425        );
2426        jl.angular_limit_max = Vec3Fix::new(
2427            Fix128::from_ratio(1, 4),
2428            Fix128::from_ratio(1, 4),
2429            Fix128::from_ratio(1, 4),
2430        );
2431        let mut over = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2432        over[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::from_ratio(1, 2));
2433        let o0 = angle(&over);
2434        solve_d6_joint(&jl, &mut over, DT);
2435        assert!(angle(&over) < o0);
2436        let mut inside = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2437        inside[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::from_ratio(1, 10));
2438        let r0 = inside[1].rotation;
2439        solve_d6_joint(&jl, &mut inside, DT);
2440        assert_eq!(inside[1].rotation, r0);
2441        let free = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
2442        let mut f = pair(Vec3Fix::ZERO, 0, v3i(4, 3, 5), 1);
2443        f[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::from_ratio(1, 2));
2444        let (p0, q0) = (f[1].position, f[1].rotation);
2445        solve_d6_joint(&free, &mut f, DT);
2446        assert_eq!((f[1].position, f[1].rotation), (p0, q0));
2447    }
2448
2449    // ---- cone twist ----------------------------------------------------
2450
2451    #[test]
2452    fn cone_twist_joint_enforces_cone_and_twist_limits() {
2453        // twist 軸 z、cone limit 0.25 rad: B を x 周り 1 rad 傾ける → cone 角 1 > 0.25 → 減る
2454        let cone_angle = |b: &[RigidBody]| {
2455            let a = b[0].rotation.rotate_vec(Vec3Fix::UNIT_Z);
2456            let c = b[1].rotation.rotate_vec(Vec3Fix::UNIT_Z);
2457            Fix128::atan2(a.cross(c).length(), a.dot(c))
2458        };
2459        let j = ConeTwistJoint::new(
2460            0,
2461            1,
2462            Vec3Fix::ZERO,
2463            Vec3Fix::ZERO,
2464            Vec3Fix::UNIT_Z,
2465            Vec3Fix::UNIT_Z,
2466        )
2467        .with_limits(Fix128::from_ratio(1, 4), Fix128::from_ratio(1, 4));
2468        let mut tilted = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2469        tilted[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_X, Fix128::ONE);
2470        let c0 = cone_angle(&tilted);
2471        assert!(near(c0, Fix128::ONE), "{c0:?}");
2472        solve_cone_twist_joint(&j, &mut tilted, DT);
2473        let c1 = cone_angle(&tilted);
2474        assert!(c1 < c0, "{c0:?} -> {c1:?}");
2475        // 反復で cone limit 近傍まで単調に戻る
2476        let mut prev = c1;
2477        for _ in 0..40 {
2478            solve_cone_twist_joint(&j, &mut tilted, DT);
2479            let c = cone_angle(&tilted);
2480            assert!(c <= prev + Fix128 { hi: 0, lo: 1 << 30 });
2481            prev = c;
2482        }
2483        assert!(
2484            prev < Fix128::from_ratio(1, 4) + Fix128::from_ratio(1, 50),
2485            "{prev:?}"
2486        );
2487        // twist: z 周り 1 rad (cone 0) → twist 1 > 0.25 → 減る
2488        let twist = |b: &[RigidBody]| {
2489            let rel = b[1].rotation.mul(b[0].rotation.conjugate());
2490            compute_twist_angle(rel, Vec3Fix::UNIT_Z)
2491        };
2492        let mut twisted = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2493        twisted[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::ONE);
2494        let t0 = twist(&twisted);
2495        solve_cone_twist_joint(&j, &mut twisted, DT);
2496        assert!(twist(&twisted) < t0);
2497        // 範囲内 (cone 0.1、twist 0.1) は不変、位置部は ball と同じ
2498        let mut inside = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2499        inside[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_X, Fix128::from_ratio(1, 10));
2500        let r0 = inside[1].rotation;
2501        solve_cone_twist_joint(&j, &mut inside, DT);
2502        assert_eq!(inside[1].rotation, r0);
2503        let mut pos = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 3);
2504        solve_cone_twist_joint(&j, &mut pos, DT);
2505        assert_eq!(pos[0].position, v3i(1, 0, 0));
2506        assert_eq!(pos[1].position, v3i(1, 0, 0));
2507    }
2508
2509    // ---- Joint enum: bodies / compute_force / breakable ----------------
2510
2511    #[test]
2512    fn joint_bodies_and_compute_force_per_kind() {
2513        let bodies = pair(Vec3Fix::ZERO, 1, v3i(3, 4, 0), 1); // 距離 5
2514        let ball = Joint::Ball(BallJoint::new(2, 7, Vec3Fix::ZERO, Vec3Fix::ZERO));
2515        assert_eq!(ball.bodies(), (2, 7));
2516        let ball01 = Joint::Ball(BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO));
2517        assert_eq!(ball01.compute_force(&bodies), fi(5));
2518        let hinge = Joint::Hinge(HingeJoint::new(
2519            0,
2520            1,
2521            Vec3Fix::ZERO,
2522            Vec3Fix::ZERO,
2523            Vec3Fix::UNIT_Z,
2524            Vec3Fix::UNIT_Z,
2525        ));
2526        assert_eq!(hinge.compute_force(&bodies), fi(5));
2527        let fixed = Joint::Fixed(FixedJoint::new(
2528            0,
2529            1,
2530            Vec3Fix::ZERO,
2531            Vec3Fix::ZERO,
2532            QuatFix::IDENTITY,
2533        ));
2534        assert_eq!(fixed.compute_force(&bodies), fi(5));
2535        // slider (軸 x): perp = (0,4,0) → 4
2536        let slider = Joint::Slider(SliderJoint::new(
2537            0,
2538            1,
2539            Vec3Fix::UNIT_X,
2540            Vec3Fix::ZERO,
2541            Vec3Fix::ZERO,
2542        ));
2543        assert_eq!(slider.compute_force(&bodies), fi(4));
2544        // spring: |k (dist - rest)| = |2 (5 - 8)| = 6
2545        let spring = Joint::Spring(SpringJoint::new(
2546            0,
2547            1,
2548            Vec3Fix::ZERO,
2549            Vec3Fix::ZERO,
2550            fi(8),
2551            fi(2),
2552            Fix128::ZERO,
2553        ));
2554        assert_eq!(spring.compute_force(&bodies), fi(6));
2555        let d6 = Joint::D6(D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO));
2556        assert_eq!(d6.compute_force(&bodies), fi(5));
2557        let ct = Joint::ConeTwist(ConeTwistJoint::new(
2558            0,
2559            1,
2560            Vec3Fix::ZERO,
2561            Vec3Fix::ZERO,
2562            Vec3Fix::UNIT_Z,
2563            Vec3Fix::UNIT_Z,
2564        ));
2565        assert_eq!(ct.compute_force(&bodies), fi(5));
2566        // anchor が効く: A anchor (3,4,0) なら距離 0
2567        let anchored = Joint::Ball(BallJoint::new(0, 1, v3i(3, 4, 0), Vec3Fix::ZERO));
2568        assert_eq!(anchored.compute_force(&bodies), Fix128::ZERO);
2569    }
2570
2571    #[allow(deprecated)] // pins the separation-based solve_joints_breakable
2572    #[test]
2573    fn solve_joints_breakable_breaks_strictly_above_threshold_and_skips_broken() {
2574        // 距離 5 の ball joint: break 5 → 壊れない (`>` は false)、break 4 → 壊れて解かれない
2575        let mk = |bf: i64| {
2576            Joint::Ball(BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO).with_break_force(fi(bf)))
2577        };
2578        let mut hold = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2579        let broken = solve_joints_breakable(&[mk(5)], &mut hold, DT);
2580        assert!(broken.is_empty());
2581        assert!(
2582            near_v(hold[1].position, Vec3Fix::ZERO),
2583            "解かれて A に一致 {:?}",
2584            hold[1].position
2585        );
2586        let mut snap = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2587        let broken2 = solve_joints_breakable(&[mk(4)], &mut snap, DT);
2588        assert_eq!(broken2, vec![0]);
2589        assert_eq!(snap[1].position, v3i(3, 4, 0), "壊れた joint は解かれない");
2590        // 複数: index 降順で返る、壊れていない方は解かれる
2591        let mut multi = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2592        let js = [mk(4), mk(100), mk(1)];
2593        let broken3 = solve_joints_breakable(&js, &mut multi, DT);
2594        assert_eq!(broken3, vec![2, 0]);
2595        assert!(near_v(multi[1].position, Vec3Fix::ZERO));
2596        // solve_joints (非 breakable) は全部解く
2597        let mut all = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2598        solve_joints(&[mk(1)], &mut all, DT);
2599        assert!(near_v(all[1].position, Vec3Fix::ZERO));
2600    }
2601
2602    #[test]
2603    fn breaking_on_force_breaks_strictly_above_threshold_and_skips_broken() {
2604        // 距離 5・質量 1・DT 1/4 の ball joint の反力は 5 / (1/16) = 80: break 80 → 壊れない (`>` は false)、break 64 → 壊れて解かれない
2605        let mk = |bf: i64| {
2606            Joint::Ball(BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO).with_break_force(fi(bf)))
2607        };
2608        let mut hold = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2609        let broken = solve_joints_breaking_on_force(&[mk(80)], &mut hold, DT);
2610        assert!(broken.is_empty());
2611        assert!(
2612            near_v(hold[1].position, Vec3Fix::ZERO),
2613            "解かれて A に一致 {:?}",
2614            hold[1].position
2615        );
2616        let mut snap = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2617        let broken2 = solve_joints_breaking_on_force(&[mk(64)], &mut snap, DT);
2618        assert_eq!(broken2, vec![0]);
2619        // 判定は solve の後: 壊れた joint もこの呼び出しで解かれている
2620        assert!(
2621            near_v(snap[1].position, Vec3Fix::ZERO),
2622            "{:?}",
2623            snap[1].position
2624        );
2625        // 同じ 2 body の joint を 3 本: 1 本目が gap を閉じる (80 N) ので後の 2 本は
2626        // gap 0 で反力 0 (Gauss-Seidel の順で荷重を受ける) ⇒ 壊れるのは 1 本目だけ
2627        let mut multi = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2628        let js = [mk(64), mk(1600), mk(16)];
2629        let broken3 = solve_joints_breaking_on_force(&js, &mut multi, DT);
2630        assert_eq!(broken3, vec![0]);
2631        assert!(near_v(multi[1].position, Vec3Fix::ZERO));
2632        // solve_joints (非 breakable) は全部解く
2633        let mut all = pair(Vec3Fix::ZERO, 0, v3i(3, 4, 0), 1);
2634        solve_joints(&[mk(16)], &mut all, DT);
2635        assert!(near_v(all[1].position, Vec3Fix::ZERO));
2636    }
2637
2638    // ---- D6 builders ---------------------------------------------------
2639
2640    #[test]
2641    fn d6_with_linear_motion_and_limits_builders_drive_solver() {
2642        // builder は各軸に個別に格納する (x/y/z の取り違えを検出)
2643        let j = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO)
2644            .with_linear_motion(D6Motion::Locked, D6Motion::Limited, D6Motion::Free)
2645            .with_linear_limits(v3i(-7, -2, -9), v3i(7, 2, 9));
2646        assert_eq!(
2647            (j.linear_x, j.linear_y, j.linear_z),
2648            (D6Motion::Locked, D6Motion::Limited, D6Motion::Free)
2649        );
2650        assert_eq!(j.linear_limit_min, v3i(-7, -2, -9));
2651        assert_eq!(j.linear_limit_max, v3i(7, 2, 9));
2652        // angular 側は default (Free / ±π) のまま
2653        assert_eq!(
2654            (j.angular_x, j.angular_y, j.angular_z),
2655            (D6Motion::Free, D6Motion::Free, D6Motion::Free)
2656        );
2657        assert_eq!(j.angular_limit_max.y, Fix128::PI);
2658
2659        // 手書き field 設定と builder は同一 joint を作る
2660        let mut manual = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
2661        manual.linear_x = D6Motion::Locked;
2662        manual.linear_y = D6Motion::Limited;
2663        manual.linear_z = D6Motion::Free;
2664        manual.linear_limit_min = v3i(-7, -2, -9);
2665        manual.linear_limit_max = v3i(7, 2, 9);
2666        assert_eq!(j, manual);
2667
2668        // solver: B (4, 3, 5)、A static / B inv 1 → x locked → 0、y limited [-2, 2] → 2、z free → 5
2669        let mut bodies = pair(Vec3Fix::ZERO, 0, v3i(4, 3, 5), 1);
2670        solve_d6_joint(&j, &mut bodies, DT);
2671        assert_eq!(bodies[1].position, v3i(0, 2, 5));
2672        // 下限側: y = -6 → error -6 - (-2) = -4 → (0, -2, 0)
2673        let mut low = pair(Vec3Fix::ZERO, 0, v3i(0, -6, 0), 1);
2674        solve_d6_joint(&j, &mut low, DT);
2675        assert_eq!(low[1].position, v3i(0, -2, 0));
2676        // 限界内 (0, 1, 0) は不変
2677        let mut inside = pair(Vec3Fix::ZERO, 0, v3i(0, 1, 0), 1);
2678        solve_d6_joint(&j, &mut inside, DT);
2679        assert_eq!(inside[1].position, v3i(0, 1, 0));
2680    }
2681
2682    #[test]
2683    fn d6_with_angular_motion_and_limits_builders_drive_solver() {
2684        let q = Fix128::from_ratio(1, 4);
2685        let j = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO)
2686            .with_angular_motion(D6Motion::Free, D6Motion::Locked, D6Motion::Limited)
2687            .with_angular_limits(Vec3Fix::new(-q, -q, -q), Vec3Fix::new(q, q, q));
2688        assert_eq!(
2689            (j.angular_x, j.angular_y, j.angular_z),
2690            (D6Motion::Free, D6Motion::Locked, D6Motion::Limited)
2691        );
2692        assert_eq!(j.angular_limit_min, Vec3Fix::new(-q, -q, -q));
2693        assert_eq!(j.angular_limit_max, Vec3Fix::new(q, q, q));
2694        // linear 側は default (Free / ±1) のまま
2695        assert_eq!(
2696            (j.linear_x, j.linear_y, j.linear_z),
2697            (D6Motion::Free, D6Motion::Free, D6Motion::Free)
2698        );
2699        assert_eq!(j.linear_limit_min, v3i(-1, -1, -1));
2700
2701        let angle = |b: &[RigidBody], axis: Vec3Fix| {
2702            let rel = b[1].rotation.mul(b[0].rotation.conjugate());
2703            compute_twist_angle(rel, axis)
2704        };
2705        // z Limited [-1/4, 1/4]: 1/2 回転は限界に向かって減る
2706        let mut over = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2707        over[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::from_ratio(1, 2));
2708        let o0 = angle(&over, Vec3Fix::UNIT_Z);
2709        solve_d6_joint(&j, &mut over, DT);
2710        let o1 = angle(&over, Vec3Fix::UNIT_Z);
2711        assert!(o1 < o0, "{o1:?} < {o0:?}");
2712        // 1.2.0: exact solve lands on the limit (±2⁻⁴⁰ rounding), never below
2713        assert!(
2714            o1 >= q - Fix128::from_raw(0, 1 << 24),
2715            "limit は over-correct しない: {o1:?}"
2716        );
2717        // z 1/10 は限界内 → z 角は不変 (y Locked が拾う twist は数値 noise 程度、2^-40 以下)
2718        let mut inside = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2719        inside[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::from_ratio(1, 10));
2720        let z0 = angle(&inside, Vec3Fix::UNIT_Z);
2721        solve_d6_joint(&j, &mut inside, DT);
2722        let z1 = angle(&inside, Vec3Fix::UNIT_Z);
2723        assert!(near(z0, z1), "{z0:?} vs {z1:?}");
2724        // y Locked: 1/10 でも引き戻される、x Free: 1/2 でも不変
2725        let mut locked = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2726        locked[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Y, Fix128::from_ratio(1, 10));
2727        let l0 = angle(&locked, Vec3Fix::UNIT_Y).abs();
2728        solve_d6_joint(&j, &mut locked, DT);
2729        assert!(angle(&locked, Vec3Fix::UNIT_Y).abs() < l0);
2730        let mut free = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2731        free[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_X, Fix128::from_ratio(1, 2));
2732        let x0 = angle(&free, Vec3Fix::UNIT_X);
2733        solve_d6_joint(&j, &mut free, DT);
2734        let x1 = angle(&free, Vec3Fix::UNIT_X);
2735        assert!(near(x0, x1), "{x0:?} vs {x1:?}");
2736        assert!(
2737            x1 > Fix128::from_ratio(49, 100),
2738            "x Free は 1/2 のまま: {x1:?}"
2739        );
2740    }
2741
2742    // ---- batch 6: anchors / compliance / limit equality / compute_force ----
2743
2744    /// A: static at origin、z 軸 180° 回転、anchor (1,0,0) → world anchor (-1,0,0)
2745    /// B: inv 1 at (3,-2,0)、anchor (0,2,0) → world anchor (3,0,0)  ⇒ gap 4 (x 方向)
2746    fn anchored_pair() -> Vec<RigidBody> {
2747        let mut b = pair(Vec3Fix::ZERO, 0, v3i(3, -2, 0), 1);
2748        b[0].rotation = QuatFix::new(Fix128::ZERO, Fix128::ZERO, Fix128::ONE, Fix128::ZERO);
2749        b
2750    }
2751
2752    const A_ANCHOR: Vec3Fix = Vec3Fix {
2753        x: Fix128::ONE,
2754        y: Fix128::ZERO,
2755        z: Fix128::ZERO,
2756    };
2757    const B_ANCHOR: Vec3Fix = Vec3Fix {
2758        x: Fix128::ZERO,
2759        y: Fix128 { hi: 2, lo: 0 },
2760        z: Fix128::ZERO,
2761    };
2762
2763    #[test]
2764    fn every_joint_positional_part_uses_rotated_local_anchors() {
2765        // Shared geometry for ball/hinge/fixed/cone-twist/d6 below: world anchor_a =
2766        // (-1,0,0) (A static, 180° about z), world anchor_b = (3,-2,0)+(0,2,0) = (3,0,0)
2767        // (B_ANCHOR's rotate is identity) ⇒ delta = (4,0,0), distance = 4 (no sqrt: the
2768        // y components cancel exactly), normal = (1,0,0).
2769        // r_b = (0,2,0) (B's local anchor, B's rotation starts identity).
2770        // r_b × normal = (0,2,0) × (1,0,0) = (0,0,-2) ⇒ point_angular_w =
2771        // (-2)^2 * inv_inertia.z(1) = 4. w_sum = inv_m_a(0) + inv_m_b(1) + 0(A static) + 4 = 5.
2772        // lambda = distance / w_sum = 4/5. translation: normal * lambda * inv_m_b = (4/5,0,0)
2773        // ⇒ B.position -= that = (3 - 4/5, -2, 0) = (11/5, -2, 0).
2774        // rotation: apply_point_rotation(.., r_b, normal, -lambda): omega =
2775        // world_inv_inertia_apply((0,0,-2)) * (-4/5) = (0,0,-2) * (-4/5) = (0,0,8/5)
2776        // ⇒ signed twist about +z = 8/5 (nonzero: this is exactly the lever-arm rotation
2777        // the pre-fix code never produced).
2778        let want_pos = Vec3Fix::new(
2779            Fix128::from_ratio(11, 5),
2780            -Fix128::from_int(2),
2781            Fix128::ZERO,
2782        );
2783        let want_twist = Fix128::from_ratio(8, 5);
2784
2785        let mut b = anchored_pair();
2786        solve_ball_joint(&BallJoint::new(0, 1, A_ANCHOR, B_ANCHOR), &mut b, DT);
2787        assert!(near_v(b[1].position, want_pos), "ball {:?}", b[1].position);
2788        assert!(near(
2789            compute_twist_angle(b[1].rotation, Vec3Fix::UNIT_Z),
2790            want_twist
2791        ));
2792
2793        // hinge's axis-alignment step (its own step 2) is a no-op here: rotating UNIT_Z
2794        // by a rotation that is itself about Z leaves UNIT_Z unchanged, for both bodies,
2795        // so world_axis_a == world_axis_b after step 1 and the cross product is zero.
2796        let mut h = anchored_pair();
2797        solve_hinge_joint(
2798            &HingeJoint::new(0, 1, A_ANCHOR, B_ANCHOR, Vec3Fix::UNIT_Z, Vec3Fix::UNIT_Z),
2799            &mut h,
2800            DT,
2801        );
2802        assert!(near_v(h[1].position, want_pos), "hinge {:?}", h[1].position);
2803        assert!(near(
2804            compute_twist_angle(h[1].rotation, Vec3Fix::UNIT_Z),
2805            want_twist
2806        ));
2807
2808        // fixed's position is identical to ball/hinge (step 1 is the same point
2809        // constraint), but its own step 2 ("maintain relative rotation") then measures
2810        // B against target_rot_b = A.rotation * relative_rotation = Rz(pi) * Rz(-pi) =
2811        // identity exactly, so rot_error = B.rotation = Rz(8/5) (the step-1 result above).
2812        // angular_inverse_mass(B, UNIT_Z) is exactly 1 (inv_inertia.z = 1, and Z is
2813        // invariant under a rotation about Z), so this step's lambda = 8/5 exactly
2814        // cancels the step-1 rotation: B ends up back at identity, same as pre-fix.
2815        let mut f = anchored_pair();
2816        solve_fixed_joint(
2817            &FixedJoint::new(
2818                0,
2819                1,
2820                A_ANCHOR,
2821                B_ANCHOR,
2822                QuatFix::new(Fix128::ZERO, Fix128::ZERO, -Fix128::ONE, Fix128::ZERO),
2823            ),
2824            &mut f,
2825            DT,
2826        );
2827        assert!(near_v(f[1].position, want_pos), "fixed {:?}", f[1].position);
2828        assert_eq!(
2829            f[1].rotation,
2830            QuatFix::IDENTITY,
2831            "fixed rotation {:?}",
2832            f[1].rotation
2833        );
2834
2835        // cone-twist's cone/twist steps are no-ops here too: world_axis_a == world_axis_b
2836        // (both UNIT_Z, same reasoning as hinge) makes cone_angle exactly 0 (<= the
2837        // default pi/2 limit), and the resulting twist_angle (8/5 - pi) is within the
2838        // default pi twist_limit.
2839        let mut ct = anchored_pair();
2840        solve_cone_twist_joint(
2841            &ConeTwistJoint::new(0, 1, A_ANCHOR, B_ANCHOR, Vec3Fix::UNIT_Z, Vec3Fix::UNIT_Z),
2842            &mut ct,
2843            DT,
2844        );
2845        assert!(
2846            near_v(ct[1].position, want_pos),
2847            "cone {:?}",
2848            ct[1].position
2849        );
2850        assert!(near(
2851            compute_twist_angle(ct[1].rotation, Vec3Fix::UNIT_Z),
2852            want_twist
2853        ));
2854
2855        // d6 locked on all 3 linear axes: axis_x = frame_a.rotate(UNIT_X) = (-1,0,0) (A's
2856        // 180° frame), and the x-step alone reproduces the ball-joint computation above
2857        // (same delta, same lever arm) since delta's y/z components are both exactly 0
2858        // (anchor_b.y = -2+2 = 0 = anchor_a.y, anchor_b.z = anchor_a.z = 0), so the y and
2859        // z steps see error == 0 and are no-ops; angular is all Free (default), so no
2860        // angular correction either.
2861        let mut d6j = D6Joint::new(0, 1, A_ANCHOR, B_ANCHOR);
2862        d6j.linear_x = D6Motion::Locked;
2863        d6j.linear_y = D6Motion::Locked;
2864        d6j.linear_z = D6Motion::Locked;
2865        let mut d = anchored_pair();
2866        solve_d6_joint(&d6j, &mut d, DT);
2867        assert!(near_v(d[1].position, want_pos), "d6 {:?}", d[1].position);
2868        assert!(near(
2869            compute_twist_angle(d[1].rotation, Vec3Fix::UNIT_Z),
2870            want_twist
2871        ));
2872        // slider (軸 x、A 回転で world 軸 -x): perp は y 成分 0 なので along のみ → 移動なし、
2873        // B anchor を (0,3,0) にして perp 1 を作る → B の y が -1 動く
2874        //
2875        // Unlike the 5 cases above, this sub-case is unaffected by the lever-arm fix:
2876        // r_b = (0,3,0) and the correction direction perp_normal = (0,1,0) are parallel,
2877        // so r_b x perp_normal = 0 and point_angular_w is exactly 0 (a lever arm aligned
2878        // with the correction direction produces no torque) — translation-only is still
2879        // the exact answer here, same as before this fix.
2880        let mut sl = anchored_pair();
2881        solve_slider_joint(
2882            &SliderJoint::new(0, 1, Vec3Fix::UNIT_X, A_ANCHOR, v3i(0, 3, 0)),
2883            &mut sl,
2884            DT,
2885        );
2886        assert!(
2887            near_v(sl[1].position, v3i(3, -3, 0)),
2888            "slider {:?}",
2889            sl[1].position
2890        );
2891        // spring: rest 4 なら力 0 (anchor 込みの距離が 4)、rest 2 なら k=1 で 2 → impulse 1/2
2892        let mut sp0 = anchored_pair();
2893        solve_spring_joint(
2894            &SpringJoint::new(0, 1, A_ANCHOR, B_ANCHOR, fi(4), Fix128::ONE, Fix128::ZERO),
2895            &mut sp0,
2896            DT,
2897        );
2898        assert_eq!(sp0[1].position, v3i(3, -2, 0));
2899        let mut sp = anchored_pair();
2900        solve_spring_joint(
2901            &SpringJoint::new(0, 1, A_ANCHOR, B_ANCHOR, fi(2), Fix128::ONE, Fix128::ZERO),
2902            &mut sp,
2903            DT,
2904        );
2905        // The spring takes the same lever-arm coupling as the point joints above:
2906        // C = 2, w = 5, lambda = (dt^2 k C) / (1 + k dt^2 w) = (1/8) / (21/16) = 2/21
2907        // ⇒ B moves -2/21 along x and turns by I⁻¹ (r_b × n) (−λ) = +4/21 about z.
2908        assert!(
2909            near_v(
2910                sp[1].position,
2911                Vec3Fix::new(Fix128::from_ratio(61, 21), fi(-2), Fix128::ZERO)
2912            ),
2913            "spring {:?}",
2914            sp[1].position
2915        );
2916        assert!(near(
2917            compute_twist_angle(sp[1].rotation, Vec3Fix::UNIT_Z),
2918            Fix128::from_ratio(4, 21)
2919        ));
2920    }
2921
2922    #[test]
2923    fn compliance_softens_every_positional_joint_identically() {
2924        // A static、B inv 1、gap 4、compliance 1/16 (dt 1/4 → term 1) → w 2 → 移動 2
2925        let expect = v3i(2, 0, 0);
2926        let mut h = HingeJoint::new(
2927            0,
2928            1,
2929            Vec3Fix::ZERO,
2930            Vec3Fix::ZERO,
2931            Vec3Fix::UNIT_Z,
2932            Vec3Fix::UNIT_Z,
2933        );
2934        h.compliance = Fix128::from_ratio(1, 16);
2935        let mut bh = pair(Vec3Fix::ZERO, 0, v3i(4, 0, 0), 1);
2936        solve_hinge_joint(&h, &mut bh, DT);
2937        assert_eq!(bh[1].position, expect, "hinge");
2938        let mut f = FixedJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, QuatFix::IDENTITY);
2939        f.compliance = Fix128::from_ratio(1, 16);
2940        let mut bf = pair(Vec3Fix::ZERO, 0, v3i(4, 0, 0), 1);
2941        solve_fixed_joint(&f, &mut bf, DT);
2942        assert_eq!(bf[1].position, expect, "fixed");
2943        let mut c = ConeTwistJoint::new(
2944            0,
2945            1,
2946            Vec3Fix::ZERO,
2947            Vec3Fix::ZERO,
2948            Vec3Fix::UNIT_Z,
2949            Vec3Fix::UNIT_Z,
2950        );
2951        c.compliance = Fix128::from_ratio(1, 16);
2952        let mut bc = pair(Vec3Fix::ZERO, 0, v3i(4, 0, 0), 1);
2953        solve_cone_twist_joint(&c, &mut bc, DT);
2954        assert_eq!(bc[1].position, expect, "cone");
2955        let mut s = SliderJoint::new(0, 1, Vec3Fix::UNIT_X, Vec3Fix::ZERO, Vec3Fix::ZERO);
2956        s.compliance = Fix128::from_ratio(1, 16);
2957        let mut bs = pair(Vec3Fix::ZERO, 0, v3i(0, 4, 0), 1); // perp 4
2958        solve_slider_joint(&s, &mut bs, DT);
2959        assert_eq!(bs[1].position, v3i(0, 2, 0), "slider");
2960        let mut d = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
2961        d.linear_x = D6Motion::Locked;
2962        d.compliance = Fix128::from_ratio(1, 16);
2963        let mut bd = pair(Vec3Fix::ZERO, 0, v3i(4, 0, 0), 1);
2964        solve_d6_joint(&d, &mut bd, DT);
2965        assert_eq!(bd[1].position, expect, "d6");
2966        // angular compliance: hinge 軸誤差の補正量が小さくなる (compliance 0 との比較)
2967        let mk = |ac: Fix128| {
2968            let mut j = HingeJoint::new(
2969                0,
2970                1,
2971                Vec3Fix::ZERO,
2972                Vec3Fix::ZERO,
2973                Vec3Fix::UNIT_Z,
2974                Vec3Fix::UNIT_Z,
2975            );
2976            j.angular_compliance = ac;
2977            let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
2978            b[1].rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_X, Fix128::from_ratio(1, 2));
2979            solve_hinge_joint(&j, &mut b, DT);
2980            let a = b[0].rotation.rotate_vec(Vec3Fix::UNIT_Z);
2981            let c = b[1].rotation.rotate_vec(Vec3Fix::UNIT_Z);
2982            a.cross(c).length()
2983        };
2984        assert!(
2985            mk(Fix128::from_ratio(1, 4)) > mk(Fix128::ZERO),
2986            "angular compliance で補正が弱まる"
2987        );
2988    }
2989
2990    #[test]
2991    fn limit_equality_is_inside_the_allowed_range() {
2992        // slider along == min ちょうど → 不変 (`<` false)、d6 proj == limit_max → 不変、cone == limit → 不変
2993        let sj = SliderJoint::new(0, 1, Vec3Fix::UNIT_X, Vec3Fix::ZERO, Vec3Fix::ZERO)
2994            .with_limits(fi(-1), fi(2));
2995        let mut at_min = pair(Vec3Fix::ZERO, 0, v3i(-1, 0, 0), 1);
2996        solve_slider_joint(&sj, &mut at_min, DT);
2997        assert_eq!(at_min[1].position, v3i(-1, 0, 0));
2998        let mut dj = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
2999        dj.linear_y = D6Motion::Limited; // [-1, 1]
3000        let mut at_max = pair(Vec3Fix::ZERO, 0, v3i(0, 1, 0), 1);
3001        solve_d6_joint(&dj, &mut at_max, DT);
3002        assert_eq!(at_max[1].position, v3i(0, 1, 0));
3003        let mut at_lo = pair(Vec3Fix::ZERO, 0, v3i(0, -1, 0), 1);
3004        solve_d6_joint(&dj, &mut at_lo, DT);
3005        assert_eq!(at_lo[1].position, v3i(0, -1, 0));
3006        // d6 limited で下限割れ: (0,-3,0) → -3 - (-1) = -2 → B += 2 → (0,-1,0) (上限側は既存 test)
3007        let mut below = pair(Vec3Fix::ZERO, 0, v3i(0, -3, 0), 1);
3008        solve_d6_joint(&dj, &mut below, DT);
3009        assert_eq!(below[1].position, v3i(0, -1, 0));
3010    }
3011
3012    #[test]
3013    fn compute_force_honours_anchors_and_rotation_for_every_kind() {
3014        let bodies = anchored_pair(); // world anchor 距離 4 (x)
3015        assert_eq!(
3016            Joint::Ball(BallJoint::new(0, 1, A_ANCHOR, B_ANCHOR)).compute_force(&bodies),
3017            fi(4)
3018        );
3019        assert_eq!(
3020            Joint::Hinge(HingeJoint::new(
3021                0,
3022                1,
3023                A_ANCHOR,
3024                B_ANCHOR,
3025                Vec3Fix::UNIT_Z,
3026                Vec3Fix::UNIT_Z
3027            ))
3028            .compute_force(&bodies),
3029            fi(4)
3030        );
3031        assert_eq!(
3032            Joint::Fixed(FixedJoint::new(0, 1, A_ANCHOR, B_ANCHOR, QuatFix::IDENTITY))
3033                .compute_force(&bodies),
3034            fi(4)
3035        );
3036        assert_eq!(
3037            Joint::D6(D6Joint::new(0, 1, A_ANCHOR, B_ANCHOR)).compute_force(&bodies),
3038            fi(4)
3039        );
3040        assert_eq!(
3041            Joint::ConeTwist(ConeTwistJoint::new(
3042                0,
3043                1,
3044                A_ANCHOR,
3045                B_ANCHOR,
3046                Vec3Fix::UNIT_Z,
3047                Vec3Fix::UNIT_Z
3048            ))
3049            .compute_force(&bodies),
3050            fi(4)
3051        );
3052        // spring: |k (4 - rest)| = |3 (4 - 1)| = 9
3053        assert_eq!(
3054            Joint::Spring(SpringJoint::new(
3055                0,
3056                1,
3057                A_ANCHOR,
3058                B_ANCHOR,
3059                fi(1),
3060                fi(3),
3061                Fix128::ZERO
3062            ))
3063            .compute_force(&bodies),
3064            fi(9)
3065        );
3066        // slider 軸 y (A 回転で world -y): delta (4,0,0) の perp は 4、軸 x なら perp 0
3067        assert_eq!(
3068            Joint::Slider(SliderJoint::new(0, 1, Vec3Fix::UNIT_Y, A_ANCHOR, B_ANCHOR))
3069                .compute_force(&bodies),
3070            fi(4)
3071        );
3072        assert_eq!(
3073            Joint::Slider(SliderJoint::new(0, 1, Vec3Fix::UNIT_X, A_ANCHOR, B_ANCHOR))
3074                .compute_force(&bodies),
3075            Fix128::ZERO
3076        );
3077        // anchor なしなら距離は |(3,-2,0)| = √13 ≠ 4
3078        assert_ne!(
3079            Joint::Ball(BallJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO)).compute_force(&bodies),
3080            fi(4)
3081        );
3082    }
3083
3084    // ---- batch 7: angular / limit / compliance arithmetic (mutation kills, 2026-09-15) ----
3085    //
3086    // Conventions used below (all derived by hand, see each test's comment):
3087    // * `DT = 1/4` so `dt * dt = 1/16` and `compliance / dt²` is `compliance * 16`.
3088    // * `set_inv_inertia(i_a, i_b)` sets an isotropic `inv_inertia = (i, i, i)`, so the
3089    //   angular inverse mass `w = n · I⁻¹ n` is exactly `i` for every unit axis and every
3090    //   body orientation (static bodies contribute 0 regardless).
3091    // * `q35(axis)` is the unit quaternion `(axis * 3/5, 4/5)`: a rotation of
3092    //   `θ = 2·atan(3/4) ≈ 1.287 002 rad` with `sin θ = 24/25`, `cos θ = 7/25`.
3093    // * `apply_angular_correction(λ)` rotates A by exactly `+w_a λ` and B by `−w_b λ`, and
3094    //   every caller uses `λ = error / (w_a + w_b + α̃)`, so with `α̃ = 0` one solve changes
3095    //   the relative angle by exactly `error` (lands on the limit / on zero).
3096
3097    /// Isotropic `inv_inertia = (i, i, i)` on both bodies: `n · I⁻¹ n == i` for any unit `n`.
3098    fn set_inv_inertia(b: &mut [RigidBody], ia: Fix128, ib: Fix128) {
3099        b[0].inv_inertia = Vec3Fix::new(ia, ia, ia);
3100        b[1].inv_inertia = Vec3Fix::new(ib, ib, ib);
3101    }
3102
3103    /// Unit quaternion `(axis * 3/5, 4/5)`: rotation by `2·atan(3/4) ≈ 1.287 rad`.
3104    fn q35(axis: Vec3Fix) -> QuatFix {
3105        let s = Fix128::from_ratio(3, 5);
3106        QuatFix::new(axis.x * s, axis.y * s, axis.z * s, Fix128::from_ratio(4, 5))
3107    }
3108
3109    /// Relative twist of B w.r.t. A about `axis` (same formula as the solvers, signed).
3110    fn rel_twist(b: &[RigidBody], axis: Vec3Fix) -> Fix128 {
3111        compute_twist_angle(b[1].rotation.mul(b[0].rotation.conjugate()), axis)
3112    }
3113
3114    /// Angle between the bodies' world z axes (cone angle of a z twist axis).
3115    fn tilt_z(b: &[RigidBody]) -> Fix128 {
3116        let a = b[0].rotation.rotate_vec(Vec3Fix::UNIT_Z);
3117        let c = b[1].rotation.rotate_vec(Vec3Fix::UNIT_Z);
3118        Fix128::atan2(a.cross(c).length(), a.dot(c))
3119    }
3120
3121    /// Angle between world z and `q`'s z axis.
3122    fn tilt_from_world_z(q: QuatFix) -> Fix128 {
3123        let c = q.rotate_vec(Vec3Fix::UNIT_Z);
3124        Fix128::atan2(Vec3Fix::UNIT_Z.cross(c).length(), Vec3Fix::UNIT_Z.dot(c))
3125    }
3126
3127    /// `θ = 2·atan2(3, 4)`, the rotation angle of `q35`.
3128    fn theta35() -> Fix128 {
3129        Fix128::atan2(fi(3), fi(4)).double()
3130    }
3131
3132    /// Kills `776:64` / `876:64` / `950:64` / `1104:68` / `1200:64` (`correction * inv_mass_a`
3133    /// → `/`) and `1030:81` (spring `impulse * inv_mass_a` → `/`).
3134    ///
3135    /// A inv_mass 3 at 0, B inv_mass 1 at gap 4 → w = 4, λ = 1, correction = 1:
3136    /// A moves `1 * 3 = 3` (mutant: `1 / 3`), B moves `1 * 1 = 1` → both land on 3.
3137    /// Spring: rest 1, k 2, dist 4 → F = 6, impulse = 6 · ¼ = 3/2 → A += 3/2 · 3 = 9/2
3138    /// (mutant: 3/2 / 3 = 1/2), B −= 3/2 → 5/2.
3139    #[test]
3140    fn positional_body_a_update_multiplies_by_inverse_mass() {
3141        let expect = v3i(3, 0, 0);
3142        let mut h = pair(Vec3Fix::ZERO, 3, v3i(4, 0, 0), 1);
3143        solve_hinge_joint(
3144            &HingeJoint::new(
3145                0,
3146                1,
3147                Vec3Fix::ZERO,
3148                Vec3Fix::ZERO,
3149                Vec3Fix::UNIT_Z,
3150                Vec3Fix::UNIT_Z,
3151            ),
3152            &mut h,
3153            DT,
3154        );
3155        assert_eq!((h[0].position, h[1].position), (expect, expect), "hinge");
3156        let mut f = pair(Vec3Fix::ZERO, 3, v3i(4, 0, 0), 1);
3157        solve_fixed_joint(
3158            &FixedJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, QuatFix::IDENTITY),
3159            &mut f,
3160            DT,
3161        );
3162        assert_eq!((f[0].position, f[1].position), (expect, expect), "fixed");
3163        let mut c = pair(Vec3Fix::ZERO, 3, v3i(4, 0, 0), 1);
3164        solve_cone_twist_joint(
3165            &ConeTwistJoint::new(
3166                0,
3167                1,
3168                Vec3Fix::ZERO,
3169                Vec3Fix::ZERO,
3170                Vec3Fix::UNIT_Z,
3171                Vec3Fix::UNIT_Z,
3172            ),
3173            &mut c,
3174            DT,
3175        );
3176        assert_eq!((c[0].position, c[1].position), (expect, expect), "cone");
3177        let mut d6 = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
3178        d6.linear_x = D6Motion::Locked;
3179        let mut d = pair(Vec3Fix::ZERO, 3, v3i(4, 0, 0), 1);
3180        solve_d6_joint(&d6, &mut d, DT);
3181        assert_eq!((d[0].position, d[1].position), (expect, expect), "d6");
3182        // slider: axis x, gap (0,4,0) is entirely perpendicular → same split along y
3183        let mut s = pair(Vec3Fix::ZERO, 3, v3i(0, 4, 0), 1);
3184        solve_slider_joint(
3185            &SliderJoint::new(0, 1, Vec3Fix::UNIT_X, Vec3Fix::ZERO, Vec3Fix::ZERO),
3186            &mut s,
3187            DT,
3188        );
3189        assert_eq!(
3190            (s[0].position, s[1].position),
3191            (v3i(0, 3, 0), v3i(0, 3, 0)),
3192            "slider"
3193        );
3194        let mut sp = pair(Vec3Fix::ZERO, 3, v3i(4, 0, 0), 1);
3195        solve_spring_joint(
3196            &SpringJoint::new(
3197                0,
3198                1,
3199                Vec3Fix::ZERO,
3200                Vec3Fix::ZERO,
3201                fi(1),
3202                fi(2),
3203                Fix128::ZERO,
3204            ),
3205            &mut sp,
3206            DT,
3207        );
3208        assert_eq!(
3209            sp[0].position,
3210            Vec3Fix::new(Fix128::from_ratio(3, 4), Fix128::ZERO, Fix128::ZERO),
3211            "spring A"
3212        );
3213        assert_eq!(
3214            sp[1].position,
3215            Vec3Fix::new(Fix128::from_ratio(15, 4), Fix128::ZERO, Fix128::ZERO),
3216            "spring B"
3217        );
3218    }
3219
3220    /// Kills `795:59` (`angular_compliance / dt²` → `*`), `795:65` (`dt * dt` → `/`, `+`),
3221    /// `797` (`angular_w_sum + angular_compliance` → `-`, `*`), `1296` (`w_a + w_b` → `*`)
3222    /// and the `w_a * λ` / `w_b * λ` products in `apply_angular_correction` (1316 / 1319).
3223    ///
3224    /// Both dynamic, `w_a = 1/2`, `w_b = 1/4`, `angular_compliance = 1/64` → term 1/4,
3225    /// `w = 1/2 + 1/4 + 1/4 = 1`. B = q35(x) tilts its z axis by `θ` about +x, the
3226    /// misalignment angle is `atan2(24/25, 7/25) = θ`, `λ = θ / 1 = θ`:
3227    /// A rotates `+θ/2` about x, B rotates `−θ/4` → B's tilt from world z is `3θ/4`,
3228    /// the residual angle between the two axes is `θ/4` (compliance keeps 1/4 of the error).
3229    /// Mutants: `w ∈ {0.751, 0.766, 0.781 (795), 0.5, 0.1875 (797), 0.375 (1296)}` →
3230    /// A's tilt `∈ {0.666θ, 0.653θ, 0.64θ, θ, 2.67θ, 1.33θ}` instead of `θ/2`.
3231    #[test]
3232    fn hinge_axis_alignment_uses_angular_compliance_and_inertia_sum() {
3233        let mut j = HingeJoint::new(
3234            0,
3235            1,
3236            Vec3Fix::ZERO,
3237            Vec3Fix::ZERO,
3238            Vec3Fix::UNIT_Z,
3239            Vec3Fix::UNIT_Z,
3240        );
3241        j.angular_compliance = Fix128::from_ratio(1, 64);
3242        let mut b = pair(Vec3Fix::ZERO, 1, Vec3Fix::ZERO, 1);
3243        set_inv_inertia(&mut b, Fix128::from_ratio(1, 2), Fix128::from_ratio(1, 4));
3244        b[1].rotation = q35(Vec3Fix::UNIT_X);
3245        let theta = theta35();
3246        assert!(near(tilt_z(&b), theta));
3247        solve_hinge_joint(&j, &mut b, DT);
3248        let a_tilt = tilt_from_world_z(b[0].rotation);
3249        let b_tilt = tilt_from_world_z(b[1].rotation);
3250        let rel = tilt_z(&b);
3251        assert!(near(a_tilt, theta.half()), "A tilt {a_tilt:?} vs θ/2");
3252        assert!(
3253            near(b_tilt, theta - theta / fi(4)),
3254            "B tilt {b_tilt:?} vs 3θ/4"
3255        );
3256        assert!(near(rel, theta / fi(4)), "residual {rel:?} vs θ/4");
3257        // A rotated about +x (its z axis tipped toward −y), B rotated back about −x
3258        assert!(b[0].rotation.rotate_vec(Vec3Fix::UNIT_Z).y < Fix128::ZERO);
3259    }
3260
3261    /// Kills `900` (`error_mag` from `atan2(...).double()`), `901:59` (`angular_compliance / dt²`
3262    /// → `*`), `901:65` (`dt * dt` → `/`, `+`), `903` (`+ angular_compliance` → `-`, `*`) and
3263    /// `1319` (`w_b * λ` → `/`).
3264    ///
3265    /// A static, `w_b = 3/4`, `angular_compliance = 1/64` → term 1/4, `w = 1`. B = q35(z),
3266    /// target identity → error quaternion q35(z), `error_mag = 2·atan2(3/5, 4/5) = θ`,
3267    /// `λ = θ`, B rotates `−3θ/4` → residual relative twist exactly `θ/4 ≈ 0.3218`.
3268    /// Mutants: `w ∈ {0.751, 0.766, 0.781, 0.5, 0.1875}` → residual
3269    /// `∈ {0.001θ, 0.02θ, 0.04θ, −θ/2, −3θ (wraps to 2.42)}`; `w_b / λ` → `θ − 0.583`.
3270    #[test]
3271    fn fixed_joint_rotation_lock_uses_angular_compliance_and_inertia_sum() {
3272        let mut j = FixedJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, QuatFix::IDENTITY);
3273        j.angular_compliance = Fix128::from_ratio(1, 64);
3274        let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3275        set_inv_inertia(&mut b, Fix128::ZERO, Fix128::from_ratio(3, 4));
3276        b[1].rotation = q35(Vec3Fix::UNIT_Z);
3277        let theta = theta35();
3278        assert!(near(rel_twist(&b, Vec3Fix::UNIT_Z), theta));
3279        solve_fixed_joint(&j, &mut b, DT);
3280        let after = rel_twist(&b, Vec3Fix::UNIT_Z);
3281        assert!(near(after, theta / fi(4)), "{after:?} vs θ/4");
3282        assert_eq!(b[0].rotation, QuatFix::IDENTITY, "static A never rotates");
3283        // rigid (compliance 0) + w_b = 1: one solve restores the identity exactly
3284        let rigid = FixedJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, QuatFix::IDENTITY);
3285        let mut r = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3286        set_inv_inertia(&mut r, Fix128::ZERO, Fix128::ONE);
3287        r[1].rotation = q35(Vec3Fix::UNIT_Z);
3288        solve_fixed_joint(&rigid, &mut r, DT);
3289        assert!(near(rel_twist(&r, Vec3Fix::UNIT_Z), Fix128::ZERO));
3290        assert!(near(r[1].rotation.w.abs(), Fix128::ONE));
3291    }
3292
3293    /// Kills `823:18` (`<` → `==`), `824:35` (`min − angle` → `/`, `+`), `826:16`
3294    /// (`!w_ang.is_zero()` negation), `827:45` (`1 / w` → `*`), `833:21` (sign of λ),
3295    /// `833:29` (`error * inv_w` → `/`, `+`), `1296` (`w_a + w_b` → `*`) and `1316` (`w_a * λ`).
3296    ///
3297    /// Static A, `w_b = 2`: B = q35(z) (`θ ≈ 1.287`), limits `[2, 3]` → `e = 2 − θ ≈ 0.713`,
3298    /// `λ = −e / 2`, B rotates `−w_b λ = +e` → the angle lands on the limit, exactly 2.
3299    /// Mutants: `==` / `!` → 1.287; `/` (`2/θ`) → 2.841; `+` → θ+3.287 (wraps to −1.709);
3300    /// `inv_w = 2` and `e / inv_w` → B rotates 4e → wraps to −2.144; `e + 1/2` → wraps to −2.57;
3301    /// sign → 0.574; `w_a * w_b = 0` → no move.
3302    /// Two dynamic bodies, `w_a = 2`, `w_b = 6` (`w = 8`): A rotates `2λ = −e/4`, B rotates
3303    /// `+6e/8`, relative angle again exactly 2 and A's own angle is `−e/4`.
3304    #[test]
3305    fn hinge_min_limit_lands_exactly_on_the_limit() {
3306        let j = HingeJoint::new(
3307            0,
3308            1,
3309            Vec3Fix::ZERO,
3310            Vec3Fix::ZERO,
3311            Vec3Fix::UNIT_Z,
3312            Vec3Fix::UNIT_Z,
3313        )
3314        .with_limits(fi(2), fi(3));
3315        let theta = theta35();
3316        let e = fi(2) - theta;
3317        let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3318        set_inv_inertia(&mut b, Fix128::ZERO, fi(2));
3319        b[1].rotation = q35(Vec3Fix::UNIT_Z);
3320        assert!(near(rel_twist(&b, Vec3Fix::UNIT_Z), theta));
3321        solve_hinge_joint(&j, &mut b, DT);
3322        let after = rel_twist(&b, Vec3Fix::UNIT_Z);
3323        assert!(near(after, fi(2)), "{after:?} vs 2");
3324        assert_eq!(b[0].rotation, QuatFix::IDENTITY);
3325
3326        let mut both = pair(Vec3Fix::ZERO, 1, Vec3Fix::ZERO, 1);
3327        set_inv_inertia(&mut both, fi(2), fi(6));
3328        both[1].rotation = q35(Vec3Fix::UNIT_Z);
3329        solve_hinge_joint(&j, &mut both, DT);
3330        let rel = rel_twist(&both, Vec3Fix::UNIT_Z);
3331        let a_angle = compute_twist_angle(both[0].rotation, Vec3Fix::UNIT_Z);
3332        let b_angle = compute_twist_angle(both[1].rotation, Vec3Fix::UNIT_Z);
3333        assert!(near(rel, fi(2)), "rel {rel:?} vs 2");
3334        assert!(near(a_angle, -(e / fi(4))), "A {a_angle:?} vs −e/4");
3335        assert!(
3336            near(b_angle, theta + (e * fi(3)) / fi(4)),
3337            "B {b_angle:?} vs θ + 3e/4"
3338        );
3339    }
3340
3341    /// Kills `837:31` (`angle − max` → `+`) and `846` (`error * inv_w` → `/`, `+`).
3342    ///
3343    /// Static A, `w_b = 2`, limits `[1/4, 1/2]`: `e = θ − 1/2 ≈ 0.787`, `λ = e / 2`,
3344    /// B rotates `−2λ = −e` → lands exactly on 1/2.
3345    /// Mutants: `+` → `e = θ + 1/2` → B ends at `−1/2`; `e / inv_w = 2e` → B rotates `−4e`
3346    /// → wraps to 2.14; `e + inv_w` → 1.287 − 2.574 = −1.287.
3347    #[test]
3348    fn hinge_max_limit_lands_exactly_on_the_limit() {
3349        let j = HingeJoint::new(
3350            0,
3351            1,
3352            Vec3Fix::ZERO,
3353            Vec3Fix::ZERO,
3354            Vec3Fix::UNIT_Z,
3355            Vec3Fix::UNIT_Z,
3356        )
3357        .with_limits(Fix128::from_ratio(1, 4), Fix128::from_ratio(1, 2));
3358        let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3359        set_inv_inertia(&mut b, Fix128::ZERO, fi(2));
3360        b[1].rotation = q35(Vec3Fix::UNIT_Z);
3361        solve_hinge_joint(&j, &mut b, DT);
3362        let after = rel_twist(&b, Vec3Fix::UNIT_Z);
3363        assert!(near(after, Fix128::from_ratio(1, 2)), "{after:?} vs 1/2");
3364    }
3365
3366    /// Negative-angle branch (live since `compute_twist_angle` became signed): B = q35(z)⁻¹
3367    /// reads `−θ ≈ −1.287`, limits `[−1/2, 1/2]` → `angle < min`, `e = −1/2 + θ ≈ 0.787`,
3368    /// `λ = −e / w_b`, B rotates `+e` → lands exactly on `−1/2`.
3369    /// Also pins the sign convention itself: `q` and `−q` (same rotation) read the same angle,
3370    /// and the inverse reads the negated angle (kills the `w < 0` flip in `compute_twist_angle`,
3371    /// 1347-1348, and `.double()` at 1352).
3372    #[test]
3373    fn hinge_min_limit_with_negative_angle_pushes_toward_min() {
3374        let theta = theta35();
3375        let q = q35(Vec3Fix::UNIT_Z);
3376        let neg_q = QuatFix::new(-q.x, -q.y, -q.z, -q.w);
3377        assert!(near(compute_twist_angle(q, Vec3Fix::UNIT_Z), theta));
3378        assert!(near(compute_twist_angle(neg_q, Vec3Fix::UNIT_Z), theta));
3379        assert!(near(
3380            compute_twist_angle(q.conjugate(), Vec3Fix::UNIT_Z),
3381            -theta
3382        ));
3383
3384        let j = HingeJoint::new(
3385            0,
3386            1,
3387            Vec3Fix::ZERO,
3388            Vec3Fix::ZERO,
3389            Vec3Fix::UNIT_Z,
3390            Vec3Fix::UNIT_Z,
3391        )
3392        .with_limits(Fix128::from_ratio(-1, 2), Fix128::from_ratio(1, 2));
3393        let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3394        set_inv_inertia(&mut b, Fix128::ZERO, Fix128::ONE);
3395        b[1].rotation = q.conjugate();
3396        assert!(near(rel_twist(&b, Vec3Fix::UNIT_Z), -theta));
3397        solve_hinge_joint(&j, &mut b, DT);
3398        let after = rel_twist(&b, Vec3Fix::UNIT_Z);
3399        assert!(near(after, Fix128::from_ratio(-1, 2)), "{after:?} vs −1/2");
3400        // positive side: +θ lands on +1/2
3401        let mut p = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3402        set_inv_inertia(&mut p, Fix128::ZERO, Fix128::ONE);
3403        p[1].rotation = q;
3404        solve_hinge_joint(&j, &mut p, DT);
3405        assert!(near(
3406            rel_twist(&p, Vec3Fix::UNIT_Z),
3407            Fix128::from_ratio(1, 2)
3408        ));
3409    }
3410
3411    /// Regression pin: with the angle exactly on a limit nothing happens, bit for bit.
3412    /// (`<` → `<=` / `>` → `>=` at 823 / 836 are equivalent mutants: they enter the branch
3413    /// with `error = 0`, `λ = 0`, and `rotate_by_angle` returns `q` unchanged for `θ = 0`.)
3414    #[test]
3415    fn hinge_limit_equality_is_a_bitwise_no_op() {
3416        let mk = |min: Fix128, max: Fix128| {
3417            HingeJoint::new(
3418                0,
3419                1,
3420                Vec3Fix::ZERO,
3421                Vec3Fix::ZERO,
3422                Vec3Fix::UNIT_Z,
3423                Vec3Fix::UNIT_Z,
3424            )
3425            .with_limits(min, max)
3426        };
3427        let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3428        set_inv_inertia(&mut b, fi(3), fi(1));
3429        b[1].rotation = q35(Vec3Fix::UNIT_Z);
3430        let before = b[1].rotation;
3431        let theta = rel_twist(&b, Vec3Fix::UNIT_Z);
3432        // angle == min
3433        solve_hinge_joint(&mk(theta, theta + fi(1)), &mut b, DT);
3434        assert_eq!(b[1].rotation, before, "angle == min must not touch B");
3435        // angle == max
3436        solve_hinge_joint(&mk(theta - fi(1), theta), &mut b, DT);
3437        assert_eq!(b[1].rotation, before, "angle == max must not touch B");
3438    }
3439
3440    /// Kills `927:36` (`pos_a + rot(anchor_a)` → `-`) and `930:26` (`anchor_b − anchor_a` → `+`).
3441    ///
3442    /// A static at 0 with `local_anchor_a = (0, 1, 0)`; B inv_mass 1 at `(3, 5, 0)`, axis x.
3443    /// `delta = (3, 4, 0)`, perpendicular part `(0, 4, 0)` (power of two → exact `1/len`)
3444    /// → B moves to `(3, 1, 0)`. Either mutant yields `delta = (3, 6, 0)` → B near `(3, −1, 0)`.
3445    #[test]
3446    fn slider_adds_anchor_a_and_uses_b_minus_a() {
3447        let j = SliderJoint::new(0, 1, Vec3Fix::UNIT_X, v3i(0, 1, 0), Vec3Fix::ZERO);
3448        let mut b = pair(Vec3Fix::ZERO, 0, v3i(3, 5, 0), 1);
3449        solve_slider_joint(&j, &mut b, DT);
3450        assert_eq!(b[1].position, v3i(3, 1, 0));
3451    }
3452
3453    /// Kills `965:45` (`1 / w` → `*`), `966:54` (`error * inv_w` → `/`), `967:20`
3454    /// (`!inv_mass_a.is_zero()` negation), `969:55` (A `−` → `+`), `969:68` (A `* inv_mass` → `/`),
3455    /// `973:68` (B `* inv_mass` → `/`) and `984:68` (max branch, A `* inv_mass` → `/`).
3456    ///
3457    /// A inv_mass 3 at 0, B inv_mass 5, limits `[−1, 2]`.
3458    /// min: B at `(−5, 0, 0)` → along −5, `e = 4`, `w = 8`, `correction = 4/8 = 1/2` along x:
3459    /// A −= 1/2 · 3 = 3/2 → `(−3/2, 0, 0)`, B += 1/2 · 5 = 5/2 → `(−5/2, 0, 0)` (along = −1).
3460    /// Mutants: `inv_w = 8` / `e / inv_w` → correction 32; `!` → A stays at 0; `+` → A at +3/2;
3461    /// A `/` → 1/6; B `/` → 1/10.
3462    /// max: B at `(6, 0, 0)` → `e = 4`, correction 1/2: A += 3/2, B −= 5/2 → along = 2.
3463    #[test]
3464    fn slider_limit_corrections_split_by_inverse_mass() {
3465        let j = SliderJoint::new(0, 1, Vec3Fix::UNIT_X, Vec3Fix::ZERO, Vec3Fix::ZERO)
3466            .with_limits(fi(-1), fi(2));
3467        let half = Fix128::from_ratio(1, 2);
3468        let mut under = pair(Vec3Fix::ZERO, 3, v3i(-5, 0, 0), 5);
3469        solve_slider_joint(&j, &mut under, DT);
3470        assert_eq!(
3471            under[0].position,
3472            Vec3Fix::new(-(fi(3) * half), Fix128::ZERO, Fix128::ZERO)
3473        );
3474        assert_eq!(
3475            under[1].position,
3476            Vec3Fix::new(-(fi(5) * half), Fix128::ZERO, Fix128::ZERO)
3477        );
3478        assert_eq!(under[1].position.x - under[0].position.x, fi(-1));
3479        let mut over = pair(Vec3Fix::ZERO, 3, v3i(6, 0, 0), 5);
3480        solve_slider_joint(&j, &mut over, DT);
3481        assert_eq!(
3482            over[0].position,
3483            Vec3Fix::new(fi(3) * half, Fix128::ZERO, Fix128::ZERO)
3484        );
3485        assert_eq!(
3486            over[1].position,
3487            Vec3Fix::new(fi(7) * half, Fix128::ZERO, Fix128::ZERO)
3488        );
3489        assert_eq!(over[1].position.x - over[0].position.x, fi(2));
3490    }
3491
3492    /// Kills `1015:35` (`v_b − v_a` → `+`).
3493    ///
3494    /// rest 4 = distance (spring force 0), damping 2, `v_a = (1, 0, 0)`, `v_b = (3, 0, 0)`:
3495    /// relative velocity along the normal is `3 − 1 = 2` → `F = 4`; XPBD multiplier with
3496    /// implicit damping `λ = dt² F / (1 + (k dt² + c dt) w) = (4/16) / (1 + (2/16 + 2/4)·2)
3497    /// = 1/9` → A `(1/9, 0, 0)`, B `(4 − 1/9, 0, 0)`. Mutant: `3 + 1 = 4` → `F = 8` →
3498    /// `λ = 2/9` → A `(2/9, 0, 0)`.
3499    #[test]
3500    fn spring_damping_uses_relative_velocity_b_minus_a() {
3501        let j = SpringJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, fi(4), fi(2), fi(2));
3502        let mut b = pair(Vec3Fix::ZERO, 1, v3i(4, 0, 0), 1);
3503        b[0].velocity = v3i(1, 0, 0);
3504        b[1].velocity = v3i(3, 0, 0);
3505        solve_spring_joint(&j, &mut b, DT);
3506        assert!(
3507            near_v(
3508                b[0].position,
3509                Vec3Fix::new(Fix128::from_ratio(1, 9), Fix128::ZERO, Fix128::ZERO)
3510            ),
3511            "A {:?}",
3512            b[0].position
3513        );
3514        assert!(
3515            near_v(
3516                b[1].position,
3517                Vec3Fix::new(Fix128::from_ratio(35, 9), Fix128::ZERO, Fix128::ZERO)
3518            ),
3519            "B {:?}",
3520            b[1].position
3521        );
3522    }
3523
3524    /// Kills `1115:55` (`angular_compliance / dt²` → `*`), `1115:61` (`dt * dt` → `/`, `+`),
3525    /// `1159` (`angular_w_sum + angular_compliance` → `-`, `*`) and `1166` (`error / w_ang` → `*`).
3526    ///
3527    /// Static A, `w_b = 3/4`, `angular_compliance = 3/64` → term 3/4, `w = 3/2`. z Locked,
3528    /// B = q35(z): `error = θ`, `λ = 2θ/3`, B rotates `−(3/4)(2θ/3) = −θ/2` → residual `θ/2`.
3529    /// Mutants: `w ∈ {0.753, 0.797, 0.844}` → residual `{0.004θ, 0.059θ, 0.111θ}`;
3530    /// `w = 0` → no move (θ); `w = 0.5625` → `−0.33θ`; `error * w` → `λ = 1.5θ` → `−0.125θ`.
3531    #[test]
3532    fn d6_locked_angular_axis_uses_angular_compliance_and_inertia_sum() {
3533        let mut j = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
3534        j.angular_z = D6Motion::Locked;
3535        j.angular_compliance = Fix128::from_ratio(3, 64);
3536        let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3537        set_inv_inertia(&mut b, Fix128::ZERO, Fix128::from_ratio(3, 4));
3538        b[1].rotation = q35(Vec3Fix::UNIT_Z);
3539        let theta = theta35();
3540        solve_d6_joint(&j, &mut b, DT);
3541        let after = rel_twist(&b, Vec3Fix::UNIT_Z);
3542        assert!(near(after, theta.half()), "{after:?} vs θ/2");
3543    }
3544
3545    /// Kills `1146:30` (`<` → `==`), `1147:31` (`angle − min` → `/`, `+`) and `1149:31`
3546    /// (`angle − max` → `+`); also exercises the live negative-angle `angle < min` branch.
3547    ///
3548    /// Static A, `w_b = 1`, no compliance → `λ = error`, B rotates `−error`: one solve lands
3549    /// exactly on the violated limit.
3550    /// min: limits `[2, 3]`, B = q35(z) → `error = θ − 2 < 0` → angle becomes 2.
3551    /// Mutants: `==` → 1.287; `/` (`θ/2`) → 0.6435; `+` (`θ + 2`) → −2.0.
3552    /// max: limits `[1/4, 1/2]` → `error = θ − 1/2` → angle 1/2. Mutant `+` → −1/2.
3553    /// negative: B = q35(z)⁻¹ (angle −θ), limits `[−1/2, 1/2]` → `error = −θ + 1/2` → −1/2.
3554    #[test]
3555    fn d6_limited_angular_axis_lands_exactly_on_the_limit() {
3556        let mk = |min: Fix128, max: Fix128| {
3557            let mut j = D6Joint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO);
3558            j.angular_z = D6Motion::Limited;
3559            j.angular_limit_min.z = min;
3560            j.angular_limit_max.z = max;
3561            j
3562        };
3563        let half = Fix128::from_ratio(1, 2);
3564        let run = |rot: QuatFix, min: Fix128, max: Fix128| {
3565            let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3566            set_inv_inertia(&mut b, Fix128::ZERO, Fix128::ONE);
3567            b[1].rotation = rot;
3568            solve_d6_joint(&mk(min, max), &mut b, DT);
3569            rel_twist(&b, Vec3Fix::UNIT_Z)
3570        };
3571        let q = q35(Vec3Fix::UNIT_Z);
3572        let under = run(q, fi(2), fi(3));
3573        assert!(near(under, fi(2)), "min: {under:?} vs 2");
3574        let over = run(q, Fix128::from_ratio(1, 4), half);
3575        assert!(near(over, half), "max: {over:?} vs 1/2");
3576        let negative = run(q.conjugate(), -half, half);
3577        assert!(near(negative, -half), "negative: {negative:?} vs −1/2");
3578    }
3579
3580    /// Kills `1225:32` (`cone − limit` → `/`, `+`), `1228:63` (`angular_compliance / dt²` → `*`),
3581    /// `1228:69` (`dt * dt` → `/`, `+`), `1230` (`+ angular_compliance` → `-`, `*`), `1233:45`
3582    /// (`1 / w` → `*`) and `1239:27` (`error * inv_w` → `/`, `+`).
3583    ///
3584    /// Static A, `w_b = 1/2`, `angular_compliance = 1/64` → term 1/4, `w = 3/4`, `inv_w = 4/3`.
3585    /// B = q35(x) → cone angle `φ = θ`, `cone_limit = 1/2` → `e = θ − 1/2 ≈ 0.787`,
3586    /// `λ = 4e/3`, B rotates `−(1/2)(4e/3) = −2e/3` about +x → `φ' = θ − 2e/3 = (θ + 1)/3 ≈ 0.7623`.
3587    /// Mutants: `/` (`2θ`) → 0.429; `+` → 0.096; `w ∈ {0.501, 0.516, 0.531}` → 0.502 / 0.524 /
3588    /// 0.546; `w = 1/4` → 0.287; `w = 1/8` → 1.861; `inv_w = 3/4` and `e / inv_w` → 0.992;
3589    /// `e + inv_w` → 0.227.
3590    #[test]
3591    fn cone_limit_correction_uses_angular_compliance_and_inertia_sum() {
3592        let half = Fix128::from_ratio(1, 2);
3593        let mut j = ConeTwistJoint::new(
3594            0,
3595            1,
3596            Vec3Fix::ZERO,
3597            Vec3Fix::ZERO,
3598            Vec3Fix::UNIT_Z,
3599            Vec3Fix::UNIT_Z,
3600        )
3601        .with_limits(half, half);
3602        j.angular_compliance = Fix128::from_ratio(1, 64);
3603        let mut b = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3604        set_inv_inertia(&mut b, Fix128::ZERO, half);
3605        b[1].rotation = q35(Vec3Fix::UNIT_X);
3606        let theta = theta35();
3607        assert!(near(tilt_z(&b), theta));
3608        let expected = (theta + Fix128::ONE) / fi(3);
3609        solve_cone_twist_joint(&j, &mut b, DT);
3610        let after = tilt_z(&b);
3611        assert!(near(after, expected), "{after:?} vs {expected:?}");
3612        // twist stayed zero: B still rotates about x only
3613        assert!(near(rel_twist(&b, Vec3Fix::UNIT_Z), Fix128::ZERO));
3614        // rigid, w_b = 1: one solve lands exactly on the cone limit
3615        let rigid = ConeTwistJoint::new(
3616            0,
3617            1,
3618            Vec3Fix::ZERO,
3619            Vec3Fix::ZERO,
3620            Vec3Fix::UNIT_Z,
3621            Vec3Fix::UNIT_Z,
3622        )
3623        .with_limits(half, half);
3624        let mut r = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3625        set_inv_inertia(&mut r, Fix128::ZERO, Fix128::ONE);
3626        r[1].rotation = q35(Vec3Fix::UNIT_X);
3627        solve_cone_twist_joint(&rigid, &mut r, DT);
3628        assert!(near(tilt_z(&r), half));
3629    }
3630
3631    /// Kills `1252:36` (`twist_angle > 0` → `<`, `==`), `1253:25` (`twist − limit` → `/`, `+`),
3632    /// `1255` (`twist + limit` → `-`, now live), `1258` / `1260` (compliance term) and
3633    /// `1269` (`error * inv_w`).
3634    ///
3635    /// Static A, `w_b = 1/2`, `angular_compliance = 1/64` → `w = 3/4`, `inv_w = 4/3`,
3636    /// `twist_limit = 1/2`.
3637    /// positive: B = q35(z), twist `θ`, `e = θ − 1/2`, B rotates `−2e/3` → `(θ + 1)/3 ≈ 0.7623`.
3638    /// `<` / `==` take the `twist + limit` branch (`e = θ + 1/2`) → 0.096; `/` → −0.429.
3639    /// negative: B = q35(z)⁻¹, twist `−θ`, `e = −θ + 1/2`, B rotates `+2|e|/3` → `−(θ + 1)/3`.
3640    /// Mutant `twist − limit` there → `e = −θ − 1/2` → −0.096.
3641    #[test]
3642    fn twist_limit_correction_error_arithmetic() {
3643        let half = Fix128::from_ratio(1, 2);
3644        let mut j = ConeTwistJoint::new(
3645            0,
3646            1,
3647            Vec3Fix::ZERO,
3648            Vec3Fix::ZERO,
3649            Vec3Fix::UNIT_Z,
3650            Vec3Fix::UNIT_Z,
3651        )
3652        .with_limits(half, half);
3653        j.angular_compliance = Fix128::from_ratio(1, 64);
3654        let theta = theta35();
3655        let expected = (theta + Fix128::ONE) / fi(3);
3656        let q = q35(Vec3Fix::UNIT_Z);
3657
3658        let mut pos = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3659        set_inv_inertia(&mut pos, Fix128::ZERO, half);
3660        pos[1].rotation = q;
3661        solve_cone_twist_joint(&j, &mut pos, DT);
3662        let after = rel_twist(&pos, Vec3Fix::UNIT_Z);
3663        assert!(near(after, expected), "positive: {after:?} vs {expected:?}");
3664        assert!(near(tilt_z(&pos), Fix128::ZERO));
3665
3666        let mut neg = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3667        set_inv_inertia(&mut neg, Fix128::ZERO, half);
3668        neg[1].rotation = q.conjugate();
3669        assert!(near(rel_twist(&neg, Vec3Fix::UNIT_Z), -theta));
3670        solve_cone_twist_joint(&j, &mut neg, DT);
3671        let after = rel_twist(&neg, Vec3Fix::UNIT_Z);
3672        assert!(
3673            near(after, -expected),
3674            "negative: {after:?} vs {:?}",
3675            -expected
3676        );
3677
3678        // rigid, w_b = 1: ±θ land exactly on ±1/2
3679        let rigid = ConeTwistJoint::new(
3680            0,
3681            1,
3682            Vec3Fix::ZERO,
3683            Vec3Fix::ZERO,
3684            Vec3Fix::UNIT_Z,
3685            Vec3Fix::UNIT_Z,
3686        )
3687        .with_limits(half, half);
3688        for (rot, limit) in [(q, half), (q.conjugate(), -half)] {
3689            let mut r = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3690            set_inv_inertia(&mut r, Fix128::ZERO, Fix128::ONE);
3691            r[1].rotation = rot;
3692            solve_cone_twist_joint(&rigid, &mut r, DT);
3693            let t = rel_twist(&r, Vec3Fix::UNIT_Z);
3694            assert!(near(t, limit), "{t:?} vs {limit:?}");
3695        }
3696    }
3697
3698    /// Regression pin: cone / twist angle exactly on the limit is a bitwise no-op.
3699    /// (`>` → `>=` at 1224 / 1251 are equivalent mutants: `error = 0`, `λ = 0`, and
3700    /// `rotate_by_angle` returns `q` unchanged for `θ = 0`.)
3701    #[test]
3702    fn cone_and_twist_limit_equality_is_a_bitwise_no_op() {
3703        let mk = |cone: Fix128, twist: Fix128| {
3704            ConeTwistJoint::new(
3705                0,
3706                1,
3707                Vec3Fix::ZERO,
3708                Vec3Fix::ZERO,
3709                Vec3Fix::UNIT_Z,
3710                Vec3Fix::UNIT_Z,
3711            )
3712            .with_limits(cone, twist)
3713        };
3714        // cone == limit (twist is 0 < π)
3715        let mut c = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3716        c[1].rotation = q35(Vec3Fix::UNIT_X);
3717        let a = c[0].rotation.rotate_vec(Vec3Fix::UNIT_Z).normalize();
3718        let bz = c[1].rotation.rotate_vec(Vec3Fix::UNIT_Z).normalize();
3719        let (_, cross_len) = a.cross(bz).normalize_with_length();
3720        let cone = Fix128::atan2(cross_len, a.dot(bz));
3721        let before = c[1].rotation;
3722        solve_cone_twist_joint(&mk(cone, Fix128::PI), &mut c, DT);
3723        assert_eq!(c[1].rotation, before, "cone == limit must not touch B");
3724        // twist == limit (cone is 0 < π/2)
3725        let mut t = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3726        t[1].rotation = q35(Vec3Fix::UNIT_Z);
3727        let twist = rel_twist(&t, Vec3Fix::UNIT_Z);
3728        let before = t[1].rotation;
3729        solve_cone_twist_joint(&mk(Fix128::HALF_PI, twist), &mut t, DT);
3730        assert_eq!(t[1].rotation, before, "twist == limit must not touch B");
3731    }
3732
3733    /// Kills the anisotropic terms of `angular_inverse_mass` (1282-1285: body-frame
3734    /// `conjugate()` rotation, `x²·I_x + y²·I_y + z²·I_z` → `-`, `/`), its static
3735    /// early-return (1279), and the `w_a λ` / `−w_b λ` split in `apply_angular_correction`
3736    /// (1313-1319).
3737    ///
3738    /// A = q35(x), B = q35(z) ⊗ q35(x) (B is A twisted by `θ` about world z), both hinge
3739    /// local axes are `(0, 24/25, 7/25)` = world z in either body frame, so the axes stay
3740    /// aligned and the limit branch measures a relative twist of exactly `θ` about world z.
3741    /// `inv_inertia_a = (5, 2, 7)` → `w_a = (24/25)²·2 + (7/25)²·7 = (1152 + 343)/625 = 2.392`,
3742    /// `inv_inertia_b = (11, 13, 3)` → `w_b = (24/25)²·13 + (7/25)²·3 = (7488 + 147)/625 = 12.216`,
3743    /// `w = 9130/625`. Limits `[2, 3]`: `e = 2 − θ`, A rotates `−w_a e / w` about z, B
3744    /// `+w_b e / w`; the relative angle lands exactly on 2 and A's rotation is `−1495 e / 9130`.
3745    /// (Isotropic-inertia mutants would give `w_a = 5` or `7`, `w_b = 11` or `3`; dropping
3746    /// `conjugate()` rotates the axis the wrong way: `(0, −24/25, 7/25)` has the same squares
3747    /// here, which is why the sign of A's rotation and the `x²I_x − y²I_y` mutants are pinned
3748    /// through `w_a` / `w_b` and the exact landing on 2.)
3749    #[test]
3750    fn angular_inverse_mass_is_anisotropic_and_split_by_body() {
3751        let local_axis = Vec3Fix::new(
3752            Fix128::ZERO,
3753            Fix128::from_ratio(24, 25),
3754            Fix128::from_ratio(7, 25),
3755        );
3756        let j = HingeJoint::new(0, 1, Vec3Fix::ZERO, Vec3Fix::ZERO, local_axis, local_axis)
3757            .with_limits(fi(2), fi(3));
3758        let theta = theta35();
3759        let e = fi(2) - theta;
3760        let qa = q35(Vec3Fix::UNIT_X);
3761        let mut b = pair(Vec3Fix::ZERO, 1, Vec3Fix::ZERO, 1);
3762        b[0].inv_inertia = v3i(5, 2, 7);
3763        b[1].inv_inertia = v3i(11, 13, 3);
3764        b[0].rotation = qa;
3765        b[1].rotation = q35(Vec3Fix::UNIT_Z).mul(qa).normalize();
3766        assert!(near_v(qa.rotate_vec(local_axis), Vec3Fix::UNIT_Z));
3767        assert!(near_v(
3768            b[1].rotation.rotate_vec(local_axis),
3769            Vec3Fix::UNIT_Z
3770        ));
3771        assert!(near(rel_twist(&b, Vec3Fix::UNIT_Z), theta));
3772        let w_a = Fix128::from_ratio(1152 + 343, 625);
3773        let w_b = Fix128::from_ratio(7488 + 147, 625);
3774        assert!(near(angular_inverse_mass(&b[0], Vec3Fix::UNIT_Z), w_a));
3775        assert!(near(angular_inverse_mass(&b[1], Vec3Fix::UNIT_Z), w_b));
3776        let a_before = b[0].rotation;
3777        solve_hinge_joint(&j, &mut b, DT);
3778        let rel = rel_twist(&b, Vec3Fix::UNIT_Z);
3779        assert!(near(rel, fi(2)), "rel {rel:?} vs 2");
3780        let a_delta = b[0].rotation.mul(a_before.conjugate());
3781        let a_angle = compute_twist_angle(a_delta, Vec3Fix::UNIT_Z);
3782        let expected_a = -(w_a * e) / (w_a + w_b);
3783        assert!(near(a_angle, expected_a), "A {a_angle:?} vs {expected_a:?}");
3784        // A rotated about world z only: its own z axis direction is unchanged
3785        assert!(near_v(
3786            b[0].rotation.rotate_vec(local_axis),
3787            Vec3Fix::UNIT_Z
3788        ));
3789        // static A contributes 0 even with non-zero inv_inertia
3790        let mut s = pair(Vec3Fix::ZERO, 0, Vec3Fix::ZERO, 1);
3791        s[0].inv_inertia = v3i(5, 2, 7);
3792        assert_eq!(angular_inverse_mass(&s[0], Vec3Fix::UNIT_Z), Fix128::ZERO);
3793    }
3794
3795    /// oracle: for an isotropic `inv_inertia = (c, c, c)` the generalised
3796    /// inverse mass `n · R diag(c, c, c) Rᵀ n` is `c (n·n)` for every
3797    /// rotation; the value must be the orientation-free sum
3798    /// `n.x²·c + n.y²·c + n.z²·c`, bit for bit, for 64 non-identity
3799    /// orientations (the body-frame rotation of `n` rounds differently for
3800    /// each of them).
3801    #[test]
3802    fn angular_inverse_mass_of_an_isotropic_body_does_not_depend_on_orientation() {
3803        let c = Fix128::from_ratio(5, 2);
3804        let n = Vec3Fix::new(
3805            Fix128::from_ratio(3, 7),
3806            Fix128::from_ratio(-11, 13),
3807            Fix128::from_ratio(1, 3),
3808        );
3809        let expected = n.x * n.x * c + n.y * n.y * c + n.z * n.z * c;
3810        let mut differ = 0;
3811        for i in 0..16_i64 {
3812            let axis = Vec3Fix::new(
3813                Fix128::from_ratio(1 + i, 3),
3814                Fix128::from_ratio(2 - i, 5),
3815                Fix128::from_ratio(3 + 2 * i, 7),
3816            )
3817            .normalize();
3818            for k in 1..=4_i64 {
3819                let q =
3820                    QuatFix::from_axis_angle(axis, Fix128::from_ratio(7 * k + i, 9)).normalize();
3821                let mut b = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
3822                b.inv_inertia = Vec3Fix::new(c, c, c);
3823                b.rotation = q;
3824                differ += usize::from(angular_inverse_mass(&b, n) != expected);
3825            }
3826        }
3827        assert_eq!(differ, 0, "{differ}/64 orientations differ from c·(n·n)");
3828    }
3829}