Model Viewer

Load glTF models with PBR textures and orbit camera.

▶ Run in browser

Tags: 3d

Auto-downloads the Khronos DamagedHelmet sample on first run.

Asset references use the canonical Resource(package, name) form, which resolves through :mod:importlib.resources. The DamagedHelmet directory under examples/assets/ is a real Python package (it ships an empty __init__.py); we add examples/assets to sys.path so the package is importable even when the demo is launched via a bare file path (uv run python <file>). See docs/package_resources.md.

Controls: Left-click drag: orbit camera Scroll wheel: zoom in/out Escape: quit

Usage: uv run python examples/features/3d/model_viewer.py

Source

  1"""Model Viewer: Load glTF models with PBR textures and orbit camera.
  2
  3# /// simvx
  4# web = { root = "ModelViewer" }
  5# ///
  6
  7Auto-downloads the Khronos DamagedHelmet sample on first run.
  8
  9Asset references use the canonical ``Resource(package, name)`` form, which
 10resolves through :mod:`importlib.resources`. The ``DamagedHelmet`` directory
 11under ``examples/assets/`` is a real Python package (it ships an empty
 12``__init__.py``); we add ``examples/assets`` to ``sys.path`` so the package
 13is importable even when the demo is launched via a bare file path
 14(``uv run python <file>``). See ``docs/package_resources.md``.
 15
 16Controls:
 17    Left-click drag: orbit camera
 18    Scroll wheel: zoom in/out
 19    Escape: quit
 20
 21Usage:
 22    uv run python examples/features/3d/model_viewer.py
 23"""
 24
 25import math
 26import sys
 27import urllib.request
 28from pathlib import Path
 29
 30# Make ``examples/assets`` importable so ``Resource("DamagedHelmet", ...)``
 31# resolves to the package next door. Mirrors how a game project would ship
 32# its own assets dir on ``sys.path``; production games typically rely on
 33# their normal package install path instead.
 34_ASSETS_PARENT = (Path(__file__).parent / "assets").resolve()
 35if str(_ASSETS_PARENT) not in sys.path:
 36    sys.path.insert(0, str(_ASSETS_PARENT))
 37
 38from simvx.core import (  # noqa: E402
 39    AnchorPreset,
 40    Camera3D,
 41    DirectionalLight3D,
 42    Input,
 43    InputMap,
 44    Key,
 45    Label,
 46    MouseButton,
 47    Node,
 48    Resource,
 49)
 50from simvx.graphics import App  # noqa: E402
 51
 52# One canonical handle per asset. Construction is lazy: the underlying file
 53# is not touched until ``.path`` / ``.read_bytes()`` is accessed.
 54HELMET_GLTF = Resource("DamagedHelmet", "DamagedHelmet.gltf")
 55HELMET_BIN = Resource("DamagedHelmet", "DamagedHelmet.bin")
 56HELMET_TEXTURES = [
 57    Resource("DamagedHelmet", "Default_albedo.jpg"),
 58    Resource("DamagedHelmet", "Default_normal.jpg"),
 59    Resource("DamagedHelmet", "Default_metalRoughness.jpg"),
 60    Resource("DamagedHelmet", "Default_emissive.jpg"),
 61    Resource("DamagedHelmet", "Default_AO.jpg"),
 62]
 63
 64# Khronos glTF-Sample-Assets raw URLs
 65_BASE_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets" "/main/Models/DamagedHelmet/glTF"
 66_DOWNLOAD_TARGETS = [HELMET_GLTF, HELMET_BIN, *HELMET_TEXTURES]
 67
 68
 69def _download_assets() -> None:
 70    """Download the DamagedHelmet package contents if any file is missing."""
 71    asset_dir = (Path(__file__).parent / "assets" / "DamagedHelmet").resolve()
 72    asset_dir.mkdir(parents=True, exist_ok=True)
 73    for resource in _DOWNLOAD_TARGETS:
 74        dest = asset_dir / resource.name
 75        if dest.exists():
 76            continue
 77        url = f"{_BASE_URL}/{resource.name}"
 78        print(f"Downloading {resource.name}...")
 79        urllib.request.urlretrieve(url, dest)
 80
 81
 82class ModelViewer(Node):
 83    def on_ready(self):
 84        InputMap.add_action("quit", [Key.ESCAPE])
 85
 86        # Orbit state
 87        self._yaw = 0.0
 88        self._pitch = 20.0
 89        self._distance = 3.5
 90        self._auto_rotate = True
 91        self._target = (0.0, 0.0, 0.0)
 92
 93        # Camera
 94        self._cam = Camera3D(name="Camera", fov=45, near=0.1, far=100.0)
 95        self.add_child(self._cam)
 96
 97        # Lighting: key + fill
 98        key = DirectionalLight3D(name="KeyLight", intensity=1.5)
 99        key.look_at((-1.0, -2.0, -1.0))
100        self.add_child(key)
101
102        fill = DirectionalLight3D(name="FillLight", intensity=0.4, colour=(0.6, 0.7, 1.0))
103        fill.look_at((1.0, -1.0, 2.0))
104        self.add_child(fill)
105
106        # Load model. ``import_gltf`` is backend-agnostic: the same call
107        # works on desktop (Vulkan texture uploads) and in the web runtime
108        # (pixels streamed via the resource drain channel). In browser builds
109        # the glTF assets must be bundled alongside the game; when they're
110        # missing ``import_gltf`` returns an empty Node3D and the viewer
111        # shows just the lit backdrop.
112        self._load_model()
113
114        # Bottom controls strip: anchored so it tracks the viewport on resize.
115        hint = Label("Drag: orbit    Scroll: zoom    Esc: quit", name="Hint")
116        hint.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
117        hint.margin_left = 12.0
118        hint.margin_bottom = 34.0
119        hint.font_size = 15.0
120        self.add_child(hint)
121
122        self._update_camera()
123
124    def _load_model(self) -> None:
125        # Desktop-only: download the Khronos sample if it isn't cached.
126        # ``urllib`` isn't available under Pyodide by default, so skip the
127        # fetch in browser builds: the user must bundle assets themselves.
128        from simvx.graphics.assets.scene_import import import_gltf
129        if sys.platform != "emscripten":
130            _download_assets()
131
132        # ``Resource.path`` resolves the package handle through
133        # importlib.resources to a real filesystem path the glTF parser can
134        # open. Sibling .bin / .jpg files referenced from inside the .gltf
135        # are picked up relative to that path automatically.
136        model = import_gltf(str(HELMET_GLTF.path))
137        model.name = "Helmet"
138        self.add_child(model)
139
140    def on_update(self, dt):
141        # Auto-rotate
142        if self._auto_rotate:
143            self._yaw += 20.0 * dt
144
145        # Mouse drag orbit (left button = mouse_1)
146        if Input.is_mouse_button_pressed(MouseButton.LEFT):
147            delta = Input.mouse_delta
148            dx, dy = float(delta.x), float(delta.y)
149            if abs(dx) > 0.1 or abs(dy) > 0.1:
150                self._auto_rotate = False
151                self._yaw -= dx * 0.3
152                self._pitch += dy * 0.3
153                self._pitch = max(-89.0, min(89.0, self._pitch))
154
155        # Scroll zoom
156        scroll = Input.scroll_delta
157        if scroll[1] != 0.0:
158            self._distance -= scroll[1] * 0.3
159            self._distance = max(1.0, min(20.0, self._distance))
160
161        # Escape to quit
162        if Input.is_action_just_pressed("quit"):
163            self.app.quit()
164            return
165
166        self._update_camera()
167
168    def _update_camera(self):
169        yaw_rad = math.radians(self._yaw)
170        pitch_rad = math.radians(self._pitch)
171        cp = math.cos(pitch_rad)
172        x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
173        y = self._target[1] + self._distance * math.sin(pitch_rad)
174        z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
175        self._cam.position = (x, y, z)
176        self._cam.look_at(self._target)
177
178
179if __name__ == "__main__":
180    app = App(title="Model Viewer: DamagedHelmet", width=1280, height=720)
181    app.run(ModelViewer())