Scene Workspace Interop

@honua/sdk-js/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.

Sample Pattern

import {
  createMapLibreSceneAdapter,
  createSceneWorkspace,
  diagnoseScenePrimitives,
  sceneWorkspaceIntentFromAdapterEvent,
  selectSceneEvidenceForFeature,
  selectScenePrimitivesByKind,
  selectSceneVisibleLayers,
} from "@honua/sdk-js/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 },
    },
    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.

import {
  createSceneStateSynchronizer,
  defaultSceneStateSyncMappings,
} from "@honua/sdk-js/scene-workspace";

const sharedState = createSceneStateSynchronizer({
  applicationId: "incident-command",
  ports: [
    {
      id: "map-2d",
      renderer: "maplibre",
      mappings: defaultSceneStateSyncMappings("maplibre"),
      subscribe: (publish, signal) => mapStatePort.subscribe(publish, signal),
      apply: (delivery, signal) => mapStatePort.apply(delivery, signal),
    },
    {
      id: "globe-3d",
      renderer: "cesium",
      mappings: defaultSceneStateSyncMappings("cesium"),
      subscribe: (publish, signal) => globeStatePort.subscribe(publish, signal),
      apply: (delivery, signal) => globeStatePort.apply(delivery, signal),
    },
  ],
});

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.

MapLibre camera and application time mappings are deliberately equivalent: its center/zoom/pitch cannot preserve a Cesium globe horizon or roll, and it has no native clock. Selection, protocol-neutral filters, source-qualified detail, attribution identifiers, and realtime freshness map exactly by default. Apps must narrow a port's mappings to unsupported when their adapter cannot honor a slice; the synchronizer diagnoses that boundary instead of silently dropping state.

The runnable shared renderer state fixture uses real MapLibre and Cesium canvases and proves bidirectional camera, selection, filter, and time flow plus loop suppression and cleanup. 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:

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.

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; it is not yet the production adapter described by issue #395.

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 from issue #71: 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