Physics Sandbox

Physics Sandbox — Visual demo of the SimVX physics engine.

Demonstrates: - Falling cubes under gravity - Static ground plane - Bouncing spheres with restitution - Impulse-driven interaction

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

Run with: python physics_sandbox.py

Source Code

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