Skip to content
Starter Kits— browse docs
On this page

3D Model (tool)

A shared 3D model viewer — one world-media reference, and an orbit camera that belongs to whoever's looking.

Say you've got a .glb of a relic, a ship, or a building, and you want it on a page in your world where everyone can look at it. Not everyone from the same angle — everyone from their own angle, at the same model.

That's this kit, and it's the clearest example on the list of a document storing a reference rather than content.

You will learn

  • How to store a piece of world media by id instead of copying the file
  • How three.js gets loaded once by the platform rather than bundled by you
  • Why the orbit camera is local state and the auto-rotate toggle isn't
  • What a tool does when the host doesn't offer a capability it can use

Try it

3D Model, running (with nothing to show)
Starting the example…

This is the only kit on the list you can't really play with here, and the reason is the kit's whole point: it shows world MEDIA, and a docs page has no world and no media pool. So what you get is the real empty state — and pressing “Add a 3D model” does nothing, because there is nothing behind it to pick from. Run it in a world, drop a .glb on it, and the viewer takes over. (three.js only loads once a model is chosen, which is why an empty viewer costs nothing.)

Create it

vvd create relic-viewer --tool --template=model
Expected output:
→ Creating tool relic-viewer in /Users/you/dev/relic-viewer — from the 3D Model template

✦  ah — a relic-viewer tool. let's build it.

Next:
cd relic-viewer
vvd run    # render it live in your world — hot-reloads as you edit
vvd save   # save a new version (a private draft)
✓ Created relic-viewer (tool) → /Users/you/dev/relic-viewer

Run it in a real world and the empty state becomes useful: pick a .glb or .gltf from your world's media, or drop one onto the frame, and the viewer takes over.

What you'd build with it

Anything where the thing being discussed is an object:

  • A relic or artefact viewer — one document per object, embedded as a block on the object's card.
  • A ship or vehicle showcase — turntable it, note the differences between marks.
  • Character or costume turnarounds — the reference sheet that used to be six flat images.
  • An architectural or set model — walk the geometry rather than describe it.
  • A prop library — a folder of these, one per prop, each linked from the scene it appears in.
  • A sculpt review page — everyone orbits their own way and comments on the same version.

What's in it

src/codec.ts12 lines

The data model. One declaration of what the document holds and how two people's edits merge — and the file `vvd save` reads to derive what an agent can do with your creation.

src/codec.ts
import { defineStateCodec, field } from "@vvd/sdk"

// The whole durable shape: a world MEDIA reference (the id of a .glb/.gltf in the
// world's media pool — always an id, never a copy of the file) plus one shared
// display toggle. The camera (orbit angles, zoom) is LOCAL view state and is
// NEVER stored here — two people orbit the same model independently.
export const codec = defineStateCodec({
  modelMediaId: field.value<string | null>(null),
  autoRotate: field.value<boolean>(true),
})

export default codec
src/tool.tsx262 lines

The tool itself: a `defineTool` with a `render` function. This is the file you edit first.

src/tool.tsx
import { type PointerEvent, useEffect, useMemo, useRef } from "react"

import { DocumentGate, HostIcon, defineTool, loadThree, useContextMenu, useDocument, useDocumentPresence, useHostCapability, useResolvedMedia } from "@vvd/sdk"

import { codec } from "@/codec"

const NAME = "relic-viewer"
const FILE = "src/tool.tsx"

// relic-viewer is a shared 3D model viewer. The document stores a world MEDIA
// reference (an id into the world's media pool — never a copy of the file), so
// everyone sees the same model, plus one shared auto-rotate toggle. The orbit
// CAMERA is local — each peer looks from their own angle, and no camera field
// ever lands in the codec. State edits (replace / toggle / remove) ride the
// platform undo timeline automatically (defineStateCodec.undoScope).

/** LOCAL orbit state — spherical coordinates around the origin. Never in the codec. */
type Orbit = { theta: number; phi: number; dist: number; drag: { x: number; y: number } | null }

const clampPhi = (v: number) => Math.min(2.9, Math.max(0.2, v))
const clampDist = (v: number) => Math.min(50, Math.max(0.2, v))

/** The baseline presence display — who else is in this document right now. */
function PresenceStrip({ handle }: { handle: Parameters<typeof useDocumentPresence>[0] }) {
  const others = useDocumentPresence(handle).filter((p) => !p.isSelf)
  if (others.length === 0) return null
  return (
    <span style={{ display: "flex", alignItems: "center" }} title={others.map((p) => p.name).join(", ")}>
      {others.slice(0, 5).map((p) => (
        <span key={p.userId} style={{ width: 24, height: 24, borderRadius: "50%", marginLeft: -8, display: "inline-flex", alignItems: "center", justifyContent: "center", background: p.color, color: "#fff", fontSize: 11, fontWeight: 700, border: "2px solid rgba(255,255,255,0.35)" }}>
          {p.name ? p.name.slice(0, 1).toUpperCase() : ""}
        </span>
      ))}
      {others.length > 5 && <span style={{ marginLeft: 6, fontSize: 12, opacity: 0.7 }}>+{others.length - 5}</span>}
    </span>
  )
}

/** The three.js stage. Shared three loads lazily through the SDK (loadThree) — the
 *  host ships ONE copy; a .vvd bundle never inlines its own. */
function Viewer({ url, autoRotate }: { url: string; autoRotate: boolean }) {
  const mountRef = useRef<HTMLDivElement | null>(null)
  const orbitRef = useRef<Orbit>({ theta: 0.6, phi: 1.15, dist: 4, drag: null })
  const autoRotateRef = useRef(autoRotate)
  useEffect(() => { autoRotateRef.current = autoRotate }, [autoRotate])

  useEffect(() => {
    const mount = mountRef.current
    if (!mount) return
    let disposed = false
    let raf = 0
    let renderer: { dispose(): void; domElement: HTMLCanvasElement } | null = null
    let onResize: (() => void) | null = null
    void loadThree().then((kit) => {
      if (disposed) return
      const T = kit as any // three ships no SDK types over the kit seam
      const scene = new T.Scene()
      const camera = new T.PerspectiveCamera(50, mount.clientWidth / Math.max(1, mount.clientHeight), 0.01, 100)
      const r = new T.WebGLRenderer({ antialias: true, alpha: true })
      renderer = r
      r.setPixelRatio(Math.min(2, window.devicePixelRatio || 1))
      r.setSize(mount.clientWidth, mount.clientHeight)
      mount.appendChild(r.domElement)
      scene.add(new T.AmbientLight(0xffffff, 0.8))
      const keyLight = new T.DirectionalLight(0xffffff, 1.4)
      keyLight.position.set(3, 4, 5)
      scene.add(keyLight)
      const rimLight = new T.DirectionalLight(0xffffff, 0.5)
      rimLight.position.set(-4, 2, -3)
      scene.add(rimLight)
      onResize = () => {
        camera.aspect = mount.clientWidth / Math.max(1, mount.clientHeight)
        camera.updateProjectionMatrix()
        r.setSize(mount.clientWidth, mount.clientHeight)
      }
      window.addEventListener("resize", onResize)
      new T.GLTFLoader().loadAsync(url).then((gltf: { scene: { position: { set(x: number, y: number, z: number): void } } }) => {
        if (disposed) return
        const obj = gltf.scene
        // Center the model on the origin; back the camera off proportionally.
        const box = new T.Box3().setFromObject(obj)
        const center = box.getCenter(new T.Vector3())
        const size = box.getSize(new T.Vector3())
        obj.position.set(-center.x, -center.y, -center.z)
        scene.add(obj)
        orbitRef.current.dist = clampDist((Math.max(size.x, size.y, size.z) || 1) * 1.8)
      })
      const tick = () => {
        raf = requestAnimationFrame(tick)
        const o = orbitRef.current
        if (autoRotateRef.current && !o.drag) o.theta += 0.004
        camera.position.set(
          o.dist * Math.sin(o.phi) * Math.sin(o.theta),
          o.dist * Math.cos(o.phi),
          o.dist * Math.sin(o.phi) * Math.cos(o.theta),
        )
        camera.lookAt(0, 0, 0)
        r.render(scene, camera)
      }
      tick()
    })
    return () => {
      disposed = true
      cancelAnimationFrame(raf)
      if (onResize) window.removeEventListener("resize", onResize)
      if (renderer) {
        renderer.dispose()
        renderer.domElement.remove()
      }
    }
  }, [url])

  // Zoom wheels the orbit distance — a NON-passive listener so preventDefault
  // can stop the page from scrolling under the canvas.
  useEffect(() => {
    const mount = mountRef.current
    if (!mount) return
    const onWheel = (e: WheelEvent) => {
      e.preventDefault()
      const o = orbitRef.current
      o.dist = clampDist(o.dist * (1 + e.deltaY * 0.0015))
    }
    mount.addEventListener("wheel", onWheel, { passive: false })
    return () => mount.removeEventListener("wheel", onWheel)
  }, [])

  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
    orbitRef.current.drag = { x: e.clientX, y: e.clientY }
    e.currentTarget.setPointerCapture?.(e.pointerId)
  }
  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    const o = orbitRef.current
    if (!o.drag) return
    o.theta -= (e.clientX - o.drag.x) * 0.008
    o.phi = clampPhi(o.phi - (e.clientY - o.drag.y) * 0.006)
    o.drag = { x: e.clientX, y: e.clientY }
  }
  const endDrag = () => { orbitRef.current.drag = null }

  return (
    <div ref={mountRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove}
      onPointerUp={endDrag} onPointerLeave={endDrag}
      style={{ position: "absolute", inset: 0, cursor: "grab", touchAction: "none" }} />
  )
}

function Stage({ data, actions, handle, canEdit, worldId }: {
  data: { modelMediaId: string | null; autoRotate: boolean }
  actions: { set(name: "modelMediaId", value: string | null): void; set(name: "autoRotate", value: boolean): void }
  handle: Parameters<typeof useDocumentPresence>[0]
  canEdit: boolean
  worldId: string
}) {
  const media = useHostCapability("media")
  const url = useResolvedMedia(media, data.modelMediaId)
  const { openContextMenu } = useContextMenu()
  const fileRef = useRef<HTMLInputElement | null>(null)

  const setModel = (id: string | null) => actions.set("modelMediaId", id)

  // Add/replace through the host's media picker when it ships one; otherwise a
  // plain file input feeding media.upload does the same job.
  const pickModel = () => {
    if (!canEdit) return
    if (media.pick) {
      void media.pick({ worldId, accept: [".glb", ".gltf", "model/gltf-binary"] }).then((chosen) => {
        if (chosen) setModel(chosen.id)
      })
    } else {
      fileRef.current?.click()
    }
  }
  const uploadFile = (file: File) => {
    if (!canEdit) return
    void media.upload?.(file, { worldId, filename: file.name }).then((made) => setModel(made.id))
  }

  return (
    <div
      onContextMenu={(e) => {
        // Viewer actions ride the platform right-click engine — pure data items.
        if (!canEdit || !data.modelMediaId) return
        openContextMenu(e, [
          { kind: "item" as const, id: "replace", label: "Replace model", icon: "image", onSelect: pickModel },
          { kind: "item" as const, id: "auto-rotate", label: data.autoRotate ? "Auto-rotate off" : "Auto-rotate on", icon: "tabler:refresh", onSelect: () => actions.set("autoRotate", !data.autoRotate) },
          { kind: "separator" as const },
          { kind: "item" as const, id: "remove", label: "Remove model", icon: "tabler:trash", variant: "destructive" as const, onSelect: () => setModel(null) },
        ])
      }}
      onDragOver={(e) => { if (canEdit) e.preventDefault() }}
      onDrop={(e) => {
        // A FILE drop from the desktop is a plain upload — not a document drag, so
        // it stays native and never touches the platform dnd channel.
        if (!canEdit) return
        e.preventDefault()
        const file = e.dataTransfer.files[0]
        if (file) uploadFile(file)
      }}
      style={{ position: "relative", height: "100%", minHeight: 420, width: "100%", overflow: "hidden" }}>
      {url ? (
        <Viewer url={url} autoRotate={data.autoRotate} />
      ) : data.modelMediaId ? (
        <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", fontSize: 13, opacity: 0.55 }}>Loading model…</div>
      ) : (
        <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
          <div style={{ display: "grid", justifyItems: "center", gap: 12, textAlign: "center", maxWidth: 400, padding: 24 }}>
            <HostIcon icon="box" size={30} />
            {canEdit ? (
              <>
                <button type="button" onClick={pickModel}
                  style={{ padding: "9px 18px", borderRadius: 10, border: "1px solid rgba(127,127,127,0.4)", background: "transparent", color: "inherit", cursor: "pointer", font: "inherit", fontSize: 14, fontWeight: 600 }}>
                  Add a 3D model
                </button>
                <p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, opacity: 0.55 }}>
                  Pick a .glb or .gltf from world media — or drop a file right here.
                  Everyone in this document sees the same model; the camera stays yours.
                </p>
              </>
            ) : (
              <p style={{ margin: 0, fontSize: 13, opacity: 0.55 }}>No model yet.</p>
            )}
          </div>
        </div>
      )}
      <div style={{ position: "absolute", top: 10, right: 12, zIndex: 5 }}><PresenceStrip handle={handle} /></div>
      <div style={{ position: "absolute", left: 0, right: 0, bottom: 14, textAlign: "center", pointerEvents: "none" }}>
        <p style={{ margin: "0 auto", maxWidth: 460, fontSize: 13, opacity: 0.5 }}>
          Welcome to <span style={{ fontWeight: 600, opacity: 1 }}>{NAME}</span> — edit this viewer in{" "}
          <code style={{ fontFamily: "ui-monospace, monospace", fontSize: 12 }}>{FILE}</code>.
        </p>
      </div>
      {canEdit && (
        <input ref={fileRef} type="file" accept=".glb,.gltf" style={{ display: "none" }}
          onChange={(e) => {
            const file = e.target.files && e.target.files[0]
            if (file) uploadFile(file)
            e.target.value = ""
          }} />
      )}
    </div>
  )
}

export default defineTool({
  id: "relic-viewer",
  name: "relic-viewer",
  documentTypes: ["relic-viewer"],
  needs: [],
  surface: "plain",
  render: function RelicViewerView({ document, context }) {
    const coords = useMemo(
      () => ({ worldId: document.worldId, documentId: document.id }),
      [document.worldId, document.id],
    )
    const { data, status, actions, retry, handle } = useDocument(coords, codec)
    return (
      <DocumentGate status={status} onRetry={retry}>
        {data && actions && <Stage data={data} actions={actions} handle={handle} canEdit={context.canEdit} worldId={document.worldId} />}
      </DocumentGate>
    )
  },
})

Twelve lines of codec — a media id and a boolean — and everything else is the viewer. loadThree() is the line worth finding: three.js arrives from the platform's own copy, at the moment a model is actually chosen.

Why does the document store an id instead of the file?Deep dive

Because a document that contains a copy of a file is a document that has already started going stale.

Store the id and there is exactly one .glb in the world's media pool. Replace it and everything pointing at it updates. Check who can see it once, in one place — media permissions belong to the world, not to your tool. And your document stays small, which matters more than it sounds: a CRDT keeps history, and a history of embedded binaries is a document nobody can open.

The same rule holds for document references — Linking documents is the same idea for cards rather than files. It's one of the few genuinely non-negotiable conventions in vvd: always an id, never a copy.

Why isn't three.js in the bundle?Deep dive

Three.js is around a megabyte. If every creation that wanted 3D shipped its own copy, a world with four of them would download it four times.

So the platform ships one, and loadThree() from @vvd/sdk is a lazy dynamic import of that shared copy. Your bundle stays small, the download happens once per session, and it doesn't happen at all until a model is actually being shown — which is why the empty state in the frame above costs nothing.

How it works

Media is a host capability. useHostCapability("media") and useResolvedMedia(media, id) — your tool asks the host to resolve an id and never learns where the bytes live. See Host capabilities.

Shared state and local state, side by side. autoRotate is in the codec because everyone should see the same thing rotating; the orbit angles are useRef because they're yours. Same split as Canvas.

Right-click goes through the platform. useContextMenu() takes plain data — labels, icon ids, handlers — and the host renders it, so the menu matches every other menu in the world. See Host capabilities.

Next steps

  • Hello World (app) — the first kit that owns a whole space instead of one document.
  • Host capabilities — media, menus, search, and what happens when a host doesn't offer one.
  • Blocks — how to put this viewer inside a card.