Transform Hierarchy

MeshInstance3D follows any parent transform.

▶ Run in browser

Tags: 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()