Model Viewer

Load glTF models with PBR textures and orbit camera.

▶ Run in browser

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