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.
- forge3d/__init__.py +105 -0
- forge3d/app.py +219 -0
- forge3d/backend.py +118 -0
- forge3d/camera.py +235 -0
- forge3d/collision/__init__.py +20 -0
- forge3d/collision/detection.py +739 -0
- forge3d/collision/epa.py +217 -0
- forge3d/collision/gjk.py +266 -0
- forge3d/collision/heightfield.py +196 -0
- forge3d/collision/layers.py +38 -0
- forge3d/constraints/__init__.py +35 -0
- forge3d/constraints/base.py +132 -0
- forge3d/constraints/joints.py +635 -0
- forge3d/contact/__init__.py +1 -0
- forge3d/contact/solver.py +341 -0
- forge3d/dynamics/__init__.py +24 -0
- forge3d/dynamics/aba.py +108 -0
- forge3d/dynamics/crba.py +73 -0
- forge3d/dynamics/model.py +97 -0
- forge3d/dynamics/rnea.py +260 -0
- forge3d/errors.py +98 -0
- forge3d/events.py +267 -0
- forge3d/facade.py +1032 -0
- forge3d/input.py +243 -0
- forge3d/io/__init__.py +10 -0
- forge3d/io/mesh_data.py +206 -0
- forge3d/io/obj_loader.py +203 -0
- forge3d/io/world_snapshot.py +284 -0
- forge3d/logging.py +35 -0
- forge3d/math/__init__.py +59 -0
- forge3d/math/inertia.py +60 -0
- forge3d/math/quaternion.py +141 -0
- forge3d/math/se3.py +162 -0
- forge3d/math/spatial.py +139 -0
- forge3d/model/__init__.py +14 -0
- forge3d/model/kinematics.py +134 -0
- forge3d/model/robot_config.py +138 -0
- forge3d/model/urdf_loader.py +194 -0
- forge3d/py.typed +0 -0
- forge3d/recorder.py +204 -0
- forge3d/render/__init__.py +1 -0
- forge3d/render/base.py +36 -0
- forge3d/render/hq/__init__.py +1 -0
- forge3d/render/hq/raytracer.py +297 -0
- forge3d/render/hq/renderer.py +72 -0
- forge3d/render/hq/scene.py +138 -0
- forge3d/render/realtime/__init__.py +5 -0
- forge3d/render/realtime/context.py +119 -0
- forge3d/render/realtime/meshes.py +254 -0
- forge3d/render/realtime/renderer.py +565 -0
- forge3d/render/realtime/shaders.py +193 -0
- forge3d/render/snapshot.py +91 -0
- forge3d/robot/__init__.py +38 -0
- forge3d/robot/presets.py +82 -0
- forge3d/robot/robot.py +162 -0
- forge3d/sim/__init__.py +1 -0
- forge3d/sim/domain_rand.py +113 -0
- forge3d/sim/jax_batch.py +191 -0
- forge3d/sim/world.py +626 -0
- forge3d/viewer.py +235 -0
- pyforge3d-1.0.0.dist-info/METADATA +566 -0
- pyforge3d-1.0.0.dist-info/RECORD +64 -0
- pyforge3d-1.0.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|
forge3d/dynamics/aba.py
ADDED
|
@@ -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
|
forge3d/dynamics/crba.py
ADDED
|
@@ -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
|
+
)
|