Model Viewer¶
Load glTF models with PBR textures and orbit camera.
▶ Run in browserTags: 3d
Auto-downloads the Khronos BoomBox sample on first run.
Asset references use the canonical Resource(package, name) form, which
resolves through :mod:importlib.resources. The BoomBox directory beside
this file is a real Python package (it carries an empty __init__.py); we add
that assets directory to sys.path so the package is importable however
the demo is launched. See docs/package_resources.md.
The camera frames whatever it loads. glTF models are authored at real-world scale, and the samples differ by orders of magnitude: this radio is 20 cm across, and a fixed orbit distance that suits it shows nothing at all of a building. The orbit distance and the zoom limits are therefore read off the model’s own bounds, so pointing the viewer at another file needs no tuning.
The Khronos BoomBox sample is third-party and is not part of the installed
library; if it is not beside this file it is fetched once into
$XDG_CACHE_HOME/simvx/example-assets/. It is CC0-1.0; see
assets/ATTRIBUTION.md.
Controls: Left-click drag: orbit camera Scroll wheel: zoom in/out Escape: quit
Usage: uv run python examples/features/3d/model_viewer.py uv run python examples/features/3d/model_viewer.py –test # headless self-check
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 BoomBox sample on first run.
8
9Asset references use the canonical ``Resource(package, name)`` form, which
10resolves through :mod:`importlib.resources`. The ``BoomBox`` directory beside
11this file is a real Python package (it carries an empty ``__init__.py``); we add
12that ``assets`` directory to ``sys.path`` so the package is importable however
13the demo is launched. See ``docs/package_resources.md``.
14
15The camera frames whatever it loads. glTF models are authored at real-world
16scale, and the samples differ by orders of magnitude: this radio is 20 cm
17across, and a fixed orbit distance that suits it shows nothing at all of a
18building. The orbit distance and the zoom limits are therefore read off the
19model's own bounds, so pointing the viewer at another file needs no tuning.
20
21The Khronos BoomBox sample is third-party and is not part of the installed
22library; if it is not beside this file it is fetched once into
23``$XDG_CACHE_HOME/simvx/example-assets/``. It is CC0-1.0; see
24``assets/ATTRIBUTION.md``.
25
26Controls:
27 Left-click drag: orbit camera
28 Scroll wheel: zoom in/out
29 Escape: quit
30
31Usage:
32 uv run python examples/features/3d/model_viewer.py
33 uv run python examples/features/3d/model_viewer.py --test # headless self-check
34"""
35
36import math
37import os
38import sys
39import urllib.request
40from pathlib import Path
41
42# Make ``examples/assets`` importable so ``Resource("BoomBox", ...)`` resolves
43# to the package next door. Mirrors how a game project would ship its own
44# assets dir on ``sys.path``; production games typically rely on their normal
45# package install path instead.
46_ASSETS_PARENT = (Path(__file__).parent / "assets").resolve()
47if str(_ASSETS_PARENT) not in sys.path:
48 sys.path.insert(0, str(_ASSETS_PARENT))
49
50from simvx.core import ( # noqa: E402
51 AnchorPreset,
52 Camera3D,
53 DirectionalLight3D,
54 Input,
55 InputMap,
56 Key,
57 Label,
58 MouseButton,
59 Node,
60 Resource,
61)
62from simvx.graphics import App # noqa: E402
63
64# One canonical handle per asset. Construction is lazy: the underlying file
65# is not touched until ``.path`` / ``.read_bytes()`` is accessed.
66MODEL_GLTF = Resource("BoomBox", "BoomBox.gltf")
67MODEL_BIN = Resource("BoomBox", "BoomBox.bin")
68MODEL_TEXTURES = [
69 Resource("BoomBox", "BoomBox_baseColor.png"),
70 Resource("BoomBox", "BoomBox_normal.png"),
71 Resource("BoomBox", "BoomBox_occlusionRoughnessMetallic.png"),
72 Resource("BoomBox", "BoomBox_emissive.png"),
73]
74
75# Khronos glTF-Sample-Assets raw URLs
76_BASE_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/BoomBox/glTF"
77_DOWNLOAD_TARGETS = [MODEL_GLTF, MODEL_BIN, *MODEL_TEXTURES]
78
79# How much empty space to leave around the model once it is framed: 1.0 puts
80# the bounding sphere exactly on the edges of the view, which crops the corners
81# as the model turns.
82_FRAME_MARGIN = 1.15
83
84
85def _download_assets() -> None:
86 """Fetch the BoomBox contents if they are not already available.
87
88 Writes into a per-user cache rather than beside this file, so it works from
89 a read-only install. The cache directory is put on ``sys.path`` so the
90 ``Resource`` lookup above finds it exactly as it finds a checkout copy.
91 """
92 if all((_ASSETS_PARENT / "BoomBox" / r.name).exists() for r in _DOWNLOAD_TARGETS):
93 return
94 cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
95 asset_dir = cache / "simvx" / "example-assets" / "BoomBox"
96 asset_dir.mkdir(parents=True, exist_ok=True)
97 (asset_dir / "__init__.py").touch()
98 if str(asset_dir.parent) not in sys.path:
99 sys.path.insert(0, str(asset_dir.parent))
100 for resource in _DOWNLOAD_TARGETS:
101 dest = asset_dir / resource.name
102 if dest.exists():
103 continue
104 url = f"{_BASE_URL}/{resource.name}"
105 print(f"Downloading {resource.name}...")
106 urllib.request.urlretrieve(url, dest)
107
108
109class ModelViewer(Node):
110 def on_ready(self):
111 InputMap.add_action("quit", [Key.ESCAPE])
112
113 # Orbit state. The distance and its limits are placeholders until the
114 # model is loaded and framed; a model whose size is unknown until then
115 # is exactly what this viewer exists to look at.
116 self._yaw = 0.0
117 self._pitch = 20.0
118 self._distance = 3.5
119 self._min_distance = 0.1
120 self._max_distance = 20.0
121 self._auto_rotate = True
122 self._target = (0.0, 0.0, 0.0)
123
124 # Camera
125 self._cam = Camera3D(name="Camera", fov=45, near=0.1, far=100.0)
126 self.add_child(self._cam)
127
128 # Lighting: key + fill
129 key = DirectionalLight3D(name="KeyLight", intensity=1.5)
130 key.look_at((-1.0, -2.0, -1.0))
131 self.add_child(key)
132
133 fill = DirectionalLight3D(name="FillLight", intensity=0.4, colour=(0.6, 0.7, 1.0))
134 fill.look_at((1.0, -1.0, 2.0))
135 self.add_child(fill)
136
137 # Load model. ``import_gltf`` is backend-agnostic: the same call
138 # works on desktop (Vulkan texture uploads) and in the web runtime
139 # (pixels streamed via the resource drain channel). In browser builds
140 # the glTF assets must be bundled alongside the game; when they're
141 # missing ``import_gltf`` returns an empty Node3D and the viewer
142 # shows just the lit backdrop.
143 self._load_model()
144
145 # Bottom controls strip: anchored so it tracks the viewport on resize.
146 hint = Label("Drag: orbit Scroll: zoom Esc: quit", name="Hint")
147 hint.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
148 hint.margin_left = 12.0
149 hint.margin_bottom = 34.0
150 hint.font_size = 15.0
151 self.add_child(hint)
152
153 self._update_camera()
154
155 def _load_model(self) -> None:
156 # Desktop-only: download the Khronos sample if it isn't cached.
157 # ``urllib`` isn't available under Pyodide by default, so skip the
158 # fetch in browser builds: the user must bundle assets themselves.
159 from simvx.graphics.assets.scene_import import import_gltf
160
161 if sys.platform != "emscripten":
162 _download_assets()
163
164 # ``Resource.path`` resolves the package handle through
165 # importlib.resources to a real filesystem path the glTF parser can
166 # open. Sibling .bin / .png files referenced from inside the .gltf
167 # are picked up relative to that path automatically.
168 model = import_gltf(str(MODEL_GLTF.path))
169 model.name = "Model"
170 self.add_child(model)
171 self._frame(model)
172
173 def _frame(self, model) -> None:
174 """Point the orbit camera at ``model`` and pull back far enough to see it.
175
176 The near and far planes move with it: a 20 cm object framed from 30 cm
177 away needs a near plane a good deal closer than the 10 cm a
178 person-scale scene wants, and clipping it away looks like a model that
179 failed to load.
180 """
181 radius = _bounding_radius(model)
182 if radius <= 0.0:
183 return
184 # Distance at which a sphere of that radius exactly fills the vertical
185 # field of view, plus the margin.
186 fit = radius / math.sin(math.radians(self._cam.fov) * 0.5)
187 self._target = tuple(float(c) for c in model.world_position)
188 self._distance = fit * _FRAME_MARGIN
189 self._min_distance = radius * 1.05
190 self._max_distance = fit * 8.0
191 self._cam.near = radius * 0.01
192 self._cam.far = self._max_distance + radius * 4.0
193
194 def on_update(self, dt):
195 # Auto-rotate
196 if self._auto_rotate:
197 self._yaw += 20.0 * dt
198
199 # Mouse drag orbit (left button = mouse_1)
200 if Input.is_mouse_button_pressed(MouseButton.LEFT):
201 delta = Input.mouse_delta
202 dx, dy = float(delta.x), float(delta.y)
203 if abs(dx) > 0.1 or abs(dy) > 0.1:
204 self._auto_rotate = False
205 self._yaw -= dx * 0.3
206 self._pitch += dy * 0.3
207 self._pitch = max(-89.0, min(89.0, self._pitch))
208
209 # Scroll zoom, in proportion to the distance: a fixed step is either
210 # imperceptible on a large model or a jump past a small one.
211 scroll = Input.scroll_delta
212 if scroll[1] != 0.0:
213 self._distance *= 0.9 ** scroll[1]
214 self._distance = max(self._min_distance, min(self._max_distance, self._distance))
215
216 # Escape to quit
217 if Input.is_action_just_pressed("quit"):
218 self.app.quit()
219 return
220
221 self._update_camera()
222
223 def _update_camera(self):
224 yaw_rad = math.radians(self._yaw)
225 pitch_rad = math.radians(self._pitch)
226 cp = math.cos(pitch_rad)
227 x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
228 y = self._target[1] + self._distance * math.sin(pitch_rad)
229 z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
230 self._cam.position = (x, y, z)
231 self._cam.look_at(self._target)
232
233
234def _bounding_radius(model) -> float:
235 """Distance from ``model``'s own origin to the furthest vertex under it.
236
237 A radius rather than a box, because it is what an orbit camera needs and
238 because rotation cannot change it: every mesh under the model contributes
239 its own bounding radius, scaled by that node's world scale and offset by
240 how far the node sits from the model's origin. Returns 0.0 for a model
241 that carries no mesh at all, which is what a failed import produces.
242 """
243 origin = tuple(float(c) for c in model.world_position)
244 radius = 0.0
245 stack = [model]
246 while stack:
247 node = stack.pop()
248 stack.extend(node.children)
249 mesh = getattr(node, "mesh", None)
250 if mesh is None:
251 continue
252 scale = max(abs(float(c)) for c in node.world_scale)
253 offset = math.dist(tuple(float(c) for c in node.world_position), origin)
254 radius = max(radius, offset + scale * mesh.bounding_radius())
255 return radius
256
257
258def _selftest() -> bool:
259 """Headless check: the model resolves, imports, and reaches the screen.
260
261 The claim under test is the whole asset path, which is the part of this
262 example a reader is most likely to copy: a ``Resource`` handle resolved
263 through ``importlib.resources``, backed by a fetch into a per-user cache
264 when the model is not beside this file. So the fetch runs first, and what
265 the viewer built from it is then read back off the scene it drew.
266
267 The model is third-party and is not part of the installed library, so this
268 needs one of three things: a copy beside this file, a cache warmed by an
269 earlier run, or the network. With none of them the question was never put
270 to the code, which is what ``Unsupported`` reports.
271 """
272 from urllib.error import HTTPError, URLError
273
274 from simvx.core import MeshInstance3D
275 from simvx.core.testing.selftest import Unsupported
276 from simvx.graphics.testing import assert_not_blank, save_png
277
278 try:
279 _download_assets()
280 except HTTPError:
281 # The server answered and refused: the fetch path itself is wrong.
282 raise
283 except URLError as exc:
284 raise Unsupported(
285 f"the BoomBox sample is neither beside this file nor cached, and it cannot be fetched: {exc.reason}"
286 ) from exc
287
288 app = App(title="Model Viewer: BoomBox", width=1280, height=720, visible=False)
289 scene = ModelViewer(name="ModelViewer")
290 frames = app.run_headless(scene, frames=8, capture_frames=[7])
291 assert_not_blank(frames[0])
292 save_png(frames[0], "/tmp/model_viewer_test.png")
293
294 ok = True
295
296 def check(label: str, passed: bool, detail: str) -> None:
297 nonlocal ok
298 ok = ok and passed
299 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
300
301 check(
302 "the Resource handle resolves to a file on disk",
303 Path(MODEL_GLTF.path).is_file(),
304 str(MODEL_GLTF.path),
305 )
306
307 model = next((child for child in scene.children if child.name == "Model"), None)
308 check(
309 "import_gltf hangs the model off the viewer",
310 model is not None,
311 f"children: {[c.name for c in scene.children]}",
312 )
313
314 # An empty Node3D is what import_gltf returns when it finds nothing to
315 # import, and it draws exactly like a successful import of an empty file.
316 meshes = []
317 stack = [model] if model is not None else []
318 while stack:
319 node = stack.pop()
320 if isinstance(node, MeshInstance3D) and node.mesh is not None:
321 meshes.append(node)
322 stack.extend(node.children)
323 check(
324 "and the model it built carries mesh geometry",
325 bool(meshes),
326 f"{len(meshes)} MeshInstance3D with a mesh under it",
327 )
328
329 # The framing is what makes the viewer independent of the model's scale:
330 # this sample is about 20 cm across, so an orbit distance left at the
331 # person-scale default would put the camera 15 model-widths away and draw
332 # a speck. Assert the camera sits within a few radii of the model.
333 radius = _bounding_radius(model) if model is not None else 0.0
334 check(
335 "the camera is framed on the model rather than on a fixed distance",
336 0.0 < radius and radius < scene._distance < radius * 6.0,
337 f"radius {radius:.4f}, orbit distance {scene._distance:.4f}",
338 )
339
340 print("screenshot: /tmp/model_viewer_test.png")
341 print("SELFTEST:", "PASS" if ok else "FAIL")
342 return ok
343
344
345if __name__ == "__main__":
346 if "--test" in sys.argv:
347 from simvx.core.testing.selftest import run_selftest
348
349 sys.exit(run_selftest(_selftest))
350 app = App(title="Model Viewer: BoomBox", width=1280, height=720)
351 app.run(ModelViewer())