Transform Hierarchy¶
MeshInstance3D follows any parent transform.
▶ Run in browserTags: 3d
A MeshInstance3D draws at its parent’s world transform, whatever that parent happens to be. Four identical slabs sit side by side, each attached a different way, and the movable parents animate so the meshes visibly track them:
Direct child of the scene root: the mesh’s own transform is the world one
Inside a Node3D used as a group: spin the group and the mesh turns with it, in place, because its own transform is zero
Inside a KINEMATIC PhysicsBody3D: move the body, the mesh follows the pose the physics world reports
Inside a STATIC PhysicsBody3D holding a CollisionShape3D: the usual pairing of solid level geometry with its visual
An on-screen legend maps each slab colour to its parenting pattern.
Controls: Escape - Quit
Run: uv run python examples/features/3d/mesh_parenting.py Headless self-check: uv run python examples/features/3d/mesh_parenting.py –test
Source¶
1"""Transform Hierarchy: MeshInstance3D follows any parent transform.
2
3# /// simvx
4# web = { width = 1280, height = 720 }
5# ///
6
7A MeshInstance3D draws at its parent's world transform, whatever that parent
8happens to be. Four identical slabs sit side by side, each attached a different
9way, and the movable parents animate so the meshes visibly track them:
10
11 - Direct child of the scene root: the mesh's own transform is the world one
12 - Inside a Node3D used as a group: spin the group and the mesh turns with
13 it, in place, because its own transform is zero
14 - Inside a KINEMATIC PhysicsBody3D: move the body, the mesh follows the pose
15 the physics world reports
16 - Inside a STATIC PhysicsBody3D holding a CollisionShape3D: the usual pairing
17 of solid level geometry with its visual
18
19An on-screen legend maps each slab colour to its parenting pattern.
20
21Controls:
22 Escape - Quit
23
24Run: uv run python examples/features/3d/mesh_parenting.py
25Headless self-check: uv run python examples/features/3d/mesh_parenting.py --test
26"""
27
28import math
29
30from simvx.core import (
31 BodyMode,
32 BoxShape3D,
33 Camera3D,
34 CollisionShape3D,
35 DirectionalLight3D,
36 Input,
37 Key,
38 Material,
39 Mesh,
40 MeshInstance3D,
41 Node,
42 Node3D,
43 PhysicsBody3D,
44 PhysicsRoot,
45 Quat,
46 Text2D,
47 Vec3,
48 WorldEnvironment,
49)
50from simvx.graphics import App
51
52# One shared cube mesh: every slab is the same geometry, only the parent differs.
53_CUBE = Mesh.cube(size=1.0)
54_SLAB_SCALE = Vec3(3, 0.5, 6)
55
56# One colour per parenting pattern; the legend reuses these so the mapping is visible.
57_C_DIRECT = (1, 0, 0, 1) # red: direct child of the root
58_C_GROUP = (0, 1, 0, 1) # green: Node3D group
59_C_KINEMATIC = (0, 0, 1, 1) # blue: KINEMATIC body
60_C_STATIC = (1, 1, 0, 1) # yellow: STATIC body + collider
61
62
63class MeshParentingDemo(Node):
64 """Shows the parenting patterns side by side, with the movable parents animated."""
65
66 input_actions = {"quit": [Key.ESCAPE]}
67
68 def on_ready(self):
69 # One isolated 3D world the body nodes resolve to (Y-up gravity). The
70 # bodies are STATIC/KINEMATIC and only serve as parent transforms for
71 # the meshes; the KINEMATIC one is moved directly via ``position``.
72 self._root = self.add_child(PhysicsRoot(name="World", gravity=Vec3(0, -9.8, 0)))
73
74 # Environment: default gradient sky + a touch of ambient so the scene
75 # reads as a deliberate composition rather than slabs in a black void.
76 env = self.add_child(WorldEnvironment())
77 env.ambient_light_energy = 0.5
78
79 # Camera
80 self.add_child(Camera3D(name="Cam", position=Vec3(0, 12, 22), fov=60, look_at=Vec3(0, 0, 0)))
81
82 # Lighting
83 sun = self.add_child(DirectionalLight3D(name="Sun"))
84 sun.direction = Vec3(-0.3, -1, -0.5)
85
86 # Ground plane so the slabs sit in a scene rather than float in space.
87 self.add_child(
88 MeshInstance3D(
89 name="Ground",
90 mesh=_CUBE,
91 material=Material(colour=(0.10, 0.11, 0.13, 1), metallic=0.1, roughness=0.9),
92 scale=Vec3(32, 0.1, 16),
93 position=Vec3(0, -0.8, 0),
94 )
95 )
96
97 x = -7.5
98 self._legend: list[tuple[str, tuple[float, float, float, float]]] = []
99 self._t = 0.0
100
101 # Direct child of the scene root: no intermediate transform at all.
102 self.add_child(
103 MeshInstance3D(
104 name="DirectChild",
105 mesh=_CUBE,
106 material=Material(colour=_C_DIRECT),
107 scale=_SLAB_SCALE,
108 position=Vec3(x, 0, 0),
109 )
110 )
111 self._legend.append(("Direct child of scene root", _C_DIRECT))
112 x += 5
113
114 # Node3D used as a group: the mesh keeps a zero local transform and
115 # inherits everything the group does (here, a slow spin).
116 self._group = self.add_child(Node3D(name="SpinGroup", position=Vec3(x, 0, 0)))
117 self._group.add_child(
118 MeshInstance3D(
119 name="GroupSlab",
120 mesh=_CUBE,
121 material=Material(colour=_C_GROUP),
122 scale=_SLAB_SCALE,
123 )
124 )
125 self._legend.append(("Inside a spinning Node3D group", _C_GROUP))
126 x += 5
127
128 # KINEMATIC body: the pose is owned by the physics world, so the mesh
129 # tracks whatever the simulation reports for its parent each frame. The
130 # collider and the visual are attached before the body enters the tree,
131 # which is when the body itself is built.
132 kinematic = PhysicsBody3D(name="BobbingBody", mode=BodyMode.KINEMATIC, position=Vec3(x, 0, 0))
133 kinematic.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(1.5, 0.25, 3.0))))
134 kinematic.add_child(
135 MeshInstance3D(
136 name="KinematicSlab",
137 mesh=_CUBE,
138 material=Material(colour=_C_KINEMATIC),
139 scale=_SLAB_SCALE,
140 )
141 )
142 self._kinematic = self._root.add_child(kinematic)
143 self._kinematic_x = x
144 self._legend.append(("Inside a bobbing KINEMATIC body", _C_KINEMATIC))
145 x += 5
146
147 # STATIC body + CollisionShape3D: how solid level geometry is built,
148 # with the collider and the visual as siblings under one body.
149 static_body = PhysicsBody3D(name="StaticBody", mode=BodyMode.STATIC, position=Vec3(x, 0, 0))
150 static_body.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(1.5, 0.25, 3.0))))
151 static_body.add_child(
152 MeshInstance3D(
153 name="StaticSlab",
154 mesh=_CUBE,
155 material=Material(colour=_C_STATIC),
156 scale=_SLAB_SCALE,
157 )
158 )
159 self._root.add_child(static_body)
160 self._legend.append(("Inside a STATIC body + CollisionShape3D", _C_STATIC))
161
162 # On-screen legend (top-left): one line per column, left to right,
163 # tinted with the matching slab colour.
164 margin = 16
165 self.add_child(
166 Text2D(
167 text="TRANSFORM HIERARCHY",
168 font_scale=2.2,
169 outline=0.08,
170 position=(margin, margin),
171 )
172 )
173 for i, (label, colour) in enumerate(self._legend):
174 self.add_child(
175 Text2D(
176 text=f"{i + 1}. {label}",
177 font_scale=1.5,
178 colour=colour,
179 outline=0.08,
180 position=(margin, margin + 58 + i * 30),
181 )
182 )
183
184 def on_update(self, dt: float):
185 if Input.is_action_just_pressed("quit"):
186 self.app.quit()
187 return
188
189 # Animate the movable parents: only the PARENT transforms change, so the
190 # meshes visibly following is the transform propagation being demonstrated.
191 self._t += dt
192 self._group.rotation = Quat.from_euler(0, self._t * 0.8, 0)
193 self._kinematic.position = Vec3(self._kinematic_x, 0.4 * math.sin(self._t * 1.5), 0)
194
195
196def _selftest() -> bool:
197 """Headless: check each slab really does follow the parent it was attached to.
198
199 The claim is about ``world_position``, which is what the renderer draws at, so
200 that is what is sampled -- every frame, on the mesh nodes themselves. Only the
201 PARENTS are animated, so a mesh that moves has inherited the movement and a
202 mesh that does not has an unmoving parent.
203
204 The slabs are reached with ``expect()`` rather than ``find()``: a typo in a
205 name would otherwise surface as ``None`` has no ``world_position`` thirty
206 lines later, while ``expect()`` fails at the lookup and names what it looked
207 for. The animated parents are reached with ``ancestor()`` from the slab that
208 is supposed to be following them, which is the relationship under test.
209 """
210 from simvx.core.testing import InputSimulator
211 from simvx.graphics.testing import assert_not_blank, save_png
212
213 QUIT = 150
214 FRAMES = 200 # ESC ends the run before this, which is itself a check
215
216 app = App(title="Transform Hierarchy", width=1280, height=720, visible=False)
217 scene = MeshParentingDemo(name="MeshParenting")
218 sim = InputSimulator()
219
220 names = ("DirectChild", "GroupSlab", "KinematicSlab", "StaticSlab")
221 tracks: dict[str, list[tuple[float, float, float]]] = {n: [] for n in names}
222 parents: list[tuple[float, float, float]] = []
223 spins: list[tuple[tuple[float, ...], tuple[float, ...]]] = []
224 ran = 0
225
226 def on_frame(idx: int, _t: float) -> bool:
227 nonlocal ran
228 ran = idx
229 for name in names:
230 node = scene.expect(name)
231 tracks[name].append(tuple(float(v) for v in node.world_position))
232 group_slab = scene.expect("GroupSlab")
233 spins.append(
234 (
235 tuple(float(v) for v in (group_slab.world_rotation * Vec3(1, 0, 0))),
236 tuple(float(v) for v in (group_slab.ancestor(Node3D).world_rotation * Vec3(1, 0, 0))),
237 )
238 )
239 kinematic_parent = scene.expect("KinematicSlab").ancestor(PhysicsBody3D)
240 parents.append(tuple(float(v) for v in kinematic_parent.world_position))
241 if idx == QUIT:
242 sim.press_key(Key.ESCAPE)
243 elif idx == QUIT + 1:
244 sim.release_key(Key.ESCAPE)
245 return True
246
247 frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[60])
248 assert_not_blank(frames[0])
249 save_png(frames[0], "/tmp/mesh_parenting_test.png")
250
251 ok = True
252
253 def check(label: str, passed: bool, detail: str) -> None:
254 nonlocal ok
255 ok = ok and passed
256 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
257
258 def travel(track) -> tuple[float, float, float]:
259 return tuple(max(p[i] for p in track) - min(p[i] for p in track) for i in range(3))
260
261 # A mesh parented straight to the root has no intermediate transform, and a
262 # mesh under a STATIC body has one the simulation never writes: both hold
263 # still while the scene around them animates.
264 for name in ("DirectChild", "StaticSlab"):
265 moved = max(travel(tracks[name]))
266 check(f"{name} never moves", moved < 1e-5, f"largest travel {moved:.2e}")
267
268 # The slab's own transform is zero, so the spinning group turns it in place:
269 # its world POSITION is the group's and never moves, while its world
270 # ORIENTATION is the group's and turns with it. Both halves are the claim.
271 gx, gy, gz = travel(tracks["GroupSlab"])
272 swept = max(abs(a[0] - spins[0][0][0]) for a, _ in spins)
273 apart = max(max(abs(u - v) for u, v in zip(slab, group, strict=True)) for slab, group in spins)
274 check(
275 "GroupSlab turns with the Node3D group, in place and in step",
276 max(gx, gy, gz) < 1e-5 and swept > 0.5 and apart < 1e-6,
277 f"held its position to {max(gx, gy, gz):.2e}, swept {swept:.2f} about the group, "
278 f"never {apart:.2e} out of step with it",
279 )
280
281 # The kinematic body bobs in y alone, and the mesh under it reports the same
282 # pose the physics world reports for the body, frame for frame.
283 kx, ky, kz = travel(tracks["KinematicSlab"])
284 drift = max(abs(a[1] - b[1]) for a, b in zip(tracks["KinematicSlab"], parents, strict=True))
285 check(
286 "KinematicSlab tracks the body's pose exactly",
287 ky > 0.5 and kx < 1e-5 and kz < 1e-5 and drift < 1e-5,
288 f"bobbed {ky:.2f} in y, never leaving the body by more than {drift:.2e}",
289 )
290
291 check("Escape ends the run", ran < FRAMES - 1, f"stopped at frame {ran} of {FRAMES}")
292
293 print("screenshot: /tmp/mesh_parenting_test.png")
294 print("SELFTEST:", "PASS" if ok else "FAIL")
295 return ok
296
297
298def main():
299 import sys
300
301 if "--test" in sys.argv:
302 sys.exit(0 if _selftest() else 1)
303 app = App(title="SimVX Transform Hierarchy", width=1280, height=720)
304 app.run(MeshParentingDemo(name="MeshParenting"))
305
306
307if __name__ == "__main__":
308 main()