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