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,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)