pyforge3d 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.
Files changed (64) hide show
  1. forge3d/__init__.py +105 -0
  2. forge3d/app.py +219 -0
  3. forge3d/backend.py +118 -0
  4. forge3d/camera.py +235 -0
  5. forge3d/collision/__init__.py +20 -0
  6. forge3d/collision/detection.py +739 -0
  7. forge3d/collision/epa.py +217 -0
  8. forge3d/collision/gjk.py +266 -0
  9. forge3d/collision/heightfield.py +196 -0
  10. forge3d/collision/layers.py +38 -0
  11. forge3d/constraints/__init__.py +35 -0
  12. forge3d/constraints/base.py +132 -0
  13. forge3d/constraints/joints.py +635 -0
  14. forge3d/contact/__init__.py +1 -0
  15. forge3d/contact/solver.py +341 -0
  16. forge3d/dynamics/__init__.py +24 -0
  17. forge3d/dynamics/aba.py +108 -0
  18. forge3d/dynamics/crba.py +73 -0
  19. forge3d/dynamics/model.py +97 -0
  20. forge3d/dynamics/rnea.py +260 -0
  21. forge3d/errors.py +98 -0
  22. forge3d/events.py +267 -0
  23. forge3d/facade.py +1032 -0
  24. forge3d/input.py +243 -0
  25. forge3d/io/__init__.py +10 -0
  26. forge3d/io/mesh_data.py +206 -0
  27. forge3d/io/obj_loader.py +203 -0
  28. forge3d/io/world_snapshot.py +284 -0
  29. forge3d/logging.py +35 -0
  30. forge3d/math/__init__.py +59 -0
  31. forge3d/math/inertia.py +60 -0
  32. forge3d/math/quaternion.py +141 -0
  33. forge3d/math/se3.py +162 -0
  34. forge3d/math/spatial.py +139 -0
  35. forge3d/model/__init__.py +14 -0
  36. forge3d/model/kinematics.py +134 -0
  37. forge3d/model/robot_config.py +138 -0
  38. forge3d/model/urdf_loader.py +194 -0
  39. forge3d/py.typed +0 -0
  40. forge3d/recorder.py +204 -0
  41. forge3d/render/__init__.py +1 -0
  42. forge3d/render/base.py +36 -0
  43. forge3d/render/hq/__init__.py +1 -0
  44. forge3d/render/hq/raytracer.py +297 -0
  45. forge3d/render/hq/renderer.py +72 -0
  46. forge3d/render/hq/scene.py +138 -0
  47. forge3d/render/realtime/__init__.py +5 -0
  48. forge3d/render/realtime/context.py +119 -0
  49. forge3d/render/realtime/meshes.py +254 -0
  50. forge3d/render/realtime/renderer.py +565 -0
  51. forge3d/render/realtime/shaders.py +193 -0
  52. forge3d/render/snapshot.py +91 -0
  53. forge3d/robot/__init__.py +38 -0
  54. forge3d/robot/presets.py +82 -0
  55. forge3d/robot/robot.py +162 -0
  56. forge3d/sim/__init__.py +1 -0
  57. forge3d/sim/domain_rand.py +113 -0
  58. forge3d/sim/jax_batch.py +191 -0
  59. forge3d/sim/world.py +626 -0
  60. forge3d/viewer.py +235 -0
  61. pyforge3d-1.0.0.dist-info/METADATA +566 -0
  62. pyforge3d-1.0.0.dist-info/RECORD +64 -0
  63. pyforge3d-1.0.0.dist-info/WHEEL +4 -0
  64. pyforge3d-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,341 @@
1
+ """Iterative impulse-based contact solver (PGS) with angular dynamics.
2
+
3
+ Algorithm — per step:
4
+ PRE-COMPUTE (before PGS loop):
5
+ v_n_pre = normal relative velocity at contact (at start of step)
6
+ restitution_target = max(0, -e * v_n_pre) if approaching, else 0
7
+
8
+ PGS loop (N_ITER iterations):
9
+ For each contact c:
10
+ 1. Compute current v_n at contact (includes ω × r lever arm).
11
+ 2. Normal impulse drives v_n → restitution_target; clamp λ_n ≥ 0.
12
+ 3. Friction impulse (2 tangential dirs); clamp |λ_t| ≤ μ * λ_n.
13
+ 4. Apply impulse changes immediately (sequential / Gauss-Seidel).
14
+
15
+ POST-PGS:
16
+ Baumgarte position correction (separate from velocity constraint).
17
+ This avoids the interaction between position bias and restitution.
18
+
19
+ Effective mass along direction d with lever arms r_a, r_b:
20
+ K = 1/m_a + 1/m_b + (r_a×d)·I_a⁻¹·(r_a×d) + (r_b×d)·I_b⁻¹·(r_b×d)
21
+ Impulse J to change v_n by Δv: J = Δv / K
22
+
23
+ Bodies with inertia_local=None → point mass (zero rotational contribution).
24
+ Static bodies → infinite mass and inertia (no velocity update).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import replace
30
+ from typing import Any
31
+
32
+ import numpy as np
33
+
34
+ from forge3d.collision.detection import ContactPoint, _cross3, _quat_to_rot_unit
35
+
36
+ # Reusable zero vector — avoids np.zeros(3) allocation in hot loops
37
+ _ZERO3 = np.zeros(3)
38
+ _ZERO33 = np.zeros((3, 3))
39
+
40
+ # ── Tuning parameters ─────────────────────────────────────────────────────────
41
+
42
+ N_ITER = 6 # PGS iterations per step (6 sufficient for stable resting contact)
43
+ RESTITUTION_THRESHOLD = 0.5 # m/s: below this, treat e=0 (Zeno prevention)
44
+ BAUMGARTE_BETA = 0.3 # position-correction fraction per step (post-PGS)
45
+ PENETRATION_SLOP = 0.001 # m: allowed overlap before correction kicks in
46
+ VELOCITY_EPS = 1e-9 # m/s: tangential velocity threshold
47
+
48
+
49
+ # ── Public interface ──────────────────────────────────────────────────────────
50
+
51
+
52
+ def solve_contacts(
53
+ bodies: list[Any],
54
+ contacts: list[ContactPoint],
55
+ spring_k: float = 0.0,
56
+ dt: float = 1.0 / 60.0,
57
+ ) -> list[Any]:
58
+ """Impulse-based contact solver: sequential PGS with pre-computed batch data.
59
+
60
+ Uses Gauss-Seidel ordering (contacts processed sequentially so friction
61
+ converges correctly for coupled constraints like pinch grasps), but all
62
+ per-contact constants are pre-computed as NumPy arrays to avoid repeat
63
+ work. Inner loop variables are Python scalars / list lookups — the
64
+ bottleneck is Python loop iterations × contacts, bounded by N_ITER × C.
65
+ """
66
+ if not contacts:
67
+ return list(bodies)
68
+
69
+ vels: list[np.ndarray] = [b.vel.copy() if not b.static else _ZERO3.copy() for b in bodies]
70
+ omegas: list[np.ndarray] = [b.omega.copy() if not b.static else _ZERO3.copy() for b in bodies]
71
+ poss: list[np.ndarray] = [b.pos.copy() for b in bodies]
72
+
73
+ I_inv: list[np.ndarray] = [_inv_inertia_world(b) for b in bodies]
74
+
75
+ nc = len(contacts)
76
+ lambda_n = np.zeros(nc)
77
+ lambda_t1 = np.zeros(nc)
78
+ lambda_t2 = np.zeros(nc)
79
+
80
+ tangents: list[tuple[np.ndarray, np.ndarray]] = [_tangent_pair(c.normal) for c in contacts]
81
+
82
+ # Restitution targets (pre-step velocities)
83
+ v_n_target = np.zeros(nc)
84
+ for ci, c in enumerate(contacts):
85
+ v_n_pre = _contact_v_n(c, bodies, vels, omegas)
86
+ if v_n_pre < -RESTITUTION_THRESHOLD:
87
+ ia, ib = c.body_a_idx, c.body_b_idx
88
+ b_static = ib < 0 or bodies[ib].static
89
+ e = bodies[ia].restitution if b_static else 0.5 * (bodies[ia].restitution + bodies[ib].restitution)
90
+ v_n_target[ci] = -e * v_n_pre
91
+
92
+ # Pre-computed per-contact constants
93
+ c_ia = [c.body_a_idx for c in contacts]
94
+ c_ib = [c.body_b_idx for c in contacts]
95
+ c_static = [ib < 0 or bodies[ib].static for ib in c_ib]
96
+ c_inv_ma = [0.0 if bodies[ia].static else 1.0 / bodies[ia].mass for ia in c_ia]
97
+ c_inv_mb = [0.0 if (bs or ib < 0) else 1.0 / bodies[ib].mass for ib, bs in zip(c_ib, c_static)]
98
+ c_I_a = [I_inv[ia] for ia in c_ia]
99
+ c_I_b = [I_inv[ib] if not bs and ib >= 0 else _ZERO33 for ib, bs in zip(c_ib, c_static)]
100
+ c_r_a = [c.pos - bodies[ia].pos for c, ia in zip(contacts, c_ia)]
101
+ c_r_b = [c.pos - bodies[ib].pos if not bs and ib >= 0 else _ZERO3 for c, ib, bs in zip(contacts, c_ib, c_static)]
102
+ c_K_n = [_eff_K(ra, rb, c.normal, ima, imb, Ia, Ib)
103
+ for c, ra, rb, ima, imb, Ia, Ib in zip(contacts, c_r_a, c_r_b, c_inv_ma, c_inv_mb, c_I_a, c_I_b)]
104
+ c_K_t = [(_eff_K(ra, rb, t1, ima, imb, Ia, Ib), _eff_K(ra, rb, t2, ima, imb, Ia, Ib))
105
+ for (t1, t2), ra, rb, ima, imb, Ia, Ib in zip(tangents, c_r_a, c_r_b, c_inv_ma, c_inv_mb, c_I_a, c_I_b)]
106
+ c_mu = [bodies[ia].friction if bs else 0.5 * (bodies[ia].friction + bodies[ib].friction)
107
+ for ia, ib, bs in zip(c_ia, c_ib, c_static)]
108
+
109
+ spring_pairs: set[tuple[int, int]] = set()
110
+
111
+ for _iteration in range(N_ITER):
112
+ for ci, c in enumerate(contacts):
113
+ ia = c_ia[ci]; ib = c_ib[ci]; b_static = c_static[ci]
114
+ inv_ma = c_inv_ma[ci]; inv_mb = c_inv_mb[ci]
115
+ I_a = c_I_a[ci]; I_b = c_I_b[ci]
116
+ r_a = c_r_a[ci]; r_b = c_r_b[ci]
117
+ K_n = c_K_n[ci]
118
+ if K_n < 1e-12:
119
+ continue
120
+
121
+ v_n = _contact_v_n_fast(c.pos, c.normal, ia, ib, b_static, r_a, r_b, vels, omegas)
122
+ delta_n = (v_n_target[ci] - v_n) / K_n
123
+
124
+ if spring_k > 0.0:
125
+ pair = (ia, ib)
126
+ if pair not in spring_pairs:
127
+ spring_pairs.add(pair)
128
+ delta_n = max(delta_n, spring_k * c.depth * dt)
129
+
130
+ lambda_n_new = max(0.0, lambda_n[ci] + delta_n)
131
+ actual_n = lambda_n_new - lambda_n[ci]
132
+ lambda_n[ci] = lambda_n_new
133
+ if abs(actual_n) > 1e-14:
134
+ _apply_impulse(ia, ib, actual_n * c.normal, r_a, r_b, inv_ma, inv_mb, I_a, I_b, vels, omegas)
135
+
136
+ mu = c_mu[ci]
137
+ if mu < 1e-12 or lambda_n[ci] < 1e-12:
138
+ continue
139
+
140
+ t1, t2 = tangents[ci]
141
+ K_t1, K_t2 = c_K_t[ci]
142
+ for t_vec, K_t, lt, idx in [(t1, K_t1, lambda_t1, ci), (t2, K_t2, lambda_t2, ci)]:
143
+ if K_t < 1e-12:
144
+ continue
145
+ v_a_c = vels[ia] + _cross3(omegas[ia], r_a)
146
+ v_b_c = (vels[ib] + _cross3(omegas[ib], r_b) if not b_static and ib >= 0 else _ZERO3)
147
+ v_t = float(np.dot(v_a_c - v_b_c, t_vec))
148
+ lt_max = mu * lambda_n[ci]
149
+ raw = lt[idx] + (-v_t / K_t)
150
+ lt_new = raw if -lt_max <= raw <= lt_max else max(-lt_max, min(lt_max, raw))
151
+ actual_t = lt_new - lt[idx]
152
+ lt[idx] = lt_new
153
+ if abs(actual_t) > 1e-14:
154
+ _apply_impulse(ia, ib, actual_t * t_vec, r_a, r_b, inv_ma, inv_mb, I_a, I_b, vels, omegas)
155
+
156
+ # Baumgarte position correction
157
+ for c in contacts:
158
+ ia = c.body_a_idx; ib = c.body_b_idx
159
+ b_static = ib < 0 or bodies[ib].static
160
+ excess = max(0.0, c.depth - PENETRATION_SLOP)
161
+ if excess < 1e-6:
162
+ continue
163
+ inv_ma = 0.0 if bodies[ia].static else 1.0 / bodies[ia].mass
164
+ inv_mb = 0.0 if b_static or ib < 0 else 1.0 / bodies[ib].mass
165
+ total_inv = inv_ma + inv_mb
166
+ if total_inv < 1e-12:
167
+ continue
168
+ corr = BAUMGARTE_BETA * excess
169
+ poss[ia] += corr * (inv_ma / total_inv) * c.normal
170
+ if not b_static and ib >= 0:
171
+ poss[ib] -= corr * (inv_mb / total_inv) * c.normal
172
+
173
+ result: list[Any] = list(bodies)
174
+ for i, b in enumerate(bodies):
175
+ if not b.static:
176
+ result[i] = replace(b, vel=vels[i], omega=omegas[i], pos=poss[i])
177
+ return result
178
+
179
+
180
+ # ── Batch (vectorized) helpers ────────────────────────────────────────────────
181
+
182
+
183
+ def _bcross(a: np.ndarray, b: np.ndarray) -> np.ndarray:
184
+ """Vectorized cross product for (C, 3) arrays. Avoids np.cross overhead."""
185
+ return np.stack([
186
+ a[:, 1]*b[:, 2] - a[:, 2]*b[:, 1],
187
+ a[:, 2]*b[:, 0] - a[:, 0]*b[:, 2],
188
+ a[:, 0]*b[:, 1] - a[:, 1]*b[:, 0],
189
+ ], axis=1)
190
+
191
+
192
+ def _batch_tangent_pairs(
193
+ normals: np.ndarray,
194
+ ) -> tuple[np.ndarray, np.ndarray]:
195
+ """Stable tangent frames for all contacts at once. Returns (t1, t2) each (C, 3)."""
196
+ C = len(normals)
197
+ # Choose reference vector not parallel to n
198
+ ref = np.where(np.abs(normals[:, 0:1]) < 0.9,
199
+ np.broadcast_to([1., 0., 0.], (C, 3)),
200
+ np.broadcast_to([0., 1., 0.], (C, 3))).copy()
201
+ t1 = _bcross(normals, ref)
202
+ t1_len = np.linalg.norm(t1, axis=1, keepdims=True)
203
+ t1 /= np.where(t1_len > 1e-10, t1_len, 1.0)
204
+ t2 = _bcross(normals, t1)
205
+ t2 /= np.linalg.norm(t2, axis=1, keepdims=True).clip(1e-10)
206
+ return t1, t2
207
+
208
+
209
+ def _batch_apply(
210
+ ia: np.ndarray,
211
+ ib: np.ndarray,
212
+ is_sb: np.ndarray,
213
+ inv_ma: np.ndarray,
214
+ inv_mb: np.ndarray,
215
+ I_a: np.ndarray,
216
+ I_b: np.ndarray,
217
+ r_a: np.ndarray,
218
+ r_b: np.ndarray,
219
+ J: np.ndarray,
220
+ vels: np.ndarray,
221
+ omegas: np.ndarray,
222
+ ) -> None:
223
+ """Apply impulse J (C, 3) to all contact body pairs via scatter-add."""
224
+ # Body a
225
+ np.add.at(vels, ia, inv_ma[:, None] * J)
226
+ np.add.at(omegas, ia, np.einsum("cij,cj->ci", I_a, _bcross(r_a, J)))
227
+ # Body b — dynamic only
228
+ dyn_b = ~is_sb
229
+ if dyn_b.any():
230
+ J_b = -J[dyn_b]
231
+ np.add.at(vels, ib[dyn_b], inv_mb[dyn_b, None] * J_b)
232
+ np.add.at(omegas, ib[dyn_b], np.einsum("cij,cj->ci", I_b[dyn_b], _bcross(r_b[dyn_b], J_b)))
233
+
234
+
235
+ # ── Internal helpers ──────────────────────────────────────────────────────────
236
+
237
+
238
+ def _inv_inertia_world(body: Any) -> np.ndarray:
239
+ """3×3 inverse inertia in world frame. Zeros for static / point-mass."""
240
+ if body.static or body.inertia_local is None:
241
+ return np.zeros((3, 3))
242
+ R = _quat_to_rot_unit(body.quat)
243
+ # Use pre-computed inverse (inertia_local is constant — never changes).
244
+ # Fall back to np.linalg.inv for bodies created without the cache.
245
+ I_inv = (
246
+ body.inertia_inv_local
247
+ if getattr(body, "inertia_inv_local", None) is not None
248
+ else np.linalg.inv(body.inertia_local)
249
+ )
250
+ return R @ I_inv @ R.T
251
+
252
+
253
+ def _eff_K(
254
+ r_a: np.ndarray,
255
+ r_b: np.ndarray,
256
+ d: np.ndarray,
257
+ inv_ma: float,
258
+ inv_mb: float,
259
+ I_a: np.ndarray,
260
+ I_b: np.ndarray,
261
+ ) -> float:
262
+ """Scalar constraint coefficient K = 1/m_eff along direction d."""
263
+ rxa = _cross3(r_a, d)
264
+ rxb = _cross3(r_b, d)
265
+ K = inv_ma + inv_mb + float(rxa @ I_a @ rxa) + float(rxb @ I_b @ rxb)
266
+ return K if K > 1e-14 else 0.0
267
+
268
+
269
+ def _apply_impulse(
270
+ ia: int,
271
+ ib: int,
272
+ J: np.ndarray,
273
+ r_a: np.ndarray,
274
+ r_b: np.ndarray,
275
+ inv_ma: float,
276
+ inv_mb: float,
277
+ I_a: np.ndarray,
278
+ I_b: np.ndarray,
279
+ vels: list[np.ndarray],
280
+ omegas: list[np.ndarray],
281
+ ) -> None:
282
+ """Apply impulse vector J (in-place). Reaction -J to body_b if dynamic."""
283
+ vels[ia] += inv_ma * J
284
+ omegas[ia] += I_a @ _cross3(r_a, J)
285
+ if ib >= 0 and inv_mb > 0.0:
286
+ vels[ib] -= inv_mb * J
287
+ omegas[ib] -= I_b @ _cross3(r_b, J)
288
+
289
+
290
+ def _contact_v_n(
291
+ c: ContactPoint,
292
+ bodies: list[Any],
293
+ vels: list[np.ndarray],
294
+ omegas: list[np.ndarray],
295
+ ) -> float:
296
+ """Normal relative velocity at contact point (+ve = separating)."""
297
+ ia = c.body_a_idx
298
+ ib = c.body_b_idx
299
+ b_static = ib < 0 or bodies[ib].static
300
+ r_a = c.pos - bodies[ia].pos
301
+ v_a = vels[ia] + _cross3(omegas[ia], r_a)
302
+ if b_static:
303
+ v_rel = v_a
304
+ else:
305
+ r_b = c.pos - bodies[ib].pos
306
+ v_rel = v_a - (vels[ib] + _cross3(omegas[ib], r_b))
307
+ return float(np.dot(v_rel, c.normal))
308
+
309
+
310
+ def _contact_v_n_fast(
311
+ pos: np.ndarray,
312
+ normal: np.ndarray,
313
+ ia: int,
314
+ ib: int,
315
+ b_static: bool,
316
+ r_a: np.ndarray,
317
+ r_b: np.ndarray,
318
+ vels: list[np.ndarray],
319
+ omegas: list[np.ndarray],
320
+ ) -> float:
321
+ """Normal relative velocity using pre-computed r_a / r_b (no body lookup)."""
322
+ v_a = vels[ia] + _cross3(omegas[ia], r_a)
323
+ if b_static:
324
+ v_rel = v_a
325
+ else:
326
+ v_rel = v_a - (vels[ib] + _cross3(omegas[ib], r_b))
327
+ return float(np.dot(v_rel, normal))
328
+
329
+
330
+ def _tangent_pair(normal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
331
+ """Two orthonormal vectors in the contact plane."""
332
+ n = np.asarray(normal, dtype=float)
333
+ if abs(n[0]) < 0.9:
334
+ v = np.array([1.0, 0.0, 0.0])
335
+ else:
336
+ v = np.array([0.0, 1.0, 0.0])
337
+ t1 = _cross3(n, v)
338
+ t1 /= np.linalg.norm(t1) + 1e-300
339
+ t2 = _cross3(n, t1)
340
+ t2 /= np.linalg.norm(t2) + 1e-300
341
+ return t1, t2
@@ -0,0 +1,24 @@
1
+ """forge3d.dynamics — RNEA, forward dynamics, integrators."""
2
+
3
+ from forge3d.dynamics.model import RigidBodyModel, make_2dof_arm
4
+ from forge3d.dynamics.rnea import (
5
+ compute_mass_matrix,
6
+ forward_dynamics,
7
+ inverse_dynamics,
8
+ kinetic_energy,
9
+ potential_energy,
10
+ semi_implicit_euler,
11
+ total_energy,
12
+ )
13
+
14
+ __all__ = [
15
+ "RigidBodyModel",
16
+ "make_2dof_arm",
17
+ "compute_mass_matrix",
18
+ "forward_dynamics",
19
+ "inverse_dynamics",
20
+ "kinetic_energy",
21
+ "potential_energy",
22
+ "semi_implicit_euler",
23
+ "total_energy",
24
+ ]
@@ -0,0 +1,108 @@
1
+ """Articulated Body Algorithm (ABA) — O(n) forward dynamics.
2
+
3
+ Reference: Featherstone, Rigid Body Dynamics Algorithms (2008), Algorithm 7.2.
4
+
5
+ Three passes:
6
+ Pass 1 (forward) : compute spatial velocities and bias forces.
7
+ Pass 2 (backward): compute articulated-body inertia (IA) and bias (pA).
8
+ Pass 3 (forward) : compute joint accelerations and link accelerations.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ from forge3d.dynamics.model import RigidBodyModel
18
+ from forge3d.math.spatial import Xrot, crf, crm
19
+
20
+
21
+ def _joint_xform(S_i: Any, q_i: float) -> Any:
22
+ axis = S_i[:3]
23
+ n = float(np.linalg.norm(axis))
24
+ if n < 1e-10:
25
+ return np.eye(6)
26
+ return Xrot(axis / n, q_i)
27
+
28
+
29
+ def _gravity_spatial(gravity: Any) -> Any:
30
+ g = np.asarray(gravity, dtype=float)
31
+ return np.array([0.0, 0.0, 0.0, -g[0], -g[1], -g[2]])
32
+
33
+
34
+ def forward_dynamics_aba(
35
+ model: RigidBodyModel,
36
+ q: Any,
37
+ qd: Any,
38
+ tau: Any,
39
+ gravity: Any | None = None,
40
+ ) -> Any:
41
+ """Articulated Body Algorithm: compute qdd in O(n).
42
+
43
+ Returns
44
+ -------
45
+ qdd : (n,) joint accelerations.
46
+ """
47
+ q = np.asarray(q, dtype=float)
48
+ qd = np.asarray(qd, dtype=float)
49
+ tau = np.asarray(tau, dtype=float)
50
+ n = model.n_links
51
+ grav = np.asarray(gravity if gravity is not None else model.gravity, dtype=float)
52
+ a_base = _gravity_spatial(grav)
53
+
54
+ # Per-link arrays
55
+ Xup = np.empty((n, 6, 6))
56
+ v = np.empty((n, 6))
57
+ c = np.empty((n, 6)) # velocity-product acceleration (Coriolis bias)
58
+ IA = [None] * n # articulated-body inertia (6x6)
59
+ pA = np.empty((n, 6)) # articulated-body bias force
60
+
61
+ # ── Pass 1: forward — velocities & bias ─────────────────────────────────
62
+ for i in range(n):
63
+ X_J = _joint_xform(model.S[i], q[i])
64
+ Xup[i] = X_J @ model.X_tree[i]
65
+
66
+ if model.parent[i] == -1:
67
+ v[i] = model.S[i] * qd[i]
68
+ else:
69
+ v[i] = Xup[i] @ v[model.parent[i]] + model.S[i] * qd[i]
70
+
71
+ c[i] = crm(v[i]) @ model.S[i] * qd[i] # = v ×m (S*qd)
72
+ IA[i] = model.I_link[i].copy()
73
+ pA[i] = crf(v[i]) @ model.I_link[i] @ v[i]
74
+
75
+ # ── Pass 2: backward — articulated-body inertia & bias ──────────────────
76
+ # U[i] = IA[i] @ S[i], d[i] = S[i]^T @ U[i] (scalar for 1-DOF joints)
77
+ U = np.empty((n, 6))
78
+ d = np.empty(n)
79
+ u = np.empty(n)
80
+
81
+ for i in range(n - 1, -1, -1):
82
+ U[i] = IA[i] @ model.S[i]
83
+ d[i] = float(model.S[i] @ U[i])
84
+ u[i] = float(tau[i]) - float(model.S[i] @ pA[i])
85
+
86
+ p = model.parent[i]
87
+ if p != -1:
88
+ # Articulated inertia felt at parent: IA - U*U^T/d
89
+ Ia = IA[i] - np.outer(U[i], U[i]) / d[i]
90
+ # Articulated bias felt at parent: pA + Ia*c + U*(u/d)
91
+ pa = pA[i] + Ia @ c[i] + U[i] * (u[i] / d[i])
92
+ IA[p] = IA[p] + Xup[i].T @ Ia @ Xup[i]
93
+ pA[p] = pA[p] + Xup[i].T @ pa
94
+
95
+ # ── Pass 3: forward — accelerations ──────────────────────────────────────
96
+ qdd = np.empty(n)
97
+ a = np.empty((n, 6))
98
+
99
+ for i in range(n):
100
+ if model.parent[i] == -1:
101
+ a[i] = Xup[i] @ a_base + c[i]
102
+ else:
103
+ a[i] = Xup[i] @ a[model.parent[i]] + c[i]
104
+
105
+ qdd[i] = (u[i] - float(U[i] @ a[i])) / d[i]
106
+ a[i] = a[i] + model.S[i] * qdd[i]
107
+
108
+ return qdd
@@ -0,0 +1,73 @@
1
+ """Composite Rigid Body Algorithm (CRBA) — O(n²) mass matrix.
2
+
3
+ Reference: Featherstone, Rigid Body Dynamics Algorithms (2008), Algorithm 6.2.
4
+
5
+ Compared to the column-by-column RNEA approach (n RNEA calls),
6
+ CRBA makes a single backward sweep and is ~6x faster per joint.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from forge3d.dynamics.model import RigidBodyModel
16
+ from forge3d.math.spatial import Xrot
17
+
18
+
19
+ def _joint_xform(S_i: Any, q_i: float) -> Any:
20
+ """Spatial transform for joint i at angle q_i (same helper as in rnea.py)."""
21
+ axis = S_i[:3]
22
+ n = float(np.linalg.norm(axis))
23
+ if n < 1e-10:
24
+ return np.eye(6)
25
+ return Xrot(axis / n, q_i)
26
+
27
+
28
+ def mass_matrix(model: RigidBodyModel, q: Any) -> Any:
29
+ """Composite Rigid Body Algorithm: n×n joint-space mass matrix M(q).
30
+
31
+ Algorithm 6.2 from Featherstone (RBDA):
32
+ 1. Compute Xup[i] for all links.
33
+ 2. Init composite inertia Ic[i] = I_link[i].
34
+ 3. Backward sweep: Ic[parent] += Xup[i]^T @ Ic[i] @ Xup[i].
35
+ 4. For each (i,j) pair on the kinematic path, fill M[i,j].
36
+
37
+ Returns
38
+ -------
39
+ M : (n, n) symmetric positive-definite mass matrix.
40
+ """
41
+ q = np.asarray(q, dtype=float)
42
+ n = model.n_links
43
+
44
+ # ── Step 1: forward kinematics — compute Xup ─────────────────────────────
45
+ Xup = np.empty((n, 6, 6))
46
+ for i in range(n):
47
+ X_J = _joint_xform(model.S[i], q[i])
48
+ Xup[i] = X_J @ model.X_tree[i]
49
+
50
+ # ── Step 2: init composite inertias ───────────────────────────────────────
51
+ Ic = [model.I_link[i].copy() for i in range(n)]
52
+
53
+ # ── Step 3: backward accumulation ─────────────────────────────────────────
54
+ for i in range(n - 1, -1, -1):
55
+ p = model.parent[i]
56
+ if p != -1:
57
+ # Ic expressed in parent frame: Xup^T * Ic * Xup
58
+ Ic[p] = Ic[p] + Xup[i].T @ Ic[i] @ Xup[i]
59
+
60
+ # ── Step 4: fill mass matrix ───────────────────────────────────────────────
61
+ M = np.zeros((n, n))
62
+ for i in range(n):
63
+ fh = Ic[i] @ model.S[i] # 6-vector "generalised force"
64
+ M[i, i] = float(model.S[i] @ fh) # diagonal
65
+
66
+ j = i
67
+ while model.parent[j] != -1:
68
+ fh = Xup[j].T @ fh # propagate up the tree
69
+ j = model.parent[j]
70
+ M[i, j] = float(model.S[j] @ fh)
71
+ M[j, i] = M[i, j] # symmetry
72
+
73
+ return M
@@ -0,0 +1,97 @@
1
+ """Rigid body tree model for dynamics computations.
2
+
3
+ A model is a kinematic tree of links connected by 1-DOF joints (revolute or
4
+ prismatic). All quantities are expressed in link-local coordinates.
5
+
6
+ Conventions:
7
+ - Link indices are 0-based. The base (world) is implicitly index -1.
8
+ - parent[i]: index of parent link (-1 = base/world).
9
+ - X_tree[i]: 6x6 spatial transform from parent link's frame to link i's
10
+ joint frame (at zero configuration). Shape: (n, 6, 6).
11
+ - S[i]: 6-vector joint subspace (motion subspace). Shape: (n, 6).
12
+ Revolute about axis a: S = [a; 0].
13
+ Prismatic along axis a: S = [0; a].
14
+ - I[i]: 6x6 spatial inertia of link i in link i's frame. Shape: (n, 6, 6).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+
24
+ from forge3d.math.spatial import Xpose, spatial_inertia
25
+
26
+
27
+ @dataclass
28
+ class RigidBodyModel:
29
+ """Minimal tree model for RNEA / CRBA / ABA."""
30
+
31
+ n_links: int
32
+ parent: list[int] # length n_links; -1 = base
33
+ X_tree: Any # (n_links, 6, 6) float64
34
+ S: Any # (n_links, 6) float64 joint subspace
35
+ I_link: Any # (n_links, 6, 6) float64 spatial inertia
36
+ gravity: Any # (3,) gravity vector in world frame e.g. [0,0,-9.81]
37
+
38
+
39
+ def make_2dof_arm(
40
+ *,
41
+ L1: float = 1.0,
42
+ L2: float = 0.8,
43
+ m1: float = 1.0,
44
+ m2: float = 0.8,
45
+ Izz1: float = 1.0 / 12.0, # m*L^2/12 for uniform rod (normalised by m*L^2)
46
+ Izz2: float = 1.0 / 12.0,
47
+ gravity: Any = None,
48
+ ) -> RigidBodyModel:
49
+ """2-DOF revolute arm in the x-y plane, joints revolve about z-axis.
50
+
51
+ Link 1: joint at world origin, CoM at L1/2 along x-axis of link frame.
52
+ Link 2: joint at end of link 1 (L1 along x-axis), CoM at L2/2 along x.
53
+
54
+ Izz1 / Izz2 are rotational inertia about z through CoM
55
+ as a fraction of m*L^2. Default: uniform rod = 1/12.
56
+ """
57
+ if gravity is None:
58
+ gravity = np.array([0.0, -9.81, 0.0]) # gravity in -y (vertical plane)
59
+
60
+ # ── Joint subspaces (revolute about z) ────────────────────────────────────
61
+ S = np.zeros((2, 6))
62
+ S[0, 2] = 1.0 # link 1: rotate about z → omega_z component
63
+ S[1, 2] = 1.0 # link 2: rotate about z
64
+
65
+ # ── X_tree: from parent frame to joint frame (at q=0) ─────────────────────
66
+ # Link 0: joint at world origin → identity
67
+ X_tree_0 = np.eye(6)
68
+ # Link 1: joint at end of link 1. In link 1's frame (at q1=0) the
69
+ # joint-2 origin is at p = [L1, 0, 0] from link-1's origin.
70
+ # No relative rotation at q=0.
71
+ X_tree_1 = Xpose(np.eye(3), np.array([L1, 0.0, 0.0]))
72
+
73
+ X_tree = np.stack([X_tree_0, X_tree_1]) # (2,6,6)
74
+
75
+ # ── Spatial inertias (in link frame, origin = joint) ──────────────────────
76
+ # CoM of each link is at L/2 along x in the link's own frame.
77
+ com1 = np.array([L1 / 2.0, 0.0, 0.0])
78
+ com2 = np.array([L2 / 2.0, 0.0, 0.0])
79
+
80
+ I1_cm = np.diag([0.0, 0.0, Izz1 * m1 * L1**2])
81
+ I2_cm = np.diag([0.0, 0.0, Izz2 * m2 * L2**2])
82
+
83
+ I_link = np.stack(
84
+ [
85
+ spatial_inertia(m1, com1, I1_cm),
86
+ spatial_inertia(m2, com2, I2_cm),
87
+ ]
88
+ ) # (2,6,6)
89
+
90
+ return RigidBodyModel(
91
+ n_links=2,
92
+ parent=[-1, 0],
93
+ X_tree=X_tree,
94
+ S=S,
95
+ I_link=I_link,
96
+ gravity=np.asarray(gravity, dtype=float),
97
+ )