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