import { recordTilePublication } from './tile-telemetry'; import { mergeSpatialTileResults, retainCompatibleSpatialTiles } from './tile-presentation'; import { SpatialTileScheduler, type TileDemand } from './tile-scheduler'; import { spatialStyleSignature, type SpatialDemandTarget } from './tiles'; import type { SpatialTileResult } from './worker-protocol'; interface TileScheduler { setDemand(demand: TileDemand, onresource: (result: SpatialTileResult) => void): void; clear(): void; } interface SpatialTilePresentationOptions { read: () => SpatialTileResult[]; publish: (tiles: SpatialTileResult[]) => void; scheduler?: TileScheduler; requestFrame?: (callback: FrameRequestCallback) => number; cancelFrame?: (handle: number) => void; } /** * Bridges spatial demand and component presentation state. * * The scheduler owns computation and its cache. This controller owns the * main-thread publication lifecycle: compatible-resource retention, stale * callback rejection, and merging all results received during one browser * frame into one state update. */ export class SpatialTilePresentationController { readonly #read: () => SpatialTileResult[]; readonly #publish: (tiles: SpatialTileResult[]) => void; readonly #scheduler: TileScheduler; readonly #requestFrame: (callback: FrameRequestCallback) => number; readonly #cancelFrame: (handle: number) => void; #demandSignature = ''; #demandGeneration = 0; #publishFrame: number | undefined; #pendingResults: SpatialTileResult[] = []; #complexMode: boolean | undefined; constructor(options: SpatialTilePresentationOptions) { this.#read = options.read; this.#publish = options.publish; this.#scheduler = options.scheduler ?? new SpatialTileScheduler(); // Keep browser globals behind callbacks so constructing the shell during // server rendering does not require a Canvas animation-frame environment. this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)); this.#cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle)); } setDemand(demand: TileDemand | undefined) { if (!demand) { if (this.#demandSignature === '' && this.#read().length === 0) return; this.#demandSignature = ''; this.#demandGeneration += 1; this.#discardPendingResults(); this.#scheduler.clear(); if (this.#read().length > 0) this.#publish([]); return; } const signature = tileDemandSignature(demand); if (signature === this.#demandSignature) return; this.#demandSignature = signature; const generation = ++this.#demandGeneration; this.#discardPendingResults(); const current = this.#read(); const complexMode = demand.document.complex_mode ?? false; const semanticModeChanged = this.#complexMode !== undefined && this.#complexMode !== complexMode; this.#complexMode = complexMode; const compatible = semanticModeChanged ? [] : retainCompatibleSpatialTiles(current, demand.targets, demand.viewport); if (compatible.length !== current.length) this.#publish(compatible); let acceptingSynchronousResults = true; const synchronousResults: SpatialTileResult[] = []; this.#scheduler.setDemand(demand, (result) => { if (generation !== this.#demandGeneration) return; if (!matchesTarget(result, demand.targets)) return; if (acceptingSynchronousResults) { synchronousResults.push(result); return; } this.#enqueueResult(result); }); acceptingSynchronousResults = false; if (synchronousResults.length > 0) this.#publishResults(synchronousResults); } /** * Stop serving the current tile demand without withdrawing the tiles that * belong to the scene still on screen. A whole-scene model request can take * a few frames to arrive; clearing its fallback tiles first would expose the * coarse scene in isolation and can briefly reveal discontinuity artifacts. */ suspend() { if (this.#demandSignature === '') return; this.#demandSignature = ''; this.#demandGeneration += 1; this.#discardPendingResults(); this.#scheduler.clear(); } dispose() { this.#demandSignature = ''; this.#demandGeneration += 1; this.#discardPendingResults(); this.#scheduler.clear(); } #enqueueResult(result: SpatialTileResult) { this.#pendingResults.push(result); if (this.#publishFrame !== undefined) return; this.#publishFrame = this.#requestFrame(() => { this.#publishFrame = undefined; if (this.#pendingResults.length === 0) return; const results = this.#pendingResults; this.#pendingResults = []; this.#publishResults(results); }); } #publishResults(results: SpatialTileResult[]) { const current = this.#read(); const merged = mergeSpatialTileResults(current, results); if (merged === current) return; const published = results.filter((candidate) => merged.includes(candidate)); recordTilePublication(published); this.#publish(merged); } #discardPendingResults() { this.#pendingResults = []; if (this.#publishFrame === undefined) return; this.#cancelFrame(this.#publishFrame); this.#publishFrame = undefined; } } function tileDemandSignature(demand: TileDemand) { return JSON.stringify([ demand.document.complex_mode ?? false, demand.targets, demand.viewport, demand.fallbackCoverage ]); } function matchesTarget(result: SpatialTileResult, targets: SpatialDemandTarget[]) { return targets.some( ({ expressionId, representation, modelRevision, styleSignature }) => expressionId === result.resource.expressionId && representation === result.resource.representation && modelRevision === result.resource.modelRevision && (styleSignature ?? '') === spatialStyleSignature(result.resource) ); }