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