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,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."""