import { buildScene } from './cartesium'; import { createLivePreviewDocument, needsWholeSceneRefinement, refineDelayMs, shouldPresentScene } from './rendering'; import type { ScenePresentationPlan } from './scene-demand'; import { recordScenePublication } from './scene-telemetry'; import type { GraphDocument2d, RenderScene2d } from './types'; interface ScenePresentationControllerOptions { publish: (scene: RenderScene2d, document: GraphDocument2d) => void; setBusy: (busy: boolean) => void; setError: (error: string | null) => void; build?: (document: GraphDocument2d) => Promise; setTimer?: (callback: () => void, delayMs: number) => number; clearTimer?: (handle: number) => void; } /** Owns preview/refinement request mechanics while the shell owns render policy. */ export class ScenePresentationController { readonly #publish: (scene: RenderScene2d, document: GraphDocument2d) => void; readonly #setBusy: (busy: boolean) => void; readonly #setError: (error: string | null) => void; readonly #build: (document: GraphDocument2d) => Promise; readonly #setTimer: (callback: () => void, delayMs: number) => number; readonly #clearTimer: (handle: number) => void; #requestRevision = 0; #nextSceneSequence = 0; #presentedSceneSequence = 0; #settledContentEpoch = 0; #currentContentSignature: string | undefined; #refineTimer: number | undefined; constructor(options: ScenePresentationControllerOptions) { this.#publish = options.publish; this.#setBusy = options.setBusy; this.#setError = options.setError; this.#build = options.build ?? buildScene; // Delay browser-global lookup until an effect actually schedules work so // the controller remains safe to construct during server rendering. this.#setTimer = options.setTimer ?? ((callback, delayMs) => window.setTimeout(callback, delayMs)); this.#clearTimer = options.clearTimer ?? ((handle) => window.clearTimeout(handle)); } setPlan(plan: ScenePresentationPlan | undefined) { const requestRevision = ++this.#requestRevision; this.#clearRefineTimer(); if (!plan) { this.#setBusy(false); return; } const contentChanged = plan.contentSignature !== this.#currentContentSignature; // Live model requests may complete a little behind animation/drag input so // rendering continues to make progress. Once that model settles, however, // no completion from the preceding live stream may replace the committed // value. A content epoch provides that one-way admission fence while still // allowing camera-only work for the same model to trail a gesture. if (contentChanged && !plan.interactive) this.#settledContentEpoch += 1; this.#currentContentSignature = plan.contentSignature; const settledContentEpoch = this.#settledContentEpoch; this.#setBusy(true); if (!plan.preserveCameraSceneOnSettle) { // Dispatch immediately. The worker/client queue already coalesces this // stream to one active request and the newest request waiting to start. void this.#request( createLivePreviewDocument(plan.document, plan.interactive), plan.document, requestRevision, settledContentEpoch, !plan.interactive ); } if (plan.interactive) return; if (plan.preserveCameraSceneOnSettle && plan.retainedSceneNeedsRefinement) { this.#scheduleRefinement(plan.document, requestRevision, settledContentEpoch, true); } else if (plan.preserveCameraSceneOnSettle) { // The retained scene already satisfies the settled camera. No request // will run a completion handler, so clear the progress state here. this.#setBusy(false); } } dispose() { this.#requestRevision += 1; this.#clearRefineTimer(); } async #request( requestDocument: GraphDocument2d, presentationDocument: GraphDocument2d, requestRevision: number, settledContentEpoch: number, refineAfterPreview = false ) { const sceneSequence = ++this.#nextSceneSequence; try { const scene = await this.#build(requestDocument); if (settledContentEpoch !== this.#settledContentEpoch) return; // Whole-scene geometry may safely trail a camera gesture. Sequence order, // rather than current-plan identity, prevents an older completion from // replacing a scene that was requested and presented later. if (!shouldPresentScene(sceneSequence, this.#presentedSceneSequence)) return; recordScenePublication(scene); this.#publish(scene, presentationDocument); this.#presentedSceneSequence = sceneSequence; this.#setError(null); if ( refineAfterPreview && requestRevision === this.#requestRevision && needsWholeSceneRefinement(scene) ) { this.#scheduleRefinement( presentationDocument, requestRevision, settledContentEpoch, false ); } } catch (error) { if (requestRevision !== this.#requestRevision) return; this.#setError(error instanceof Error ? error.message : String(error)); } finally { if (requestRevision === this.#requestRevision) this.#setBusy(false); } } #scheduleRefinement( document: GraphDocument2d, requestRevision: number, settledContentEpoch: number, preservingCameraScene: boolean ) { this.#refineTimer = this.#setTimer(() => { this.#refineTimer = undefined; void this.#request(document, document, requestRevision, settledContentEpoch); }, refineDelayMs(preservingCameraScene)); } #clearRefineTimer() { if (this.#refineTimer === undefined) return; this.#clearTimer(this.#refineTimer); this.#refineTimer = undefined; } }