Collision Shapes¶
A circle and a box body falling onto a static floor.
▶ Run in browserTags: 2d physics collision rigidbody
Two dynamic PhysicsBody2D nodes (one with a CircleShape2D collider, one with a RectangleShape2D collider) fall under gravity and come to rest on a STATIC PhysicsBody2D floor. The Physics2DWorld resolves the contacts automatically, so each body settles exactly on top of the floor.
What it demonstrates¶
PhysicsBody2D(DYNAMIC) with two collider kinds: CircleShape2D and RectangleShape2D.
PhysicsBody2D(STATIC) as an immovable floor the dynamic bodies rest against.
Both ways to give a body its geometry: the single-collider
shape=shortcut (live-swappable) and the equivalent CollisionShape2D child node.Automatic gravity + impulse-based contact response from the Physics2DWorld.
Different shapes settling at the correct height (floor top minus their extent).
Controls: R / click (tap) - Reset the bodies back to their drop positions ESC - Quit
Run: uv run python examples/features/2d/collision_shapes.py Headless self-check: uv run python examples/features/2d/collision_shapes.py –test
Source¶
1"""Collision Shapes: A circle and a box body falling onto a static floor.
2
3Two dynamic PhysicsBody2D nodes (one with a CircleShape2D collider, one with a
4RectangleShape2D collider) fall under gravity and come to rest on a STATIC
5PhysicsBody2D floor. The Physics2DWorld resolves the contacts automatically, so
6each body settles exactly on top of the floor.
7
8# /// simvx
9# tags = ["2d", "physics", "collision", "rigidbody"]
10# web = { root = "CollisionShapesDemo", width = 800, height = 600, responsive = true }
11# ///
12
13## What it demonstrates
14- PhysicsBody2D(DYNAMIC) with two collider kinds: CircleShape2D and RectangleShape2D.
15- PhysicsBody2D(STATIC) as an immovable floor the dynamic bodies rest against.
16- Both ways to give a body its geometry: the single-collider `shape=` shortcut
17 (live-swappable) and the equivalent CollisionShape2D child node.
18- Automatic gravity + impulse-based contact response from the Physics2DWorld.
19- Different shapes settling at the correct height (floor top minus their extent).
20
21Controls:
22 R / click (tap) - Reset the bodies back to their drop positions
23 ESC - Quit
24
25Run: uv run python examples/features/2d/collision_shapes.py
26Headless self-check: uv run python examples/features/2d/collision_shapes.py --test
27"""
28
29from simvx.core import (
30 BodyMode,
31 CircleShape2D,
32 CollisionShape2D,
33 Input,
34 InputMap,
35 Key,
36 MouseButton,
37 Node2D,
38 PhysicsBody2D,
39 PhysicsMaterial,
40 PhysicsRoot2D,
41 RectangleShape2D,
42 Vec2,
43)
44from simvx.graphics import App
45
46WIDTH, HEIGHT = 800, 600
47
48FLOOR_Y = 500.0
49FLOOR_HALF_W = 320.0
50FLOOR_HALF_H = 20.0
51FLOOR_TOP = FLOOR_Y - FLOOR_HALF_H
52
53BALL_RADIUS = 30.0
54BOX_HALF = 35.0
55
56# 2D physics here runs in screen pixels, Y-down (gravity = +Y, falling reads as
57# falling on screen). Continuous collision keeps the box from tunnelling the floor
58# at the fall speeds this gravity produces.
59GRAVITY = Vec2(0.0, 1600.0)
60
61BALL_DROP = Vec2(WIDTH / 2 - 110, 100)
62BOX_DROP = Vec2(WIDTH / 2 + 110, 100)
63
64
65class CollisionShapesDemo(Node2D):
66 """Dynamic circle + box bodies resting on a static floor."""
67
68 dynamic = True # bodies fall + settle every frame (physics world positions)
69
70 def on_ready(self):
71 # Left-click (touch on web) also resets, so the demo stays replayable
72 # without a keyboard.
73 InputMap.add_action("reset", [Key.R, MouseButton.LEFT])
74 InputMap.add_action("quit", [Key.ESCAPE])
75
76 # One isolated Y-down 2D world; every physics node below resolves to it.
77 self._root = self.add_child(PhysicsRoot2D(name="World", gravity=GRAVITY))
78
79 # Static floor: a wide, thin box. Immovable, infinite effective mass. The
80 # `shape=` Property is the short form for a body with one collider, and
81 # assigning a new shape to it swaps the live body's geometry in place.
82 floor = PhysicsBody2D(
83 name="Floor",
84 mode=BodyMode.STATIC,
85 position=Vec2(WIDTH / 2, FLOOR_Y),
86 material=PhysicsMaterial(friction=0.7),
87 shape=RectangleShape2D(half_extents=Vec2(FLOOR_HALF_W, FLOOR_HALF_H)),
88 )
89 self._root.add_child(floor)
90
91 # Dynamic ball: a circular collider. Continuous CCD avoids tunnelling.
92 self._ball = PhysicsBody2D(
93 name="Ball",
94 mode=BodyMode.DYNAMIC,
95 mass=1.0,
96 continuous=True,
97 position=Vec2(BALL_DROP.x, BALL_DROP.y),
98 material=PhysicsMaterial(friction=0.5),
99 shape=CircleShape2D(BALL_RADIUS),
100 )
101 self._root.add_child(self._ball)
102
103 # Dynamic box: the same collider attached the other way, as a CollisionShape2D
104 # child. Equivalent to `shape=`, and it is what a scene built in the editor
105 # looks like, since a child is a node in the hierarchy rather than a resource
106 # on the body. A body resolves to `shape=` first, then its first such child.
107 self._box = PhysicsBody2D(
108 name="Box",
109 mode=BodyMode.DYNAMIC,
110 mass=1.0,
111 continuous=True,
112 position=Vec2(BOX_DROP.x, BOX_DROP.y),
113 material=PhysicsMaterial(friction=0.5),
114 )
115 self._box.add_child(CollisionShape2D(shape=RectangleShape2D(half_extents=Vec2(BOX_HALF, BOX_HALF))))
116 self._root.add_child(self._box)
117
118 def _reset(self):
119 self._ball.position = Vec2(BALL_DROP.x, BALL_DROP.y)
120 self._ball.velocity = Vec2()
121 self._box.position = Vec2(BOX_DROP.x, BOX_DROP.y)
122 self._box.velocity = Vec2()
123
124 def on_update(self, dt: float):
125 if Input.is_action_just_pressed("quit"):
126 self.app.quit()
127 elif Input.is_action_just_pressed("reset"):
128 self._reset()
129
130 def on_draw(self, renderer):
131 # Floor.
132 renderer.draw_rect(
133 (WIDTH / 2 - FLOOR_HALF_W, FLOOR_Y - FLOOR_HALF_H),
134 (FLOOR_HALF_W * 2, FLOOR_HALF_H * 2),
135 colour=(0.4, 0.4, 0.45, 1.0),
136 filled=True,
137 )
138
139 # Ball.
140 bp = self._ball.world_position
141 renderer.draw_circle((bp.x, bp.y), BALL_RADIUS, colour=(0.95, 0.45, 0.3, 1.0), filled=True)
142
143 # Box (axis-aligned; the demo bodies stay upright as they drop).
144 xp = self._box.world_position
145 renderer.draw_rect(
146 (xp.x - BOX_HALF, xp.y - BOX_HALF),
147 (BOX_HALF * 2, BOX_HALF * 2),
148 colour=(0.4, 0.7, 0.95, 1.0),
149 filled=True,
150 )
151
152 # HUD.
153 renderer.draw_text("Collision Shapes", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
154 renderer.draw_text("R / click: reset ESC: quit", (10, 40), colour=(0.75, 0.75, 0.75))
155
156
157def _selftest() -> bool:
158 """Headless: drop both bodies, check where they came to rest, then reset with the real R key."""
159 from simvx.core.testing import InputSimulator
160 from simvx.graphics.testing import assert_not_blank, save_png
161
162 SETTLED = 150 # both bodies down and at rest
163 RESET = 160 # the R key, through the action map
164 RESET_SEEN = 162
165 RESETTLED = 300
166
167 app = App(title="CollisionShapes", width=WIDTH, height=HEIGHT, visible=False)
168 scene = CollisionShapesDemo(name="CollisionShapesDemo")
169 sim = InputSimulator()
170 seen: dict[str, object] = {}
171
172 def sample() -> dict[str, tuple[float, float]]:
173 return {
174 "ball": (float(scene._ball.world_position.x), float(scene._ball.world_position.y)),
175 "box": (float(scene._box.world_position.x), float(scene._box.world_position.y)),
176 "floor": (float(scene._root.node_at("Floor").world_position.y), 0.0),
177 }
178
179 def on_frame(idx: int, _t: float) -> bool:
180 if idx == SETTLED:
181 seen["settled"] = sample()
182 elif idx == RESET:
183 sim.press_key(Key.R)
184 elif idx == RESET + 1:
185 sim.release_key(Key.R)
186 elif idx == RESET_SEEN:
187 seen["reset"] = sample()
188 elif idx == RESETTLED:
189 seen["resettled"] = sample()
190 return True
191
192 frames = app.run_headless(scene, frames=320, on_frame=on_frame, capture_frames=[319])
193 assert_not_blank(frames[0])
194 save_png(frames[0], "/tmp/collision_shapes_test.png")
195
196 ok = True
197
198 def check(label: str, passed: bool, detail: str) -> None:
199 nonlocal ok
200 ok = ok and passed
201 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
202
203 # Each shape stops with its own extent clear of the floor's top face, which is
204 # the whole claim: the world resolved a circle and a box against the same plane
205 # and neither sank into it nor tunnelled through at this gravity.
206 settled = seen["settled"]
207 ball_x, ball_y = settled["ball"]
208 box_x, box_y = settled["box"]
209 check(
210 "the circle rests one radius above the floor",
211 abs(ball_y - (FLOOR_TOP - BALL_RADIUS)) < 1.5,
212 f"y={ball_y:.2f} (floor top {FLOOR_TOP:.0f} - r {BALL_RADIUS:.0f})",
213 )
214 check(
215 "the box rests one half-extent above the floor",
216 abs(box_y - (FLOOR_TOP - BOX_HALF)) < 1.5,
217 f"y={box_y:.2f} (floor top {FLOOR_TOP:.0f} - half {BOX_HALF:.0f})",
218 )
219
220 # The box got its collider from a CollisionShape2D child rather than `shape=`;
221 # it landed at all, so that second attachment style reached the world too.
222 check(
223 "both bodies fell straight down",
224 abs(ball_x - BALL_DROP.x) < 1.0 and abs(box_x - BOX_DROP.x) < 1.0,
225 f"ball x={ball_x:.1f} box x={box_x:.1f}",
226 )
227 check(
228 "the static floor did not move under them",
229 abs(settled["floor"][0] - FLOOR_Y) < 0.01,
230 f"y={settled['floor'][0]:.2f}",
231 )
232
233 # R is bound to an action, so this goes through the same edge the player's
234 # keypress does rather than calling _reset directly.
235 reset = seen["reset"]
236 check(
237 "R puts both bodies back at their drop heights",
238 abs(reset["ball"][1] - BALL_DROP.y) < 30.0 and abs(reset["box"][1] - BOX_DROP.y) < 30.0,
239 f"ball y={reset['ball'][1]:.1f} box y={reset['box'][1]:.1f}",
240 )
241 check(
242 "and they fall and settle again",
243 abs(seen["resettled"]["ball"][1] - ball_y) < 1.5 and abs(seen["resettled"]["box"][1] - box_y) < 1.5,
244 f"ball y={seen['resettled']['ball'][1]:.2f} box y={seen['resettled']['box'][1]:.2f}",
245 )
246
247 print("screenshot: /tmp/collision_shapes_test.png")
248 print("SELFTEST:", "PASS" if ok else "FAIL")
249 return ok
250
251
252if __name__ == "__main__":
253 import sys
254
255 if "--test" in sys.argv:
256 sys.exit(0 if _selftest() else 1)
257 App(title="2D Collision Shapes", width=WIDTH, height=HEIGHT).run(CollisionShapesDemo())