3D & CAD Visualization
The browser-side 3D stack: the CesiumJS globe, Google Photorealistic 3D Tiles, 3D Gaussian Splat reconstructions, and the Autodesk APS CAD viewer — what each technology is, which module uses it, and how the pieces connect.
Three kinds of “3D”, in plain terms
The product shows three different kinds of three-dimensional content, and it helps to keep them apart, because each comes from a different source and is rendered by different technology:
- The world. A virtual globe of the real Earth — terrain, satellite imagery, photorealistic buildings — that gives inspection data geographic context. You don’t model this yourself; it is streamed on demand from commercial providers (Cesium Ion, Google).
- The site, as captured. A 3D reconstruction of the actual facility, computed from the drone photos of a campaign. KAP uses 3D Gaussian Splats for this — a reconstruction looks like a photograph you can fly through, because it was built from photographs.
- The design. The engineering CAD model of the facility — the geometry as designed, with part structure and metadata. This comes from files like Navisworks exports and is rendered by Autodesk’s viewer, not by the globe.
The first two are layered together in one scene: the reconstruction of the site sits on the globe at its real-world coordinates, so an engineer can zoom from planet scale down to a single flange. The CAD viewer is a separate surface with its own module and its own rendering engine.
One vocabulary note before the details: 3D Tiles is an open format (originally from Cesium) for streaming massive 3D content — the scene is cut into a spatial tree of tiles, and the viewer loads only the tiles the camera can see, at the detail the distance justifies. Google’s photorealistic Earth, Cesium Ion assets, and KAP’s splat reconstructions all reach the browser as 3D Tiles; that shared format is what lets one Cesium scene render all of them.
CesiumJS — the globe engine
CesiumJS is the open-source JavaScript globe-and-map engine that renders every geographic 3D scene in KAP. It is a regular npm dependency of the web app (cesium 1.134.1 in web/package.json).
Three modules embed a Cesium scene:
| Module | What its Cesium scene shows |
|---|---|
modules/photo-map-3d/ |
The full inspection scene: Google Photorealistic 3D Tiles as the base world, splat reconstructions of the site, photo/gas/note markers. |
modules/gallery/ |
Owns the shared Viewer wrapper class and renders dataset assets in 3D context. |
modules/gas-heatmap/ |
A simpler globe: Ion world imagery with gas readings draped over it as a heatmap layer. |
Static assets: copy-cesium.mjs
Cesium ships a folder of static runtime assets (web workers, WASM, widget CSS, icons) that must be served by the app, version-matched to the installed package. The postinstall script web/scripts/copy-cesium.mjs copies node_modules/cesium/Build/Cesium into web/public/cesium/ on every npm install — local dev, CI, and Docker builds alike. The folder is git-ignored: it is a build artifact of the dependency, not source. At runtime, code sets window.CESIUM_BASE_URL = '/cesium/' before creating a viewer so Cesium finds those assets.
The Cesium Ion token
Cesium Ion is Cesium’s commercial cloud: it hosts tiled assets (including KAP’s converted splat reconstructions, world terrain, and Bing imagery) and streams them straight to the browser — tiles do not pass through the Next.js server, which is why the architecture diagram draws that edge directly from the client. Access requires a token, supplied as NEXT_PUBLIC_CESIUM_ACCESS_TOKEN.
The wiring lives in web/modules/photo-map-3d/core/hooks/use-cesium-viewer.ts: ensureCesiumIonToken() resolves the token and assigns Cesium.Ion.defaultAccessToken before the viewer is created. Because NEXT_PUBLIC_* variables are embedded at build time (webpack replaces them; a missing variable can literally become the string "undefined"), the helper checks several sources and sanitizes the value, and the hook exposes a checkCesiumIonToken() diagnostic on window for debugging token problems in production. The gas-heatmap module reads the same variable independently in gas-heatmap-canvas-tab.tsx.
The Viewer wrapper
web/modules/gallery/core/utils/viewer.ts is the shared wrapper around Cesium.Viewer that photo-map-3d and gallery use (gas-heatmap creates its own plain viewer). It exists to encode KAP’s defaults in one place:
- Chrome off — every stock Cesium widget (base-layer picker, geocoder, timeline, info box, …) is disabled; KAP renders its own controls.
- Performance settings — render-on-demand (
requestRenderMode), FXAA instead of MSAA, fog and shadows off, logarithmic depth buffer, capped globe tile detail. - Base maps — it loads Google Photorealistic 3D Tiles as the default base world (next section) and can switch to Bing aerial imagery (
Cesium.IonImageryProvider.fromAssetId(3)) viasetBaseMap(). - Layer toggles —
toggleGoogle3DTiles()andtoggle3DGS()show/hide the base world and the site reconstructions independently (toggle3DGStreats every tileset in the scene except the Google one as site content).
The useCesiumViewer() hook mounts this wrapper into React: it creates the viewer in an effect, waits for the scene to be ready, sets zoom limits (69 m–50 km) and camera controls, and attaches a readable render-error listener (Cesium’s default banner logs errors as [object Object], which once made a /cesium asset outage undiagnosable from production logs).
Google Photorealistic 3D Tiles — the base world
Google Photorealistic 3D Tiles is Google Maps’ photorealistic model of the Earth — the textured-mesh cities and terrain from Google Earth — served in the open 3D Tiles format, which means Cesium can render it natively. In KAP it is the default base world of the photo-map-3d scene: the reason a site reconstruction appears in its real surroundings rather than floating over a bare ellipsoid.
The loading lives in the Viewer wrapper (addGoogle3DTiles() in modules/gallery/core/utils/viewer.ts): it builds a Cesium3DTileset from https://tile.googleapis.com/v1/3dtiles/root.json?key=… using the NEXT_PUBLIC_GOOGLE_MAPS_API_KEY environment variable, adds it to the scene, and — because this is a planet-sized dataset — applies aggressive tuning: coarser screen-space error, level-of-detail skipping, a 256 MB tile cache, and foveated rendering that prioritizes center-screen tiles. If the key is absent or loading fails, the scene continues without a base world (non-fatal by design). showCreditsOnScreen: true keeps Google’s required attribution visible.
3D Gaussian Splats — the site reconstruction
3D Gaussian Splatting (3DGS) is a reconstruction technique: from a set of overlapping photographs, an optimization process produces millions of small translucent colored blobs (“splats”) that together re-render the scene photorealistically from any angle. For inspection data it beats classic textured meshes at exactly the things that matter — thin pipes, railings, gauges — because it never has to force the scene into a surface mesh. The reconstruction is computed offline from campaign drone photos; the web app only displays it.
Storage and configuration
Reconstructions reach the browser as Cesium 3D Tiles hosted on Cesium Ion — the raw .splat file format is explicitly not loaded (the loader in use-supabase-3d-gaussian-splats.ts skips .splat URLs; that path is kept only for backward compatibility). What the database stores is configuration, not geometry: the datasets table has a gaussian_splats JSON column (plus 3d_tilesets, default_camera, and the shared offsets blob, which also carries the marker-alignment nudge) describing which assets belong to a dataset, their Ion asset IDs or URLs, positions, and offsets.
Two API routes serve this configuration (both authenticated, RLS-enforced — see the API inventory):
GET/PUT /api/datasets/{slug}/3d-config— reads/writes a dataset’s 3D scene setup (assets, camera). Backed byDataset3DService(modules/photo-map-3d/services/dataset-3d-service.ts).GET /api/gallery/assets/{assetId}/tileset— resolves where an asset’s tiles actually live. The resolution rule is inmodules/photo-map-3d/services/fetch-tileset-manifest.ts: an all-numericassetIdis a Cesium Ion asset (tileset URLhttps://assets.cesium.com/{assetId}/tileset.json); anything else is looked up in the datasets’gaussian_splatsmetadata.
Loading into the scene
Inside photo-map-3d, three hooks divide the work (modules/photo-map-3d/core/hooks/):
use-3d-asset-loader.tsdoes the heavy lifting: it keeps a central registry of every tileset loaded into the scene (preventing duplicates, with a React StrictMode double-mount guard), loads each configured asset as aCesium3DTileset, applies per-asset position/offset, and reports load state (idle → loading → interactive/error) for the UI.use-3dgs-state.tsholds the visibility state — the “show Google 3D Tiles” and “show 3DGS” toggles and loading flags the scene controls bind to.use-supabase-3d-gaussian-splats.tsfetches the dataset’s 3D config throughDataset3DServiceand hands the asset list to the loader.
CAD viewing — Autodesk APS
The design-side 3D lives in web/modules/cad-viewer/, and it does not use Cesium at all. CAD files are rendered by the Autodesk Platform Services (APS, formerly Forge) Viewer — Autodesk’s own browser engine — because CAD formats are proprietary and Autodesk’s cloud does the conversion.
The pipeline, end to end:
- Token — the browser gets a short-lived APS access token from
GET /api/aps/auth(the server holds the Autodesk app credentials). - Upload —
POST /api/aps/uploadsends the CAD file (the upload panel accepts.nwd, Navisworks) to an APS Object Storage bucket and returns its URN. - Translate —
POST /api/aps/translatestarts Autodesk’s Model Derivative job converting that URN to SVF2, the viewer’s streaming format (2D and 3D views). - Poll —
GET /api/aps/status/{urn}reports translation progress;ui/components/translation-status.tsxpolls it. - View —
use-aps-viewer.tsinjects Autodesk’s viewer bundle (viewer3D.min.js, version7.*, loaded fromdeveloper.api.autodesk.comat runtime — it is not an npm dependency), initializesAutodesk.Viewing.GuiViewer3D, and loads the translated document.
The browser side of steps 1–4 is wrapped in modules/cad-viewer/services/aps-client.ts (with retry/error classification; uploads deliberately never auto-retry). A demo flow (GET /api/aps/demo-file, POST /api/aps/upload-demo) ships a sample model so the viewer works without hunting for a file.
⚠ The APS routes are currently unauthenticated — flagged in the Backend API chapter and the API inventory, whose ⚠ flags double as the security worklist.
Note the division of labor with the CAD, BIM & P&ID Handbook: that handbook covers the open data standards (IFC, DEXPI, tagging) that make CAD data interoperable; this chapter covers the browser viewer pipeline that puts a model on screen.
Where things live — the map
| Concern | Code | Backing endpoints |
|---|---|---|
| Cesium static assets | web/scripts/copy-cesium.mjs → public/cesium/ |
— |
| Viewer wrapper + Google 3D Tiles + base maps | modules/gallery/core/utils/viewer.ts |
— (streams from tile.googleapis.com) |
| Viewer mount, Ion token, camera | modules/photo-map-3d/core/hooks/use-cesium-viewer.ts |
— (streams from assets.cesium.com) |
| Asset loading & registry | modules/photo-map-3d/core/hooks/use-3d-asset-loader.ts |
GET /api/gallery/assets/{assetId}/tileset |
| 3D scene config | modules/photo-map-3d/services/dataset-3d-service.ts, fetch-tileset-manifest.ts |
GET/PUT /api/datasets/{slug}/3d-config |
| Splat fetch + visibility state | modules/photo-map-3d/services/use-supabase-3d-gaussian-splats.ts, core/hooks/use-3dgs-state.ts |
(same as above) |
| Gas heatmap globe | modules/gas-heatmap/ui/gas-heatmap-canvas-tab.tsx |
GET /api/datasets/{slug}/gas-readings |
| In-scene gas markers | modules/photo-map-3d/overlays/hooks/use-gas-readings-management.ts |
— (prop-driven, see below) |
| CAD viewer | modules/cad-viewer/ (core/hooks/, services/aps-client.ts, ui/components/) |
/api/aps/* (auth, upload, translate, status, demo) |
| Right-click menu + nearest-asset ranking | modules/photo-map-3d/core/hooks/use-map-context-menu.ts, ui/components/map-context-menu.tsx |
— (prop-driven, see below) |
| Asset inspector panel | modules/photo-map-3d/ui/components/inspector-sections/equipment-inspector-section.tsx |
GET /api/equipment/{id} via hooks/use-equipment-detail.ts |
Selecting versus acting: click and right-click do different jobs
Left-click selects — the asset opens in the right-hand inspector alongside photos, notes, gas readings and measurements, and the map stays where it is. Right-click acts — it opens a menu of things to do, deliberately carrying no description of the asset, because a menu that repeated the inspector would make the two gestures redundant.
The menu takes two shapes, keyed off what was under the cursor:
| Target | Entries |
|---|---|
| An asset | Fly to asset · Show details · Copy tag · (Open asset page) |
| Empty space | Add note here · Nearby assets |
Copy tag disables itself when the asset carries no tag. Add note here passes the picked ground position, so the note lands where the user clicked rather than at the camera target.
The way out to the asset page is prop-driven, like gas readings
Both exits to the full asset page — the inspector’s Open asset page button and the context menu’s matching entry — hang off a single prop, onAssetSelect. Omit it and neither appears, with no error: the panel still shows the asset’s image and anomaly counts, offering no way to reach them.
This is deliberate, because the destination is workspace-specific: Data Explorer’s register browses the evidence images, Integrity’s opens the finding loop. A page supplies a handler routing to ${workspace.routePrefix}/assets, and a surface that should not navigate away simply omits it.
Read the workspace with useOptionalWorkspace(), not useWorkspace(). Routes under /datasets/… claim no workspace of their own and the components render standalone in tests, where the throwing hook turns an optional affordance into a crash.
Focusing one asset: focusAssetId and the ?asset= deep link
The map can be asked to go to one asset. The request is a prop, focusAssetId (an equipment id, declared in modules/gallery/shared/gallery-types.ts and served by modules/photo-map-3d/core/hooks/use-asset-markers.ts): the camera flies to that asset’s marker (70 m out, pitched 35° down), the pin grows and turns amber, its label is forced on, and both draw through the scan so you never land behind the thing you asked for. It is a one-shot request, not a selection: when the flight lands the hook calls onAssetFocused, and the parent must clear the prop — otherwise the same id cannot be flown to twice. Clearing it leaves the highlight in place. Inside the map, the right-click menu’s Fly to asset and the Go to asset search both set the same prop.
The two pages that hold an asset in common talk to each other through a URL parameter, ?asset=<equipmentId>, in both directions:
| Direction | Sender | Receiver |
|---|---|---|
| 3D → Assets | Click a marker in the Evidence Explorer map (onAssetSelect) |
/w/integrity/assets?asset= — the register selects that asset instead of its richest-evidence default, and the choice survives a reload |
| Assets → 3D | View in 3D on the Assets page (viewIn3d in components/workspaces/assets-register-client.tsx) |
/w/integrity/evidence-explorer?org=&dataset=&asset= — the explorer waits for the equipment markers to load, sets focusAssetId, then strips asset from the URL |
The strip matters. The explorer’s param is consumed once and removed with router.replace, so a later manual pick, or the browser’s Back button, is not yanked back to the deep-linked asset. The Assets page keeps its param, because there it is the selection. The page-level campaign normalisation redirect (campaignScopeHref) carries asset through untouched; CampaignScopeSearch names it so that stays deliberate.
View in 3D only carries an asset id the scene can honour: the button omits asset when the equipment has no geometry (the positions API filters those out), and lands on the explorer scoped to the campaign with no fly-to.
The receiving half is the part that has gone missing before. It lived on the Campaigns module while the explorer was a ?view=explorer mode there, and was dropped when Evidence Explorer became its own route (2026-08-28); the sender kept emitting the param for two weeks while nothing read it. The component test tests/components/evidence-explorer-deeplink.test.tsx now pins the seam, alongside assets-register-view-in-3d.test.tsx (sender) and assets-register-deeplink.test.tsx (the register’s receiver).
The marker the camera flies to sits at the asset’s registered position — equipment_positions, i.e. the pds_assets geometry — so “go to” is only as right as that register entry. Where the register is anchored to the wrong CAD object the pin is off the vessel; that is a data problem, not a rendering one (docs/data/photo-cad-gps-alignment.md, problem 3).
“Nearby assets” has no radius — it is the nearest five
nearestMarkers() ranks every equipment marker by distance and returns .slice(0, 5). There is no distance cutoff anywhere in the path.
Two consequences worth knowing before trusting the list:
- The five nearest assets always appear, however far away. Right-click empty space at the edge of a site and assets kilometres out are listed under “Nearby assets” — the component test asserts an entry at
2.4 km. - “None within range.” only renders when the dataset has no equipment markers at all, not when nothing is genuinely close. The wording implies a range that does not exist.
Distances use an equirectangular approximation (R = 6_371_000) — exact enough to rank assets across a facility and far cheaper than a geodesic for a five-entry menu, but not a survey-grade measurement.
Gas readings are prop-driven, and that fails silently
photo-map-3d never fetches gas readings. It renders whatever the caller passes as gasReadings / datasetHasGasReadings, and the Layers panel keys the existence of its “Gas Readings” row off datasetHasGasReadings and the row’s enabled state off the reading count. A page that omits both props therefore doesn’t show an empty gas layer — the row vanishes from Data Layers with no error in the console or the network tab.
Any page mounting the map must supply both. The established path is useDatasetGasReadings({ slug, datasetHasGasReadings, activeTab }) from modules/gas-heatmap/hooks/, fed by the dataset’s has_gas_readings column — so workspace-scoped surfaces must carry that column through WorkspaceDataset → workspaceDatasetToListItem → DatasetListItem.
Note datasets.has_gas_readings can be stale (true with zero rows in v_dataset_gas_readings). That is the intended degradation: the row renders disabled with “No gas readings available” rather than disappearing.
Environment variables: NEXT_PUBLIC_CESIUM_ACCESS_TOKEN (Cesium Ion), NEXT_PUBLIC_GOOGLE_MAPS_API_KEY (Google 3D Tiles), plus the server-side Autodesk APS credentials read by the /api/aps/* handlers.
Last Updated: 2026-08-06