type: reference title: "Scene Workspace Interop" description: "Renderer-neutral coordination for apps that combine a 3D scene, 2D map, table, detail, timeline and realtime status: support status, workspace state, shared MapLibre/Cesium state, scene primitives, diagnostics and the Cesium mount lifecycle." resource: "honua://capability/scene.catalog"
Scene Workspace Interop
@honua/app-platform/scene-workspace is a renderer-neutral coordination layer for
apps that combine a 3D scene, 2D map, table, detail panel, timeline, evidence
tray, and realtime status. It does not import Cesium, MapLibre, or a UI
framework. Renderers translate their native events into typed workspace intents
and subscribe to narrow slices or selectors.
The former @honua/sdk-js/scene-workspace subpath remains a deprecated forwarder
through 0.2.x, with removal scheduled for npm 0.3.0. New applications should
install @honua/app-platform plus only the optional renderer peers they use.
Support status: beta
Beta.
The renderer-neutral workspace state, the scene primitive contract, and the
Cesium primitive adapter keep their shape through @honua/app-platform 0.1.x:
no export is renamed or removed, and primitive kinds, diagnostic codes, and
renderer capability flags grow additively only. Breaking one of those is a
called-out change in a minor release, never a silent edit.
What may still change inside beta:
- Additive union members — a new primitive kind or diagnostic code can require a
new arm in an exhaustive
switch. - The
cesiumoptional-peer floor may rise within 0.1.x. - Server-authored 3D style types (
Honua3DStyleSpecand friends) track the Honua Server styling contract and may gain fields.
Still experimental, and deliberately not promoted by proximity:
| Surface | Why it is held back |
|---|---|
Honua Server scene discovery (listScenes, getScene, sceneToRuntimePrimitives, …) |
Server-attached; outside the open-endpoint evidence behind beta. |
SceneView and the elevation/analysis widgets |
Execute against Honua Server analysis endpoints. |
mountSourceToCesium / projectSourceToCesium / mountCesiumScene |
Two of the three named blockers are cleared — refresh() now diffs instead of rebuilding, and mountCesiumScene owns both mounts behind one dispose(). Held back for the remaining one: there is no symbology surface, and adding it means new required shapes — see Support status. |
Promotion adds no required dependency: cesium stays an optional peer that the
adapter imports lazily, and no core or 2D bundle ceiling moved.
Sample Pattern
import {
createMapLibreSceneAdapter,
createSceneWorkspace,
diagnoseScenePrimitives,
sceneWorkspaceIntentFromAdapterEvent,
selectSceneEvidenceForFeature,
selectScenePrimitivesByKind,
selectSceneVisibleLayers,
} from "@honua/app-platform/scene-workspace";
import { sourceFeatureSelectionTarget } from "@honua/sdk-js/exploration";
const workspace = createSceneWorkspace({
sceneId: "incident-command",
title: "Incident Command Scene",
layers: {
buildings: { id: "buildings", sourceId: "buildings", title: "Buildings", visible: true, kind: "scene" },
incidents: { id: "incidents", sourceId: "incidents", title: "Incidents", visible: true, kind: "feature" },
},
primitives: {
terrain: {
kind: "elevation-source",
id: "terrain",
sourceId: "terrain-dem",
protocol: "terrain-rgb",
tiles: ["/terrain/{z}/{y}/{x}.png"],
encoding: "mapbox",
tileSize: 512,
exaggeration: 1.25,
cache: { status: "ready", scope: "tiles", ttlMs: 86400000 },
},
orthophoto: {
kind: "imagery-layer",
id: "orthophoto",
sourceId: "orthophoto",
protocol: "url-template",
url: "/imagery/{z}/{x}/{y}.jpg",
opacity: 0.85,
attribution: "County orthophotography",
cache: { status: "ready", scope: "tiles", ttlMs: 86400000 },
},
buildings: {
kind: "extrusion",
id: "buildings",
sourceId: "buildings",
height: ["get", "height_m"],
base: 0,
},
},
});
const unsubscribeScene = workspace.subscribe("camera", ({ state }) => {
cesiumAdapter.flyTo(state.camera);
});
const unsubscribeLayers = workspace.subscribe("layers", ({ state }) => {
mapAdapter.setVisibleLayers(selectSceneVisibleLayers(state));
});
const unsubscribeSelection = workspace.subscribe("selection", ({ state }) => {
tableAdapter.highlight(state.selection);
detailAdapter.renderEvidence(selectSceneEvidenceForFeature(state, "INC-7"));
});
cesiumAdapter.onCameraChange((camera) => {
workspace.dispatch(sceneWorkspaceIntentFromAdapterEvent({ type: "camera-change", camera }, "scene"));
});
mapAdapter.onFeatureClick((sourceId, id) => {
workspace.dispatch({
kind: "set-selection",
source: "map",
selection: [sourceFeatureSelectionTarget(sourceId, id)],
});
});
realtimeAdapter.onStatus((realtime) => {
workspace.dispatch({ kind: "set-realtime", source: "realtime", realtime });
});
The important property is that no adapter calls another adapter directly. The scene publishes camera or selection state, the map publishes selected features, the table and detail panel observe the same source-qualified selection, and the timeline/realtime layer shares status through the same state model.
Shared MapLibre and Cesium state
createSceneStateSynchronizer() owns a bounded, renderer-neutral lifecycle for
applications that show MapLibre and Cesium together or let a user switch
between them. Ports publish versioned envelopes for camera, selection,
filters, time, detail, credential-free attribution, and realtime.
Every accepted envelope carries a monotonic revision, canonical timestamps,
source/schema/plan identity, renderer origin, and an explicit
exact/equivalent/unsupported mapping result.
The synchronizer is a transport and never touches a renderer. The two bindings
that do are shipped: createMapLibreStateSyncPort() and
createCesiumStateSyncPort().
import {
createCesiumStateSyncPort,
createMapLibreStateSyncPort,
createSceneStateSynchronizer,
} from "@honua/app-platform/scene-workspace";
const identity = { sourceId: "live-incidents", planId: "incident-command" };
const mapPort = createMapLibreStateSyncPort(map, {
id: "map-2d",
identity,
// Without a temporal field the port declares `time` outbound-unsupported
// rather than accepting shared time it cannot apply.
timeField: "observed_at",
});
const globePort = createCesiumStateSyncPort(viewer, {
id: "globe-3d",
identity,
entityIdForTarget: (target) => `incident-${target.id}`,
targetForEntityId: (entityId) => ({ sourceId: "live-incidents", id: Number(entityId.slice(9)) }),
});
const sharedState = createSceneStateSynchronizer({
applicationId: "incident-command",
ports: [mapPort, globePort],
});
Both renderers stay duck-typed: nothing in the entrypoint statically imports either package, and CesiumJS is reached only through a lazy dynamic import performed on the first apply that needs a Cesium constructor.
What each port actually drives
| Slice | 2D map | Cesium viewer |
|---|---|---|
camera |
jumpTo centre/zoom/bearing/pitch/roll through the documented correspondence |
Camera.setView destination + orientation |
selection |
setFeatureState on the addressed source |
viewer.selectedEntity (one focused entity) |
filters |
setFilter, composed on top of the style's own filter |
entity visibility evaluated against entity properties |
time |
an epoch-millisecond window on the configured field | viewer.clock, through the same clock plan the scene adapter applies |
detail |
setFeatureState under a separate key |
refused: the globe's single focus channel belongs to selection |
attribution |
shared identifiers for the host to render | shared identifiers; provider credit display is untouched |
realtime |
status only | status only |
Each port computes its own mappings from what the live renderer can actually
do, so a fidelity claim is never just a string table: a map without a
feature-state API declares selection outbound-unsupported, a viewer without
a clock declares time outbound-unsupported, and the synchronizer reports
unsupported-target instead of pretending the state landed.
The same rule applies within a slice the renderer mostly supports. The 2D
filter language has no expression for like, none for a comparison, membership
or range clause whose published value has the wrong shape, and none for an
equality clause carrying no operand at all — FilterClause.value is optional
and a valueless clause survives normalization — while the globe evaluates all of
them against entity properties. So the 2D filters mapping is equivalent
rather than exact, and a clause addressed at a layer's source that compiles to
nothing is reported as filters-clause-not-expressible naming the clause,
operator and field, instead of vanishing from the composed filter.
compileMapLibreFilterSet() is the compiler that returns that report alongside
the filter; compileMapLibreFilters() is the same call with the report
discarded.
Two things are deliberately not reported. A clause whose appliesTo excludes
the layer's source was never addressed there. And omissions are reported when the
filters slice is applied, not again on each time delivery that recomposes the
same clauses — the shortfall belongs to the filter state, and re-reporting it
would attribute a filter problem to time revisions and, during playback, evict
unrelated diagnostics from the bounded degradation history.
The globe's time slice goes through sceneTimelineToCesiumClockPlan() and
applyCesiumClockPlan() rather than writing the clock itself, so shared time and
adapter-applied time have exactly one set of semantics: the extent is written
before the instant, speed becomes the clock multiplier, uninterpretable fields
are named rather than dropped, and a viewer that declares
clockOwnership: "host" is left alone (reported as time-clock-host-owned)
instead of being fought for transport. port.dispose() restores the clock it
displaced.
The 2D/3D camera correspondence
The renderer-neutral SceneCameraState is a globe pose. A 2D map has no camera
height — it has a zoom, which is a statement about ground resolution:
groundResolution(zoom, lat) = C · cos(lat) / (tileSize · 2^zoom) [m/px]
centreDistance(px) = 0.5 · viewportHeight / tan(fov / 2)
cameraHeight(m) = groundResolution · centreDistance(px) · cos(pitch2D)
mapLibreZoomToCameraHeight() / mapLibreCameraHeightToZoom() are that
relationship, and mapLibreViewToSceneCamera() / sceneCameraToMapLibreView()
are the whole-pose conversions built on it. The correspondence is neither
latitude- nor viewport-independent, and the live port reads the viewport from
the map it is bound to rather than assuming one. Pitch flips convention across
the boundary (pitch2D = 0 is nadir, which is pitch = -90 on the globe);
bearing and heading share the compass convention.
Poses a Web Mercator plane cannot hold are clamped and reported, never silently
applied. sceneCameraToMapLibreView() returns degradations typed by
SceneCameraDegradationCode — camera-latitude-clamped,
camera-zoom-clamped, camera-pitch-clamped, camera-roll-dropped — and
fidelity: "exact" only when nothing was clamped. The live port surfaces the
same records through port.degradations and the onDegraded callback, each
carrying the requested and applied values.
Loop closure and convergence
An adapter must publish a strictly increasing local sequence. When it emits a
native event caused by applying revision 42, it sets causeRevision: 42; the
synchronizer suppresses that echo. Untagged equivalent values and stale local
sequences are also suppressed. Camera and time events are coalesced over one
frame by default while the final state is retained. Delivery to each port is
serialized, failed applies produce diagnostics without poisoning later work,
and detach, abort, and disposal cancel pending work and remove listeners.
The shipped ports close that loop with two rules:
- Applied-signature matching. After applying a delivery the port reads the
renderer back and records a quantized signature of what it actually holds
(about 1 cm horizontally, nine significant digits of height, 1/10000 of a
degree of orientation). The next renderer event whose signature matches is
published once with
causeRevision, which the synchronizer records asloop-suppressed. The acknowledgement is published rather than swallowed so loop closure stays observable. - Lossy sides do not write back. A renderer that clamped what it was given applies its best effort, records a degradation, and leaves the shared value alone — writing the clamped pose back would drag every other view down to the least capable renderer's limits and is the one shape that can oscillate. Both the read-back and the delivered value count as echoes, so a change converges in one round trip even when the destination renderer applies further constraints of its own.
Disposal is owned: port.dispose() releases every renderer listener, clears the
feature state it wrote, restores the layer filters it composed, and restores the
entity visibility it changed. The synchronizer's own dispose() reaches the
same release through the AbortSignal it passes to subscribe.
Attribution and the two slice vocabularies
sceneAttributionId() reduces free-form credit text to the identifier charset
the attribution slice enforces ("County orthophotography" →
county-orthophotography), and sceneAttributionValue() builds a sorted,
de-duplicated slice value from live provider credits and primitive attribution.
Text that cannot be reduced is dropped rather than guessed at; the safe-id rule
is a boundary to satisfy, not to relax. On the Cesium side, primitive
attribution now reaches terrain providers, tilesets, and models as well as
imagery.
SCENE_STATE_SYNC_SLICES (7) and SCENE_WORKSPACE_SLICES (13) are different
sizes because they answer different questions, and
SCENE_STATE_SYNC_SLICE_WORKSPACE_CROSSWALK names the relationship. The wire
vocabulary is what two renderers must agree on; the store vocabulary is what one
workspace notifies its own subscribers about. time (wire) is timeline
(store) — the only rename. attribution maps to null: it is derived from what
each renderer credits rather than stored as workspace state. The workspace-only
members (all, scene, layers, primitives, diagnostics, evidence,
history) are single-application concerns with nothing to synchronize.
The runnable shared renderer state fixture
drives real MapLibre and Cesium canvases through these ports and asserts
destination-renderer state — map.getCenter(), map.getZoom(),
map.getFilter(), map.getFeatureState(), viewer.camera.positionCartographic,
viewer.selectedEntity, viewer.entities.getById(...).show, viewer.clock —
rather than a dictionary the fixture kept for itself. Renderer packages remain
optional peers: the scene-workspace entrypoint has no static MapLibre or Cesium
import.
Scene Primitives
Scene primitives describe 3D intent without naming a renderer package:
camera: serializable view state separate from source data state.groundandelevation-source: terrain/ground metadata, cache policy, attribution, and tile protocol. Every terrain protocol —terrain-rgb,raster-dem,quantized-mesh,image-service,i3s, andcustom— requires a renderable endpoint and in-range tile/zoom/exaggeration values; a missing or malformed endpoint fails closed rather than reaching a provider factory. See Terrain diagnostics. This is renderer-neutral vocabulary, not a claim that every adapter materializes every protocol.imagery-layer: URL-template, WMS, WMTS, single-tile, or ArcGIS imagery with explicit service configuration, opacity, attribution, and cache metadata. ArcGIS MapServer endpoints use Cesium's native MapServer provider; ImageServer endpoints use itsexportImageoperation through a bounded URL template. Service parameters override case-insensitive query keys already present in the endpoint: URL-template and single-tile bindings append them to the URL, WMS forwards them as request parameters, and WMTS forwards them as dimensions. WMS and WMTS accept explicitlayer,style, andformatfields (plustileMatrixSetIdfor WMTS); other provider kinds reject those service fields instead of silently ignoring them. Custom subdomains require a{s}URL placeholder. ArcGIS MapServer rejectsminimumLevel, which Cesium does not expose as a constructor option, while ImageServer URL templates support it. ArcGIS imagery URLs must identify a MapServer or ImageServer service. When a later layer reuses an ID, its predecessor is removed before replacement. Cesium-owned WMS/WMTS operation keys are removed from endpoint URLs, and WMTS dimensions that alias provider fields fail closed instead of creating case-insensitive KVP conflicts. ImageServer parameters cannot override the adapter-owned export format, projection, viewport, or response type. Single-tile bindings reject tile-level bounds because the provider represents one untiled image and cannot honor minimum or maximum tile levels. Provider URLs may be relative, HTTP, or HTTPS; malformed URLs and bindings containing userinfo, signed-URL query keys, or credential-like parameters fail closed before workspace serialization or provider creation.extrusion: a source-bound height/base/color definition that MapLibre can render asfill-extrusion.model-layer: glTF, 3D Tiles, I3S, or custom model binding for a 3D adapter, optionally placed byposition(lon/lat/height),rotation(heading/pitch/roll in degrees), andscale. Tiled point clouds ride the3d-tilesformat and accept boundedpointCloudShading(attenuation, maximum attenuation, geometric-error scale, and eye-dome lighting strength/radius), validated as a closed record so a misspelled key fails closed instead of silently falling back to renderer defaults. Asset URIs obey the same credential-free rule as imagery: relative, HTTP, or HTTPS only, with userinfo, signed-URL query keys, and credential-like fragment parameters rejected before workspace serialization or renderer materialization. Placement outside the globe's coordinate ranges, non-finite rotation, non-positive scale, and out-of-range shading fail closed rather than producing a silently wrong model matrix. See Model-layer diagnostics.scene-layer-metadata: SceneServer/mesh/point-cloud metadata preserved when a renderer cannot draw it directly.
Elevation-source, imagery-layer, and model-layer primitives additionally accept
an optional crs, verticalDatum, and precision. Every primitive accepts an
optional cache and sourceVersion. All of them are descriptive plan data and
all of them round-trip through workspace serialization. See
Spatial reference and fidelity and
Precision, cache, and source version.
Use MapLibre 2.5D when the experience is a pitched map with raster-dem terrain, hillshade, and source-bound building or asset extrusions. Use a Cesium or custom 3D adapter when the workflow needs globe navigation, glTF/3D Tiles/I3S model layers, point clouds, precise ground clamping, or scene-layer symbology.
Adapters declare SceneRuntimeCapabilities and can run
diagnoseScenePrimitives() before applying state:
const adapter = createMapLibreSceneAdapter();
const diagnostics = diagnoseScenePrimitives(
selectScenePrimitivesByKind(workspace.state, "elevation-source"),
adapter.capabilities,
);
workspace.dispatch(
sceneWorkspaceIntentFromAdapterEvent({ type: "primitive-diagnostics", diagnostics }, "scene"),
);
Diagnostics use supported, degraded, and unsupported states. Degraded
means the app can continue with an explicit fallback, such as rendering a
SceneServer layer as metadata while the MapLibre map keeps terrain and
extrusions active. Unsupported means the primitive should be routed to another
adapter or retained in migration diagnostics rather than silently dropped.
Spatial reference and fidelity
A primitive that renders is not automatically a primitive that renders in the right place. Elevation-source, imagery-layer, and model-layer primitives can declare the horizontal CRS and vertical datum they were authored against, and the adapter classifies that declaration against the renderer before a viewer exists:
| Fidelity | Meaning |
|---|---|
exact |
The renderer addresses the world in this CRS. Nothing is resampled. |
equivalent |
The renderer honors the binding through its own documented reprojection or tiling scheme. Footprints land correctly; samples are resampled. |
unsupported |
The renderer cannot honor it. This SDK never reinterprets coordinates or transforms heights, so the binding fails closed. |
This is the same exact / equivalent / unsupported vocabulary the
shared state synchronizer applies to slice
mappings, and it appears on the diagnostic as an explicit fidelity field.
Both shipped adapters declare RENDERER_WGS84_SPATIAL_CAPABILITIES: geographic
WGS84 (EPSG:4326, OGC:CRS84) is exact, the Web Mercator family
(EPSG:3857 and its EPSG:900913 / ESRI:102100 aliases) is equivalent
because both engines reproject it onto a WGS84 globe themselves, and heights are
only honored against the ellipsoidal datum (EPSG:4979, spelled
ellipsoidal-wgs84 on the Cesium entity path).
A custom adapter declares its own SceneRuntimeCapabilities.spatial record;
classification always follows the record, never a hard-coded globe.
Identifiers are normalized before comparison, so EPSG:3857, epsg:3857,
3857, urn:ogc:def:crs:EPSG::3857, and
http://www.opengis.net/def/crs/EPSG/0/3857 are one CRS. An identifier that
cannot be resolved at all is treated as unsupported rather than assumed
compatible.
| Code | Status | Fidelity | Raised when |
|---|---|---|---|
scene-primitive-crs-exact |
supported |
exact |
The declared crs is one the renderer addresses natively. |
scene-primitive-crs-equivalent |
degraded |
equivalent |
The renderer reprojects the declared crs itself. |
scene-primitive-crs-unsupported |
unsupported |
unsupported |
The declared crs is unresolvable or outside the renderer's record. |
scene-primitive-vertical-datum-unsupported |
unsupported |
unsupported |
The declared verticalDatum is not one the renderer can interpret as scene heights. |
A vertical datum has only two honest states against a renderer that performs no
height transform, so the code vocabulary reuses the entity path's
vertical-datum-unsupported spelling rather than inventing a second one: a
datum the renderer can interpret is silent, and one it cannot fails closed.
The horizontal axis reports all three fidelities because equivalent is a real
state there — the renderer genuinely does reproject Web Mercator itself.
Only EPSG:4979 and its ellipsoidal-wgs84 spelling name the WGS84 ellipsoid
as a vertical reference. EPSG:4326 and OGC:CRS84 are two-dimensional, so
they classify as an unhonorable vertical datum even though they are exact
horizontal ones; write EPSG:4979 when the heights really are ellipsoidal.
Three properties hold by construction:
- Pure. Classification loads no renderer peer, so a migration analysis can run before a viewer exists.
- Silent when nothing is known. A primitive that declares neither field
diagnoses exactly as it did before this contract existed, and so does any
primitive measured against a renderer that declares no
spatialrecord. Absence of metadata is never read as agreement. - Fail-closed when something is known but unhonorable. Nothing is reprojected or transformed to make a declaration fit, and no fidelity is inferred from an identifier the normalizer could not resolve.
This example imports through the deprecated @honua/sdk-js/scene-workspace
forwarder so it stays compile-checked in this repository; the identical symbols
are exported from @honua/app-platform/scene-workspace.
import {
CESIUM_SCENE_CAPABILITIES,
diagnoseScenePrimitives,
type SceneRuntimePrimitive,
} from "@honua/sdk-js/scene-workspace";
const primitives: SceneRuntimePrimitive[] = [
{
kind: "elevation-source",
id: "site-terrain",
sourceId: "site-terrain",
protocol: "quantized-mesh",
url: "https://terrain.example.test/tiles",
crs: "EPSG:4326",
verticalDatum: "EPSG:5703", // NAVD88 orthometric heights
},
];
for (const diagnostic of diagnoseScenePrimitives(primitives, CESIUM_SCENE_CAPABILITIES)) {
// scene-primitive-vertical-datum-unsupported / unsupported:
// Cesium heights are ellipsoidal, so this binding never reaches the globe.
console.log(diagnostic.code, diagnostic.fidelity, diagnostic.status);
}
Precision, cache, and source version
Placing a binding correctly is not the same as resolving it at the detail the
author claimed. Elevation-source, imagery-layer, and model-layer primitives can
declare a precision descriptor, and every primitive can declare cache
freshness and a sourceVersion. None of it changes what the SDK does to the
data — nothing is resampled, revalidated, or version-negotiated — but all of it
now reaches the diagnostic stream instead of sitting inert in the plan.
precision: {
horizontalMeters?: number; // finest horizontal detail the binding resolves
verticalMeters?: number; // finest height detail the binding resolves
coordinateFrame?: "geocentric" | "local";
coordinateStorage?: "float32" | "float64";
}
The two magnitudes are claims. The other two fields, the declared DEM
encoding, and a renderer's own SceneSpatialCapabilities.precision floor are
limits. A claim is compared against the coarsest limit that applies to its
axis, because a binding resolves no finer than its bluntest instrument.
| Limit | Quantum | Derived from |
|---|---|---|
dem-encoding |
0.1 m (mapbox), 1/256 m (terrarium) |
The published decode formulas. Mapbox Terrain-RGB is -10000 + (R * 65536 + G * 256 + B) * 0.1, so 0.1 m is the smallest height step it can express; Terrarium is (R * 256 + G + B / 256) - 32768. Applies to terrain-rgb and raster-dem sources that declare one of those encodings. |
geocentric-float32-coordinates |
0.5 m | A float32 carries a 24-bit significand, so adjacent representable values near the WGS84 semi-major axis are 2 ** (floor(log2 6378137) - 23) = 0.5 m apart. This is why 3D assets are published in a local frame anchored by a relative-to-centre origin rather than in raw ECEF. |
renderer-floor |
Adapter-declared | SceneRuntimeCapabilities.spatial.precision. |
| Code | Status | Fidelity | Raised when |
|---|---|---|---|
scene-primitive-precision-exact |
supported |
exact |
The claim is at or above the coarsest limit on that axis; the binding is carried as authored. |
scene-primitive-precision-equivalent |
degraded |
equivalent |
The claim is finer than the coarsest limit; the binding is carried at that coarser quantum. context names the axis, the claim, the limit, and its source. |
scene-primitive-cache-stale |
degraded |
— | cache.status is stale. The scene draws material the plan already knew was out of date. |
scene-primitive-cache-bypass |
supported |
— | cache.status is bypass. Every request for the material reaches the origin. |
scene-primitive-asset-metadata-invalid |
degraded |
— | A precision, cache, or sourceVersion value could not be read. context.invalidFields names them, dotted from the primitive root. |
Four properties hold by construction, and each is a deliberate choice:
- Silent on half a comparison. A claim with no declared limit, a limit with
no claim, an encoding whose quantum the plan does not publish (
custom, or none declared), and a renderer that declares no floor all produce nothing. An undeclaredencodingis never assumed to be the renderer's default, and an omitted renderer floor is not read as an unbounded one. - No
unsupportedprecision fidelity exists. A binding whose detail exceeds a limit still renders, only coarser than authored — which is exactly whatequivalentmeans. Minting anunsupportedprecision finding would name a state that cannot occur, so the vocabulary stops at two, the same way the vertical-datum axis stops at one. - Neither shipped adapter declares a precision floor. Cesium encodes
positions relative to a centre before they reach the GPU, and MapLibre samples
whatever the DEM encodes; neither imposes a documented sample-spacing limit
above the binding's own encoding. A number invented for the table would be
worse than silence, so
RENDERER_WGS84_SPATIAL_CAPABILITIESomitsprecisionand a custom adapter declares its own. - Descriptive metadata degrades; it never fails closed. Precision, cache
state, and source version describe the material behind a binding, and none of
them can put it in the wrong place, so an unreadable one reports
degradedand the scene still renders. Staying silent is not the alternative: acache.staustypo dropped in silence would read asready, so both records are validated as closed records and every unknown own key is named.
sourceVersion is opaque — a service version, dataset edition, or content
digest, spelled however the publisher spells it. It is never parsed or compared;
it is copied onto the context of every diagnostic the primitive raises, so a
finding can be traced back to the material it was computed from.
Cache metadata is reported, not acted on. The SDK does not own the cache: it
never revalidates, refetches, or evicts, and ready and unknown are both
silent because neither says anything the developer can act on.
Authorization is deliberately not carried here. The epic that introduced
this metadata asked scene assets to carry attribution, cache, source-version,
and authorization metadata. The first three are on ScenePrimitiveBase; the
fourth is refused. A scene primitive that reached workspace serialization
holding a credential or a signed URL would persist it into every snapshot,
history entry, and shared plan, so credential-bearing URIs fail closed
(scene-primitive-imagery-credentials-forbidden,
scene-primitive-model-credentials-forbidden) with the same fallback in both
cases: resolve authorization at the host boundary. The host attaches
credentials to the request — through a fetch interceptor, a signing proxy, or a
Cesium Resource it owns — and the plan stays shareable. This is a standing
decision, not an unfinished field.
import {
CESIUM_SCENE_CAPABILITIES,
diagnoseScenePrimitives,
type SceneRuntimePrimitive,
} from "@honua/sdk-js/scene-workspace";
const primitives: SceneRuntimePrimitive[] = [
{
kind: "model-layer",
id: "site-model",
uri: "https://models.example.test/tileset.json",
format: "3d-tiles",
sourceVersion: "dem-2026.2",
cache: { status: "stale", scope: "tiles" },
// Survey-grade source coordinates rendered through float32 geocentric storage.
precision: { horizontalMeters: 0.01, coordinateFrame: "geocentric", coordinateStorage: "float32" },
},
];
for (const diagnostic of diagnoseScenePrimitives(primitives, CESIUM_SCENE_CAPABILITIES)) {
// scene-primitive-precision-equivalent / degraded / equivalent, then
// scene-primitive-cache-stale — both carrying context.sourceVersion.
console.log(diagnostic.code, diagnostic.fidelity, diagnostic.context?.sourceVersion);
}
Terrain diagnostics
Renderer capability validation runs before endpoint validation. The Cesium
adapter advertises and materializes only quantized-mesh; it never passes
Terrain-RGB, raster DEM, ImageServer, or custom bindings to
CesiumTerrainProvider.fromUrl. MapLibre continues to materialize
terrain-rgb and raster-dem through its raster-dem source path. The other
protocol values remain portable descriptive primitive vocabulary for future or
host-provided adapters.
| Renderer | Materialized terrain protocols | Descriptive-only protocols |
|---|---|---|
| Cesium | quantized-mesh |
terrain-rgb, raster-dem, image-service, i3s, custom |
| MapLibre | terrain-rgb, raster-dem |
quantized-mesh, image-service, i3s, custom |
For protocols a renderer accepts, endpoint and range validation happens before
the provider/source is constructed. Applying one primitive directly with
applyCesiumTerrain() throws, because the caller asked for exactly that
binding; a batch apply through applyCesiumScenePrimitives() or
applyMapLibreScenePrimitives() skips the offending primitive and keeps its
diagnostic so the rest of the plan still lands. In every case the live scene's
terrain provider and vertical exaggeration are left as they were — an
unsupported elevation source no longer moves the exaggeration of a terrain it
never loaded.
| Code | Raised when |
|---|---|
scene-primitive-terrain-source-missing-url |
Neither url nor a non-blank tiles entry is present. |
scene-primitive-terrain-source-url-invalid |
A declared url or tiles entry is malformed or uses a scheme other than relative/HTTP/HTTPS. context.invalidFields names the offenders. |
scene-primitive-terrain-range-invalid |
tileSize, exaggeration, or the zoom range is non-finite, non-positive, out of 0..24, or inverted. |
Model-layer diagnostics
A model layer is validated before any renderer peer loads, so an unrenderable
binding never reaches a Cesium factory. Every code below is error severity and
unsupported status; applyCesiumScenePrimitives() skips the primitive and
reports the diagnostic instead of attaching anything.
| Code | Raised when |
|---|---|
scene-primitive-model-source-missing-uri |
uri is absent or blank. |
scene-primitive-model-source-uri-invalid |
uri is malformed or uses a scheme other than relative/HTTP/HTTPS. |
scene-primitive-model-credentials-forbidden |
uri carries userinfo or a credential-like query/fragment key. |
scene-primitive-model-placement-invalid |
position, rotation, or scale is non-finite, out of range, or non-positive. context.invalidFields names the offenders. |
scene-primitive-model-point-cloud-shading-invalid |
pointCloudShading is set on a non-tiled format, is not a plain data record, or carries an unknown key, a non-boolean toggle, or a non-positive magnitude. context.invalidFields names the offenders. |
scene-primitive-model-format-not-materialized |
The renderer engine can consume the format but this adapter does not attach it. |
scene-primitive-unsupported |
The renderer engine cannot consume the format at all. |
The last two are deliberately distinct. SceneRuntimeCapabilities.modelLayer
carries both formats (what the engine can consume) and an optional
materializedFormats (what the adapter actually attaches). Cesium declares
i3s in formats because CesiumJS can consume I3S, but omits it from
materializedFormats because this adapter does not wire it — so an I3S binding
fails closed with scene-primitive-model-format-not-materialized and its
context.materializedFormats, instead of reporting supported and then
rendering nothing.
Cesium entity queries
The experimental accepted-plan Source to Cesium entity lifecycle is
documented in Experimental Cesium entity adapter.
It is a bounded feature/entity slice alongside the existing terrain, model,
and 3D Tiles primitive adapter.
Against real Cesium, entities materialize from a live Source and an accepted
plan onto a real Viewer, Cesium's own availability decides which of them are
drawn at a given clock instant, polygon interior rings reach the GPU, the entity
ceiling fails closed, and disposal returns the collection to baseline inside the
primitive lane's measured teardown ceilings.
refresh() is a diff rather than a rebuild. It runs the mount-diff discipline
the beta primitive path runs on: identity is the projected entity id qualified by
geometry kind, configuration is an order-independent per-facet fingerprint, and a
feature that cannot be fingerprinted is treated as changed. A feature whose row
did not change keeps the same live Entity, a changed feature keeps it too
and has only the changed facets written onto it, and departures are released last
so a mid-refresh failure cannot leave a hole. The consequence a host can rely on
is that viewer.selectedEntity, a tracked entity, and an entity reference the
application holds all survive a refresh — asserted by object identity against a
real Viewer.
mountCesiumScene is the single owner over both halves: it delegates to the
primitive mount and to each entity mount, bounds the number of sources, and
releases everything behind one idempotent, retryable dispose() — entity mounts
first, in reverse acquisition order, then the primitive plan.
mountScenePrimitivesToCesium is unchanged and still owns primitives alone.
The slice stays experimental regardless, and the reason is recorded rather than
implied: with the refresh diff and the single owner landed, the one remaining
blocker is that there is no symbology surface, and adding it means new required
shapes rather than purely additive ones. See
Support status.
Cesium layer disposal
Cesium layer handles returned by the primitive adapter own the resources they
materialize. Calling remove() is idempotent, and control calls made after
removal are no-ops rather than mutations of a destroyed object.
Tilesets and models are removed through Cesium's primitive collection and then
destroyed exactly once behind an isDestroyed() check, so a collection
configured with destroyPrimitives = false cannot leak them. Terrain handles
clear the active provider and call its optional destroy() method; replacing
terrain destroys the displaced provider immediately, while removing a stale
handle never disturbs the newer provider. Imagery handles control visibility and
opacity, remove their Cesium imagery layer, and destroy an owned provider at
most once.
setOpacity is present only where the adapter owns an alpha channel: imagery
layers (ImageryLayer.alpha) and glTF/GLB models (Model.color alpha). A
Cesium3DTileset has no tileset-wide alpha — tileset translucency is a
Cesium3DTileStyle concern owned by the server styling contract — so tileset
handles omit setOpacity rather than clobbering an applied style. Callers must
feature-detect it.
The application owns the Cesium Viewer/Scene itself and must dispose that
target separately.
Cesium scene mount lifecycle
applyCesiumScenePrimitives() is a one-shot projection: it returns the layer
handles it created and forgets them. Applying twice constructs a second set of
providers, tilesets, and models, and the caller must remember every handle from
the first call or leak it.
mountScenePrimitivesToCesium() is the lifecycle-owning entry point. It applies
the plan and returns one handle that owns every renderer resource the adapter
created for as long as that plan is live. Applications hold the mount, not the
handles.
import {
type CesiumSceneRuntimeTarget,
type SceneRuntimePrimitive,
mountScenePrimitivesToCesium,
} from "@honua/sdk-js/scene-workspace";
declare const target: CesiumSceneRuntimeTarget;
declare const acceptedPlan: readonly SceneRuntimePrimitive[];
declare const revisedPlan: readonly SceneRuntimePrimitive[];
declare const unmounted: AbortSignal;
const mount = await mountScenePrimitivesToCesium(target, acceptedPlan, {
signal: unmounted,
maxLayers: 32,
});
// Revise the plan through the same mount: unchanged primitives are reused,
// primitives that left the plan are disposed.
const revision = await mount.apply(revisedPlan);
console.log(revision.created, revision.reused, revision.disposed);
// One call releases everything the mount owns.
mount.dispose();
Four properties define the lifecycle.
Bounded. A mount owns at most maxLayers layer handles
(DEFAULT_SCENE_MOUNT_LAYER_LIMIT, 64, by default). The ceiling counts the
elevation-source, imagery-layer, and model-layer primitives in the plan — the
kinds that each pin one adapter-owned renderer resource — and is enforced before
the Cesium peer is loaded. An over-budget plan is refused with a
HonuaCesiumSceneMountError (layer-limit-exceeded) and the currently mounted
plan is left exactly as it was, so the ceiling is an admission gate rather than a
post-hoc count.
Diffed. Identity is the primitive's kind and id together; configuration is a
stable fingerprint of the whole primitive. A revision reuses each primitive whose
identity and fingerprint are unchanged — its Cesium object is never
reconstructed, so visibility and opacity the host applied to the handle survive
the revision — and disposes exactly the handles whose primitive left the plan or
changed. A primitive that cannot be fingerprinted deterministically is treated as
changed and rebuilt, never assumed unchanged. An unchanged camera primitive is
not re-applied either, so a revision that does not move the view does not yank a
camera the user has navigated.
Cancellation-safe. The mount accepts an AbortSignal, and dispose() fires
its own. Aborting before materialization loads nothing. Aborting during
materialization unwinds through the adapter's transactional rollback: everything
already attached is removed and destroyed, and the terrain provider and vertical
exaggeration are restored. The Cesium asset factories take no AbortSignal, so a
load already in flight cannot be stopped at the peer — what the mount guarantees
instead is that the resource it resolves to is destroyed and never attached,
rather than landing in a scene the host has abandoned. Aborting a single
apply() leaves the previously applied plan mounted and the mount usable.
Provably released. dispose() releases every handle the mount owns, in
reverse construction order, exactly once, and is idempotent. If a handle refuses
to release, the failures are aggregated and thrown, the mount stays in
disposing, and a later dispose() retries only what is still owned. The same
retention applies to a handle that refused to release during a revision: the
revision still lands, scene-mount-disposal-incomplete is reported, and the
retained handle stays the mount's responsibility.
Rollback is unchanged from the one-shot path: a failure mid-application restores
the scene's pre-application terrain, imagery, and primitives, so a failed
revision leaves the previously applied plan intact and a subsequent dispose()
releases exactly that plan.
Diagnostics stay plan-scoped. Every application re-diagnoses the whole plan
against CESIUM_SCENE_CAPABILITIES, so a reused primitive still reports its
current model-layer, terrain, and spatial-reference findings even though nothing
was rebuilt for it. The mount adds one scene-mount-applied diagnostic per
application whose context carries the revision number and the created, reused,
and disposed ids. The two layers answer different questions: plan findings say
whether a binding can render where its author meant it, and mount findings say
what the renderer currently owns.
applyCesiumScenePrimitives() keeps working unchanged for callers that do not
need a lifecycle; it is the same engine with no diff, no ceiling, and no
ownership.
Application time, realtime deltas, and rebuild boundaries
The workspace timeline slice is canonical application time, and the Cesium
adapter binds it to a live Clock. The realtime slice travels with the
application that carried it, so a revision driven by a live feed can be read
against the feed state it arrived under.
Who owns the clock
Time binding is opt-in on the target. A CesiumSceneRuntimeTarget gains two
optional fields:
import {
type CesiumSceneRuntimeTarget,
type SceneRuntimePrimitive,
type SceneWorkspaceState,
mountScenePrimitivesToCesium,
} from "@honua/sdk-js/scene-workspace";
declare const viewer: {
camera: CesiumSceneRuntimeTarget["camera"];
scene: CesiumSceneRuntimeTarget["scene"];
clock: CesiumSceneRuntimeTarget["clock"];
};
declare const plan: readonly SceneRuntimePrimitive[];
declare const state: SceneWorkspaceState;
// Opting in: the adapter drives `viewer.clock` from `state.timeline`.
const target: CesiumSceneRuntimeTarget = {
camera: viewer.camera,
scene: viewer.scene,
clock: viewer.clock,
};
const mount = await mountScenePrimitivesToCesium(target, plan, { state });
// Advancing time is a revision with the same plan and a moved timeline.
const advanced = await mount.apply(plan, {
state: { ...state, timeline: { ...state.timeline, currentTime: "2026-03-01T12:00:00Z" } },
});
console.log(advanced.created, advanced.rebuildBoundaries);
Three rules define ownership, and each one reports itself:
- No
clockon the target — the adapter never reads or writes a clock. A timeline that declares a time is reported asscene-time-clock-unbound(degraded), never silently dropped. clockpresent (the defaultclockOwnership: "adapter") — the adapter writesClock.currentTimefromtimeline.currentTime,Clock.startTime/Clock.stopTimefromtimeline.startTime/timeline.endTimewhen both parse and are ordered,Clock.shouldAnimatefromtimeline.playing, andClock.multiplierfromtimeline.speed. Only declared fields are written.clockOwnership: "host"— the adapter stands down and writes nothing. Declare this when Cesium's own Animation/Timeline widgets or a host simulation loop own the clock. The refusal is reported asscene-time-host-owned(supported), so standing down is distinguishable from a missing binding.
Time participates in the adapter's transactional rollback: a failed application restores the clock alongside the terrain provider and vertical exaggeration.
defaultSceneStateSyncMappings("cesium").time already advertised this binding as
exact under the code cesium-clock; it is now backed by a writer.
Driving a globe from the temporal playback controller
createTemporalPlayback() (@honua/sdk-js/map) is the renderer-neutral
play/pause/scrub controller behind <honua-time-slider>. It now has a Cesium
sink, so one controller instance can hold a MapLibre filter binding, a
data-to-map bridge where clause, a slider view, and a globe at once:
import { createTemporalPlayback } from "@honua/sdk-js/map";
import { type CesiumSceneRuntimeTarget, bindTemporalPlaybackToCesium } from "@honua/sdk-js/scene-workspace";
declare const target: CesiumSceneRuntimeTarget;
const playback = createTemporalPlayback({
extent: ["2026-03-01T00:00:00Z", "2026-03-02T00:00:00Z"],
windowMs: 60 * 60 * 1000,
apply: () => undefined,
});
const binding = await bindTemporalPlaybackToCesium(target, playback);
playback.play();
// ...
binding.dispose(); // restores the clock exactly as it was at bind time
playback.dispose();
scene-workspace never imports /map: it declares the structural slice it needs
(CesiumTemporalPlayback), the same posture <honua-time-slider> takes, so
neither entrypoint gains the other's closure.
Two mappings are worth stating plainly:
- A playback window is an interval and a Cesium clock is an instant, so
the mapping is
equivalent, not exact. The binding writes the window's start by default — matching the slider'saria-valuenow,scrub(), andprogresssemantics — andinstant: "window-end"binds the leading edge instead. - By default the binding sets
shouldAnimate = falsefor as long as it is live: the controller is the transport and the SDK is the only writer ofcurrentTime.transport: "mirror"instead followsplayback.playingand scalesClock.multiplierbyplayback.speed, so Cesium interpolates between the controller's frames and the controller re-anchors it on every tick.
Entity availability needs no extra wiring: Cesium evaluates
TimeIntervalCollection availability against Clock.currentTime, so a temporal
entity honours the bound application time as soon as the clock does.
Rebuild boundaries
Every revision through a mount reports what it had to cross. apply() returns
rebuildBoundaries, one entry per binding, and the mount exposes the most recent
list as mount.rebuildBoundaries:
| Boundary | Meaning |
|---|---|
none |
Applied in place; the renderer resource was reused untouched. |
primitive-identity |
The binding was not previously mounted; its resource was constructed. |
primitive-configuration |
The configuration fingerprint changed; the resource was rebuilt. |
plan-membership |
The binding left the plan; its resource was released. |
unfingerprintable |
The primitive could not be fingerprinted deterministically, so it was rebuilt conservatively. |
Each crossing that is not none also emits a scene-mount-rebuild-boundary
diagnostic naming the primitive, its kind, and the boundary in
context.rebuildBoundary. The initial application reports no boundaries — with
no previously mounted plan there is nothing to have crossed, and
scene-mount-applied already lists what was created.
The load-bearing consequence: advancing application time crosses no boundary.
Time lives in the workspace state, never in the plan, so it is outside the
fingerprint the diff runs on. Re-applying the same plan with a moved timeline
reuses every handle by identity and mutates only the clock. A realtime data
delta is the other case — it revises a binding's configuration, so exactly that
binding is rebuilt (primitive-configuration) while the rest are carried
forward.
Time and delta diagnostic codes
| Code | Severity / status | Meaning |
|---|---|---|
scene-time-applied |
info / supported | Application time was written to the bound clock. context.rebuildBoundary is none. |
scene-time-host-owned |
info / supported | The target declares clockOwnership: "host"; the adapter reported the time without writing it. |
scene-time-clock-unbound |
warning / degraded | The timeline declares a time but no clock is bound to the target. |
scene-time-invalid |
warning / degraded | Declared timeline fields could not be interpreted; context.rejected names them. |
scene-time-runtime-unavailable |
warning / degraded | The loaded cesium peer exposes no usable JulianDate. |
scene-mount-rebuild-boundary |
info / supported | A revision could not update a binding in place; context.rebuildBoundary names the crossing. |
What stays snapshot-only
- Feature/entity data. The primitive adapter binds terrain, imagery,
tilesets, and models. Per-feature deltas remain the experimental entity slice's
concern, and that slice is still an unconditional remove-then-re-add reported
honestly as
rebuildBoundary: "entity-snapshot". That is now measured rather than asserted: the browser lane refreshes a mount against a source where one feature is byte-identical and observes that itsEntityis replaced anyway — see Experimental Cesium entity adapter. - Sub-primitive updates. There is no partial mutation of a materialized
binding: any configuration change rebuilds that binding. Opacity and visibility
are the exception, because the layer handle owns them directly
(
setVisible/setOpacity) and neither goes through the plan. - Playback history. The controller keeps only the current window by construction; the SDK caches no past windows and pre-fetches nothing.
Demo Fit
The incident operations dashboard can use this workspace when the map expands from 2D incident points into a 3D command scene. The same source-qualified selection targets keep a clicked building, table row, evidence packet, and detail panel aligned. Realtime transport remains separate; it only publishes status or deltas that the workspace can expose to renderers.
The Palantir-style operations sample can layer the scene workspace beside the app workspace: the app workspace owns cross-app metadata, jobs, source cache, and reviewable MCP/AI drafts; the scene workspace owns renderer-neutral 3D view state and scene-specific evidence.
Boundaries
- Renderer adapters own Cesium/MapLibre/OpenLayers objects.
- The workspace owns serializable scene state and source-qualified selection.
- Cloud Honua queries, realtime subscriptions, and job runs stay in the SDK client/app workspace and feed scene intents at the protocol edge.
- Snapshot and restore are value-detached so saved state can be persisted without retaining renderer-owned objects.
- Each synchronizer is application-instance scoped. It does not coordinate browser tabs, persist state, or own renderer objects.