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/facade.py
ADDED
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
"""forge3d public Facade — World, Body, Shape, Material.
|
|
2
|
+
|
|
3
|
+
"Easy like pygame, beautiful like simulation."
|
|
4
|
+
Coordinate system: z-up, SI units.
|
|
5
|
+
|
|
6
|
+
Users only need::
|
|
7
|
+
|
|
8
|
+
import forge3d as f3d
|
|
9
|
+
world = f3d.World()
|
|
10
|
+
box = world.add_box(size=(1,1,1), position=(0,0,5))
|
|
11
|
+
while viewer.is_open:
|
|
12
|
+
world.step()
|
|
13
|
+
viewer.draw()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from forge3d.sim.world import PhysicsWorld, _Body
|
|
24
|
+
|
|
25
|
+
# ── Material ──────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Material:
|
|
30
|
+
"""Surface appearance for a rigid body.
|
|
31
|
+
|
|
32
|
+
Color can be a preset name (str) or an RGB tuple in [0, 1].
|
|
33
|
+
Preset names: 'default', 'red', 'blue', 'green', 'orange', 'ground',
|
|
34
|
+
'gold', 'white'.
|
|
35
|
+
Use ``texture_path`` for an albedo image (PNG/JPEG, loaded at render time).
|
|
36
|
+
|
|
37
|
+
Examples
|
|
38
|
+
--------
|
|
39
|
+
>>> Material(color="red")
|
|
40
|
+
>>> Material(color=(0.9, 0.4, 0.1), roughness=0.3)
|
|
41
|
+
>>> Material(color="default", metallic=0.8, roughness=0.2)
|
|
42
|
+
>>> Material(texture_path="wall.png")
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
color: Any = "default" # str preset OR (R, G, B) tuple
|
|
46
|
+
roughness: float = 0.5
|
|
47
|
+
metallic: float = 0.0
|
|
48
|
+
texture_path: str | None = None # path to albedo texture image
|
|
49
|
+
normal_map_path: str | None = None
|
|
50
|
+
|
|
51
|
+
def _material_id(self) -> str:
|
|
52
|
+
"""Resolve to a snapshot material ID string."""
|
|
53
|
+
if isinstance(self.color, str) and self.texture_path is None:
|
|
54
|
+
return self.color
|
|
55
|
+
if isinstance(self.color, str):
|
|
56
|
+
from forge3d.render.snapshot import BUILTIN_MATERIALS
|
|
57
|
+
|
|
58
|
+
base = BUILTIN_MATERIALS.get(self.color)
|
|
59
|
+
r, g, b = base.color if base else (0.75, 0.75, 0.75)
|
|
60
|
+
else:
|
|
61
|
+
r, g, b = self.color
|
|
62
|
+
suffix = f"_{hash(self.texture_path) & 0xFFFF:04x}" if self.texture_path else ""
|
|
63
|
+
return f"custom#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}{suffix}"
|
|
64
|
+
|
|
65
|
+
def _to_snapshot_material(self) -> Any:
|
|
66
|
+
from forge3d.render.snapshot import Material as SM
|
|
67
|
+
|
|
68
|
+
if isinstance(self.color, str):
|
|
69
|
+
from forge3d.render.snapshot import BUILTIN_MATERIALS
|
|
70
|
+
|
|
71
|
+
base = BUILTIN_MATERIALS.get(self.color)
|
|
72
|
+
return SM(
|
|
73
|
+
color=base.color if base else (0.75, 0.75, 0.75),
|
|
74
|
+
roughness=self.roughness,
|
|
75
|
+
metallic=self.metallic,
|
|
76
|
+
texture_path=self.texture_path,
|
|
77
|
+
normal_map_path=self.normal_map_path,
|
|
78
|
+
)
|
|
79
|
+
return SM(
|
|
80
|
+
color=tuple(self.color),
|
|
81
|
+
roughness=self.roughness,
|
|
82
|
+
metallic=self.metallic,
|
|
83
|
+
texture_path=self.texture_path,
|
|
84
|
+
normal_map_path=self.normal_map_path,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── Shape ─────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Shape:
|
|
93
|
+
"""Collision / visual shape descriptor.
|
|
94
|
+
|
|
95
|
+
Create via factory methods::
|
|
96
|
+
|
|
97
|
+
Shape.box(size=(1, 1, 1))
|
|
98
|
+
Shape.sphere(radius=0.5)
|
|
99
|
+
Shape.capsule(radius=0.2, half_length=0.5)
|
|
100
|
+
Shape.convex_mesh(mesh_data) # MeshData from load_obj()
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
type: str
|
|
104
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def box(size: Any = (1.0, 1.0, 1.0)) -> Shape:
|
|
108
|
+
"""Box shape with half-extents derived from *size*."""
|
|
109
|
+
sx, sy, sz = size
|
|
110
|
+
return Shape("box", {"half_extents": np.array([sx / 2, sy / 2, sz / 2])})
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def sphere(radius: float = 0.5) -> Shape:
|
|
114
|
+
"""Sphere shape."""
|
|
115
|
+
return Shape("sphere", {"radius": float(radius)})
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def capsule(radius: float = 0.2, half_length: float = 0.5) -> Shape:
|
|
119
|
+
"""Capsule shape (cylinder + hemispherical end-caps, axis = body +Z)."""
|
|
120
|
+
return Shape("capsule", {"radius": float(radius), "half_length": float(half_length)})
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def convex_mesh(mesh_data: Any) -> Shape:
|
|
124
|
+
"""Convex-hull collision shape from a MeshData object.
|
|
125
|
+
|
|
126
|
+
Load mesh data with::
|
|
127
|
+
|
|
128
|
+
from forge3d.io import load_obj
|
|
129
|
+
mesh = load_obj("model.obj")
|
|
130
|
+
shape = Shape.convex_mesh(mesh)
|
|
131
|
+
"""
|
|
132
|
+
return Shape("mesh", {"mesh_data": mesh_data})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── Body ──────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Body:
|
|
139
|
+
"""Handle to a simulated rigid body.
|
|
140
|
+
|
|
141
|
+
Returned by ``world.add_box()``, ``world.add_sphere()``, etc.
|
|
142
|
+
Use properties to read the body's current state and methods to control it.
|
|
143
|
+
|
|
144
|
+
Examples
|
|
145
|
+
--------
|
|
146
|
+
>>> box = world.add_box(size=(1, 1, 1), position=(0, 0, 5), name="my_box")
|
|
147
|
+
>>> box.position
|
|
148
|
+
array([0., 0., 5.])
|
|
149
|
+
>>> box.name
|
|
150
|
+
'my_box'
|
|
151
|
+
>>> box.apply_force((0, 0, 10)) # applied on next world.step()
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, physics_world: PhysicsWorld, body_id: int) -> None:
|
|
155
|
+
self._pw = physics_world
|
|
156
|
+
self._id = body_id
|
|
157
|
+
# Pending per-frame force/torque accumulators (applied by World.step)
|
|
158
|
+
self._force_accum: np.ndarray = np.zeros(3)
|
|
159
|
+
self._torque_accum: np.ndarray = np.zeros(3)
|
|
160
|
+
|
|
161
|
+
def _state(self) -> _Body:
|
|
162
|
+
return self._pw._get_body(self._id)
|
|
163
|
+
|
|
164
|
+
# ── Read-only state ───────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def position(self) -> np.ndarray:
|
|
168
|
+
"""World-frame position (3,) in metres."""
|
|
169
|
+
return self._state().pos.copy()
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def velocity(self) -> np.ndarray:
|
|
173
|
+
"""Linear velocity (3,) in m/s."""
|
|
174
|
+
return self._state().vel.copy()
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def orientation(self) -> np.ndarray:
|
|
178
|
+
"""Unit quaternion [w, x, y, z]."""
|
|
179
|
+
return self._state().quat.copy()
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def angular_velocity(self) -> np.ndarray:
|
|
183
|
+
"""Angular velocity (3,) in rad/s."""
|
|
184
|
+
return self._state().omega.copy()
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def name(self) -> str:
|
|
188
|
+
"""Human-readable name assigned at creation."""
|
|
189
|
+
return self._state().name
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def is_static(self) -> bool:
|
|
193
|
+
"""True if this body does not move under physics forces."""
|
|
194
|
+
return self._state().static
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def mass(self) -> float:
|
|
198
|
+
"""Body mass in kg (0.0 for static bodies)."""
|
|
199
|
+
return self._state().mass
|
|
200
|
+
|
|
201
|
+
# ── Control ───────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def apply_force(self, force: Any) -> None:
|
|
204
|
+
"""Accumulate a world-frame force (N) to apply on the next step.
|
|
205
|
+
|
|
206
|
+
Forces reset to zero after each :meth:`World.step` call.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
force : (3,) force vector in Newtons, world frame.
|
|
211
|
+
|
|
212
|
+
Notes
|
|
213
|
+
-----
|
|
214
|
+
For per-frame forces use this instead of ``apply_impulse``::
|
|
215
|
+
|
|
216
|
+
body.apply_force(np.array([0, 0, 50])) # 50 N upward thrust
|
|
217
|
+
world.step(dt=1/60) # force applied here
|
|
218
|
+
"""
|
|
219
|
+
self._force_accum = self._force_accum + np.asarray(force, dtype=float)
|
|
220
|
+
|
|
221
|
+
def apply_torque(self, torque: Any) -> None:
|
|
222
|
+
"""Accumulate a world-frame torque (N·m) to apply on the next step.
|
|
223
|
+
|
|
224
|
+
Torques reset to zero after each :meth:`World.step` call.
|
|
225
|
+
"""
|
|
226
|
+
self._torque_accum = self._torque_accum + np.asarray(torque, dtype=float)
|
|
227
|
+
|
|
228
|
+
def set_position(self, position: Any) -> None:
|
|
229
|
+
"""Instantly teleport this body to *position* (keeps orientation)."""
|
|
230
|
+
b = self._state()
|
|
231
|
+
self._pw.update_body_pose(self._id, np.asarray(position, dtype=float), b.quat)
|
|
232
|
+
|
|
233
|
+
def set_orientation(self, quat: Any) -> None:
|
|
234
|
+
"""Instantly set orientation (keeps position). quat = [w, x, y, z]."""
|
|
235
|
+
b = self._state()
|
|
236
|
+
self._pw.update_body_pose(self._id, b.pos, np.asarray(quat, dtype=float))
|
|
237
|
+
|
|
238
|
+
def set_velocity(self, vel: Any) -> None:
|
|
239
|
+
"""Override linear velocity (m/s)."""
|
|
240
|
+
b = self._state()
|
|
241
|
+
from dataclasses import replace
|
|
242
|
+
|
|
243
|
+
self._pw._replace_body(self._id, replace(b, vel=np.asarray(vel, dtype=float)))
|
|
244
|
+
|
|
245
|
+
def set_angular_velocity(self, omega: Any) -> None:
|
|
246
|
+
"""Override angular velocity (rad/s)."""
|
|
247
|
+
b = self._state()
|
|
248
|
+
from dataclasses import replace
|
|
249
|
+
|
|
250
|
+
self._pw._replace_body(self._id, replace(b, omega=np.asarray(omega, dtype=float)))
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def is_sleeping(self) -> bool:
|
|
254
|
+
"""True if this body is below the sleep velocity threshold for ≥ 1 s."""
|
|
255
|
+
return self._pw.is_sleeping(self._id)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def collision_layer(self) -> int:
|
|
259
|
+
"""Bit-field: which layer(s) this body belongs to."""
|
|
260
|
+
return self._state().collision_layer
|
|
261
|
+
|
|
262
|
+
@collision_layer.setter
|
|
263
|
+
def collision_layer(self, value: int) -> None:
|
|
264
|
+
from dataclasses import replace
|
|
265
|
+
b = self._state()
|
|
266
|
+
self._pw._replace_body(self._id, replace(b, collision_layer=int(value)))
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def collision_mask(self) -> int:
|
|
270
|
+
"""Bit-field: which layers this body detects collisions with."""
|
|
271
|
+
return self._state().collision_mask
|
|
272
|
+
|
|
273
|
+
@collision_mask.setter
|
|
274
|
+
def collision_mask(self, value: int) -> None:
|
|
275
|
+
from dataclasses import replace
|
|
276
|
+
b = self._state()
|
|
277
|
+
self._pw._replace_body(self._id, replace(b, collision_mask=int(value)))
|
|
278
|
+
|
|
279
|
+
def _flush_accumulators(self, dt: float) -> None:
|
|
280
|
+
"""Apply accumulated force/torque as impulses (called by World.step)."""
|
|
281
|
+
if np.any(self._force_accum != 0):
|
|
282
|
+
b = self._state()
|
|
283
|
+
if not b.static and b.mass > 0.0:
|
|
284
|
+
dv = self._force_accum * dt / b.mass
|
|
285
|
+
self._pw.apply_impulse(self._id, dv * b.mass)
|
|
286
|
+
self._force_accum = np.zeros(3)
|
|
287
|
+
if np.any(self._torque_accum != 0):
|
|
288
|
+
b = self._state()
|
|
289
|
+
if not b.static and b.inertia_inv_local is not None:
|
|
290
|
+
from forge3d.math.quaternion import quat_to_rot
|
|
291
|
+
|
|
292
|
+
R = quat_to_rot(b.quat)
|
|
293
|
+
I_world_inv = R @ b.inertia_inv_local @ R.T
|
|
294
|
+
d_omega = I_world_inv @ self._torque_accum * dt
|
|
295
|
+
from dataclasses import replace
|
|
296
|
+
|
|
297
|
+
self._pw._replace_body(
|
|
298
|
+
self._id, replace(b, omega=b.omega + d_omega)
|
|
299
|
+
)
|
|
300
|
+
self._torque_accum = np.zeros(3)
|
|
301
|
+
|
|
302
|
+
def __repr__(self) -> str:
|
|
303
|
+
try:
|
|
304
|
+
p = self.position
|
|
305
|
+
return (
|
|
306
|
+
f"Body(id={self._id}, name={self.name!r}, "
|
|
307
|
+
f"pos=({p[0]:.2f}, {p[1]:.2f}, {p[2]:.2f}))"
|
|
308
|
+
)
|
|
309
|
+
except Exception:
|
|
310
|
+
return f"Body(id={self._id})"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ── World ─────────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class World:
|
|
317
|
+
"""forge3d physics world — pygame-style public API.
|
|
318
|
+
|
|
319
|
+
Coordinate system: z-up, SI units. Start here::
|
|
320
|
+
|
|
321
|
+
world = forge3d.World()
|
|
322
|
+
world.add_ground()
|
|
323
|
+
box = world.add_box(size=(1, 1, 1), position=(0, 0, 5))
|
|
324
|
+
world.step() # advances by default_dt=1/60 s
|
|
325
|
+
snap = world.snapshot()
|
|
326
|
+
|
|
327
|
+
The internal ``PhysicsWorld`` is accessible via ``world._physics`` for
|
|
328
|
+
advanced use, but the public API never needs it.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
DEFAULT_DT: float = 1.0 / 60.0
|
|
332
|
+
|
|
333
|
+
def __init__(self, gravity: Any = (0.0, 0.0, -9.81)) -> None:
|
|
334
|
+
from forge3d.errors import require_sequence
|
|
335
|
+
require_sequence(gravity, 3, "gravity", "World()")
|
|
336
|
+
self._physics = PhysicsWorld(gravity=list(gravity))
|
|
337
|
+
self._bodies: dict[int, Body] = {}
|
|
338
|
+
self._materials: dict[str, Material] = {}
|
|
339
|
+
self._camera: tuple | None = None
|
|
340
|
+
self._robots: list[Any] = []
|
|
341
|
+
self._welds: dict[int, tuple[int, np.ndarray]] = {}
|
|
342
|
+
# Event system
|
|
343
|
+
from forge3d.events import EventDispatcher
|
|
344
|
+
self._events = EventDispatcher()
|
|
345
|
+
# Collision ignore set: frozenset of (id_a, id_b) pairs
|
|
346
|
+
self._ignored_pairs: set[frozenset[int]] = set()
|
|
347
|
+
|
|
348
|
+
# ── Scene construction ────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
def add_ground(
|
|
351
|
+
self,
|
|
352
|
+
material: Material | str = "ground",
|
|
353
|
+
size: Any = (40.0, 40.0, 0.2),
|
|
354
|
+
height: float = 0.0,
|
|
355
|
+
) -> Body:
|
|
356
|
+
"""Add a static ground plane. By default: 40×40 m slab at z=0."""
|
|
357
|
+
mat_id, mat = _resolve_material(material)
|
|
358
|
+
if mat:
|
|
359
|
+
self._materials[mat_id] = mat
|
|
360
|
+
bid = self._physics.add_static_box(
|
|
361
|
+
size=size,
|
|
362
|
+
position=(0.0, 0.0, height - size[2] / 2),
|
|
363
|
+
material=mat_id,
|
|
364
|
+
name="ground",
|
|
365
|
+
)
|
|
366
|
+
body = Body(self._physics, bid)
|
|
367
|
+
self._bodies[bid] = body
|
|
368
|
+
return body
|
|
369
|
+
|
|
370
|
+
def add_box(
|
|
371
|
+
self,
|
|
372
|
+
size: Any = (1.0, 1.0, 1.0),
|
|
373
|
+
position: Any = (0.0, 0.0, 0.0),
|
|
374
|
+
mass: float = 1.0,
|
|
375
|
+
material: Material | str = "default",
|
|
376
|
+
name: str = "",
|
|
377
|
+
restitution: float = 0.3,
|
|
378
|
+
friction: float = 0.5,
|
|
379
|
+
) -> Body:
|
|
380
|
+
"""Add a box-shaped rigid body."""
|
|
381
|
+
from forge3d.errors import require_all_positive, require_nonneg, require_positive, require_range, require_sequence
|
|
382
|
+
require_positive(float(mass), "mass", "World.add_box()")
|
|
383
|
+
require_sequence(size, 3, "size", "World.add_box()")
|
|
384
|
+
require_all_positive(size, "size", "World.add_box()")
|
|
385
|
+
require_range(float(restitution), 0.0, 1.0, "restitution", "World.add_box()")
|
|
386
|
+
require_nonneg(float(friction), "friction", "World.add_box()")
|
|
387
|
+
mat_id, mat = _resolve_material(material)
|
|
388
|
+
if mat:
|
|
389
|
+
self._materials[mat_id] = mat
|
|
390
|
+
bid = self._physics.add_box(
|
|
391
|
+
size=size,
|
|
392
|
+
position=position,
|
|
393
|
+
mass=mass,
|
|
394
|
+
material=mat_id,
|
|
395
|
+
name=name,
|
|
396
|
+
restitution=restitution,
|
|
397
|
+
friction=friction,
|
|
398
|
+
)
|
|
399
|
+
body = Body(self._physics, bid)
|
|
400
|
+
self._bodies[bid] = body
|
|
401
|
+
return body
|
|
402
|
+
|
|
403
|
+
def add_capsule(
|
|
404
|
+
self,
|
|
405
|
+
radius: float = 0.2,
|
|
406
|
+
half_length: float = 0.5,
|
|
407
|
+
position: Any = (0.0, 0.0, 0.0),
|
|
408
|
+
quat: Any = None,
|
|
409
|
+
mass: float = 1.0,
|
|
410
|
+
material: Material | str = "default",
|
|
411
|
+
name: str = "",
|
|
412
|
+
restitution: float = 0.3,
|
|
413
|
+
friction: float = 0.5,
|
|
414
|
+
) -> Body:
|
|
415
|
+
"""Add a capsule-shaped rigid body (cylinder + two hemispherical caps).
|
|
416
|
+
|
|
417
|
+
The capsule axis is aligned with body-local +Z. Use ``quat`` to orient it.
|
|
418
|
+
"""
|
|
419
|
+
mat_id, mat = _resolve_material(material)
|
|
420
|
+
if mat:
|
|
421
|
+
self._materials[mat_id] = mat
|
|
422
|
+
bid = self._physics.add_capsule(
|
|
423
|
+
radius=radius,
|
|
424
|
+
half_length=half_length,
|
|
425
|
+
position=position,
|
|
426
|
+
quat=quat,
|
|
427
|
+
mass=mass,
|
|
428
|
+
material=mat_id,
|
|
429
|
+
name=name,
|
|
430
|
+
restitution=restitution,
|
|
431
|
+
friction=friction,
|
|
432
|
+
)
|
|
433
|
+
body = Body(self._physics, bid)
|
|
434
|
+
self._bodies[bid] = body
|
|
435
|
+
return body
|
|
436
|
+
|
|
437
|
+
def add_mesh(
|
|
438
|
+
self,
|
|
439
|
+
mesh_data: Any,
|
|
440
|
+
position: Any = (0.0, 0.0, 0.0),
|
|
441
|
+
quat: Any = None,
|
|
442
|
+
mass: float = 1.0,
|
|
443
|
+
material: Material | str = "default",
|
|
444
|
+
name: str = "",
|
|
445
|
+
restitution: float = 0.3,
|
|
446
|
+
friction: float = 0.5,
|
|
447
|
+
static: bool = False,
|
|
448
|
+
) -> Body:
|
|
449
|
+
"""Add a convex-hull rigid body from a MeshData object.
|
|
450
|
+
|
|
451
|
+
Typical use::
|
|
452
|
+
|
|
453
|
+
from forge3d.io import load_obj
|
|
454
|
+
mesh = load_obj("assets/models/cube.obj")
|
|
455
|
+
body = world.add_mesh(mesh, position=(0, 0, 3), mass=1.0)
|
|
456
|
+
"""
|
|
457
|
+
mat_id, mat = _resolve_material(material)
|
|
458
|
+
if mat:
|
|
459
|
+
self._materials[mat_id] = mat
|
|
460
|
+
bid = self._physics.add_convex_mesh(
|
|
461
|
+
mesh_data=mesh_data,
|
|
462
|
+
position=position,
|
|
463
|
+
quat=quat,
|
|
464
|
+
mass=mass,
|
|
465
|
+
material=mat_id,
|
|
466
|
+
name=name,
|
|
467
|
+
restitution=restitution,
|
|
468
|
+
friction=friction,
|
|
469
|
+
static=static,
|
|
470
|
+
)
|
|
471
|
+
body = Body(self._physics, bid)
|
|
472
|
+
self._bodies[bid] = body
|
|
473
|
+
return body
|
|
474
|
+
|
|
475
|
+
def add_sphere(
|
|
476
|
+
self,
|
|
477
|
+
radius: float = 0.5,
|
|
478
|
+
position: Any = (0.0, 0.0, 0.0),
|
|
479
|
+
mass: float = 1.0,
|
|
480
|
+
material: Material | str = "default",
|
|
481
|
+
name: str = "",
|
|
482
|
+
restitution: float = 0.3,
|
|
483
|
+
friction: float = 0.5,
|
|
484
|
+
static: bool = False,
|
|
485
|
+
) -> Body:
|
|
486
|
+
"""Add a sphere-shaped rigid body.
|
|
487
|
+
|
|
488
|
+
``static=True`` creates a non-moving marker (e.g. target visualization).
|
|
489
|
+
"""
|
|
490
|
+
from forge3d.errors import require_nonneg, require_positive, require_range
|
|
491
|
+
if not static:
|
|
492
|
+
require_positive(float(mass), "mass", "World.add_sphere()")
|
|
493
|
+
require_positive(float(radius), "radius", "World.add_sphere()")
|
|
494
|
+
require_range(float(restitution), 0.0, 1.0, "restitution", "World.add_sphere()")
|
|
495
|
+
require_nonneg(float(friction), "friction", "World.add_sphere()")
|
|
496
|
+
mat_id, mat = _resolve_material(material)
|
|
497
|
+
if mat:
|
|
498
|
+
self._materials[mat_id] = mat
|
|
499
|
+
bid = self._physics.add_sphere(
|
|
500
|
+
radius=radius,
|
|
501
|
+
position=position,
|
|
502
|
+
mass=mass,
|
|
503
|
+
material=mat_id,
|
|
504
|
+
name=name,
|
|
505
|
+
restitution=restitution,
|
|
506
|
+
friction=friction,
|
|
507
|
+
static=static,
|
|
508
|
+
)
|
|
509
|
+
body = Body(self._physics, bid)
|
|
510
|
+
self._bodies[bid] = body
|
|
511
|
+
return body
|
|
512
|
+
|
|
513
|
+
def add_terrain(
|
|
514
|
+
self,
|
|
515
|
+
heights: Any,
|
|
516
|
+
cell_size: float = 1.0,
|
|
517
|
+
origin: Any = (0.0, 0.0, 0.0),
|
|
518
|
+
material: Material | str = "ground",
|
|
519
|
+
) -> Any:
|
|
520
|
+
"""Add a heightfield terrain (static, collision-only).
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
heights: 2D array of shape (rows, cols) with z-heights in metres.
|
|
524
|
+
cell_size: World-space size of each grid cell (m).
|
|
525
|
+
origin: World-space position of the (0, 0) grid corner.
|
|
526
|
+
material: Surface material for rendering.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
A :class:`~forge3d.collision.heightfield.Heightfield` object.
|
|
530
|
+
Pass it to ``world.add_terrain()`` to enable collision.
|
|
531
|
+
|
|
532
|
+
Example::
|
|
533
|
+
|
|
534
|
+
import numpy as np
|
|
535
|
+
rng = np.random.default_rng(42)
|
|
536
|
+
h = rng.uniform(0, 2, (32, 32)).astype(np.float32)
|
|
537
|
+
terrain = world.add_terrain(h, cell_size=0.5, origin=(-8, -8, 0))
|
|
538
|
+
"""
|
|
539
|
+
from forge3d.collision.heightfield import Heightfield
|
|
540
|
+
|
|
541
|
+
hf = Heightfield(
|
|
542
|
+
heights=np.asarray(heights, dtype=np.float32),
|
|
543
|
+
cell_size=float(cell_size),
|
|
544
|
+
origin=np.asarray(origin, dtype=float),
|
|
545
|
+
)
|
|
546
|
+
self._physics._heightfields.append(hf)
|
|
547
|
+
return hf
|
|
548
|
+
|
|
549
|
+
def add(self, obj: Any) -> Any:
|
|
550
|
+
"""Add a Body or Robot to the world. Returns the object passed in."""
|
|
551
|
+
if isinstance(obj, Body):
|
|
552
|
+
bid = obj._id
|
|
553
|
+
if bid not in self._bodies:
|
|
554
|
+
self._bodies[bid] = obj
|
|
555
|
+
return obj
|
|
556
|
+
if hasattr(obj, "n_joints") and hasattr(obj, "link_visual_boxes"):
|
|
557
|
+
return self._add_robot(obj)
|
|
558
|
+
raise TypeError(f"world.add() expects a Body or Robot, got {type(obj).__name__}.")
|
|
559
|
+
|
|
560
|
+
def _add_robot(self, robot: Any) -> Any:
|
|
561
|
+
from forge3d.math.quaternion import quat_from_rot
|
|
562
|
+
|
|
563
|
+
boxes = robot.link_visual_boxes()
|
|
564
|
+
for i, (center, R, he) in enumerate(boxes):
|
|
565
|
+
quat = quat_from_rot(R)
|
|
566
|
+
bid = self._physics.add_static_box(
|
|
567
|
+
size=tuple(float(v) for v in he * 2),
|
|
568
|
+
position=tuple(float(v) for v in center),
|
|
569
|
+
material=robot.material,
|
|
570
|
+
name=f"{robot.name}_link{i}",
|
|
571
|
+
restitution=0.0,
|
|
572
|
+
friction=0.5,
|
|
573
|
+
quat=quat,
|
|
574
|
+
)
|
|
575
|
+
robot._body_ids.append(bid)
|
|
576
|
+
self._robots.append(robot)
|
|
577
|
+
return robot
|
|
578
|
+
|
|
579
|
+
# ── Scene query ───────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
@property
|
|
582
|
+
def bodies(self) -> list[Body]:
|
|
583
|
+
"""All body handles currently in the world (order: insertion)."""
|
|
584
|
+
return list(self._bodies.values())
|
|
585
|
+
|
|
586
|
+
def get_body(self, name: str) -> Body:
|
|
587
|
+
"""Return the first body with *name*.
|
|
588
|
+
|
|
589
|
+
Raises
|
|
590
|
+
------
|
|
591
|
+
KeyError
|
|
592
|
+
If no body with that name exists.
|
|
593
|
+
"""
|
|
594
|
+
for body in self._bodies.values():
|
|
595
|
+
try:
|
|
596
|
+
if body.name == name:
|
|
597
|
+
return body
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
raise KeyError(f"No body named '{name}' in world. "
|
|
601
|
+
f"Available: {[b.name for b in self.bodies]}")
|
|
602
|
+
|
|
603
|
+
# ── Scene mutation ────────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
def remove(self, body: Body) -> None:
|
|
606
|
+
"""Remove a body from the simulation.
|
|
607
|
+
|
|
608
|
+
The body handle becomes stale after this call.
|
|
609
|
+
|
|
610
|
+
Parameters
|
|
611
|
+
----------
|
|
612
|
+
body : Body handle returned by an add_* method.
|
|
613
|
+
"""
|
|
614
|
+
bid = body._id
|
|
615
|
+
self._physics.remove_body(bid)
|
|
616
|
+
self._bodies.pop(bid, None)
|
|
617
|
+
self._welds.pop(bid, None)
|
|
618
|
+
|
|
619
|
+
def clear(self, keep_statics: bool = False) -> None:
|
|
620
|
+
"""Remove all bodies from the world.
|
|
621
|
+
|
|
622
|
+
Parameters
|
|
623
|
+
----------
|
|
624
|
+
keep_statics : If True, static bodies (ground planes, robot links)
|
|
625
|
+
are kept; only dynamic bodies are removed.
|
|
626
|
+
"""
|
|
627
|
+
to_remove = []
|
|
628
|
+
for bid, body in list(self._bodies.items()):
|
|
629
|
+
try:
|
|
630
|
+
is_static = body.is_static
|
|
631
|
+
except Exception:
|
|
632
|
+
is_static = False
|
|
633
|
+
if keep_statics and is_static:
|
|
634
|
+
continue
|
|
635
|
+
to_remove.append(bid)
|
|
636
|
+
|
|
637
|
+
for bid in to_remove:
|
|
638
|
+
self._physics.remove_body(bid)
|
|
639
|
+
self._bodies.pop(bid, None)
|
|
640
|
+
self._welds.pop(bid, None)
|
|
641
|
+
|
|
642
|
+
# ── Physical interactions ─────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
def apply_impulse(self, body: Body, impulse: Any) -> None:
|
|
645
|
+
"""Apply an instantaneous velocity impulse to *body* (Δv = impulse / mass).
|
|
646
|
+
|
|
647
|
+
Use this to apply per-frame forces from a game loop::
|
|
648
|
+
|
|
649
|
+
# Apply force F for time dt:
|
|
650
|
+
world.apply_impulse(ball, np.array([F_x, F_y, 0]) * dt)
|
|
651
|
+
world.step(dt)
|
|
652
|
+
"""
|
|
653
|
+
self._physics.apply_impulse(body._id, impulse)
|
|
654
|
+
|
|
655
|
+
def teleport(
|
|
656
|
+
self,
|
|
657
|
+
body: Body,
|
|
658
|
+
position: Any,
|
|
659
|
+
quat: Any = None,
|
|
660
|
+
) -> None:
|
|
661
|
+
"""Instantly move a body to a new position (and optionally orientation)."""
|
|
662
|
+
b = body._state()
|
|
663
|
+
q = b.quat.copy() if quat is None else np.asarray(quat, dtype=float)
|
|
664
|
+
self._physics.update_body_pose(body._id, np.asarray(position, dtype=float), q)
|
|
665
|
+
|
|
666
|
+
def weld(
|
|
667
|
+
self,
|
|
668
|
+
body: Body,
|
|
669
|
+
anchor: Body,
|
|
670
|
+
local_offset: Any = None,
|
|
671
|
+
) -> None:
|
|
672
|
+
"""Attach *body* kinematically to *anchor* (weld constraint).
|
|
673
|
+
|
|
674
|
+
After welding, ``body`` will follow ``anchor`` rigidly each ``step()``.
|
|
675
|
+
"""
|
|
676
|
+
from forge3d.math.quaternion import quat_to_rot
|
|
677
|
+
|
|
678
|
+
if local_offset is None:
|
|
679
|
+
a_state = anchor._state()
|
|
680
|
+
b_state = body._state()
|
|
681
|
+
R_anchor = quat_to_rot(a_state.quat)
|
|
682
|
+
local_offset = R_anchor.T @ (b_state.pos - a_state.pos)
|
|
683
|
+
self._welds[body._id] = (anchor._id, np.asarray(local_offset, dtype=float))
|
|
684
|
+
|
|
685
|
+
def release(self, body: Body) -> None:
|
|
686
|
+
"""Remove weld constraint from *body* (body resumes normal physics)."""
|
|
687
|
+
self._welds.pop(body._id, None)
|
|
688
|
+
|
|
689
|
+
# ── Joint / constraint system ─────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
def add_joint(
|
|
692
|
+
self,
|
|
693
|
+
joint_type: str,
|
|
694
|
+
body_a: Body,
|
|
695
|
+
body_b: Body | None = None,
|
|
696
|
+
anchor_a: Any = (0.0, 0.0, 0.0),
|
|
697
|
+
anchor_b: Any = (0.0, 0.0, 0.0),
|
|
698
|
+
axis: Any = (0.0, 0.0, 1.0),
|
|
699
|
+
limits: tuple[float, float] | None = None,
|
|
700
|
+
motor_velocity: float | None = None,
|
|
701
|
+
motor_max_torque: float = 10.0,
|
|
702
|
+
stiffness: float = 100.0,
|
|
703
|
+
damping: float = 5.0,
|
|
704
|
+
rest_length: float = 1.0,
|
|
705
|
+
target_distance: float = 1.0,
|
|
706
|
+
) -> Any:
|
|
707
|
+
"""Add a joint constraint between two bodies.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
joint_type: One of ``"fixed"``, ``"ball"``, ``"hinge"``,
|
|
711
|
+
``"prismatic"``, ``"distance"``, ``"spring"``.
|
|
712
|
+
body_a: First body (required).
|
|
713
|
+
body_b: Second body. If ``None``, the joint anchors body_a
|
|
714
|
+
to a world-fixed point (``anchor_b`` in world frame).
|
|
715
|
+
anchor_a: Attachment point in body_a local frame.
|
|
716
|
+
anchor_b: Attachment point in body_b local frame (or world frame
|
|
717
|
+
if body_b is None).
|
|
718
|
+
axis: Hinge / slide axis in body_a local frame
|
|
719
|
+
(used for ``"hinge"`` and ``"prismatic"``).
|
|
720
|
+
limits: Angular limits (rad) for hinge or distance limits (m)
|
|
721
|
+
for prismatic.
|
|
722
|
+
motor_velocity: Target velocity for hinge/prismatic motor.
|
|
723
|
+
motor_max_torque: Torque cap for hinge motor (N·m).
|
|
724
|
+
stiffness: Spring constant k (N/m) for spring joint.
|
|
725
|
+
damping: Damping coefficient c (N·s/m) for spring joint.
|
|
726
|
+
rest_length: Natural spring length (m) for spring joint.
|
|
727
|
+
target_distance: Target distance (m) for distance joint.
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
A :class:`forge3d.constraints.JointHandle` (pass to
|
|
731
|
+
:meth:`remove_joint` to delete the joint).
|
|
732
|
+
|
|
733
|
+
Examples::
|
|
734
|
+
|
|
735
|
+
hinge = world.add_joint("hinge", door, frame,
|
|
736
|
+
anchor_a=(-0.5, 0, 0),
|
|
737
|
+
anchor_b=(0.5, 0, 0),
|
|
738
|
+
axis=(0, 0, 1))
|
|
739
|
+
spring = world.add_joint("spring", box, ceiling,
|
|
740
|
+
stiffness=200.0, damping=10.0,
|
|
741
|
+
rest_length=2.0)
|
|
742
|
+
"""
|
|
743
|
+
from forge3d.constraints import (
|
|
744
|
+
BallJoint,
|
|
745
|
+
DistanceJoint,
|
|
746
|
+
FixedJoint,
|
|
747
|
+
HingeJoint,
|
|
748
|
+
JointHandle,
|
|
749
|
+
PrismaticJoint,
|
|
750
|
+
SpringJoint,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
id_a = body_a._id
|
|
754
|
+
id_b = body_b._id if body_b is not None else -1
|
|
755
|
+
anc_a = np.asarray(anchor_a, dtype=float)
|
|
756
|
+
anc_b = np.asarray(anchor_b, dtype=float)
|
|
757
|
+
ax = np.asarray(axis, dtype=float)
|
|
758
|
+
|
|
759
|
+
from forge3d.constraints.base import Constraint as _Constraint # noqa: F811
|
|
760
|
+
|
|
761
|
+
jtype = joint_type.lower().replace("-", "_")
|
|
762
|
+
constraint: _Constraint
|
|
763
|
+
if jtype == "fixed":
|
|
764
|
+
constraint = FixedJoint(id_a, id_b, anc_a, anc_b)
|
|
765
|
+
elif jtype == "ball":
|
|
766
|
+
constraint = BallJoint(id_a, id_b, anc_a, anc_b)
|
|
767
|
+
elif jtype == "hinge":
|
|
768
|
+
constraint = HingeJoint(
|
|
769
|
+
id_a, id_b, anc_a, anc_b, ax,
|
|
770
|
+
limits=limits,
|
|
771
|
+
motor_velocity=motor_velocity,
|
|
772
|
+
motor_max_torque=motor_max_torque,
|
|
773
|
+
)
|
|
774
|
+
elif jtype == "prismatic":
|
|
775
|
+
constraint = PrismaticJoint(
|
|
776
|
+
id_a, id_b, anc_a, anc_b, ax,
|
|
777
|
+
limits=limits,
|
|
778
|
+
motor_velocity=motor_velocity,
|
|
779
|
+
motor_max_force=motor_max_torque,
|
|
780
|
+
)
|
|
781
|
+
elif jtype == "distance":
|
|
782
|
+
constraint = DistanceJoint(id_a, id_b, anc_a, anc_b, target_distance)
|
|
783
|
+
elif jtype == "spring":
|
|
784
|
+
constraint = SpringJoint(
|
|
785
|
+
id_a, id_b, anc_a, anc_b,
|
|
786
|
+
stiffness=stiffness, damping=damping, rest_length=rest_length,
|
|
787
|
+
)
|
|
788
|
+
else:
|
|
789
|
+
raise ValueError(
|
|
790
|
+
f"Unknown joint type '{joint_type}'. "
|
|
791
|
+
f"Choose from: fixed, ball, hinge, prismatic, distance, spring."
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
jid = self._physics.add_constraint(constraint)
|
|
795
|
+
return JointHandle(joint_id=jid, joint_type=jtype)
|
|
796
|
+
|
|
797
|
+
def remove_joint(self, handle: Any) -> None:
|
|
798
|
+
"""Remove a joint by its handle (returned from :meth:`add_joint`)."""
|
|
799
|
+
self._physics.remove_constraint(handle.joint_id)
|
|
800
|
+
|
|
801
|
+
def set_camera(
|
|
802
|
+
self,
|
|
803
|
+
position: Any,
|
|
804
|
+
target: Any = (0.0, 0.0, 0.0),
|
|
805
|
+
up: Any = (0.0, 0.0, 1.0),
|
|
806
|
+
fov_deg: float = 45.0,
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Set the default camera pose for snapshots and the Viewer."""
|
|
809
|
+
self._physics.set_camera(position, target, up, fov_deg)
|
|
810
|
+
self._camera = (position, target, up, fov_deg)
|
|
811
|
+
|
|
812
|
+
# ── Simulation ────────────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
def step(self, dt: float | None = None) -> None:
|
|
815
|
+
"""Advance simulation by dt seconds (default: 1/60 s).
|
|
816
|
+
|
|
817
|
+
1. Flush per-body force/torque accumulators (apply_force / apply_torque).
|
|
818
|
+
2. Physics step (gravity → contacts → impulses → position update).
|
|
819
|
+
3. Apply weld constraints.
|
|
820
|
+
4. Dispatch collision events.
|
|
821
|
+
"""
|
|
822
|
+
_dt = dt if dt is not None else self.DEFAULT_DT
|
|
823
|
+
# Flush accumulators before physics step
|
|
824
|
+
for body in self._bodies.values():
|
|
825
|
+
if body._force_accum is not None and (
|
|
826
|
+
np.any(body._force_accum != 0) or np.any(body._torque_accum != 0)
|
|
827
|
+
):
|
|
828
|
+
body._flush_accumulators(_dt)
|
|
829
|
+
self._physics.step(_dt)
|
|
830
|
+
self._apply_welds()
|
|
831
|
+
# Always dispatch so _prev_contacts stays up to date (enables on_end after late registration)
|
|
832
|
+
self._dispatch_events()
|
|
833
|
+
|
|
834
|
+
def _dispatch_events(self) -> None:
|
|
835
|
+
"""Detect current contacts and dispatch begin/stay/end callbacks."""
|
|
836
|
+
from forge3d.collision.detection import detect_contacts
|
|
837
|
+
from forge3d.events import CollisionEvent
|
|
838
|
+
|
|
839
|
+
contacts_raw = detect_contacts(self._physics._bodies)
|
|
840
|
+
|
|
841
|
+
class _EvtContact:
|
|
842
|
+
__slots__ = ["body_id_a", "body_id_b", "contact_point", "normal",
|
|
843
|
+
"impulse", "relative_speed"]
|
|
844
|
+
|
|
845
|
+
def __init__(self, c: Any, bodies: list[Any]) -> None:
|
|
846
|
+
ba = bodies[c.body_a_idx]
|
|
847
|
+
self.body_id_a = ba.body_id
|
|
848
|
+
if c.body_b_idx >= 0:
|
|
849
|
+
self.body_id_b = bodies[c.body_b_idx].body_id
|
|
850
|
+
else:
|
|
851
|
+
self.body_id_b = -1
|
|
852
|
+
self.contact_point = c.pos
|
|
853
|
+
self.normal = c.normal
|
|
854
|
+
self.impulse = 0.0
|
|
855
|
+
self.relative_speed = 0.0
|
|
856
|
+
|
|
857
|
+
evt_contacts = [_EvtContact(c, self._physics._bodies) for c in contacts_raw]
|
|
858
|
+
# Filter ignored pairs
|
|
859
|
+
if self._ignored_pairs:
|
|
860
|
+
evt_contacts = [
|
|
861
|
+
c for c in evt_contacts
|
|
862
|
+
if frozenset({c.body_id_a, c.body_id_b}) not in self._ignored_pairs
|
|
863
|
+
]
|
|
864
|
+
|
|
865
|
+
self._events._bodies = self._bodies # type: ignore[assignment] # share reference
|
|
866
|
+
self._events.dispatch(evt_contacts)
|
|
867
|
+
|
|
868
|
+
# ── Collision event API ────────────────────────────────────────────────────
|
|
869
|
+
|
|
870
|
+
def on_collision_begin(self, fn: Any) -> Any:
|
|
871
|
+
"""Register a callback for when two bodies first collide.
|
|
872
|
+
|
|
873
|
+
Can be used as a decorator::
|
|
874
|
+
|
|
875
|
+
@world.on_collision_begin
|
|
876
|
+
def hit(event: forge3d.CollisionEvent) -> None:
|
|
877
|
+
print(event.body_a.name, "hit", event.body_b.name)
|
|
878
|
+
"""
|
|
879
|
+
self._events.add_begin_listener(fn)
|
|
880
|
+
return fn
|
|
881
|
+
|
|
882
|
+
def on_collision_stay(self, fn: Any) -> Any:
|
|
883
|
+
"""Register a callback called every step while two bodies remain in contact."""
|
|
884
|
+
self._events.add_stay_listener(fn)
|
|
885
|
+
return fn
|
|
886
|
+
|
|
887
|
+
def on_collision_end(self, fn: Any) -> Any:
|
|
888
|
+
"""Register a callback when two bodies separate."""
|
|
889
|
+
self._events.add_end_listener(fn)
|
|
890
|
+
return fn
|
|
891
|
+
|
|
892
|
+
def add_collision_handler(self, body_a: "Body", body_b: "Body") -> Any:
|
|
893
|
+
"""Return a :class:`~forge3d.events.CollisionHandler` for a specific body pair.
|
|
894
|
+
|
|
895
|
+
Example::
|
|
896
|
+
|
|
897
|
+
handler = world.add_collision_handler(ball, floor)
|
|
898
|
+
handler.on_begin = lambda e: print("Hit!")
|
|
899
|
+
"""
|
|
900
|
+
handler = self._events.add_pair_handler(body_a._id, body_b._id)
|
|
901
|
+
return handler
|
|
902
|
+
|
|
903
|
+
def ignore_collision(self, body_a: "Body", body_b: "Body") -> None:
|
|
904
|
+
"""Permanently ignore physics collisions between two specific bodies."""
|
|
905
|
+
pair: frozenset[int] = frozenset({body_a._id, body_b._id})
|
|
906
|
+
self._ignored_pairs.add(pair)
|
|
907
|
+
self._physics._ignored_pairs.add(pair)
|
|
908
|
+
|
|
909
|
+
def add_trigger_zone(
|
|
910
|
+
self,
|
|
911
|
+
position: Any = (0.0, 0.0, 0.0),
|
|
912
|
+
size: Any = (1.0, 1.0, 1.0),
|
|
913
|
+
name: str = "trigger",
|
|
914
|
+
) -> Any:
|
|
915
|
+
"""Add an invisible trigger zone (no physics collision, events only).
|
|
916
|
+
|
|
917
|
+
Returns a :class:`~forge3d.events.TriggerZone` with ``on_enter`` and
|
|
918
|
+
``on_exit`` decorator attributes.
|
|
919
|
+
|
|
920
|
+
Example::
|
|
921
|
+
|
|
922
|
+
goal = world.add_trigger_zone(position=(5, 0, 0.5), size=(1, 1, 1))
|
|
923
|
+
|
|
924
|
+
@goal.on_enter
|
|
925
|
+
def scored(body: forge3d.Body) -> None:
|
|
926
|
+
print(f"GOAL! {body.name}")
|
|
927
|
+
"""
|
|
928
|
+
pos = np.asarray(position, dtype=float)
|
|
929
|
+
sz = np.asarray(size, dtype=float)
|
|
930
|
+
half_extents = sz / 2.0
|
|
931
|
+
zone = self._events.add_trigger_zone(pos, half_extents)
|
|
932
|
+
return zone
|
|
933
|
+
|
|
934
|
+
def _apply_welds(self) -> None:
|
|
935
|
+
if not self._welds:
|
|
936
|
+
return
|
|
937
|
+
from forge3d.math.quaternion import quat_to_rot
|
|
938
|
+
|
|
939
|
+
_ZEROS3 = np.zeros(3)
|
|
940
|
+
for body_id, (anchor_id, offset) in self._welds.items():
|
|
941
|
+
try:
|
|
942
|
+
anchor = self._physics._get_body(anchor_id)
|
|
943
|
+
except RuntimeError:
|
|
944
|
+
continue
|
|
945
|
+
R_anchor = quat_to_rot(anchor.quat)
|
|
946
|
+
new_pos = anchor.pos + R_anchor @ offset
|
|
947
|
+
self._physics.update_body_pose(
|
|
948
|
+
body_id, new_pos, anchor.quat, vel=_ZEROS3, omega=_ZEROS3
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
def _sync_robot(self, robot: Any) -> None:
|
|
952
|
+
from forge3d.math.quaternion import quat_from_rot
|
|
953
|
+
|
|
954
|
+
boxes = robot.link_visual_boxes()
|
|
955
|
+
for bid, (center, R, _he) in zip(robot._body_ids, boxes, strict=True):
|
|
956
|
+
quat = quat_from_rot(R)
|
|
957
|
+
self._physics.update_body_pose(bid, center, quat)
|
|
958
|
+
|
|
959
|
+
@property
|
|
960
|
+
def time(self) -> float:
|
|
961
|
+
"""Elapsed simulation time in seconds."""
|
|
962
|
+
return self._physics.time
|
|
963
|
+
|
|
964
|
+
# ── SceneSnapshot ─────────────────────────────────────────────────────────
|
|
965
|
+
|
|
966
|
+
def snapshot(self) -> Any:
|
|
967
|
+
"""Build a SceneSnapshot for the current state (used by Viewer/Recorder)."""
|
|
968
|
+
for robot in self._robots:
|
|
969
|
+
self._sync_robot(robot)
|
|
970
|
+
|
|
971
|
+
snap = self._physics.snapshot()
|
|
972
|
+
|
|
973
|
+
for mat_id, mat in self._materials.items():
|
|
974
|
+
if mat_id not in snap.materials:
|
|
975
|
+
snap.materials[mat_id] = mat._to_snapshot_material()
|
|
976
|
+
|
|
977
|
+
return snap
|
|
978
|
+
|
|
979
|
+
# ── Serialization ─────────────────────────────────────────────────────────
|
|
980
|
+
|
|
981
|
+
def save(self, path: Any) -> None:
|
|
982
|
+
"""Save the current world state to a JSON file.
|
|
983
|
+
|
|
984
|
+
Args:
|
|
985
|
+
path: Output path (str or :class:`pathlib.Path`).
|
|
986
|
+
|
|
987
|
+
Example::
|
|
988
|
+
|
|
989
|
+
world.save("checkpoint.json")
|
|
990
|
+
"""
|
|
991
|
+
from forge3d.io.world_snapshot import save_world
|
|
992
|
+
save_world(self, path)
|
|
993
|
+
|
|
994
|
+
@classmethod
|
|
995
|
+
def load(cls, path: Any) -> "World":
|
|
996
|
+
"""Load a world from a JSON file saved by :meth:`save`.
|
|
997
|
+
|
|
998
|
+
Args:
|
|
999
|
+
path: Path to a JSON file.
|
|
1000
|
+
|
|
1001
|
+
Returns:
|
|
1002
|
+
A new :class:`World` with all bodies restored.
|
|
1003
|
+
|
|
1004
|
+
Example::
|
|
1005
|
+
|
|
1006
|
+
world = forge3d.World.load("checkpoint.json")
|
|
1007
|
+
"""
|
|
1008
|
+
from forge3d.io.world_snapshot import load_world
|
|
1009
|
+
return load_world(path) # type: ignore[return-value]
|
|
1010
|
+
|
|
1011
|
+
def __repr__(self) -> str:
|
|
1012
|
+
return (
|
|
1013
|
+
f"World(t={self.time:.3f}s, "
|
|
1014
|
+
f"bodies={len(self._physics._bodies)}, "
|
|
1015
|
+
f"gravity={self._physics._gravity.tolist()})"
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _resolve_material(m: Material | str) -> tuple[str, Material | None]:
|
|
1023
|
+
"""Return (material_id, Material|None) from a Material object or string."""
|
|
1024
|
+
if isinstance(m, str):
|
|
1025
|
+
return m, None
|
|
1026
|
+
if isinstance(m, Material):
|
|
1027
|
+
return m._material_id(), m
|
|
1028
|
+
raise TypeError(
|
|
1029
|
+
f"material must be a str preset name or a Material object, "
|
|
1030
|
+
f"got {type(m).__name__!r}. "
|
|
1031
|
+
f"Valid presets: 'default', 'red', 'blue', 'green', 'orange', 'ground', 'gold', 'white'."
|
|
1032
|
+
)
|