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