Scaled Colliders¶
one shape resource, many sizes, and the collider matches the mesh.
▶ Run in browserTags: physics 3d scale
A body’s scale reaches its collider. Three crates share one BoxShape3D with
half-extents of 0.5, and their bodies are scaled 1, 2 and 3.5, so they land and
rest at heights of 0.5, 1.0 and 1.75: the size you see is the size the simulation
uses. Each crate’s MeshInstance3D is a child of its body, so it inherits the same
scale and the one unit cube mesh draws all three sizes.
Scale belongs to the body, not to the shape. A shape resource is shared, so it
cannot hold one node’s size; the body passes its world_scale to the physics world
with its pose, and every backend applies it to the geometry. That is why one
resource can serve any number of bodies of any size.
A live rescale works the same way: assign body.scale and the collider follows
at once, and the body wakes so it can settle at its new size.
Not every collider can take every scale. A box scales per axis, so it can be
squashed on Y alone. A sphere has one radius, so a non-uniform scale on a
SphereShape3D body raises ValueError explaining why, rather than choosing a
radius that no longer matches the mesh. A mirrored scale (a negative component)
still counts as uniform.
Controls, each also a button on screen:
1: grow the highlighted middle crate
2: shrink it
3: squash it on Y only, which a box can do
4: try the same squash on the ball, which is refused with the reason
R: reset the scene
Left and Right arrows, or drag: orbit the camera; mouse wheel or pinch: zoom
Source¶
1# /// simvx
2# tags = ["3d", "physics", "scale"]
3# ///
4"""Scaled Colliders: one shape resource, many sizes, and the collider matches the mesh.
5
6A body's `scale` reaches its collider. Three crates share one `BoxShape3D` with
7half-extents of 0.5, and their bodies are scaled 1, 2 and 3.5, so they land and
8rest at heights of 0.5, 1.0 and 1.75: the size you see is the size the simulation
9uses. Each crate's `MeshInstance3D` is a child of its body, so it inherits the same
10scale and the one unit cube mesh draws all three sizes.
11
12Scale belongs to the body, not to the shape. A shape resource is shared, so it
13cannot hold one node's size; the body passes its `world_scale` to the physics world
14with its pose, and every backend applies it to the geometry. That is why one
15resource can serve any number of bodies of any size.
16
17A live rescale works the same way: assign `body.scale` and the collider follows
18at once, and the body wakes so it can settle at its new size.
19
20Not every collider can take every scale. A box scales per axis, so it can be
21squashed on Y alone. A sphere has one radius, so a non-uniform scale on a
22`SphereShape3D` body raises `ValueError` explaining why, rather than choosing a
23radius that no longer matches the mesh. A mirrored scale (a negative component)
24still counts as uniform.
25
26Controls, each also a button on screen:
27
28- 1: grow the highlighted middle crate
29- 2: shrink it
30- 3: squash it on Y only, which a box can do
31- 4: try the same squash on the ball, which is refused with the reason
32- R: reset the scene
33- Left and Right arrows, or drag: orbit the camera; mouse wheel or pinch: zoom
34"""
35
36from simvx.core import (
37 Anchor,
38 Axis1D,
39 BodyMode,
40 BoxShape3D,
41 Button,
42 CanvasLayer,
43 DirectionalLight3D,
44 HBoxContainer,
45 Input,
46 Key,
47 Label,
48 Material,
49 Mesh,
50 MeshInstance3D,
51 Node,
52 OrbitCamera3D,
53 PhysicsBody3D,
54 SphereShape3D,
55 Vec3,
56 on_input,
57)
58from simvx.graphics import App
59
60CRATES = ((-4.0, 1.0), (0.0, 2.0), (4.0, 3.5)) # (x, uniform scale) of each crate
61BALL_RADIUS = 0.6
62SQUASH = Vec3(2.0, 0.5, 2.0)
63BUTTONS = {
64 "grow": "Grow",
65 "shrink": "Shrink",
66 "squash_box": "Squash box",
67 "squash_ball": "Squash ball",
68 "reset": "Reset",
69}
70
71CRATE_MAT = Material(colour=(0.85, 0.62, 0.32, 1.0), roughness=0.7)
72PICKED_MAT = Material(colour=(0.35, 0.80, 0.95, 1.0), emissive_colour=(0.1, 0.4, 0.5, 0.5), roughness=0.4)
73BALL_MAT = Material(colour=(0.75, 0.35, 0.45, 1.0), roughness=0.5)
74GROUND_MAT = Material(colour=(0.22, 0.24, 0.28, 1.0), roughness=1.0)
75
76
77def body_with_mesh(shape, mesh, material, position, scale=1.0, mode=BodyMode.DYNAMIC) -> PhysicsBody3D:
78 """A body and the mesh that draws it, as its child so it inherits the body's scale."""
79 body = PhysicsBody3D(mode=mode, shape=shape, position=position, scale=Vec3(scale))
80 body.add_child(MeshInstance3D(mesh=mesh, material=material))
81 return body
82
83
84class ColliderScaleScene(Node):
85 input_context = {
86 "grow": [Key.KEY_1],
87 "shrink": [Key.KEY_2],
88 "squash_box": [Key.KEY_3],
89 "squash_ball": [Key.KEY_4],
90 "reset": [Key.R],
91 "orbit": [Axis1D(low=Key.LEFT, high=Key.RIGHT)],
92 }
93
94 def on_ready(self):
95 self.camera = self.add_child(OrbitCamera3D(pivot=(2, 2, 0), distance=23.0, yaw=0.4, pitch=-0.3))
96 sun = self.add_child(DirectionalLight3D(position=(6, 14, 10), colour=(1.0, 0.96, 0.88), intensity=3.0))
97 sun.look_at((0, 0, 0))
98
99 ground = BoxShape3D((30.0, 0.5, 30.0))
100 self.add_child(body_with_mesh(ground, Mesh.box((60, 1, 60)), GROUND_MAT, (0, -0.5, 0), mode=BodyMode.STATIC))
101
102 # One shape resource and one mesh for every crate: the sizes live on the bodies.
103 crate_shape, cube = BoxShape3D((0.5, 0.5, 0.5)), Mesh.box()
104 self.crates = [self.add_child(body_with_mesh(crate_shape, cube, CRATE_MAT, (x, 8, 0), s)) for x, s in CRATES]
105 self.picked = self.crates[1]
106 self.picked.find(MeshInstance3D).material = PICKED_MAT
107 ball = body_with_mesh(SphereShape3D(BALL_RADIUS), Mesh.sphere(BALL_RADIUS), BALL_MAT, (8, 8, 0), 2)
108 self.ball = self.add_child(ball)
109 self.message = "One BoxShape3D, three sizes"
110
111 hud = self.add_child(CanvasLayer())
112 self.status = hud.add_child(
113 Label(anchor=Anchor.TOP_WIDE, inset=16, size=(0, 56), font_size=20, autoshrink=True)
114 )
115 buttons = hud.add_child(HBoxContainer(anchor=Anchor.CENTER_BOTTOM, inset=20))
116 for action, text in BUTTONS.items():
117 buttons.add_child(Button(text, action=action))
118 buttons.autosize_to_content()
119
120 def rescale_picked(self, scale):
121 self.picked.scale = scale
122 self.message = f"Middle crate scale: {tuple(round(float(c), 2) for c in self.picked.scale)}"
123
124 @on_input("grow")
125 def grow(self, event):
126 self.rescale_picked(Vec3(float(self.picked.scale.x) + 0.5))
127
128 @on_input("shrink")
129 def shrink(self, event):
130 self.rescale_picked(Vec3(max(0.5, float(self.picked.scale.x) - 0.5)))
131
132 @on_input("squash_box")
133 def squash_box(self, event):
134 self.rescale_picked(SQUASH)
135
136 @on_input("squash_ball")
137 def squash_ball(self, event):
138 before = Vec3(self.ball.scale)
139 try:
140 self.ball.scale = SQUASH
141 except ValueError as exc:
142 # The node keeps a scale its collider refused, so the two disagree until it is put back.
143 self.ball.scale = before
144 self.message = str(exc)
145
146 @on_input("reset")
147 def reset(self, event):
148 self.tree.change_scene(ColliderScaleScene())
149
150 def on_update(self, dt):
151 self.camera.orbit(Input.value("orbit") * dt, 0.0)
152 heights = " ".join(f"{float(crate.world_position.y):.2f}" for crate in self.crates)
153 self.status.text = f"{self.message}\nCrate rest heights: {heights}"
154
155
156if __name__ == "__main__":
157 App(title="Scaled Colliders", width=1280, height=720).run(ColliderScaleScene())