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
forge3d/dynamics/rnea.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Recursive Newton-Euler Algorithm (RNEA) — inverse dynamics.
|
|
2
|
+
|
|
3
|
+
Given a RigidBodyModel and joint state (q, qd, qdd), computes joint torques tau
|
|
4
|
+
such that the equations of motion M(q)*qdd + h(q,qd) = tau are satisfied.
|
|
5
|
+
|
|
6
|
+
Also provides:
|
|
7
|
+
- compute_mass_matrix : O(n^2) CRBA via repeated RNEA calls
|
|
8
|
+
- forward_dynamics : compute qdd from tau (uses CRBA + RNEA for bias)
|
|
9
|
+
- semi_implicit_euler : one integration step
|
|
10
|
+
|
|
11
|
+
Reference: Featherstone, Rigid Body Dynamics Algorithms (2008), Algorithm 5.1.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from forge3d.dynamics.model import RigidBodyModel
|
|
21
|
+
from forge3d.math.spatial import Xrot, crf, crm
|
|
22
|
+
|
|
23
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _joint_transform(S_i: Any, q_i: float) -> Any:
|
|
27
|
+
"""Spatial transform due to joint motion.
|
|
28
|
+
|
|
29
|
+
For revolute joint (S = [axis; 0]) rotating by q_i about joint axis.
|
|
30
|
+
For zero motion returns identity.
|
|
31
|
+
"""
|
|
32
|
+
axis = S_i[:3]
|
|
33
|
+
n = np.linalg.norm(axis)
|
|
34
|
+
if n < 1e-10:
|
|
35
|
+
# Prismatic joint (not used in Phase 1 but handled gracefully)
|
|
36
|
+
return np.eye(6)
|
|
37
|
+
return Xrot(axis / n, q_i)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _gravity_spatial(gravity: Any) -> Any:
|
|
41
|
+
"""Convert gravity 3-vector to spatial base acceleration (Featherstone trick).
|
|
42
|
+
|
|
43
|
+
Setting a_base = [0; -gravity] accounts for gravitational forces without
|
|
44
|
+
adding explicit external forces. The sign flip makes gravity appear as an
|
|
45
|
+
inertial upward acceleration propagating through the tree.
|
|
46
|
+
"""
|
|
47
|
+
g = np.asarray(gravity, dtype=float)
|
|
48
|
+
return np.array([0.0, 0.0, 0.0, -g[0], -g[1], -g[2]])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── RNEA ─────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def inverse_dynamics(
|
|
55
|
+
model: RigidBodyModel,
|
|
56
|
+
q: Any,
|
|
57
|
+
qd: Any,
|
|
58
|
+
qdd: Any,
|
|
59
|
+
gravity: Any | None = None,
|
|
60
|
+
) -> Any:
|
|
61
|
+
"""Recursive Newton-Euler: tau = ID(model, q, qd, qdd).
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
model : RigidBodyModel
|
|
66
|
+
q, qd, qdd : (n,) joint angles, velocities, accelerations
|
|
67
|
+
gravity : (3,) gravity vector (overrides model.gravity if given)
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
tau : (n,) joint torques
|
|
72
|
+
"""
|
|
73
|
+
q = np.asarray(q, dtype=float)
|
|
74
|
+
qd = np.asarray(qd, dtype=float)
|
|
75
|
+
qdd = np.asarray(qdd, dtype=float)
|
|
76
|
+
n = model.n_links
|
|
77
|
+
grav = np.asarray(gravity if gravity is not None else model.gravity, dtype=float)
|
|
78
|
+
|
|
79
|
+
# Storage
|
|
80
|
+
v = np.zeros((n, 6)) # spatial velocities
|
|
81
|
+
a = np.zeros((n, 6)) # spatial accelerations
|
|
82
|
+
f = np.zeros((n, 6)) # spatial forces
|
|
83
|
+
Xup = np.zeros((n, 6, 6)) # cumulative joint+tree transforms
|
|
84
|
+
|
|
85
|
+
# Base spatial acceleration (gravity trick)
|
|
86
|
+
a_base = _gravity_spatial(grav)
|
|
87
|
+
|
|
88
|
+
# ── Forward pass ──────────────────────────────────────────────────────────
|
|
89
|
+
for i in range(n):
|
|
90
|
+
X_J = _joint_transform(model.S[i], q[i])
|
|
91
|
+
Xup[i] = X_J @ model.X_tree[i]
|
|
92
|
+
|
|
93
|
+
if model.parent[i] == -1:
|
|
94
|
+
v[i] = model.S[i] * qd[i]
|
|
95
|
+
a[i] = Xup[i] @ a_base + model.S[i] * qdd[i] + crm(v[i]) @ model.S[i] * qd[i]
|
|
96
|
+
else:
|
|
97
|
+
p = model.parent[i]
|
|
98
|
+
v[i] = Xup[i] @ v[p] + model.S[i] * qd[i]
|
|
99
|
+
a[i] = Xup[i] @ a[p] + model.S[i] * qdd[i] + crm(v[i]) @ model.S[i] * qd[i]
|
|
100
|
+
|
|
101
|
+
f[i] = model.I_link[i] @ a[i] + crf(v[i]) @ model.I_link[i] @ v[i]
|
|
102
|
+
|
|
103
|
+
# ── Backward pass ─────────────────────────────────────────────────────────
|
|
104
|
+
tau = np.zeros(n)
|
|
105
|
+
for i in range(n - 1, -1, -1):
|
|
106
|
+
tau[i] = float(model.S[i] @ f[i])
|
|
107
|
+
p = model.parent[i]
|
|
108
|
+
if p != -1:
|
|
109
|
+
f[p] = f[p] + Xup[i].T @ f[i]
|
|
110
|
+
|
|
111
|
+
return tau
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Mass matrix (CRBA via column-by-column RNEA) ──────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def compute_mass_matrix(model: RigidBodyModel, q: Any) -> Any:
|
|
118
|
+
"""n×n joint-space mass matrix M(q) via O(n^2) RNEA calls."""
|
|
119
|
+
q = np.asarray(q, dtype=float)
|
|
120
|
+
n = model.n_links
|
|
121
|
+
M = np.zeros((n, n))
|
|
122
|
+
zero_qd = np.zeros(n)
|
|
123
|
+
zero_grav = np.zeros(3)
|
|
124
|
+
for j in range(n):
|
|
125
|
+
qdd_j = np.zeros(n)
|
|
126
|
+
qdd_j[j] = 1.0
|
|
127
|
+
col = inverse_dynamics(model, q, zero_qd, qdd_j, gravity=zero_grav)
|
|
128
|
+
M[:, j] = col
|
|
129
|
+
return M
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Forward dynamics ──────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def forward_dynamics(
|
|
136
|
+
model: RigidBodyModel,
|
|
137
|
+
q: Any,
|
|
138
|
+
qd: Any,
|
|
139
|
+
tau: Any,
|
|
140
|
+
gravity: Any | None = None,
|
|
141
|
+
) -> Any:
|
|
142
|
+
"""Compute joint accelerations qdd given torques tau.
|
|
143
|
+
|
|
144
|
+
qdd = M(q)^{-1} * (tau - h(q, qd))
|
|
145
|
+
where h = RNEA(q, qd, 0, gravity) (bias: Coriolis + gravity)
|
|
146
|
+
"""
|
|
147
|
+
q = np.asarray(q, dtype=float)
|
|
148
|
+
qd = np.asarray(qd, dtype=float)
|
|
149
|
+
tau = np.asarray(tau, dtype=float)
|
|
150
|
+
grav = gravity if gravity is not None else model.gravity
|
|
151
|
+
|
|
152
|
+
M = compute_mass_matrix(model, q)
|
|
153
|
+
h = inverse_dynamics(model, q, qd, np.zeros_like(q), gravity=grav)
|
|
154
|
+
return np.linalg.solve(M, tau - h)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Integrator ────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def semi_implicit_euler(
|
|
161
|
+
model: RigidBodyModel,
|
|
162
|
+
q: Any,
|
|
163
|
+
qd: Any,
|
|
164
|
+
tau: Any,
|
|
165
|
+
dt: float,
|
|
166
|
+
gravity: Any | None = None,
|
|
167
|
+
) -> tuple[Any, Any]:
|
|
168
|
+
"""Semi-implicit (symplectic) Euler integration step.
|
|
169
|
+
|
|
170
|
+
1. qdd = forward_dynamics(q, qd, tau)
|
|
171
|
+
2. qd_new = qd + dt * qdd (velocity updated first)
|
|
172
|
+
3. q_new = q + dt * qd_new
|
|
173
|
+
|
|
174
|
+
Returns (q_new, qd_new) — no in-place mutation.
|
|
175
|
+
"""
|
|
176
|
+
q = np.asarray(q, dtype=float)
|
|
177
|
+
qd = np.asarray(qd, dtype=float)
|
|
178
|
+
qdd = forward_dynamics(model, q, qd, tau, gravity=gravity)
|
|
179
|
+
qd_new = qd + dt * qdd
|
|
180
|
+
q_new = q + dt * qd_new
|
|
181
|
+
return q_new, qd_new
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── Energy utilities ──────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def kinetic_energy(model: RigidBodyModel, q: Any, qd: Any) -> float:
|
|
188
|
+
"""T = 0.5 * qd^T * M(q) * qd."""
|
|
189
|
+
q = np.asarray(q, dtype=float)
|
|
190
|
+
qd = np.asarray(qd, dtype=float)
|
|
191
|
+
M = compute_mass_matrix(model, q)
|
|
192
|
+
return float(0.5 * qd @ M @ qd)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def potential_energy(model: RigidBodyModel, q: Any) -> float:
|
|
196
|
+
"""V = -sum_i m_i * g · r_i (height above reference).
|
|
197
|
+
|
|
198
|
+
Uses gravity direction from model.gravity.
|
|
199
|
+
"""
|
|
200
|
+
from forge3d.math.se3 import aa_to_rot, unskew
|
|
201
|
+
|
|
202
|
+
q = np.asarray(q, dtype=float)
|
|
203
|
+
n = model.n_links
|
|
204
|
+
grav = model.gravity
|
|
205
|
+
|
|
206
|
+
# Forward kinematics via SE(3).
|
|
207
|
+
# Convention: T_world[i] maps points FROM link i's joint frame TO world frame.
|
|
208
|
+
# T_world[i] = T_world[parent] @ T_parent_to_link[i]
|
|
209
|
+
# T_parent_to_link[i] = make_se3(R_tree @ R_joint, p_tree)
|
|
210
|
+
# where (Featherstone passive convention):
|
|
211
|
+
# E = X_tree[:3,:3] (E = R_tree.T)
|
|
212
|
+
# R_tree = E.T (active rotation of tree frame in parent)
|
|
213
|
+
# p_tree = unskew(-(R_tree @ X_tree[3:,:3]))
|
|
214
|
+
# R_joint = aa_to_rot(S[:3]/|S[:3]|, q_i)
|
|
215
|
+
|
|
216
|
+
R_world = [np.eye(3)] * n # accumulated world-frame rotation of link i
|
|
217
|
+
p_world = [np.zeros(3)] * n # world-frame position of link i joint origin
|
|
218
|
+
|
|
219
|
+
for i in range(n):
|
|
220
|
+
X_t = model.X_tree[i]
|
|
221
|
+
E = X_t[:3, :3] # E = R_tree.T (Featherstone passive)
|
|
222
|
+
R_tree = E.T # active rotation
|
|
223
|
+
p_tree = unskew(-(R_tree @ X_t[3:, :3])) # joint position in parent frame
|
|
224
|
+
|
|
225
|
+
axis = model.S[i][:3]
|
|
226
|
+
n_ax = float(np.linalg.norm(axis))
|
|
227
|
+
R_joint = aa_to_rot(axis / n_ax, q[i]) if n_ax > 1e-10 else np.eye(3)
|
|
228
|
+
|
|
229
|
+
if model.parent[i] == -1:
|
|
230
|
+
R_parent = np.eye(3)
|
|
231
|
+
p_parent = np.zeros(3)
|
|
232
|
+
else:
|
|
233
|
+
R_parent = R_world[model.parent[i]]
|
|
234
|
+
p_parent = p_world[model.parent[i]]
|
|
235
|
+
|
|
236
|
+
# World position of this link's joint origin
|
|
237
|
+
p_world[i] = p_parent + R_parent @ p_tree
|
|
238
|
+
# World orientation: parent @ tree-rotation @ joint-rotation
|
|
239
|
+
R_world[i] = R_parent @ R_tree @ R_joint
|
|
240
|
+
|
|
241
|
+
# Accumulate PE contribution from each link's CoM
|
|
242
|
+
V = 0.0
|
|
243
|
+
for i in range(n):
|
|
244
|
+
I_i = model.I_link[i]
|
|
245
|
+
mass = float(I_i[3, 3])
|
|
246
|
+
if mass < 1e-12:
|
|
247
|
+
continue
|
|
248
|
+
# CoM in link frame from spatial inertia: I[:3, 3:] = mass * skew(com)
|
|
249
|
+
skew_com = I_i[:3, 3:] / mass
|
|
250
|
+
com_local = np.array([-skew_com[1, 2], skew_com[0, 2], -skew_com[0, 1]])
|
|
251
|
+
|
|
252
|
+
com_world = p_world[i] + R_world[i] @ com_local
|
|
253
|
+
V += -mass * float(np.dot(grav, com_world))
|
|
254
|
+
|
|
255
|
+
return V
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def total_energy(model: RigidBodyModel, q: Any, qd: Any) -> float:
|
|
259
|
+
"""Total mechanical energy E = T + V."""
|
|
260
|
+
return kinetic_energy(model, q, qd) + potential_energy(model, q)
|
forge3d/errors.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""forge3d exception hierarchy and validation utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ── Exception hierarchy ───────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Forge3dError(Exception):
|
|
13
|
+
"""Base exception for all forge3d errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PhysicsError(Forge3dError):
|
|
17
|
+
"""Physics configuration or state error."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationError(Forge3dError, ValueError):
|
|
21
|
+
"""Invalid argument passed to a forge3d API call.
|
|
22
|
+
|
|
23
|
+
The message always follows the pattern:
|
|
24
|
+
``ClassName.method_name() — description, got value=<value>``
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RenderError(Forge3dError):
|
|
29
|
+
"""Renderer configuration or capability error."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AssetError(Forge3dError):
|
|
33
|
+
"""Asset loading failure (OBJ/texture not found, parse error, etc.)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Input validation helpers ───────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def require_positive(value: float, name: str, caller: str) -> float:
|
|
40
|
+
"""Raise ValidationError if value <= 0."""
|
|
41
|
+
if value <= 0:
|
|
42
|
+
raise ValidationError(
|
|
43
|
+
f"{caller} — {name} must be positive, got {name}={value!r}"
|
|
44
|
+
)
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def require_nonneg(value: float, name: str, caller: str) -> float:
|
|
49
|
+
"""Raise ValidationError if value < 0."""
|
|
50
|
+
if value < 0:
|
|
51
|
+
raise ValidationError(
|
|
52
|
+
f"{caller} — {name} must be ≥ 0, got {name}={value!r}"
|
|
53
|
+
)
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_range(value: float, lo: float, hi: float, name: str, caller: str) -> float:
|
|
58
|
+
"""Raise ValidationError if value not in [lo, hi]."""
|
|
59
|
+
if not (lo <= value <= hi):
|
|
60
|
+
raise ValidationError(
|
|
61
|
+
f"{caller} — {name} must be in [{lo}, {hi}], got {name}={value!r}"
|
|
62
|
+
)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def require_sequence(value: Any, length: int, name: str, caller: str) -> Any:
|
|
67
|
+
"""Raise ValidationError if value is not a sequence of the given length."""
|
|
68
|
+
try:
|
|
69
|
+
seq = tuple(value)
|
|
70
|
+
except TypeError:
|
|
71
|
+
raise ValidationError(
|
|
72
|
+
f"{caller} — {name} must be a {length}-element sequence, "
|
|
73
|
+
f"got {name}={value!r}"
|
|
74
|
+
) from None
|
|
75
|
+
if len(seq) != length:
|
|
76
|
+
raise ValidationError(
|
|
77
|
+
f"{caller} — {name} must be a {length}-element sequence, "
|
|
78
|
+
f"got {len(seq)} elements"
|
|
79
|
+
)
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def require_all_positive(value: Any, name: str, caller: str) -> None:
|
|
84
|
+
"""Raise ValidationError if any component of a size/extents tuple is <= 0."""
|
|
85
|
+
for i, v in enumerate(value):
|
|
86
|
+
if v <= 0:
|
|
87
|
+
raise ValidationError(
|
|
88
|
+
f"{caller} — all {name} components must be positive, "
|
|
89
|
+
f"got {name}[{i}]={v!r}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Deprecation helper ────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def deprecated(message: str, stacklevel: int = 2) -> None:
|
|
97
|
+
"""Issue a DeprecationWarning with the given message."""
|
|
98
|
+
warnings.warn(message, DeprecationWarning, stacklevel=stacklevel + 1)
|
forge3d/events.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Collision event system for forge3d.
|
|
2
|
+
|
|
3
|
+
Provides per-frame collision callbacks (begin / stay / end) and
|
|
4
|
+
pair-specific collision handlers, similar to Unity's OnCollision* API
|
|
5
|
+
and Pymunk's collision handler system.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
# Type alias for callback functions
|
|
19
|
+
CollisionCallback = Callable[["CollisionEvent"], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CollisionEvent:
|
|
24
|
+
"""Data describing a single collision contact.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
body_a: First body involved in the collision.
|
|
28
|
+
body_b: Second body involved in the collision.
|
|
29
|
+
contact_point: Approximate world-frame contact point (3,).
|
|
30
|
+
normal: Contact normal pointing from body_b toward body_a (3,).
|
|
31
|
+
impulse: Estimated contact impulse magnitude (N·s).
|
|
32
|
+
relative_speed: Relative normal speed at contact (m/s, unsigned).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
body_a: Any # forge3d.facade.Body
|
|
36
|
+
body_b: Any # forge3d.facade.Body
|
|
37
|
+
contact_point: np.ndarray
|
|
38
|
+
normal: np.ndarray
|
|
39
|
+
impulse: float
|
|
40
|
+
relative_speed: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class CollisionHandler:
|
|
45
|
+
"""Callbacks for a specific pair of bodies.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
on_begin: Called once when contact first appears.
|
|
49
|
+
on_stay: Called every step while contact persists.
|
|
50
|
+
on_end: Called once when contact disappears.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
on_begin: CollisionCallback | None = None
|
|
54
|
+
on_stay: CollisionCallback | None = None
|
|
55
|
+
on_end: CollisionCallback | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class TriggerZone:
|
|
60
|
+
"""Pure-data trigger zone — no physics body, no collision geometry.
|
|
61
|
+
|
|
62
|
+
Detects bodies entering / exiting a box region each step.
|
|
63
|
+
The zone does NOT generate physics contacts.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
position: World-space centre (3,).
|
|
67
|
+
half_extents: Box half-extents (3,).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
position: np.ndarray
|
|
71
|
+
half_extents: np.ndarray
|
|
72
|
+
_prev_inside: set[int] = field(default_factory=set)
|
|
73
|
+
_on_enter: list[Callable[[Any], None]] = field(default_factory=list)
|
|
74
|
+
_on_exit: list[Callable[[Any], None]] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
def on_enter(self, fn: Callable[[Any], None]) -> Callable[[Any], None]:
|
|
77
|
+
"""Register a callback (or use as decorator) for when a body enters this zone."""
|
|
78
|
+
self._on_enter.append(fn)
|
|
79
|
+
return fn
|
|
80
|
+
|
|
81
|
+
def on_exit(self, fn: Callable[[Any], None]) -> Callable[[Any], None]:
|
|
82
|
+
"""Register a callback for when a body exits this zone."""
|
|
83
|
+
self._on_exit.append(fn)
|
|
84
|
+
return fn
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Backward-compatible alias
|
|
88
|
+
_TriggerZone = TriggerZone
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class EventDispatcher:
|
|
92
|
+
"""Manages collision event state and dispatches callbacks.
|
|
93
|
+
|
|
94
|
+
Attached to ``PhysicsWorld`` and updated every ``step()``.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
# Global listeners
|
|
99
|
+
self._on_begin: list[CollisionCallback] = []
|
|
100
|
+
self._on_stay: list[CollisionCallback] = []
|
|
101
|
+
self._on_end: list[CollisionCallback] = []
|
|
102
|
+
|
|
103
|
+
# Per-pair handlers — key = frozenset({id_a, id_b})
|
|
104
|
+
self._pair_handlers: dict[frozenset[int], CollisionHandler] = {}
|
|
105
|
+
|
|
106
|
+
# Contact state tracking — set of frozenset({id_a, id_b})
|
|
107
|
+
self._prev_contacts: set[frozenset[int]] = set()
|
|
108
|
+
|
|
109
|
+
# Body-id → Body facade handle (set by World)
|
|
110
|
+
self._bodies: dict[int, Any] = {}
|
|
111
|
+
|
|
112
|
+
# Trigger zones
|
|
113
|
+
self._triggers: list[_TriggerZone] = []
|
|
114
|
+
|
|
115
|
+
# ── Listener registration ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def add_begin_listener(self, fn: CollisionCallback) -> None:
|
|
118
|
+
self._on_begin.append(fn)
|
|
119
|
+
|
|
120
|
+
def add_stay_listener(self, fn: CollisionCallback) -> None:
|
|
121
|
+
self._on_stay.append(fn)
|
|
122
|
+
|
|
123
|
+
def add_end_listener(self, fn: CollisionCallback) -> None:
|
|
124
|
+
self._on_end.append(fn)
|
|
125
|
+
|
|
126
|
+
def add_pair_handler(self, id_a: int, id_b: int) -> CollisionHandler:
|
|
127
|
+
key = frozenset({id_a, id_b})
|
|
128
|
+
if key not in self._pair_handlers:
|
|
129
|
+
self._pair_handlers[key] = CollisionHandler()
|
|
130
|
+
return self._pair_handlers[key]
|
|
131
|
+
|
|
132
|
+
def add_trigger_zone(
|
|
133
|
+
self,
|
|
134
|
+
position: np.ndarray,
|
|
135
|
+
half_extents: np.ndarray,
|
|
136
|
+
) -> TriggerZone:
|
|
137
|
+
"""Create and register a trigger zone (pure-data, no physics body)."""
|
|
138
|
+
zone = TriggerZone(
|
|
139
|
+
position=np.asarray(position, dtype=float),
|
|
140
|
+
half_extents=np.asarray(half_extents, dtype=float),
|
|
141
|
+
)
|
|
142
|
+
self._triggers.append(zone)
|
|
143
|
+
return zone
|
|
144
|
+
|
|
145
|
+
def ignore_pair(self, id_a: int, id_b: int) -> None:
|
|
146
|
+
"""Ignore all events and collisions between two bodies (handled at broad-phase)."""
|
|
147
|
+
key = frozenset({id_a, id_b})
|
|
148
|
+
self._pair_handlers[key] = CollisionHandler() # empty handler (no callbacks)
|
|
149
|
+
|
|
150
|
+
# ── Per-step dispatch ─────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def dispatch(self, contacts: list[Any]) -> None:
|
|
153
|
+
"""Compare current contacts with previous; fire begin/stay/end callbacks.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
contacts: List of contact data objects from the physics step.
|
|
157
|
+
Each contact has ``.body_id_a``, ``.body_id_b``,
|
|
158
|
+
``.contact_point``, ``.normal``, ``.impulse``.
|
|
159
|
+
"""
|
|
160
|
+
curr_contacts: set[frozenset[int]] = set()
|
|
161
|
+
contact_map: dict[frozenset[int], Any] = {}
|
|
162
|
+
|
|
163
|
+
for c in contacts:
|
|
164
|
+
key = frozenset({c.body_id_a, c.body_id_b})
|
|
165
|
+
curr_contacts.add(key)
|
|
166
|
+
contact_map[key] = c
|
|
167
|
+
|
|
168
|
+
began = curr_contacts - self._prev_contacts
|
|
169
|
+
stayed = curr_contacts & self._prev_contacts
|
|
170
|
+
ended = self._prev_contacts - curr_contacts
|
|
171
|
+
|
|
172
|
+
for key in began:
|
|
173
|
+
event = self._make_event(key, contact_map[key])
|
|
174
|
+
if event is None:
|
|
175
|
+
continue
|
|
176
|
+
for fn in self._on_begin:
|
|
177
|
+
fn(event)
|
|
178
|
+
handler = self._pair_handlers.get(key)
|
|
179
|
+
if handler and handler.on_begin:
|
|
180
|
+
handler.on_begin(event)
|
|
181
|
+
|
|
182
|
+
for key in stayed:
|
|
183
|
+
event = self._make_event(key, contact_map[key])
|
|
184
|
+
if event is None:
|
|
185
|
+
continue
|
|
186
|
+
for fn in self._on_stay:
|
|
187
|
+
fn(event)
|
|
188
|
+
handler = self._pair_handlers.get(key)
|
|
189
|
+
if handler and handler.on_stay:
|
|
190
|
+
handler.on_stay(event)
|
|
191
|
+
|
|
192
|
+
for key in ended:
|
|
193
|
+
# No contact data available for ended contacts; create minimal event
|
|
194
|
+
ids = list(key)
|
|
195
|
+
ba = self._bodies.get(ids[0])
|
|
196
|
+
bb = self._bodies.get(ids[1])
|
|
197
|
+
if ba is None or bb is None:
|
|
198
|
+
continue
|
|
199
|
+
event = CollisionEvent(
|
|
200
|
+
body_a=ba, body_b=bb,
|
|
201
|
+
contact_point=np.zeros(3),
|
|
202
|
+
normal=np.zeros(3),
|
|
203
|
+
impulse=0.0,
|
|
204
|
+
relative_speed=0.0,
|
|
205
|
+
)
|
|
206
|
+
for fn in self._on_end:
|
|
207
|
+
fn(event)
|
|
208
|
+
handler = self._pair_handlers.get(key)
|
|
209
|
+
if handler and handler.on_end:
|
|
210
|
+
handler.on_end(event)
|
|
211
|
+
|
|
212
|
+
self._prev_contacts = curr_contacts
|
|
213
|
+
|
|
214
|
+
# Trigger zones
|
|
215
|
+
self._dispatch_triggers()
|
|
216
|
+
|
|
217
|
+
def _make_event(self, key: frozenset[int], contact: Any) -> CollisionEvent | None:
|
|
218
|
+
ids = list(key)
|
|
219
|
+
ba = self._bodies.get(contact.body_id_a) or self._bodies.get(ids[0])
|
|
220
|
+
bb = self._bodies.get(contact.body_id_b) or self._bodies.get(ids[1])
|
|
221
|
+
if ba is None or bb is None:
|
|
222
|
+
return None
|
|
223
|
+
cp = np.asarray(contact.contact_point, dtype=float)
|
|
224
|
+
n = np.asarray(contact.normal, dtype=float)
|
|
225
|
+
imp = float(getattr(contact, "impulse", 0.0))
|
|
226
|
+
spd = float(getattr(contact, "relative_speed", 0.0))
|
|
227
|
+
return CollisionEvent(body_a=ba, body_b=bb, contact_point=cp,
|
|
228
|
+
normal=n, impulse=imp, relative_speed=spd)
|
|
229
|
+
|
|
230
|
+
def _dispatch_triggers(self) -> None:
|
|
231
|
+
"""Check which bodies are inside each trigger zone (AABB check)."""
|
|
232
|
+
for zone in self._triggers:
|
|
233
|
+
curr_inside: set[int] = set()
|
|
234
|
+
for bid, body in self._bodies.items():
|
|
235
|
+
try:
|
|
236
|
+
bpos = body.position
|
|
237
|
+
diff = np.abs(bpos - zone.position)
|
|
238
|
+
if np.all(diff <= zone.half_extents):
|
|
239
|
+
curr_inside.add(bid)
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
entered = curr_inside - zone._prev_inside
|
|
244
|
+
exited = zone._prev_inside - curr_inside
|
|
245
|
+
|
|
246
|
+
for bid in entered:
|
|
247
|
+
body = self._bodies.get(bid)
|
|
248
|
+
if body is not None:
|
|
249
|
+
for fn in zone._on_enter:
|
|
250
|
+
fn(body)
|
|
251
|
+
|
|
252
|
+
for bid in exited:
|
|
253
|
+
body = self._bodies.get(bid)
|
|
254
|
+
if body is not None:
|
|
255
|
+
for fn in zone._on_exit:
|
|
256
|
+
fn(body)
|
|
257
|
+
|
|
258
|
+
zone._prev_inside = curr_inside
|
|
259
|
+
|
|
260
|
+
def register_body(self, body_id: int, body: Any) -> None:
|
|
261
|
+
self._bodies[body_id] = body
|
|
262
|
+
|
|
263
|
+
def unregister_body(self, body_id: int) -> None:
|
|
264
|
+
self._bodies.pop(body_id, None)
|
|
265
|
+
# Remove from trigger zone tracking
|
|
266
|
+
for zone in self._triggers:
|
|
267
|
+
zone._prev_inside.discard(body_id)
|