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,739 @@
|
|
|
1
|
+
"""Narrow-phase collision detection for primitive shape pairs.
|
|
2
|
+
|
|
3
|
+
Returns ContactPoint lists — pure data, no side effects, no renderer imports.
|
|
4
|
+
|
|
5
|
+
Supported pairs:
|
|
6
|
+
sphere vs. box/OBB (general: any face, any orientation)
|
|
7
|
+
sphere vs. sphere (dynamic-dynamic)
|
|
8
|
+
box vs. box (SAT: all 6 faces, any orientation)
|
|
9
|
+
capsule vs. sphere
|
|
10
|
+
capsule vs. box
|
|
11
|
+
capsule vs. capsule
|
|
12
|
+
mesh vs. plane (convex-hull vertices vs. half-space)
|
|
13
|
+
mesh vs. sphere (GJK + EPA)
|
|
14
|
+
mesh vs. box (GJK + EPA)
|
|
15
|
+
mesh vs. mesh (GJK + EPA)
|
|
16
|
+
* vs. static plane (half-space at z=0)
|
|
17
|
+
|
|
18
|
+
Legacy helpers _sphere_vs_box_halfspace and _box_vs_box_halfspace are kept
|
|
19
|
+
for direct test imports (backward compat).
|
|
20
|
+
|
|
21
|
+
Convention: ContactPoint.normal points from body_b toward body_a.
|
|
22
|
+
Positive depth means penetration.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
# ── Fast 3-vector helpers (avoids np.cross moveaxis overhead on tiny arrays) ──
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cross3(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
36
|
+
"""Inline 3D cross product for (3,) vectors — 10× faster than np.cross."""
|
|
37
|
+
return np.array(
|
|
38
|
+
[
|
|
39
|
+
a[1] * b[2] - a[2] * b[1],
|
|
40
|
+
a[2] * b[0] - a[0] * b[2],
|
|
41
|
+
a[0] * b[1] - a[1] * b[0],
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _quat_to_rot_unit(q: np.ndarray) -> np.ndarray:
|
|
47
|
+
"""quat → rot matrix assuming q is already unit (skips normalization)."""
|
|
48
|
+
w, x, y, z = q
|
|
49
|
+
return np.array(
|
|
50
|
+
[
|
|
51
|
+
[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
|
|
52
|
+
[2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
|
|
53
|
+
[2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
pass # _Body is duck-typed to avoid circular imports
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ContactPoint:
|
|
64
|
+
"""One contact between two bodies (pure data)."""
|
|
65
|
+
|
|
66
|
+
body_a_idx: int # index of dynamic body (always dynamic)
|
|
67
|
+
body_b_idx: int # index of other body (static or dynamic; -1 = infinite plane)
|
|
68
|
+
pos: np.ndarray # contact point in world frame
|
|
69
|
+
normal: np.ndarray # unit normal b → a (pushes a away)
|
|
70
|
+
depth: float # penetration depth > 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Public entry point ────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_MAX_CONTACTS_PER_PAIR = 4 # reduce manifold: 4 pts sufficient for stability
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _aabb_half_extents(body: Any, R: np.ndarray | None) -> np.ndarray:
|
|
80
|
+
"""Axis-aligned bounding-box half-extents for a body (broadphase only)."""
|
|
81
|
+
st = body.shape_type
|
|
82
|
+
if st == "sphere":
|
|
83
|
+
r = float(body.shape_params["radius"])
|
|
84
|
+
return np.array([r, r, r])
|
|
85
|
+
if st == "capsule":
|
|
86
|
+
r = float(body.shape_params["radius"])
|
|
87
|
+
hl = float(body.shape_params["half_length"])
|
|
88
|
+
return np.array([r + hl, r + hl, r + hl])
|
|
89
|
+
if st == "box":
|
|
90
|
+
h = body.shape_params["half_extents"]
|
|
91
|
+
if R is not None:
|
|
92
|
+
return np.abs(R).dot(h) # exact AABB of rotated OBB
|
|
93
|
+
# Static box: check if it has a non-identity quat
|
|
94
|
+
q = body.quat
|
|
95
|
+
if abs(float(q[0])) > 0.9999:
|
|
96
|
+
return np.asarray(h, dtype=float)
|
|
97
|
+
return np.abs(_quat_to_rot_unit(q)).dot(np.asarray(h, dtype=float))
|
|
98
|
+
if st == "mesh":
|
|
99
|
+
hull_verts = body.shape_params["hull_vertices"] # (K, 3) local
|
|
100
|
+
if R is not None:
|
|
101
|
+
world_verts = hull_verts @ R.T # (K, 3) world
|
|
102
|
+
else:
|
|
103
|
+
Rm = _quat_to_rot_unit(body.quat)
|
|
104
|
+
world_verts = hull_verts @ Rm.T
|
|
105
|
+
return (world_verts.max(axis=0) - world_verts.min(axis=0)) * 0.5 + 0.01
|
|
106
|
+
# plane or unknown: no culling
|
|
107
|
+
return np.full(3, 1e9)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def detect_contacts(
|
|
111
|
+
bodies: list[Any],
|
|
112
|
+
ignored_pairs: "set[frozenset[int]] | None" = None,
|
|
113
|
+
) -> list[ContactPoint]:
|
|
114
|
+
"""Detect all contacts in the current body list.
|
|
115
|
+
|
|
116
|
+
Iterates all (a, b) pairs where a is dynamic.
|
|
117
|
+
Dynamic-dynamic pairs are checked once (i < j).
|
|
118
|
+
Per-step optimisations:
|
|
119
|
+
* R matrices pre-computed once per body (reused across all pairs).
|
|
120
|
+
* AABB broadphase: skip pairs whose world-frame bounding boxes don't
|
|
121
|
+
overlap — eliminates >80% of SAT calls in typical scenes.
|
|
122
|
+
* Contact manifold capped at _MAX_CONTACTS_PER_PAIR.
|
|
123
|
+
"""
|
|
124
|
+
contacts: list[ContactPoint] = []
|
|
125
|
+
n = len(bodies)
|
|
126
|
+
|
|
127
|
+
# Pre-compute rotation matrices and AABB half-extents once per step
|
|
128
|
+
R_cache: list[np.ndarray | None] = [
|
|
129
|
+
None if b.static else _quat_to_rot_unit(b.quat) for b in bodies
|
|
130
|
+
]
|
|
131
|
+
aabb: list[np.ndarray] = [_aabb_half_extents(b, R_cache[i]) for i, b in enumerate(bodies)]
|
|
132
|
+
|
|
133
|
+
for i in range(n):
|
|
134
|
+
a = bodies[i]
|
|
135
|
+
if a.static:
|
|
136
|
+
continue
|
|
137
|
+
aabb_a = aabb[i]
|
|
138
|
+
pos_a = a.pos
|
|
139
|
+
for j in range(n):
|
|
140
|
+
if i == j:
|
|
141
|
+
continue
|
|
142
|
+
b = bodies[j]
|
|
143
|
+
if not b.static and j < i:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# ── Layer/mask filter ──────────────────────────────────────────────
|
|
147
|
+
layer_a = getattr(a, "collision_layer", 0x0001)
|
|
148
|
+
mask_a = getattr(a, "collision_mask", 0xFFFF)
|
|
149
|
+
layer_b = getattr(b, "collision_layer", 0x0001)
|
|
150
|
+
mask_b = getattr(b, "collision_mask", 0xFFFF)
|
|
151
|
+
if not ((layer_a & mask_b) and (layer_b & mask_a)):
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# ── Pair-based ignore ──────────────────────────────────────────────
|
|
155
|
+
if ignored_pairs:
|
|
156
|
+
pair_key = frozenset({a.body_id, b.body_id})
|
|
157
|
+
if pair_key in ignored_pairs:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# ── AABB broadphase ────────────────────────────────────────────────
|
|
161
|
+
# If the axis-aligned bounding boxes don't overlap, no contact possible.
|
|
162
|
+
diff = pos_a - b.pos
|
|
163
|
+
if (
|
|
164
|
+
abs(diff[0]) > aabb_a[0] + aabb[j][0]
|
|
165
|
+
or abs(diff[1]) > aabb_a[1] + aabb[j][1]
|
|
166
|
+
or abs(diff[2]) > aabb_a[2] + aabb[j][2]
|
|
167
|
+
):
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
pts = _dispatch(a, i, b, j, R_cache[i], R_cache[j])
|
|
171
|
+
if len(pts) > _MAX_CONTACTS_PER_PAIR:
|
|
172
|
+
pts.sort(key=lambda c: -c.depth)
|
|
173
|
+
pts = pts[:_MAX_CONTACTS_PER_PAIR]
|
|
174
|
+
contacts.extend(pts)
|
|
175
|
+
return contacts
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _dispatch(
|
|
182
|
+
a: Any,
|
|
183
|
+
ia: int,
|
|
184
|
+
b: Any,
|
|
185
|
+
ib: int,
|
|
186
|
+
R_a: np.ndarray | None,
|
|
187
|
+
R_b: np.ndarray | None,
|
|
188
|
+
) -> list[ContactPoint]:
|
|
189
|
+
"""Route to the correct detection function based on shape types."""
|
|
190
|
+
st_a = a.shape_type
|
|
191
|
+
st_b = b.shape_type
|
|
192
|
+
|
|
193
|
+
# ── sphere ────────────────────────────────────────────────────────────────
|
|
194
|
+
if st_a == "sphere" and st_b == "box":
|
|
195
|
+
return _sphere_vs_obb(a, ia, b, ib, R_b)
|
|
196
|
+
if st_a == "sphere" and st_b == "sphere":
|
|
197
|
+
return _sphere_vs_sphere(a, ia, b, ib)
|
|
198
|
+
if st_a == "sphere" and st_b == "plane":
|
|
199
|
+
return _sphere_vs_plane(a, ia, b, ib)
|
|
200
|
+
|
|
201
|
+
# ── box ───────────────────────────────────────────────────────────────────
|
|
202
|
+
if st_a == "box" and st_b == "sphere":
|
|
203
|
+
pts = _sphere_vs_obb(b, ib, a, ia, R_a)
|
|
204
|
+
return [ContactPoint(ia, ib, p.pos, -p.normal, p.depth) for p in pts]
|
|
205
|
+
if st_a == "box" and st_b == "box":
|
|
206
|
+
return _box_vs_box_sat(a, ia, b, ib, R_a, R_b)
|
|
207
|
+
if st_a == "box" and st_b == "plane":
|
|
208
|
+
return _box_vs_plane(a, ia, b, ib, R_a)
|
|
209
|
+
|
|
210
|
+
# ── capsule ───────────────────────────────────────────────────────────────
|
|
211
|
+
if st_a == "capsule" and st_b == "sphere":
|
|
212
|
+
return _capsule_vs_sphere(a, ia, b, ib)
|
|
213
|
+
if st_a == "capsule" and st_b == "box":
|
|
214
|
+
return _capsule_vs_box(a, ia, b, ib, R_b)
|
|
215
|
+
if st_a == "capsule" and st_b == "capsule":
|
|
216
|
+
return _capsule_vs_capsule(a, ia, b, ib)
|
|
217
|
+
if st_a == "capsule" and st_b == "plane":
|
|
218
|
+
return _capsule_vs_plane(a, ia, b, ib)
|
|
219
|
+
|
|
220
|
+
# ── mesh (convex hull) ────────────────────────────────────────────────────
|
|
221
|
+
if st_a == "mesh" and st_b == "plane":
|
|
222
|
+
return _mesh_vs_plane(a, ia, b, ib, R_a)
|
|
223
|
+
if st_b == "mesh" and st_a == "plane":
|
|
224
|
+
pts = _mesh_vs_plane(b, ib, a, ia, R_b)
|
|
225
|
+
return [ContactPoint(ia, ib, p.pos, -p.normal, p.depth) for p in pts]
|
|
226
|
+
if st_a == "mesh" or st_b == "mesh":
|
|
227
|
+
return _gjk_epa_pair(a, ia, b, ib)
|
|
228
|
+
|
|
229
|
+
# ── reverse-order: if b is dynamic and a is static/different shape ────────
|
|
230
|
+
if st_b == "sphere" and st_a == "capsule":
|
|
231
|
+
pts = _capsule_vs_sphere(b, ib, a, ia)
|
|
232
|
+
return [ContactPoint(ia, ib, p.pos, -p.normal, p.depth) for p in pts]
|
|
233
|
+
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── Half-space helpers ────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _box_top_z(body: Any) -> float:
|
|
241
|
+
"""Top face z of an axis-aligned box (assumes identity rotation for static)."""
|
|
242
|
+
return float(body.pos[2] + body.shape_params["half_extents"][2])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ── Primitive detectors ───────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _sphere_vs_box_halfspace(sphere: Any, ia: int, box: Any, ib: int) -> list[ContactPoint]:
|
|
249
|
+
"""Sphere vs. static box treated as an infinite half-space (top face)."""
|
|
250
|
+
plane_z = _box_top_z(box)
|
|
251
|
+
r = float(sphere.shape_params["radius"])
|
|
252
|
+
depth = r - (float(sphere.pos[2]) - plane_z)
|
|
253
|
+
if depth <= 0.0:
|
|
254
|
+
return []
|
|
255
|
+
normal = np.array([0.0, 0.0, 1.0])
|
|
256
|
+
pos = np.array([float(sphere.pos[0]), float(sphere.pos[1]), plane_z])
|
|
257
|
+
return [ContactPoint(ia, ib, pos, normal, depth)]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _box_vs_box_halfspace(dyn_box: Any, ia: int, static_box: Any, ib: int) -> list[ContactPoint]:
|
|
261
|
+
"""Dynamic box (possibly rotated) vs. static box half-space.
|
|
262
|
+
|
|
263
|
+
Checks all 8 corners of the dynamic box against the plane.
|
|
264
|
+
"""
|
|
265
|
+
plane_z = _box_top_z(static_box)
|
|
266
|
+
he = dyn_box.shape_params["half_extents"]
|
|
267
|
+
R = _quat_to_rot_unit(dyn_box.quat)
|
|
268
|
+
|
|
269
|
+
signs = np.array(
|
|
270
|
+
[
|
|
271
|
+
[1, 1, 1],
|
|
272
|
+
[1, 1, -1],
|
|
273
|
+
[1, -1, 1],
|
|
274
|
+
[1, -1, -1],
|
|
275
|
+
[-1, 1, 1],
|
|
276
|
+
[-1, 1, -1],
|
|
277
|
+
[-1, -1, 1],
|
|
278
|
+
[-1, -1, -1],
|
|
279
|
+
],
|
|
280
|
+
dtype=float,
|
|
281
|
+
)
|
|
282
|
+
corners_world = dyn_box.pos + (signs * he) @ R.T # (8, 3)
|
|
283
|
+
depths = plane_z - corners_world[:, 2]
|
|
284
|
+
penetrating = depths > 0.0
|
|
285
|
+
normal = np.array([0.0, 0.0, 1.0])
|
|
286
|
+
return [
|
|
287
|
+
ContactPoint(
|
|
288
|
+
ia,
|
|
289
|
+
ib,
|
|
290
|
+
np.array([corners_world[k, 0], corners_world[k, 1], plane_z]),
|
|
291
|
+
normal.copy(),
|
|
292
|
+
float(depths[k]),
|
|
293
|
+
)
|
|
294
|
+
for k in np.where(penetrating)[0]
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _sphere_vs_sphere(a: Any, ia: int, b: Any, ib: int) -> list[ContactPoint]:
|
|
299
|
+
"""Dynamic sphere vs. dynamic sphere."""
|
|
300
|
+
diff = a.pos - b.pos
|
|
301
|
+
dist = float(np.linalg.norm(diff))
|
|
302
|
+
r_sum = float(a.shape_params["radius"]) + float(b.shape_params["radius"])
|
|
303
|
+
depth = r_sum - dist
|
|
304
|
+
if depth <= 0.0 or dist < 1e-10:
|
|
305
|
+
return []
|
|
306
|
+
# normal: b → a (push a away from b)
|
|
307
|
+
normal = diff / dist
|
|
308
|
+
contact_pos = b.pos + float(b.shape_params["radius"]) * (-normal)
|
|
309
|
+
return [ContactPoint(ia, ib, contact_pos, normal.copy(), float(depth))]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _box_vs_box_sat(
|
|
313
|
+
dyn_box: Any,
|
|
314
|
+
ia: int,
|
|
315
|
+
other_box: Any,
|
|
316
|
+
ib: int,
|
|
317
|
+
R_a: np.ndarray | None = None,
|
|
318
|
+
R_b: np.ndarray | None = None,
|
|
319
|
+
) -> list[ContactPoint]:
|
|
320
|
+
"""OBB-vs-OBB via Separating Axis Theorem (SAT) with contact manifold.
|
|
321
|
+
|
|
322
|
+
Fully vectorized: all 15 axes projected and tested with batch NumPy ops.
|
|
323
|
+
|
|
324
|
+
Algorithm:
|
|
325
|
+
1. Test 15 potential separating axes (A faces × B faces × edge pairs).
|
|
326
|
+
2. If any axis separates: no contact.
|
|
327
|
+
3. Otherwise, the axis with minimum overlap is the contact normal.
|
|
328
|
+
4. Collect all corners of ``dyn_box`` that lie inside ``other_box``
|
|
329
|
+
as the contact manifold (multi-point for stability).
|
|
330
|
+
"""
|
|
331
|
+
if R_a is None:
|
|
332
|
+
R_a = _quat_to_rot_unit(dyn_box.quat)
|
|
333
|
+
if R_b is None:
|
|
334
|
+
R_b = _quat_to_rot_unit(other_box.quat)
|
|
335
|
+
h_a = dyn_box.shape_params["half_extents"]
|
|
336
|
+
h_b = other_box.shape_params["half_extents"]
|
|
337
|
+
|
|
338
|
+
# Vector from dyn_box center to other_box center
|
|
339
|
+
T = other_box.pos - dyn_box.pos
|
|
340
|
+
|
|
341
|
+
# ── 6 face axes (columns of R_a and R_b) — shape (6, 3) ─────────────────
|
|
342
|
+
face_axes = np.concatenate([R_a.T, R_b.T], axis=0) # (6, 3)
|
|
343
|
+
|
|
344
|
+
# ── 9 edge-edge cross products via broadcasting — no tile/repeat ──────────
|
|
345
|
+
Ra3 = R_a.T[:, None, :] # (3, 1, 3)
|
|
346
|
+
Rb3 = R_b.T[None, :, :] # (1, 3, 3)
|
|
347
|
+
# Inline cross: result (3, 3, 3) → reshape to (9, 3)
|
|
348
|
+
ec = np.empty((3, 3, 3))
|
|
349
|
+
ec[..., 0] = Ra3[..., 1] * Rb3[..., 2] - Ra3[..., 2] * Rb3[..., 1]
|
|
350
|
+
ec[..., 1] = Ra3[..., 2] * Rb3[..., 0] - Ra3[..., 0] * Rb3[..., 2]
|
|
351
|
+
ec[..., 2] = Ra3[..., 0] * Rb3[..., 1] - Ra3[..., 1] * Rb3[..., 0]
|
|
352
|
+
edge_axes = ec.reshape(9, 3)
|
|
353
|
+
edge_lens_sq = (edge_axes**2).sum(axis=1) # (9,) — avoid sqrt when possible
|
|
354
|
+
valid_edge = edge_lens_sq > 1e-16
|
|
355
|
+
if valid_edge.any():
|
|
356
|
+
edge_lens = np.sqrt(edge_lens_sq[valid_edge])
|
|
357
|
+
norm_edge = edge_axes[valid_edge] / edge_lens[:, None]
|
|
358
|
+
all_axes = np.concatenate([face_axes, norm_edge], axis=0) # (6+K, 3)
|
|
359
|
+
else:
|
|
360
|
+
all_axes = face_axes # (6, 3)
|
|
361
|
+
|
|
362
|
+
# ── Batch SAT projection ──────────────────────────────────────────────────
|
|
363
|
+
# r_a[k] = sum_i |axes[k] · R_a[:,i]| * h_a[i]
|
|
364
|
+
r_a_all = (np.abs(all_axes @ R_a) * h_a).sum(axis=1) # (N,)
|
|
365
|
+
r_b_all = (np.abs(all_axes @ R_b) * h_b).sum(axis=1) # (N,)
|
|
366
|
+
t_proj_all = all_axes @ T # (N,)
|
|
367
|
+
depths_all = r_a_all + r_b_all - np.abs(t_proj_all) # (N,)
|
|
368
|
+
|
|
369
|
+
if (depths_all < 0.0).any():
|
|
370
|
+
return [] # separating axis found — no contact
|
|
371
|
+
|
|
372
|
+
min_idx = int(np.argmin(depths_all))
|
|
373
|
+
min_depth = float(depths_all[min_idx])
|
|
374
|
+
axis = all_axes[min_idx]
|
|
375
|
+
t_proj = float(t_proj_all[min_idx])
|
|
376
|
+
# Convention: normal points from other_box (b) toward dyn_box (a)
|
|
377
|
+
contact_normal = (-np.sign(t_proj) * axis) if abs(t_proj) > 1e-10 else axis.copy()
|
|
378
|
+
|
|
379
|
+
# ── Contact manifold: corners of dyn_box inside other_box (vectorized) ───
|
|
380
|
+
# 8 corners of dyn_box in world frame
|
|
381
|
+
signs = np.array(
|
|
382
|
+
[
|
|
383
|
+
[-1, -1, -1],
|
|
384
|
+
[-1, -1, 1],
|
|
385
|
+
[-1, 1, -1],
|
|
386
|
+
[-1, 1, 1],
|
|
387
|
+
[1, -1, -1],
|
|
388
|
+
[1, -1, 1],
|
|
389
|
+
[1, 1, -1],
|
|
390
|
+
[1, 1, 1],
|
|
391
|
+
],
|
|
392
|
+
dtype=float,
|
|
393
|
+
)
|
|
394
|
+
corners_world = dyn_box.pos + (signs * h_a) @ R_a.T # (8, 3)
|
|
395
|
+
c_locals = (corners_world - other_box.pos) @ R_b # (8, 3) world→body
|
|
396
|
+
inside = np.all(np.abs(c_locals) <= h_b + 1e-6, axis=1) # (8,)
|
|
397
|
+
|
|
398
|
+
if inside.any():
|
|
399
|
+
return [
|
|
400
|
+
ContactPoint(ia, ib, corners_world[k].copy(), contact_normal.copy(), min_depth)
|
|
401
|
+
for k in np.where(inside)[0]
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
# Fallback: midpoint contact (edge-edge penetration)
|
|
405
|
+
return [
|
|
406
|
+
ContactPoint(
|
|
407
|
+
ia,
|
|
408
|
+
ib,
|
|
409
|
+
(dyn_box.pos + other_box.pos) * 0.5,
|
|
410
|
+
contact_normal.copy(),
|
|
411
|
+
min_depth,
|
|
412
|
+
)
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ── General sphere vs OBB ─────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _sphere_vs_obb(
|
|
420
|
+
sphere: Any,
|
|
421
|
+
ia: int,
|
|
422
|
+
box: Any,
|
|
423
|
+
ib: int,
|
|
424
|
+
R_b: np.ndarray | None = None,
|
|
425
|
+
) -> list[ContactPoint]:
|
|
426
|
+
"""General sphere vs. OBB (any orientation, any face).
|
|
427
|
+
|
|
428
|
+
Algorithm:
|
|
429
|
+
1. Transform sphere center to box local frame.
|
|
430
|
+
2. Clamp to box half-extents → closest point on box surface.
|
|
431
|
+
3. If distance < radius: contact.
|
|
432
|
+
4. Special case: sphere center inside box → push out along min-overlap axis.
|
|
433
|
+
"""
|
|
434
|
+
r = float(sphere.shape_params["radius"])
|
|
435
|
+
if R_b is None:
|
|
436
|
+
R_b = _quat_to_rot_unit(box.quat)
|
|
437
|
+
h_b = box.shape_params["half_extents"]
|
|
438
|
+
|
|
439
|
+
d = sphere.pos - box.pos
|
|
440
|
+
d_local = R_b.T @ d
|
|
441
|
+
|
|
442
|
+
# Closest point on box to sphere center (in local frame)
|
|
443
|
+
closest_local = np.clip(d_local, -h_b, h_b)
|
|
444
|
+
closest_world = box.pos + R_b @ closest_local
|
|
445
|
+
|
|
446
|
+
diff = sphere.pos - closest_world
|
|
447
|
+
dist = float(np.linalg.norm(diff))
|
|
448
|
+
depth = r - dist
|
|
449
|
+
|
|
450
|
+
if depth <= 0.0:
|
|
451
|
+
return []
|
|
452
|
+
|
|
453
|
+
if dist < 1e-10:
|
|
454
|
+
# Sphere center is inside the box: push out along min-overlap axis
|
|
455
|
+
overlap = h_b - np.abs(d_local)
|
|
456
|
+
min_ax = int(np.argmin(overlap))
|
|
457
|
+
axis_local = np.zeros(3)
|
|
458
|
+
axis_local[min_ax] = np.sign(d_local[min_ax]) if abs(d_local[min_ax]) > 1e-10 else 1.0
|
|
459
|
+
normal = R_b @ axis_local
|
|
460
|
+
depth = float(overlap[min_ax]) + r
|
|
461
|
+
else:
|
|
462
|
+
normal = diff / dist
|
|
463
|
+
|
|
464
|
+
contact_pos = closest_world
|
|
465
|
+
return [ContactPoint(ia, ib, contact_pos.copy(), normal.copy(), float(depth))]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ── Static plane ──────────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _sphere_vs_plane(sphere: Any, ia: int, plane: Any, ib: int) -> list[ContactPoint]:
|
|
472
|
+
"""Sphere vs. infinite half-space defined by plane.normal and plane.pos."""
|
|
473
|
+
n = plane.shape_params["normal"] # unit normal pointing "up" (into free space)
|
|
474
|
+
offset = float(np.dot(plane.shape_params["normal"], plane.pos))
|
|
475
|
+
r = float(sphere.shape_params["radius"])
|
|
476
|
+
signed_dist = float(np.dot(n, sphere.pos)) - offset
|
|
477
|
+
depth = r - signed_dist
|
|
478
|
+
if depth <= 0.0:
|
|
479
|
+
return []
|
|
480
|
+
contact_pos = sphere.pos - signed_dist * n
|
|
481
|
+
n_arr = np.asarray(n, dtype=float)
|
|
482
|
+
return [ContactPoint(ia, ib, contact_pos.copy(), n_arr.copy(), float(depth))]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _box_vs_plane(
|
|
486
|
+
box: Any,
|
|
487
|
+
ia: int,
|
|
488
|
+
plane: Any,
|
|
489
|
+
ib: int,
|
|
490
|
+
R: np.ndarray | None = None,
|
|
491
|
+
) -> list[ContactPoint]:
|
|
492
|
+
"""Box corners vs. infinite half-space plane."""
|
|
493
|
+
n = np.asarray(plane.shape_params["normal"], dtype=float)
|
|
494
|
+
offset = float(np.dot(n, plane.pos))
|
|
495
|
+
if R is None:
|
|
496
|
+
R = _quat_to_rot_unit(box.quat)
|
|
497
|
+
h = box.shape_params["half_extents"]
|
|
498
|
+
signs = np.array(
|
|
499
|
+
[
|
|
500
|
+
[-1, -1, -1],
|
|
501
|
+
[-1, -1, 1],
|
|
502
|
+
[-1, 1, -1],
|
|
503
|
+
[-1, 1, 1],
|
|
504
|
+
[1, -1, -1],
|
|
505
|
+
[1, -1, 1],
|
|
506
|
+
[1, 1, -1],
|
|
507
|
+
[1, 1, 1],
|
|
508
|
+
],
|
|
509
|
+
dtype=float,
|
|
510
|
+
)
|
|
511
|
+
corners = box.pos + (signs * h) @ R.T # (8, 3)
|
|
512
|
+
signed_dists = corners @ n - offset # (8,)
|
|
513
|
+
penetrating = signed_dists < 0.0
|
|
514
|
+
return [
|
|
515
|
+
ContactPoint(ia, ib, corners[k].copy(), n.copy(), float(-signed_dists[k]))
|
|
516
|
+
for k in np.where(penetrating)[0]
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ── Capsule helpers ───────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _capsule_endpoints(cap: Any) -> tuple[np.ndarray, np.ndarray]:
|
|
524
|
+
"""Return (p1, p2) world-frame endpoints of the capsule segment."""
|
|
525
|
+
R = _quat_to_rot_unit(cap.quat)
|
|
526
|
+
axis = R @ np.array([0.0, 0.0, 1.0])
|
|
527
|
+
half_len = float(cap.shape_params["half_length"])
|
|
528
|
+
return cap.pos - half_len * axis, cap.pos + half_len * axis
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _closest_point_on_segment(p: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
532
|
+
"""Closest point on line segment [a,b] to point p."""
|
|
533
|
+
ab = b - a
|
|
534
|
+
ab_sq = float(np.dot(ab, ab))
|
|
535
|
+
if ab_sq < 1e-14:
|
|
536
|
+
return a.copy()
|
|
537
|
+
t = float(np.dot(p - a, ab)) / ab_sq
|
|
538
|
+
return a + np.clip(t, 0.0, 1.0) * ab
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _closest_points_segment_segment(
|
|
542
|
+
a1: np.ndarray, a2: np.ndarray, b1: np.ndarray, b2: np.ndarray
|
|
543
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
544
|
+
"""Closest point pair between two line segments."""
|
|
545
|
+
d1 = a2 - a1
|
|
546
|
+
d2 = b2 - b1
|
|
547
|
+
r = a1 - b1
|
|
548
|
+
e = float(np.dot(d2, d2))
|
|
549
|
+
f = float(np.dot(d2, r))
|
|
550
|
+
|
|
551
|
+
if e < 1e-14:
|
|
552
|
+
tc = 0.0
|
|
553
|
+
sc = float(np.dot(d1, r)) / max(float(np.dot(d1, d1)), 1e-14)
|
|
554
|
+
sc = np.clip(sc, 0.0, 1.0)
|
|
555
|
+
else:
|
|
556
|
+
c_val = float(np.dot(d1, r))
|
|
557
|
+
b_val = float(np.dot(d1, d2))
|
|
558
|
+
a_val = float(np.dot(d1, d1))
|
|
559
|
+
denom = a_val * e - b_val * b_val
|
|
560
|
+
|
|
561
|
+
if abs(denom) < 1e-14:
|
|
562
|
+
sc = 0.0
|
|
563
|
+
else:
|
|
564
|
+
sc = np.clip((b_val * f - c_val * e) / denom, 0.0, 1.0)
|
|
565
|
+
|
|
566
|
+
tc = np.clip((b_val * sc + f) / e, 0.0, 1.0)
|
|
567
|
+
sc = np.clip((b_val * tc - c_val) / max(a_val, 1e-14), 0.0, 1.0)
|
|
568
|
+
|
|
569
|
+
pa = a1 + sc * d1
|
|
570
|
+
pb = b1 + tc * d2
|
|
571
|
+
return pa, pb
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ── Capsule collision pairs ───────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _capsule_vs_sphere(cap: Any, ia: int, sph: Any, ib: int) -> list[ContactPoint]:
|
|
578
|
+
"""Capsule vs. sphere.
|
|
579
|
+
|
|
580
|
+
Convention: body_a=cap (ia), body_b=sph (ib).
|
|
581
|
+
Normal points from body_b (sphere) toward body_a (capsule).
|
|
582
|
+
"""
|
|
583
|
+
p1, p2 = _capsule_endpoints(cap)
|
|
584
|
+
closest = _closest_point_on_segment(sph.pos, p1, p2)
|
|
585
|
+
to_sphere = sph.pos - closest # from capsule axis toward sphere
|
|
586
|
+
dist = float(np.linalg.norm(to_sphere))
|
|
587
|
+
r_sum = float(cap.shape_params["radius"]) + float(sph.shape_params["radius"])
|
|
588
|
+
depth = r_sum - dist
|
|
589
|
+
if depth <= 0.0:
|
|
590
|
+
return []
|
|
591
|
+
# Normal from body_b (sphere) to body_a (capsule) = opposite of to_sphere
|
|
592
|
+
if dist > 1e-10:
|
|
593
|
+
normal = -to_sphere / dist # from sphere toward capsule
|
|
594
|
+
else:
|
|
595
|
+
normal = np.array([0.0, 0.0, 1.0])
|
|
596
|
+
# Contact position on capsule surface in direction of sphere
|
|
597
|
+
contact_pos = closest + float(cap.shape_params["radius"]) * (-normal)
|
|
598
|
+
return [ContactPoint(ia, ib, contact_pos.copy(), normal.copy(), float(depth))]
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _capsule_vs_box(
|
|
602
|
+
cap: Any,
|
|
603
|
+
ia: int,
|
|
604
|
+
box: Any,
|
|
605
|
+
ib: int,
|
|
606
|
+
R_b: np.ndarray | None = None,
|
|
607
|
+
) -> list[ContactPoint]:
|
|
608
|
+
"""Capsule vs. OBB — approximate via closest point on capsule axis to box."""
|
|
609
|
+
p1, p2 = _capsule_endpoints(cap)
|
|
610
|
+
if R_b is None:
|
|
611
|
+
R_b = _quat_to_rot_unit(box.quat)
|
|
612
|
+
h_b = box.shape_params["half_extents"]
|
|
613
|
+
|
|
614
|
+
# Sample points along capsule axis (endpoints + midpoint)
|
|
615
|
+
samples = [p1, (p1 + p2) * 0.5, p2]
|
|
616
|
+
contacts: list[ContactPoint] = []
|
|
617
|
+
seen_depth: set[float] = set()
|
|
618
|
+
|
|
619
|
+
for pt in samples:
|
|
620
|
+
d_local = R_b.T @ (pt - box.pos)
|
|
621
|
+
closest_local = np.clip(d_local, -h_b, h_b)
|
|
622
|
+
closest_world = box.pos + R_b @ closest_local
|
|
623
|
+
diff = pt - closest_world
|
|
624
|
+
dist = float(np.linalg.norm(diff))
|
|
625
|
+
r = float(cap.shape_params["radius"])
|
|
626
|
+
depth = r - dist
|
|
627
|
+
if depth <= 0.0:
|
|
628
|
+
continue
|
|
629
|
+
if dist < 1e-10:
|
|
630
|
+
overlap = h_b - np.abs(d_local)
|
|
631
|
+
min_ax = int(np.argmin(overlap))
|
|
632
|
+
axis_local = np.zeros(3)
|
|
633
|
+
axis_local[min_ax] = np.sign(d_local[min_ax]) if abs(d_local[min_ax]) > 1e-10 else 1.0
|
|
634
|
+
normal = R_b @ axis_local
|
|
635
|
+
depth = float(overlap[min_ax]) + r
|
|
636
|
+
else:
|
|
637
|
+
normal = diff / dist
|
|
638
|
+
key = round(depth, 6)
|
|
639
|
+
if key not in seen_depth:
|
|
640
|
+
seen_depth.add(key)
|
|
641
|
+
contact_pos = closest_world
|
|
642
|
+
contacts.append(ContactPoint(ia, ib, contact_pos.copy(), normal.copy(), float(depth)))
|
|
643
|
+
|
|
644
|
+
return contacts
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _capsule_vs_capsule(cap_a: Any, ia: int, cap_b: Any, ib: int) -> list[ContactPoint]:
|
|
648
|
+
"""Capsule vs. capsule via segment-segment closest points."""
|
|
649
|
+
a1, a2 = _capsule_endpoints(cap_a)
|
|
650
|
+
b1, b2 = _capsule_endpoints(cap_b)
|
|
651
|
+
pa, pb = _closest_points_segment_segment(a1, a2, b1, b2)
|
|
652
|
+
diff = pa - pb
|
|
653
|
+
dist = float(np.linalg.norm(diff))
|
|
654
|
+
r_sum = float(cap_a.shape_params["radius"]) + float(cap_b.shape_params["radius"])
|
|
655
|
+
depth = r_sum - dist
|
|
656
|
+
if depth <= 0.0:
|
|
657
|
+
return []
|
|
658
|
+
normal = diff / dist if dist > 1e-10 else np.array([0.0, 0.0, 1.0])
|
|
659
|
+
contact_pos = pb + float(cap_b.shape_params["radius"]) * normal
|
|
660
|
+
return [ContactPoint(ia, ib, contact_pos.copy(), normal.copy(), float(depth))]
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _capsule_vs_plane(cap: Any, ia: int, plane: Any, ib: int) -> list[ContactPoint]:
|
|
664
|
+
"""Capsule vs. infinite half-space plane."""
|
|
665
|
+
n = np.asarray(plane.shape_params["normal"], dtype=float)
|
|
666
|
+
offset = float(np.dot(n, plane.pos))
|
|
667
|
+
r = float(cap.shape_params["radius"])
|
|
668
|
+
p1, p2 = _capsule_endpoints(cap)
|
|
669
|
+
contacts: list[ContactPoint] = []
|
|
670
|
+
for pt in (p1, p2):
|
|
671
|
+
signed_dist = float(np.dot(n, pt)) - offset
|
|
672
|
+
depth = r - signed_dist
|
|
673
|
+
if depth > 0.0:
|
|
674
|
+
contact_pos = pt - signed_dist * n
|
|
675
|
+
contacts.append(ContactPoint(ia, ib, contact_pos.copy(), n.copy(), float(depth)))
|
|
676
|
+
return contacts
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# ── Mesh (convex hull) detectors ──────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _mesh_vs_plane(
|
|
683
|
+
mesh: Any, ia: int, plane: Any, ib: int, R: np.ndarray | None = None
|
|
684
|
+
) -> list[ContactPoint]:
|
|
685
|
+
"""Convex-hull mesh vs. infinite half-space plane.
|
|
686
|
+
|
|
687
|
+
Tests all hull vertices against the plane and returns penetrating ones.
|
|
688
|
+
"""
|
|
689
|
+
n = np.asarray(plane.shape_params["normal"], dtype=float)
|
|
690
|
+
offset = float(np.dot(n, plane.pos))
|
|
691
|
+
if R is None:
|
|
692
|
+
R = _quat_to_rot_unit(mesh.quat)
|
|
693
|
+
|
|
694
|
+
hull_verts = mesh.shape_params["hull_vertices"] # (K, 3) local frame
|
|
695
|
+
world_verts = mesh.pos + hull_verts @ R.T # (K, 3) world frame
|
|
696
|
+
signed_dists = world_verts @ n - offset # (K,)
|
|
697
|
+
|
|
698
|
+
penetrating = signed_dists < 0.0
|
|
699
|
+
if not penetrating.any():
|
|
700
|
+
return []
|
|
701
|
+
|
|
702
|
+
contacts = [
|
|
703
|
+
ContactPoint(ia, ib, world_verts[k].copy(), n.copy(), float(-signed_dists[k]))
|
|
704
|
+
for k in np.where(penetrating)[0]
|
|
705
|
+
]
|
|
706
|
+
if len(contacts) > _MAX_CONTACTS_PER_PAIR:
|
|
707
|
+
contacts.sort(key=lambda c: -c.depth)
|
|
708
|
+
contacts = contacts[:_MAX_CONTACTS_PER_PAIR]
|
|
709
|
+
return contacts
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _gjk_epa_pair(a: Any, ia: int, b: Any, ib: int) -> list[ContactPoint]:
|
|
713
|
+
"""Generic convex-body collision via GJK + EPA.
|
|
714
|
+
|
|
715
|
+
Used when at least one body is a 'mesh' shape type.
|
|
716
|
+
Returns up to one contact point with the penetration normal from EPA.
|
|
717
|
+
"""
|
|
718
|
+
from forge3d.collision.gjk import gjk_contact
|
|
719
|
+
|
|
720
|
+
result = gjk_contact(a, b)
|
|
721
|
+
if result is None:
|
|
722
|
+
return []
|
|
723
|
+
|
|
724
|
+
depth, normal = result
|
|
725
|
+
if depth <= 0.0:
|
|
726
|
+
return []
|
|
727
|
+
|
|
728
|
+
# Contact position: average of deepest support points
|
|
729
|
+
pa = _body_support_world(a, normal)
|
|
730
|
+
pb = _body_support_world(b, -normal)
|
|
731
|
+
contact_pos = (pa + pb) * 0.5
|
|
732
|
+
return [ContactPoint(ia, ib, contact_pos, normal.copy(), float(depth))]
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _body_support_world(body: Any, d: np.ndarray) -> np.ndarray:
|
|
736
|
+
"""World-frame support point for a body in direction d."""
|
|
737
|
+
from forge3d.collision.gjk import _body_support
|
|
738
|
+
|
|
739
|
+
return _body_support(body, d)
|