Model Viewer¶
Load glTF models with PBR textures and orbit camera.
▶ Run in browserTags: 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())