Metadata-Version: 2.4
Name: pyforge3d
Version: 1.0.0
Summary: Pure-Python 3D physics game engine — easy like pygame, beautiful like simulation.
Project-URL: Homepage, https://github.com/iruki-dev/forge3d
Project-URL: Repository, https://github.com/iruki-dev/forge3d
Project-URL: Documentation, https://forge3d.readthedocs.io
Project-URL: Bug Tracker, https://github.com/iruki-dev/forge3d/issues
Project-URL: Changelog, https://github.com/iruki-dev/forge3d/blob/main/CHANGELOG.md
Author-email: iruki <me@iruki.dev>
License: MIT License
        
        Copyright (c) 2026 forge3d contributors
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: 3d,collision,game-engine,jax,numpy,physics,reinforcement-learning,rendering,rigid-body,robotics,simulation
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Games/Entertainment :: Simulation
Classifier: Topic :: Scientific/Engineering :: Physics
Classifier: Topic :: Scientific/Engineering :: Visualization
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: jax[cpu]>=0.4.25
Requires-Dist: numpy>=1.26
Requires-Dist: scipy>=1.12
Requires-Dist: sympy>=1.13
Provides-Extra: all
Requires-Dist: gymnasium>=0.29; extra == 'all'
Requires-Dist: imageio-ffmpeg>=0.5; extra == 'all'
Requires-Dist: imageio>=2.34; extra == 'all'
Requires-Dist: moderngl>=5.10; extra == 'all'
Requires-Dist: mypy>=1.9; extra == 'all'
Requires-Dist: optax>=0.2; extra == 'all'
Requires-Dist: pyglet>=2.0; extra == 'all'
Requires-Dist: pytest-cov>=5.0; extra == 'all'
Requires-Dist: pytest>=8.0; extra == 'all'
Requires-Dist: ruff>=0.4; extra == 'all'
Requires-Dist: stable-baselines3>=2.2; extra == 'all'
Provides-Extra: dev
Requires-Dist: imageio-ffmpeg>=0.5; extra == 'dev'
Requires-Dist: imageio>=2.34; extra == 'dev'
Requires-Dist: moderngl>=5.10; extra == 'dev'
Requires-Dist: mypy>=1.9; extra == 'dev'
Requires-Dist: pyglet>=2.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
Requires-Dist: mkdocstrings[python]>=0.25; extra == 'docs'
Provides-Extra: render
Requires-Dist: imageio-ffmpeg>=0.5; extra == 'render'
Requires-Dist: imageio>=2.34; extra == 'render'
Requires-Dist: moderngl>=5.10; extra == 'render'
Requires-Dist: pyglet>=2.0; extra == 'render'
Provides-Extra: rl
Requires-Dist: gymnasium>=0.29; extra == 'rl'
Requires-Dist: optax>=0.2; extra == 'rl'
Requires-Dist: stable-baselines3>=2.2; extra == 'rl'
Description-Content-Type: text/markdown

# forge3d

> **Pure-Python 3D game engine — easy like pygame, beautiful like simulation.**

[![PyPI version](https://img.shields.io/pypi/v/pyforge3d.svg)](https://pypi.org/project/pyforge3d/)
[![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![CI](https://github.com/iruki-dev/forge3d/actions/workflows/ci.yml/badge.svg)](https://github.com/iruki-dev/forge3d/actions/workflows/ci.yml)
[![Tests](https://img.shields.io/badge/tests-459%20passing-brightgreen.svg)](#testing)

forge3d is a batteries-included 3D physics and game engine written entirely in Python — no
external physics engines (no MuJoCo, PyBullet, or Bullet). Dynamics, collision, and contact are
all solved by forge3d's own code, backed by NumPy and optional JAX acceleration.

```
┌──────────────────────────────────────────────────────────────┐
│                     Public API (Facade)                       │
│  App · World · Body · Shape · Material · Viewer · Recorder    │
│         Input · Key · OrbitCamera · FollowCamera              │
└──────────────┬──────────────────────────┬────────────────────┘
     ┌──────────▼──────────┐   ┌──────────▼───────────────────┐
     │  Physics core        │   │   Rendering layer             │
     │  RNEA · CRBA · ABA   │   │   RealtimeRenderer (OpenGL)  │
     │  SAT · GJK/EPA       │   │   HQRenderer (ray-tracer)    │
     │  Impulse contact     │   │   SceneSnapshot contract      │
     └─────────────────────┘   └──────────────────────────────┘
```

---

## Features

| Category | Details |
|---|---|
| **Game loop** | `App` class — `@on_start` / `@on_update` / `@on_render` decorators |
| **Input** | `Input` snapshot — `key_held`, `key_pressed`, `mouse_pos`, `scroll_delta` |
| **Camera** | `OrbitCamera`, `FollowCamera` — orbit, zoom, pan, smooth follow |
| **Physics** | Rigid-body dynamics (RNEA / CRBA / ABA), semi-implicit Euler integrator |
| **Collision** | SAT OBB-OBB (15-axis), sphere, capsule, GJK/EPA convex-mesh; AABB broad-phase |
| **Contact** | Impulse solver, Coulomb friction, Baumgarte correction, warm-starting |
| **Grasping** | `weld` / `release` kinematic constraints; friction-based pinch grasp |
| **Robots** | UR5 6-DOF FK/IK, DH parameters, Jacobian |
| **Rendering** | Real-time PBR (Cook-Torrance, PCF shadows, albedo/normal textures); HQ software ray-tracer |
| **RL** | Gymnasium-compatible `ReachEnv` / `PickPlaceEnv` |
| **Performance** | JAX JIT+vmap: 2 000× throughput vs. NumPy; AABB broad-phase O(n log n) |
| **Testing** | 215+ automated tests; PyBullet baseline comparison |
| **Typed** | Fully annotated with `py.typed` marker |

---

## Installation

```bash
# Core library (physics + rendering)
pip install pyforge3d

# With rendering dependencies
pip install "pyforge3d[render]"

# With RL dependencies
pip install "pyforge3d[rl]"

# Everything
pip install "pyforge3d[all]"

# Development install
git clone https://github.com/iruki-dev/forge3d
cd forge3d
pip install -e ".[dev]"
```

> **Install name vs import name:** `pip install pyforge3d` — then `import forge3d` in your code.

**Requirements:** Python 3.12+, NumPy, SciPy, JAX (CPU wheel)

---

## Quick start (15 lines)

```python
import forge3d as f3d

world = f3d.World(gravity=(0, 0, -9.81))
world.add_ground()
box = world.add_box(size=(1, 1, 1), position=(0, 0, 5), mass=1.0)

viewer = f3d.Viewer(world, max_frames=90)
while viewer.is_open:
    world.step(dt=1 / 60)
    viewer.draw()

print(f"Box landed at z = {box.position[2]:.3f} m")
```

---

## App-style game loop

The `App` class wraps the game loop into a decorator-driven flow — just like popular game
frameworks, but with full 3D physics built in.

```python
import forge3d as f3d

app = f3d.App("Physics Sandbox", width=1280, height=720, fps=60)
ball = None  # filled in on_start

@app.on_start
def setup(world: f3d.World) -> None:
    global ball
    world.add_ground()
    ball = world.add_sphere(radius=0.4, position=(0, 0, 6),
                             material=f3d.Material(color="orange"))

@app.on_update
def update(world: f3d.World, dt: float, inp: f3d.Input) -> None:
    # Jump on space
    if inp.key_pressed(f3d.Key.SPACE):
        world.apply_impulse(ball, (0, 0, 8))
    # Nudge with arrow keys
    if inp.key_held(f3d.Key.RIGHT):
        world.apply_impulse(ball, (3 * dt, 0, 0))
    if inp.key_held(f3d.Key.LEFT):
        world.apply_impulse(ball, (-3 * dt, 0, 0))

app.run()
```

---

## Recording a high-quality video

```python
import forge3d as f3d

world = f3d.World(gravity=(0, 0, -9.81))
world.add_ground(material=f3d.Material(color="ground", roughness=0.8))
world.add_sphere(radius=0.4, position=(0, 0, 4.4), mass=1.0,
                  restitution=0.8, material=f3d.Material(color="orange"))
world.set_camera(position=(4, -7, 3), target=(0, 0, 1))

rec = f3d.Recorder(world, mode="hq", resolution=(1920, 1080),
                   samples=16, output="bounce.mp4")
rec.run(duration=3.0, dt=1 / 240, fps=60)
```

The same `World` code — only the renderer changes. That is the SceneSnapshot contract working.

---

## Camera controls

```python
import forge3d as f3d

world = f3d.World()
world.add_ground()
ball = world.add_sphere(radius=0.5, position=(0, 0, 3))

# Orbit camera starts 10 m back, 30° elevation
cam = f3d.OrbitCamera(target=(0, 0, 1), distance=10, elevation=30)

viewer = f3d.Viewer(world, max_frames=300)
while viewer.is_open:
    inp = viewer.input          # per-frame Input snapshot
    cam.rotate(d_azimuth=inp.scroll_delta() * 5)
    if inp.mouse_button(1):     # right-drag to orbit
        dx, dy = inp.mouse_delta()
        cam.rotate(d_azimuth=dx * 0.5, d_elevation=-dy * 0.5)
    viewer.set_camera(cam.to_snapshot())
    world.step()
    viewer.draw()
```

---

## Robot arm control

```python
import numpy as np
import forge3d as f3d
import forge3d.robot as f3r

world = f3d.World()
world.add_ground()

arm = f3r.load("ur5", base_position=(0, 0, 0))
world.add(arm)

arm.set_joints([0.0, -np.pi/2, np.pi/2, -np.pi/2, -np.pi/2, 0.0])
world.step()

ee_pos, ee_rot = arm.ee_pose()
print(f"End-effector: {ee_pos.round(4)}")
```

---

## Reinforcement learning

```python
from apps.robot_rl.envs.reach_env import ReachEnv
from stable_baselines3 import PPO

env  = ReachEnv(render_mode=None)          # headless, fast
model = PPO("MlpPolicy", env, verbose=1)
model.learn(total_timesteps=200_000)
model.save("reach_policy")
```

JAX batch stepping — 2 000× throughput:

```python
import jax, jax.numpy as jnp
from forge3d.sim.jax_batch import batch_reach_reset, batch_reach_step

key = jax.random.PRNGKey(42)
q, tgt, obs = batch_reach_reset(key, n_envs=256)
q, obs, rew, done = batch_reach_step(q, tgt, jnp.zeros((256, 6)))
```

---

## API reference

### `forge3d.App`

| Member | Description |
|---|---|
| `App(title, width, height, fps, gravity)` | Create application |
| `@app.on_start` | `fn(world)` — called once before the loop |
| `@app.on_update` | `fn(world, dt, inp)` — called every frame before `step()` |
| `@app.on_render` | `fn(world, viewer)` — called after `draw()` |
| `app.world` | The managed `World` |
| `app.run(max_frames)` | Start the game loop |

### `forge3d.World`

| Member | Description |
|---|---|
| `World(gravity)` | Create world; default gravity `(0, 0, -9.81)` |
| `add_ground(material, size, height)` | Static ground plane |
| `add_box(size, position, mass, …)` | Dynamic box body |
| `add_sphere(radius, position, mass, …)` | Dynamic sphere body |
| `add_capsule(radius, half_length, …)` | Dynamic capsule body |
| `add_mesh(mesh_data, …)` | Dynamic convex-hull body |
| `remove(body)` | Remove a body from simulation |
| `clear(keep_statics)` | Remove all (or all dynamic) bodies |
| `get_body(name)` | Find body by name |
| `bodies` | `list[Body]` — all current bodies |
| `step(dt)` | Advance physics by `dt` s (default 1/60) |
| `snapshot()` | Build `SceneSnapshot` for rendering |
| `apply_impulse(body, impulse)` | Δv = impulse / mass |
| `teleport(body, position, quat)` | Instantly move a body |
| `weld(body, anchor, local_offset)` | Kinematic attachment |
| `release(body)` | Remove weld constraint |
| `set_camera(position, target, …)` | Default camera pose |
| `time` | Elapsed simulation time in seconds |

### `forge3d.Body`

| Member | Description |
|---|---|
| `position` | `(3,)` world-frame position |
| `velocity` | `(3,)` linear velocity m/s |
| `orientation` | `(4,)` quaternion `[w, x, y, z]` |
| `angular_velocity` | `(3,)` angular velocity rad/s |
| `name` | String label |
| `is_static` | `bool` |
| `mass` | float, kg |
| `apply_force(force)` | Accumulate force for next `step()` |
| `apply_torque(torque)` | Accumulate torque for next `step()` |
| `set_position(pos)` | Teleport shortcut |
| `set_velocity(vel)` | Velocity override |

### `forge3d.Input`

| Member | Description |
|---|---|
| `key_held(key)` | `bool` — key currently pressed |
| `key_pressed(key)` | `bool` — key just went down this frame |
| `key_released(key)` | `bool` — key just went up this frame |
| `mouse_pos()` | `(x, y)` pixels |
| `mouse_delta()` | `(dx, dy)` pixels since last frame |
| `mouse_button(n)` | `bool` — mouse button 0/1/2 |
| `scroll_delta()` | `float` — mouse wheel tick this frame |

### `forge3d.Key` — key name constants

```python
f3d.Key.SPACE, Key.ESCAPE, Key.ENTER, Key.BACKSPACE, Key.TAB
Key.UP, Key.DOWN, Key.LEFT, Key.RIGHT
Key.W, Key.A, Key.S, Key.D   # also Key.Q … Key.Z
Key.N0 … Key.N9
Key.F1 … Key.F12
Key.SHIFT, Key.CTRL, Key.ALT
```

### `forge3d.OrbitCamera`

| Member | Description |
|---|---|
| `OrbitCamera(target, distance, azimuth, elevation, fov_deg)` | Create camera |
| `rotate(d_azimuth, d_elevation)` | Orbit around target |
| `zoom(delta)` | Multiply distance; `delta > 0` → zoom in |
| `pan(dx, dy)` | Translate target in screen space |
| `position` | Current world-space eye position |
| `to_snapshot()` | `CameraSnapshot` for `viewer.set_camera()` |

### `forge3d.Shape`

```python
Shape.box(size=(1, 1, 1))
Shape.sphere(radius=0.5)
Shape.capsule(radius=0.2, half_length=0.5)
Shape.convex_mesh(mesh_data)          # from forge3d.io.load_obj()
```

### `forge3d.Material`

```python
Material(color="red")                 # built-in preset
Material(color=(0.9, 0.4, 0.1))       # RGB tuple in [0, 1]
Material(color="default", roughness=0.3, metallic=0.7)
Material(texture_path="wall.png")
```

Built-in colour presets: `"default"`, `"red"`, `"blue"`, `"green"`, `"orange"`,
`"ground"`, `"gold"`, `"white"`.

### `forge3d.Viewer`

```python
viewer = f3d.Viewer(world, width=1280, height=720, max_frames=None)
viewer.is_open          # False when window closed or max_frames reached
viewer.draw()           # render one frame → ndarray (H×W×3 uint8)
viewer.input            # current-frame Input snapshot
viewer.set_camera(cam)  # override camera (CameraSnapshot or OrbitCamera)
viewer.run(dt, max_frames, collect_frames)
viewer.close()
```

### `forge3d.Recorder`

```python
rec = f3d.Recorder(world, mode="realtime",        # or "hq"
                   resolution=(1920, 1080),
                   samples=64,                     # HQ only
                   output="sim.mp4")
rec.run(duration=5.0, dt=1/240, fps=60)
rec.run_policy(policy, env, duration=5.0)          # SB3-compatible
```

---

## Architecture

forge3d is designed around one principle: **physics and rendering never know about each other**.

```
world.step()                     # pure physics, no renderer import
snap = world.snapshot()          # SceneSnapshot — pure data
frame = renderer.render(snap)    # renderer consumes data only
```

The `SceneSnapshot` is the sole bridge. Swap the renderer without touching the physics.

```
Physics core        → SceneSnapshot →  RealtimeRenderer (OpenGL)
(RNEA/CRBA/ABA)                    →  HQRenderer       (ray-tracer)
                                   →  headless / training (no snapshot)
```

---

## Project structure

```
forge3d/
├── src/forge3d/
│   ├── __init__.py          # Public API: World, App, Body, Shape, Material…
│   ├── app.py               # App — game-loop abstraction
│   ├── input.py             # Input, Key, _InputBuilder
│   ├── camera.py            # OrbitCamera, FollowCamera
│   ├── facade.py            # World, Body, Shape, Material facades
│   ├── viewer.py            # Viewer (realtime render loop)
│   ├── recorder.py          # Recorder (video capture)
│   ├── backend.py           # NumPy ↔ JAX backend switch
│   ├── math/                # SE3, quaternion, spatial algebra
│   ├── dynamics/            # RNEA, CRBA, ABA
│   ├── collision/
│   │   ├── detection.py     # SAT + AABB broad-phase
│   │   ├── gjk.py           # GJK distance / intersection
│   │   └── epa.py           # EPA penetration depth
│   ├── contact/
│   │   └── solver.py        # Impulse solver (PGS + Coulomb friction)
│   ├── model/               # RigidBodyModel, DH joints, kinematics
│   ├── sim/
│   │   ├── world.py         # PhysicsWorld (step, snapshot, CRUD)
│   │   ├── jax_batch.py     # JAX JIT+vmap batch physics
│   │   └── domain_rand.py   # Domain randomisation
│   ├── robot/               # Robot, UR5 preset, FK/IK
│   ├── render/
│   │   ├── snapshot.py      # SceneSnapshot, Transform, Material…
│   │   ├── base.py          # Renderer ABC
│   │   ├── realtime/        # OpenGL rasteriser (PBR, PCF shadow)
│   │   └── hq/              # Software ray-tracer (Blinn-Phong, AO, AA)
│   ├── io/
│   │   ├── obj_loader.py    # Pure-Python OBJ parser
│   │   └── mesh_data.py     # MeshData, convex_hull_inertia
│   └── py.typed             # PEP 561 type marker
├── apps/robot_rl/            # RL application (Gymnasium envs, training)
├── examples/                 # Self-contained examples (≤15 lines each)
├── tests/                    # 215+ automated tests
├── validation/               # PyBullet / MuJoCo baseline comparison
├── assets/                   # 3D models, textures
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
└── pyproject.toml
```

---

## Validation

| Check | Result |
|---|---|
| PyBullet acceleration comparison (50 pairs) | `max_abs < 2e-11` ✅ |
| Energy conservation (torque-free, no damping) | deviation < 0.1 % ✅ |
| Pendulum period vs. closed-form | error < 0.01 % ✅ |
| JAX ↔ NumPy FK consistency | `max_diff < 1e-16` ✅ |
| Restitution coefficient vs. theory | error < 1.5 % ✅ |
| Coulomb friction threshold | ✅ |

---

## Backend switching

```bash
ENGINE_BACKEND=numpy python my_sim.py   # NumPy (default, CPU)
ENGINE_BACKEND=jax   python my_sim.py   # JAX JIT+vmap
```

The physics core is identical under both backends. JAX enables JIT compilation and
`vmap` for batched multi-environment rollouts.

---

## Testing

```bash
# Single file (recommended for iteration)
pytest tests/test_collision.py -q

# Full suite (skips slow training tests)
pytest tests/ --ignore=tests/test_p9_training.py -q

# With coverage
pytest --cov=forge3d tests/ -q
```

---

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). In short:

1. Fork → feature branch → PR
2. `ruff check . && ruff format .` must pass
3. `mypy src/` must pass
4. New physics code needs a conservation-law or analytical-solution test
5. New public API needs an example in `examples/`

---

## License

MIT — see [LICENSE](LICENSE).
