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
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
→ 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.
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.
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.