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/__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
+ ]