From 111ba1626c21df41f0d9779d82ddb4c0b1427f1b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 17:24:39 +0400 Subject: [PATCH] Keep the annotation board across engine switches and draw it again on Cesium The marks belong to the page session; only their drawing belongs to the attached viewer. A switch takes the drawing off, keeps the marks, and the next Cesium attachment draws them again. Without a viewer the board refuses new annotations instead of failing in the resolver. --- src/annotations/annotationEngine.js | 76 +++++++++++++++-- src/annotations/annotationSession.test.mjs | 95 ++++++++++++++++++++++ src/annotations/drawTool.test.mjs | 6 +- src/annotations/index.js | 40 ++++++--- src/app/tools.js | 48 +++++++++-- src/main.js | 2 +- 6 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 src/annotations/annotationSession.test.mjs diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 8daf316d5..a27937373 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -130,10 +130,20 @@ export function normalizeTargetKey(target) { return head || raw; } +// While no globe draws the marks (an engine without annotation drawing, or +// between two engines) they stay on the board and this draws nothing. +const DETACHED_RENDERER = Object.freeze({ + add() {}, + update() {}, + remove() {}, + sync() {}, + destroy() {}, +}); + export function createAnnotationEngine({ placeSearch = defaultGeospatial, - viewer, - renderer, + viewer = null, + renderer = DETACHED_RENDERER, outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS, resolveTarget = resolveAnnotationTarget, }) { @@ -410,14 +420,16 @@ export function createAnnotationEngine({ * @returns {Promise<{ok, drawn, failed, ids, results}>} */ async function annotate(requests, opts = {}) { - if (destroyed) + if (destroyed || !viewer) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], - error: 'destroyed', + error: destroyed + ? 'destroyed' + : 'Annotations are drawn on the Cesium globe only.', }; const list = (Array.isArray(requests) ? requests : [requests]).map( promoteStreetHighlight, @@ -1630,7 +1642,7 @@ export function createAnnotationEngine({ /** Fly the camera to one mark; a line is framed whole, not by its start. */ function frame(id) { const anno = annotations.get(id); - if (!anno) return false; + if (!anno || !viewer) return false; if (anno.type === 'route' && anno.path?.length) { const ring = anno.path.map((p) => [p.lon, p.lat]); // Unwrap around the first vertex, as ringRange does, so a line across @@ -1794,6 +1806,60 @@ export function createAnnotationEngine({ changeListeners.add(listener); return () => changeListeners.delete(listener); }, + /** + * The globe leaves: take every mark off it and release its drawing. The + * marks stay on the board (list, count, Draw's rows) for attachRenderer. + */ + detachRenderer() { + if (destroyed || renderer === DETACHED_RENDERER) return; + // Work begun for this globe (outline lookups, a flight to a mark) ends + // with it; the marks already on the board stay. + abortPending(); + dropQueuedOutlineUpgrades(); + generation++; + const previous = renderer; + renderer = DETACHED_RENDERER; + viewer = null; + for (const anno of annotations.values()) { + anno.pendingOutline = false; + try { + previous.remove(anno); + } catch { + /* the globe is going; its drawing goes with it */ + } + } + try { + previous.destroy(); + } finally { + releaseContinuousRender('annotations'); + } + }, + /** + * Draw the board on a globe: `viewer` and the `renderer` built for it. + * Marks kept while no globe drew them appear again as they were. + */ + attachRenderer({ viewer: nextViewer, renderer: nextRenderer }) { + if (destroyed) return; + engine.detachRenderer(); + viewer = nextViewer; + renderer = nextRenderer ?? DETACHED_RENDERER; + for (const anno of annotations.values()) { + try { + const completion = renderer.add(anno); + completion?.catch?.(() => {}); + } catch { + rollbackRendererState(anno); + } + } + renderer.sync(annotations); + syncAnnotationHold(); + ensureTicking(); + governorRequestRender('annotations'); + }, + /** Whether a globe draws the marks now. */ + get attached() { + return renderer !== DETACHED_RENDERER; + }, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/annotations/annotationSession.test.mjs b/src/annotations/annotationSession.test.mjs new file mode 100644 index 000000000..afb6dc745 --- /dev/null +++ b/src/annotations/annotationSession.test.mjs @@ -0,0 +1,95 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createAnnotationEngine } from './annotationEngine.js'; + +const BUILDING = { + lon: -97.74, + lat: 30.27, + ring: [ + [-97.741, 30.269], + [-97.739, 30.269], + [-97.739, 30.271], + [-97.741, 30.269], + ], + footprintKind: 'building', + label: 'Capitol', +}; + +function recordingRenderer(log, name) { + return { + add(anno) { + log.push(`${name} add ${anno.label}`); + }, + update() {}, + remove(anno) { + log.push(`${name} remove ${anno.label}`); + }, + sync() {}, + destroy() { + log.push(`${name} destroy`); + }, + }; +} + +function withAnimationFrames(t) { + const saved = [ + globalThis.requestAnimationFrame, + globalThis.cancelAnimationFrame, + ]; + globalThis.requestAnimationFrame = () => 1; + globalThis.cancelAnimationFrame = () => {}; + t.after(() => { + [globalThis.requestAnimationFrame, globalThis.cancelAnimationFrame] = saved; + }); +} + +test('the marks survive a globe leaving and are drawn again on the next one', async (t) => { + withAnimationFrames(t); + const log = []; + const engine = createAnnotationEngine({ + viewer: {}, + renderer: recordingRenderer(log, 'cesium'), + resolveTarget: async () => ({ ...BUILDING }), + }); + const result = await engine.annotate([ + { type: 'area', target: 'Capitol', label: 'Capitol' }, + ]); + assert.equal(result.drawn, 1); + assert.equal(engine.attached, true); + + engine.detachRenderer(); + assert.equal(engine.attached, false); + assert.equal(engine.count(), 1, 'the board keeps its mark'); + assert.deepEqual(log.slice(-2), ['cesium remove Capitol', 'cesium destroy']); + // No globe draws annotations here: a new one is refused, nothing is lost. + const refused = await engine.annotate([{ type: 'pin', target: 'x' }]); + assert.equal(refused.ok, false); + assert.equal(engine.count(), 1); + assert.equal(engine.frame(engine.list()[0].id), false); + + log.length = 0; + engine.attachRenderer({ + viewer: {}, + renderer: recordingRenderer(log, 'next'), + }); + assert.deepEqual(log, ['next add Capitol']); + assert.equal(engine.attached, true); + assert.equal(engine.count(), 1); + engine.destroy(); + assert.ok(log.includes('next destroy')); +}); + +test('detaching twice and attaching over an attached globe release each drawing once', (t) => { + withAnimationFrames(t); + const log = []; + const engine = createAnnotationEngine({ + viewer: {}, + renderer: recordingRenderer(log, 'a'), + }); + engine.detachRenderer(); + engine.detachRenderer(); + engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'b') }); + engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'c') }); + assert.deepEqual(log, ['a destroy', 'b destroy']); + engine.destroy(); +}); diff --git a/src/annotations/drawTool.test.mjs b/src/annotations/drawTool.test.mjs index 67ff94cfa..309cd76f8 100644 --- a/src/annotations/drawTool.test.mjs +++ b/src/annotations/drawTool.test.mjs @@ -21,14 +21,14 @@ const tool = () => read('src/annotations/drawTool.js'); test('the application composition wires the draw tool and owns its teardown', () => { const tools = read('src/app/tools.js'); - const engineAt = tools.indexOf('const annotations = initAnnotations({'); + const engineAt = tools.indexOf('const annotations = sessionAnnotations({'); const drawAt = tools.indexOf( 'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),', ); - assert.ok(engineAt >= 0, 'initAnnotations call missing'); + assert.ok(engineAt >= 0, 'the session annotation board is missing'); assert.ok( drawAt > engineAt, - 'initDrawTool must follow initAnnotations and receive its engine', + 'initDrawTool must follow the annotation board and receive it', ); assert.match( tools, diff --git a/src/annotations/index.js b/src/annotations/index.js index 51a0f3782..ae21ca3ed 100644 --- a/src/annotations/index.js +++ b/src/annotations/index.js @@ -16,21 +16,39 @@ export function initAnnotations({ placeSearch, resolver, }) { - // World-space footprint draping; clamped marks can use the photoreal tiles. - if (tileset) { - try { - tileset.enableCollision = true; - } catch { - /* older tileset */ - } - } - const renderer = createHybridAnnotationRenderer(viewer); + const engine = createSessionAnnotations({ placeSearch, resolver }); + engine.attachScene({ viewer, tileset }); + return engine; +} + +/** + * The page's annotation board, kept across globe engine switches: the marks + * belong to the page session, their drawing to the attached globe. + * `attachScene({ viewer, tileset })` draws the board on a Cesium viewer (an + * engine without one keeps the marks undrawn); `detachScene()` releases the + * drawing and keeps the marks. + */ +export function createSessionAnnotations({ placeSearch, resolver }) { const engine = createAnnotationEngine({ - viewer, - renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget, }); + engine.attachScene = ({ viewer = null, tileset = null } = {}) => { + if (!viewer) return engine.detachRenderer(); + // World-space footprint draping; clamped marks can use the photoreal tiles. + if (tileset) { + try { + tileset.enableCollision = true; + } catch { + /* older tileset */ + } + } + engine.attachRenderer({ + viewer, + renderer: createHybridAnnotationRenderer(viewer), + }); + }; + engine.detachScene = () => engine.detachRenderer(); window.__gevAnnotations = engine; return engine; } diff --git a/src/app/tools.js b/src/app/tools.js index e9d687e81..cff2b3405 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,5 +1,5 @@ import { SceneDirector } from '../scenes/director.js'; -import { initAnnotations } from '../annotations/index.js'; +import { createSessionAnnotations } from '../annotations/index.js'; import { initDrawTool } from '../annotations/drawTool.js'; import { initGeoFileImport } from '../ui/geoFileImport.js'; import { useDisplayStore } from '../ui/stores/display.js'; @@ -18,6 +18,38 @@ import { releaseContinuousRender, } from '../renderGovernor.js'; +/** + * The annotation board: the page session's when there is one (created once, + * destroyed with the page), else this application's. + */ +export function sessionAnnotations({ + layerSession, + placeSearch, + resolver, + defer, +}) { + const create = () => createSessionAnnotations({ placeSearch, resolver }); + if (!layerSession) { + const annotations = create(); + defer(() => destroyAnnotations(annotations)); + return annotations; + } + if (!layerSession.annotations) { + const annotations = (layerSession.annotations = create()); + layerSession.lifetime.signal.addEventListener( + 'abort', + () => destroyAnnotations(annotations), + { once: true }, + ); + } + return layerSession.annotations; +} + +function destroyAnnotations(annotations) { + if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; + annotations.destroy(); +} + /** * Attach scene tools, rendering listeners and the application debug handle. * Without a viewer (the experimental Navara engine) the viewer-bound tools @@ -90,16 +122,16 @@ export function createApplicationTools({ }), ); } - const annotations = initAnnotations({ - viewer, - tileset, + // The annotation board belongs to the page session when there is one: an + // engine switch only takes its drawing off this viewer. + const annotations = sessionAnnotations({ + layerSession, placeSearch, resolver: operations.annotationResolver, + defer, }); - defer(() => { - if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; - annotations.destroy(); - }); + annotations.attachScene({ viewer, tileset }); + defer(() => annotations.detachScene()); const displayStore = useDisplayStore(applicationPinia()); // The Display panel's controls answer through the store; returns the // disconnect. diff --git a/src/main.js b/src/main.js index dfadaa0ff..224d2dd69 100644 --- a/src/main.js +++ b/src/main.js @@ -45,7 +45,7 @@ const session = createSessionDiagnostics(() => { placeSearch: style?.placeSearch, dataManager: components.data?.dataManager, voice: layerSession.voice?.commands ?? components.tools?.voiceCommands, - annotations: components.tools?.annotations, + annotations: layerSession.annotations ?? components.tools?.annotations, geoImport: window.__gevGeoImport, drawTool: window.__gevDrawTool, };