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/__init__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""forge3d — pure-Python 3D game engine.
|
|
2
|
+
|
|
3
|
+
"Easy like pygame, beautiful like simulation."
|
|
4
|
+
Coordinate system: z-up, SI units (metres, kg, seconds).
|
|
5
|
+
|
|
6
|
+
Minimal example (14 lines)::
|
|
7
|
+
|
|
8
|
+
import forge3d as f3d
|
|
9
|
+
|
|
10
|
+
world = f3d.World(gravity=(0, 0, -9.81))
|
|
11
|
+
world.add_ground()
|
|
12
|
+
box = world.add_box(size=(1, 1, 1), position=(0, 0, 5), mass=1.0)
|
|
13
|
+
viewer = f3d.Viewer(world, max_frames=90)
|
|
14
|
+
while viewer.is_open:
|
|
15
|
+
world.step(dt=1 / 60)
|
|
16
|
+
viewer.draw()
|
|
17
|
+
print(f"Box final z = {box.position[2]:.2f} m")
|
|
18
|
+
|
|
19
|
+
App-style game loop::
|
|
20
|
+
|
|
21
|
+
app = f3d.App("My World")
|
|
22
|
+
|
|
23
|
+
@app.on_start
|
|
24
|
+
def setup(world):
|
|
25
|
+
world.add_ground()
|
|
26
|
+
global ball
|
|
27
|
+
ball = world.add_sphere(position=(0, 0, 5))
|
|
28
|
+
|
|
29
|
+
@app.on_update
|
|
30
|
+
def update(world, dt, inp):
|
|
31
|
+
if inp.key_pressed(f3d.Key.SPACE):
|
|
32
|
+
world.apply_impulse(ball, (0, 0, 8))
|
|
33
|
+
|
|
34
|
+
app.run()
|
|
35
|
+
|
|
36
|
+
Load a 3D model::
|
|
37
|
+
|
|
38
|
+
from forge3d.io import load_obj
|
|
39
|
+
mesh = load_obj("assets/models/cube.obj")
|
|
40
|
+
body = world.add_mesh(mesh, position=(0, 0, 3), mass=1.0)
|
|
41
|
+
|
|
42
|
+
Public API:
|
|
43
|
+
App — game-loop abstraction (on_start, on_update, on_render)
|
|
44
|
+
World — physics world with scene construction helpers
|
|
45
|
+
Body — handle to a simulated rigid body
|
|
46
|
+
Shape — shape descriptor (box, sphere, capsule, mesh)
|
|
47
|
+
Material — surface appearance (PBR: color, roughness, metallic, texture)
|
|
48
|
+
Viewer — realtime render loop + Input access
|
|
49
|
+
Recorder — capture simulation to video / image sequence
|
|
50
|
+
Input — per-frame keyboard/mouse state snapshot
|
|
51
|
+
Key — keyboard key name constants
|
|
52
|
+
OrbitCamera — orbit-around-target camera controller
|
|
53
|
+
FollowCamera — smooth body-tracking camera controller
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
from forge3d.app import App
|
|
59
|
+
from forge3d.errors import Forge3dError, PhysicsError, RenderError, ValidationError
|
|
60
|
+
from forge3d.backend import backend_name as _backend_name # noqa: F401
|
|
61
|
+
from forge3d.camera import FollowCamera, OrbitCamera
|
|
62
|
+
from forge3d.constraints import JointHandle
|
|
63
|
+
from forge3d.collision.layers import CollisionLayer
|
|
64
|
+
from forge3d.events import CollisionEvent, CollisionHandler
|
|
65
|
+
from forge3d.facade import Body, Material, Shape, World
|
|
66
|
+
from forge3d.input import Input, Key
|
|
67
|
+
from forge3d.io.world_snapshot import StateRecorder
|
|
68
|
+
from forge3d.recorder import Recorder
|
|
69
|
+
from forge3d.viewer import Viewer
|
|
70
|
+
|
|
71
|
+
__version__ = "1.0.0"
|
|
72
|
+
__all__ = [
|
|
73
|
+
# Core
|
|
74
|
+
"World",
|
|
75
|
+
"Body",
|
|
76
|
+
"Shape",
|
|
77
|
+
"Material",
|
|
78
|
+
# Joints
|
|
79
|
+
"JointHandle",
|
|
80
|
+
# Events
|
|
81
|
+
"CollisionEvent",
|
|
82
|
+
"CollisionHandler",
|
|
83
|
+
# Collision layers
|
|
84
|
+
"CollisionLayer",
|
|
85
|
+
# Serialization
|
|
86
|
+
"StateRecorder",
|
|
87
|
+
# Game loop
|
|
88
|
+
"App",
|
|
89
|
+
# Input
|
|
90
|
+
"Input",
|
|
91
|
+
"Key",
|
|
92
|
+
# Camera
|
|
93
|
+
"OrbitCamera",
|
|
94
|
+
"FollowCamera",
|
|
95
|
+
# Output
|
|
96
|
+
"Viewer",
|
|
97
|
+
"Recorder",
|
|
98
|
+
# Errors
|
|
99
|
+
"Forge3dError",
|
|
100
|
+
"ValidationError",
|
|
101
|
+
"PhysicsError",
|
|
102
|
+
"RenderError",
|
|
103
|
+
# Version
|
|
104
|
+
"__version__",
|
|
105
|
+
]
|
forge3d/app.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""forge3d App — high-level game-loop abstraction.
|
|
2
|
+
|
|
3
|
+
Provides a decorator-driven API similar to popular game frameworks::
|
|
4
|
+
|
|
5
|
+
import forge3d as f3d
|
|
6
|
+
|
|
7
|
+
app = f3d.App("Physics Sandbox")
|
|
8
|
+
ball = None
|
|
9
|
+
|
|
10
|
+
@app.on_start
|
|
11
|
+
def setup(world: f3d.World) -> None:
|
|
12
|
+
global ball
|
|
13
|
+
world.add_ground()
|
|
14
|
+
ball = world.add_sphere(radius=0.4, position=(0, 0, 6))
|
|
15
|
+
|
|
16
|
+
@app.on_update
|
|
17
|
+
def update(world: f3d.World, dt: float, inp: f3d.Input) -> None:
|
|
18
|
+
if inp.key_pressed(f3d.Key.SPACE):
|
|
19
|
+
world.apply_impulse(ball, (0, 0, 8))
|
|
20
|
+
|
|
21
|
+
app.run()
|
|
22
|
+
|
|
23
|
+
Signature flexibility
|
|
24
|
+
---------------------
|
|
25
|
+
``on_start`` callback may accept 0 or 1 argument (the ``World``).
|
|
26
|
+
``on_update`` callback may accept 0, 1 (world), 2 (world, dt), or
|
|
27
|
+
3 (world, dt, inp) arguments — missing arguments are simply not passed.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import inspect
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class App:
|
|
38
|
+
"""High-level forge3d application with a managed physics + render loop.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
title : Window title (shown in windowed mode).
|
|
43
|
+
width : Render width in pixels.
|
|
44
|
+
height : Render height in pixels.
|
|
45
|
+
fps : Target frames per second; also the physics step rate.
|
|
46
|
+
gravity : World gravity vector (x, y, z) in m/s².
|
|
47
|
+
|
|
48
|
+
Examples
|
|
49
|
+
--------
|
|
50
|
+
>>> app = f3d.App("My World", fps=60)
|
|
51
|
+
>>> @app.on_start
|
|
52
|
+
... def setup(world):
|
|
53
|
+
... world.add_ground()
|
|
54
|
+
... world.add_box(position=(0, 0, 5))
|
|
55
|
+
>>> @app.on_update
|
|
56
|
+
... def update(world, dt, inp):
|
|
57
|
+
... if inp.key_held(f3d.Key.W):
|
|
58
|
+
... world.apply_impulse(world.bodies[0], (0, 5*dt, 0))
|
|
59
|
+
>>> app.run(max_frames=120) # doctest: +SKIP
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
title: str = "forge3d",
|
|
65
|
+
width: int = 1280,
|
|
66
|
+
height: int = 720,
|
|
67
|
+
fps: float = 60.0,
|
|
68
|
+
gravity: Any = (0.0, 0.0, -9.81),
|
|
69
|
+
) -> None:
|
|
70
|
+
from forge3d.facade import World
|
|
71
|
+
|
|
72
|
+
self._world: World = World(gravity=gravity)
|
|
73
|
+
self._title = title
|
|
74
|
+
self._width = width
|
|
75
|
+
self._height = height
|
|
76
|
+
self._fps = float(fps)
|
|
77
|
+
self._dt = 1.0 / self._fps
|
|
78
|
+
|
|
79
|
+
self._on_start: Callable | None = None
|
|
80
|
+
self._on_update: Callable | None = None
|
|
81
|
+
self._on_render: Callable | None = None
|
|
82
|
+
|
|
83
|
+
# ── Properties ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def world(self) -> Any:
|
|
87
|
+
"""The managed :class:`~forge3d.facade.World` instance."""
|
|
88
|
+
return self._world
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def fps(self) -> float:
|
|
92
|
+
"""Target frames per second."""
|
|
93
|
+
return self._fps
|
|
94
|
+
|
|
95
|
+
@fps.setter
|
|
96
|
+
def fps(self, value: float) -> None:
|
|
97
|
+
if value <= 0:
|
|
98
|
+
raise ValueError(f"fps must be positive, got {value}")
|
|
99
|
+
self._fps = float(value)
|
|
100
|
+
self._dt = 1.0 / self._fps
|
|
101
|
+
|
|
102
|
+
# ── Decorator callbacks ───────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def on_start(self, func: Callable) -> Callable:
|
|
105
|
+
"""Register a callback called once before the game loop begins.
|
|
106
|
+
|
|
107
|
+
Signature: ``fn()`` or ``fn(world)``
|
|
108
|
+
"""
|
|
109
|
+
self._on_start = func
|
|
110
|
+
return func
|
|
111
|
+
|
|
112
|
+
def on_update(self, func: Callable) -> Callable:
|
|
113
|
+
"""Register a callback called every frame, before :meth:`World.step`.
|
|
114
|
+
|
|
115
|
+
Signature: one of:
|
|
116
|
+
- ``fn()``
|
|
117
|
+
- ``fn(world)``
|
|
118
|
+
- ``fn(world, dt)``
|
|
119
|
+
- ``fn(world, dt, inp)``
|
|
120
|
+
"""
|
|
121
|
+
self._on_update = func
|
|
122
|
+
return func
|
|
123
|
+
|
|
124
|
+
def on_render(self, func: Callable) -> Callable:
|
|
125
|
+
"""Register a callback called after :meth:`Viewer.draw` each frame.
|
|
126
|
+
|
|
127
|
+
Signature: ``fn()`` or ``fn(world)`` or ``fn(world, viewer)``
|
|
128
|
+
"""
|
|
129
|
+
self._on_render = func
|
|
130
|
+
return func
|
|
131
|
+
|
|
132
|
+
# ── Run ───────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def run(self, max_frames: int | None = None) -> None:
|
|
135
|
+
"""Start the game loop.
|
|
136
|
+
|
|
137
|
+
Initialises the viewer, fires :meth:`on_start`, then loops:
|
|
138
|
+
1. Build :class:`~forge3d.input.Input` snapshot
|
|
139
|
+
2. Call :meth:`on_update` with ``(world, dt, inp)``
|
|
140
|
+
3. ``world.step(dt)``
|
|
141
|
+
4. ``viewer.draw()``
|
|
142
|
+
5. Fire :meth:`on_render` if registered
|
|
143
|
+
6. Advance frame; stop when ``max_frames`` reached or window closed
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
max_frames : Maximum frames to render before stopping automatically.
|
|
148
|
+
``None`` (default) runs until the window is closed or
|
|
149
|
+
the default headless limit is reached.
|
|
150
|
+
"""
|
|
151
|
+
from forge3d.input import _InputBuilder
|
|
152
|
+
from forge3d.viewer import Viewer
|
|
153
|
+
|
|
154
|
+
# Fire on_start
|
|
155
|
+
if self._on_start is not None:
|
|
156
|
+
_call_flexible(self._on_start, self._world)
|
|
157
|
+
|
|
158
|
+
viewer = Viewer(
|
|
159
|
+
self._world,
|
|
160
|
+
width=self._width,
|
|
161
|
+
height=self._height,
|
|
162
|
+
max_frames=max_frames,
|
|
163
|
+
)
|
|
164
|
+
inp_builder = _InputBuilder()
|
|
165
|
+
|
|
166
|
+
while viewer.is_open:
|
|
167
|
+
inp = inp_builder.build()
|
|
168
|
+
|
|
169
|
+
if self._on_update is not None:
|
|
170
|
+
_call_flexible(self._on_update, self._world, self._dt, inp)
|
|
171
|
+
|
|
172
|
+
self._world.step(self._dt)
|
|
173
|
+
|
|
174
|
+
viewer.draw()
|
|
175
|
+
|
|
176
|
+
if self._on_render is not None:
|
|
177
|
+
_call_flexible(self._on_render, self._world, viewer)
|
|
178
|
+
|
|
179
|
+
inp_builder.end_frame()
|
|
180
|
+
|
|
181
|
+
viewer.close()
|
|
182
|
+
|
|
183
|
+
def __repr__(self) -> str:
|
|
184
|
+
return (
|
|
185
|
+
f"App(title={self._title!r}, "
|
|
186
|
+
f"{self._width}×{self._height}, "
|
|
187
|
+
f"fps={self._fps:.0f})"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _call_flexible(func: Callable, *positional: Any) -> Any:
|
|
195
|
+
"""Call *func* with as many leading positional args as its signature allows.
|
|
196
|
+
|
|
197
|
+
This lets users write ``fn(world)``, ``fn(world, dt)``, or
|
|
198
|
+
``fn(world, dt, inp)`` interchangeably — missing args are omitted.
|
|
199
|
+
Works with regular functions, lambdas, and bound methods.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
sig = inspect.signature(func)
|
|
203
|
+
n = len([
|
|
204
|
+
p for p in sig.parameters.values()
|
|
205
|
+
if p.kind in (
|
|
206
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
207
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
208
|
+
)
|
|
209
|
+
])
|
|
210
|
+
# If a parameter has VAR_POSITIONAL (*args), pass everything
|
|
211
|
+
has_var_positional = any(
|
|
212
|
+
p.kind == inspect.Parameter.VAR_POSITIONAL
|
|
213
|
+
for p in sig.parameters.values()
|
|
214
|
+
)
|
|
215
|
+
if has_var_positional:
|
|
216
|
+
return func(*positional)
|
|
217
|
+
return func(*positional[:n])
|
|
218
|
+
except (ValueError, TypeError):
|
|
219
|
+
return func(*positional)
|
forge3d/backend.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Backend abstraction: numpy ↔ jax.
|
|
2
|
+
|
|
3
|
+
Select via environment variable before process start:
|
|
4
|
+
ENGINE_BACKEND=numpy (default)
|
|
5
|
+
ENGINE_BACKEND=jax
|
|
6
|
+
|
|
7
|
+
Engine code imports names from here:
|
|
8
|
+
from forge3d.backend import xp, jit, vmap, set_at, new_prng_key, split_key, rand_uniform
|
|
9
|
+
|
|
10
|
+
xp — numpy or jax.numpy module
|
|
11
|
+
jit — jax.jit or no-op decorator
|
|
12
|
+
vmap — jax.vmap or loop-based fallback
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import functools
|
|
18
|
+
import os
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as _np
|
|
22
|
+
|
|
23
|
+
_BACKEND: str = os.environ.get("ENGINE_BACKEND", "numpy")
|
|
24
|
+
|
|
25
|
+
# Declare so static analysis sees these names regardless of which branch runs.
|
|
26
|
+
xp: Any
|
|
27
|
+
jit: Any
|
|
28
|
+
vmap: Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def backend_name() -> str:
|
|
32
|
+
"""Return the active backend name ('numpy' or 'jax')."""
|
|
33
|
+
return _BACKEND
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── JAX backend ───────────────────────────────────────────────────────────────
|
|
37
|
+
if _BACKEND == "jax":
|
|
38
|
+
import jax as _jax
|
|
39
|
+
import jax.numpy as _jnp
|
|
40
|
+
|
|
41
|
+
xp = _jnp
|
|
42
|
+
jit = _jax.jit
|
|
43
|
+
vmap = _jax.vmap
|
|
44
|
+
|
|
45
|
+
def set_at(arr: Any, idx: Any, val: Any) -> Any:
|
|
46
|
+
"""Return arr with arr[idx] = val — JAX functional update."""
|
|
47
|
+
return arr.at[idx].set(val)
|
|
48
|
+
|
|
49
|
+
def new_prng_key(seed: int = 0) -> Any:
|
|
50
|
+
return _jax.random.PRNGKey(seed)
|
|
51
|
+
|
|
52
|
+
def split_key(key: Any, num: int = 2) -> Any:
|
|
53
|
+
return _jax.random.split(key, num)
|
|
54
|
+
|
|
55
|
+
def rand_uniform(
|
|
56
|
+
key: Any,
|
|
57
|
+
shape: tuple[int, ...],
|
|
58
|
+
low: float = 0.0,
|
|
59
|
+
high: float = 1.0,
|
|
60
|
+
) -> Any:
|
|
61
|
+
return _jax.random.uniform(key, shape=shape, minval=low, maxval=high)
|
|
62
|
+
|
|
63
|
+
# ── NumPy backend ─────────────────────────────────────────────────────────────
|
|
64
|
+
elif _BACKEND == "numpy":
|
|
65
|
+
xp = _np
|
|
66
|
+
|
|
67
|
+
def jit(fn: Any, **_: Any) -> Any: # type: ignore[misc]
|
|
68
|
+
"""No-op decorator: functions run eagerly under NumPy."""
|
|
69
|
+
return fn
|
|
70
|
+
|
|
71
|
+
def vmap( # type: ignore[misc]
|
|
72
|
+
fn: Any,
|
|
73
|
+
in_axes: int = 0,
|
|
74
|
+
out_axes: int = 0,
|
|
75
|
+
) -> Any:
|
|
76
|
+
"""Loop-based vmap fallback: maps fn over axis-0 of all inputs."""
|
|
77
|
+
|
|
78
|
+
@functools.wraps(fn)
|
|
79
|
+
def _wrapped(*args: Any) -> Any:
|
|
80
|
+
n: int = args[0].shape[0]
|
|
81
|
+
results = [fn(*[a[i] for a in args]) for i in range(n)]
|
|
82
|
+
if not results:
|
|
83
|
+
return _np.array([])
|
|
84
|
+
if isinstance(results[0], tuple):
|
|
85
|
+
return tuple(_np.stack([r[j] for r in results]) for j in range(len(results[0])))
|
|
86
|
+
return _np.stack(results)
|
|
87
|
+
|
|
88
|
+
return _wrapped
|
|
89
|
+
|
|
90
|
+
def set_at(arr: Any, idx: Any, val: Any) -> Any: # type: ignore[misc]
|
|
91
|
+
"""Return a copy of arr with arr[idx] replaced by val."""
|
|
92
|
+
out = _np.array(arr, copy=True)
|
|
93
|
+
out[idx] = val
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
def new_prng_key(seed: int = 0) -> Any: # type: ignore[misc]
|
|
97
|
+
"""Create a uint32[2] key compatible with JAX PRNG conventions."""
|
|
98
|
+
return _np.array([seed, 0], dtype=_np.uint32)
|
|
99
|
+
|
|
100
|
+
def split_key(key: Any, num: int = 2) -> Any: # type: ignore[misc]
|
|
101
|
+
"""Split key into num independent keys (deterministic, sequential seeds)."""
|
|
102
|
+
return _np.stack(
|
|
103
|
+
[_np.array([int(key[0]) + i, int(key[1])], dtype=_np.uint32) for i in range(num)]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def rand_uniform( # type: ignore[misc]
|
|
107
|
+
key: Any,
|
|
108
|
+
shape: tuple[int, ...],
|
|
109
|
+
low: float = 0.0,
|
|
110
|
+
high: float = 1.0,
|
|
111
|
+
) -> Any:
|
|
112
|
+
rng = _np.random.default_rng(int(key[0]))
|
|
113
|
+
return rng.uniform(low=low, high=high, size=shape)
|
|
114
|
+
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Unknown ENGINE_BACKEND={_BACKEND!r}. Set the environment variable to 'numpy' or 'jax'."
|
|
118
|
+
)
|
forge3d/camera.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""forge3d camera controllers.
|
|
2
|
+
|
|
3
|
+
Controllers compute a :class:`~forge3d.render.snapshot.CameraSnapshot` from
|
|
4
|
+
user-facing parameters (distance, angles, target) and can be driven by
|
|
5
|
+
:class:`~forge3d.input.Input` events each frame.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
cam = f3d.OrbitCamera(target=(0, 0, 1), distance=10, elevation=30)
|
|
10
|
+
|
|
11
|
+
while viewer.is_open:
|
|
12
|
+
inp = viewer.input
|
|
13
|
+
if inp.mouse_button(1): # right-drag to orbit
|
|
14
|
+
dx, dy = inp.mouse_delta()
|
|
15
|
+
cam.rotate(d_azimuth=dx * 0.4, d_elevation=-dy * 0.4)
|
|
16
|
+
cam.zoom(inp.scroll_delta() * 0.5)
|
|
17
|
+
viewer.set_camera(cam.to_snapshot())
|
|
18
|
+
world.step()
|
|
19
|
+
viewer.draw()
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from forge3d.facade import Body
|
|
30
|
+
from forge3d.render.snapshot import CameraSnapshot
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _normalize(v: np.ndarray) -> np.ndarray:
|
|
37
|
+
n = np.linalg.norm(v)
|
|
38
|
+
return v / (n + 1e-12)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── OrbitCamera ───────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OrbitCamera:
|
|
45
|
+
"""Spherical-coordinate camera that orbits a target point.
|
|
46
|
+
|
|
47
|
+
The camera is always looking at *target* from a point on a sphere of
|
|
48
|
+
radius *distance*. *azimuth* (yaw) rotates around the z-axis;
|
|
49
|
+
*elevation* (pitch) tilts above/below the horizon.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
target : Point the camera looks at, world-frame (x, y, z).
|
|
54
|
+
distance : Eye-to-target distance in metres.
|
|
55
|
+
azimuth : Horizontal angle in degrees (0 = +x axis).
|
|
56
|
+
elevation : Vertical angle in degrees above the horizon (clamped ±89°).
|
|
57
|
+
fov_deg : Vertical field-of-view in degrees.
|
|
58
|
+
|
|
59
|
+
Usage::
|
|
60
|
+
|
|
61
|
+
cam = f3d.OrbitCamera(target=(0, 0, 1), distance=8)
|
|
62
|
+
cam.rotate(d_azimuth=45) # spin 45° around target
|
|
63
|
+
cam.zoom(2) # move 20% closer (delta > 0 = closer)
|
|
64
|
+
snap = cam.to_snapshot()
|
|
65
|
+
viewer.set_camera(snap)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
target: Any = (0.0, 0.0, 0.0),
|
|
71
|
+
distance: float = 10.0,
|
|
72
|
+
azimuth: float = 45.0,
|
|
73
|
+
elevation: float = 30.0,
|
|
74
|
+
fov_deg: float = 45.0,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.target = np.asarray(target, dtype=float)
|
|
77
|
+
self.distance = max(0.05, float(distance))
|
|
78
|
+
self.azimuth = float(azimuth)
|
|
79
|
+
self.elevation = float(np.clip(elevation, -89.0, 89.0))
|
|
80
|
+
self.fov_deg = float(fov_deg)
|
|
81
|
+
|
|
82
|
+
# ── Derived geometry ──────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def position(self) -> np.ndarray:
|
|
86
|
+
"""Current world-space eye position."""
|
|
87
|
+
az = np.radians(self.azimuth)
|
|
88
|
+
el = np.radians(self.elevation)
|
|
89
|
+
x = self.distance * np.cos(el) * np.cos(az)
|
|
90
|
+
y = self.distance * np.cos(el) * np.sin(az)
|
|
91
|
+
z = self.distance * np.sin(el)
|
|
92
|
+
return self.target + np.array([x, y, z])
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def _right(self) -> np.ndarray:
|
|
96
|
+
az = np.radians(self.azimuth)
|
|
97
|
+
return np.array([-np.sin(az), np.cos(az), 0.0])
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def _up_screen(self) -> np.ndarray:
|
|
101
|
+
fwd = _normalize(self.target - self.position)
|
|
102
|
+
return _normalize(np.cross(self._right, fwd))
|
|
103
|
+
|
|
104
|
+
# ── Manipulation ──────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def rotate(self, d_azimuth: float = 0.0, d_elevation: float = 0.0) -> OrbitCamera:
|
|
107
|
+
"""Orbit around the target.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
d_azimuth : Azimuth delta in degrees.
|
|
112
|
+
d_elevation : Elevation delta in degrees (clamped to ±89°).
|
|
113
|
+
"""
|
|
114
|
+
self.azimuth += d_azimuth
|
|
115
|
+
self.elevation = float(np.clip(self.elevation + d_elevation, -89.0, 89.0))
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def zoom(self, delta: float) -> OrbitCamera:
|
|
119
|
+
"""Adjust distance.
|
|
120
|
+
|
|
121
|
+
*delta > 0* moves the camera closer; *delta < 0* moves it farther.
|
|
122
|
+
The factor is ``distance *= (1 - delta * 0.1)`` so a delta of 1.0
|
|
123
|
+
reduces distance by 10%.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
delta : Zoom speed; typically ``Input.scroll_delta()``.
|
|
128
|
+
"""
|
|
129
|
+
self.distance = max(0.05, self.distance * (1.0 - delta * 0.1))
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def set_distance(self, d: float) -> OrbitCamera:
|
|
133
|
+
"""Directly set the eye-to-target distance."""
|
|
134
|
+
self.distance = max(0.05, float(d))
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def pan(self, dx: float, dy: float) -> OrbitCamera:
|
|
138
|
+
"""Translate the *target* point in screen space.
|
|
139
|
+
|
|
140
|
+
Useful for middle-mouse-button panning.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
dx, dy : Pixel offsets in screen space.
|
|
145
|
+
"""
|
|
146
|
+
pan_speed = self.distance * 0.001
|
|
147
|
+
self.target += self._right * (-dx * pan_speed) + self._up_screen * (dy * pan_speed)
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def look_at(self, target: Any) -> OrbitCamera:
|
|
151
|
+
"""Point the camera at a new target without changing distance."""
|
|
152
|
+
self.target = np.asarray(target, dtype=float)
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
# ── Snapshot ──────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def to_snapshot(self) -> CameraSnapshot:
|
|
158
|
+
"""Build a :class:`~forge3d.render.snapshot.CameraSnapshot` from the
|
|
159
|
+
current orbit state — suitable for :meth:`Viewer.set_camera`.
|
|
160
|
+
"""
|
|
161
|
+
from forge3d.render.snapshot import CameraSnapshot
|
|
162
|
+
|
|
163
|
+
return CameraSnapshot(
|
|
164
|
+
position=self.position.copy(),
|
|
165
|
+
target=self.target.copy(),
|
|
166
|
+
up=np.array([0.0, 0.0, 1.0]),
|
|
167
|
+
fov_deg=self.fov_deg,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str:
|
|
171
|
+
return (
|
|
172
|
+
f"OrbitCamera(target={self.target.round(2).tolist()}, "
|
|
173
|
+
f"az={self.azimuth:.1f}°, el={self.elevation:.1f}°, "
|
|
174
|
+
f"d={self.distance:.2f} m)"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── FollowCamera ──────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class FollowCamera:
|
|
182
|
+
"""Camera that smoothly tracks a :class:`~forge3d.facade.Body`.
|
|
183
|
+
|
|
184
|
+
The camera sits at *body.position + offset* and looks at *body.position*.
|
|
185
|
+
A smoothing factor ``alpha`` low-pass filters position changes
|
|
186
|
+
(0 = frozen, 1 = instant snap).
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
body : The :class:`Body` to follow.
|
|
191
|
+
offset : Camera offset from the body in world frame (x, y, z).
|
|
192
|
+
alpha : Smoothing factor per frame [0, 1]. Default 0.1 (smooth).
|
|
193
|
+
fov_deg : Vertical field-of-view.
|
|
194
|
+
|
|
195
|
+
Usage::
|
|
196
|
+
|
|
197
|
+
cam = f3d.FollowCamera(ball, offset=(0, -8, 4), alpha=0.08)
|
|
198
|
+
# each frame:
|
|
199
|
+
viewer.set_camera(cam.to_snapshot())
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
body: Body,
|
|
205
|
+
offset: Any = (0.0, -8.0, 4.0),
|
|
206
|
+
alpha: float = 0.1,
|
|
207
|
+
fov_deg: float = 45.0,
|
|
208
|
+
) -> None:
|
|
209
|
+
self._body = body
|
|
210
|
+
self.offset = np.asarray(offset, dtype=float)
|
|
211
|
+
self.alpha = float(np.clip(alpha, 0.0, 1.0))
|
|
212
|
+
self.fov_deg = float(fov_deg)
|
|
213
|
+
# Smoothed eye position — initialised to exact value
|
|
214
|
+
self._eye: np.ndarray = body.position + self.offset
|
|
215
|
+
|
|
216
|
+
def to_snapshot(self) -> CameraSnapshot:
|
|
217
|
+
"""Update smoothed position and return a CameraSnapshot."""
|
|
218
|
+
from forge3d.render.snapshot import CameraSnapshot
|
|
219
|
+
|
|
220
|
+
target = self._body.position.copy()
|
|
221
|
+
desired_eye = target + self.offset
|
|
222
|
+
self._eye = self._eye + self.alpha * (desired_eye - self._eye)
|
|
223
|
+
|
|
224
|
+
return CameraSnapshot(
|
|
225
|
+
position=self._eye.copy(),
|
|
226
|
+
target=target,
|
|
227
|
+
up=np.array([0.0, 0.0, 1.0]),
|
|
228
|
+
fov_deg=self.fov_deg,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def __repr__(self) -> str:
|
|
232
|
+
return (
|
|
233
|
+
f"FollowCamera(body={self._body!r}, "
|
|
234
|
+
f"offset={self.offset.tolist()}, alpha={self.alpha:.2f})"
|
|
235
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""forge3d.collision — narrow-phase collision detection.
|
|
2
|
+
|
|
3
|
+
Exported helpers:
|
|
4
|
+
detect_contacts — main entry point for world.step()
|
|
5
|
+
gjk — GJK intersection/distance test
|
|
6
|
+
gjk_contact — GJK + EPA: returns (depth, normal) for intersecting pairs
|
|
7
|
+
ContactPoint — per-contact data (pos, normal, depth, body indices)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from forge3d.collision.detection import ContactPoint, detect_contacts
|
|
11
|
+
from forge3d.collision.gjk import gjk, gjk_contact, gjk_distance, gjk_intersect
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ContactPoint",
|
|
15
|
+
"detect_contacts",
|
|
16
|
+
"gjk",
|
|
17
|
+
"gjk_contact",
|
|
18
|
+
"gjk_distance",
|
|
19
|
+
"gjk_intersect",
|
|
20
|
+
]
|