3D Viewer

A standalone Svelte component that wraps Babylon.js with shared lighting, gray skybox, and arc-rotate camera controls (orbit, pan, zoom, WASD). No floor—just the canvas and skybox. Supports Z-up or Y-up and loading GLB/GLTF models by URL or via API.

How dist works

When you run npm run build, Vite compiles the library from src/ into the dist/ folder (bundled JavaScript and type definitions). The package is set up so that when another app installs this repo and does:

import { ThreeDViewer } from '3d-viewer';

Node resolves that to dist/index.js (see main, module, and exports in package.json). So the built dist/ is what other projects actually use—not the raw src/ files.

You must run npm run build in this repo before:

  • Using npm link or npm install /path/to/3d-viewer in another app, or
  • Publishing to a registry (otherwise consumers get no or stale build).

If dist/ is missing or outdated, the other app’s import will fail or use old code.

This package includes a prepare script that runs npm run build after npm install. So when someone installs from GitHub (e.g. npm install github:adom-inc/3d-viewer), npm will build the library automatically and dist/ will be present—no need to commit dist/ or use a Vite alias in the consuming app.

Installation

From GitHub (recommended; prepare builds automatically):

npm install github:adom-inc/3d-viewer

From a local path (e.g. while developing the viewer):

cd /path/to/3d-viewer
npm install
npm run build
# then in your app:
npm install /path/to/3d-viewer

Or, after publishing, use the package name from your registry.

Peer dependencies (your app must have these):

  • svelte ^4.0.0 or ^5.0.0
  • @babylonjs/core ^7.0.0
  • @babylonjs/loaders ^7.0.0

If your app doesn’t already depend on Babylon, install them:

npm install @babylonjs/core @babylonjs/loaders

Basic usage

Give the viewer a fixed height (it fills 100% width and height of its parent):

<script>
  import { ThreeDViewer } from '3d-viewer';
</script>

<div class="viewer-wrapper">
  <ThreeDViewer />
</div>

<style>
  .viewer-wrapper {
    width: 100%;
    height: 400px; /* or 100vh, etc. */
  }
</style>

Props

Prop Type Default Description
zUp boolean true Use Z-axis as up (true) or Y-axis (false, Babylon default).
modelUrl string | undefined undefined Optional URL of a GLB/GLTF to load when the viewer is ready.
showViewCube boolean true Show or hide the view cube in the corner.
showGround boolean false Show a ground plane beneath the model for receiving shadows. Use setGroundVisible(true/false) at runtime to toggle.
environmentUrl string | undefined undefined URL of a custom .env file for PBR reflections. A neutral studio environment is bundled by default — no setup needed.
initialViewMode 'perspective' | 'orthographic' 'perspective' Initial camera projection. Use setProjectionMode() or getProjectionMode() at runtime to change or read.

Example with props:

<ThreeDViewer
  zUp={true}
  modelUrl="/models/part.glb"
  showViewCube={true}
  showGround={true}
/>

Loading and clearing models

Use bind:this to get the component instance and call loadModel(url) or clearScene():

<script>
  import { ThreeDViewer } from '3d-viewer';

  let viewer;
  let modelUrl = '';

  async function loadFromUrl() {
    if (viewer && modelUrl) {
      await viewer.loadModel(modelUrl);
    }
  }

  function clear() {
    if (viewer) {
      viewer.clearScene();
    }
  }
</script>

<div class="viewer-wrapper">
  <ThreeDViewer bind:this={viewer} />
</div>

<div class="controls">
  <input type="text" bind:value={modelUrl} placeholder="GLB/GLTF URL" />
  <button on:click={loadFromUrl}>Load model</button>
  <button on:click={clear}>Clear scene</button>
</div>

<style>
  .viewer-wrapper {
    width: 100%;
    height: 400px;
  }
  .controls {
    display: flex;
    gap: 0.5rem;
    padding: 0.5rem;
  }
</style>
  • loadModel(url: string): Promise<void> — Loads a GLB/GLTF from url. Adds meshes to the scene and shadow casters, then frames the camera. Can be called multiple times; the frame will encompass all loaded models.
  • clearScene(): void — Removes all models loaded via loadModel (and any from the initial modelUrl prop). Does not remove the skybox, lighting, or content you added with addContentRoot.
  • frameModel(fillFraction?: number): void — Re-frames the camera to fit all content: loaded models plus any roots registered with addContentRoot. fillFraction defaults to 1 (fill viewport); use a larger value to zoom out.
  • getScene(): Scene | null — Returns the Babylon scene. Use it to create or modify native objects (spheres, boxes, materials, etc.).
  • getEngine(): Engine | null — Returns the Babylon engine.
  • getCamera(): Camera | null — Returns the active camera.
  • getShadowGenerator(): ShadowGenerator | null — Returns the shadow generator. Call addShadowCaster(mesh) for custom meshes that should cast shadows (or use addContentRoot with default options).
  • addContentRoot(node: TransformNode, options?: { castShadows?: boolean; receiveShadows?: boolean }): void — Registers a root (e.g. a parent of spheres) so it is included in frameModel(). By default its meshes cast and receive shadows. Does not dispose on clearScene().
  • removeContentRoot(node: TransformNode): void — Unregisters a content root from framing (does not dispose the node).
  • setGroundVisible(visible: boolean): void — Show or hide the ground plane at runtime. Updates showGround and re-runs framing if there is content.
  • goHome(): void — Jump the camera to the home view (front–top–right, like the view cube corner) and re-frame the model. No animation.
  • setProjectionMode(ortho: boolean): void — Set camera to orthographic (true) or perspective (false).
  • getProjectionMode(): 'perspective' \| 'orthographic' — Return the current camera projection.
  • showDebugLayer(): Promise<void> — Opens the Babylon.js debug layer (inspector). Lazy-loads the inspector on first use.
  • hideDebugLayer(): void — Closes the debug layer.
  • toggleDebugLayer(): Promise<void> — Toggles the debug layer open or closed.

Example: add a button to toggle the inspector:

<script>
  import { ThreeDViewer } from '3d-viewer';
  let viewer;
</script>

<div class="viewer-wrapper">
  <ThreeDViewer bind:this={viewer} />
</div>
<button on:click={() => viewer?.toggleDebugLayer()}>Toggle debug inspector</button>

<style>
  .viewer-wrapper { width: 100%; height: 400px; }
</style>

Creating native Babylon objects (e.g. planets)

You can use the viewer without loading any GLB: get the scene, create meshes with Babylon’s MeshBuilder, then register a root and frame so the camera fits your content.

Your app already has @babylonjs/core as a peer dependency, so you can use MeshBuilder, StandardMaterial, Vector3, etc.

<script lang="ts">
  import { ThreeDViewer } from '3d-viewer';
  import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
  import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
  import { Vector3 } from '@babylonjs/core/Maths/math.vector';
  import { Color3 } from '@babylonjs/core/Maths/math.color';

  let viewer;
  let planetsRoot: TransformNode | null = null;

  function addPlanets() {
    const scene = viewer?.getScene();
    if (!scene) return;

    if (planetsRoot) {
      viewer.removeContentRoot(planetsRoot);
      planetsRoot.dispose();
      planetsRoot = null;
    }

    const root = new TransformNode('PlanetsRoot', scene);
    planetsRoot = root;

    const sun = MeshBuilder.CreateSphere('sun', { diameter: 2 }, scene);
    sun.parent = root;
    const sunMat = new StandardMaterial('sunMat', scene);
    sunMat.emissiveColor = new Color3(1, 0.9, 0.5);
    sun.material = sunMat;

    const earth = MeshBuilder.CreateSphere('earth', { diameter: 0.6 }, scene);
    earth.parent = root;
    earth.position = new Vector3(3, 0, 0);
    const earthMat = new StandardMaterial('earthMat', scene);
    earthMat.diffuseColor = new Color3(0.2, 0.4, 0.8);
    earth.material = earthMat;

    viewer.addContentRoot(root);  // include in framing + shadows
    viewer.frameModel();
  }
</script>

<div class="viewer-wrapper">
  <ThreeDViewer bind:this={viewer} />
</div>
<button on:click={addPlanets}>Show planets</button>

<style>
  .viewer-wrapper { width: 100%; height: 400px; }
</style>

Simpler variant without a named root (use the scene as parent):

// Create spheres directly in the scene
const scene = viewer.getScene();
if (!scene) return;
const sphere = MeshBuilder.CreateSphere('planet', { diameter: 1 }, scene);
sphere.position = new Vector3(0, 0, 0);
viewer.addContentRoot(sphere);  // sphere is the root; it will be included in frameModel()
viewer.frameModel();
  • Use getScene() to create or modify any Babylon objects (meshes, lights, materials).
  • Use addContentRoot(node) so that node (and its children) are included when you call frameModel() and so they participate in shadow casting/receiving by default.
  • clearScene() only clears models loaded via loadModel(); it does not dispose content you added with addContentRoot(). Dispose your own nodes when you no longer need them.

Full page example

<script lang="ts">
  import { ThreeDViewer } from '3d-viewer';

  let viewer;
  const defaultModel = 'https://example.com/assets/model.glb';
</script>

<main>
  <div class="viewer-container">
    <ThreeDViewer
      bind:this={viewer}
      zUp={true}
      modelUrl={defaultModel}
      showViewCube={true}
    />
  </div>
</main>

<style>
  main {
    display: flex;
    flex-direction: column;
    height: 100vh;
  }
  .viewer-container {
    flex: 1;
    min-height: 0;
  }
</style>

Camera controls

  • Left drag — Orbit around target (or click-and-hold to set rotation point).
  • Middle mouse drag — Orbit.
  • Right drag — Pan.
  • Scroll — Zoom (zoom-to-cursor when supported).
  • WASD — Move target.
  • Q / E — Move target down / up.
  • Space — Continuous orbit.
  • Caps Lock — Precision (slower) mode.

Exposing the API to your app (ground, home, view mode)

Use bind:this to get the viewer instance, then call the methods from your own buttons or logic:

  • Ground: Set initial state with the showGround prop, or toggle at runtime with viewer.setGroundVisible(true) / viewer.setGroundVisible(false).
  • Home: Call viewer.goHome() to jump to the front–top–right view and re-frame the model.
  • View mode: Set initial projection with the initialViewMode prop ('perspective' or 'orthographic'). At runtime use viewer.setProjectionMode(true) for orthographic, viewer.setProjectionMode(false) for perspective, and viewer.getProjectionMode() to read the current mode.

Example:

<script>
  import { ThreeDViewer } from '3d-viewer';
  let viewer;
</script>

<ThreeDViewer bind:this={viewer} showGround={false} initialViewMode="perspective" />
<button on:click={() => viewer?.setGroundVisible(true)}>Show ground</button>
<button on:click={() => viewer?.setGroundVisible(false)}>Hide ground</button>
<button on:click={() => viewer?.goHome()}>Home</button>
<button on:click={() => viewer?.setProjectionMode(true)}>Orthographic</button>
<button on:click={() => viewer?.setProjectionMode(false)}>Perspective</button>

Types / .d.ts — You don't add a hand-written .d.ts in source. The build (Vite + Svelte) emits dist/index.d.ts from your entry and component. With package.json "types": "./dist/index.d.ts", installers get types for the component and its instance (including the new methods). Ensure npm run build has been run (or they install from GitHub so prepare runs it).

Coordinate system and viewer quirks

For detailed context on Z-up vs Y-up, how Fusion exports Z-up and KiCad Y-up by default, and other quirks (SpotLight-only shadows, model-relative lighting, view cube always Z-up, etc.), see VIEWER_CONTEXT.md. That doc is written for AIs and maintainers.

Notes

  • The viewer uses Babylon’s default CDN for environment/skybox assets; no extra static files are required.
  • The component owns a single Babylon engine/scene per instance; on destroy it stops the render loop and disposes the engine and scene.

Publishing

The package already has prepublishOnly: "npm run build", so npm publish builds the library first and consumers get an up-to-date dist/. You do not need to commit dist/ to git.