Scaled Colliders

one shape resource, many sizes, and the collider matches the mesh.

▶ Run in browser

Tags: 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.scale reaching the collider, at build time and live.

  • One BoxShape3D shared 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())