Skip to main content

oxiphysics_collision/
joint_collision.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Joint-limit collision: revolute, prismatic, and ball-socket angle limits
5//! modelled as constraints.  Covers soft/hard stops, gear-ratio response,
6//! cable/chain limits, articulated-body joint stops, motor hard limits,
7//! joint friction as a constraint, compliance at limits, and warm-starting.
8//!
9//! All arithmetic uses plain `f64` and `[f64; 3]` arrays — no nalgebra.
10
11// ─────────────────────────────────────────────────────────────────────────────
12// Basic 3-vector helpers
13// ─────────────────────────────────────────────────────────────────────────────
14
15/// Add two 3-vectors.
16#[inline]
17pub fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
18    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
19}
20
21/// Subtract two 3-vectors.
22#[inline]
23pub fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
24    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
25}
26
27/// Scale a 3-vector.
28#[inline]
29pub fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
30    [a[0] * s, a[1] * s, a[2] * s]
31}
32
33/// Dot product.
34#[inline]
35pub fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
36    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
37}
38
39/// Euclidean norm.
40#[inline]
41pub fn norm3(a: [f64; 3]) -> f64 {
42    dot3(a, a).sqrt()
43}
44
45/// Normalise; returns zero vector if near-zero.
46#[inline]
47pub fn normalize3(a: [f64; 3]) -> [f64; 3] {
48    let n = norm3(a);
49    if n < 1e-14 {
50        [0.0; 3]
51    } else {
52        scale3(a, 1.0 / n)
53    }
54}
55
56/// Clamp `v` to `[lo, hi]`.
57#[inline]
58pub fn clampf(v: f64, lo: f64, hi: f64) -> f64 {
59    v.max(lo).min(hi)
60}
61
62// ─────────────────────────────────────────────────────────────────────────────
63// JointType
64// ─────────────────────────────────────────────────────────────────────────────
65
66/// Classification of joint kinematics for limit enforcement.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum JointType {
69    /// Single rotational degree of freedom.
70    Revolute,
71    /// Single translational degree of freedom.
72    Prismatic,
73    /// Three rotational degrees of freedom (ball-and-socket).
74    BallSocket,
75}
76
77// ─────────────────────────────────────────────────────────────────────────────
78// JointLimits
79// ─────────────────────────────────────────────────────────────────────────────
80
81/// Angular or translational limits for a joint.
82#[derive(Debug, Clone)]
83pub struct JointLimits {
84    /// Minimum angle (rad) or translation (m).
85    pub lower: f64,
86    /// Maximum angle (rad) or translation (m).
87    pub upper: f64,
88    /// Whether limits are active.
89    pub enabled: bool,
90}
91
92impl JointLimits {
93    /// Create joint limits.
94    pub fn new(lower: f64, upper: f64) -> Self {
95        Self {
96            lower,
97            upper,
98            enabled: true,
99        }
100    }
101
102    /// Return `true` if `value` is outside the limits.
103    #[inline]
104    pub fn is_violated(&self, value: f64) -> bool {
105        self.enabled && (value < self.lower || value > self.upper)
106    }
107
108    /// Signed penetration: positive when below lower limit, negative when above upper.
109    #[inline]
110    pub fn penetration(&self, value: f64) -> f64 {
111        if value < self.lower {
112            self.lower - value
113        } else if value > self.upper {
114            value - self.upper
115        } else {
116            0.0
117        }
118    }
119
120    /// Clamp `value` to `[lower, upper]`.
121    #[inline]
122    pub fn clamp(&self, value: f64) -> f64 {
123        clampf(value, self.lower, self.upper)
124    }
125}
126
127// ─────────────────────────────────────────────────────────────────────────────
128// StopType — soft vs hard
129// ─────────────────────────────────────────────────────────────────────────────
130
131/// How the joint stop is modelled.
132#[derive(Debug, Clone, Copy, PartialEq)]
133pub enum StopType {
134    /// Rigid constraint: velocity is zeroed and position is projected.
135    Hard,
136    /// Spring-damper constraint: penalty force proportional to penetration.
137    Soft {
138        /// Stiffness coefficient (N/m or N·m/rad).
139        stiffness: f64,
140        /// Damping coefficient.
141        damping: f64,
142    },
143}
144
145impl StopType {
146    /// Compute the constraint force for the given penetration depth and velocity.
147    ///
148    /// For a `Hard` stop returns a large penalty impulse.
149    /// For a `Soft` stop returns `stiffness * penetration + damping * velocity`.
150    pub fn constraint_force(&self, penetration: f64, velocity: f64) -> f64 {
151        match *self {
152            StopType::Hard => {
153                // Return a large restoring force (handled separately as impulse).
154                if penetration > 0.0 {
155                    1e9 * penetration
156                } else {
157                    0.0
158                }
159            }
160            StopType::Soft { stiffness, damping } => {
161                if penetration > 0.0 {
162                    stiffness * penetration - damping * velocity.min(0.0)
163                } else {
164                    0.0
165                }
166            }
167        }
168    }
169}
170
171// ─────────────────────────────────────────────────────────────────────────────
172// JointLimitConstraint
173// ─────────────────────────────────────────────────────────────────────────────
174
175/// A constraint that enforces a joint limit.
176#[derive(Debug, Clone)]
177pub struct JointLimitConstraint {
178    /// Body index A (parent).
179    pub body_a: usize,
180    /// Body index B (child).
181    pub body_b: usize,
182    /// Type of joint.
183    pub joint_type: JointType,
184    /// Lower and upper limits.
185    pub limits: JointLimits,
186    /// Stop model.
187    pub stop_type: StopType,
188    /// Current joint position (angle or translation).
189    pub position: f64,
190    /// Current joint velocity.
191    pub velocity: f64,
192    /// Accumulated impulse from previous frame (warm-start).
193    pub warm_impulse: f64,
194    /// Joint axis in world space (for revolute/prismatic).
195    pub axis: [f64; 3],
196}
197
198impl JointLimitConstraint {
199    /// Create a joint limit constraint.
200    pub fn new(
201        body_a: usize,
202        body_b: usize,
203        joint_type: JointType,
204        limits: JointLimits,
205        stop_type: StopType,
206        axis: [f64; 3],
207    ) -> Self {
208        Self {
209            body_a,
210            body_b,
211            joint_type,
212            limits,
213            stop_type,
214            position: 0.0,
215            velocity: 0.0,
216            warm_impulse: 0.0,
217            axis: normalize3(axis),
218        }
219    }
220
221    /// Whether the joint is currently violating its limit.
222    pub fn is_active(&self) -> bool {
223        self.limits.is_violated(self.position)
224    }
225
226    /// Signed penetration depth (> 0 means violated).
227    pub fn penetration_depth(&self) -> f64 {
228        self.limits.penetration(self.position)
229    }
230
231    /// Compute the constraint impulse to resolve the limit violation.
232    ///
233    /// Returns the scalar impulse along the joint axis.  Positive impulse
234    /// pushes the position back into limits.
235    pub fn compute_impulse(&self, inv_mass_a: f64, inv_mass_b: f64, dt: f64) -> f64 {
236        let pen = self.penetration_depth();
237        if pen <= 0.0 {
238            return 0.0;
239        }
240        let force = self.stop_type.constraint_force(pen, self.velocity);
241        let eff_mass = inv_mass_a + inv_mass_b;
242        if eff_mass < 1e-14 {
243            return 0.0;
244        }
245        force * dt / eff_mass
246    }
247
248    /// Apply warm-starting impulse from the previous step.
249    ///
250    /// Returns the warm impulse (already clamped to `>= 0`).
251    pub fn apply_warm_start(&self) -> f64 {
252        self.warm_impulse.max(0.0)
253    }
254
255    /// Store the accumulated impulse for next-frame warm-starting.
256    pub fn store_impulse(&mut self, impulse: f64) {
257        self.warm_impulse = impulse.max(0.0);
258    }
259}
260
261// ─────────────────────────────────────────────────────────────────────────────
262// BallSocketLimitConstraint
263// ─────────────────────────────────────────────────────────────────────────────
264
265/// Constraint that enforces a cone limit on a ball-and-socket joint.
266///
267/// The cone half-angle `max_angle` (radians) is the maximum allowed swing.
268#[derive(Debug, Clone)]
269pub struct BallSocketLimitConstraint {
270    /// Parent body index.
271    pub body_a: usize,
272    /// Child body index.
273    pub body_b: usize,
274    /// Reference axis in body-A frame (cone axis).
275    pub cone_axis: [f64; 3],
276    /// Maximum cone half-angle (rad).
277    pub max_angle: f64,
278    /// Stop type.
279    pub stop_type: StopType,
280    /// Current swing angle (angle between child axis and cone axis).
281    pub current_angle: f64,
282    /// Warm-start impulse.
283    pub warm_impulse: f64,
284}
285
286impl BallSocketLimitConstraint {
287    /// Create a ball-socket limit constraint.
288    pub fn new(
289        body_a: usize,
290        body_b: usize,
291        cone_axis: [f64; 3],
292        max_angle: f64,
293        stop_type: StopType,
294    ) -> Self {
295        Self {
296            body_a,
297            body_b,
298            cone_axis: normalize3(cone_axis),
299            max_angle,
300            stop_type,
301            current_angle: 0.0,
302            warm_impulse: 0.0,
303        }
304    }
305
306    /// Update the current swing angle given the child-body axis in world space.
307    pub fn update_angle(&mut self, child_axis_world: [f64; 3]) {
308        let d = dot3(self.cone_axis, normalize3(child_axis_world));
309        let d_clamped = clampf(d, -1.0, 1.0);
310        self.current_angle = d_clamped.acos();
311    }
312
313    /// Penetration beyond the cone limit.
314    pub fn penetration(&self) -> f64 {
315        (self.current_angle - self.max_angle).max(0.0)
316    }
317
318    /// Whether the cone limit is violated.
319    pub fn is_violated(&self) -> bool {
320        self.current_angle > self.max_angle
321    }
322
323    /// Compute a corrective angular impulse magnitude.
324    pub fn compute_impulse(&self, inv_inertia_a: f64, inv_inertia_b: f64, dt: f64) -> f64 {
325        let pen = self.penetration();
326        if pen <= 0.0 {
327            return 0.0;
328        }
329        let force = self.stop_type.constraint_force(pen, 0.0);
330        let eff = inv_inertia_a + inv_inertia_b;
331        if eff < 1e-14 { 0.0 } else { force * dt / eff }
332    }
333}
334
335// ─────────────────────────────────────────────────────────────────────────────
336// GearRatioCollision
337// ─────────────────────────────────────────────────────────────────────────────
338
339/// Collision response through a gear transmission.
340///
341/// When joint A hits its limit, the impulse is transmitted to joint B
342/// through a gear ratio `ratio = omega_B / omega_A`.
343#[derive(Debug, Clone)]
344pub struct GearRatioCollision {
345    /// Joint A (driving).
346    pub joint_a: usize,
347    /// Joint B (driven).
348    pub joint_b: usize,
349    /// Gear ratio ω_B / ω_A (can be negative for reversals).
350    pub ratio: f64,
351    /// Efficiency factor ∈ (0, 1].
352    pub efficiency: f64,
353}
354
355impl GearRatioCollision {
356    /// Create a gear-ratio collision link.
357    pub fn new(joint_a: usize, joint_b: usize, ratio: f64, efficiency: f64) -> Self {
358        Self {
359            joint_a,
360            joint_b,
361            ratio,
362            efficiency: efficiency.clamp(0.0, 1.0),
363        }
364    }
365
366    /// Propagate impulse from joint A to joint B.
367    ///
368    /// `impulse_a` is the impulse applied at joint A; returns the resulting
369    /// impulse at joint B.
370    pub fn propagate_impulse(&self, impulse_a: f64) -> f64 {
371        impulse_a * self.ratio * self.efficiency
372    }
373
374    /// Effective inertia at joint A accounting for the gear and driven inertia.
375    ///
376    /// `inertia_b` is the rotational inertia at joint B.
377    pub fn reflected_inertia(&self, inertia_b: f64) -> f64 {
378        inertia_b * self.ratio * self.ratio
379    }
380}
381
382// ─────────────────────────────────────────────────────────────────────────────
383// CableChainLimit
384// ─────────────────────────────────────────────────────────────────────────────
385
386/// Cable or chain joint limit.
387///
388/// A cable prevents extension beyond `max_length`; a chain additionally
389/// prevents compression below `min_length`.
390#[derive(Debug, Clone)]
391pub struct CableChainLimit {
392    /// Body index A (cable anchor).
393    pub body_a: usize,
394    /// Body index B (cable end).
395    pub body_b: usize,
396    /// Minimum allowed distance (0 for cable-only).
397    pub min_length: f64,
398    /// Maximum allowed distance.
399    pub max_length: f64,
400    /// Current distance between anchor and end.
401    pub current_length: f64,
402    /// Relative velocity along the cable direction.
403    pub relative_velocity: f64,
404    /// Accumulated constraint impulse.
405    pub impulse: f64,
406    /// Cable stiffness (for soft stops).
407    pub stiffness: f64,
408    /// Cable damping.
409    pub damping: f64,
410}
411
412impl CableChainLimit {
413    /// Create a cable/chain limit.
414    pub fn new(
415        body_a: usize,
416        body_b: usize,
417        min_length: f64,
418        max_length: f64,
419        stiffness: f64,
420        damping: f64,
421    ) -> Self {
422        Self {
423            body_a,
424            body_b,
425            min_length,
426            max_length,
427            current_length: 0.0,
428            relative_velocity: 0.0,
429            impulse: 0.0,
430            stiffness,
431            damping,
432        }
433    }
434
435    /// Extension violation: positive if cable is too long.
436    pub fn extension_violation(&self) -> f64 {
437        (self.current_length - self.max_length).max(0.0)
438    }
439
440    /// Compression violation: positive if chain is too short.
441    pub fn compression_violation(&self) -> f64 {
442        (self.min_length - self.current_length).max(0.0)
443    }
444
445    /// Net penetration (positive means constraint is violated).
446    pub fn net_penetration(&self) -> f64 {
447        self.extension_violation() + self.compression_violation()
448    }
449
450    /// Compute the corrective impulse along the cable direction.
451    pub fn compute_impulse(&self, inv_mass_a: f64, inv_mass_b: f64, dt: f64) -> f64 {
452        let pen = self.net_penetration();
453        if pen <= 0.0 {
454            return 0.0;
455        }
456        let force = self.stiffness * pen - self.damping * self.relative_velocity.min(0.0);
457        let eff = inv_mass_a + inv_mass_b;
458        if eff < 1e-14 { 0.0 } else { force * dt / eff }
459    }
460}
461
462// ─────────────────────────────────────────────────────────────────────────────
463// ArticulatedBodyJointStop
464// ─────────────────────────────────────────────────────────────────────────────
465
466/// Joint-stop contact between two bodies in an articulated chain.
467///
468/// When a joint hits its limit, the effective mass computation must account
469/// for the propagation of impulses through the articulated body tree.
470#[derive(Debug, Clone)]
471pub struct ArticulatedBodyJointStop {
472    /// Joint identifier.
473    pub joint_id: usize,
474    /// Effective inertia of the subtree (pre-computed by ABFC algorithm).
475    pub effective_inertia: f64,
476    /// Current position in the joint's coordinate.
477    pub position: f64,
478    /// Current velocity.
479    pub velocity: f64,
480    /// Joint limits.
481    pub limits: JointLimits,
482    /// Stop type.
483    pub stop_type: StopType,
484    /// Warm-starting impulse.
485    pub warm_impulse: f64,
486}
487
488impl ArticulatedBodyJointStop {
489    /// Create an articulated body joint stop.
490    pub fn new(
491        joint_id: usize,
492        effective_inertia: f64,
493        limits: JointLimits,
494        stop_type: StopType,
495    ) -> Self {
496        Self {
497            joint_id,
498            effective_inertia,
499            position: 0.0,
500            velocity: 0.0,
501            limits,
502            stop_type,
503            warm_impulse: 0.0,
504        }
505    }
506
507    /// Compute corrective impulse using the articulated effective inertia.
508    pub fn compute_impulse(&self, dt: f64) -> f64 {
509        let pen = self.limits.penetration(self.position);
510        if pen <= 0.0 {
511            return 0.0;
512        }
513        let force = self.stop_type.constraint_force(pen, self.velocity);
514        if self.effective_inertia < 1e-14 {
515            0.0
516        } else {
517            force * dt / self.effective_inertia
518        }
519    }
520
521    /// Velocity impulse to zero out joint velocity when hitting a hard stop.
522    ///
523    /// Returns the impulse needed to zero the outgoing velocity, accounting
524    /// for restitution `e`.
525    pub fn velocity_impulse(&self, restitution: f64) -> f64 {
526        if !self.limits.is_violated(self.position) {
527            return 0.0;
528        }
529        let vel_out = -(1.0 + restitution) * self.velocity;
530        if self.effective_inertia < 1e-14 {
531            0.0
532        } else {
533            vel_out / self.effective_inertia
534        }
535    }
536}
537
538// ─────────────────────────────────────────────────────────────────────────────
539// MotorHardLimit
540// ─────────────────────────────────────────────────────────────────────────────
541
542/// Represents a motor that has absolute position and velocity limits.
543///
544/// When the motor exceeds its limits, a corrective impulse is applied.
545#[derive(Debug, Clone)]
546pub struct MotorHardLimit {
547    /// Maximum positive torque the motor can produce.
548    pub max_torque: f64,
549    /// Maximum positive angular velocity.
550    pub max_velocity: f64,
551    /// Position limits.
552    pub position_limits: JointLimits,
553    /// Current motor angle.
554    pub angle: f64,
555    /// Current motor angular velocity.
556    pub angular_velocity: f64,
557    /// Motor inertia.
558    pub inertia: f64,
559    /// Whether the motor is currently stalled (against hard stop).
560    pub stalled: bool,
561}
562
563impl MotorHardLimit {
564    /// Create a motor hard limit.
565    pub fn new(max_torque: f64, max_velocity: f64, lower: f64, upper: f64, inertia: f64) -> Self {
566        Self {
567            max_torque,
568            max_velocity,
569            position_limits: JointLimits::new(lower, upper),
570            angle: 0.0,
571            angular_velocity: 0.0,
572            inertia,
573            stalled: false,
574        }
575    }
576
577    /// Clamp motor torque to `[-max_torque, max_torque]`.
578    pub fn clamp_torque(&self, torque: f64) -> f64 {
579        clampf(torque, -self.max_torque, self.max_torque)
580    }
581
582    /// Clamp motor velocity to `[-max_velocity, max_velocity]`.
583    pub fn clamp_velocity(&self, vel: f64) -> f64 {
584        clampf(vel, -self.max_velocity, self.max_velocity)
585    }
586
587    /// Check if the motor is against a hard stop.
588    pub fn check_stall(&mut self) {
589        self.stalled = self.position_limits.is_violated(self.angle);
590    }
591
592    /// Impulse to push the motor back into limits (applied when stalled).
593    pub fn limit_impulse(&self, dt: f64) -> f64 {
594        if !self.stalled {
595            return 0.0;
596        }
597        let pen = self.position_limits.penetration(self.angle);
598        if self.inertia < 1e-14 {
599            0.0
600        } else {
601            // Large stiffness for hard stop
602            1e6 * pen * dt / self.inertia
603        }
604    }
605}
606
607// ─────────────────────────────────────────────────────────────────────────────
608// JointFrictionConstraint
609// ─────────────────────────────────────────────────────────────────────────────
610
611/// Joint friction modelled as a velocity-level constraint.
612///
613/// Friction opposes relative velocity at the joint with a Coulomb-like model.
614#[derive(Debug, Clone)]
615pub struct JointFrictionConstraint {
616    /// Body A index.
617    pub body_a: usize,
618    /// Body B index.
619    pub body_b: usize,
620    /// Joint axis (world space).
621    pub axis: [f64; 3],
622    /// Coulomb friction coefficient (dimensionless).
623    pub friction_coefficient: f64,
624    /// Current normal force at the joint (N).
625    pub normal_force: f64,
626    /// Current relative velocity along the joint axis.
627    pub relative_velocity: f64,
628    /// Accumulated friction impulse (warm-start).
629    pub warm_impulse: f64,
630}
631
632impl JointFrictionConstraint {
633    /// Create a joint friction constraint.
634    pub fn new(body_a: usize, body_b: usize, axis: [f64; 3], friction_coefficient: f64) -> Self {
635        Self {
636            body_a,
637            body_b,
638            axis: normalize3(axis),
639            friction_coefficient,
640            normal_force: 0.0,
641            relative_velocity: 0.0,
642            warm_impulse: 0.0,
643        }
644    }
645
646    /// Maximum friction impulse magnitude given timestep `dt`.
647    pub fn max_friction_impulse(&self, inv_mass_eff: f64, dt: f64) -> f64 {
648        let _ = dt;
649        let max_f = self.friction_coefficient * self.normal_force.abs();
650        let _ = inv_mass_eff;
651        max_f
652    }
653
654    /// Compute the friction impulse needed to bring joint velocity to zero,
655    /// clamped to the friction cone.
656    pub fn compute_impulse(&self, inv_mass_eff: f64, dt: f64) -> f64 {
657        if inv_mass_eff < 1e-14 {
658            return 0.0;
659        }
660        let delta_v = -self.relative_velocity;
661        let raw_impulse = delta_v / inv_mass_eff;
662        let max_imp = self.max_friction_impulse(inv_mass_eff, dt);
663        clampf(raw_impulse, -max_imp, max_imp)
664    }
665
666    /// Apply warm start — returns clamped previous impulse.
667    pub fn warm_start_impulse(&self, inv_mass_eff: f64, dt: f64) -> f64 {
668        let max_imp = self.max_friction_impulse(inv_mass_eff, dt);
669        clampf(self.warm_impulse, -max_imp, max_imp)
670    }
671}
672
673// ─────────────────────────────────────────────────────────────────────────────
674// JointComplianceParams
675// ─────────────────────────────────────────────────────────────────────────────
676
677/// Compliance (softness) parameters for a joint limit constraint.
678///
679/// Implements the Constraint Force Mixing (CFM) / Error Reduction Parameter
680/// (ERP) approach used in many rigid-body solvers.
681#[derive(Debug, Clone, Default)]
682pub struct JointComplianceParams {
683    /// Error Reduction Parameter ∈ (0, 1].  Fraction of position error
684    /// corrected per step.
685    pub erp: f64,
686    /// Constraint Force Mixing coefficient.  Non-zero → soft constraint.
687    pub cfm: f64,
688}
689
690impl JointComplianceParams {
691    /// Create compliance params with typical values.
692    pub fn new(erp: f64, cfm: f64) -> Self {
693        Self {
694            erp: clampf(erp, 0.0, 1.0),
695            cfm: cfm.max(0.0),
696        }
697    }
698
699    /// Default rigid constraint (ERP=0.2, CFM=0).
700    pub fn rigid() -> Self {
701        Self::new(0.2, 0.0)
702    }
703
704    /// Soft spring-like constraint.
705    pub fn soft(stiffness: f64, damping: f64, dt: f64) -> Self {
706        // From ODE manual: cfm = 1/(dt*k + c), erp = dt*k/(dt*k + c)
707        let denom = dt * stiffness + damping;
708        if denom < 1e-14 {
709            Self::rigid()
710        } else {
711            Self::new(dt * stiffness / denom, 1.0 / denom)
712        }
713    }
714
715    /// Correct a position error `c` with effective mass `k_eff` over step `dt`.
716    ///
717    /// Returns the corrective velocity (bias) to add to the constraint RHS.
718    pub fn positional_bias(&self, error: f64, dt: f64) -> f64 {
719        if dt < 1e-14 {
720            0.0
721        } else {
722            self.erp * error / dt
723        }
724    }
725
726    /// Modify the effective constraint mass to include CFM softness.
727    ///
728    /// `k_eff` is the raw inverse effective mass.
729    pub fn softened_keff(&self, k_eff: f64) -> f64 {
730        k_eff + self.cfm
731    }
732}
733
734// ─────────────────────────────────────────────────────────────────────────────
735// JointLimitWarmStart
736// ─────────────────────────────────────────────────────────────────────────────
737
738/// Cache of warm-start impulses for joint limit constraints across frames.
739#[derive(Debug, Clone, Default)]
740pub struct JointLimitWarmStart {
741    /// Map from `(body_a, body_b, axis_index)` → cached impulse.
742    entries: Vec<WarmEntry>,
743}
744
745/// One warm-start entry.
746#[derive(Debug, Clone)]
747struct WarmEntry {
748    body_a: usize,
749    body_b: usize,
750    axis_idx: usize,
751    impulse: f64,
752}
753
754impl JointLimitWarmStart {
755    /// Create an empty warm-start cache.
756    pub fn new() -> Self {
757        Self {
758            entries: Vec::new(),
759        }
760    }
761
762    /// Store (or update) a warm-start impulse for the given constraint key.
763    pub fn store(&mut self, body_a: usize, body_b: usize, axis_idx: usize, impulse: f64) {
764        for entry in &mut self.entries {
765            if entry.body_a == body_a && entry.body_b == body_b && entry.axis_idx == axis_idx {
766                entry.impulse = impulse;
767                return;
768            }
769        }
770        self.entries.push(WarmEntry {
771            body_a,
772            body_b,
773            axis_idx,
774            impulse,
775        });
776    }
777
778    /// Retrieve a warm-start impulse, returning `0` if not found.
779    pub fn retrieve(&self, body_a: usize, body_b: usize, axis_idx: usize) -> f64 {
780        for entry in &self.entries {
781            if entry.body_a == body_a && entry.body_b == body_b && entry.axis_idx == axis_idx {
782                return entry.impulse;
783            }
784        }
785        0.0
786    }
787
788    /// Clear all stored impulses (call at the start of each frame if needed).
789    pub fn clear(&mut self) {
790        self.entries.clear();
791    }
792
793    /// Number of cached entries.
794    pub fn len(&self) -> usize {
795        self.entries.len()
796    }
797
798    /// Whether the cache is empty.
799    pub fn is_empty(&self) -> bool {
800        self.entries.is_empty()
801    }
802
803    /// Scale all stored impulses by `factor` (use for substep warm-starting).
804    pub fn scale(&mut self, factor: f64) {
805        for entry in &mut self.entries {
806            entry.impulse *= factor;
807        }
808    }
809}
810
811// ─────────────────────────────────────────────────────────────────────────────
812// JointLimitSolver
813// ─────────────────────────────────────────────────────────────────────────────
814
815/// Iterative solver for a collection of joint limit constraints.
816///
817/// Performs sequential impulse iterations (PGS) with warm-starting.
818#[derive(Debug, Clone)]
819pub struct JointLimitSolver {
820    /// Joint limit constraints registered with this solver.
821    pub constraints: Vec<JointLimitConstraint>,
822    /// Warm-start cache.
823    pub warm_start: JointLimitWarmStart,
824    /// Compliance for all constraints.
825    pub compliance: JointComplianceParams,
826    /// Number of solver iterations per step.
827    pub iterations: usize,
828}
829
830impl JointLimitSolver {
831    /// Create a solver with default parameters.
832    pub fn new(iterations: usize) -> Self {
833        Self {
834            constraints: Vec::new(),
835            warm_start: JointLimitWarmStart::new(),
836            compliance: JointComplianceParams::rigid(),
837            iterations,
838        }
839    }
840
841    /// Register a constraint.
842    pub fn add_constraint(&mut self, c: JointLimitConstraint) {
843        self.constraints.push(c);
844    }
845
846    /// Apply warm starts from the previous frame to all constraints.
847    pub fn apply_warm_starts(&mut self) {
848        for c in &mut self.constraints {
849            let wi = self.warm_start.retrieve(c.body_a, c.body_b, 0);
850            c.warm_impulse = wi;
851        }
852    }
853
854    /// Solve all constraints for `dt` seconds.
855    ///
856    /// `inv_masses[i]` is the inverse mass of body `i`.
857    /// Returns the total impulse applied over all iterations.
858    pub fn solve(&mut self, inv_masses: &[f64], dt: f64) -> f64 {
859        // Apply warm starts
860        self.apply_warm_starts();
861        let mut total_impulse = 0.0f64;
862        for _iter in 0..self.iterations {
863            for c in &mut self.constraints {
864                let im_a = if c.body_a < inv_masses.len() {
865                    inv_masses[c.body_a]
866                } else {
867                    0.0
868                };
869                let im_b = if c.body_b < inv_masses.len() {
870                    inv_masses[c.body_b]
871                } else {
872                    0.0
873                };
874                let impulse = c.compute_impulse(im_a, im_b, dt);
875                let new_acc = (c.warm_impulse + impulse).max(0.0);
876                let delta = new_acc - c.warm_impulse;
877                c.warm_impulse = new_acc;
878                total_impulse += delta.abs();
879            }
880        }
881        // Store warm starts
882        for c in &self.constraints {
883            self.warm_start.store(c.body_a, c.body_b, 0, c.warm_impulse);
884        }
885        total_impulse
886    }
887
888    /// Clear all constraints (keep warm-start cache).
889    pub fn clear_constraints(&mut self) {
890        self.constraints.clear();
891    }
892}
893
894// ─────────────────────────────────────────────────────────────────────────────
895// JointStopContact
896// ─────────────────────────────────────────────────────────────────────────────
897
898/// A contact-like event generated when a joint hits its hard stop.
899#[derive(Debug, Clone)]
900pub struct JointStopContact {
901    /// Joint identifier.
902    pub joint_id: usize,
903    /// Contact normal (direction of correction).
904    pub normal: [f64; 3],
905    /// Penetration depth.
906    pub depth: f64,
907    /// Relative velocity along the normal at the stop.
908    pub relative_velocity: f64,
909    /// Restitution coefficient for the stop bounce.
910    pub restitution: f64,
911}
912
913impl JointStopContact {
914    /// Create a joint stop contact.
915    pub fn new(
916        joint_id: usize,
917        normal: [f64; 3],
918        depth: f64,
919        relative_velocity: f64,
920        restitution: f64,
921    ) -> Self {
922        Self {
923            joint_id,
924            normal: normalize3(normal),
925            depth,
926            relative_velocity,
927            restitution,
928        }
929    }
930
931    /// Compute the corrective impulse (Baumgarte + restitution).
932    ///
933    /// `eff_mass` is the scalar effective mass at the constraint.
934    /// `beta` is the Baumgarte position correction factor.
935    /// `dt` is the timestep.
936    pub fn compute_impulse(&self, eff_mass: f64, beta: f64, dt: f64) -> f64 {
937        if eff_mass < 1e-14 {
938            return 0.0;
939        }
940        let baumgarte_bias = beta * self.depth / dt;
941        let restitution_bias = self.restitution * self.relative_velocity.min(0.0).abs();
942        let target_vel = baumgarte_bias + restitution_bias;
943        let delta_v = target_vel - self.relative_velocity.min(0.0);
944        (delta_v / eff_mass).max(0.0)
945    }
946}
947
948// ─────────────────────────────────────────────────────────────────────────────
949// PrismaticJointLimit
950// ─────────────────────────────────────────────────────────────────────────────
951
952/// Prismatic joint with translational limits and full collision response.
953#[derive(Debug, Clone)]
954pub struct PrismaticJointLimit {
955    /// Parent body.
956    pub body_a: usize,
957    /// Child body.
958    pub body_b: usize,
959    /// Sliding axis (world space, unit vector).
960    pub axis: [f64; 3],
961    /// Translation limits.
962    pub limits: JointLimits,
963    /// Stop type.
964    pub stop_type: StopType,
965    /// Current translation along axis.
966    pub translation: f64,
967    /// Current velocity along axis.
968    pub velocity: f64,
969    /// Warm-start impulse.
970    pub warm_impulse: f64,
971    /// Compliance parameters.
972    pub compliance: JointComplianceParams,
973}
974
975impl PrismaticJointLimit {
976    /// Create a prismatic joint limit.
977    pub fn new(
978        body_a: usize,
979        body_b: usize,
980        axis: [f64; 3],
981        lower: f64,
982        upper: f64,
983        stop_type: StopType,
984        compliance: JointComplianceParams,
985    ) -> Self {
986        Self {
987            body_a,
988            body_b,
989            axis: normalize3(axis),
990            limits: JointLimits::new(lower, upper),
991            stop_type,
992            translation: 0.0,
993            velocity: 0.0,
994            warm_impulse: 0.0,
995            compliance,
996        }
997    }
998
999    /// Penetration depth at the current translation.
1000    pub fn penetration(&self) -> f64 {
1001        self.limits.penetration(self.translation)
1002    }
1003
1004    /// Positional bias velocity.
1005    pub fn bias_velocity(&self, dt: f64) -> f64 {
1006        self.compliance.positional_bias(self.penetration(), dt)
1007    }
1008
1009    /// Effective constraint jacobian row (scalar, since 1-D constraint).
1010    pub fn constraint_row(&self) -> f64 {
1011        if self.translation < self.limits.lower {
1012            1.0
1013        } else if self.translation > self.limits.upper {
1014            -1.0
1015        } else {
1016            0.0
1017        }
1018    }
1019
1020    /// Solve for the constraint impulse.
1021    pub fn solve_impulse(&self, inv_mass_a: f64, inv_mass_b: f64, dt: f64) -> f64 {
1022        let pen = self.penetration();
1023        if pen <= 0.0 {
1024            return 0.0;
1025        }
1026        let bias = self.bias_velocity(dt);
1027        let k_eff_raw = inv_mass_a + inv_mass_b;
1028        let k_eff = self.compliance.softened_keff(k_eff_raw);
1029        if k_eff < 1e-14 {
1030            return 0.0;
1031        }
1032        let lambda = (bias - self.velocity) / k_eff;
1033        lambda.max(0.0)
1034    }
1035}
1036
1037// ─────────────────────────────────────────────────────────────────────────────
1038// RevoluteJointLimit
1039// ─────────────────────────────────────────────────────────────────────────────
1040
1041/// Revolute joint with angular limits and full collision response.
1042#[derive(Debug, Clone)]
1043pub struct RevoluteJointLimit {
1044    /// Parent body.
1045    pub body_a: usize,
1046    /// Child body.
1047    pub body_b: usize,
1048    /// Rotation axis (world space).
1049    pub axis: [f64; 3],
1050    /// Angular limits.
1051    pub limits: JointLimits,
1052    /// Stop type.
1053    pub stop_type: StopType,
1054    /// Current angle (rad).
1055    pub angle: f64,
1056    /// Current angular velocity (rad/s).
1057    pub ang_velocity: f64,
1058    /// Warm-start impulse.
1059    pub warm_impulse: f64,
1060    /// Compliance.
1061    pub compliance: JointComplianceParams,
1062    /// Restitution at hard stop (0 = no bounce).
1063    pub restitution: f64,
1064}
1065
1066impl RevoluteJointLimit {
1067    /// Create a revolute joint limit.
1068    pub fn new(
1069        body_a: usize,
1070        body_b: usize,
1071        axis: [f64; 3],
1072        lower: f64,
1073        upper: f64,
1074        stop_type: StopType,
1075        compliance: JointComplianceParams,
1076        restitution: f64,
1077    ) -> Self {
1078        Self {
1079            body_a,
1080            body_b,
1081            axis: normalize3(axis),
1082            limits: JointLimits::new(lower, upper),
1083            stop_type,
1084            angle: 0.0,
1085            ang_velocity: 0.0,
1086            warm_impulse: 0.0,
1087            compliance,
1088            restitution,
1089        }
1090    }
1091
1092    /// Angular penetration.
1093    pub fn penetration(&self) -> f64 {
1094        self.limits.penetration(self.angle)
1095    }
1096
1097    /// Whether the limit is active.
1098    pub fn is_active(&self) -> bool {
1099        self.limits.is_violated(self.angle)
1100    }
1101
1102    /// Solve for corrective angular impulse.
1103    ///
1104    /// `inv_inertia_a`, `inv_inertia_b` — inverse moment of inertia projected
1105    /// onto the joint axis.
1106    pub fn solve_impulse(&self, inv_inertia_a: f64, inv_inertia_b: f64, dt: f64) -> f64 {
1107        let pen = self.penetration();
1108        if pen <= 0.0 {
1109            return 0.0;
1110        }
1111        let bias = self.compliance.positional_bias(pen, dt);
1112        let restitution_term = if self.ang_velocity.abs() > 1e-6 {
1113            self.restitution * self.ang_velocity.abs()
1114        } else {
1115            0.0
1116        };
1117        let k_eff_raw = inv_inertia_a + inv_inertia_b;
1118        let k_eff = self.compliance.softened_keff(k_eff_raw);
1119        if k_eff < 1e-14 {
1120            return 0.0;
1121        }
1122        let target = bias + restitution_term;
1123        let lambda = (target - self.ang_velocity) / k_eff;
1124        (self.warm_impulse + lambda).max(0.0) - self.warm_impulse
1125    }
1126}
1127
1128// ─────────────────────────────────────────────────────────────────────────────
1129// JointLimitPipeline
1130// ─────────────────────────────────────────────────────────────────────────────
1131
1132/// High-level pipeline that processes all joint limit collisions for one frame.
1133#[derive(Debug, Clone, Default)]
1134pub struct JointLimitPipeline {
1135    /// Revolute joint limits.
1136    pub revolute: Vec<RevoluteJointLimit>,
1137    /// Prismatic joint limits.
1138    pub prismatic: Vec<PrismaticJointLimit>,
1139    /// Ball-socket limits.
1140    pub ball_socket: Vec<BallSocketLimitConstraint>,
1141    /// Cable/chain limits.
1142    pub cables: Vec<CableChainLimit>,
1143    /// Articulated body stops.
1144    pub ab_stops: Vec<ArticulatedBodyJointStop>,
1145    /// Motor hard limits.
1146    pub motors: Vec<MotorHardLimit>,
1147    /// Warm-start cache.
1148    pub warm_start: JointLimitWarmStart,
1149    /// Global compliance.
1150    pub compliance: JointComplianceParams,
1151    /// Iterations per step.
1152    pub iterations: usize,
1153}
1154
1155impl JointLimitPipeline {
1156    /// Create a pipeline.
1157    pub fn new(iterations: usize) -> Self {
1158        Self {
1159            revolute: Vec::new(),
1160            prismatic: Vec::new(),
1161            ball_socket: Vec::new(),
1162            cables: Vec::new(),
1163            ab_stops: Vec::new(),
1164            motors: Vec::new(),
1165            warm_start: JointLimitWarmStart::new(),
1166            compliance: JointComplianceParams::rigid(),
1167            iterations,
1168        }
1169    }
1170
1171    /// Step all registered constraints by `dt` with the given inverse masses.
1172    ///
1173    /// Returns the total absolute impulse applied.
1174    pub fn step(&mut self, inv_masses: &[f64], inv_inertias: &[f64], dt: f64) -> f64 {
1175        let mut total = 0.0f64;
1176
1177        // Revolute
1178        for c in &mut self.revolute {
1179            let ia = if c.body_a < inv_inertias.len() {
1180                inv_inertias[c.body_a]
1181            } else {
1182                0.0
1183            };
1184            let ib = if c.body_b < inv_inertias.len() {
1185                inv_inertias[c.body_b]
1186            } else {
1187                0.0
1188            };
1189            for _ in 0..self.iterations {
1190                let imp = c.solve_impulse(ia, ib, dt);
1191                c.warm_impulse = (c.warm_impulse + imp).max(0.0);
1192                total += imp.abs();
1193            }
1194        }
1195
1196        // Prismatic
1197        for c in &mut self.prismatic {
1198            let ma = if c.body_a < inv_masses.len() {
1199                inv_masses[c.body_a]
1200            } else {
1201                0.0
1202            };
1203            let mb = if c.body_b < inv_masses.len() {
1204                inv_masses[c.body_b]
1205            } else {
1206                0.0
1207            };
1208            for _ in 0..self.iterations {
1209                let imp = c.solve_impulse(ma, mb, dt);
1210                c.warm_impulse = (c.warm_impulse + imp).max(0.0);
1211                total += imp.abs();
1212            }
1213        }
1214
1215        // Articulated body stops
1216        for c in &mut self.ab_stops {
1217            for _ in 0..self.iterations {
1218                let imp = c.compute_impulse(dt);
1219                c.warm_impulse = (c.warm_impulse + imp).max(0.0);
1220                total += imp.abs();
1221            }
1222        }
1223
1224        // Cable/chain
1225        for c in &self.cables {
1226            let ma = if c.body_a < inv_masses.len() {
1227                inv_masses[c.body_a]
1228            } else {
1229                0.0
1230            };
1231            let mb = if c.body_b < inv_masses.len() {
1232                inv_masses[c.body_b]
1233            } else {
1234                0.0
1235            };
1236            let imp = c.compute_impulse(ma, mb, dt);
1237            total += imp.abs();
1238        }
1239
1240        total
1241    }
1242
1243    /// Count total active constraints.
1244    pub fn active_count(&self) -> usize {
1245        self.revolute.iter().filter(|c| c.is_active()).count()
1246            + self
1247                .prismatic
1248                .iter()
1249                .filter(|c| c.limits.is_violated(c.translation))
1250                .count()
1251            + self.ball_socket.iter().filter(|c| c.is_violated()).count()
1252    }
1253}
1254
1255// ─────────────────────────────────────────────────────────────────────────────
1256// Utility: wrap angle to [-π, π]
1257// ─────────────────────────────────────────────────────────────────────────────
1258
1259/// Wrap an angle to `(−π, π]`.
1260pub fn wrap_angle(angle: f64) -> f64 {
1261    use std::f64::consts::PI;
1262    let mut a = angle;
1263    while a > PI {
1264        a -= 2.0 * PI;
1265    }
1266    while a <= -PI {
1267        a += 2.0 * PI;
1268    }
1269    a
1270}
1271
1272/// Angular difference `b − a` wrapped to `(−π, π]`.
1273pub fn angle_diff(a: f64, b: f64) -> f64 {
1274    wrap_angle(b - a)
1275}
1276
1277// ─────────────────────────────────────────────────────────────────────────────
1278// #[cfg(test)] unit tests
1279// ─────────────────────────────────────────────────────────────────────────────
1280
1281#[cfg(test)]
1282mod tests {
1283    use super::*;
1284    use std::f64::consts::PI;
1285
1286    // ── 1. JointLimits::is_violated ──────────────────────────────────────────
1287    #[test]
1288    fn test_joint_limits_violation() {
1289        let lim = JointLimits::new(-1.0, 1.0);
1290        assert!(lim.is_violated(-1.5));
1291        assert!(lim.is_violated(1.5));
1292        assert!(!lim.is_violated(0.0));
1293        assert!(!lim.is_violated(-1.0));
1294        assert!(!lim.is_violated(1.0));
1295    }
1296
1297    // ── 2. JointLimits::penetration ──────────────────────────────────────────
1298    #[test]
1299    fn test_joint_limits_penetration() {
1300        let lim = JointLimits::new(-PI / 2.0, PI / 2.0);
1301        let pen = lim.penetration(-PI);
1302        assert!((pen - (PI / 2.0)).abs() < 1e-10);
1303        assert_eq!(lim.penetration(0.0), 0.0);
1304    }
1305
1306    // ── 3. JointLimits::clamp ────────────────────────────────────────────────
1307    #[test]
1308    fn test_joint_limits_clamp() {
1309        let lim = JointLimits::new(-1.0, 2.0);
1310        assert_eq!(lim.clamp(-5.0), -1.0);
1311        assert_eq!(lim.clamp(5.0), 2.0);
1312        assert_eq!(lim.clamp(0.5), 0.5);
1313    }
1314
1315    // ── 4. StopType::Hard force ───────────────────────────────────────────────
1316    #[test]
1317    fn test_stop_type_hard_force() {
1318        let s = StopType::Hard;
1319        assert!(s.constraint_force(0.1, 0.0) > 0.0);
1320        assert_eq!(s.constraint_force(0.0, 0.0), 0.0);
1321    }
1322
1323    // ── 5. StopType::Soft force ───────────────────────────────────────────────
1324    #[test]
1325    fn test_stop_type_soft_force() {
1326        let s = StopType::Soft {
1327            stiffness: 1000.0,
1328            damping: 10.0,
1329        };
1330        let f = s.constraint_force(0.01, 0.0);
1331        assert!((f - 10.0).abs() < 1e-10);
1332    }
1333
1334    // ── 6. StopType::Soft no force when not violated ──────────────────────────
1335    #[test]
1336    fn test_stop_type_soft_no_violation() {
1337        let s = StopType::Soft {
1338            stiffness: 1000.0,
1339            damping: 10.0,
1340        };
1341        assert_eq!(s.constraint_force(-0.1, 0.0), 0.0);
1342    }
1343
1344    // ── 7. JointLimitConstraint::compute_impulse ─────────────────────────────
1345    #[test]
1346    fn test_joint_limit_constraint_impulse() {
1347        let lim = JointLimits::new(-1.0, 1.0);
1348        let mut c = JointLimitConstraint::new(
1349            0,
1350            1,
1351            JointType::Revolute,
1352            lim,
1353            StopType::Soft {
1354                stiffness: 1000.0,
1355                damping: 0.0,
1356            },
1357            [0.0, 0.0, 1.0],
1358        );
1359        c.position = 1.5; // 0.5 rad past upper limit
1360        let imp = c.compute_impulse(1.0, 1.0, 0.01);
1361        assert!(imp > 0.0, "impulse should be positive");
1362    }
1363
1364    // ── 8. JointLimitConstraint warm-start ───────────────────────────────────
1365    #[test]
1366    fn test_warm_start_positive_clamped() {
1367        let lim = JointLimits::new(-1.0, 1.0);
1368        let mut c = JointLimitConstraint::new(
1369            0,
1370            1,
1371            JointType::Revolute,
1372            lim,
1373            StopType::Hard,
1374            [1.0, 0.0, 0.0],
1375        );
1376        c.store_impulse(-5.0);
1377        // Negative impulse should be clamped to 0
1378        assert_eq!(c.apply_warm_start(), 0.0);
1379        c.store_impulse(3.0);
1380        assert_eq!(c.apply_warm_start(), 3.0);
1381    }
1382
1383    // ── 9. BallSocketLimitConstraint::update_angle ───────────────────────────
1384    #[test]
1385    fn test_ball_socket_angle_update() {
1386        let mut bs =
1387            BallSocketLimitConstraint::new(0, 1, [0.0, 0.0, 1.0], PI / 4.0, StopType::Hard);
1388        // Child axis aligned with cone axis → angle = 0
1389        bs.update_angle([0.0, 0.0, 1.0]);
1390        assert!(bs.current_angle.abs() < 1e-10);
1391        assert!(!bs.is_violated());
1392        // Child axis perpendicular → angle = π/2
1393        bs.update_angle([1.0, 0.0, 0.0]);
1394        assert!((bs.current_angle - PI / 2.0).abs() < 1e-10);
1395        assert!(bs.is_violated());
1396    }
1397
1398    // ── 10. BallSocketLimitConstraint::penetration ───────────────────────────
1399    #[test]
1400    fn test_ball_socket_penetration() {
1401        let mut bs =
1402            BallSocketLimitConstraint::new(0, 1, [0.0, 0.0, 1.0], PI / 6.0, StopType::Hard);
1403        bs.current_angle = PI / 3.0;
1404        let pen = bs.penetration();
1405        assert!((pen - PI / 6.0).abs() < 1e-10);
1406    }
1407
1408    // ── 11. GearRatioCollision::propagate_impulse ────────────────────────────
1409    #[test]
1410    fn test_gear_propagate_impulse() {
1411        let gear = GearRatioCollision::new(0, 1, 3.0, 1.0);
1412        assert!((gear.propagate_impulse(10.0) - 30.0).abs() < 1e-10);
1413    }
1414
1415    // ── 12. GearRatioCollision::reflected_inertia ────────────────────────────
1416    #[test]
1417    fn test_gear_reflected_inertia() {
1418        let gear = GearRatioCollision::new(0, 1, 2.0, 0.9);
1419        // reflected = 5.0 * 4.0 = 20.0
1420        assert!((gear.reflected_inertia(5.0) - 20.0).abs() < 1e-10);
1421    }
1422
1423    // ── 13. CableChainLimit extension violation ───────────────────────────────
1424    #[test]
1425    fn test_cable_extension_violation() {
1426        let mut cable = CableChainLimit::new(0, 1, 0.0, 2.0, 1000.0, 10.0);
1427        cable.current_length = 2.5;
1428        assert!((cable.extension_violation() - 0.5).abs() < 1e-10);
1429        assert_eq!(cable.compression_violation(), 0.0);
1430    }
1431
1432    // ── 14. CableChainLimit compression violation ─────────────────────────────
1433    #[test]
1434    fn test_chain_compression_violation() {
1435        let mut chain = CableChainLimit::new(0, 1, 1.0, 3.0, 500.0, 5.0);
1436        chain.current_length = 0.5;
1437        assert!((chain.compression_violation() - 0.5).abs() < 1e-10);
1438    }
1439
1440    // ── 15. CableChainLimit::compute_impulse ─────────────────────────────────
1441    #[test]
1442    fn test_cable_impulse_positive_on_violation() {
1443        let mut cable = CableChainLimit::new(0, 1, 0.0, 1.0, 1000.0, 0.0);
1444        cable.current_length = 1.2;
1445        let imp = cable.compute_impulse(1.0, 1.0, 0.01);
1446        assert!(imp > 0.0);
1447    }
1448
1449    // ── 16. ArticulatedBodyJointStop::compute_impulse ────────────────────────
1450    #[test]
1451    fn test_ab_stop_impulse() {
1452        let lim = JointLimits::new(-1.0, 1.0);
1453        let mut stop = ArticulatedBodyJointStop::new(
1454            0,
1455            2.0,
1456            lim,
1457            StopType::Soft {
1458                stiffness: 500.0,
1459                damping: 0.0,
1460            },
1461        );
1462        stop.position = 1.5;
1463        let imp = stop.compute_impulse(0.01);
1464        assert!(imp > 0.0);
1465    }
1466
1467    // ── 17. ArticulatedBodyJointStop::velocity_impulse ───────────────────────
1468    #[test]
1469    fn test_ab_stop_velocity_impulse_restitution() {
1470        let lim = JointLimits::new(-1.0, 1.0);
1471        let mut stop = ArticulatedBodyJointStop::new(0, 1.0, lim, StopType::Hard);
1472        stop.position = 1.5;
1473        stop.velocity = -2.0;
1474        // restitution=0.5 → impulse = (1+0.5)*2.0 / 1.0 = 3.0
1475        let vi = stop.velocity_impulse(0.5);
1476        assert!((vi - 3.0).abs() < 1e-10);
1477    }
1478
1479    // ── 18. MotorHardLimit::clamp_torque ─────────────────────────────────────
1480    #[test]
1481    fn test_motor_clamp_torque() {
1482        let m = MotorHardLimit::new(10.0, 5.0, -PI, PI, 0.1);
1483        assert_eq!(m.clamp_torque(20.0), 10.0);
1484        assert_eq!(m.clamp_torque(-20.0), -10.0);
1485        assert_eq!(m.clamp_torque(5.0), 5.0);
1486    }
1487
1488    // ── 19. MotorHardLimit::check_stall ──────────────────────────────────────
1489    #[test]
1490    fn test_motor_stall_detection() {
1491        let mut m = MotorHardLimit::new(10.0, 5.0, -PI, PI, 0.1);
1492        m.angle = PI + 0.1; // past upper limit
1493        m.check_stall();
1494        assert!(m.stalled);
1495        m.angle = 0.0;
1496        m.check_stall();
1497        assert!(!m.stalled);
1498    }
1499
1500    // ── 20. MotorHardLimit::limit_impulse zero when not stalled ──────────────
1501    #[test]
1502    fn test_motor_limit_impulse_not_stalled() {
1503        let m = MotorHardLimit::new(10.0, 5.0, -PI, PI, 0.1);
1504        assert_eq!(m.limit_impulse(0.01), 0.0);
1505    }
1506
1507    // ── 21. JointFrictionConstraint::compute_impulse ─────────────────────────
1508    #[test]
1509    fn test_friction_impulse_computed() {
1510        let mut fric = JointFrictionConstraint::new(0, 1, [0.0, 0.0, 1.0], 0.3);
1511        fric.normal_force = 100.0;
1512        fric.relative_velocity = 2.0;
1513        let imp = fric.compute_impulse(2.0, 0.01);
1514        // raw = -2.0 / 2.0 = -1.0; max = 0.3*100=30; clamped to -30..30 → -1.0
1515        assert!((imp - (-1.0)).abs() < 1e-10);
1516    }
1517
1518    // ── 22. JointFrictionConstraint warm start ────────────────────────────────
1519    #[test]
1520    fn test_friction_warm_start_clamped() {
1521        let mut fric = JointFrictionConstraint::new(0, 1, [1.0, 0.0, 0.0], 0.1);
1522        fric.normal_force = 50.0;
1523        fric.warm_impulse = 1000.0; // way above max friction
1524        let ws = fric.warm_start_impulse(1.0, 0.01);
1525        // max = 0.1 * 50 = 5.0
1526        assert!(ws <= 5.0 + 1e-10);
1527    }
1528
1529    // ── 23. JointComplianceParams::rigid ─────────────────────────────────────
1530    #[test]
1531    fn test_compliance_rigid() {
1532        let c = JointComplianceParams::rigid();
1533        assert!((c.erp - 0.2).abs() < 1e-10);
1534        assert_eq!(c.cfm, 0.0);
1535    }
1536
1537    // ── 24. JointComplianceParams::soft formula ───────────────────────────────
1538    #[test]
1539    fn test_compliance_soft() {
1540        let c = JointComplianceParams::soft(1000.0, 10.0, 0.01);
1541        // denom = 0.01*1000 + 10 = 20; erp = 10/20 = 0.5; cfm = 1/20 = 0.05
1542        assert!((c.erp - 0.5).abs() < 1e-10);
1543        assert!((c.cfm - 0.05).abs() < 1e-10);
1544    }
1545
1546    // ── 25. JointComplianceParams::positional_bias ────────────────────────────
1547    #[test]
1548    fn test_compliance_positional_bias() {
1549        let c = JointComplianceParams::new(0.5, 0.0);
1550        let bias = c.positional_bias(0.1, 0.01);
1551        // bias = 0.5 * 0.1 / 0.01 = 5.0
1552        assert!((bias - 5.0).abs() < 1e-10);
1553    }
1554
1555    // ── 26. JointLimitWarmStart::store/retrieve ───────────────────────────────
1556    #[test]
1557    fn test_warm_start_store_retrieve() {
1558        let mut ws = JointLimitWarmStart::new();
1559        ws.store(0, 1, 2, 5.5);
1560        assert!((ws.retrieve(0, 1, 2) - 5.5).abs() < 1e-10);
1561        assert_eq!(ws.retrieve(0, 1, 3), 0.0); // missing
1562    }
1563
1564    // ── 27. JointLimitWarmStart::scale ────────────────────────────────────────
1565    #[test]
1566    fn test_warm_start_scale() {
1567        let mut ws = JointLimitWarmStart::new();
1568        ws.store(0, 1, 0, 10.0);
1569        ws.scale(0.5);
1570        assert!((ws.retrieve(0, 1, 0) - 5.0).abs() < 1e-10);
1571    }
1572
1573    // ── 28. JointLimitWarmStart::clear ────────────────────────────────────────
1574    #[test]
1575    fn test_warm_start_clear() {
1576        let mut ws = JointLimitWarmStart::new();
1577        ws.store(0, 1, 0, 1.0);
1578        ws.clear();
1579        assert!(ws.is_empty());
1580    }
1581
1582    // ── 29. JointLimitSolver::solve zero impulse when no violation ────────────
1583    #[test]
1584    fn test_solver_no_violation() {
1585        let mut solver = JointLimitSolver::new(4);
1586        let lim = JointLimits::new(-1.0, 1.0);
1587        let c = JointLimitConstraint::new(
1588            0,
1589            1,
1590            JointType::Revolute,
1591            lim,
1592            StopType::Soft {
1593                stiffness: 100.0,
1594                damping: 0.0,
1595            },
1596            [0.0, 0.0, 1.0],
1597        );
1598        solver.add_constraint(c);
1599        // position stays at 0.0 → no violation
1600        let inv_masses = vec![1.0, 1.0];
1601        let total = solver.solve(&inv_masses, 0.01);
1602        assert_eq!(total, 0.0);
1603    }
1604
1605    // ── 30. JointLimitSolver::solve positive impulse on violation ─────────────
1606    #[test]
1607    fn test_solver_violation_produces_impulse() {
1608        let mut solver = JointLimitSolver::new(4);
1609        let lim = JointLimits::new(-1.0, 1.0);
1610        let mut c = JointLimitConstraint::new(
1611            0,
1612            1,
1613            JointType::Revolute,
1614            lim,
1615            StopType::Soft {
1616                stiffness: 100.0,
1617                damping: 0.0,
1618            },
1619            [0.0, 0.0, 1.0],
1620        );
1621        c.position = 2.0; // 1 rad past upper limit
1622        solver.add_constraint(c);
1623        let inv_masses = vec![1.0, 1.0];
1624        let total = solver.solve(&inv_masses, 0.01);
1625        assert!(total > 0.0, "expected positive impulse, got {}", total);
1626    }
1627
1628    // ── 31. PrismaticJointLimit::penetration ──────────────────────────────────
1629    #[test]
1630    fn test_prismatic_penetration() {
1631        let mut pj = PrismaticJointLimit::new(
1632            0,
1633            1,
1634            [1.0, 0.0, 0.0],
1635            -0.5,
1636            0.5,
1637            StopType::Hard,
1638            JointComplianceParams::rigid(),
1639        );
1640        pj.translation = 0.8;
1641        assert!((pj.penetration() - 0.3).abs() < 1e-10);
1642    }
1643
1644    // ── 32. PrismaticJointLimit::constraint_row ───────────────────────────────
1645    #[test]
1646    fn test_prismatic_constraint_row() {
1647        let mut pj = PrismaticJointLimit::new(
1648            0,
1649            1,
1650            [0.0, 1.0, 0.0],
1651            -1.0,
1652            1.0,
1653            StopType::Hard,
1654            JointComplianceParams::rigid(),
1655        );
1656        pj.translation = 1.5;
1657        assert_eq!(pj.constraint_row(), -1.0);
1658        pj.translation = -1.5;
1659        assert_eq!(pj.constraint_row(), 1.0);
1660        pj.translation = 0.0;
1661        assert_eq!(pj.constraint_row(), 0.0);
1662    }
1663
1664    // ── 33. RevoluteJointLimit::is_active ─────────────────────────────────────
1665    #[test]
1666    fn test_revolute_is_active() {
1667        let mut rev = RevoluteJointLimit::new(
1668            0,
1669            1,
1670            [0.0, 1.0, 0.0],
1671            -PI / 2.0,
1672            PI / 2.0,
1673            StopType::Hard,
1674            JointComplianceParams::rigid(),
1675            0.0,
1676        );
1677        rev.angle = PI;
1678        assert!(rev.is_active());
1679        rev.angle = 0.0;
1680        assert!(!rev.is_active());
1681    }
1682
1683    // ── 34. RevoluteJointLimit::solve_impulse ────────────────────────────────
1684    #[test]
1685    fn test_revolute_solve_impulse_positive() {
1686        let mut rev = RevoluteJointLimit::new(
1687            0,
1688            1,
1689            [0.0, 0.0, 1.0],
1690            -PI / 4.0,
1691            PI / 4.0,
1692            StopType::Hard,
1693            JointComplianceParams::new(0.8, 0.0),
1694            0.0,
1695        );
1696        rev.angle = PI / 2.0; // past limit
1697        rev.ang_velocity = 1.0;
1698        let imp = rev.solve_impulse(1.0, 1.0, 0.01);
1699        assert!(imp >= 0.0);
1700    }
1701
1702    // ── 35. JointStopContact::compute_impulse ────────────────────────────────
1703    #[test]
1704    fn test_joint_stop_contact_impulse() {
1705        let contact = JointStopContact::new(
1706            0,
1707            [0.0, 1.0, 0.0],
1708            0.05,
1709            -1.0, // approaching
1710            0.2,
1711        );
1712        let imp = contact.compute_impulse(2.0, 0.2, 0.01);
1713        assert!(imp >= 0.0);
1714    }
1715
1716    // ── 36. JointLimitPipeline::active_count ─────────────────────────────────
1717    #[test]
1718    fn test_pipeline_active_count() {
1719        let mut pipe = JointLimitPipeline::new(4);
1720        let mut rev = RevoluteJointLimit::new(
1721            0,
1722            1,
1723            [0.0, 0.0, 1.0],
1724            -1.0,
1725            1.0,
1726            StopType::Hard,
1727            JointComplianceParams::rigid(),
1728            0.0,
1729        );
1730        rev.angle = 2.0; // violated
1731        pipe.revolute.push(rev);
1732        assert_eq!(pipe.active_count(), 1);
1733    }
1734
1735    // ── 37. wrap_angle ────────────────────────────────────────────────────────
1736    #[test]
1737    fn test_wrap_angle() {
1738        assert!((wrap_angle(3.0 * PI) - PI).abs() < 1e-10);
1739        assert!(
1740            (wrap_angle(-3.0 * PI) - (-PI)).abs() < 1e-10
1741                || (wrap_angle(-3.0 * PI) - PI).abs() < 1e-10
1742        );
1743        assert!(wrap_angle(0.5).abs() - 0.5 < 1e-10);
1744    }
1745
1746    // ── 38. angle_diff ────────────────────────────────────────────────────────
1747    #[test]
1748    fn test_angle_diff_wrap() {
1749        let d = angle_diff(PI * 0.9, -PI * 0.9);
1750        // difference is about -1.8π → wrapped ≈ 0.2π
1751        assert!(d.abs() < PI + 1e-10);
1752    }
1753
1754    // ── 39. normalize3 zero vector ────────────────────────────────────────────
1755    #[test]
1756    fn test_normalize3_zero() {
1757        let v = normalize3([0.0, 0.0, 0.0]);
1758        assert_eq!(v, [0.0, 0.0, 0.0]);
1759    }
1760
1761    // ── 40. normalize3 unit result ────────────────────────────────────────────
1762    #[test]
1763    fn test_normalize3_unit() {
1764        let v = normalize3([3.0, 4.0, 0.0]);
1765        let n = norm3(v);
1766        assert!((n - 1.0).abs() < 1e-10);
1767    }
1768
1769    // ── 41. clampf ────────────────────────────────────────────────────────────
1770    #[test]
1771    fn test_clampf() {
1772        assert_eq!(clampf(5.0, 0.0, 3.0), 3.0);
1773        assert_eq!(clampf(-1.0, 0.0, 3.0), 0.0);
1774        assert_eq!(clampf(1.5, 0.0, 3.0), 1.5);
1775    }
1776
1777    // ── 42. JointLimitSolver::clear_constraints ───────────────────────────────
1778    #[test]
1779    fn test_solver_clear_constraints() {
1780        let mut solver = JointLimitSolver::new(2);
1781        let lim = JointLimits::new(-1.0, 1.0);
1782        let c = JointLimitConstraint::new(
1783            0,
1784            1,
1785            JointType::Prismatic,
1786            lim,
1787            StopType::Hard,
1788            [1.0, 0.0, 0.0],
1789        );
1790        solver.add_constraint(c);
1791        assert_eq!(solver.constraints.len(), 1);
1792        solver.clear_constraints();
1793        assert!(solver.constraints.is_empty());
1794    }
1795}