Mesh Parenting

MeshInstance3D rendering under different parent node types.

▶ Run in browser

Tags: 3d

Demonstrates that MeshInstance3D renders correctly when parented to:

  1. Scene root directly (baseline)

  2. Node3D intermediate

  3. KinematicBody3D

  4. StaticBody3D

  5. StaticBody3D with collision shape

  6. Many objects (transform buffer scaling)

Run: uv run python examples/features/3d/mesh_parenting.py uv run python examples/features/3d/mesh_parenting.py –test

Source

  1"""Mesh Parenting: MeshInstance3D rendering under different parent node types.
  2
  3# /// simvx
  4# web = { width = 1280, height = 720 }
  5# ///
  6
  7Demonstrates that MeshInstance3D renders correctly when parented to:
  81. Scene root directly (baseline)
  92. Node3D intermediate
 103. KinematicBody3D
 114. StaticBody3D
 125. StaticBody3D with collision shape
 136. Many objects (transform buffer scaling)
 14
 15Run: uv run python examples/features/3d/mesh_parenting.py
 16     uv run python examples/features/3d/mesh_parenting.py --test
 17"""
 18
 19import sys
 20
 21from simvx.core import (
 22    Camera3D,
 23    DirectionalLight3D,
 24    Material,
 25    Mesh,
 26    MeshInstance3D,
 27    Node,
 28    Node3D,
 29    Vec3,
 30)
 31from simvx.core.collision import BoxShape
 32from simvx.core.physics import KinematicBody3D, PhysicsMaterial, PhysicsServer, StaticBody3D
 33from simvx.graphics import App
 34
 35# Shared meshes (like working examples do)
 36_CUBE = Mesh.cube(size=1.0)
 37_SPHERE = Mesh.sphere(radius=0.5, rings=12, segments=12)
 38_CYLINDER = Mesh.cylinder(radius=1.0, height=0.3, segments=16)
 39
 40
 41class StaticBodyRenderTest(Node):
 42    """Tests all parenting patterns side by side."""
 43
 44    def on_ready(self):
 45        super().on_ready()
 46        PhysicsServer.reset()
 47        srv = PhysicsServer.get()
 48        srv.gravity = Vec3(0, -9.8, 0)
 49
 50        # Camera
 51        self.add_child(Camera3D(name="Cam", position=Vec3(0, 12, 22), fov=60, look_at=Vec3(0, 0, 0)))
 52
 53        # Lighting
 54        sun = self.add_child(DirectionalLight3D(name="Sun"))
 55        sun.direction = Vec3(-0.3, -1, -0.5)
 56
 57        x = -12
 58        self._results = []
 59
 60        # --- Test 1: Direct child (baseline, always works) ---
 61        self.add_child(MeshInstance3D(
 62            name="T1_Direct",
 63            mesh=_CUBE,
 64            material=Material(colour=(1, 0, 0, 1)),
 65            scale=Vec3(3, 0.5, 6),
 66            position=Vec3(x, 0, 0),
 67        ))
 68        self._results.append(("T1: Direct child", x))
 69        x += 5
 70
 71        # --- Test 2: Inside Node3D ---
 72        group = self.add_child(Node3D(name="T2_Group", position=Vec3(x, 0, 0)))
 73        group.add_child(MeshInstance3D(
 74            name="T2_InNode3D",
 75            mesh=_CUBE,
 76            material=Material(colour=(0, 1, 0, 1)),
 77            scale=Vec3(3, 0.5, 6),
 78        ))
 79        self._results.append(("T2: Inside Node3D", x))
 80        x += 5
 81
 82        # --- Test 3: Inside KinematicBody3D ---
 83        body3 = self.add_child(KinematicBody3D(name="T3_Kin", position=Vec3(x, 0, 0)))
 84        body3.add_child(MeshInstance3D(
 85            name="T3_InKinBody",
 86            mesh=_CUBE,
 87            material=Material(colour=(0, 0, 1, 1)),
 88            scale=Vec3(3, 0.5, 6),
 89        ))
 90        self._results.append(("T3: Inside KinematicBody3D", x))
 91        x += 5
 92
 93        # --- Test 4: Inside StaticBody3D (no collision shape) ---
 94        sb4 = self.add_child(StaticBody3D(name="T4_SB", position=Vec3(x, 0, 0)))
 95        sb4.add_child(MeshInstance3D(
 96            name="T4_InSB",
 97            mesh=_CUBE,
 98            material=Material(colour=(1, 1, 0, 1)),
 99            scale=Vec3(3, 0.5, 6),
100        ))
101        self._results.append(("T4: Inside StaticBody3D", x))
102        x += 5
103
104        # --- Test 5: Inside StaticBody3D WITH collision shape (Marble Rally pattern) ---
105        sb5 = StaticBody3D(name="T5_SBCol", position=Vec3(x, 0, 0))
106        sb5.collision_shape = BoxShape(half_extents=(1.5, 0.25, 3.0))
107        sb5.physics_material = PhysicsMaterial(friction=0.8, restitution=0.2)
108        sb5.add_child(MeshInstance3D(
109            name="T5_InSBCol",
110            mesh=_CUBE,
111            material=Material(colour=(1, 0, 1, 1)),
112            scale=Vec3(3, 0.5, 6),
113        ))
114        self.add_child(sb5)
115        self._results.append(("T5: Inside SB3D+collision", x))
116        x += 5
117
118        # --- Test 6: Cylinder inside StaticBody3D (round pad test) ---
119        sb6 = self.add_child(StaticBody3D(name="T6_SBCyl", position=Vec3(x, 0, 0)))
120        sb6.add_child(MeshInstance3D(
121            name="T6_Cylinder",
122            mesh=_CYLINDER,
123            material=Material(colour=(0, 1, 1, 1)),
124        ))
125        self._results.append(("T6: Cylinder in SB3D", x))
126
127        # Print expected layout
128        for label, xpos in self._results:
129            print(f"  x={xpos:+3d}  {label}")
130
131
132def main():
133    test_mode = "--test" in sys.argv
134
135    if test_mode:
136        print("=== Static Body Rendering Test (headless) ===\n")
137        app = App(title="SB3D Test", width=1280, height=720, visible=False, mode="3d")
138        scene = StaticBodyRenderTest(name="Test")
139        frames = app.run_headless(scene, frames=10, capture_frames=[9])
140
141        if not frames:
142            print("ERROR: No frames captured!")
143            # sys.exit OK here: headless path, app.run_headless() already returned (no live event loop / audio threads).
144            sys.exit(1)
145
146        import numpy as np
147        from PIL import Image
148
149        img = np.array(frames[0])
150        Image.fromarray(img).save("/tmp/static_body_test.png")
151        print("Screenshot: /tmp/static_body_test.png\n")
152
153        colours = {
154            "T1: Direct child": ("red", lambda i: (i[:,:,0] > 100) & (i[:,:,1] < 80) & (i[:,:,2] < 80)),
155            "T2: Inside Node3D": ("green", lambda i: (i[:,:,1] > 100) & (i[:,:,0] < 80) & (i[:,:,2] < 80)),
156            "T3: Inside KinematicBody3D": ("blue", lambda i: (i[:,:,2] > 100) & (i[:,:,0] < 80) & (i[:,:,1] < 80)),
157            "T4: Inside StaticBody3D": ("yellow", lambda i: (i[:,:,0] > 100) & (i[:,:,1] > 100) & (i[:,:,2] < 80)),
158            "T5: Inside SB3D+collision": ("magenta", lambda i: (i[:,:,0] > 100) & (i[:,:,2] > 100) & (i[:,:,1] < 80)),
159            "T6: Cylinder in SB3D": ("cyan", lambda i: (i[:,:,1] > 100) & (i[:,:,2] > 100) & (i[:,:,0] < 80)),
160        }
161
162        all_pass = True
163        for label, (colour_name, detect_fn) in colours.items():
164            count = int(np.sum(detect_fn(img)))
165            status = "PASS" if count > 50 else "FAIL"
166            flag = "  " if count > 50 else "!!"
167            if count <= 50:
168                all_pass = False
169            print(f"  {flag} {status}: {label} ({colour_name}, {count} px)")
170
171        print(f"\n{'ALL TESTS PASSED' if all_pass else 'SOME TESTS FAILED'}")
172        # sys.exit OK here: headless path, app.run_headless() already returned (no live event loop / audio threads).
173        sys.exit(0 if all_pass else 1)
174    else:
175        print("Static Body Rendering Demo: visual inspection")
176        print("You should see 6 coloured slabs/cylinders in a row.\n")
177        app = App(title="StaticBody3D Render Test", width=1280, height=720, mode="3d")
178        app.run(StaticBodyRenderTest(name="Test"))
179
180
181if __name__ == "__main__":
182    main()