Scaled Colliders¶
one shape resource, many sizes, and the collider matches the mesh.
▶ Run in browserTags: physics 3d scale
Three crates share ONE BoxShape3D. They are drawn at different sizes because
their nodes are scaled, and they COLLIDE at those sizes too: each rests at its own
scaled half-height, so the outline you see is the outline the simulation uses.
Scale belongs to the body, not to the shape. A shape resource is shared – these
three crates are one backend collider between them – so it cannot bake in one
node’s size; the node pushes its world_scale through with its pose instead, and
every backend applies it to its own instance of the geometry. That is also why a
resource can be handed round freely: it costs one collider however many bodies use
it, and it releases that collider when you let it go.
Not every collider can express every scale. A sphere has one radius, so squashing one on a single axis has no representation and the seam says so with a clear error rather than quietly picking a number – which would put the collider back out of step with the mesh, the very thing scale exists to fix. Press 4 to see the error text; the ball keeps the size it had.
Shows:
Node3D.scalereaching the collider, at build time and live.One
BoxShape3Dshared by three bodies of three different sizes.The per-kind scale rule: boxes take a scale per axis, spheres need a uniform one.
Mirroring (a negative component) counting as uniform, so a sprite-style flip is not an error.
Controls: 1 - grow the middle crate (live rescale) 2 - shrink it back 3 - squash it on Y only: a box can do this 4 - try the same squash on the ball: refused, with the reason Arrows - orbit the camera R - rebuild the scene Escape - quit
Run: uv run python examples/features/physics/collider_scale.py Headless self-check: uv run python examples/features/physics/collider_scale.py –test
Source¶
1"""Scaled Colliders: one shape resource, many sizes, and the collider matches the mesh.
2
3Three crates share ONE ``BoxShape3D``. They are drawn at different sizes because
4their nodes are scaled, and they COLLIDE at those sizes too: each rests at its own
5scaled half-height, so the outline you see is the outline the simulation uses.
6
7Scale belongs to the body, not to the shape. A shape resource is shared -- these
8three crates are one backend collider between them -- so it cannot bake in one
9node's size; the node pushes its ``world_scale`` through with its pose instead, and
10every backend applies it to its own instance of the geometry. That is also why a
11resource can be handed round freely: it costs one collider however many bodies use
12it, and it releases that collider when you let it go.
13
14Not every collider can express every scale. A sphere has one radius, so squashing
15one on a single axis has no representation and the seam says so with a clear error
16rather than quietly picking a number -- which would put the collider back out of
17step with the mesh, the very thing scale exists to fix. Press 4 to see the error
18text; the ball keeps the size it had.
19
20Shows:
21 - ``Node3D.scale`` reaching the collider, at build time and live.
22 - One ``BoxShape3D`` shared by three bodies of three different sizes.
23 - The per-kind scale rule: boxes take a scale per axis, spheres need a uniform one.
24 - Mirroring (a negative component) counting as uniform, so a sprite-style flip
25 is not an error.
26
27Controls:
28 1 - grow the middle crate (live rescale)
29 2 - shrink it back
30 3 - squash it on Y only: a box can do this
31 4 - try the same squash on the ball: refused, with the reason
32 Arrows - orbit the camera
33 R - rebuild the scene
34 Escape - quit
35
36Run: uv run python examples/features/physics/collider_scale.py
37Headless self-check: uv run python examples/features/physics/collider_scale.py --test
38
39# /// simvx
40# tags = ["3d", "physics", "scale"]
41# ///
42"""
43
44from __future__ import annotations
45
46import math
47
48from simvx.core import (
49 BodyMode,
50 BoxShape3D,
51 Camera3D,
52 DirectionalLight3D,
53 Input,
54 InputMap,
55 Key,
56 Material,
57 Mesh,
58 MeshInstance3D,
59 Node,
60 PhysicsBody3D,
61 SphereShape3D,
62 Text2D,
63 Vec3,
64)
65from simvx.graphics import App
66
67#: The three crates, as (x position, uniform scale). They all share one shape.
68_CRATES = ((-4.0, 1.0), (0.0, 2.0), (4.0, 3.5))
69#: Where the ball stands, and the radius its resource was authored at.
70_BALL_X = 8.0
71_BALL_RADIUS = 0.6
72
73_CRATE_MAT = Material(colour=(0.85, 0.62, 0.32, 1.0), roughness=0.7)
74_PICKED_MAT = Material(colour=(0.35, 0.80, 0.95, 1.0), emissive_colour=(0.1, 0.4, 0.5, 0.5), roughness=0.4)
75_BALL_MAT = Material(colour=(0.75, 0.35, 0.45, 1.0), roughness=0.5)
76_GROUND_MAT = Material(colour=(0.22, 0.24, 0.28, 1.0), roughness=1.0)
77
78
79class ColliderScaleScene(Node):
80 def on_ready(self):
81 InputMap.add_action("grow", [Key.KEY_1])
82 InputMap.add_action("shrink", [Key.KEY_2])
83 InputMap.add_action("squash_box", [Key.KEY_3])
84 InputMap.add_action("squash_ball", [Key.KEY_4])
85 InputMap.add_action("orbit_left", [Key.LEFT])
86 InputMap.add_action("orbit_right", [Key.RIGHT])
87 InputMap.add_action("rebuild", [Key.R])
88 InputMap.add_action("quit", [Key.ESCAPE])
89
90 self._cam_angle = 0.4
91 self._cam = self.add_child(Camera3D())
92 self._update_camera()
93
94 sun = DirectionalLight3D(position=(6, 14, 10))
95 sun.colour = (1.0, 0.96, 0.88)
96 sun.intensity = 3.0
97 sun.look_at((0, 0, 0))
98 self.add_child(sun)
99
100 self._cube = Mesh.cube()
101 self._sphere = Mesh.sphere()
102 self._crates: list[PhysicsBody3D] = []
103 self._visuals: dict[PhysicsBody3D, MeshInstance3D] = {}
104 self._ball: PhysicsBody3D | None = None
105 self._ball_visual: MeshInstance3D | None = None
106 self._message = ""
107 self._build()
108
109 self._hud = Text2D(
110 text="1 grow | 2 shrink | 3 squash the box on Y | 4 squash the ball (refused) | R reset",
111 position=(10, 10),
112 font_scale=1.3,
113 )
114 self.add_child(self._hud)
115 self._readout = Text2D(text="", position=(10, 38), font_scale=1.3)
116 self.add_child(self._readout)
117
118 # -- scene ------------------------------------------------------------
119
120 def _build(self) -> None:
121 for node in list(self._crates) + list(self._visuals.values()):
122 node.destroy()
123 if self._ball is not None:
124 self._ball.destroy()
125 if self._ball_visual is not None:
126 self._ball_visual.destroy()
127 self._crates.clear()
128 self._visuals.clear()
129
130 ground = self.add_child(
131 PhysicsBody3D(mode=BodyMode.STATIC, shape=BoxShape3D((30.0, 0.5, 30.0)), position=(0.0, -0.5, 0.0))
132 )
133 self.add_child(
134 MeshInstance3D(mesh=self._cube, material=_GROUND_MAT, position=(0.0, -0.5, 0.0), scale=(60.0, 1.0, 60.0))
135 )
136 self._ground = ground
137
138 # ONE shape resource for every crate. Sharing it is the point: the sizes
139 # below live on the bodies, so the backend holds one collider for all three.
140 self._crate_shape = BoxShape3D((0.5, 0.5, 0.5))
141 for x, s in _CRATES:
142 body = self.add_child(
143 PhysicsBody3D(mode=BodyMode.DYNAMIC, shape=self._crate_shape, position=(x, 8.0, 0.0), mass=1.0)
144 )
145 body.scale = Vec3(s, s, s)
146 self._crates.append(body)
147 self._visuals[body] = self.add_child(MeshInstance3D(mesh=self._cube, material=_CRATE_MAT))
148
149 self._ball_shape = SphereShape3D(_BALL_RADIUS)
150 self._ball = self.add_child(
151 PhysicsBody3D(mode=BodyMode.DYNAMIC, shape=self._ball_shape, position=(_BALL_X, 8.0, 0.0), mass=1.0)
152 )
153 self._ball.scale = Vec3(2.0, 2.0, 2.0)
154 self._ball_visual = self.add_child(MeshInstance3D(mesh=self._sphere, material=_BALL_MAT))
155 self._message = "one BoxShape3D, three sizes"
156
157 @property
158 def _picked(self) -> PhysicsBody3D:
159 """The middle crate: the one the live controls resize."""
160 return self._crates[1]
161
162 # -- controls ----------------------------------------------------------
163
164 def _rescale_picked(self, scale: Vec3) -> None:
165 self._picked.scale = scale
166 self._picked.wake()
167 self._message = f"middle crate scale -> {tuple(round(float(c), 2) for c in scale)}"
168
169 def on_update(self, dt: float) -> None:
170 if Input.is_action_just_pressed("quit"):
171 self.app.quit()
172 if Input.is_action_just_pressed("rebuild"):
173 self._build()
174 if Input.is_action_just_pressed("grow"):
175 s = float(self._picked.scale.x) + 0.5
176 self._rescale_picked(Vec3(s, s, s))
177 if Input.is_action_just_pressed("shrink"):
178 s = max(0.5, float(self._picked.scale.x) - 0.5)
179 self._rescale_picked(Vec3(s, s, s))
180 if Input.is_action_just_pressed("squash_box"):
181 self._rescale_picked(Vec3(2.0, 0.5, 2.0))
182 if Input.is_action_just_pressed("squash_ball"):
183 self._try_squash_ball()
184
185 if Input.is_action_pressed("orbit_left"):
186 self._cam_angle -= dt
187 self._update_camera()
188 if Input.is_action_pressed("orbit_right"):
189 self._cam_angle += dt
190 self._update_camera()
191
192 for body, visual in self._visuals.items():
193 visual.position = body.world_position
194 visual.rotation = body.world_rotation
195 visual.scale = body.scale
196 visual.material = _PICKED_MAT if body is self._picked else _CRATE_MAT
197 if self._ball is not None and self._ball_visual is not None:
198 self._ball_visual.position = self._ball.world_position
199 self._ball_visual.scale = self._ball.scale * (_BALL_RADIUS * 2.0)
200
201 heights = " ".join(f"{float(b.world_position.y):.2f}" for b in self._crates)
202 self._readout.text = f"{self._message} rest heights: {heights}"
203
204 def _try_squash_ball(self) -> None:
205 """A sphere has one radius, so a non-uniform scale is refused, not guessed."""
206 assert self._ball is not None
207 try:
208 self._ball.scale = Vec3(2.0, 0.5, 2.0)
209 except ValueError as exc:
210 self._ball.scale = Vec3(2.0, 2.0, 2.0) # put back what it had
211 self._message = str(exc).split(";")[0]
212 else:
213 self._message = "the ball accepted a non-uniform scale (it should not have)"
214
215 def _update_camera(self) -> None:
216 radius = 22.0
217 self._cam.position = (
218 math.sin(self._cam_angle) * radius,
219 9.0,
220 math.cos(self._cam_angle) * radius,
221 )
222 self._cam.look_at((2.0, 2.0, 0.0))
223
224
225def _selftest() -> bool:
226 """Headless check: every crate rests at its own scaled half-height."""
227 from simvx.core.testing import SceneRunner
228
229 scene = ColliderScaleScene()
230 runner = SceneRunner()
231 runner.load(scene)
232 runner.advance_frames(600)
233
234 ok = True
235 for body, (_, s) in zip(scene._crates, _CRATES, strict=True):
236 y = float(body.world_position.y)
237 want = 0.5 * s
238 print(f"crate scale {s}: rest y={y:.3f} (want {want:.3f})")
239 ok = ok and abs(y - want) < 0.1
240 ball_y = float(scene._ball.world_position.y)
241 print(f"ball scale 2.0: rest y={ball_y:.3f} (want {_BALL_RADIUS * 2.0:.3f})")
242 ok = ok and abs(ball_y - _BALL_RADIUS * 2.0) < 0.1
243
244 # One resource, one backend collider, however many bodies are built on it.
245 world = scene._ground.world
246 print(f"backend shape records: {len(world._shapes)} for {len(scene._crates)} crates + ball + ground")
247 ok = ok and len(world._shapes) == 3 # ground, crate, ball
248
249 scene._try_squash_ball()
250 print(f"non-uniform on the ball: {scene._message}")
251 ok = ok and "cannot be scaled non-uniformly" in scene._message
252
253 scene._rescale_picked(Vec3(2.0, 0.5, 2.0))
254 runner.advance_frames(600)
255 squashed_y = float(scene._picked.world_position.y)
256 print(f"box squashed to y-scale 0.5: rest y={squashed_y:.3f} (want 0.250)")
257 ok = ok and abs(squashed_y - 0.25) < 0.1
258
259 print("SELFTEST:", "PASS" if ok else "FAIL")
260 return ok
261
262
263if __name__ == "__main__":
264 import sys
265
266 if "--test" in sys.argv:
267 sys.exit(0 if _selftest() else 1)
268 app = App(title="Scaled Colliders", width=1280, height=720)
269 app.run(ColliderScaleScene())