Physics Sandbox

Visual demo of the SimVX physics engine.

▶ Run in browser

Tags: 3d physics rigid-body

Demonstrates: - Falling cubes under gravity (PhysicsBody3D, mode=DYNAMIC) - Static ground plane + containing walls (mode=STATIC) - Bouncing spheres with restitution (PhysicsMaterial) - Impulse-driven interaction (PhysicsBody3D.push)

Everything lives under a single PhysicsRoot that owns one isolated PhysicsWorld (gravity, body_count, clear()) – the seam replacement for the old global PhysicsServer.

Controls: SPACE / click / tap : Spawn a new ball with random impulse R : Reset the scene ESC : Quit

Run with: uv run python examples/demos/physics_sandbox.py

Source

  1#!/usr/bin/env python3
  2"""Physics Sandbox: Visual demo of the SimVX physics engine.
  3
  4# /// simvx
  5# tags = ["3d", "physics", "rigid-body"]
  6# web = { width = 1280, height = 720, root = "PhysicsSandbox" }
  7# ///
  8
  9Demonstrates:
 10    - Falling cubes under gravity (PhysicsBody3D, mode=DYNAMIC)
 11    - Static ground plane + containing walls (mode=STATIC)
 12    - Bouncing spheres with restitution (PhysicsMaterial)
 13    - Impulse-driven interaction (PhysicsBody3D.push)
 14
 15Everything lives under a single ``PhysicsRoot`` that owns one isolated
 16``PhysicsWorld`` (gravity, ``body_count``, ``clear()``) -- the seam replacement
 17for the old global ``PhysicsServer``.
 18
 19Controls:
 20    SPACE / click / tap : Spawn a new ball with random impulse
 21    R                   : Reset the scene
 22    ESC                 : Quit
 23
 24Run with:
 25    uv run python examples/demos/physics_sandbox.py
 26"""
 27
 28import random
 29
 30from simvx.core import (
 31    BodyMode,
 32    BoxShape3D,
 33    Camera3D,
 34    Input,
 35    InputMap,
 36    Key,
 37    Material,
 38    Mesh,
 39    MeshInstance3D,
 40    MouseButton,
 41    Node,
 42    PhysicsBody3D,
 43    PhysicsMaterial,
 44    PhysicsRoot,
 45    SphereShape3D,
 46    Text2D,
 47    Vec3,
 48)
 49from simvx.graphics import App
 50
 51# ============================================================================
 52# Physics-aware mesh node helpers
 53# ============================================================================
 54
 55
 56class PhysicsCube(PhysicsBody3D):
 57    """A falling cube with physics (DYNAMIC PhysicsBody3D)."""
 58
 59    def __init__(self, size: float = 1.0, colour: tuple = (0.8, 0.3, 0.2, 1.0), **kwargs):
 60        half = size / 2
 61        super().__init__(
 62            mode=BodyMode.DYNAMIC,
 63            shape=BoxShape3D(half_extents=Vec3(half, half, half)),
 64            material=PhysicsMaterial(friction=0.6, restitution=0.3),
 65            **kwargs,
 66        )
 67        self._size = size
 68        self._colour = colour
 69
 70    def on_ready(self):
 71        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
 72        mesh_node.mesh = Mesh.cube(size=self._size)
 73        mesh_node.material = Material(colour=self._colour)
 74
 75
 76class PhysicsBall(PhysicsBody3D):
 77    """A bouncing sphere with physics (DYNAMIC PhysicsBody3D)."""
 78
 79    def __init__(self, radius: float = 0.5, colour: tuple = (0.2, 0.6, 0.9, 1.0), **kwargs):
 80        super().__init__(
 81            mode=BodyMode.DYNAMIC,
 82            shape=SphereShape3D(radius=radius),
 83            material=PhysicsMaterial(friction=0.3, restitution=0.8),
 84            **kwargs,
 85        )
 86        self._radius = radius
 87        self._colour = colour
 88
 89    def on_ready(self):
 90        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
 91        mesh_node.mesh = Mesh.sphere(radius=self._radius)
 92        mesh_node.material = Material(colour=self._colour)
 93
 94
 95class Ground(PhysicsBody3D):
 96    """Static ground plane (STATIC PhysicsBody3D)."""
 97
 98    def __init__(self, **kwargs):
 99        super().__init__(
100            mode=BodyMode.STATIC,
101            shape=BoxShape3D(half_extents=Vec3(25, 0.5, 25)),
102            material=PhysicsMaterial(friction=0.8, restitution=0.5),
103            **kwargs,
104        )
105
106    def on_ready(self):
107        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
108        mesh_node.mesh = Mesh.cube(size=1)
109        mesh_node.material = Material(colour=(0.4, 0.5, 0.4, 1.0))
110        mesh_node.scale = Vec3(50, 1, 50)
111
112
113class Wall(PhysicsBody3D):
114    """Static wall for containing objects (STATIC PhysicsBody3D)."""
115
116    def __init__(self, half_extents=(0.5, 5, 25), colour=(0.5, 0.5, 0.6, 0.5), **kwargs):
117        super().__init__(
118            mode=BodyMode.STATIC,
119            shape=BoxShape3D(half_extents=Vec3(*half_extents)),
120            material=PhysicsMaterial(friction=0.5, restitution=0.7),
121            **kwargs,
122        )
123        self._half_extents = half_extents
124        self._colour = colour
125
126    def on_ready(self):
127        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
128        mesh_node.mesh = Mesh.cube(size=1)
129        mesh_node.material = Material(colour=self._colour)
130        he = self._half_extents
131        mesh_node.scale = Vec3(he[0] * 2, he[1] * 2, he[2] * 2)
132
133
134# ============================================================================
135# Main Scene
136# ============================================================================
137
138
139class PhysicsSandbox(Node):
140    """Main physics sandbox scene."""
141
142    def on_ready(self):
143        InputMap.add_action("space", [Key.SPACE, MouseButton.LEFT])
144        InputMap.add_action("reset", [Key.R])
145        InputMap.add_action("quit", [Key.ESCAPE])
146
147        # One isolated world for the whole sandbox (the PhysicsServer replacement).
148        self._world_root = self.add_child(PhysicsRoot(name="World", gravity=Vec3(0, -9.8, 0)))
149
150        # Camera
151        camera = self.add_child(Camera3D(name="Camera"))
152        camera.position = Vec3(0, 15, 25)
153        camera.look_at(Vec3(0, 3, 0))
154        camera.fov = 60.0
155
156        # Ground
157        self._world_root.add_child(Ground(name="Ground", position=Vec3(0, -0.5, 0)))
158
159        # Side walls
160        self._world_root.add_child(Wall(name="WallLeft", position=Vec3(-12, 5, 0), half_extents=(0.5, 5, 12)))
161        self._world_root.add_child(Wall(name="WallRight", position=Vec3(12, 5, 0), half_extents=(0.5, 5, 12)))
162        self._world_root.add_child(Wall(name="WallBack", position=Vec3(0, 5, -12), half_extents=(12, 5, 0.5)))
163        self._world_root.add_child(Wall(name="WallFront", position=Vec3(0, 5, 12), half_extents=(12, 5, 0.5)))
164
165        # Initial stack of cubes
166        colours = [
167            (0.9, 0.2, 0.2, 1),
168            (0.2, 0.9, 0.2, 1),
169            (0.2, 0.2, 0.9, 1),
170            (0.9, 0.9, 0.2, 1),
171            (0.9, 0.2, 0.9, 1),
172            (0.2, 0.9, 0.9, 1),
173        ]
174        for i in range(3):
175            for j in range(3 - i):
176                colour = colours[(i * 3 + j) % len(colours)]
177                self._world_root.add_child(
178                    PhysicsCube(
179                        name=f"Cube_{i}_{j}",
180                        position=Vec3(-2 + j * 1.5, 1.5 + i * 1.5, 0),
181                        size=1.2,
182                        colour=colour,
183                    )
184                )
185
186        # A couple of bouncy balls
187        for i in range(3):
188            x = -3 + i * 3
189            self._world_root.add_child(
190                PhysicsBall(
191                    name=f"Ball_{i}",
192                    position=Vec3(x, 8 + i * 2, 2),
193                    radius=0.6,
194                    colour=(0.1 + i * 0.3, 0.5, 0.9 - i * 0.2, 1),
195                )
196            )
197
198        # UI
199        self._spawn_count = 0
200        self.add_child(
201            Text2D(
202                name="Title",
203                text="Physics Sandbox",
204                position=(20, 20), font_scale=2.0,
205                colour=(1.0, 1.0, 1.0, 1.0),
206            )
207        )
208        self._info_text = self.add_child(
209            Text2D(
210                name="Info",
211                text="SPACE / click: spawn ball | R: reset | ESC: quit",
212                position=(20, 60), font_scale=1.0,
213                colour=(0.78, 0.78, 0.78, 1.0),
214            )
215        )
216        self._count_text = self.add_child(
217            Text2D(
218                name="Count",
219                text="Bodies: 0",
220                position=(20, 90), font_scale=1.0,
221                colour=(0.71, 0.71, 0.71, 1.0),
222            )
223        )
224
225    def on_update(self, dt: float):
226        # Quit on ESC
227        if Input.is_action_just_pressed("quit"):
228            self.app.quit()
229            return
230
231        # Spawn ball on space or click/tap
232        if Input.is_action_just_pressed("space"):
233            self._spawn_ball()
234
235        # Reset on R
236        if Input.is_action_just_pressed("reset"):
237            self.tree.change_scene(PhysicsSandbox())
238            return
239
240        # Update body count (per-world seam, not the old global server)
241        self._count_text.text = f"Bodies: {self._world_root.world.body_count}"
242
243        # Clean up fallen objects
244        for child in list(self._world_root.children):
245            if isinstance(child, PhysicsBody3D) and child.position.y < -20:
246                child.destroy()
247
248    def _spawn_ball(self):
249        self._spawn_count += 1
250        colour = (random.random(), random.random(), random.random(), 1.0)
251        ball = self._world_root.add_child(
252            PhysicsBall(
253                name=f"SpawnBall_{self._spawn_count}",
254                position=Vec3(random.uniform(-5, 5), 12, random.uniform(-5, 5)),
255                radius=random.uniform(0.3, 0.8),
256                colour=colour,
257            )
258        )
259        # Random impulse
260        ball.push(
261            Vec3(
262                random.uniform(-5, 5),
263                random.uniform(0, 5),
264                random.uniform(-5, 5),
265            )
266        )
267
268
269# ============================================================================
270# Entry Point
271# ============================================================================
272
273
274if __name__ == "__main__":
275    App(title="Physics Sandbox: SimVX", width=1280, height=720, target_fps=60, physics_fps=60).run(
276        PhysicsSandbox()
277    )