Digital Twin Viewer Reference
How the domain viewer builds its 3D scene: the world frame, both camera modes with their exact mouse and keyboard mappings, the first-person rules, and what each layer loads and draws. Every value here was read from the viewer's source, not from its README — use it when you (or your agent) build a viewer of your own or reason about what's on screen.
Read from domain-viewer at feature/organization-logo @ 8f48c94 plus uncommitted changes,
checked 13 Oct 2026 · three 0.182.0 · React Three Fiber 9.5.0 · drei 10.7.7 · Spark 2.0.0.
Anything marked in development describes code that isn't committed yet.
Try it
A live example scene built with the same frame, camera, controls and layer styling as the viewer, drawn from a synthetic shelving maze around a central plaza instead of a real domain. Click the scene to focus it, then use the controls below — press F for first person.
The example draws the Portals, Nav Mesh, Occlusion and Point Cloud layers as the viewer does, plus a Devices layer specific to this example: two robots, a phone and a pair of smart glasses at scripted poses, updated at 10 Hz and drawn as received. Each device shows its camera frustum and a floor ring in its own colour. There's no splat (Rendering) or local model (Model). Auto-rotate runs at the viewer's 60 Hz speed but is time-based here, so it doesn't speed up on high-refresh displays (see Gaps).
Domains
A domain is the digital representation of a physical space — the thing the viewer loads when you type a domain ID. It's hosted on a domain server and holds the space's portals plus the data built from scans of it: the point cloud, nav mesh, occlusion mesh and splats drawn in Layers. Portals are printed QR markers that act as access points to a domain: a device that sees one can work out where it is in the domain's coordinate frame.
You create and manage domains with the Domain Management Tool (DMT). Follow the DMT user guide → for the full walkthrough. In short:
- Install the DMT — iOS 16+ (Android is returning) — and create an account in the app or the posemesh console.
- Get portals. Order a portal kit for a durable install, or generate portals in the console or the app and print them yourself at 100% scale, so the printed size matches the size the domain expects.
- Place them at likely entry points to the space. Avoid reflective surfaces and sharp shadows, which interfere with detection.
- Create the domain in the DMT and give it a name — the name is public to apps built on the Auki Network.
- Pick a domain server to host it: your own, a public one provided by Auki, or one run by the community.
- Add the portals to the domain, with one of two flows:
- Manual flow, for smaller spaces: place portals directly. The first one goes on the floor and sets the domain's coordinate system.
- Capture flow, for larger spaces (iOS only): start on a floor portal to set the ground plane, then record the space while keeping portals in view. For large spaces, record several captures; each must include at least two portals from earlier captures. Upload the captures, send them to a refinement server, and apply the result to the domain.
Once a domain has portals, and refinements if you ran the capture flow, open it in the domain viewer by its domain ID: the Loading section below describes what it fetches, and World describes the frame it draws the domain in.
At a glance
| Units & frame | metres, +Y up — right-handed, three.js convention |
| Camera | perspective, 50° FOV — starts at (15, 15, 15), looking at the origin |
| Modes | Map · First person — press F to switch |
| Eye height | 1.6 m, fixed — first person only |
| Walk / sprint | 3 m/s · 6 m/s — hold Shift to sprint |
| Auto-rotate | after 5 s idle — map mode, once a domain is loaded |
| Floor grid | 0.2 m cells · 1 m sections — infinite, fades out by 500 m |
| Layers | Portals, Nav Mesh, Occlusion, Point Cloud, Rendering, Model — all on by default |
World
components/3d/Scene.tsx · FloorGrid.tsx · OriginLines.tsx
Domain coordinates map one to one onto scene coordinates: one unit is one metre and +Y points up. The floor is the plane y = 0. The plan below is drawn to scale, looking down the Y axis.
| Origin axes | Three 1 m lines from the origin, 2 px wide: X #dc2626, Y #84cc16, Z #2563eb. |
| Floor grid | drei Grid, infinite, on y = 0. Cells every 0.2 m, sections every 1 m, both #404040 in the dark colour scheme and #c0c0c0 in light. Fades to nothing by 500 m. Drawn first (renderOrder −100) and double-sided, so it stays visible from below the floor. |
| Lighting | Ambient light at 0.5 plus a directional light at 0.5 from (10, 100, 10). The point cloud, both meshes and the splats use unlit materials, so lighting only shapes the GLB models: the portal QR markers and the local model. |
| Canvas | Transparent, over the viewer's dark neutral background. preserveDrawingBuffer is on so Share → Screenshot can read the pixels back. |
| Resolution | While a splat is in the scene, Spark's renderer caps the device pixel ratio at 1.0 and restores it when the splat unmounts. On a 2× display the whole scene renders at half resolution whenever a domain has a splat. |
Camera
components/Viewer3D.tsx · PersistedMapControls.tsx · 3d/controllers/CameraController.tsx
| Projection | Perspective, 50° vertical field of view. Near 0.1 m and far 1000 m, the React Three Fiber defaults, so anything beyond 1 km is clipped. |
| Start | (15, 15, 15), looking at the origin, which is also the starting orbit target. |
| Map controls | three.js MapControls with damping 0.05, so motion eases out after you let go. Elevation is limited to 0–90°: the camera can look straight down but can't orbit below the floor. Zoom has no minimum or maximum distance. |
| Pose memory | About every half second (every 30 frames) map mode saves position, rotation, zoom and target into an in-memory store. When map controls mount again, for example coming back from first person, they restore the saved target. Nothing is written to browser storage, so a reload starts from the default view. |
| Auto-rotate | After 5 s without a pointer press, wheel or key press, the camera circles the current target at its current height. The radius is the current horizontal distance, never less than 5 m; from the default view that's 21.2 m. It's off in first person and waits until a domain has loaded. |
| Rotation speed | The camera advances a fixed 0.0015 rad per rendered frame, so the speed follows the display's refresh rate: about 70 s per turn at 60 Hz and 35 s at 120 Hz. |
| Turning it off | Rendering panel → Auto-rotate. Off stops the drift immediately. Recommended when watching live data, where a moving camera makes it hard to tell scene motion from viewpoint motion. In development. |
Controls
There are two camera modes. Map is the default; F switches between them.
- Map
- First person
| Input | What it does |
|---|---|
| Left drag | Pan. The view slides across the floor plane, not the screen plane, so dragging up moves you forward over the ground. |
| Right drag | Orbit around the target. Stops at the horizon and at straight down. |
| Wheel | Zoom (dolly) toward the orbit target. It doesn't zoom toward the cursor. |
| Middle drag | Zoom, same as the wheel. |
| Ctrl / ⌘ / Shift + left drag | Orbit instead of pan. Handy on a trackpad without a right button. |
| Trackpad | Two-finger scroll zooms. A two-finger click-drag acts as a right drag and orbits. |
| Touch | One finger pans. Two fingers zoom and orbit together. |
| F | Switch to first person. Ignored when the viewer runs inside an iframe. |
| Esc | Close the open panel. In the domain ID field, clear the field. |
| Arrow keys | Nothing in map mode. Keyboard panning isn't wired up. |
| Input | What it does |
|---|---|
| Click the view | Locks the pointer and starts looking around. Until you click, an overlay with a crosshair covers the view. |
| Move the mouse | Look around: turn left and right, tilt up and down. |
| W or ↑ | Walk forward, toward where you're looking, kept on the floor. |
| S or ↓ | Walk back. |
| A or ← | Step left. |
| D or → | Step right. |
| Hold Shift | Sprint: 6 m/s instead of 3 m/s. |
| F | Back to map mode, whether or not the pointer is locked. |
| Esc | The first press releases the pointer (the browser does this) and the overlay comes back. A second press, with the pointer free, returns to map mode. |
| Right button, wheel | Not used. |
First person
components/FPSControls.tsx · drei PointerLockControls
| Where you start | Entering first person always moves the camera to (0, 1.6, 3): 3 m in front of the origin at standing eye height. It keeps the map camera's orientation, so you start facing wherever the map view pointed. |
| Eye height | Y is reset to 1.6 m every frame. There's no jumping, crouching, flying, gravity or collision. You walk through walls, meshes and splats. |
| Looking up or down | Forward and back follow the view direction with the vertical part thrown away, so ground speed is the walk speed × cos(pitch): full speed looking level, about 71% at 45°, nothing at all looking straight down. Stepping left and right isn't affected. |
| Speed | 3 m/s walking, 6 m/s with Shift, scaled by frame time, so it's the same at any refresh rate. Diagonal movement is normalised and isn't faster. |
| While in it | Auto-rotate is off. Mouse look comes from drei's PointerLockControls at its default sensitivity. |
| Leaving | Map controls take over from where you stand. The orbit target is set 10 m ahead along your view and the camera's up vector is reset, so the view doesn't tilt or jump back to the old map pose. |
| The overlay | The click-to-enter overlay lists W A S D, Mouse and Shift. The arrow keys work too but aren't mentioned there. |
Layers
Layers panel · store/visualizationStore.ts · components/3d/renderers/
Each layer reads its own visibility switch, so toggling one never redraws another. The coloured dot beside each row in the Layers panel only identifies the row — it isn't the colour the layer renders in, so the table lists both.
| Layer | Default | Source | Drawn as |
|---|---|---|---|
| Portals | On | GET …/lighthouses | One copy of QR.glb per portal, placed at the portal's pose and scaled to its printed size (reported_size in cm ÷ 100). |
| Nav Mesh | On | OBJ named navmesh_v1 | Unlit #2b4d2b at 90% opacity, double-sided, pulled slightly forward in depth so it sits on top of floor surfaces it shares. |
| Occlusion | On | OBJ named occlusionmesh_v1 | Unlit #808080 faces at 80% opacity plus #303030 wireframe edges. |
| Point Cloud | On | PLY refined_pointcloud_{refinement} | Points coloured from the PLY, 0.09 m across in world space and smaller with distance. Placed through the canonical refinement's alignment matrix. |
| Rendering | On | Splat tiles or one splat file for the canonical refinement | Gaussian splats through Spark 2.0. Listed only when the domain has a splat. See Splats. |
| Model | On · in development | Local file /models/camera_test.glb | One GLB at a fixed transform, movable with a gizmo. See Model. |
Loading
app/actions.ts · services/domainService.ts · hooks/useRefinementSplat.ts
What happens between typing a domain ID and seeing the scene. These steps run in order.
- A server action authenticates the app with the API server, then asks DDS for a domain
token (
POST /api/v1/domains/{id}/auth). DDS answers with the URL of the domain server that hosts the domain. The app secret never reaches the browser. - The browser fetches the portals (
…/lighthouses) and the domain's data manifest (…/data) from that domain server. - It downloads
domain_metadata, which names the canonical refinement and carries its alignment matrix. The point cloud and splats are looked up by that refinement ID. - Each layer's bytes come from
…/data/{fileId}?raw=trueand are parsed in the browser: PLY for the point cloud, OBJ for the meshes. - Splat files download last, one tile after another. The "Acquiring domain" overlay stays up until they have all arrived, then fades.
See the API Reference for the DDS and domain server endpoints.
Splats
components/3d/RefinementSplat.tsx · spark-r3f/ · hooks/useRefinementSplat.ts
| Tiled splats | Data types splat_partition or splat_partition_sog, named splat_partition_{full|coarse|fine}_{size}_{x}_{z}_{refinement}. If any coarse or fine tiles exist the viewer loads coarse and fine and ignores full; otherwise it loads full. |
| Single file | Used when there are no tiles. Data type is one of splat_data, splat_data_sog, refined_splat, splat or gaussian_splat, named refined_splat_, splat_ or gaussian_splat_ followed by the refinement ID. |
| Format | Types ending in _sog load as SOG (Spark's pcsogszip). Everything else loads as .splat. |
| Placement | Each tile sits at ((x + 0.5) × size, 0, (z − 0.5) × size), and the splat is rotated 180° about X, which flips Y and Z. No alignment matrix is applied. |
| Reveal | The "Magic" shader effect plays over 3 s, once per load. Hiding and showing the layer doesn't replay it. |
| Renderer | A Spark SparkRenderer is added to the scene explicitly, with level of detail on, maxStdDev √5 and minPixelRadius 2. |
| Detail slider | Rendering panel → Detail, 0.25× to 4× in steps of 0.05, default 1×. It sets Spark's splat budget (lodSplatScale, which only matters above the platform budget, about 2.5 M splats on desktop) and the inverse minimum pixel size (lodRenderScale = 1 ÷ detail), so it has a visible effect on small scenes too. Changes apply live. |
Model
components/3d/renderers/ModelRenderer.tsx · modelConfig.ts · ModelSettings.tsx · In development
| File | public/models/camera_test.glb, served from the app, not the domain. If the file is missing or broken, the layer logs a warning and the rest of the scene keeps rendering. |
| Start transform | Position (0.3962, 1.3418, 2.8246) m, rotation (0, 0, 0), scale 1. |
| Model panel | Gizmo on or off, with Move, Rotate and Scale modes. Readouts show position in metres, rotation in degrees and scale. The panel hides itself when there's no model loaded. |
| Keeping a placement | Copy puts the current transform on the clipboard as the TypeScript constants in modelConfig.ts. Paste them there to keep the placement across reloads. Reset returns the model to the configured transform. |
| While dragging | Map controls pause while a gizmo handle is held, so dragging the model doesn't also move the camera. |
Embedding
app/[id]/preview/page.tsx · components/DomainShare.tsx
| Preview route | /{domainId}/preview shows the same scene with no control bar. Share → Copy Embed produces a 1200 × 630 iframe pointing at it. |
| Inside an iframe | F is ignored, so embeds stay in map mode. Mouse and touch controls still work. |
| Opened directly | The preview route outside an iframe still hides the control bar, but F works. |
Gaps
Where the docs and the code disagree, and what isn't verified.
The README says left drag rotates and right drag pans. The viewer uses three.js
MapControls, where left drag pans and right drag orbits.
"Persisted camera state" means an in-memory store. It survives switching modes, not reloading the page.
The Nav Mesh dot is amber but the mesh is dark green; Occlusion shows red but draws grey. Someone matching the panel to the scene by colour will be misled.
The overlay lists W A S D, but the arrow keys work identically.
The step is per frame, not per second, so a 120 Hz display turns twice as fast as a 60 Hz one.
The point cloud goes through the canonical refinement's alignment matrix. The nav mesh, occlusion mesh and splats are drawn without it. That's correct if those assets are already in the domain frame, but it hasn't been checked on a domain whose matrix isn't the identity.
References
- three.js MapControls
- three.js OrbitControls — parent of MapControls: damping, polar limits, modifier keys.
- three.js PointerLockControls
- MDN · Pointer Lock API — why the first Esc only releases the pointer.
- React Three Fiber — Canvas and camera defaults.
- drei — Grid, Line, PointerLockControls, TransformControls.
- Spark 2.0 — level-of-detail settings behind the Detail slider.
- Viewer source:
components/Viewer3D.tsx,FPSControls.tsx,PersistedMapControls.tsx,components/3d/,store/,hooks/useRefinementSplat.ts,services/domainService.ts.