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,635 @@
|
|
|
1
|
+
"""Concrete joint / constraint implementations.
|
|
2
|
+
|
|
3
|
+
All joints use velocity-level Sequential Impulse + Baumgarte stabilization.
|
|
4
|
+
Reference: Erin Catto, "Iterative Dynamics with Temporal Coherence" (2005).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from forge3d.constraints.base import (
|
|
15
|
+
BAUMGARTE_BETA,
|
|
16
|
+
BAUMGARTE_SLOP,
|
|
17
|
+
Constraint,
|
|
18
|
+
_quat_to_rot,
|
|
19
|
+
_skew,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FixedJoint(Constraint):
|
|
24
|
+
"""Rigid weld — zero relative motion.
|
|
25
|
+
|
|
26
|
+
Constrains all 6 DOF between two anchor points.
|
|
27
|
+
Replaces the ``World.weld()`` kinematic approach for physics-correct
|
|
28
|
+
two-body welds (both bodies remain dynamic).
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
id_a: body_id of the first body.
|
|
32
|
+
id_b: body_id of the second body (-1 → world/static anchor).
|
|
33
|
+
anchor_a: Attachment point in body_a local frame.
|
|
34
|
+
anchor_b: Attachment point in body_b local frame.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
id_a: int,
|
|
40
|
+
id_b: int,
|
|
41
|
+
anchor_a: np.ndarray,
|
|
42
|
+
anchor_b: np.ndarray,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.id_a = id_a
|
|
45
|
+
self.id_b = id_b
|
|
46
|
+
self.anchor_a = np.asarray(anchor_a, dtype=float)
|
|
47
|
+
self.anchor_b = np.asarray(anchor_b, dtype=float)
|
|
48
|
+
|
|
49
|
+
def apply(self, bodies: list[Any], id_to_idx: dict[int, int], dt: float) -> None:
|
|
50
|
+
if self.id_a not in id_to_idx:
|
|
51
|
+
return
|
|
52
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
53
|
+
Ra = _quat_to_rot(ba.quat)
|
|
54
|
+
r_a = Ra @ self.anchor_a
|
|
55
|
+
p_a = ba.pos + r_a
|
|
56
|
+
|
|
57
|
+
if self.id_b >= 0 and self.id_b in id_to_idx:
|
|
58
|
+
bb = bodies[id_to_idx[self.id_b]]
|
|
59
|
+
Rb = _quat_to_rot(bb.quat)
|
|
60
|
+
r_b = Rb @ self.anchor_b
|
|
61
|
+
p_b = bb.pos + r_b
|
|
62
|
+
# Relative velocity at constraint point
|
|
63
|
+
v_rel = (ba.vel + np.cross(ba.omega, r_a)) - (bb.vel + np.cross(bb.omega, r_b))
|
|
64
|
+
else:
|
|
65
|
+
# body_b is world/static — treat as zero velocity
|
|
66
|
+
r_b = self.anchor_b.copy()
|
|
67
|
+
p_b = self.anchor_b.copy()
|
|
68
|
+
v_rel = ba.vel + np.cross(ba.omega, r_a)
|
|
69
|
+
bb = None
|
|
70
|
+
|
|
71
|
+
# Position error (Baumgarte)
|
|
72
|
+
C = p_a - p_b
|
|
73
|
+
bias = (BAUMGARTE_BETA / dt) * np.where(np.abs(C) > BAUMGARTE_SLOP, C, 0.0)
|
|
74
|
+
|
|
75
|
+
# Effective mass (3×3)
|
|
76
|
+
K = self._effective_mass_ball(
|
|
77
|
+
ba, r_a,
|
|
78
|
+
bb if bb is not None else _StaticProxy(),
|
|
79
|
+
r_b if bb is not None else np.zeros(3),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
rhs = -(v_rel + bias)
|
|
83
|
+
try:
|
|
84
|
+
impulse = np.linalg.solve(K + 1e-9 * np.eye(3), rhs)
|
|
85
|
+
except np.linalg.LinAlgError:
|
|
86
|
+
impulse = np.zeros(3)
|
|
87
|
+
|
|
88
|
+
self._apply_impulse_pair(
|
|
89
|
+
bodies, id_to_idx, self.id_a,
|
|
90
|
+
self.id_b if bb is not None else -1,
|
|
91
|
+
impulse, r_a, r_b,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BallJoint(Constraint):
|
|
96
|
+
"""Ball-and-socket joint — 3 translational DOF constrained, rotation free.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
id_a, id_b: Body IDs.
|
|
100
|
+
anchor_a, anchor_b: Local attachment points.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
id_a: int,
|
|
106
|
+
id_b: int,
|
|
107
|
+
anchor_a: np.ndarray,
|
|
108
|
+
anchor_b: np.ndarray,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.id_a = id_a
|
|
111
|
+
self.id_b = id_b
|
|
112
|
+
self.anchor_a = np.asarray(anchor_a, dtype=float)
|
|
113
|
+
self.anchor_b = np.asarray(anchor_b, dtype=float)
|
|
114
|
+
|
|
115
|
+
def apply(self, bodies: list[Any], id_to_idx: dict[int, int], dt: float) -> None:
|
|
116
|
+
if self.id_a not in id_to_idx:
|
|
117
|
+
return
|
|
118
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
119
|
+
Ra = _quat_to_rot(ba.quat)
|
|
120
|
+
r_a = Ra @ self.anchor_a
|
|
121
|
+
p_a = ba.pos + r_a
|
|
122
|
+
|
|
123
|
+
if self.id_b >= 0 and self.id_b in id_to_idx:
|
|
124
|
+
bb = bodies[id_to_idx[self.id_b]]
|
|
125
|
+
Rb = _quat_to_rot(bb.quat)
|
|
126
|
+
r_b = Rb @ self.anchor_b
|
|
127
|
+
p_b = bb.pos + r_b
|
|
128
|
+
v_rel = (ba.vel + np.cross(ba.omega, r_a)) - (bb.vel + np.cross(bb.omega, r_b))
|
|
129
|
+
else:
|
|
130
|
+
r_b = np.zeros(3)
|
|
131
|
+
p_b = self.anchor_b.copy()
|
|
132
|
+
v_rel = ba.vel + np.cross(ba.omega, r_a)
|
|
133
|
+
bb = None
|
|
134
|
+
|
|
135
|
+
C = p_a - p_b
|
|
136
|
+
bias = (BAUMGARTE_BETA / dt) * np.where(np.abs(C) > BAUMGARTE_SLOP, C, 0.0)
|
|
137
|
+
|
|
138
|
+
K = self._effective_mass_ball(
|
|
139
|
+
ba, r_a,
|
|
140
|
+
bb if bb is not None else _StaticProxy(),
|
|
141
|
+
r_b if bb is not None else np.zeros(3),
|
|
142
|
+
)
|
|
143
|
+
rhs = -(v_rel + bias)
|
|
144
|
+
try:
|
|
145
|
+
impulse = np.linalg.solve(K + 1e-9 * np.eye(3), rhs)
|
|
146
|
+
except np.linalg.LinAlgError:
|
|
147
|
+
impulse = np.zeros(3)
|
|
148
|
+
|
|
149
|
+
self._apply_impulse_pair(
|
|
150
|
+
bodies, id_to_idx, self.id_a,
|
|
151
|
+
self.id_b if bb is not None else -1,
|
|
152
|
+
impulse, r_a, r_b,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class HingeJoint(Constraint):
|
|
157
|
+
"""Revolute joint — 1 rotational DOF around a specified axis.
|
|
158
|
+
|
|
159
|
+
Constrains 5 DOF: 3 translational + 2 angular (perpendicular to hinge axis).
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
id_a, id_b: Body IDs.
|
|
163
|
+
anchor_a, anchor_b: Local attachment points.
|
|
164
|
+
axis_a: Hinge axis in body_a local frame (will be normalized).
|
|
165
|
+
limits: Optional (min_angle, max_angle) in radians.
|
|
166
|
+
motor_velocity: Target angular velocity (rad/s) if motor enabled.
|
|
167
|
+
motor_max_torque: Maximum motor torque (N·m).
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
id_a: int,
|
|
173
|
+
id_b: int,
|
|
174
|
+
anchor_a: np.ndarray,
|
|
175
|
+
anchor_b: np.ndarray,
|
|
176
|
+
axis_a: np.ndarray,
|
|
177
|
+
limits: tuple[float, float] | None = None,
|
|
178
|
+
motor_velocity: float | None = None,
|
|
179
|
+
motor_max_torque: float = 10.0,
|
|
180
|
+
) -> None:
|
|
181
|
+
self.id_a = id_a
|
|
182
|
+
self.id_b = id_b
|
|
183
|
+
self.anchor_a = np.asarray(anchor_a, dtype=float)
|
|
184
|
+
self.anchor_b = np.asarray(anchor_b, dtype=float)
|
|
185
|
+
axis = np.asarray(axis_a, dtype=float)
|
|
186
|
+
self.axis_a = axis / (np.linalg.norm(axis) + 1e-12)
|
|
187
|
+
self.limits = limits
|
|
188
|
+
self.motor_velocity = motor_velocity
|
|
189
|
+
self.motor_max_torque = float(motor_max_torque)
|
|
190
|
+
self._accumulated_angle: float = 0.0
|
|
191
|
+
self._prev_quat_a: np.ndarray | None = None
|
|
192
|
+
self._prev_quat_b: np.ndarray | None = None
|
|
193
|
+
|
|
194
|
+
def apply(self, bodies: list[Any], id_to_idx: dict[int, int], dt: float) -> None:
|
|
195
|
+
if self.id_a not in id_to_idx:
|
|
196
|
+
return
|
|
197
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
198
|
+
Ra = _quat_to_rot(ba.quat)
|
|
199
|
+
r_a = Ra @ self.anchor_a
|
|
200
|
+
p_a = ba.pos + r_a
|
|
201
|
+
axis_world = Ra @ self.axis_a
|
|
202
|
+
|
|
203
|
+
if self.id_b >= 0 and self.id_b in id_to_idx:
|
|
204
|
+
bb = bodies[id_to_idx[self.id_b]]
|
|
205
|
+
Rb = _quat_to_rot(bb.quat)
|
|
206
|
+
r_b = Rb @ self.anchor_b
|
|
207
|
+
p_b = bb.pos + r_b
|
|
208
|
+
omega_rel = ba.omega - bb.omega
|
|
209
|
+
else:
|
|
210
|
+
r_b = np.zeros(3)
|
|
211
|
+
p_b = self.anchor_b.copy()
|
|
212
|
+
omega_rel = ba.omega
|
|
213
|
+
bb = None
|
|
214
|
+
|
|
215
|
+
# ── 1. Point-to-point (ball joint) ────────────────────────────────────
|
|
216
|
+
v_rel_lin = (ba.vel + np.cross(ba.omega, r_a)) - (
|
|
217
|
+
(bb.vel + np.cross(bb.omega, r_b)) if bb is not None else np.zeros(3)
|
|
218
|
+
)
|
|
219
|
+
C_lin = p_a - p_b
|
|
220
|
+
bias_lin = (BAUMGARTE_BETA / dt) * np.where(np.abs(C_lin) > BAUMGARTE_SLOP, C_lin, 0.0)
|
|
221
|
+
|
|
222
|
+
K_lin = self._effective_mass_ball(
|
|
223
|
+
ba, r_a,
|
|
224
|
+
bb if bb is not None else _StaticProxy(),
|
|
225
|
+
r_b if bb is not None else np.zeros(3),
|
|
226
|
+
)
|
|
227
|
+
rhs_lin = -(v_rel_lin + bias_lin)
|
|
228
|
+
try:
|
|
229
|
+
impulse_lin = np.linalg.solve(K_lin + 1e-9 * np.eye(3), rhs_lin)
|
|
230
|
+
except np.linalg.LinAlgError:
|
|
231
|
+
impulse_lin = np.zeros(3)
|
|
232
|
+
|
|
233
|
+
self._apply_impulse_pair(
|
|
234
|
+
bodies, id_to_idx, self.id_a,
|
|
235
|
+
self.id_b if bb is not None else -1,
|
|
236
|
+
impulse_lin, r_a, r_b,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Re-fetch (body was replaced)
|
|
240
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
241
|
+
if bb is not None:
|
|
242
|
+
bb = bodies[id_to_idx[self.id_b]]
|
|
243
|
+
|
|
244
|
+
# ── 2. Angular constraint (2 axes perpendicular to hinge) ─────────────
|
|
245
|
+
perp1, perp2 = _perp_basis(axis_world)
|
|
246
|
+
omega_rel_updated = ba.omega - (bb.omega if bb is not None else np.zeros(3))
|
|
247
|
+
|
|
248
|
+
for perp in (perp1, perp2):
|
|
249
|
+
omega_along_perp = np.dot(omega_rel_updated, perp)
|
|
250
|
+
K_ang = (
|
|
251
|
+
(np.dot(perp, self._I_world_inv(ba) @ perp))
|
|
252
|
+
+ (np.dot(perp, self._I_world_inv(bb) @ perp) if bb is not None else 0.0)
|
|
253
|
+
)
|
|
254
|
+
if abs(K_ang) < 1e-12:
|
|
255
|
+
continue
|
|
256
|
+
lam = -omega_along_perp / K_ang
|
|
257
|
+
torque_imp = lam * perp
|
|
258
|
+
# Apply angular impulses
|
|
259
|
+
idx_a = id_to_idx[self.id_a]
|
|
260
|
+
baa = bodies[idx_a]
|
|
261
|
+
if not baa.static:
|
|
262
|
+
bodies[idx_a] = replace(
|
|
263
|
+
baa, omega=baa.omega + self._I_world_inv(baa) @ torque_imp
|
|
264
|
+
)
|
|
265
|
+
if bb is not None:
|
|
266
|
+
idx_b = id_to_idx[self.id_b]
|
|
267
|
+
bbb = bodies[idx_b]
|
|
268
|
+
if not bbb.static:
|
|
269
|
+
bodies[idx_b] = replace(
|
|
270
|
+
bbb, omega=bbb.omega - self._I_world_inv(bbb) @ torque_imp
|
|
271
|
+
)
|
|
272
|
+
# Re-fetch for next iteration
|
|
273
|
+
omega_rel_updated = (
|
|
274
|
+
bodies[id_to_idx[self.id_a]].omega
|
|
275
|
+
- (bodies[id_to_idx[self.id_b]].omega if bb is not None else np.zeros(3))
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# ── 3. Motor ──────────────────────────────────────────────────────────
|
|
279
|
+
if self.motor_velocity is not None:
|
|
280
|
+
ba2 = bodies[id_to_idx[self.id_a]]
|
|
281
|
+
bb2 = bodies[id_to_idx[self.id_b]] if bb is not None else None
|
|
282
|
+
omega_rel_ax = np.dot(ba2.omega - (bb2.omega if bb2 else np.zeros(3)), axis_world)
|
|
283
|
+
error_vel = self.motor_velocity - omega_rel_ax
|
|
284
|
+
K_motor = (
|
|
285
|
+
np.dot(axis_world, self._I_world_inv(ba2) @ axis_world)
|
|
286
|
+
+ (np.dot(axis_world, self._I_world_inv(bb2) @ axis_world) if bb2 else 0.0)
|
|
287
|
+
)
|
|
288
|
+
if abs(K_motor) > 1e-12:
|
|
289
|
+
lam_motor = np.clip(
|
|
290
|
+
error_vel / K_motor,
|
|
291
|
+
-self.motor_max_torque * dt,
|
|
292
|
+
self.motor_max_torque * dt,
|
|
293
|
+
)
|
|
294
|
+
mot_imp = lam_motor * axis_world
|
|
295
|
+
idx_a = id_to_idx[self.id_a]
|
|
296
|
+
baa = bodies[idx_a]
|
|
297
|
+
if not baa.static:
|
|
298
|
+
bodies[idx_a] = replace(
|
|
299
|
+
baa, omega=baa.omega + self._I_world_inv(baa) @ mot_imp
|
|
300
|
+
)
|
|
301
|
+
if bb2 is not None:
|
|
302
|
+
idx_b = id_to_idx[self.id_b]
|
|
303
|
+
bbb = bodies[idx_b]
|
|
304
|
+
if not bbb.static:
|
|
305
|
+
bodies[idx_b] = replace(
|
|
306
|
+
bbb, omega=bbb.omega - self._I_world_inv(bbb) @ mot_imp
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class PrismaticJoint(Constraint):
|
|
311
|
+
"""Slider joint — 1 translational DOF along a specified axis.
|
|
312
|
+
|
|
313
|
+
Constrains 5 DOF: all rotation + 2 translational directions perpendicular to axis.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
id_a, id_b: Body IDs.
|
|
317
|
+
anchor_a, anchor_b: Local attachment points.
|
|
318
|
+
axis_a: Slide axis in body_a local frame.
|
|
319
|
+
limits: Optional (min_dist, max_dist) in metres.
|
|
320
|
+
motor_velocity: Target velocity along axis (m/s).
|
|
321
|
+
motor_max_force: Maximum motor force (N).
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
def __init__(
|
|
325
|
+
self,
|
|
326
|
+
id_a: int,
|
|
327
|
+
id_b: int,
|
|
328
|
+
anchor_a: np.ndarray,
|
|
329
|
+
anchor_b: np.ndarray,
|
|
330
|
+
axis_a: np.ndarray,
|
|
331
|
+
limits: tuple[float, float] | None = None,
|
|
332
|
+
motor_velocity: float | None = None,
|
|
333
|
+
motor_max_force: float = 100.0,
|
|
334
|
+
) -> None:
|
|
335
|
+
self.id_a = id_a
|
|
336
|
+
self.id_b = id_b
|
|
337
|
+
self.anchor_a = np.asarray(anchor_a, dtype=float)
|
|
338
|
+
self.anchor_b = np.asarray(anchor_b, dtype=float)
|
|
339
|
+
ax = np.asarray(axis_a, dtype=float)
|
|
340
|
+
self.axis_a = ax / (np.linalg.norm(ax) + 1e-12)
|
|
341
|
+
self.limits = limits
|
|
342
|
+
self.motor_velocity = motor_velocity
|
|
343
|
+
self.motor_max_force = float(motor_max_force)
|
|
344
|
+
|
|
345
|
+
def apply(self, bodies: list[Any], id_to_idx: dict[int, int], dt: float) -> None:
|
|
346
|
+
if self.id_a not in id_to_idx:
|
|
347
|
+
return
|
|
348
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
349
|
+
Ra = _quat_to_rot(ba.quat)
|
|
350
|
+
axis_world = Ra @ self.axis_a
|
|
351
|
+
r_a = Ra @ self.anchor_a
|
|
352
|
+
p_a = ba.pos + r_a
|
|
353
|
+
|
|
354
|
+
if self.id_b >= 0 and self.id_b in id_to_idx:
|
|
355
|
+
bb = bodies[id_to_idx[self.id_b]]
|
|
356
|
+
Rb = _quat_to_rot(bb.quat)
|
|
357
|
+
r_b = Rb @ self.anchor_b
|
|
358
|
+
p_b = bb.pos + r_b
|
|
359
|
+
else:
|
|
360
|
+
r_b = np.zeros(3)
|
|
361
|
+
p_b = self.anchor_b.copy()
|
|
362
|
+
bb = None
|
|
363
|
+
|
|
364
|
+
v_rel_lin = (ba.vel + np.cross(ba.omega, r_a)) - (
|
|
365
|
+
(bb.vel + np.cross(bb.omega, r_b)) if bb else np.zeros(3)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Constrain 2 perpendicular directions
|
|
369
|
+
perp1, perp2 = _perp_basis(axis_world)
|
|
370
|
+
C_p1 = np.dot(p_a - p_b, perp1)
|
|
371
|
+
C_p2 = np.dot(p_a - p_b, perp2)
|
|
372
|
+
|
|
373
|
+
for perp, C in ((perp1, C_p1), (perp2, C_p2)):
|
|
374
|
+
v_perp = np.dot(v_rel_lin, perp)
|
|
375
|
+
bias = (BAUMGARTE_BETA / dt) * (C if abs(C) > BAUMGARTE_SLOP else 0.0)
|
|
376
|
+
|
|
377
|
+
K = self._effective_mass_ball(
|
|
378
|
+
ba, r_a,
|
|
379
|
+
bb if bb else _StaticProxy(),
|
|
380
|
+
r_b if bb else np.zeros(3),
|
|
381
|
+
)
|
|
382
|
+
K_perp = np.dot(perp, K @ perp)
|
|
383
|
+
if abs(K_perp) < 1e-12:
|
|
384
|
+
continue
|
|
385
|
+
lam = -(v_perp + bias) / K_perp
|
|
386
|
+
impulse = lam * perp
|
|
387
|
+
self._apply_impulse_pair(
|
|
388
|
+
bodies, id_to_idx, self.id_a,
|
|
389
|
+
self.id_b if bb else -1,
|
|
390
|
+
impulse, r_a, r_b,
|
|
391
|
+
)
|
|
392
|
+
# Re-fetch
|
|
393
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
394
|
+
v_rel_lin = (ba.vel + np.cross(ba.omega, r_a)) - (
|
|
395
|
+
(bodies[id_to_idx[self.id_b]].vel + np.cross(bodies[id_to_idx[self.id_b]].omega, r_b))
|
|
396
|
+
if bb else np.zeros(3)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Angular constraint — lock all rotation
|
|
400
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
401
|
+
bb2 = bodies[id_to_idx[self.id_b]] if bb else None
|
|
402
|
+
omega_rel = ba.omega - (bb2.omega if bb2 else np.zeros(3))
|
|
403
|
+
for ax_c in (axis_world, perp1, perp2):
|
|
404
|
+
om_along = np.dot(omega_rel, ax_c)
|
|
405
|
+
K_ang = (
|
|
406
|
+
np.dot(ax_c, self._I_world_inv(ba) @ ax_c)
|
|
407
|
+
+ (np.dot(ax_c, self._I_world_inv(bb2) @ ax_c) if bb2 else 0.0)
|
|
408
|
+
)
|
|
409
|
+
if abs(K_ang) < 1e-12:
|
|
410
|
+
continue
|
|
411
|
+
lam = -om_along / K_ang
|
|
412
|
+
ang_imp = lam * ax_c
|
|
413
|
+
idx_a = id_to_idx[self.id_a]
|
|
414
|
+
baa = bodies[idx_a]
|
|
415
|
+
if not baa.static:
|
|
416
|
+
bodies[idx_a] = replace(baa, omega=baa.omega + self._I_world_inv(baa) @ ang_imp)
|
|
417
|
+
if bb2:
|
|
418
|
+
idx_b = id_to_idx[self.id_b]
|
|
419
|
+
bbb = bodies[idx_b]
|
|
420
|
+
if not bbb.static:
|
|
421
|
+
bodies[idx_b] = replace(bbb, omega=bbb.omega - self._I_world_inv(bbb) @ ang_imp)
|
|
422
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
423
|
+
bb2 = bodies[id_to_idx[self.id_b]] if bb else None
|
|
424
|
+
omega_rel = ba.omega - (bb2.omega if bb2 else np.zeros(3))
|
|
425
|
+
|
|
426
|
+
# Motor along axis
|
|
427
|
+
if self.motor_velocity is not None:
|
|
428
|
+
ba2 = bodies[id_to_idx[self.id_a]]
|
|
429
|
+
bb2 = bodies[id_to_idx[self.id_b]] if bb else None
|
|
430
|
+
v_axis = np.dot(
|
|
431
|
+
(ba2.vel + np.cross(ba2.omega, r_a))
|
|
432
|
+
- ((bb2.vel + np.cross(bb2.omega, r_b)) if bb2 else np.zeros(3)),
|
|
433
|
+
axis_world,
|
|
434
|
+
)
|
|
435
|
+
err = self.motor_velocity - v_axis
|
|
436
|
+
K_m = np.dot(axis_world, self._effective_mass_ball(
|
|
437
|
+
ba2, r_a, bb2 if bb2 else _StaticProxy(), r_b if bb2 else np.zeros(3)
|
|
438
|
+
) @ axis_world)
|
|
439
|
+
if abs(K_m) > 1e-12:
|
|
440
|
+
lam_m = np.clip(
|
|
441
|
+
err / K_m,
|
|
442
|
+
-self.motor_max_force * dt,
|
|
443
|
+
self.motor_max_force * dt,
|
|
444
|
+
)
|
|
445
|
+
self._apply_impulse_pair(
|
|
446
|
+
bodies, id_to_idx, self.id_a,
|
|
447
|
+
self.id_b if bb else -1,
|
|
448
|
+
lam_m * axis_world, r_a, r_b,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class DistanceJoint(Constraint):
|
|
453
|
+
"""Maintain a target distance between two anchor points.
|
|
454
|
+
|
|
455
|
+
Can act as a rigid rod (exact distance) or one-sided rope (max distance).
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
id_a, id_b: Body IDs.
|
|
459
|
+
anchor_a, anchor_b: Local attachment points.
|
|
460
|
+
target_distance: Desired distance in metres.
|
|
461
|
+
min_distance: If set, acts as a compression stop (push apart).
|
|
462
|
+
one_sided: If True, only prevents compression (rope, not rod).
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
def __init__(
|
|
466
|
+
self,
|
|
467
|
+
id_a: int,
|
|
468
|
+
id_b: int,
|
|
469
|
+
anchor_a: np.ndarray,
|
|
470
|
+
anchor_b: np.ndarray,
|
|
471
|
+
target_distance: float,
|
|
472
|
+
min_distance: float | None = None,
|
|
473
|
+
one_sided: bool = False,
|
|
474
|
+
) -> None:
|
|
475
|
+
self.id_a = id_a
|
|
476
|
+
self.id_b = id_b
|
|
477
|
+
self.anchor_a = np.asarray(anchor_a, dtype=float)
|
|
478
|
+
self.anchor_b = np.asarray(anchor_b, dtype=float)
|
|
479
|
+
self.target_distance = float(target_distance)
|
|
480
|
+
self.min_distance = min_distance
|
|
481
|
+
self.one_sided = one_sided
|
|
482
|
+
|
|
483
|
+
def apply(self, bodies: list[Any], id_to_idx: dict[int, int], dt: float) -> None:
|
|
484
|
+
if self.id_a not in id_to_idx:
|
|
485
|
+
return
|
|
486
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
487
|
+
Ra = _quat_to_rot(ba.quat)
|
|
488
|
+
r_a = Ra @ self.anchor_a
|
|
489
|
+
p_a = ba.pos + r_a
|
|
490
|
+
|
|
491
|
+
if self.id_b >= 0 and self.id_b in id_to_idx:
|
|
492
|
+
bb = bodies[id_to_idx[self.id_b]]
|
|
493
|
+
Rb = _quat_to_rot(bb.quat)
|
|
494
|
+
r_b = Rb @ self.anchor_b
|
|
495
|
+
p_b = bb.pos + r_b
|
|
496
|
+
else:
|
|
497
|
+
r_b = np.zeros(3)
|
|
498
|
+
p_b = self.anchor_b.copy()
|
|
499
|
+
bb = None
|
|
500
|
+
|
|
501
|
+
diff = p_a - p_b
|
|
502
|
+
dist = np.linalg.norm(diff)
|
|
503
|
+
if dist < 1e-10:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
n = diff / dist # unit vector a→b
|
|
507
|
+
C = dist - self.target_distance
|
|
508
|
+
|
|
509
|
+
if self.one_sided and C < 0:
|
|
510
|
+
return # rope: only prevent stretching
|
|
511
|
+
if self.min_distance is not None and dist < self.min_distance:
|
|
512
|
+
C = dist - self.min_distance
|
|
513
|
+
|
|
514
|
+
v_a = ba.vel + np.cross(ba.omega, r_a)
|
|
515
|
+
v_b = (bb.vel + np.cross(bb.omega, r_b)) if bb is not None else np.zeros(3)
|
|
516
|
+
v_rel_n = np.dot(v_a - v_b, n)
|
|
517
|
+
|
|
518
|
+
bias = (BAUMGARTE_BETA / dt) * C if abs(C) > BAUMGARTE_SLOP else 0.0
|
|
519
|
+
K = self._effective_mass_ball(
|
|
520
|
+
ba, r_a,
|
|
521
|
+
bb if bb else _StaticProxy(),
|
|
522
|
+
r_b if bb else np.zeros(3),
|
|
523
|
+
)
|
|
524
|
+
K_n = np.dot(n, K @ n)
|
|
525
|
+
if abs(K_n) < 1e-12:
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
lam = -(v_rel_n + bias) / K_n
|
|
529
|
+
impulse = lam * n
|
|
530
|
+
self._apply_impulse_pair(
|
|
531
|
+
bodies, id_to_idx, self.id_a,
|
|
532
|
+
self.id_b if bb else -1,
|
|
533
|
+
impulse, r_a, r_b,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class SpringJoint(Constraint):
|
|
538
|
+
"""Spring-damper force element between two anchor points.
|
|
539
|
+
|
|
540
|
+
Not a hard constraint — applies spring force each step.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
id_a, id_b: Body IDs.
|
|
544
|
+
anchor_a, anchor_b: Local attachment points.
|
|
545
|
+
stiffness: Spring constant k (N/m).
|
|
546
|
+
damping: Damping coefficient c (N·s/m).
|
|
547
|
+
rest_length: Natural length at zero force (m).
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
def __init__(
|
|
551
|
+
self,
|
|
552
|
+
id_a: int,
|
|
553
|
+
id_b: int,
|
|
554
|
+
anchor_a: np.ndarray,
|
|
555
|
+
anchor_b: np.ndarray,
|
|
556
|
+
stiffness: float = 100.0,
|
|
557
|
+
damping: float = 5.0,
|
|
558
|
+
rest_length: float = 1.0,
|
|
559
|
+
) -> None:
|
|
560
|
+
self.id_a = id_a
|
|
561
|
+
self.id_b = id_b
|
|
562
|
+
self.anchor_a = np.asarray(anchor_a, dtype=float)
|
|
563
|
+
self.anchor_b = np.asarray(anchor_b, dtype=float)
|
|
564
|
+
self.stiffness = float(stiffness)
|
|
565
|
+
self.damping = float(damping)
|
|
566
|
+
self.rest_length = float(rest_length)
|
|
567
|
+
|
|
568
|
+
def apply(self, bodies: list[Any], id_to_idx: dict[int, int], dt: float) -> None:
|
|
569
|
+
if self.id_a not in id_to_idx:
|
|
570
|
+
return
|
|
571
|
+
ba = bodies[id_to_idx[self.id_a]]
|
|
572
|
+
Ra = _quat_to_rot(ba.quat)
|
|
573
|
+
r_a = Ra @ self.anchor_a
|
|
574
|
+
p_a = ba.pos + r_a
|
|
575
|
+
|
|
576
|
+
if self.id_b >= 0 and self.id_b in id_to_idx:
|
|
577
|
+
bb = bodies[id_to_idx[self.id_b]]
|
|
578
|
+
Rb = _quat_to_rot(bb.quat)
|
|
579
|
+
r_b = Rb @ self.anchor_b
|
|
580
|
+
p_b = bb.pos + r_b
|
|
581
|
+
else:
|
|
582
|
+
r_b = np.zeros(3)
|
|
583
|
+
p_b = self.anchor_b.copy()
|
|
584
|
+
bb = None
|
|
585
|
+
|
|
586
|
+
diff = p_a - p_b
|
|
587
|
+
dist = np.linalg.norm(diff)
|
|
588
|
+
if dist < 1e-10:
|
|
589
|
+
return
|
|
590
|
+
n = diff / dist # a → b direction
|
|
591
|
+
|
|
592
|
+
# Spring force
|
|
593
|
+
f_spring = -self.stiffness * (dist - self.rest_length)
|
|
594
|
+
|
|
595
|
+
# Damping force (relative velocity along spring axis)
|
|
596
|
+
v_a = ba.vel + np.cross(ba.omega, r_a)
|
|
597
|
+
v_b = (bb.vel + np.cross(bb.omega, r_b)) if bb else np.zeros(3)
|
|
598
|
+
v_rel_n = np.dot(v_a - v_b, n)
|
|
599
|
+
f_damp = -self.damping * v_rel_n
|
|
600
|
+
|
|
601
|
+
f_total = (f_spring + f_damp) * n # force on body_a
|
|
602
|
+
|
|
603
|
+
# Convert force to velocity impulse: Δv = (F * dt) / m
|
|
604
|
+
impulse = f_total * dt
|
|
605
|
+
self._apply_impulse_pair(
|
|
606
|
+
bodies, id_to_idx, self.id_a,
|
|
607
|
+
self.id_b if bb else -1,
|
|
608
|
+
impulse, r_a, r_b,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _perp_basis(n: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
616
|
+
"""Return two unit vectors perpendicular to n."""
|
|
617
|
+
n = n / (np.linalg.norm(n) + 1e-12)
|
|
618
|
+
if abs(n[0]) < 0.9:
|
|
619
|
+
t = np.array([1.0, 0.0, 0.0])
|
|
620
|
+
else:
|
|
621
|
+
t = np.array([0.0, 1.0, 0.0])
|
|
622
|
+
e1 = np.cross(n, t)
|
|
623
|
+
e1 /= np.linalg.norm(e1) + 1e-12
|
|
624
|
+
e2 = np.cross(n, e1)
|
|
625
|
+
return e1, e2
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class _StaticProxy:
|
|
629
|
+
"""Stand-in for a static/world anchor (zero mass, zero inertia)."""
|
|
630
|
+
static = True
|
|
631
|
+
mass = 0.0
|
|
632
|
+
inertia_inv_local = None
|
|
633
|
+
quat = np.array([1.0, 0.0, 0.0, 0.0])
|
|
634
|
+
vel = np.zeros(3)
|
|
635
|
+
omega = np.zeros(3)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""contact package."""
|