rigidax 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rigidax/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """rigidax: differentiable rigid-body kinematics and dynamics in JAX"""
2
+
3
+ from rigidax.dynamics import (
4
+ bias_forces,
5
+ body_spatial_inertias,
6
+ coriolis_forces,
7
+ difference,
8
+ dof_inertias,
9
+ dynamics_terms,
10
+ forward_dynamics,
11
+ gravity_forces,
12
+ integrate,
13
+ mass_matrix,
14
+ mass_matrix_inverse,
15
+ rnea,
16
+ )
17
+ from rigidax.kinematics import (
18
+ Kinematics,
19
+ body_coms,
20
+ body_jacobian,
21
+ body_pose,
22
+ center_of_mass,
23
+ center_of_mass_jacobian,
24
+ forward_kinematics,
25
+ point_jacobian,
26
+ points_jacobians,
27
+ site_jacobian,
28
+ site_pose,
29
+ )
30
+ from rigidax.model import Model, Params, Topology, from_mujoco, from_xml_string, load
31
+
32
+ __version__ = "1.0.0"
33
+
34
+ __all__ = [
35
+ "Kinematics",
36
+ "Model",
37
+ "Params",
38
+ "Topology",
39
+ "bias_forces",
40
+ "body_coms",
41
+ "body_jacobian",
42
+ "body_pose",
43
+ "body_spatial_inertias",
44
+ "center_of_mass",
45
+ "center_of_mass_jacobian",
46
+ "coriolis_forces",
47
+ "difference",
48
+ "dof_inertias",
49
+ "dynamics_terms",
50
+ "forward_dynamics",
51
+ "forward_kinematics",
52
+ "from_mujoco",
53
+ "from_xml_string",
54
+ "gravity_forces",
55
+ "integrate",
56
+ "load",
57
+ "mass_matrix",
58
+ "mass_matrix_inverse",
59
+ "point_jacobian",
60
+ "points_jacobians",
61
+ "rnea",
62
+ "site_jacobian",
63
+ "site_pose",
64
+ ]
rigidax/dynamics.py ADDED
@@ -0,0 +1,212 @@
1
+ """Inverse/forward dynamics and configuration-space integration.
2
+
3
+ All algorithms are written as dense, vectorized passes over the DOF ancestor mask,
4
+ so there are no sequential loops over joints.
5
+ """
6
+
7
+ import jax
8
+ import jax.numpy as jnp
9
+ import jax.scipy.linalg as jsl
10
+ import numpy as np
11
+ from jax import Array
12
+
13
+ from rigidax.kinematics import Kinematics, _kin, body_coms
14
+ from rigidax.model import Model, derived
15
+ from rigidax.spatial import (
16
+ cross_force,
17
+ cross_motion,
18
+ einsum,
19
+ mm,
20
+ quat_conj,
21
+ quat_integrate,
22
+ quat_log,
23
+ quat_mul,
24
+ quat_normalize,
25
+ quat_to_mat,
26
+ skew,
27
+ )
28
+
29
+
30
+ def body_spatial_inertias(model: Model, kin: Kinematics, bodies=None) -> Array:
31
+ """(nbody, 6, 6) spatial inertias in the world frame, about the origin"""
32
+ P = model.params
33
+ b = slice(None) if bodies is None else bodies
34
+ with jax.ensure_compile_time_eval(): # parameter-only: constant if not traced
35
+ Ri = quat_to_mat(jnp.asarray(P.body_iquat)[b])
36
+ I_local = einsum("bij,bj,bkj->bik", Ri, jnp.asarray(P.body_inertia)[b], Ri)
37
+ ipos = jnp.asarray(P.body_ipos)[b]
38
+ m = jnp.asarray(P.body_mass)[b][:, None, None]
39
+ m_eye = m * jnp.eye(3)
40
+ R, p = kin.body_R[b], kin.body_p[b]
41
+ Ic = mm(mm(R, I_local), jnp.swapaxes(R, -1, -2))
42
+ C = skew(p + einsum("bij,bj->bi", R, ipos))
43
+ mC = m * C
44
+ top = jnp.concatenate([Ic - mm(mC, C), mC], -1)
45
+ bot = jnp.concatenate([-mC, jnp.broadcast_to(m_eye, mC.shape)], -1)
46
+ return jnp.concatenate([top, bot], -2)
47
+
48
+
49
+ def _moving_inertias(model, kin):
50
+ """Spatial inertias of the bodies that some DOF moves (static subset)"""
51
+ return body_spatial_inertias(model, kin, derived(model.topo).owned)
52
+
53
+
54
+ def dof_inertias(model: Model, kin: Kinematics) -> Array:
55
+ """(nv, 6, 6) inertia carried by each DOF (welded bodies fused, virtual DOFs zero)"""
56
+ D = derived(model.topo)
57
+ own = np.zeros((model.nv, len(D.owned)))
58
+ own[D.owned_dof, np.arange(len(D.owned))] = 1.0
59
+ return einsum("ki,iab->kab", own, _moving_inertias(model, kin))
60
+
61
+
62
+ def _ext_wrenches(model: Model, kin: Kinematics, xfrc: Array) -> Array:
63
+ """Per-body [force, torque] at the COM (world axes, MuJoCo's xfrc_applied layout)
64
+ -> spatial forces about the origin, for each moving body"""
65
+ D = derived(model.topo)
66
+ c = body_coms(model, None, kin)[D.owned]
67
+ f, tq = xfrc[D.owned, :3], xfrc[D.owned, 3:]
68
+ return jnp.concatenate([tq + jnp.cross(c, f), f], -1)
69
+
70
+
71
+ def _rnea(model, kin, Ib, v, a, gravity, F_ext):
72
+ D = derived(model.topo)
73
+ A, W = jnp.asarray(D.A), jnp.asarray(D.W)
74
+ S = kin.S
75
+ acc = None
76
+ if v is not None:
77
+ Sv = S * v[:, None]
78
+ V = mm(A, Sv)
79
+ if np.array_equal(D.cross, np.arange(model.nv)):
80
+ Vc = V # hinge/slide only: each axis moves with its own frame
81
+ else:
82
+ Vc = jnp.concatenate([V, jnp.zeros((1, 6), V.dtype)])[D.cross]
83
+ acc = cross_motion(Vc, Sv)
84
+ if a is not None:
85
+ Sa = S * a[:, None]
86
+ acc = Sa if acc is None else acc + Sa
87
+ Acc = jnp.zeros((len(D.owned), 6), S.dtype) if acc is None else mm(A, acc)[D.owned_dof]
88
+ if gravity:
89
+ g = jnp.asarray(model.params.gravity, S.dtype)
90
+ Acc = Acc + jnp.concatenate([jnp.zeros(3, S.dtype), -g])
91
+ F = einsum("iab,ib->ia", Ib, Acc)
92
+ if v is not None:
93
+ Vb = V[D.owned_dof]
94
+ F = F + cross_force(Vb, einsum("iab,ib->ia", Ib, Vb))
95
+ if F_ext is not None:
96
+ F = F - F_ext
97
+ tau = jnp.sum(S * mm(W, F), -1)
98
+ if a is not None:
99
+ tau = tau + jnp.asarray(model.params.dof_armature) * a
100
+ return tau
101
+
102
+
103
+ def rnea(model: Model, q: Array, v: Array | None, a: Array | None, *,
104
+ xfrc: Array | None = None, gravity: bool = True,
105
+ kin: Kinematics | None = None) -> Array:
106
+ """Inverse dynamics: tau = M(q) a + C(q, v) v + g(q) - J^T f_ext.
107
+
108
+ v / a may be None (treated as zero). `xfrc` is (nbody, 6) [force, torque] applied
109
+ at each body's COM in world axes, like MuJoCo's xfrc_applied.
110
+ Armature is included in the M(q) a term.
111
+ """
112
+ kin = _kin(model, q, kin)
113
+ F_ext = None if xfrc is None else _ext_wrenches(model, kin, xfrc)
114
+ return _rnea(model, kin, _moving_inertias(model, kin), v, a, gravity, F_ext)
115
+
116
+
117
+ def _crba(model, kin, Ib):
118
+ D = derived(model.topo)
119
+ A = jnp.asarray(D.A)
120
+ Ic = einsum("ki,iab->kab", D.W, Ib) # composite inertia of each DOF's subtree
121
+ F = einsum("kab,kb->ka", Ic, kin.S)
122
+ M_all = mm(F, kin.S.T) # M_all[k, j] = s_j . Ic_k s_k
123
+ L = A * M_all # keep (k, ancestor j) pairs
124
+ return L + jnp.tril(L, -1).T + jnp.diag(jnp.asarray(model.params.dof_armature))
125
+
126
+
127
+ def mass_matrix(model: Model, q: Array, kin: Kinematics | None = None) -> Array:
128
+ """Joint-space inertia matrix (CRBA), including armature, (nv, nv)"""
129
+ kin = _kin(model, q, kin)
130
+ return _crba(model, kin, _moving_inertias(model, kin))
131
+
132
+
133
+ def bias_forces(model: Model, q: Array, v: Array, kin: Kinematics | None = None) -> Array:
134
+ """C(q, v) v + g(q), (nv,)"""
135
+ return rnea(model, q, v, None, kin=kin)
136
+
137
+
138
+ def gravity_forces(model: Model, q: Array, kin: Kinematics | None = None) -> Array:
139
+ return rnea(model, q, None, None, kin=kin)
140
+
141
+
142
+ def coriolis_forces(model: Model, q: Array, v: Array, kin: Kinematics | None = None) -> Array:
143
+ return rnea(model, q, v, None, gravity=False, kin=kin)
144
+
145
+
146
+ def dynamics_terms(model: Model, q: Array, v: Array, kin: Kinematics | None = None):
147
+ """(M, bias) from a single kinematics and inertia pass"""
148
+ kin = _kin(model, q, kin)
149
+ Ib = _moving_inertias(model, kin)
150
+ return _crba(model, kin, Ib), _rnea(model, kin, Ib, v, None, True, None)
151
+
152
+
153
+ def forward_dynamics(model: Model, q: Array, v: Array, tau: Array, *,
154
+ xfrc: Array | None = None, kin: Kinematics | None = None) -> Array:
155
+ """qacc = M^-1 (tau + J^T f_ext - bias), via one Cholesky factorization"""
156
+ kin = _kin(model, q, kin)
157
+ Ib = _moving_inertias(model, kin)
158
+ M = _crba(model, kin, Ib)
159
+ F_ext = None if xfrc is None else _ext_wrenches(model, kin, xfrc)
160
+ bias = _rnea(model, kin, Ib, v, None, True, F_ext)
161
+ return jsl.cho_solve(jsl.cho_factor(M, lower=True), tau - bias)
162
+
163
+
164
+ def mass_matrix_inverse(M: Array) -> Array:
165
+ c = jsl.cho_factor(M, lower=True)
166
+ return jsl.cho_solve(c, jnp.eye(M.shape[0], dtype=M.dtype))
167
+
168
+
169
+ # ----- configuration space -----
170
+
171
+
172
+ def integrate(model: Model, q: Array, v: Array, dt: float) -> Array:
173
+ """q (+) v*dt, respecting quaternions (MuJoCo's mj_integratePos)"""
174
+ D = derived(model.topo)
175
+ q = jnp.asarray(q)
176
+ if len(D.scalar_j):
177
+ j = D.scalar_j
178
+ q = q.at[D.qadr[j]].add(v[D.dadr[j]] * dt)
179
+ if len(D.free_j):
180
+ qa, da = D.qadr[D.free_j][:, None], D.dadr[D.free_j][:, None]
181
+ q = q.at[qa + jnp.arange(3)].add(v[da + jnp.arange(3)] * dt)
182
+ quat = quat_normalize(q[qa + 3 + jnp.arange(4)])
183
+ q = q.at[qa + 3 + jnp.arange(4)].set(quat_integrate(quat, v[da + 3 + jnp.arange(3)], dt))
184
+ if len(D.ball_j):
185
+ qa, da = D.qadr[D.ball_j][:, None], D.dadr[D.ball_j][:, None]
186
+ quat = quat_normalize(q[qa + jnp.arange(4)])
187
+ q = q.at[qa + jnp.arange(4)].set(quat_integrate(quat, v[da + jnp.arange(3)], dt))
188
+ return q
189
+
190
+
191
+ def difference(model: Model, q0: Array, q1: Array) -> Array:
192
+ """v such that integrate(q0, v, 1) == q1 (MuJoCo's mj_differentiatePos, dt=1)"""
193
+ D = derived(model.topo)
194
+ q0, q1 = jnp.asarray(q0), jnp.asarray(q1)
195
+ v = jnp.zeros(model.nv, jnp.result_type(q0, q1))
196
+ if len(D.scalar_j):
197
+ j = D.scalar_j
198
+ v = v.at[D.dadr[j]].set(q1[D.qadr[j]] - q0[D.qadr[j]])
199
+
200
+ def rot(qa):
201
+ a = quat_normalize(q0[qa + jnp.arange(4)])
202
+ b = quat_normalize(q1[qa + jnp.arange(4)])
203
+ return quat_log(quat_mul(quat_conj(a), b))
204
+
205
+ if len(D.free_j):
206
+ qa, da = D.qadr[D.free_j][:, None], D.dadr[D.free_j][:, None]
207
+ v = v.at[da + jnp.arange(3)].set(q1[qa + jnp.arange(3)] - q0[qa + jnp.arange(3)])
208
+ v = v.at[da + 3 + jnp.arange(3)].set(rot(qa + 3))
209
+ if len(D.ball_j):
210
+ qa, da = D.qadr[D.ball_j][:, None], D.dadr[D.ball_j][:, None]
211
+ v = v.at[da + jnp.arange(3)].set(rot(qa))
212
+ return v
rigidax/ik/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """rigidax.ik: differential IK, batched retargeting and self-collision avoidance"""
2
+
3
+ from rigidax.ik.collision import SphereModel
4
+ from rigidax.ik.qp import INF, QPResult, QPState, solve_qp, spd_inverse
5
+ from rigidax.ik.solver import FloorPoints, IKSolver
6
+ from rigidax.ik.tasks import (
7
+ DirectionTask,
8
+ HeightTask,
9
+ JointTask,
10
+ OrientationTask,
11
+ PositionTask,
12
+ PostureTask,
13
+ TwistTask,
14
+ direction,
15
+ height,
16
+ joints,
17
+ orientation,
18
+ position,
19
+ rotation_error,
20
+ twist,
21
+ )
22
+ from rigidax.ik.trajectory import smooth_trajectory
23
+
24
+ __all__ = [
25
+ "DirectionTask",
26
+ "FloorPoints",
27
+ "HeightTask",
28
+ "IKSolver",
29
+ "INF",
30
+ "JointTask",
31
+ "OrientationTask",
32
+ "PositionTask",
33
+ "PostureTask",
34
+ "QPResult",
35
+ "QPState",
36
+ "SphereModel",
37
+ "TwistTask",
38
+ "direction",
39
+ "height",
40
+ "joints",
41
+ "orientation",
42
+ "position",
43
+ "rotation_error",
44
+ "smooth_trajectory",
45
+ "solve_qp",
46
+ "spd_inverse",
47
+ "twist",
48
+ ]
rigidax/ik/bvh.py ADDED
@@ -0,0 +1,99 @@
1
+ """Minimal BVH motion-capture reader (Y-up files converted to Z-up)"""
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+
7
+
8
+ def load_bvh(path):
9
+ """-> joint names, parents, offsets (J, 3), channels per joint, motion (T, C)"""
10
+ names, parents, offsets, channels, stack = [], [], [], [], []
11
+ lines = iter(Path(path).read_text().split("\n"))
12
+ for line in lines:
13
+ tok = line.split()
14
+ if not tok:
15
+ continue
16
+ if tok[0] in ("ROOT", "JOINT"):
17
+ names.append(tok[1])
18
+ parents.append(stack[-1] if stack else -1)
19
+ offsets.append(None)
20
+ channels.append([])
21
+ elif tok[0] == "End":
22
+ names.append(None) # end site: parsed for nesting, dropped later
23
+ parents.append(stack[-1])
24
+ offsets.append(None)
25
+ channels.append([])
26
+ elif tok[0] == "{":
27
+ stack.append(len(names) - 1)
28
+ elif tok[0] == "}":
29
+ stack.pop()
30
+ elif tok[0] == "OFFSET":
31
+ offsets[stack[-1]] = [float(x) for x in tok[1:4]]
32
+ elif tok[0] == "CHANNELS":
33
+ channels[stack[-1]] = tok[2:]
34
+ elif tok[0] == "MOTION":
35
+ break
36
+ next(lines) # Frames:
37
+ next(lines) # Frame Time:
38
+ motion = np.array([[float(x) for x in ln.split()] for ln in lines if ln.strip()])
39
+ keep = [i for i, n in enumerate(names) if n is not None]
40
+ remap = {old: new for new, old in enumerate(keep)}
41
+ return ([names[i] for i in keep], [remap.get(parents[i], -1) for i in keep],
42
+ np.array([offsets[i] for i in keep]), [channels[i] for i in keep], motion)
43
+
44
+
45
+ def _axis_rot(axis, deg):
46
+ a = np.radians(deg)
47
+ c, s = np.cos(a), np.sin(a)
48
+ o, z = np.ones_like(a), np.zeros_like(a)
49
+ if axis == "X":
50
+ m = [[o, z, z], [z, c, -s], [z, s, c]]
51
+ elif axis == "Y":
52
+ m = [[c, z, s], [z, o, z], [-s, z, c]]
53
+ else:
54
+ m = [[c, -s, z], [s, c, z], [z, z, o]]
55
+ return np.moveaxis(np.array(m), [0, 1], [-2, -1])
56
+
57
+
58
+ def bvh_rotations(path):
59
+ """Global joint orientations {name: (T, 3, 3)}, Z-up (same frame as bvh_positions)"""
60
+ names, parents, offsets, channels, motion = load_bvh(path)
61
+ T, col = len(motion), 0
62
+ R = [None] * len(names)
63
+ for j, ch in enumerate(channels):
64
+ Rl = np.broadcast_to(np.eye(3), (T, 3, 3))
65
+ for c in ch:
66
+ if not c.endswith("position"):
67
+ Rl = Rl @ _axis_rot(c[0], motion[:, col])
68
+ col += 1
69
+ R[j] = Rl if parents[j] < 0 else R[parents[j]] @ Rl
70
+ C = np.array([[1.0, 0, 0], [0, 0, -1], [0, 1, 0]]) # Y-up -> Z-up
71
+ return {n: C @ R[i] for i, n in enumerate(names)}
72
+
73
+
74
+ def bvh_positions(path, unit: float = 0.01):
75
+ """Global joint positions {name: (T, 3)}, Z-up, in meters (unit: BVH units -> m)"""
76
+ names, parents, offsets, channels, motion = load_bvh(path)
77
+ T, col = len(motion), 0
78
+ R = [None] * len(names)
79
+ P = [None] * len(names)
80
+ for j, ch in enumerate(channels):
81
+ Rl = np.broadcast_to(np.eye(3), (T, 3, 3))
82
+ pos = np.broadcast_to(offsets[j], (T, 3))
83
+ for c in ch:
84
+ if c.endswith("position"):
85
+ pos = pos.copy() if pos.base is not None else pos
86
+ pos = np.array(pos)
87
+ pos[:, "XYZ".index(c[0])] = motion[:, col]
88
+ else:
89
+ Rl = Rl @ _axis_rot(c[0], motion[:, col])
90
+ col += 1
91
+ p = parents[j]
92
+ if p < 0:
93
+ R[j], P[j] = Rl, pos
94
+ else:
95
+ R[j] = R[p] @ Rl
96
+ P[j] = P[p] + np.einsum("tij,j->ti", R[p], offsets[j])
97
+ pos = np.stack(P, 1) * unit
98
+ C = np.array([[1.0, 0, 0], [0, 0, -1], [0, 1, 0]]) # Y-up -> Z-up
99
+ return {n: pos[:, i] @ C.T for i, n in enumerate(names)}
@@ -0,0 +1,235 @@
1
+ """Automatic sphere collision models for any MuJoCo model.
2
+
3
+ Each body's collision geometry (meshes, boxes, capsules, cylinders, spheres, ellipsoids)
4
+ is sampled into points. The points are sliced along their principal axis, and each
5
+ slice gets a sphere at its centroid covering most of its cross-section. Pairs are
6
+ generated automatically: different bodies, not parent/child, optionally filtered.
7
+ Pairs that already overlap in the reference pose get a relaxed minimum distance, so
8
+ the robot never fights its own default posture.
9
+ """
10
+
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+
14
+ import jax.numpy as jnp
15
+ import numpy as np
16
+ from jax import Array
17
+
18
+ from rigidax.kinematics import Kinematics, forward_kinematics
19
+ from rigidax.model import Model, derived
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SphereModel:
24
+ body: np.ndarray # (S,) body index of each sphere
25
+ center: np.ndarray # (S, 3) center in the body frame
26
+ radius: np.ndarray # (S,)
27
+ pairs: np.ndarray # (P, 2) sphere indices
28
+ min_dist: np.ndarray # (P,) required clearance between sphere surfaces
29
+ # Row structure for the solver: each pair is owned by one "row" sphere, and each
30
+ # row constrains its closest partner. Fixed shapes, no sorting, stable meanings.
31
+ rows: np.ndarray = None # (R,) sphere indices that own pairs
32
+ partner: np.ndarray = None # (R, S) bool: partner spheres of each row
33
+ row_min_dist: np.ndarray = None # (R, S) clearance per (row, partner)
34
+
35
+ @property
36
+ def num_spheres(self) -> int:
37
+ return len(self.radius)
38
+
39
+ def centers(self, kin: Kinematics) -> Array:
40
+ b = self.body
41
+ return kin.body_p[b] + jnp.einsum("sij,sj->si", kin.body_R[b], self.center)
42
+
43
+ def gaps(self, kin: Kinematics) -> Array:
44
+ """Surface distance minus required clearance per pair (< 0: violated)"""
45
+ C = self.centers(kin)
46
+ i, j = self.pairs[:, 0], self.pairs[:, 1]
47
+ return (jnp.linalg.norm(C[i] - C[j], axis=1) - self.radius[i] - self.radius[j]
48
+ - self.min_dist)
49
+
50
+ @staticmethod
51
+ def fit(m, model: Model, bodies: Callable[[str], bool] | None = None,
52
+ pair_filter: Callable[[str, str], bool] | None = None,
53
+ spacing: float = 0.05, cover: float = 95.0, margin: float = 0.01,
54
+ geom_group: int | None = 3, q_ref=None, extra=None) -> "SphereModel":
55
+ """Fit spheres to a compiled mujoco.MjModel `m` (the same model as `model`).
56
+
57
+ bodies: which bodies to include (by name); default: all with geometry.
58
+ pair_filter: which body pairs to check (by names); default: all eligible.
59
+ geom_group: geom group holding collision geometry (None: all geoms).
60
+ q_ref: reference pose for relaxing pre-existing overlaps (default qpos0).
61
+ extra: optional [(body name, centers (n, 3) in that body's frame, radii (n,))] of
62
+ spheres to attach rigidly, e.g. the envelope of parts not in the model
63
+ (fingers whose joints are not solved).
64
+ """
65
+ import mujoco
66
+
67
+ names = [mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY, b) or "" for b in range(m.nbody)]
68
+ sb, sc, sr = [], [], []
69
+ for b in range(1, m.nbody):
70
+ if bodies is not None and not bodies(names[b]):
71
+ continue
72
+ V = _body_points(m, b, geom_group)
73
+ if len(V) < 4:
74
+ continue
75
+ c, r = _fit_spheres(V, spacing, cover)
76
+ sb += [b] * len(r)
77
+ sc.append(c)
78
+ sr.append(r)
79
+ for name, c, r in (extra or ()):
80
+ b = m.body(name).id
81
+ if bodies is not None and not bodies(names[b]):
82
+ continue
83
+ sb += [b] * len(r)
84
+ sc.append(np.asarray(c, float).reshape(-1, 3))
85
+ sr.append(np.asarray(r, float).reshape(-1))
86
+ sb, sc, sr = np.array(sb, int), np.concatenate(sc), np.concatenate(sr)
87
+ parent = m.body_parentid
88
+ pairs = [(i, j) for i in range(len(sb)) for j in range(i + 1, len(sb))
89
+ if sb[i] != sb[j] and parent[sb[i]] != sb[j] and parent[sb[j]] != sb[i]
90
+ and (pair_filter is None or pair_filter(names[sb[i]], names[sb[j]]))]
91
+ pairs = np.array(pairs, int).reshape(-1, 2)
92
+ model_ = SphereModel(sb, sc, sr, pairs, np.zeros(len(pairs)))
93
+ kin = forward_kinematics(model, m.qpos0 if q_ref is None else q_ref)
94
+ rest = np.asarray(model_.gaps(kin))
95
+ min_dist = np.minimum(margin, rest - 0.005)
96
+ return SphereModel.with_rows(sb, sc, sr, pairs, min_dist)
97
+
98
+ @staticmethod
99
+ def with_rows(body, center, radius, pairs, min_dist) -> "SphereModel":
100
+ """Assign each pair to the endpoint with more partners (fewest rows overall)"""
101
+ S = len(radius)
102
+ degree = np.bincount(pairs.ravel(), minlength=S)
103
+ owner = np.where(degree[pairs[:, 0]] >= degree[pairs[:, 1]], pairs[:, 0], pairs[:, 1])
104
+ other = np.where(owner == pairs[:, 0], pairs[:, 1], pairs[:, 0])
105
+ rows = np.unique(owner)
106
+ r_of = {r: k for k, r in enumerate(rows)}
107
+ partner = np.zeros((len(rows), S), bool)
108
+ row_min = np.zeros((len(rows), S))
109
+ for o, t, md in zip(owner, other, min_dist):
110
+ partner[r_of[o], t] = True
111
+ row_min[r_of[o], t] = md
112
+ return SphereModel(body, center, radius, pairs, min_dist, rows, partner, row_min)
113
+
114
+ def num_rows(self, k: int = 1) -> int:
115
+ return len(self.rows) * min(k, self.partner.shape[1])
116
+
117
+ def constraint_rows(self, model: Model, kin: Kinematics, k: int = 1):
118
+ """Linearized constraints for each row sphere against its k closest partners:
119
+ rows a (R*k, nv) and bounds b (R*k,) such that a @ dq >= b (b = -gap)."""
120
+ import jax
121
+
122
+ C = self.centers(kin)
123
+ Cr = C[self.rows]
124
+ D = (jnp.linalg.norm(Cr[:, None] - C[None], axis=-1)
125
+ - self.radius[self.rows][:, None] - self.radius[None] - self.row_min_dist)
126
+ D = jnp.where(self.partner, D, jnp.inf)
127
+ k = min(k, D.shape[1])
128
+ if k == 1:
129
+ j = jnp.argmin(D, axis=1)[:, None]
130
+ else:
131
+ j = jax.lax.top_k(-D, k)[1] # (R, k) closest partners
132
+ gap = jnp.take_along_axis(D, j, axis=1) # (R, k)
133
+ gap = jnp.where(jnp.isfinite(gap), gap, 1.0) # rows with < k partners: inactive
134
+ Cj = C[j] # (R, k, 3)
135
+ delta = Cr[:, None] - Cj
136
+ n = delta / jnp.maximum(jnp.linalg.norm(delta, axis=-1, keepdims=True), 1e-9)
137
+ # a = n^T (J(c_i) - J(c_j)), with J(c) = (u + w x c) masked by the body's DOFs.
138
+ # Per DOF: n.(u + w x c) = u.n + w.(c x n): two small mat-vecs, no (S, 3, nv)
139
+ # Jacobian tensor.
140
+ w, u = kin.S[:, :3], kin.S[:, 3:]
141
+ mask = jnp.asarray(derived(model.topo).body_mask) # (nbody, nv)
142
+ un = jnp.einsum("vk,rqk->rqv", u, n)
143
+ ti = jnp.einsum("vk,rqk->rqv", w, jnp.cross(Cr[:, None], n))
144
+ tj = jnp.einsum("vk,rqk->rqv", w, jnp.cross(Cj, n))
145
+ mi = mask[self.body[self.rows]][:, None] # (R, 1, nv)
146
+ mj = mask[jnp.asarray(self.body)[j]] # (R, k, nv)
147
+ a = mi * (un + ti) - mj * (un + tj)
148
+ return a.reshape(-1, a.shape[-1]), -gap.reshape(-1)
149
+
150
+
151
+ def _quat_mat(q):
152
+ import mujoco
153
+
154
+ R = np.zeros(9)
155
+ mujoco.mju_quat2Mat(R, q)
156
+ return R.reshape(3, 3)
157
+
158
+
159
+ def _primitive_points(gtype, size, n=400):
160
+ import mujoco
161
+
162
+ rng = np.random.default_rng(0)
163
+ G = mujoco.mjtGeom
164
+ u = rng.normal(size=(n, 3))
165
+ u /= np.linalg.norm(u, axis=1, keepdims=True)
166
+ if gtype == G.mjGEOM_SPHERE:
167
+ return u * size[0]
168
+ if gtype == G.mjGEOM_ELLIPSOID:
169
+ return u * size[:3]
170
+ if gtype == G.mjGEOM_BOX:
171
+ return rng.uniform(-1, 1, (n, 3)) * size[:3]
172
+ if gtype in (G.mjGEOM_CAPSULE, G.mjGEOM_CYLINDER):
173
+ z = rng.uniform(-size[1], size[1], n)
174
+ ring = u[:, :2] / np.linalg.norm(u[:, :2], axis=1, keepdims=True) * size[0]
175
+ pts = np.c_[ring, z]
176
+ if gtype == G.mjGEOM_CAPSULE:
177
+ pts = np.r_[pts, u * size[0] + [0, 0, size[1]], u * size[0] - [0, 0, size[1]]]
178
+ return pts
179
+ return np.zeros((0, 3))
180
+
181
+
182
+ def _body_points(m, b, group):
183
+ import mujoco
184
+
185
+ out = []
186
+ for g in np.where(m.geom_bodyid == b)[0]:
187
+ if group is not None and m.geom_group[g] != group:
188
+ continue
189
+ if m.geom_type[g] == mujoco.mjtGeom.mjGEOM_MESH:
190
+ mid = m.geom_dataid[g]
191
+ v = m.mesh_vert[m.mesh_vertadr[mid]: m.mesh_vertadr[mid] + m.mesh_vertnum[mid]]
192
+ else:
193
+ v = _primitive_points(m.geom_type[g], m.geom_size[g])
194
+ if len(v):
195
+ out.append(v @ _quat_mat(m.geom_quat[g]).T + m.geom_pos[g])
196
+ return np.concatenate(out) if out else np.zeros((0, 3))
197
+
198
+
199
+ def _fit_spheres(V, spacing, cover):
200
+ """Slice along the principal axis; split wide (anisotropic) slices along their
201
+ second axis so flat bodies like a torso get several tight spheres, not one fat one"""
202
+ mu = V.mean(0)
203
+ _, _, Vt = np.linalg.svd(V - mu, full_matrices=False)
204
+ axis = Vt[0]
205
+ t = (V - mu) @ axis
206
+ k = max(1, int(np.ceil((t.max() - t.min()) / spacing)))
207
+ edges = np.linspace(t.min(), t.max(), k + 1)
208
+ centers, radii = [], []
209
+ for a, b in zip(edges[:-1], edges[1:]):
210
+ sel = (t >= a) & (t <= b)
211
+ if sel.sum() < 4:
212
+ continue
213
+ P = V[sel]
214
+ c0 = P.mean(0)
215
+ flat = P - c0
216
+ flat -= np.outer(flat @ axis, axis) # cross-section points
217
+ _, sv, Wt = np.linalg.svd(flat, full_matrices=False)
218
+ w_axis = Wt[0]
219
+ s_w = flat @ w_axis
220
+ ext_w = s_w.max() - s_w.min()
221
+ ext_d = np.ptp(flat @ Wt[1]) if len(Wt) > 1 else ext_w
222
+ # number of sub-spheres across the wide direction
223
+ n_sub = int(np.clip(np.round(ext_w / max(ext_d, 1e-6)), 1, 4))
224
+ sub_edges = np.linspace(s_w.min(), s_w.max(), n_sub + 1)
225
+ for sa, sb in zip(sub_edges[:-1], sub_edges[1:]):
226
+ ss = (s_w >= sa) & (s_w <= sb)
227
+ if ss.sum() < 4:
228
+ continue
229
+ c = P[ss].mean(0)
230
+ radial = P[ss] - c
231
+ radial -= np.outer(radial @ axis, axis)
232
+ r = max(np.percentile(np.linalg.norm(radial, axis=1), cover), 0.5 * (b - a))
233
+ centers.append(c)
234
+ radii.append(r)
235
+ return np.array(centers), np.array(radii)