Experimental Cesium entity adapter

@honua/sdk-js/scene-workspace exposes an experimental accepted-plan workflow for projecting a canonical Source query into a Cesium EntityCollection:

import type { Source } from "@honua/sdk-js/contract";
import { explainQuery } from "@honua/sdk-js/query-planner";
import { type CesiumEntityCollectionTarget, mountSourceToCesium } from "@honua/sdk-js/scene-workspace";

declare const source: Source<Record<string, unknown>>;
/** A live Cesium `viewer.entities` satisfies this structurally. */
declare const entities: CesiumEntityCollectionTarget;

const plan = explainQuery({
  descriptor: source.descriptor,
  query: { pagination: { limit: 5_000 }, returnGeometry: true, outSr: 4326 },
});

const mounted = await mountSourceToCesium(entities, source, plan, {
  featureIdField: "unit_id",
  verticalDatum: "ellipsoidal-wgs84",
  time: { startField: "observed_at", endField: "expires_at" },
});

// Re-executes the same accepted plan and *diffs* the result onto the live
// collection: a feature whose row did not change keeps the very same `Entity`.
const refreshed = await mounted.refresh();
console.log(refreshed.reused, refreshed.updated, refreshed.created, refreshed.disposed);
for (const crossing of refreshed.rebuildBoundaries) console.log(crossing.entityId, crossing.boundary);
if (refreshed.state === "degraded") {
  for (const diagnostic of mounted.diagnostics) console.warn(diagnostic.code, diagnostic.message);
}

mounted.dispose();

The core projection (projectSourceToCesium) does not import Cesium. Mounting accepts either a minimal injected Cesium module or async loader; if neither is provided, the optional cesium peer is imported lazily. Importing the entrypoint in Node/SSR therefore does not initialize a browser or WebGL runtime.

Supported slice

Unsupported features are omitted with fidelity: "unsupported"; they are not rendered as plausible substitutes. Invalid plans, CRS, limits, and adapter options fail before mounting.

The refresh diff

refresh() re-executes the accepted plan and reconciles the new snapshot onto the collection. It is a diff, running the same discipline the beta primitive mount runs on (#930):

What that buys, and what the browser lane asserts by object identity:

Outcome Boundary The live Entity
Row unchanged none The same object, untouched
Row changed entity-configuration The same object, with only the changed facets written onto it
New feature entity-identity Constructed
Feature left the snapshot snapshot-membership Released
Geometry kind changed entity-geometry-kind Released and rebuilt
Unfingerprintable unfingerprintable Released and rebuilt conservatively

Because an unchanged — and a merely changed — feature keeps its Entity, everything a host attached to that object survives a refresh: viewer.selectedEntity, a tracked entity, an entity reference the application holds, and a graphic property it adjusted. Geometry is written at the deepest field Cesium accepts (polyline.positions, polygon.hierarchy, position), so a width or material set on the graphic is not clobbered either.

refresh() returns the projection plus revision, reused, updated, created, disposed, and rebuildBoundaries — one report per entity that crossed a boundary, so a steady-state refresh reports nothing. The mount carries the most recent list as mounted.rebuildBoundaries, and the incremental-update diagnostic carries the counts and the highest boundary crossed. A refresh that had to release an entity also emits rebuild-boundary.

Mutation order is chosen so a failure cannot leave a hole: in-place writes are journaled with the values they displaced, replacements and arrivals are added next, and departures are released last. If any step fails, the additions are removed, the replaced entities are restored, and every journaled write is undone exactly — the departed set was never touched, so the previous snapshot is still attached. The mount reports the attempt with incremental-update-failed.

One owner for both mounts

mountCesiumScene owns a whole Cesium scene: the primitive plan and every accepted-plan source mounted over it. It delegates — the primitive mount keeps its own diff, layer ceiling, and transactional apply; each entity mount keeps its own refresh diff and rollback — and adds ordering, admission, and a single dispose().

import type { Source } from "@honua/sdk-js/contract";
import { explainQuery } from "@honua/sdk-js/query-planner";
import { type CesiumSceneOwnerTarget, mountCesiumScene } from "@honua/sdk-js/scene-workspace";

declare const source: Source<Record<string, unknown>>;
/** A live Cesium `Viewer` satisfies this structurally: camera, scene, clock, entities. */
declare const viewer: CesiumSceneOwnerTarget;

const scene = await mountCesiumScene(viewer, [
  { kind: "elevation-source", id: "terrain", sourceId: "terrain", protocol: "quantized-mesh", url: "https://terrain.example.test" },
]);

const units = await scene.mountSource(
  source,
  explainQuery({
    descriptor: source.descriptor,
    query: { pagination: { limit: 5_000 }, returnGeometry: true, outSr: 4326 },
  }),
  { featureIdField: "unit_id" },
);
console.log(scene.sources.get(units.sourceId) === units);

// One teardown: entity mounts first, in reverse acquisition order, then the
// primitive plan. Idempotent, and retryable if a mount refuses to release.
scene.dispose();

The owner is bounded the same way the mounts are: at most DEFAULT_CESIUM_SCENE_SOURCE_LIMIT (8, raise it with maxSources) entity mounts, refused with HonuaCesiumSceneOwnerError (source-limit-exceeded) before anything is attached. A second mount of a source it already holds is refused with source-conflict and the redundant mount is released. Disposing the owner while a mountSource() is in flight aborts it and attaches nothing. A mount that refuses to release does not stop the teardown: everything releasable is released, the failures are aggregated into an AggregateError, the owner stays in disposing owning exactly what refused, and a later dispose() retries only that.

mountScenePrimitivesToCesium is untouched and is still the supported way to own primitives alone; mountCesiumScene is additive.

Real-Cesium evidence

Until issue #1050 this path had no real-Cesium coverage at all: every test ran in jsdom against a vi.mock("cesium") stub. It now shares the primitive adapter's browser lane, test/playwright/cesium-scene-adapter-fixtures.spec.mjs, under the same rules — Playwright's headless Chromium with SwiftShader WebGL, fixture assets generated in-process, and any off-origin request aborted and failed.

The lane connects to a loopback GeoServices layer with createHonua(), accepts a plan with explainQuery, and hands both to mountSourceToCesium with no Cesium module injected, so the lazy optional-peer import is exercised in a real browser. What it establishes:

Tier decision (issue #1050)

mountSourceToCesium, projectSourceToCesium, and mountCesiumScene stay @experimental for the life of @honua/app-platform 0.1.x. The evidence above is what the surface needed to be considered for the beta tier that the surrounding @honua/app-platform/scene-workspace surface carries, because that tier is a promise about shape: exports are not renamed or removed and behaviour changes are called out, not slipped in.

Three known changes stood in the way. Two have landed, which is why the caveat is now narrower than it was:

  1. Refresh rebuilds everything. Cleared. refresh() is a diff (see The refresh diff). A feature whose row did not change keeps its live Entity untouched, a changed feature keeps it and has only its changed facets written on, and the browser lane asserts both by object identity — including that viewer.selectedEntity survives. The old rebuildBoundary: "entity-snapshot" detail is gone, replaced by a per-entity CesiumEntityRebuildBoundary vocabulary. That is epic #395 REQ-004 for this path, and it is the visible behaviour change this tier decision reserved the right to make.
  2. Two lifecycle owners. Cleared. mountCesiumScene (#395 REQ-003) owns both mounts behind one dispose(), with a bounded source ceiling, a measured teardown order, and delegation rather than reimplementation. It is purely additive: mountScenePrimitivesToCesium and its beta handle are unchanged.
  3. There is no symbology surface. This is the remaining blocker. Points are 8 px, lines are 2 px wide, and colours are Cesium's defaults; nothing in the public options can change that. A production entity path needs styling, and adding it means new required shapes — an options surface on both projectSourceToCesium and mountSourceToCesium, and very likely a per-facet contract that interacts with the in-place update path above — rather than purely additive ones. Promoting now would freeze a surface we already know has to grow a required dimension.

Issue #1050 does not ask for symbology, and its own requirements (real-Cesium evidence, measured teardown, single-owner reconciliation, the refresh diff, and a recorded tier decision) are met. REQ-005 asks the surface to be promoted or for the reason it stays experimental to be recorded: this is that record. Two of three blockers are cleared and the third is named, so the tier stands and the statement of it is now specific rather than a blanket caveat.

Bounded materialization is deliberately kept as-is: the entity ceiling is a fail-closed backstop, not a paging strategy. Sources larger than the ceiling are refused with a stable diagnostic instead of silently truncated, and streaming or tiled execution stays out of this slice.

What would change the decision: a symbology contract, with the same real-Cesium evidence the promotion of the primitive adapter carried in #1026.

Deliberate non-goals for this slice

Terrain and imagery providers, glTF/models, point clouds and 3D Tiles continue through the existing scene primitive adapter, which is beta. Multi-part geometry, vertical datum transforms, styling, clustering, streaming/tiled execution, live per-feature deltas, camera/selection/filter synchronization, attribution UI, and asset authorization/caching remain future work.