Skip to main content

phyz_diff/rollout/
step.rs

1//! Scalar-polymorphic dynamics step: ABA + forward kinematics + per-vertex
2//! ground contact + semi-implicit Euler, generic over `T: tang::Scalar`.
3//!
4//! Instantiated at `f64` this is the plain forward rollout; instantiated at
5//! [`tang::Dual<f64>`] with one seeded input it yields an **exact** partial
6//! derivative of the step through every branch the `f64` computation takes
7//! (dual comparisons act on the primal). The adjoint driver in
8//! [`super::adjoint`] uses those dual lanes as its per-step Jacobian oracle.
9//!
10//! # Scope
11//!
12//! **Every** joint type the model can express: revolute/hinge,
13//! prismatic/slide, fixed, spherical/ball (3 DOF) and free (6 DOF). Multi-DOF
14//! joints go through a generic `ndof×ndof` articulated-inertia solve and carry
15//! **quaternion** configuration, so `nq ≠ nv` in general — see [`DofLayout`].
16//!
17//! # State layout (`nq ≠ nv`)
18//!
19//! Velocity coordinates are the joint's motion subspace, one per DOF. Position
20//! coordinates are *not*: rotational sub-blocks of multi-DOF joints are stored
21//! as unit quaternions rather than exponential coordinates, so the
22//! configuration update is a Lie-group step rather than `q += dt·v`.
23//!
24//! | joint | `nq` | `q` layout | `nv` | `v` layout |
25//! |---|---|---|---|---|
26//! | revolute / prismatic | 1 | `[θ]` / `[d]` | 1 | `[θ̇]` / `[ḋ]` |
27//! | fixed | 0 | — | 0 | — |
28//! | spherical | 4 | `[w, x, y, z]` | 3 | body-frame `ω` |
29//! | free | 7 | `[x, y, z, w, qx, qy, qz]` | 6 | body-frame `[ω; v]` |
30//!
31//! `[x, y, z]` of a free joint is the successor origin in **predecessor**
32//! coordinates (the `pos` half of the Plücker transform), matching
33//! [`tang::SpatialTransform`]; its rate is therefore `Eᵀ·v_lin`, not `v_lin`.
34//! The quaternion `p` is the one with `E = R(p)`, i.e. the *coordinate*
35//! transform predecessor→successor, matching the sign convention
36//! `phyz_model::Joint::joint_transform_slice` uses for its exponential
37//! coordinates. Since `Ė = −ω× E`, the update is
38//! `p' = normalize(exp(−dt·ω) ⊗ p)`.
39//!
40//! This differs from `phyz_model`'s own `nq == nv` exponential-coordinate
41//! packing (and from `phyz::sim::SemiImplicitEulerSolver`'s flat
42//! `q += dt·v`, which for a free joint would add angular rates to positional
43//! coordinates — not a doc link, since `phyz` depends on this crate rather
44//! than the other way round). Build the initial configuration with
45//! [`DofLayout::neutral_q`] and index it with [`DofLayout::q_offsets`].
46//!
47//! # Contact model
48//!
49//! Contact here is a **differentiable forward model of its own**, not a
50//! wrapper around the GJK/EPA narrow phase: every collision-mesh vertex below
51//! the ground plane contributes an independent penalty force
52//!
53//! ```text
54//! f_z = max(0, k·depth − c·v_z),   depth = ground_height − x_z  (> 0 inside)
55//! ```
56//!
57//! applied at the vertex (wrench about the body origin). This is the standard
58//! differentiable-simulation choice: the force is a smooth function of the
59//! vertex position wherever the contact is active, and the "contact set" is
60//! implicit in the smooth `max` rather than a combinatorial narrow-phase
61//! output. Friction is deliberately absent from v1 (its `‖v_t‖` kink sits at
62//! exactly the sticking state a resting gate converges to).
63
64use phyz_math::DVec;
65use phyz_model::{JointType, Model};
66use tang::{Mat3, Quat, Scalar, SpatialInertia, SpatialMat, SpatialTransform, SpatialVec, Vec3};
67
68/// Number of inertia parameters per body: `[m, cx, cy, cz, Ixx, Iyy, Izz,
69/// Ixy, Ixz, Iyz]` — mass, COM (body frame), inertia about the COM. The
70/// packing (and the symmetric off-diagonal convention) matches vcad's
71/// `BodyMassProps::scalars()` so a phyz gradient drops into the vcad seam
72/// unchanged.
73pub const N_INERTIA_PARAMS: usize = 10;
74
75/// Pack a body's spatial inertia into the canonical 10-vector.
76pub fn inertia_params(si: &SpatialInertia<f64>) -> [f64; N_INERTIA_PARAMS] {
77    [
78        si.mass,
79        si.com.x,
80        si.com.y,
81        si.com.z,
82        si.inertia.get(0, 0),
83        si.inertia.get(1, 1),
84        si.inertia.get(2, 2),
85        si.inertia.get(0, 1),
86        si.inertia.get(0, 2),
87        si.inertia.get(1, 2),
88    ]
89}
90
91/// Rebuild a spatial inertia from the canonical 10-vector. Each off-diagonal
92/// scalar populates **both** symmetric entries, so seeding one parameter
93/// differentiates with respect to the symmetric pair — the same convention a
94/// central difference on the packed scalars probes.
95pub fn inertia_from_params<T: Scalar>(p: &[T; N_INERTIA_PARAMS]) -> SpatialInertia<T> {
96    SpatialInertia::new(
97        p[0],
98        Vec3::new(p[1], p[2], p[3]),
99        Mat3::new(p[4], p[7], p[8], p[7], p[5], p[9], p[8], p[9], p[6]),
100    )
101}
102
103/// A ground plane at `z = height` with a linear penalty contact law.
104#[derive(Debug, Clone, Copy)]
105pub struct GroundContact {
106    /// World z of the plane (m).
107    pub height: f64,
108    /// Penalty stiffness k (N/m per vertex).
109    pub stiffness: f64,
110    /// Penalty damping c (N·s/m per vertex).
111    pub damping: f64,
112}
113
114/// A body's collision skin: vertices in the **body frame** (m). Only the
115/// vertices matter to the per-vertex penalty model — triangles are not used.
116#[derive(Debug, Clone)]
117pub struct CollisionMesh {
118    /// Index of the body this skin is attached to.
119    pub body: usize,
120    /// Vertex positions in the body frame (m).
121    pub vertices: Vec<Vec3<f64>>,
122}
123
124// ---------------------------------------------------------------------------
125// Lifts: f64 model constants → T
126// ---------------------------------------------------------------------------
127
128pub(crate) fn lift_vec3<T: Scalar>(v: Vec3<f64>) -> Vec3<T> {
129    Vec3::new(T::from_f64(v.x), T::from_f64(v.y), T::from_f64(v.z))
130}
131
132fn lift_mat3<T: Scalar>(m: &Mat3<f64>) -> Mat3<T> {
133    Mat3::from_cols(
134        lift_vec3(m.col(0)),
135        lift_vec3(m.col(1)),
136        lift_vec3(m.col(2)),
137    )
138}
139
140fn lift_xform<T: Scalar>(xf: &SpatialTransform<f64>) -> SpatialTransform<T> {
141    SpatialTransform::new(lift_mat3(&xf.rot), lift_vec3(xf.pos))
142}
143
144/// Lift a body's spatial inertia to `T` with zero tangents.
145pub(crate) fn lift_inertia<T: Scalar>(si: &SpatialInertia<f64>) -> SpatialInertia<T> {
146    SpatialInertia::new(
147        T::from_f64(si.mass),
148        lift_vec3(si.com),
149        lift_mat3(&si.inertia),
150    )
151}
152
153// ---------------------------------------------------------------------------
154// Position/velocity coordinate layout
155// ---------------------------------------------------------------------------
156
157/// Largest `ndof` any joint type has (free joint).
158const MAX_DOF: usize = 6;
159
160/// Number of **position** coordinates a joint type carries in this module's
161/// layout: quaternions for the rotational sub-blocks of multi-DOF joints, so
162/// this is *not* `ndof()` in general. See the module docs for the packing.
163pub fn joint_nq(joint_type: JointType) -> usize {
164    match joint_type {
165        JointType::Revolute | JointType::Hinge | JointType::Prismatic | JointType::Slide => 1,
166        JointType::Spherical | JointType::Ball => 4,
167        JointType::Free => 7,
168        JointType::Fixed => 0,
169    }
170}
171
172/// The `q`/`v` coordinate split for a model under this module's layout.
173///
174/// `nv` and `v_offsets` agree with `Model::nv` / `Model::v_offsets` (velocity
175/// coordinates are the motion subspace either way). `nq` and `q_offsets` do
176/// **not** agree with the model's when any spherical or free joint is present,
177/// because those carry quaternions here — always index a diff-rollout `q`
178/// through this layout, never through `Model::q_offsets`.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct DofLayout {
181    /// Total number of position coordinates.
182    pub nq: usize,
183    /// Total number of velocity coordinates (== `Model::nv`).
184    pub nv: usize,
185    /// Position offset of each joint, parallel to `Model::joints`.
186    pub q_offsets: Vec<usize>,
187    /// Velocity offset of each joint, parallel to `Model::joints`.
188    pub v_offsets: Vec<usize>,
189}
190
191impl DofLayout {
192    /// Derive the layout of a model.
193    pub fn of(model: &Model) -> Self {
194        let mut nq = 0;
195        let mut nv = 0;
196        let mut q_offsets = Vec::with_capacity(model.joints.len());
197        let mut v_offsets = Vec::with_capacity(model.joints.len());
198        for joint in &model.joints {
199            q_offsets.push(nq);
200            v_offsets.push(nv);
201            nq += joint_nq(joint.joint_type);
202            nv += joint.ndof();
203        }
204        Self {
205            nq,
206            nv,
207            q_offsets,
208            v_offsets,
209        }
210    }
211
212    /// The identity configuration: zeros, except every quaternion sub-block is
213    /// the identity rotation `[1, 0, 0, 0]`. Use this as the base for a `q0`
214    /// and overwrite the coordinates you care about.
215    pub fn neutral_q(&self, model: &Model) -> Vec<f64> {
216        let mut q = vec![0.0; self.nq];
217        for (j, joint) in model.joints.iter().enumerate() {
218            let qi = self.q_offsets[j];
219            match joint.joint_type {
220                JointType::Spherical | JointType::Ball => q[qi] = 1.0,
221                JointType::Free => q[qi + 3] = 1.0,
222                _ => {}
223            }
224        }
225        q
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Generic joint helpers (all joint types)
231// ---------------------------------------------------------------------------
232
233/// Joint transform (Plücker, predecessor→successor) for `ndof`-many position
234/// coordinates starting at `q[0]`. `q` must be at least `joint_nq` long.
235fn joint_transform<T: Scalar>(
236    joint_type: JointType,
237    axis: &Vec3<T>,
238    q: &[T],
239) -> SpatialTransform<T> {
240    match joint_type {
241        JointType::Revolute | JointType::Hinge => {
242            // Rodrigues with negated angle: coordinate transform, matching
243            // the concrete Joint::joint_transform_slice.
244            let (s, c) = (-q[0]).sin_cos();
245            let ax = tang::skew(axis);
246            let rot = Mat3::identity() + ax * s + ax.mul_mat(&ax) * (T::ONE - c);
247            SpatialTransform::new(rot, Vec3::zero())
248        }
249        JointType::Prismatic | JointType::Slide => {
250            SpatialTransform::new(Mat3::identity(), *axis * q[0])
251        }
252        JointType::Spherical | JointType::Ball => {
253            let rot = quat_at(&q[0..4]).normalize().to_matrix();
254            SpatialTransform::new(rot, Vec3::zero())
255        }
256        JointType::Free => {
257            let rot = quat_at(&q[3..7]).normalize().to_matrix();
258            SpatialTransform::new(rot, Vec3::new(q[0], q[1], q[2]))
259        }
260        JointType::Fixed => SpatialTransform::identity(),
261    }
262}
263
264/// Read a `[w, x, y, z]` quaternion out of a coordinate slice.
265fn quat_at<T: Scalar>(q: &[T]) -> Quat<T> {
266    Quat::new(q[0], q[1], q[2], q[3])
267}
268
269/// The single motion-subspace column of a 1-DOF joint.
270fn motion_subspace<T: Scalar>(joint_type: JointType, axis: &Vec3<T>) -> SpatialVec<T> {
271    match joint_type {
272        JointType::Revolute | JointType::Hinge => SpatialVec::new(*axis, Vec3::zero()),
273        JointType::Prismatic | JointType::Slide => SpatialVec::new(Vec3::zero(), *axis),
274        _ => unreachable!("motion_subspace is the 1-DOF path"),
275    }
276}
277
278/// Cartesian basis vector `e_k`.
279fn unit<T: Scalar>(k: usize) -> Vec3<T> {
280    let mut e = Vec3::zero();
281    match k {
282        0 => e.x = T::ONE,
283        1 => e.y = T::ONE,
284        _ => e.z = T::ONE,
285    }
286    e
287}
288
289/// The motion subspace `S` (6×ndof) as columns; only the first `ndof` entries
290/// of the returned array are meaningful.
291fn motion_subspace_cols<T: Scalar>(
292    joint_type: JointType,
293    axis: &Vec3<T>,
294) -> [SpatialVec<T>; MAX_DOF] {
295    let mut s = [SpatialVec::zero(); MAX_DOF];
296    match joint_type {
297        JointType::Revolute | JointType::Hinge | JointType::Prismatic | JointType::Slide => {
298            s[0] = motion_subspace(joint_type, axis);
299        }
300        JointType::Spherical | JointType::Ball => {
301            for (k, s_k) in s.iter_mut().enumerate().take(3) {
302                *s_k = SpatialVec::new(unit(k), Vec3::zero());
303            }
304        }
305        JointType::Free => {
306            for k in 0..3 {
307                s[k] = SpatialVec::new(unit(k), Vec3::zero());
308                s[3 + k] = SpatialVec::new(Vec3::zero(), unit(k));
309            }
310        }
311        JointType::Fixed => {}
312    }
313    s
314}
315
316/// The joint's spatial velocity `S·v` for `ndof` velocity coordinates.
317fn joint_velocity<T: Scalar>(
318    joint_type: JointType,
319    axis: &Vec3<T>,
320    v: &[T],
321    ndof: usize,
322) -> SpatialVec<T> {
323    if ndof == 1 {
324        let s = motion_subspace(joint_type, axis);
325        return SpatialVec::new(s.angular * v[0], s.linear * v[0]);
326    }
327    let s = motion_subspace_cols(joint_type, axis);
328    let mut out = SpatialVec::zero();
329    for k in 0..ndof {
330        out = out + SpatialVec::new(s[k].angular * v[k], s[k].linear * v[k]);
331    }
332    out
333}
334
335/// Solve `A·X = B` in place for a small dense system, `A` row-major `n×n` and
336/// `B` row-major `n×m` (overwritten with `X`). Returns `false` — leaving `b`
337/// unusable — when the matrix is numerically singular.
338///
339/// Gaussian elimination with partial pivoting. The pivot search and the
340/// singularity test both read the **primal** (`to_f64`), so a dual-seeded
341/// solve differentiates exactly the elimination order the `f64` solve takes —
342/// the same discipline the rest of this module applies to its branches.
343fn solve_in_place<T: Scalar>(a: &mut [T], n: usize, b: &mut [T], m: usize) -> bool {
344    for col in 0..n {
345        let mut piv = col;
346        let mut best = a[col * n + col].to_f64().abs();
347        for row in (col + 1)..n {
348            let mag = a[row * n + col].to_f64().abs();
349            if mag > best {
350                best = mag;
351                piv = row;
352            }
353        }
354        if best < 1e-20 {
355            return false;
356        }
357        if piv != col {
358            for k in 0..n {
359                a.swap(col * n + k, piv * n + k);
360            }
361            for k in 0..m {
362                b.swap(col * m + k, piv * m + k);
363            }
364        }
365        let inv = a[col * n + col].recip();
366        for row in (col + 1)..n {
367            let f = a[row * n + col] * inv;
368            if f.to_f64() == 0.0 {
369                continue;
370            }
371            for k in col..n {
372                let t = f * a[col * n + k];
373                a[row * n + k] -= t;
374            }
375            for k in 0..m {
376                let t = f * b[col * m + k];
377                b[row * m + k] -= t;
378            }
379        }
380    }
381    // Back-substitution.
382    for col in (0..n).rev() {
383        let inv = a[col * n + col].recip();
384        for k in 0..m {
385            let mut acc = b[col * m + k];
386            for j in (col + 1)..n {
387                acc -= a[col * n + j] * b[j * m + k];
388            }
389            b[col * m + k] = acc * inv;
390        }
391    }
392    true
393}
394
395// ---------------------------------------------------------------------------
396// Generic forward kinematics
397// ---------------------------------------------------------------------------
398
399/// Generic mirror of `rigid::forward_kinematics`: world→body Plücker
400/// transforms (`.pos` = body origin in world coordinates, `.rot` = world→body
401/// rotation) and body-frame spatial velocities.
402pub(crate) fn fk_generic<T: Scalar>(
403    model: &Model,
404    layout: &DofLayout,
405    q: &[T],
406    v: &[T],
407) -> (Vec<SpatialTransform<T>>, Vec<SpatialVec<T>>) {
408    let nb = model.nbodies();
409    let mut xforms: Vec<SpatialTransform<T>> = vec![SpatialTransform::identity(); nb];
410    let mut vels: Vec<SpatialVec<T>> = vec![SpatialVec::zero(); nb];
411
412    for i in 0..nb {
413        let body = &model.bodies[i];
414        let joint = &model.joints[body.joint_idx];
415        let q_idx = layout.q_offsets[body.joint_idx];
416        let v_idx = layout.v_offsets[body.joint_idx];
417        let ndof = joint.ndof();
418        let axis = lift_vec3::<T>(joint.axis);
419
420        let x_joint = if ndof == 0 {
421            SpatialTransform::identity()
422        } else {
423            joint_transform(joint.joint_type, &axis, &q[q_idx..])
424        };
425        let x_tree = x_joint.compose(&lift_xform(&joint.parent_to_joint));
426
427        let v_joint = if ndof == 0 {
428            SpatialVec::zero()
429        } else {
430            joint_velocity(joint.joint_type, &axis, &v[v_idx..], ndof)
431        };
432
433        if body.parent < 0 {
434            xforms[i] = x_tree;
435            vels[i] = v_joint;
436        } else {
437            let pi = body.parent as usize;
438            xforms[i] = x_tree.compose(&xforms[pi]);
439            vels[i] = x_tree.apply_motion(&vels[pi]) + v_joint;
440        }
441    }
442
443    (xforms, vels)
444}
445
446// ---------------------------------------------------------------------------
447// Per-vertex ground contact
448// ---------------------------------------------------------------------------
449
450/// Wrench (about the body origin, **body frame**) contributed by one
451/// collision-mesh vertex against the ground plane.
452///
453/// `x_b` is the vertex in body coordinates; `xform` the body's world→body
454/// Plücker transform; `twist` the body-frame spatial velocity. Every factor
455/// is generic, so seeding `x_b` gives the exact ∂(wrench)/∂(vertex) the
456/// adjoint's vertex channel contracts against.
457pub(crate) fn vertex_wrench<T: Scalar>(
458    gc: &GroundContact,
459    x_b: Vec3<T>,
460    xform: &SpatialTransform<T>,
461    twist: &SpatialVec<T>,
462) -> SpatialVec<T> {
463    let e = &xform.rot; // world → body rotation
464    let x_w = xform.pos + e.transpose().mul_vec(x_b);
465    let depth = T::from_f64(gc.height) - x_w.z;
466
467    // Vertex world velocity: body-frame point velocity, rotated to world.
468    let v_b = twist.linear + twist.angular.cross(x_b);
469    let v_w = e.transpose().mul_vec(v_b);
470
471    // Spring–damper along +Z, pushing the body out of the plane; clamped to
472    // non-adhesive, and gated on actual penetration. Both the clamp and the
473    // gate branch on the primal, so the tangent is the derivative of the
474    // branch the f64 rollout takes.
475    let raw = T::from_f64(gc.stiffness) * depth - T::from_f64(gc.damping) * v_w.z;
476    let f_mag = T::select(depth, raw.max(T::ZERO), T::ZERO);
477
478    let f_w = Vec3::new(T::ZERO, T::ZERO, f_mag);
479    let f_b = e.mul_vec(f_w);
480    SpatialVec::new(x_b.cross(f_b), f_b)
481}
482
483/// Sum the ground-contact wrenches of every collision mesh into per-body
484/// external wrenches (body frame), given precomputed kinematics.
485fn contact_wrenches<T: Scalar>(
486    gc: &GroundContact,
487    meshes: &[CollisionMesh],
488    xforms: &[SpatialTransform<T>],
489    vels: &[SpatialVec<T>],
490    out: &mut [SpatialVec<T>],
491) {
492    for mesh in meshes {
493        let b = mesh.body;
494        for v in &mesh.vertices {
495            out[b] = out[b] + vertex_wrench(gc, lift_vec3(*v), &xforms[b], &vels[b]);
496        }
497    }
498}
499
500// ---------------------------------------------------------------------------
501// Generic ABA (single-DOF) with external wrenches
502// ---------------------------------------------------------------------------
503
504/// Implicit-damping contribution to a joint's effective inertia, mirroring
505/// `rigid::aba::implicit_damping`. The damping *force* stays explicit (it is
506/// in `tau`); adding `dt·c` to `D = Sᵀ Iᴬ S` is the first-order implicit solve
507/// that makes a light, heavily damped distal link stable at RL timesteps.
508///
509/// This must track the concrete ABA and the GPU kernel: if the diff step
510/// omitted it, the adjoint would return exact gradients of dynamics the engine
511/// does not integrate. `dt` and `damping` are model constants, so this carries
512/// no tangent.
513fn implicit_damping<T: Scalar>(model: &Model, joint: &phyz_model::Joint) -> T {
514    T::from_f64(model.dt * joint.damping)
515}
516
517/// Generic mirror of `rigid::aba_with_external_forces` over the full joint
518/// domain, with the body inertias supplied separately so a caller can seed
519/// them.
520///
521/// Single-DOF joints keep the scalar fast path (`D` is one number); spherical
522/// and free joints go through a dense `ndof×ndof` solve
523/// ([`solve_in_place`]), which is what makes the articulated-inertia
524/// factorisation `Iᴬ = I − U D⁻¹ Uᵀ` well-defined for them.
525#[allow(clippy::too_many_arguments)]
526pub(crate) fn aba_generic<T: Scalar>(
527    model: &Model,
528    layout: &DofLayout,
529    inertias: &[SpatialInertia<T>],
530    q: &[T],
531    v: &[T],
532    ctrl: &[T],
533    ext: Option<&[SpatialVec<T>]>,
534) -> Vec<T> {
535    let nb = model.nbodies();
536    let mut qdd = vec![T::ZERO; layout.nv];
537
538    let mut x_tree: Vec<SpatialTransform<T>> = vec![SpatialTransform::identity(); nb];
539    let mut vel: Vec<SpatialVec<T>> = vec![SpatialVec::zero(); nb];
540    let mut c_bias: Vec<SpatialVec<T>> = vec![SpatialVec::zero(); nb];
541    let mut p_a: Vec<SpatialVec<T>> = vec![SpatialVec::zero(); nb];
542    let mut i_a: Vec<SpatialMat<T>> = vec![SpatialMat::zero(); nb];
543
544    // Gravity as base acceleration.
545    let a0: SpatialVec<T> = SpatialVec::new(Vec3::zero(), -lift_vec3(model.gravity));
546
547    // -- Pass 1: velocities and bias --
548    for i in 0..nb {
549        let body = &model.bodies[i];
550        let joint = &model.joints[body.joint_idx];
551        let q_idx = layout.q_offsets[body.joint_idx];
552        let v_idx = layout.v_offsets[body.joint_idx];
553        let ndof = joint.ndof();
554        let axis = lift_vec3::<T>(joint.axis);
555
556        let x_joint = if ndof == 0 {
557            SpatialTransform::identity()
558        } else {
559            joint_transform(joint.joint_type, &axis, &q[q_idx..])
560        };
561        x_tree[i] = x_joint.compose(&lift_xform(&joint.parent_to_joint));
562
563        let v_joint = if ndof == 0 {
564            SpatialVec::zero()
565        } else {
566            joint_velocity(joint.joint_type, &axis, &v[v_idx..], ndof)
567        };
568
569        if body.parent < 0 {
570            vel[i] = v_joint;
571            c_bias[i] = SpatialVec::zero();
572        } else {
573            let pi = body.parent as usize;
574            vel[i] = x_tree[i].apply_motion(&vel[pi]) + v_joint;
575            c_bias[i] = vel[i].cross_motion(&v_joint);
576        }
577
578        i_a[i] = inertias[i].to_matrix();
579        p_a[i] = vel[i].cross_force(&i_a[i].mul_vec(&vel[i]));
580        if let Some(ext) = ext {
581            p_a[i] = p_a[i] - ext[i];
582        }
583    }
584
585    // -- Pass 2: articulated inertias and forces --
586    for i in (0..nb).rev() {
587        let body = &model.bodies[i];
588        let joint = &model.joints[body.joint_idx];
589        let v_idx = layout.v_offsets[body.joint_idx];
590        let ndof = joint.ndof();
591
592        if ndof == 0 {
593            if body.parent >= 0 {
594                let pi = body.parent as usize;
595                let x_mot = x_tree[i].to_motion_matrix();
596                i_a[pi] = i_a[pi] + x_mot.transpose().mul_mat(&i_a[i]).mul_mat(&x_mot);
597                p_a[pi] = p_a[pi] + x_tree[i].inv_apply_force(&p_a[i]);
598            }
599            continue;
600        }
601
602        let axis = lift_vec3::<T>(joint.axis);
603
604        if ndof > 1 {
605            // Multi-DOF: U = Iᴬ S, D = Sᵀ U, u = τ − Sᵀ pᴬ, then one solve of
606            // D against [u | Uᵀ] gives both D⁻¹u and D⁻¹Uᵀ.
607            let s = motion_subspace_cols(joint.joint_type, &axis);
608            let ia = i_a[i];
609            let mut u_cols = [SpatialVec::<T>::zero(); MAX_DOF];
610            for k in 0..ndof {
611                u_cols[k] = ia.mul_vec(&s[k]);
612            }
613
614            let mut d = vec![T::ZERO; ndof * ndof];
615            for k in 0..ndof {
616                for l in 0..ndof {
617                    d[k * ndof + l] = s[k].dot(&u_cols[l]);
618                }
619                d[k * ndof + k] += implicit_damping::<T>(model, joint);
620            }
621            // rhs column 0 = u, columns 1..7 = Uᵀ.
622            let mut rhs = vec![T::ZERO; ndof * 7];
623            for k in 0..ndof {
624                rhs[k * 7] =
625                    ctrl[v_idx + k] - T::from_f64(joint.damping) * v[v_idx + k] - s[k].dot(&p_a[i]);
626                let uk = u_cols[k].as_array();
627                for c in 0..6 {
628                    rhs[k * 7 + 1 + c] = uk[c];
629                }
630            }
631            if !solve_in_place(&mut d, ndof, &mut rhs, 7) {
632                continue;
633            }
634
635            if body.parent >= 0 {
636                let pi = body.parent as usize;
637                // Iᴬ_new = Iᴬ − Σ_k U[:,k] ⊗ (D⁻¹Uᵀ)[k,:]
638                let mut ia_new = ia;
639                for k in 0..ndof {
640                    let y_k = SpatialVec::new(
641                        Vec3::new(rhs[k * 7 + 1], rhs[k * 7 + 2], rhs[k * 7 + 3]),
642                        Vec3::new(rhs[k * 7 + 4], rhs[k * 7 + 5], rhs[k * 7 + 6]),
643                    );
644                    ia_new = ia_new - outer_product_6(&u_cols[k], &y_k);
645                }
646                // pᴬ_new = pᴬ + Iᴬ_new·c + U·(D⁻¹u)
647                let mut p_new = p_a[i] + ia_new.mul_vec(&c_bias[i]);
648                for k in 0..ndof {
649                    p_new = p_new + u_cols[k] * rhs[k * 7];
650                }
651
652                let x_mot = x_tree[i].to_motion_matrix();
653                i_a[pi] = i_a[pi] + x_mot.transpose().mul_mat(&ia_new).mul_mat(&x_mot);
654                p_a[pi] = p_a[pi] + x_tree[i].inv_apply_force(&p_new);
655            }
656            continue;
657        }
658
659        let s_i = motion_subspace(joint.joint_type, &axis);
660        let tau_i = ctrl[v_idx] - T::from_f64(joint.damping) * v[v_idx];
661
662        let ia = &i_a[i];
663        let u_i = tau_i - s_i.dot(&p_a[i]);
664        let d_i = s_i.dot(&ia.mul_vec(&s_i)) + implicit_damping::<T>(model, joint);
665
666        // Same degeneracy guard as the concrete ABA, on the primal.
667        if d_i.to_f64().abs() < 1e-20 {
668            continue;
669        }
670
671        let ia_s = ia.mul_vec(&s_i);
672        if body.parent >= 0 {
673            let pi = body.parent as usize;
674            let d_inv = d_i.recip();
675            let outer = outer_product_6(&ia_s, &ia_s);
676            let ia_new = *ia - outer * d_inv;
677            let p_new = p_a[i] + ia_new.mul_vec(&c_bias[i]) + ia_s * (u_i * d_inv);
678
679            let x_mot = x_tree[i].to_motion_matrix();
680            i_a[pi] = i_a[pi] + x_mot.transpose().mul_mat(&ia_new).mul_mat(&x_mot);
681            p_a[pi] = p_a[pi] + x_tree[i].inv_apply_force(&p_new);
682        }
683    }
684
685    // -- Pass 3: accelerations --
686    let mut acc: Vec<SpatialVec<T>> = vec![SpatialVec::zero(); nb];
687    for i in 0..nb {
688        let body = &model.bodies[i];
689        let joint = &model.joints[body.joint_idx];
690        let v_idx = layout.v_offsets[body.joint_idx];
691        let ndof = joint.ndof();
692
693        let a_parent = if body.parent < 0 {
694            x_tree[i].apply_motion(&a0)
695        } else {
696            x_tree[i].apply_motion(&acc[body.parent as usize])
697        };
698
699        if ndof == 0 {
700            acc[i] = a_parent + c_bias[i];
701            continue;
702        }
703
704        let axis = lift_vec3::<T>(joint.axis);
705
706        if ndof > 1 {
707            // qdd = D⁻¹(u − Sᵀ Iᴬ (a_parent + c))
708            let s = motion_subspace_cols(joint.joint_type, &axis);
709            let ia = i_a[i];
710            let a_total = a_parent + c_bias[i];
711            let ia_a = ia.mul_vec(&a_total);
712
713            let mut d = vec![T::ZERO; ndof * ndof];
714            let mut rhs = vec![T::ZERO; ndof];
715            for k in 0..ndof {
716                for l in 0..ndof {
717                    d[k * ndof + l] = s[k].dot(&ia.mul_vec(&s[l]));
718                }
719                d[k * ndof + k] += implicit_damping::<T>(model, joint);
720                rhs[k] = ctrl[v_idx + k]
721                    - T::from_f64(joint.damping) * v[v_idx + k]
722                    - s[k].dot(&p_a[i])
723                    - s[k].dot(&ia_a);
724            }
725            if !solve_in_place(&mut d, ndof, &mut rhs, 1) {
726                acc[i] = a_total;
727                continue;
728            }
729            let mut s_qdd = SpatialVec::zero();
730            for k in 0..ndof {
731                qdd[v_idx + k] = rhs[k];
732                s_qdd = s_qdd + SpatialVec::new(s[k].angular * rhs[k], s[k].linear * rhs[k]);
733            }
734            acc[i] = a_total + s_qdd;
735            continue;
736        }
737
738        let s_i = motion_subspace(joint.joint_type, &axis);
739        let ia = &i_a[i];
740        let d_i = s_i.dot(&ia.mul_vec(&s_i)) + implicit_damping::<T>(model, joint);
741        if d_i.to_f64().abs() < 1e-20 {
742            acc[i] = a_parent + c_bias[i];
743            continue;
744        }
745
746        let tau_i = ctrl[v_idx] - T::from_f64(joint.damping) * v[v_idx];
747        let u_i = tau_i - s_i.dot(&p_a[i]);
748        let qdd_i = (u_i - ia.mul_vec(&(a_parent + c_bias[i])).dot(&s_i)) * d_i.recip();
749        qdd[v_idx] = qdd_i;
750        acc[i] = a_parent + c_bias[i] + SpatialVec::new(s_i.angular * qdd_i, s_i.linear * qdd_i);
751    }
752
753    qdd
754}
755
756fn outer_product_6<T: Scalar>(a: &SpatialVec<T>, b: &SpatialVec<T>) -> SpatialMat<T> {
757    fn v3_outer<T: Scalar>(a: Vec3<T>, b: Vec3<T>) -> Mat3<T> {
758        Mat3::new(
759            a.x * b.x,
760            a.x * b.y,
761            a.x * b.z,
762            a.y * b.x,
763            a.y * b.y,
764            a.y * b.z,
765            a.z * b.x,
766            a.z * b.y,
767            a.z * b.z,
768        )
769    }
770    SpatialMat::new(
771        v3_outer(a.angular, b.angular),
772        v3_outer(a.angular, b.linear),
773        v3_outer(a.linear, b.angular),
774        v3_outer(a.linear, b.linear),
775    )
776}
777
778/// Body-frame spatial velocities and accelerations at a nominal
779/// `(q, v, qdd)`, mirroring passes 1 and 3 of [`aba_generic`] exactly
780/// (base acceleration `-gravity`, so the accelerations are the ones an
781/// inverse-dynamics pass would use).
782///
783/// This is the kinematic half of the recursive Newton–Euler regressor the
784/// adjoint's inertia channel contracts against; it costs one `O(nb)` sweep,
785/// no dual arithmetic.
786pub(crate) fn nominal_motion(
787    model: &Model,
788    layout: &DofLayout,
789    q: &[f64],
790    v: &[f64],
791    qdd: &[f64],
792) -> (Vec<SpatialVec<f64>>, Vec<SpatialVec<f64>>) {
793    let nb = model.nbodies();
794    let mut vel = vec![SpatialVec::<f64>::zero(); nb];
795    let mut c_bias = vec![SpatialVec::<f64>::zero(); nb];
796    let mut acc = vec![SpatialVec::<f64>::zero(); nb];
797    let mut x_tree = vec![SpatialTransform::<f64>::identity(); nb];
798
799    let a0 = SpatialVec::new(Vec3::zero(), -model.gravity);
800
801    for i in 0..nb {
802        let body = &model.bodies[i];
803        let joint = &model.joints[body.joint_idx];
804        let q_idx = layout.q_offsets[body.joint_idx];
805        let v_idx = layout.v_offsets[body.joint_idx];
806        let ndof = joint.ndof();
807
808        let x_joint = if ndof == 0 {
809            SpatialTransform::identity()
810        } else {
811            joint_transform(joint.joint_type, &joint.axis, &q[q_idx..])
812        };
813        x_tree[i] = x_joint.compose(&joint.parent_to_joint);
814
815        let v_joint = if ndof == 0 {
816            SpatialVec::zero()
817        } else {
818            joint_velocity(joint.joint_type, &joint.axis, &v[v_idx..], ndof)
819        };
820
821        if body.parent < 0 {
822            vel[i] = v_joint;
823        } else {
824            let pi = body.parent as usize;
825            vel[i] = x_tree[i].apply_motion(&vel[pi]) + v_joint;
826            c_bias[i] = vel[i].cross_motion(&v_joint);
827        }
828    }
829
830    for i in 0..nb {
831        let body = &model.bodies[i];
832        let joint = &model.joints[body.joint_idx];
833        let v_idx = layout.v_offsets[body.joint_idx];
834        let ndof = joint.ndof();
835
836        let a_parent = if body.parent < 0 {
837            x_tree[i].apply_motion(&a0)
838        } else {
839            x_tree[i].apply_motion(&acc[body.parent as usize])
840        };
841
842        acc[i] = a_parent + c_bias[i];
843        if ndof > 0 {
844            // S·qdd, summed over the joint's DOFs — a multi-DOF joint
845            // contributes every column, not just the first.
846            acc[i] = acc[i] + joint_velocity(joint.joint_type, &joint.axis, &qdd[v_idx..], ndof);
847        }
848    }
849
850    (vel, acc)
851}
852
853// ---------------------------------------------------------------------------
854// Generic semi-implicit Euler step
855// ---------------------------------------------------------------------------
856
857/// The configuration update `q' = Φ(q, v')` of the semi-implicit step, split
858/// out because the adjoint needs its two Jacobian blocks (`Φ_q`, `Φ_v'`)
859/// separately — with quaternion coordinates neither is the identity and
860/// `Φ_v'` is not even square.
861///
862/// Single-DOF joints get `q' = q + dt·v'`. Quaternion sub-blocks get the
863/// Lie-group step `p' = normalize(exp(−dt·ω) ⊗ p)` (the sign follows
864/// `Ė = −ω× E` for the coordinate transform `E = R(p)`), and a free joint's
865/// translation integrates `ṙ = Eᵀ·v_lin` at the **current** orientation.
866pub(crate) fn config_update_generic<T: Scalar>(
867    model: &Model,
868    layout: &DofLayout,
869    q: &[T],
870    v_next: &[T],
871    dt: T,
872) -> Vec<T> {
873    let mut out = q.to_vec();
874    for (j, joint) in model.joints.iter().enumerate() {
875        let qi = layout.q_offsets[j];
876        let vi = layout.v_offsets[j];
877        match joint.joint_type {
878            JointType::Revolute | JointType::Hinge | JointType::Prismatic | JointType::Slide => {
879                out[qi] = q[qi] + dt * v_next[vi];
880            }
881            JointType::Spherical | JointType::Ball => {
882                let p = quat_at(&q[qi..qi + 4]);
883                let omega = Vec3::new(v_next[vi], v_next[vi + 1], v_next[vi + 2]);
884                write_quat(&mut out[qi..qi + 4], &rotate_step(&p, omega, dt));
885            }
886            JointType::Free => {
887                let p = quat_at(&q[qi + 3..qi + 7]);
888                let e = p.normalize().to_matrix();
889                let v_lin = Vec3::new(v_next[vi + 3], v_next[vi + 4], v_next[vi + 5]);
890                let dr = e.transpose().mul_vec(v_lin) * dt;
891                out[qi] = q[qi] + dr.x;
892                out[qi + 1] = q[qi + 1] + dr.y;
893                out[qi + 2] = q[qi + 2] + dr.z;
894
895                let omega = Vec3::new(v_next[vi], v_next[vi + 1], v_next[vi + 2]);
896                write_quat(&mut out[qi + 3..qi + 7], &rotate_step(&p, omega, dt));
897            }
898            JointType::Fixed => {}
899        }
900    }
901    out
902}
903
904/// `normalize(exp(−dt·ω) ⊗ p)` — one Lie-group step of the coordinate
905/// quaternion under body-frame angular velocity `ω`.
906fn rotate_step<T: Scalar>(p: &Quat<T>, omega: Vec3<T>, dt: T) -> Quat<T> {
907    Quat::exp(&(omega * -dt)).mul(p).normalize()
908}
909
910fn write_quat<T: Scalar>(out: &mut [T], p: &Quat<T>) {
911    out[0] = p.w;
912    out[1] = p.v.x;
913    out[2] = p.v.y;
914    out[3] = p.v.z;
915}
916
917/// One semi-implicit Euler step, generic over the scalar:
918///
919/// ```text
920/// qdd  = ABA(q, v, ctrl; π, contact(q, v, V), ext)
921/// v'   = v + dt·qdd
922/// q'   = Φ(q, v')
923/// ```
924///
925/// `ext` is an additive per-body external wrench **on top of** the contact
926/// forces — zero in the plain rollout, dual-seeded by the adjoint driver to
927/// read out `∂qdd/∂(wrench component)` for the vertex channel.
928#[allow(clippy::too_many_arguments)]
929pub(crate) fn step_generic<T: Scalar>(
930    model: &Model,
931    layout: &DofLayout,
932    inertias: &[SpatialInertia<T>],
933    contact: Option<(&GroundContact, &[CollisionMesh])>,
934    ext: Option<&[SpatialVec<T>]>,
935    q: &[T],
936    v: &[T],
937    ctrl: &[T],
938) -> (Vec<T>, Vec<T>, Vec<T>) {
939    let nb = model.nbodies();
940    let dt = T::from_f64(model.dt);
941
942    let total_ext: Option<Vec<SpatialVec<T>>> = if contact.is_some() || ext.is_some() {
943        let mut w: Vec<SpatialVec<T>> = match ext {
944            Some(e) => e.to_vec(),
945            None => vec![SpatialVec::zero(); nb],
946        };
947        if let Some((gc, meshes)) = contact {
948            let (xforms, vels) = fk_generic(model, layout, q, v);
949            contact_wrenches(gc, meshes, &xforms, &vels, &mut w);
950        }
951        Some(w)
952    } else {
953        None
954    };
955
956    let qdd = aba_generic(model, layout, inertias, q, v, ctrl, total_ext.as_deref());
957
958    let v_next: Vec<T> = v.iter().zip(&qdd).map(|(&vi, &ai)| vi + dt * ai).collect();
959    let q_next = config_update_generic(model, layout, q, &v_next, dt);
960
961    (q_next, v_next, qdd)
962}
963
964/// Derive the coordinate layout and the nominal inertia parameters (one
965/// 10-vector per body) of a model.
966pub(crate) fn validate_and_params(model: &Model) -> (DofLayout, Vec<[f64; N_INERTIA_PARAMS]>) {
967    let layout = DofLayout::of(model);
968    let params = model
969        .bodies
970        .iter()
971        .map(|b| inertia_params(&b.inertia))
972        .collect();
973    (layout, params)
974}
975
976/// A nominal trajectory: `(states, accels)` — see [`rollout_states`].
977pub(crate) type RolloutTrace = (Vec<(Vec<f64>, Vec<f64>)>, Vec<Vec<f64>>);
978
979/// Convenience: run the plain `f64` rollout for `steps` steps from `(q0, v0)`
980/// under an open-loop control schedule, returning every state along the way
981/// (`states[t] = (q_t, v_t)`, `t = 0..=steps`) and the nominal accelerations
982/// (`accels[t] = qdd(q_t, v_t, u_t)`, length `steps`). The backward pass needs
983/// the accelerations to build its inverse-dynamics regressor; recording them
984/// here is free, recomputing them later is not.
985pub(crate) fn rollout_states(
986    model: &Model,
987    layout: &DofLayout,
988    contact: Option<(&GroundContact, &[CollisionMesh])>,
989    q0: &[f64],
990    v0: &[f64],
991    ctrl: &dyn Fn(usize) -> DVec,
992    steps: usize,
993) -> RolloutTrace {
994    let inertias: Vec<SpatialInertia<f64>> = model.bodies.iter().map(|b| b.inertia).collect();
995    let mut states = Vec::with_capacity(steps + 1);
996    let mut accels = Vec::with_capacity(steps);
997    let mut q = q0.to_vec();
998    let mut v = v0.to_vec();
999    states.push((q.clone(), v.clone()));
1000    for t in 0..steps {
1001        let u = ctrl(t);
1002        let (qn, vn, qdd) = step_generic::<f64>(
1003            model,
1004            layout,
1005            &inertias,
1006            contact,
1007            None,
1008            &q,
1009            &v,
1010            u.as_slice(),
1011        );
1012        q = qn;
1013        v = vn;
1014        states.push((q.clone(), v.clone()));
1015        accels.push(qdd);
1016    }
1017    (states, accels)
1018}