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/collision/epa.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Expanding Polytope Algorithm (EPA) for penetration depth and contact normal.
|
|
2
|
+
|
|
3
|
+
Given two intersecting convex shapes and a GJK simplex that encloses the
|
|
4
|
+
Minkowski-difference origin, EPA expands the simplex into a full polytope
|
|
5
|
+
and finds the minimum-penetration-depth contact.
|
|
6
|
+
|
|
7
|
+
Returns:
|
|
8
|
+
depth : float — penetration depth (> 0)
|
|
9
|
+
normal : (3,) ndarray — contact normal from body_b toward body_a
|
|
10
|
+
|
|
11
|
+
Convention: normal points FROM b TOWARD a (same as ContactPoint).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _face_normal_and_dist(
|
|
24
|
+
v0: np.ndarray, v1: np.ndarray, v2: np.ndarray
|
|
25
|
+
) -> tuple[np.ndarray, float]:
|
|
26
|
+
"""Outward face normal and distance from origin for one triangle.
|
|
27
|
+
|
|
28
|
+
'Outward' means pointing AWAY from the interior of the polytope,
|
|
29
|
+
i.e. the signed distance dot(normal, v0) should be positive when
|
|
30
|
+
origin is inside.
|
|
31
|
+
"""
|
|
32
|
+
edge1 = v1 - v0
|
|
33
|
+
edge2 = v2 - v0
|
|
34
|
+
n = np.cross(edge1, edge2)
|
|
35
|
+
n_len = float(np.linalg.norm(n))
|
|
36
|
+
if n_len < 1e-14:
|
|
37
|
+
return np.array([0.0, 0.0, 1.0]), 1e9
|
|
38
|
+
n = n / n_len
|
|
39
|
+
d = float(np.dot(n, v0))
|
|
40
|
+
if d < 0.0: # flip so normal points away from origin
|
|
41
|
+
n = -n
|
|
42
|
+
d = -d
|
|
43
|
+
return n, d
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _closest_face(
|
|
47
|
+
verts: list[np.ndarray], faces: list[tuple[int, int, int]]
|
|
48
|
+
) -> tuple[int, np.ndarray, float]:
|
|
49
|
+
"""Find the face (index, normal, dist) closest to the origin."""
|
|
50
|
+
min_dist = float("inf")
|
|
51
|
+
min_idx = 0
|
|
52
|
+
min_normal = np.array([0.0, 0.0, 1.0])
|
|
53
|
+
|
|
54
|
+
for i, (a, b, c) in enumerate(faces):
|
|
55
|
+
n, d = _face_normal_and_dist(verts[a], verts[b], verts[c])
|
|
56
|
+
if d < min_dist:
|
|
57
|
+
min_dist = d
|
|
58
|
+
min_idx = i
|
|
59
|
+
min_normal = n
|
|
60
|
+
|
|
61
|
+
return min_idx, min_normal, min_dist
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_initial_polytope(
|
|
65
|
+
simplex: list[np.ndarray],
|
|
66
|
+
cso_fn: Any, # callable(d) → point on CSO
|
|
67
|
+
) -> tuple[list[np.ndarray], list[tuple[int, int, int]]]:
|
|
68
|
+
"""Turn a GJK simplex into an initial tetrahedron polytope.
|
|
69
|
+
|
|
70
|
+
GJK may return a simplex with fewer than 4 vertices when the origin is
|
|
71
|
+
near a face/edge of the simplex. We expand it here to a full tetrahedron.
|
|
72
|
+
"""
|
|
73
|
+
verts = list(simplex)
|
|
74
|
+
|
|
75
|
+
# Expand to at least 4 non-coplanar vertices
|
|
76
|
+
directions = [
|
|
77
|
+
np.array([1.0, 0.0, 0.0]),
|
|
78
|
+
np.array([-1.0, 0.0, 0.0]),
|
|
79
|
+
np.array([0.0, 1.0, 0.0]),
|
|
80
|
+
np.array([0.0, -1.0, 0.0]),
|
|
81
|
+
np.array([0.0, 0.0, 1.0]),
|
|
82
|
+
np.array([0.0, 0.0, -1.0]),
|
|
83
|
+
]
|
|
84
|
+
while len(verts) < 4:
|
|
85
|
+
best = None
|
|
86
|
+
best_dist = -1.0
|
|
87
|
+
for d in directions:
|
|
88
|
+
pt = cso_fn(d)
|
|
89
|
+
dist = float(np.dot(pt, d))
|
|
90
|
+
if best is None or dist > best_dist:
|
|
91
|
+
best_dist = dist
|
|
92
|
+
best = pt
|
|
93
|
+
if best is None:
|
|
94
|
+
break
|
|
95
|
+
# Only add if it doesn't duplicate an existing vertex
|
|
96
|
+
if all(float(np.linalg.norm(best - v)) > 1e-7 for v in verts):
|
|
97
|
+
verts.append(best)
|
|
98
|
+
else:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if len(verts) < 4:
|
|
102
|
+
# Degenerate: can't form a tetrahedron
|
|
103
|
+
return verts, []
|
|
104
|
+
|
|
105
|
+
# Build 4 triangular faces from tetrahedron (A, B, C, D).
|
|
106
|
+
# Ensure each face normal points outward (away from opposite vertex).
|
|
107
|
+
A, B, C, D = verts[:4]
|
|
108
|
+
faces: list[tuple[int, int, int]] = []
|
|
109
|
+
|
|
110
|
+
def add_face(i0: int, i1: int, i2: int, opposite: np.ndarray) -> None:
|
|
111
|
+
n, _ = _face_normal_and_dist(verts[i0], verts[i1], verts[i2])
|
|
112
|
+
# If normal points TOWARD opposite vertex, flip winding
|
|
113
|
+
if np.dot(n, opposite - verts[i0]) > 0:
|
|
114
|
+
faces.append((i0, i2, i1)) # reversed winding
|
|
115
|
+
else:
|
|
116
|
+
faces.append((i0, i1, i2))
|
|
117
|
+
|
|
118
|
+
add_face(0, 1, 2, D) # ABC, opposite D
|
|
119
|
+
add_face(0, 1, 3, C) # ABD, opposite C
|
|
120
|
+
add_face(0, 2, 3, B) # ACD, opposite B
|
|
121
|
+
add_face(1, 2, 3, A) # BCD, opposite A
|
|
122
|
+
|
|
123
|
+
return verts, faces
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _expand_polytope(
|
|
127
|
+
verts: list[np.ndarray],
|
|
128
|
+
faces: list[tuple[int, int, int]],
|
|
129
|
+
new_pt: np.ndarray,
|
|
130
|
+
closest_face_idx: int,
|
|
131
|
+
) -> tuple[list[np.ndarray], list[tuple[int, int, int]]]:
|
|
132
|
+
"""Remove faces visible from new_pt and fill the hole.
|
|
133
|
+
|
|
134
|
+
A face is 'visible' from new_pt if new_pt is on the outward side of the face.
|
|
135
|
+
"""
|
|
136
|
+
# Collect edges on the silhouette (border between visible/non-visible faces)
|
|
137
|
+
edge_count: dict[tuple[int, int], int] = {}
|
|
138
|
+
|
|
139
|
+
visible: set[int] = set()
|
|
140
|
+
for i, (a, b, c) in enumerate(faces):
|
|
141
|
+
n, _ = _face_normal_and_dist(verts[a], verts[b], verts[c])
|
|
142
|
+
if np.dot(n, new_pt - verts[a]) > 1e-10:
|
|
143
|
+
visible.add(i)
|
|
144
|
+
for ea, eb in [(a, b), (b, c), (c, a)]:
|
|
145
|
+
key = (min(ea, eb), max(ea, eb))
|
|
146
|
+
edge_count[key] = edge_count.get(key, 0) + 1
|
|
147
|
+
|
|
148
|
+
silhouette = [edge for edge, cnt in edge_count.items() if cnt == 1]
|
|
149
|
+
|
|
150
|
+
new_v_idx = len(verts)
|
|
151
|
+
verts.append(new_pt)
|
|
152
|
+
|
|
153
|
+
# Keep non-visible faces
|
|
154
|
+
new_faces = [f for i, f in enumerate(faces) if i not in visible]
|
|
155
|
+
|
|
156
|
+
# Add new triangles from silhouette to new_pt
|
|
157
|
+
for ea, eb in silhouette:
|
|
158
|
+
# Find correct winding so normal points away from origin
|
|
159
|
+
n, _ = _face_normal_and_dist(verts[ea], verts[eb], new_pt)
|
|
160
|
+
# normal should have positive dot with vertices (origin is inside polytope)
|
|
161
|
+
if np.dot(n, verts[ea]) < 0:
|
|
162
|
+
new_faces.append((ea, new_v_idx, eb))
|
|
163
|
+
else:
|
|
164
|
+
new_faces.append((ea, eb, new_v_idx))
|
|
165
|
+
|
|
166
|
+
return verts, new_faces
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Public EPA interface ──────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def epa(
|
|
173
|
+
body_a: Any,
|
|
174
|
+
body_b: Any,
|
|
175
|
+
gjk_simplex: list[np.ndarray],
|
|
176
|
+
max_iter: int = 64,
|
|
177
|
+
tolerance: float = 1e-5,
|
|
178
|
+
) -> tuple[float, np.ndarray]:
|
|
179
|
+
"""Run EPA to find penetration depth and contact normal.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
body_a, body_b : duck-typed bodies with shape_type, pos, quat, shape_params.
|
|
184
|
+
gjk_simplex : simplex from GJK (must enclose the origin).
|
|
185
|
+
max_iter : maximum expansion iterations.
|
|
186
|
+
tolerance : convergence threshold.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
depth : penetration depth (> 0).
|
|
191
|
+
normal : unit contact normal from body_b toward body_a.
|
|
192
|
+
"""
|
|
193
|
+
from forge3d.collision.gjk import _cso_support
|
|
194
|
+
|
|
195
|
+
def cso_fn(d: np.ndarray) -> np.ndarray:
|
|
196
|
+
n = float(np.linalg.norm(d))
|
|
197
|
+
return _cso_support(body_a, body_b, d / n if n > 1e-12 else d)
|
|
198
|
+
|
|
199
|
+
verts, faces = _build_initial_polytope(gjk_simplex, cso_fn)
|
|
200
|
+
|
|
201
|
+
if len(faces) == 0:
|
|
202
|
+
# Degenerate — return a simple upward normal
|
|
203
|
+
return 0.01, np.array([0.0, 0.0, 1.0])
|
|
204
|
+
|
|
205
|
+
for _ in range(max_iter):
|
|
206
|
+
closest_idx, normal, dist = _closest_face(verts, faces)
|
|
207
|
+
|
|
208
|
+
new_pt = cso_fn(normal)
|
|
209
|
+
new_dist = float(np.dot(new_pt, normal))
|
|
210
|
+
|
|
211
|
+
if new_dist - dist < tolerance:
|
|
212
|
+
return dist, normal
|
|
213
|
+
|
|
214
|
+
verts, faces = _expand_polytope(verts, faces, new_pt, closest_idx)
|
|
215
|
+
|
|
216
|
+
closest_idx, normal, dist = _closest_face(verts, faces)
|
|
217
|
+
return dist, normal
|
forge3d/collision/gjk.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Gilbert-Johnson-Keerthi (GJK) distance algorithm.
|
|
2
|
+
|
|
3
|
+
Determines whether two convex shapes intersect and, if not, computes the
|
|
4
|
+
minimum distance between them.
|
|
5
|
+
|
|
6
|
+
Supported shapes:
|
|
7
|
+
sphere — represented by (center, radius)
|
|
8
|
+
box OBB — represented by (position, rotation, half_extents)
|
|
9
|
+
mesh/capsule — convex hull support via hull_vertices in shape_params
|
|
10
|
+
|
|
11
|
+
Algorithm:
|
|
12
|
+
Build a simplex in the Minkowski difference A ⊖ B. If the simplex encloses
|
|
13
|
+
the origin, the shapes intersect. Otherwise, the distance equals the length
|
|
14
|
+
of the nearest-point vector from the simplex to the origin.
|
|
15
|
+
|
|
16
|
+
Convention:
|
|
17
|
+
Normal points from shape_b toward shape_a (consistent with ContactPoint).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
# ── Support functions ─────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _support_sphere(center: np.ndarray, radius: float, d: np.ndarray) -> np.ndarray:
|
|
30
|
+
n = np.linalg.norm(d)
|
|
31
|
+
if n < 1e-12:
|
|
32
|
+
return center + np.array([radius, 0.0, 0.0])
|
|
33
|
+
return center + (radius / n) * d
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _support_box(pos: np.ndarray, R: np.ndarray, he: np.ndarray, d: np.ndarray) -> np.ndarray:
|
|
37
|
+
"""Furthest point of an OBB in direction d."""
|
|
38
|
+
d_local = R.T @ d
|
|
39
|
+
s_local = np.where(d_local >= 0, he, -he)
|
|
40
|
+
return pos + R @ s_local
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _support_convex_hull(
|
|
44
|
+
pos: np.ndarray, R: np.ndarray, hull_verts: np.ndarray, d: np.ndarray
|
|
45
|
+
) -> np.ndarray:
|
|
46
|
+
"""Furthest point of a convex hull in world-frame direction d.
|
|
47
|
+
|
|
48
|
+
hull_verts is (K, 3) in LOCAL frame. O(K) — fast for small hulls.
|
|
49
|
+
"""
|
|
50
|
+
d_local = R.T @ d # direction in local frame
|
|
51
|
+
dots = hull_verts @ d_local # (K,) — vectorised
|
|
52
|
+
return pos + R @ hull_verts[int(np.argmax(dots))]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _body_support(body: Any, d: np.ndarray) -> np.ndarray:
|
|
56
|
+
"""Dispatch to the correct support function for a body."""
|
|
57
|
+
from forge3d.math.quaternion import quat_to_rot
|
|
58
|
+
|
|
59
|
+
if body.shape_type == "sphere":
|
|
60
|
+
return _support_sphere(body.pos, float(body.shape_params["radius"]), d)
|
|
61
|
+
if body.shape_type == "box":
|
|
62
|
+
R = quat_to_rot(body.quat)
|
|
63
|
+
return _support_box(body.pos, R, body.shape_params["half_extents"], d)
|
|
64
|
+
if body.shape_type in ("mesh", "capsule"):
|
|
65
|
+
R = quat_to_rot(body.quat)
|
|
66
|
+
hull_verts = body.shape_params["hull_vertices"]
|
|
67
|
+
return _support_convex_hull(body.pos, R, hull_verts, d)
|
|
68
|
+
raise ValueError(f"GJK: unsupported shape '{body.shape_type}'")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cso_support(a: Any, b: Any, d: np.ndarray) -> np.ndarray:
|
|
72
|
+
"""Minkowski difference support: support_a(d) - support_b(-d)."""
|
|
73
|
+
return _body_support(a, d) - _body_support(b, -d)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Simplex nearest-point helpers ─────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _nearest_on_line(p0: np.ndarray, p1: np.ndarray) -> tuple[list[np.ndarray], np.ndarray]:
|
|
80
|
+
"""Nearest point on segment [p0, p1] to origin. p1 is the newest vertex."""
|
|
81
|
+
p01 = p0 - p1
|
|
82
|
+
t = float(np.dot(-p1, p01)) / float(np.dot(p01, p01) + 1e-300)
|
|
83
|
+
t = max(0.0, min(1.0, t))
|
|
84
|
+
nearest = p1 + t * p01
|
|
85
|
+
return [p0, p1] if t > 0.0 else [p1], -nearest
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _nearest_on_triangle(
|
|
89
|
+
p0: np.ndarray, p1: np.ndarray, p2: np.ndarray
|
|
90
|
+
) -> tuple[list[np.ndarray], np.ndarray] | None:
|
|
91
|
+
"""Nearest feature of triangle [p0, p1, p2] to origin (p2 newest).
|
|
92
|
+
|
|
93
|
+
Returns (reduced_simplex, direction) or None if origin inside the infinite
|
|
94
|
+
prism defined by the triangle (used when testing tetrahedron faces).
|
|
95
|
+
"""
|
|
96
|
+
ab = p1 - p2
|
|
97
|
+
ac = p0 - p2
|
|
98
|
+
ao = -p2
|
|
99
|
+
n = np.cross(ab, ac)
|
|
100
|
+
|
|
101
|
+
# Check if origin is outside each edge (in the plane of the triangle)
|
|
102
|
+
if np.dot(np.cross(ab, n), ao) > 0:
|
|
103
|
+
# Outside edge AB (between p2 and p1)
|
|
104
|
+
if np.dot(ab, ao) > 0:
|
|
105
|
+
d = np.cross(np.cross(ab, ao), ab)
|
|
106
|
+
# parenthesise to avoid 3-tuple mis-parse
|
|
107
|
+
return ([p1, p2], d) if float(np.dot(d, d)) > 1e-20 else ([p1, p2], ao)
|
|
108
|
+
return _nearest_on_line(p2, p2) # fallback: just vertex p2
|
|
109
|
+
|
|
110
|
+
if np.dot(np.cross(n, ac), ao) > 0:
|
|
111
|
+
# Outside edge AC (between p2 and p0)
|
|
112
|
+
if np.dot(ac, ao) > 0:
|
|
113
|
+
d = np.cross(np.cross(ac, ao), ac)
|
|
114
|
+
return ([p0, p2], d) if float(np.dot(d, d)) > 1e-20 else ([p0, p2], ao)
|
|
115
|
+
return [p2], ao
|
|
116
|
+
|
|
117
|
+
# Inside the triangle in the plane — check above/below face
|
|
118
|
+
dn = float(np.dot(n, ao))
|
|
119
|
+
if dn > 0:
|
|
120
|
+
return [p0, p1, p2], n
|
|
121
|
+
if dn < 0:
|
|
122
|
+
# Flip winding so normal points toward origin
|
|
123
|
+
return [p1, p0, p2], -n
|
|
124
|
+
# Origin ON the face
|
|
125
|
+
return None # contained
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _do_simplex(
|
|
129
|
+
simplex: list[np.ndarray], d: np.ndarray
|
|
130
|
+
) -> tuple[list[np.ndarray], np.ndarray] | None:
|
|
131
|
+
"""Evolve the GJK simplex toward origin.
|
|
132
|
+
|
|
133
|
+
Returns (new_simplex, new_direction) when not yet enclosed, or None when
|
|
134
|
+
the simplex contains the origin (→ intersection).
|
|
135
|
+
"""
|
|
136
|
+
n = len(simplex)
|
|
137
|
+
|
|
138
|
+
if n == 1:
|
|
139
|
+
return simplex, -simplex[0]
|
|
140
|
+
|
|
141
|
+
if n == 2:
|
|
142
|
+
return _nearest_on_line(simplex[0], simplex[1])
|
|
143
|
+
|
|
144
|
+
if n == 3:
|
|
145
|
+
result = _nearest_on_triangle(simplex[0], simplex[1], simplex[2])
|
|
146
|
+
return result # None → origin inside triangle (treat as intersection)
|
|
147
|
+
|
|
148
|
+
# n == 4: tetrahedron
|
|
149
|
+
D, C, B, A = simplex
|
|
150
|
+
AB = B - A
|
|
151
|
+
AC = C - A
|
|
152
|
+
AD = D - A
|
|
153
|
+
AO = -A
|
|
154
|
+
|
|
155
|
+
# Outward face normals (re-orient so they point away from the opposite vertex)
|
|
156
|
+
ABC = np.cross(AB, AC)
|
|
157
|
+
ACD = np.cross(AC, AD)
|
|
158
|
+
ADB = np.cross(AD, AB)
|
|
159
|
+
if np.dot(ABC, AD) > 0:
|
|
160
|
+
ABC = -ABC
|
|
161
|
+
if np.dot(ACD, AB) > 0:
|
|
162
|
+
ACD = -ACD
|
|
163
|
+
if np.dot(ADB, AC) > 0:
|
|
164
|
+
ADB = -ADB
|
|
165
|
+
|
|
166
|
+
in_abc = np.dot(ABC, AO) > 0
|
|
167
|
+
in_acd = np.dot(ACD, AO) > 0
|
|
168
|
+
in_adb = np.dot(ADB, AO) > 0
|
|
169
|
+
|
|
170
|
+
if not in_abc and not in_acd and not in_adb:
|
|
171
|
+
return None # origin inside tetrahedron → intersection!
|
|
172
|
+
|
|
173
|
+
# Reduce to the face that the origin is "most in front of"
|
|
174
|
+
if in_abc:
|
|
175
|
+
return _nearest_on_triangle(C, B, A)
|
|
176
|
+
if in_acd:
|
|
177
|
+
return _nearest_on_triangle(D, C, A)
|
|
178
|
+
return _nearest_on_triangle(B, D, A)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── Internal GJK returning simplex ───────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _gjk_internal(
|
|
185
|
+
body_a: Any, body_b: Any, max_iter: int = 64
|
|
186
|
+
) -> tuple[bool, float, list[np.ndarray]]:
|
|
187
|
+
"""Run GJK and return (intersecting, distance, final_simplex).
|
|
188
|
+
|
|
189
|
+
The simplex is needed by EPA when bodies intersect.
|
|
190
|
+
"""
|
|
191
|
+
d = body_b.pos - body_a.pos
|
|
192
|
+
if float(np.linalg.norm(d)) < 1e-10:
|
|
193
|
+
d = np.array([1.0, 0.0, 0.0])
|
|
194
|
+
d = d / np.linalg.norm(d)
|
|
195
|
+
|
|
196
|
+
s = _cso_support(body_a, body_b, d)
|
|
197
|
+
simplex: list[np.ndarray] = [s]
|
|
198
|
+
d = -s
|
|
199
|
+
|
|
200
|
+
for _ in range(max_iter):
|
|
201
|
+
d_len = float(np.linalg.norm(d))
|
|
202
|
+
if d_len < 1e-12:
|
|
203
|
+
return True, 0.0, simplex
|
|
204
|
+
|
|
205
|
+
new_pt = _cso_support(body_a, body_b, d / d_len)
|
|
206
|
+
if float(np.dot(new_pt, d)) < 0:
|
|
207
|
+
return False, d_len, simplex
|
|
208
|
+
|
|
209
|
+
simplex.append(new_pt)
|
|
210
|
+
result = _do_simplex(simplex, d)
|
|
211
|
+
|
|
212
|
+
if result is None:
|
|
213
|
+
return True, 0.0, simplex
|
|
214
|
+
|
|
215
|
+
simplex, d = result
|
|
216
|
+
|
|
217
|
+
return True, 0.0, simplex
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── Public GJK interface ──────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def gjk(body_a: Any, body_b: Any, max_iter: int = 64) -> tuple[bool, float]:
|
|
224
|
+
"""Run GJK between two convex bodies.
|
|
225
|
+
|
|
226
|
+
Parameters
|
|
227
|
+
----------
|
|
228
|
+
body_a, body_b : bodies with ``shape_type``, ``pos``, ``quat``,
|
|
229
|
+
``shape_params`` attributes (duck-typed _Body).
|
|
230
|
+
max_iter : maximum GJK iterations (default 64).
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
intersecting : True if bodies overlap.
|
|
235
|
+
distance : 0.0 if intersecting; minimum separation otherwise.
|
|
236
|
+
"""
|
|
237
|
+
intersecting, dist, _ = _gjk_internal(body_a, body_b, max_iter)
|
|
238
|
+
return intersecting, dist
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def gjk_contact(
|
|
242
|
+
body_a: Any, body_b: Any, max_iter: int = 64
|
|
243
|
+
) -> tuple[float, np.ndarray] | None:
|
|
244
|
+
"""GJK + EPA contact query.
|
|
245
|
+
|
|
246
|
+
Returns (depth, normal) where normal points from body_b toward body_a,
|
|
247
|
+
or None if bodies are not intersecting.
|
|
248
|
+
"""
|
|
249
|
+
from forge3d.collision.epa import epa
|
|
250
|
+
|
|
251
|
+
intersecting, _, simplex = _gjk_internal(body_a, body_b, max_iter)
|
|
252
|
+
if not intersecting:
|
|
253
|
+
return None
|
|
254
|
+
return epa(body_a, body_b, simplex)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def gjk_intersect(body_a: Any, body_b: Any) -> bool:
|
|
258
|
+
"""Boolean intersection test. True iff bodies overlap."""
|
|
259
|
+
intersecting, _ = gjk(body_a, body_b)
|
|
260
|
+
return intersecting
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def gjk_distance(body_a: Any, body_b: Any) -> float:
|
|
264
|
+
"""Minimum separation distance. Returns 0.0 if bodies overlap."""
|
|
265
|
+
_, dist = gjk(body_a, body_b)
|
|
266
|
+
return dist
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Heightfield terrain collision shape.
|
|
2
|
+
|
|
3
|
+
Represents a terrain as a 2D grid of height values.
|
|
4
|
+
Supports sphere and box collision against the terrain surface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from forge3d.collision.detection import ContactPoint
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Heightfield:
|
|
20
|
+
"""A 2D grid of height values representing terrain.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
heights: 2D float32 array of shape (rows, cols). heights[r, c] is
|
|
24
|
+
the terrain height (z) at grid cell (r, c).
|
|
25
|
+
cell_size: World-space size of each grid cell (m).
|
|
26
|
+
origin: World-space position of the (0, 0) grid corner (x, y, z).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
heights: np.ndarray # (rows, cols) float32
|
|
30
|
+
cell_size: float
|
|
31
|
+
origin: np.ndarray # (3,) float64 — world position of grid corner
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def rows(self) -> int:
|
|
35
|
+
return self.heights.shape[0]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def cols(self) -> int:
|
|
39
|
+
return self.heights.shape[1]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def total_width(self) -> float:
|
|
43
|
+
return self.cell_size * (self.cols - 1)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def total_depth(self) -> float:
|
|
47
|
+
return self.cell_size * (self.rows - 1)
|
|
48
|
+
|
|
49
|
+
def height_at(self, x: float, y: float) -> float:
|
|
50
|
+
"""Bilinearly interpolated height at world-space (x, y)."""
|
|
51
|
+
lx = x - self.origin[0]
|
|
52
|
+
ly = y - self.origin[1]
|
|
53
|
+
|
|
54
|
+
# Grid coordinates (float)
|
|
55
|
+
gx = lx / self.cell_size
|
|
56
|
+
gy = ly / self.cell_size
|
|
57
|
+
|
|
58
|
+
# Integer cell indices
|
|
59
|
+
c0 = int(np.floor(gx))
|
|
60
|
+
r0 = int(np.floor(gy))
|
|
61
|
+
|
|
62
|
+
# Clamp to valid range
|
|
63
|
+
c0 = max(0, min(c0, self.cols - 2))
|
|
64
|
+
r0 = max(0, min(r0, self.rows - 2))
|
|
65
|
+
c1 = c0 + 1
|
|
66
|
+
r1 = r0 + 1
|
|
67
|
+
|
|
68
|
+
# Bilinear interpolation weights
|
|
69
|
+
tx = gx - c0
|
|
70
|
+
ty = gy - r0
|
|
71
|
+
tx = max(0.0, min(1.0, tx))
|
|
72
|
+
ty = max(0.0, min(1.0, ty))
|
|
73
|
+
|
|
74
|
+
h00 = float(self.heights[r0, c0])
|
|
75
|
+
h10 = float(self.heights[r1, c0])
|
|
76
|
+
h01 = float(self.heights[r0, c1])
|
|
77
|
+
h11 = float(self.heights[r1, c1])
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
h00 * (1 - tx) * (1 - ty)
|
|
81
|
+
+ h01 * tx * (1 - ty)
|
|
82
|
+
+ h10 * (1 - tx) * ty
|
|
83
|
+
+ h11 * tx * ty
|
|
84
|
+
) + float(self.origin[2])
|
|
85
|
+
|
|
86
|
+
def normal_at(self, x: float, y: float) -> np.ndarray:
|
|
87
|
+
"""Approximate surface normal at (x, y) from finite differences."""
|
|
88
|
+
dx = self.cell_size
|
|
89
|
+
hx0 = self.height_at(x - dx, y)
|
|
90
|
+
hx1 = self.height_at(x + dx, y)
|
|
91
|
+
hy0 = self.height_at(x, y - dx)
|
|
92
|
+
hy1 = self.height_at(x, y + dx)
|
|
93
|
+
|
|
94
|
+
dhx = (hx1 - hx0) / (2 * dx)
|
|
95
|
+
dhy = (hy1 - hy0) / (2 * dx)
|
|
96
|
+
|
|
97
|
+
n = np.array([-dhx, -dhy, 1.0])
|
|
98
|
+
norm = np.linalg.norm(n)
|
|
99
|
+
return n / norm if norm > 1e-10 else np.array([0.0, 0.0, 1.0])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def sphere_vs_heightfield(
|
|
103
|
+
sphere_body: Any,
|
|
104
|
+
sphere_idx: int,
|
|
105
|
+
hf: "Heightfield",
|
|
106
|
+
) -> list["ContactPoint"]:
|
|
107
|
+
"""Collision between a sphere and a heightfield terrain.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
sphere_body: A ``_Body`` with ``shape_type == "sphere"``.
|
|
111
|
+
sphere_idx: List index of the sphere body.
|
|
112
|
+
hf: The heightfield to test against.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of ``ContactPoint`` objects (0 or 1 contacts).
|
|
116
|
+
"""
|
|
117
|
+
from forge3d.collision.detection import ContactPoint
|
|
118
|
+
|
|
119
|
+
pos = sphere_body.pos
|
|
120
|
+
radius = float(sphere_body.shape_params["radius"])
|
|
121
|
+
|
|
122
|
+
x, y = float(pos[0]), float(pos[1])
|
|
123
|
+
h_terrain = hf.height_at(x, y)
|
|
124
|
+
depth = h_terrain + radius - float(pos[2])
|
|
125
|
+
|
|
126
|
+
if depth <= 0:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
normal = hf.normal_at(x, y) # points up (z+)
|
|
130
|
+
contact_pos = np.array([x, y, h_terrain])
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
ContactPoint(
|
|
134
|
+
body_a_idx=sphere_idx,
|
|
135
|
+
body_b_idx=-1, # static terrain
|
|
136
|
+
pos=contact_pos,
|
|
137
|
+
normal=normal, # pushes sphere up
|
|
138
|
+
depth=depth,
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def box_vs_heightfield(
|
|
144
|
+
box_body: Any,
|
|
145
|
+
box_idx: int,
|
|
146
|
+
hf: "Heightfield",
|
|
147
|
+
) -> list["ContactPoint"]:
|
|
148
|
+
"""Collision between a box and a heightfield terrain.
|
|
149
|
+
|
|
150
|
+
Approximates the box as 8 corner point samples against the heightfield.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
box_body: A ``_Body`` with ``shape_type == "box"``.
|
|
154
|
+
box_idx: List index of the box body.
|
|
155
|
+
hf: The heightfield to test against.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of ``ContactPoint`` objects (0 to 4 contacts, highest penetration).
|
|
159
|
+
"""
|
|
160
|
+
from forge3d.collision.detection import ContactPoint
|
|
161
|
+
from forge3d.math.quaternion import quat_to_rot
|
|
162
|
+
|
|
163
|
+
he = box_body.shape_params["half_extents"]
|
|
164
|
+
R = quat_to_rot(box_body.quat)
|
|
165
|
+
pos = box_body.pos
|
|
166
|
+
|
|
167
|
+
# 8 corners of the box in world frame
|
|
168
|
+
sx = [-1, 1]
|
|
169
|
+
corners_local = [
|
|
170
|
+
np.array([x * he[0], y * he[1], z * he[2]])
|
|
171
|
+
for x in sx for y in sx for z in sx
|
|
172
|
+
]
|
|
173
|
+
corners_world = [pos + R @ c for c in corners_local]
|
|
174
|
+
|
|
175
|
+
contacts: list[ContactPoint] = []
|
|
176
|
+
for corner in corners_world:
|
|
177
|
+
x, y = float(corner[0]), float(corner[1])
|
|
178
|
+
h = hf.height_at(x, y)
|
|
179
|
+
depth = h - float(corner[2])
|
|
180
|
+
if depth > 0:
|
|
181
|
+
normal = hf.normal_at(x, y)
|
|
182
|
+
contacts.append(
|
|
183
|
+
ContactPoint(
|
|
184
|
+
body_a_idx=box_idx,
|
|
185
|
+
body_b_idx=-1,
|
|
186
|
+
pos=np.array([x, y, h]),
|
|
187
|
+
normal=normal,
|
|
188
|
+
depth=depth,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Return up to 4 deepest contacts
|
|
193
|
+
if len(contacts) > 4:
|
|
194
|
+
contacts.sort(key=lambda c: -c.depth)
|
|
195
|
+
contacts = contacts[:4]
|
|
196
|
+
return contacts
|