3D Viewer: Context for AIs and maintainers

This document explains how the viewer works, especially its Z-up vs Y-up behavior and how it differs from a typical Babylon.js app. Use it when modifying the viewer or when another AI needs to reason about the codebase.


Coordinate system: Z-up vs Y-up (and why it matters)

The viewer can run in two orientations: Z-up (default) or Y-up. That choice affects the camera, lights, ground plane, and how imported models appear. It exists because different tools and formats use different conventions.

How Babylon.js normally works

  • Babylon.js is Y-up by default. The default camera upVector is (0, 1, 0), the default ground plane lies in the XZ plane (normal = Y), and many examples and docs assume Y is "up."
  • Many other ecosystems (CAD, robotics, some GLTF exporters) use Z-up: Z is "up," and the "floor" is in the XY plane.

If you load a Z-up GLB into a default Babylon scene without adjusting anything, the model will look like it's lying on its side: what the model considers "up" (Z) is aligned with Babylon's Y axis instead of Z.

What this viewer does when zUp={true} (default)

  • Scene: scene.useRightHandedSystem = true is set in AdomStatics.init. In this codebase, handedness is tied to the zUp flag.
  • Camera: In AdomCamera's constructor, camera.upVector = (0, 0, 1) when zUp is true, so the orbit camera treats Z as up. Orbit, pan, zoom, and Q/E "up/down" all respect Z as vertical.
  • Lights: In SceneBuilder.init, the hemispheric light direction and the SpotLight's initial position are chosen based on zUp. In ThreeDViewer.svelte's frameModel(), the SpotLight is repositioned: when zUp is true, height is applied along Z and lateral offset in X/Y; when false, height is along Y and offset in X/Z. So "above the model" and "to the side" stay correct for both conventions.
  • Ground plane (optional): Babylon's MeshBuilder.CreateGround creates a quad in the XZ plane (normal Y). When zUp is true, the viewer rotates that ground by Math.PI/2 around X so the plane lies in the XY plane (normal Z) and positions it at worldMin.z. When zUp is false, the ground stays in the XZ plane and is positioned at worldMin.y. So in both modes the ground sits "under" the content along the chosen up axis.
  • View cube: The view cube is rendered in a separate mini-scene in ViewCubeBuilder.ts, and that scene is always Z-up: camera2.upVector = new Vector3(0, 0, 1). The cube's labels (TOP, FRONT, etc.) and the "Home" (top-front-right) view therefore assume Z is up. If the main scene is run with zUp={false} (Y-up), the view cube still shows a Z-up orientation, which can be visually inconsistent with the main scene.

So with zUp={true} (default), the main scene (camera, lights, ground) is consistent with Z as the vertical axis. With zUp={false}, the main scene matches traditional Babylon Y-up; only the view cube remains Z-up.

Source-specific conventions: Fusion (Z-up) vs KiCad (Y-up)

  • Autodesk Fusion 360 typically exports GLB/GLTF with Z-up: the design "up" is the Z axis. Using this viewer with zUp={true} (default) shows the model upright without extra transforms.
  • KiCad (and many PCB / electronics tools) often use Y-up in their 3D export or internal coordinates. For such models, zUp={false} may match the intended orientation so the board doesn't appear rotated or "lying down."

If a model appears wrong side up, try flipping the zUp prop and reloading. The viewer does not auto-detect up axis from the file.

Summary table

zUp Camera up Ground plane Typical use case
true Z XY plane, at min Z Fusion-style, CAD, Z-up GLBs
false Y XZ plane, at min Y Babylon default, KiCad, Y-up

Quirks and differences from a typical Babylon viewer

These are the main ways this component diverges from a vanilla Babylon.js setup.

  1. Single SpotLight for shadows (no DirectionalLight)
    The main scene uses a SpotLight as the only shadow-casting light (and for the lit "pool" on the ground). There is no DirectionalLight. The shadow generator is attached to the SpotLight. Light position and direction are updated in frameModel() proportionally to the model size. This matches a reference "Hydrogen" app and keeps the ground from being uniformly lit (the cone naturally darkens the edges).

  2. SpotLight position and intensity scale with the model
    In ThreeDViewer.svelte's frameModel(), the SpotLight is moved to a height and lateral offset derived from the model's bounding size (e.g. spotHeight = modelSize * 3). Intensity is scaled by the square of that height (inverse-square falloff) so illumination stays consistent across very different model scales. So lighting is model-relative, not fixed in world space.

  3. Ground plane is optional and model-relative
    When showGround is true, the ground is created inside frameModel(): its size and position are based on the current content bounds. There is no permanent "floor" at world zero; it's recreated when you frame and is tied to the content's extent and to zUp.

  4. View cube is always Z-up
    The view cube lives in its own scene (ViewCubeBuilder) with a fixed Z-up camera. The "Home" button and cube corners (e.g. top-front-right) assume Z is up. If the main scene is Y-up, the cube's orientation does not match the main scene's "up."

  5. Camera bounds and limits are set in frameModel()
    Radius limits, target bounds, panning sensibility, and near/far clip planes are recomputed when you call frameModel(). They are driven by the current set of loaded roots and addContentRoot roots, so the camera behavior is content-aware rather than fixed.

  6. High-precision depth
    The engine is created with useHighPrecisionFloats: true to reduce z-fighting when zoomed out or with large scenes.

  7. No automatic up-axis detection
    The viewer does not read the GLB/GLTF or detect whether the asset is Z-up or Y-up. The app (or user) must set the zUp prop to match the source (e.g. Fusion vs KiCad).

  8. Bundled environment and loading assets
    A default studio .env and loading logo are embedded as base64 so the package works without serving extra static files. The environmentUrl prop can override the environment for PBR reflections.


Where zUp is used in the codebase

  • AdomStatics.init(canvas, zUp) — Sets scene.useRightHandedSystem = zUp and passes zUp into AdomCamera.
  • AdomCamera constructor — Sets this.upVector = zUp ? new Vector3(0, 0, 1) : new Vector3(0, 1, 0).
  • SceneBuilder.init(engine, { zUp, ... }) — Hemispheric light direction and SpotLight initial position/direction use zUp.
  • ThreeDViewer.svelteframeModel() uses zUp for SpotLight position (which axis is "height" vs "lateral") and for ground plane rotation and position (worldMin.z vs worldMin.y).
  • ViewCubeBuilder — Does not take zUp; the view cube scene is always Z-up.

This should give other AIs enough context to understand how Z-up works and why the viewer is structured this way.