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)) via setBaseMap().
  • Layer toggles — toggleGoogle3DTiles() and toggle3DGS() show/hide the base world and the site reconstructions independently (toggle3DGS treats 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 by Dataset3DService (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 in modules/photo-map-3d/services/fetch-tileset-manifest.ts: an all-numeric assetId is a Cesium Ion asset (tileset URL https://assets.cesium.com/{assetId}/tileset.json); anything else is looked up in the datasets’ gaussian_splats metadata.

Loading into the scene

Inside photo-map-3d, three hooks divide the work (modules/photo-map-3d/core/hooks/):

  • use-3d-asset-loader.ts does 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 a Cesium3DTileset, applies per-asset position/offset, and reports load state (idle → loading → interactive/error) for the UI.
  • use-3dgs-state.ts holds 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.ts fetches the dataset’s 3D config through Dataset3DService and 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:

  1. Token — the browser gets a short-lived APS access token from GET /api/aps/auth (the server holds the Autodesk app credentials).
  2. Upload — POST /api/aps/upload sends the CAD file (the upload panel accepts .nwd, Navisworks) to an APS Object Storage bucket and returns its URN.
  3. Translate — POST /api/aps/translate starts Autodesk’s Model Derivative job converting that URN to SVF2, the viewer’s streaming format (2D and 3D views).
  4. Poll — GET /api/aps/status/{urn} reports translation progress; ui/components/translation-status.tsx polls it.
  5. View — use-aps-viewer.ts injects Autodesk’s viewer bundle (viewer3D.min.js, version 7.*, loaded from developer.api.autodesk.com at runtime — it is not an npm dependency), initializes Autodesk.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.

“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