diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 016563f0b..3c63022c3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2629,6 +2629,7 @@ "src/annotations/index.js", "src/annotations/resolver.js", "src/annotations/screenAnnotationRenderer.js", + "src/annotations/screenScene.js", "src/annotations/worldAnnotationRenderer.js", "src/app/catalog.js", "src/app/constructCatalog.js", @@ -3599,6 +3600,7 @@ "src/app/engineChoice.js", "src/app/navaraMode.js", "src/app/navaraTools.js", + "src/annotations/navaraAnnotationRenderer.js", "src/data/render/cesiumProjection.js", "src/data/render/cesiumConstants.js", "src/data/render/cesiumTrailRenderer.js", diff --git a/scripts/qa-navara-annotations.mjs b/scripts/qa-navara-annotations.mjs new file mode 100644 index 000000000..239973a89 --- /dev/null +++ b/scripts/qa-navara-annotations.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node +// Annotations on both engines, under the strict network policy: the same +// board (a hand-drawn area, a pin in view and a pin on the far side of the +// globe) is drawn on Navara, survives a switch to Cesium and back, and on +// each engine the draped area paints amber pixels on the canvas, the near +// pin's callout shows and the far pin is culled at the horizon. +// +// node scripts/qa-navara-annotations.mjs http://127.0.0.1:/ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import sharp from 'sharp'; +import { createStrict, localPreview } from './qa-strict-harness.mjs'; + +const LAT = 37.7749; +const LON = -122.4194; +const base = localPreview(process.argv[2]); +base.search = '?engine=navara&welcome=0'; +base.hash = `v=2&lat=${LAT}&lon=${LON}&alt=7000&heading=0&pitch=-90&roll=0&map=osm&l=`; +const strict = createStrict({ origin: base.origin, layers: ['annotations'] }); +const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-annotations'; +fs.mkdirSync(SHOTS, { recursive: true }); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// The area: a box around the view centre, clear of the panels. +const RING = [ + [LON - 0.012, LAT - 0.009], + [LON + 0.012, LAT - 0.009], + [LON + 0.012, LAT + 0.009], + [LON - 0.012, LAT + 0.009], +]; + +await strict.run( + { + args: [ + '--use-angle=metal', + '--enable-gpu', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], + }, + async (browser) => { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 860 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(base.href); + const ready = async (kind) => { + await page.waitForFunction( + (k) => + window.__godsEyeView?.engine?.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 120000 }, + kind, + ); + await page.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + assert.equal( + await page.evaluate(() => + Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), + ), + false, + 'keyless preview required', + ); + + /** Amber pixels of the globe canvas alone (the SVG board hidden). */ + const canvasAmber = async () => { + await page.addStyleTag({ + content: '.gev-screen-whiteboard{visibility:hidden!important}', + }); + await page.evaluate(() => window.__godsEyeView.requestRender?.('qa')); + await wait(700); + const { data, info } = await sharp( + await page.screenshot({ + clip: { x: 340, y: 180, width: 600, height: 500 }, + }), + ) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + await page.evaluate(() => + document + .querySelectorAll('style') + .forEach( + (style) => + style.textContent.includes('gev-screen-whiteboard{visibility') && + style.remove(), + ), + ); + let amber = 0; + for (let i = 0; i < data.length; i += info.channels) { + const [r, g, b] = [data[i], data[i + 1], data[i + 2]]; + if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15) + amber++; + } + return amber; + }; + /** The callout texts the page board shows, and those it hides. */ + const board = () => + page.evaluate(() => { + const out = { shown: [], hidden: [] }; + for (const group of document.querySelectorAll( + '.gev-screen-whiteboard-svg g.gev-anno', + )) { + const text = group.querySelector('.gev-anno-text')?.textContent; + if (!text) continue; + const hidden = + group.style.display === 'none' || + getComputedStyle(group).display === 'none'; + (hidden ? out.hidden : out.shown).push(text); + } + out.shown.sort(); + out.hidden.sort(); + return out; + }); + + let round = 0; + const drawn = await page.evaluate( + async (ring, lat, lon) => { + const annotations = window.__gevAnnotations; + window.qaBoard = annotations; + const result = await annotations.annotate( + [ + { + type: 'area', + manual: true, + ring, + label: 'QA Area', + color: 'amber', + }, + { + type: 'pin', + manual: true, + latitude: lat + 0.004, + longitude: lon + 0.006, + label: 'QA Near', + color: 'green', + }, + { + type: 'pin', + manual: true, + latitude: -lat, + longitude: lon + 180, + label: 'QA Far', + color: 'cyan', + }, + ], + { persist: true, flyTo: false }, + ); + return { + drawn: result.drawn, + attached: annotations.attached, + handle: window.__godsEyeView.annotations === annotations, + }; + }, + RING, + LAT, + LON, + ); + await strict.check( + page, + 'annotations', + 'the board draws three marks on the attached engine and is the debug handle', + drawn.drawn === 3 && drawn.attached && drawn.handle, + drawn, + ); + + for (const kind of ['navara', 'cesium', 'navara']) { + if ( + (await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind + ) { + await strict.pick(page, kind); + await ready(kind); + } + await wait(2500); + const state = await page.evaluate(() => ({ + same: window.__gevAnnotations === window.qaBoard, + count: window.qaBoard.count(), + attached: window.qaBoard.attached, + handle: window.__godsEyeView.annotations === window.qaBoard, + })); + await strict.check( + page, + 'annotations', + 'the same session board keeps its three marks and draws on this engine', + state.same && state.count === 3 && state.attached && state.handle, + state, + ); + const seen = await board(); + console.log(kind, 'board', JSON.stringify(seen)); + await strict.check( + page, + 'annotations', + 'the near callouts show and the far pin is culled at the horizon', + seen.shown.includes('QA Near') && + seen.shown.includes('QA Area') && + !seen.shown.includes('QA Far'), + seen, + ); + await page.screenshot({ + path: `${SHOTS}/annotations-${kind}-${round}.png`, + }); + const amber = await canvasAmber(); + // The same view without the marks (hidden from the list). + const setHidden = (hidden) => + page.evaluate((h) => { + for (const mark of window.qaBoard.list()) + window.qaBoard.setHidden(mark.id, h); + }, hidden); + await setHidden(true); + const bare = await canvasAmber(); + await setHidden(false); + console.log(kind, 'amber pixels', { bare, amber }); + await strict.check( + page, + 'annotations', + 'the draped area paints amber on the globe canvas', + amber - bare > 400, + { bare, amber }, + ); + round++; + } + + // Fly-to a mark goes through the engine's camera on Navara. + const flight = await page.evaluate(async () => { + const { engine } = window.__godsEyeView; + const before = engine.camera.height(); + const area = window.qaBoard.list().find((m) => m.label === 'QA Area'); + const ok = window.qaBoard.frame(area.id); + await new Promise((r) => setTimeout(r, 2600)); + return { ok, before, after: engine.camera.height() }; + }); + await strict.check( + page, + 'annotations', + 'framing a mark flies the Navara camera to it', + flight.ok && Math.abs(flight.after - flight.before) > 100, + flight, + ); + // Hidden from the list: the board and the canvas let it go. + const hidden = await page.evaluate(async () => { + for (const mark of window.qaBoard.list()) + window.qaBoard.setHidden(mark.id, true); + await new Promise((r) => setTimeout(r, 600)); + return window.qaBoard.list().every((m) => m.hidden); + }); + const afterHide = await board(); + await strict.check( + page, + 'annotations', + 'hidden marks leave the board', + hidden && afterHide.shown.length === 0, + afterHide, + ); + await page.evaluate(() => window.qaBoard.clear()); + assert.deepEqual(errors, []); + console.log( + 'PASS annotations drawn, culled and kept across engine switches', + ); + }, +); diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index a27937373..2be5562f1 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -2,6 +2,7 @@ import { isUnavailableCapability } from '../sources/capability.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; +import { isGlobeEngine } from '../globe/contract.js'; import { greatCircleMeters } from '../geoDistance.js'; import { holdContinuousRender, @@ -1713,12 +1714,9 @@ export function createAnnotationEngine({ geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0), ); const sphere = Cesium.BoundingSphere.fromPoints(cart); - viewer.camera.flyToBoundingSphere(sphere, { - offset: new Cesium.HeadingPitchRange( - viewer.camera.heading, - geo.math.toRadians(-35), - Math.max(900, sphere.radius * 2.6), - ), + flyToSphere(sphere.center, sphere.radius, { + pitch: geo.math.toRadians(-35), + range: Math.max(900, sphere.radius * 2.6), duration: 1.6, }); } catch { @@ -1730,8 +1728,9 @@ export function createAnnotationEngine({ * globe. On any API hiccup, report visible — bad data must never move the camera. */ function isPointOnScreen(p) { try { - const camera = viewer.camera; const pos = geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0); + if (isGlobeEngine(viewer)) return engineSeesPoint(viewer, pos); + const camera = viewer.camera; const cv = camera.frustum.computeCullingVolume( camera.position, camera.direction, @@ -1770,29 +1769,46 @@ export function createAnnotationEngine({ : anno.ring, ) : viewportRange(anno.viewport) || 600; - viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - geo.cartesian3.fromDegrees( - target.lon, - target.lat, - anno.anchor.height || 0, - ), - range, + flyToSphere( + geo.cartesian3.fromDegrees( + target.lon, + target.lat, + anno.anchor.height || 0, ), - { - offset: new Cesium.HeadingPitchRange( - viewer.camera.heading, - geo.math.toRadians(-35), - range * 2.4, - ), - duration: 1.8, - }, + range, + { pitch: geo.math.toRadians(-35), range: range * 2.4, duration: 1.8 }, ); } catch { /* framing is best-effort */ } } + /** + * Fly to a sphere keeping the camera's heading: Cesium's + * flyToBoundingSphere on a viewer, the contract's flyToSphere on a globe + * engine (the same offset semantics). + */ + function flyToSphere(center, radius, { pitch, range, duration }) { + if (isGlobeEngine(viewer)) { + viewer.camera.flyToSphere( + { center, radius }, + { heading: viewer.camera.pose().heading, pitch, range, duration }, + ); + return; + } + viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(center, radius), + { + offset: new Cesium.HeadingPitchRange( + viewer.camera.heading, + pitch, + range, + ), + duration, + }, + ); + } + const engine = { annotate, clear, @@ -2045,7 +2061,7 @@ export function createAnnotationEngine({ }) { if (destroyed) return; try { - viewer.camera.flyTo({ + viewer?.camera.flyTo({ destination: geo.cartesian3.fromDegrees(lon, lat, height), orientation: { heading: geo.math.toRadians(heading), @@ -2408,3 +2424,16 @@ function appendDistance(baseLabel, distM) { if (!dist) return baseLabel; return baseLabel ? `${baseLabel} — ${dist}` : dist; } + +/** + * Whether a globe engine shows a world point: in front of the camera, inside + * the canvas and on the near side of the globe (what the Cesium path's + * culling volume and ellipsoid occluder answer). + */ +function engineSeesPoint(engine, position) { + if (!engine.projector.horizonVisible(position)) return false; + const win = engine.projector.worldToWindow(position); + if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return false; + const { width, height } = engine.host.size(); + return win.x >= 0 && win.x <= width && win.y >= 0 && win.y <= height; +} diff --git a/src/annotations/hybridAnnotationRenderer.js b/src/annotations/hybridAnnotationRenderer.js index fe5995738..08650d541 100644 --- a/src/annotations/hybridAnnotationRenderer.js +++ b/src/annotations/hybridAnnotationRenderer.js @@ -18,14 +18,19 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js'; * * The hybrid surface honors add/update/remove/sync/destroy; update coordinates the * one route change that spans both sub-renderers. `screen` passes options to the - * screen renderer (its store, in tests). + * screen renderer (its store, in tests). On a globe without a Cesium viewer + * (Navara), `world` is that globe's world renderer and `screenScene` the + * screen scene the board is drawn over (src/annotations/screenScene.js). */ export function createHybridAnnotationRenderer( viewer, - { screen: screenOptions } = {}, + { screen: screenOptions, world: worldRenderer, screenScene } = {}, ) { - const world = createWorldAnnotationRenderer(viewer); - const screen = createScreenAnnotationRenderer(viewer, screenOptions); + const world = worldRenderer ?? createWorldAnnotationRenderer(viewer); + const screen = createScreenAnnotationRenderer( + screenScene ?? viewer, + screenOptions, + ); // anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state. const routed = new Map(); diff --git a/src/annotations/index.js b/src/annotations/index.js index ae21ca3ed..bc5e0dad9 100644 --- a/src/annotations/index.js +++ b/src/annotations/index.js @@ -24,16 +24,25 @@ export function initAnnotations({ /** * 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. + * `attachScene({ viewer, tileset })` draws the board on a Cesium viewer; + * `attachScene({ engine, renderer })` on a globe engine without a viewer + * (Navara: src/annotations/navaraAnnotationRenderer.js, built by the caller + * so this module never loads it); with neither the marks stay undrawn. + * `detachScene()` releases the drawing and keeps the marks. */ export function createSessionAnnotations({ placeSearch, resolver }) { const engine = createAnnotationEngine({ placeSearch, resolveTarget: resolver?.resolveAnnotationTarget, }); - engine.attachScene = ({ viewer = null, tileset = null } = {}) => { + engine.attachScene = ({ + viewer = null, + tileset = null, + engine: globe = null, + renderer = null, + } = {}) => { + if (!viewer && globe && renderer) + return engine.attachRenderer({ viewer: globe, renderer }); if (!viewer) return engine.detachRenderer(); // World-space footprint draping; clamped marks can use the photoreal tiles. if (tileset) { diff --git a/src/annotations/navaraAnnotationRenderer.js b/src/annotations/navaraAnnotationRenderer.js new file mode 100644 index 000000000..1c89bed05 --- /dev/null +++ b/src/annotations/navaraAnnotationRenderer.js @@ -0,0 +1,254 @@ +// The annotation board on the Navara engine: the hybrid renderer's routing +// (hybridAnnotationRenderer.js) with a Navara world renderer for the marks +// draped on the ground — area footprints and routes as clamped meshes — and +// the shared screen-space SVG board (callouts, reticles, rings, arrows) over +// the engine's projector (screenScene.js). No Cesium, no three. + +import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js'; +import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js'; +import { screenSceneFromEngine } from './screenScene.js'; + +// The world renderer's palette (worldAnnotationRenderer.js). +const PALETTE = { + primary: 0x8be9ff, + amber: 0xffb547, + cyan: 0x39d0ff, + green: 0x5dff9f, + red: 0xff6b6b, +}; + +/** + * A map colour (0xRRGGBB) on `view`: the planet's photographic exposure is + * compensated before its tone-mapping pass, as for the story overlays + * (src/layers/shared/render/navaraStory.js), so the palette stays readable. + */ +function displayColor({ view, NavaraColor }, hex) { + const c = new NavaraColor().setHex(hex); + const exposure = Math.max(1, view.toneMappingExposure ?? 1); + const [r, g, b] = c.toArray(); + return c.setRGBLinear(r / exposure, g / exposure, b / exposure); +} + +/** 0.6 .. 1.0 at ~0.8 Hz, the Cesium marks' pulse. */ +export function pulseFactor(now = performance.now()) { + return 0.8 + 0.2 * Math.sin(now * 0.005); +} + +const closedRing = (ring) => { + const [first] = ring; + const last = ring[ring.length - 1]; + return first[0] === last[0] && first[1] === last[1] + ? ring.map(([lon, lat]) => [lon, lat]) + : [...ring.map(([lon, lat]) => [lon, lat]), [first[0], first[1]]]; +}; + +/** + * The draped parts of one mark, as plain data: `{ kind: 'polygon' | + * 'polyline', geojson, alpha, pulse, width? }`. Areas (a building, a + * district, a multi-part outline with holes) are a fill and an outline per + * ring, as the Cesium world renderer draws them; a route is one line. + * Anything else is screen-space only (none). + */ +export function drapedParts(anno) { + if (anno.ring?.length >= 3) { + const building = anno.footprintKind === 'building'; + const parts = + Array.isArray(anno.polygons) && anno.polygons.length + ? anno.polygons + : [[anno.ring]]; + const out = []; + for (const [outer, ...holes] of parts) { + if (!Array.isArray(outer) || outer.length < 3) continue; + const rings = [outer, ...holes.filter((hole) => hole?.length >= 3)]; + out.push({ + kind: 'polygon', + geojson: { type: 'Polygon', coordinates: rings.map(closedRing) }, + alpha: building ? 0.45 : anno.synthesized ? 0.1 : 0.2, + pulse: true, + }); + for (const ring of rings) + out.push({ + kind: 'polyline', + geojson: { type: 'LineString', coordinates: closedRing(ring) }, + width: building ? 3 : 6, + // Approximate outlines are fainter (Cesium dashes them). + alpha: building ? 0.7 : anno.synthesized ? 0.6 : 1, + pulse: false, + }); + } + return out; + } + if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length) { + const coordinates = anno.path.map((p) => [p.lon, p.lat]); + return coordinates.length >= 2 + ? [ + { + kind: 'polyline', + geojson: { type: 'LineString', coordinates }, + width: 9, + alpha: 1, + pulse: false, + }, + ] + : []; + } + return []; +} + +/** + * The world half on Navara: one clamped mesh per draped part, its opacity + * following the mark's fade and pulse each frame and its visibility the + * mark's `hidden`. Renderer contract: add / remove / sync / destroy. + * @param {import('../globe/contract.js').GlobeEngine} engine + * @param {{ view: object, NavaraColor: Function }} views The Navara view and + * its colour class (src/app/navaraMode.js). + */ +export function createNavaraWorldAnnotationRenderer( + engine, + { view, NavaraColor }, + { now = () => performance.now() } = {}, +) { + // mark -> its meshes: { handle, kind, alpha, pulse, opacity, visible } + const drawn = new Map(); + const credited = new Set(); + let destroyed = false; + + const targetOpacity = (anno, part, at) => + Math.max( + 0, + Math.min( + 1, + (anno.alpha ?? 1) * part.alpha * (part.pulse ? pulseFactor(at) : 1), + ), + ); + + function add(anno) { + if (destroyed) return; + const parts = drapedParts(anno); + // Published before any mesh lands, so a mid-add failure leaves the meshes + // that did land reachable by remove(). + const meshes = []; + drawn.set(anno, meshes); + const color = displayColor( + { view, NavaraColor }, + PALETTE[anno.color] ?? PALETTE.primary, + ); + const at = now(); + for (const part of parts) { + const opacity = targetOpacity(anno, part, at); + const visible = !anno.hidden; + const handle = view.addMesh({ + [part.kind]: { + geojson: part.geojson, + clampToGround: true, + color, + transparent: true, + opacity, + ...(part.kind === 'polyline' ? { width: part.width } : {}), + }, + visible, + lit: false, + pickable: false, + }); + meshes.push({ ...part, handle, opacity, visible }); + } + if ( + anno.type === 'route' || + (meshes.length && + anno.ring?.length >= 3 && + !anno.synthesized && + (anno.outlineCredit === 'tiles' || anno.outlineCredit === 'osm')) + ) { + credited.add(anno); + showOsmCredit( + engine, + anno, + anno.type === 'route' + ? undefined + : { openMapTiles: anno.outlineCredit === 'tiles' }, + ); + } + engine.render.request('annotations'); + } + + function remove(anno) { + const meshes = drawn.get(anno); + if (credited.delete(anno)) hideOsmCredit(engine, anno); + if (!meshes) return; + drawn.delete(anno); + for (const mesh of meshes) { + try { + mesh.handle.delete(); + } catch { + /* the view went first */ + } + } + engine.render.request('annotations'); + } + + /** Follow each mark's fade, pulse and visibility. */ + function sync() { + if (destroyed) return; + const at = now(); + let changed = false; + for (const [anno, meshes] of drawn) { + for (const mesh of meshes) { + const opacity = targetOpacity(anno, mesh, at); + const visible = !anno.hidden && opacity > 0; + if ( + visible === mesh.visible && + Math.abs(opacity - mesh.opacity) < 0.004 + ) + continue; + mesh.handle.update({ + [mesh.kind]: { opacity }, + visible, + }); + mesh.opacity = opacity; + mesh.visible = visible; + changed = true; + } + } + if (changed) engine.render.request('annotations'); + } + + // The pulse animates every frame the engine draws (the board holds + // continuous rendering while a mark is visible). + const offFrame = engine.frames.preRender.addEventListener(sync); + + function destroy() { + if (destroyed) return; + offFrame(); + for (const anno of [...drawn.keys()]) remove(anno); + for (const anno of credited) hideOsmCredit(engine, anno); + credited.clear(); + destroyed = true; + } + + return { + add, + remove, + sync, + destroy, + /** The meshes drawn now, for diagnostics and the QA handle. */ + diagnostics: () => ({ + marks: drawn.size, + meshes: [...drawn.values()].reduce((n, meshes) => n + meshes.length, 0), + }), + }; +} + +/** + * The annotation board's renderer on a Navara scene: draped areas and routes + * on the engine, every other mark and every caption on the shared SVG board. + * `screen` passes options to the screen renderer (its store, in tests). + */ +export function createNavaraAnnotationRenderer(engine, views, { screen } = {}) { + const world = createNavaraWorldAnnotationRenderer(engine, views); + const renderer = createHybridAnnotationRenderer(null, { + world, + screenScene: screenSceneFromEngine(engine), + screen, + }); + return { ...renderer, diagnostics: world.diagnostics }; +} diff --git a/src/annotations/navaraAnnotationRenderer.test.mjs b/src/annotations/navaraAnnotationRenderer.test.mjs new file mode 100644 index 000000000..fe9eefbb3 --- /dev/null +++ b/src/annotations/navaraAnnotationRenderer.test.mjs @@ -0,0 +1,267 @@ +import '../testSupport/registerDom.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; +import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createSessionAnnotations } from './index.js'; +import { + createNavaraAnnotationRenderer, + createNavaraWorldAnnotationRenderer, + drapedParts, +} from './navaraAnnotationRenderer.js'; +import { screenSceneFromEngine } from './screenScene.js'; + +for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) { + if (type) + type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 }); +} +const settle = async () => { + for (let flush = 0; flush < 4; flush += 1) await nextTick(); +}; + +/** A Navara view that records its meshes, and the colour class. */ +function fakeViews() { + const meshes = []; + class NavaraColor { + setHex(hex) { + this.hex = hex; + return this; + } + toArray() { + return [1, 1, 1]; + } + setRGBLinear() { + return this; + } + } + const view = { + toneMappingExposure: 1, + addMesh(config) { + const mesh = { config, updates: [], deleted: false }; + meshes.push(mesh); + return { + update: (next) => mesh.updates.push(next), + delete: () => { + mesh.deleted = true; + }, + }; + }, + }; + return { views: { view, NavaraColor }, meshes }; +} + +/** The page's board view, as in screenAnnotationRenderer.test.mjs. */ +function board(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const store = useScreenAnnotationsStore(pinia); + const app = createApp(ScreenAnnotations).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + const original = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = () => 0; + t.after(() => { + globalThis.requestAnimationFrame = original; + app.unmount(); + }); + const groups = () => [ + ...(document.querySelectorAll('.gev-screen-whiteboard-svg g.gev-anno') ?? + []), + ]; + return { store, groups }; +} + +test('areas drape a fill and an outline per ring, routes a line, other marks nothing', () => { + const ring = [ + [0, 0], + [1, 0], + [1, 1], + ]; + const hole = [ + [0.2, 0.2], + [0.4, 0.2], + [0.4, 0.4], + ]; + const area = drapedParts({ + type: 'area', + ring, + polygons: [[ring, hole]], + }); + assert.deepEqual( + area.map(({ kind, alpha, pulse }) => [kind, alpha, pulse]), + [ + ['polygon', 0.2, true], + ['polyline', 1, false], + ['polyline', 1, false], + ], + ); + // Rings close for the polygon's GeoJSON, holes included. + assert.equal(area[0].geojson.coordinates.length, 2); + assert.deepEqual(area[0].geojson.coordinates[0].at(-1), [0, 0]); + assert.equal(area[1].width, 6); + const building = drapedParts({ + type: 'highlight', + ring, + footprintKind: 'building', + }); + assert.equal(building[0].alpha, 0.45); + assert.equal(building[1].width, 3); + assert.equal( + drapedParts({ type: 'area', ring, synthesized: true })[0].alpha, + 0.1, + ); + const route = drapedParts({ + type: 'route', + path: [ + { lon: 0, lat: 0 }, + { lon: 1, lat: 1 }, + ], + }); + assert.deepEqual( + route.map((p) => [p.kind, p.width]), + [['polyline', 9]], + ); + assert.deepEqual( + drapedParts({ type: 'pin', anchor: { lon: 0, lat: 0 } }), + [], + ); +}); + +test('the world half follows fade, pulse and hide, and lets go of every mesh', () => { + const engine = createFakeEngine({ kind: 'navara' }); + const { views, meshes } = fakeViews(); + let now = 0; + const world = createNavaraWorldAnnotationRenderer(engine, views, { + now: () => now, + }); + const anno = { + id: 'a', + type: 'area', + color: 'amber', + alpha: 0, + ring: [ + [0, 0], + [1, 0], + [1, 1], + ], + }; + world.add(anno); + assert.equal(meshes.length, 2); + const [fill, outline] = meshes; + assert.equal(fill.config.polygon.clampToGround, true); + assert.equal(fill.config.polygon.color.hex, 0xffb547); + assert.equal(fill.config.lit, false); + assert.equal(fill.config.pickable, false); + assert.equal(outline.config.polyline.width, 6); + // Faded in: the engine's frame moves the opacity to the mark's alpha. + anno.alpha = 1; + engine.fake.frame(); + assert.equal(outline.updates.at(-1).polyline.opacity, 1); + assert.ok(fill.updates.at(-1).polygon.opacity > 0.1); + // Pulsing: a later frame changes the fill only. + const outlineUpdates = outline.updates.length; + now = 300; + engine.fake.frame(); + assert.equal(outline.updates.length, outlineUpdates); + assert.notEqual( + fill.updates.at(-1).polygon.opacity, + fill.updates.at(-2).polygon.opacity, + ); + // Hidden from Draw's list: every mesh goes invisible. + anno.hidden = true; + world.sync(); + assert.equal(outline.updates.at(-1).visible, false); + world.remove(anno); + assert.ok(meshes.every((mesh) => mesh.deleted)); + assert.equal(world.diagnostics().meshes, 0); + world.destroy(); + const before = fill.updates.length; + engine.fake.frame(); + assert.equal(fill.updates.length, before, 'no frame listener after destroy'); +}); + +test('the screen scene reads the engine camera, projector, horizon and follow', () => { + const engine = createFakeEngine({ kind: 'navara', altitude: 1e6 }); + const scene = screenSceneFromEngine(engine); + scene.beginFrame(); + const near = { x: 6378137, y: 0, z: 0 }; + const far = { x: -6378137, y: 0, z: 0 }; + assert.equal(scene.horizonVisible(near), true); + assert.equal(scene.horizonVisible(far), false, 'behind the globe'); + const win = scene.worldToWindow(near, {}); + assert.ok(Math.abs(win.x - 512) < 1 && Math.abs(win.y - 384) < 1); + assert.ok(Math.abs(scene.cameraHeight() - 1e6) < 1); + assert.deepEqual(scene.size(), { width: 1024, height: 768 }); + assert.equal(scene.trackedSubject(), null); +}); + +test('marks on the Navara board are drawn, horizon-culled and survive an engine switch', async (t) => { + const { store, groups } = board(t); + const engine = createFakeEngine({ kind: 'navara', altitude: 2e6 }); + const { views, meshes } = fakeViews(); + const annotations = createSessionAnnotations({}); + t.after(() => annotations.destroy()); + const attach = () => + annotations.attachScene({ + engine, + renderer: createNavaraAnnotationRenderer(engine, views, { + screen: { store, activeTrackedReadoutId: () => null }, + }), + }); + attach(); + assert.equal(annotations.attached, true); + const result = await annotations.annotate( + [ + { type: 'pin', manual: true, latitude: 0, longitude: 0, label: 'Near' }, + { type: 'pin', manual: true, latitude: 0, longitude: 180, label: 'Far' }, + { + type: 'area', + manual: true, + label: 'Box', + ring: [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + }, + ], + { persist: true, flyTo: false }, + ); + assert.equal(result.drawn, 3, JSON.stringify(result)); + engine.fake.frame(); + await settle(); + const shown = () => + groups() + .filter((group) => group.style.display !== 'none') + .map((group) => group.querySelector('.gev-anno-text')?.textContent) + .filter(Boolean) + .sort(); + // The far pin is behind the globe; the area's caption sits on the board. + assert.deepEqual(shown(), ['Box', 'Near']); + const live = () => meshes.filter((mesh) => !mesh.deleted); + assert.deepEqual( + live().map((mesh) => Object.keys(mesh.config)[0]), + ['polygon', 'polyline'], + ); + // Fly-to on Navara goes through the contract's flyToSphere. + const [box] = annotations.list().filter((mark) => mark.label === 'Box'); + assert.equal(annotations.frame(box.id), true); + assert.equal(engine.fake.callsOf('camera.flyToSphere').length, 1); + // A switch takes the drawing off this engine and keeps the marks. + annotations.detachScene(); + assert.equal(live().length, 0); + assert.equal(annotations.count(), 3); + attach(); + engine.fake.frame(); + await settle(); + assert.equal(live().length, 2); + assert.deepEqual(shown(), ['Box', 'Near']); + // Engine-side camera assist: frame() of a route uses the same flight. + engine.fake.resetCalls(); + annotations.clear(); + engine.fake.frame(); + assert.equal(live().length, 0); +}); diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 00a2586eb..0cf65345f 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -2397,6 +2397,7 @@ export function createAnnotationResolver({ * the tiles for that spot aren't loaded we fall back to the ellipsoid (0). */ function sampleGroundHeight(viewer, lon, lat) { + if (isGlobeEngine(viewer)) return engineGroundHeight(viewer, lon, lat); const scene = viewer?.scene; if (!scene) return 0; try { @@ -2421,6 +2422,29 @@ export function createAnnotationResolver({ return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0; } + /** sampleGroundHeight on a globe engine: the rendered surface, else terrain. */ + function engineGroundHeight(engine, lon, lat) { + try { + if (engine.surface.clampToHeightSupported) { + const clamped = engine.surface.clampToHeight( + geo.cartesian3.fromDegrees(lon, lat, 0), + ); + const h = clamped && geo.cartographic.fromCartesian(clamped)?.height; + if (Number.isFinite(h)) return h; + } + } catch { + /* tiles not ready — fall through */ + } + try { + const h = engine.surface.terrainHeightSupported + ? engine.surface.terrainHeight(geo.cartographic.fromDegrees(lon, lat)) + : undefined; + return Number.isFinite(h) && h > 0 ? h : 0; + } catch { + return 0; + } + } + function shortLabel(formattedAddress) { if (!formattedAddress) return null; return String(formattedAddress).split(',')[0].trim() || null; diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index 0509a47a5..864a0aced 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -8,6 +8,7 @@ import { t } from '../i18n/index.js'; import { nextTick } from 'vue'; import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; import { applicationPinia } from '../ui/vueRuntime.js'; +import { isScreenScene } from './screenScene.js'; /** A label whose detailed outline could not be resolved. */ function outlineUnavailableLabel(label) { @@ -67,7 +68,7 @@ function markScale(h) { } export function createScreenAnnotationRenderer( - viewer, + target, { overlayPaintRect = getOverlayPaintRect, activeTrackedReadoutId = getActiveTrackedReadoutId, @@ -78,11 +79,9 @@ export function createScreenAnnotationRenderer( const arrowMarkers = new Map(); let viewBox = null; - const scene = viewer.scene; - const occluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - scene.camera.positionWC, - ); + // What the board is drawn over: a Cesium viewer or an engine's screen + // scene (src/annotations/screenScene.js). + const view = isScreenScene(target) ? target : screenSceneFromViewer(target); const records = new Map(); // anno.id -> { key, anno, group, parts } const leaving = new Map(); // key -> a removed mark's record while it fades out const byKey = new Map(); // key -> record, live or fading @@ -121,7 +120,7 @@ export function createScreenAnnotationRenderer( // Re-project every annotation each rendered frame so marks stay glued to the // world. postRender runs after the camera/tiles update, so positions are exact. const onPostRender = () => projectAll(); - scene.postRender.addEventListener(onPostRender); + const offPostRender = view.onFrame(onPostRender); function color(anno) { return PALETTE[anno.color] || PALETTE.primary; @@ -141,11 +140,8 @@ export function createScreenAnnotationRenderer( return cached.h; } try { - if ( - scene.clampToHeightSupported && - typeof scene.clampToHeight === 'function' - ) { - const c = scene.clampToHeight( + if (view.clampToHeightSupported()) { + const c = view.clampToHeight( Cesium.Cartesian3.fromDegrees(lon, lat, 0), ); if (c) { @@ -369,20 +365,15 @@ export function createScreenAnnotationRenderer( groundHeight(lon, lat), ); // Reject points behind the camera (they produce wrapped/extreme coords). - Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir); - if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) + Cesium.Cartesian3.subtract(world, view.cameraPosition(), scratchDir); + if (Cesium.Cartesian3.dot(scratchDir, view.cameraDirection()) <= 0) return null; // Reject points beyond the globe horizon. - if (!occluder.isPointVisible(world)) return null; - const win = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - world, - scratch, - ); + if (!view.horizonVisible(world)) return null; + const win = view.worldToWindow(world, scratch); if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null; // Reject absurd off-screen projections (anchor not in view). - const w = scene.canvas.clientWidth || scene.canvas.width; - const h = scene.canvas.clientHeight || scene.canvas.height; + const { width: w, height: h } = view.size(); if ( !allowOffscreen && (win.x < -w || win.x > 2 * w || win.y < -h || win.y > 2 * h) @@ -422,31 +413,22 @@ export function createScreenAnnotationRenderer( let top = painted?.y; let bottom = painted ? painted.y + painted.h : undefined; - const ent = viewer.trackedEntity; - const now = Cesium.JulianDate.now(); - const world = - typeof ent?.gevDisplayPosition === 'function' - ? ent.gevDisplayPosition() - : null; - const win = world - ? Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - world, - _scratchTrackedWin, - ) - : null; + // The followed subject's own graphic, where the globe draws one. + const subject = view.trackedSubject(); + const world = subject?.world ?? null; + const win = world ? view.worldToWindow(world, _scratchTrackedWin) : null; // Billboard box — centered on the anchor, magnified by scaleByDistance. - const bb = ent?.billboard; + const bb = subject?.billboard; if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) { - const baseW = bb.width?.getValue?.(now) ?? 28; - const baseH = bb.height?.getValue?.(now) ?? 28; + const baseW = bb.width ?? 28; + const baseH = bb.height ?? 28; let scale = 1; - const sbd = bb.scaleByDistance?.getValue?.(now); + const sbd = bb.scaleByDistance; if (sbd) scale = nearFarValue( sbd, - Cesium.Cartesian3.distance(scene.camera.positionWC, world), + Cesium.Cartesian3.distance(view.cameraPosition(), world), ); const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2; @@ -477,14 +459,11 @@ export function createScreenAnnotationRenderer( } projGen += 1; // new frame: entries touched below are "hot" and survive trimming const trackedRect = trackedEntityRect(); - occluder.cameraPosition = scene.camera.positionWC; - const h = scene.canvas.clientHeight || scene.canvas.height; - const w = scene.canvas.clientWidth || scene.canvas.width; + view.beginFrame(); + const { width: w, height: h } = view.size(); viewBox = `0 0 ${w} ${h}`; // Shrink point-marker geometry as the camera pulls back (declutter the blob). - const mScale = markScale( - viewer.camera.positionCartographic?.height ?? 1000, - ); + const mScale = markScale(view.cameraHeight() ?? 1000); for (const rec of records.values()) { const { anno, group, parts } = rec; @@ -821,7 +800,7 @@ export function createScreenAnnotationRenderer( function destroy() { try { - scene.postRender.removeEventListener(onPostRender); + offPostRender(); } catch { /* torn down */ } @@ -1026,3 +1005,58 @@ function placeCalloutsAboveVoicePreview(callouts, width, height) { function ensureArrowMarker(markers, key, c) { if (!markers.has(key)) markers.set(key, c); } + +/** + * A Cesium viewer as the board's screen scene (src/annotations/screenScene.js): + * Cesium's own window transform and ellipsoid occluder, and the viewer's + * tracked entity as the followed subject. + */ +export function screenSceneFromViewer(viewer) { + const scene = viewer.scene; + const occluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + scene.camera.positionWC, + ); + return { + onFrame(listener) { + scene.postRender.addEventListener(listener); + return () => scene.postRender.removeEventListener(listener); + }, + beginFrame() { + occluder.cameraPosition = scene.camera.positionWC; + }, + cameraPosition: () => scene.camera.positionWC, + cameraDirection: () => scene.camera.directionWC, + cameraHeight: () => viewer.camera.positionCartographic?.height, + horizonVisible: (world) => occluder.isPointVisible(world), + worldToWindow: (world, result) => + Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, result), + size: () => ({ + width: scene.canvas.clientWidth || scene.canvas.width, + height: scene.canvas.clientHeight || scene.canvas.height, + }), + clampToHeightSupported: () => + Boolean(scene.clampToHeightSupported) && + typeof scene.clampToHeight === 'function', + clampToHeight: (position) => scene.clampToHeight(position), + trackedSubject() { + const ent = viewer.trackedEntity; + if (!ent) return null; + const now = Cesium.JulianDate.now(); + const bb = ent.billboard; + return { + world: + typeof ent.gevDisplayPosition === 'function' + ? ent.gevDisplayPosition() + : null, + billboard: bb + ? { + width: bb.width?.getValue?.(now), + height: bb.height?.getValue?.(now), + scaleByDistance: bb.scaleByDistance?.getValue?.(now), + } + : null, + }; + }, + }; +} diff --git a/src/annotations/screenScene.js b/src/annotations/screenScene.js new file mode 100644 index 000000000..c61571b94 --- /dev/null +++ b/src/annotations/screenScene.js @@ -0,0 +1,57 @@ +// What the screen-space annotation board (screenAnnotationRenderer.js) needs +// from the globe under it: the camera, the window projection, the horizon +// test, surface heights, one callback per drawn frame and the followed +// subject. A Cesium viewer provides it through screenSceneFromViewer (in the +// renderer); a globe engine through screenSceneFromEngine here. No Cesium. + +import * as geo from '../geo/index.js'; + +const SCREEN_SCENE = Symbol('gev.annotations.screenScene'); + +/** Whether `value` is a screen scene (rather than a Cesium viewer). */ +export const isScreenScene = (value) => + value != null && value[SCREEN_SCENE] === true; + +/** Mark an object as a screen scene. */ +export function screenScene(members) { + Object.defineProperty(members, SCREEN_SCENE, { value: true }); + return members; +} + +/** + * A globe engine as the board's screen scene: its projector, its camera read + * once per board frame, its rendered surface and its follow. + * @param {import('../globe/contract.js').GlobeEngine} engine + */ +export function screenSceneFromEngine(engine) { + const position = geo.cartesian3.create(); + const direction = geo.cartesian3.create(); + let height = undefined; + const refresh = () => { + const pose = engine.camera.pose(); + geo.cartesian3.clone(pose.position, position); + geo.cartesian3.clone(pose.direction, direction); + height = pose.cartographic?.height; + }; + refresh(); + return screenScene({ + onFrame: (listener) => engine.frames.postRender.addEventListener(listener), + beginFrame: refresh, + cameraPosition: () => position, + cameraDirection: () => direction, + cameraHeight: () => height, + horizonVisible: (world) => engine.projector.horizonVisible(world), + worldToWindow: (world, result) => + engine.projector.worldToWindow(world, result), + size: () => engine.host.size(), + clampToHeightSupported: () => + engine.surface.clampToHeightSupported === true, + clampToHeight: (world) => engine.surface.clampToHeight(world), + // The followed subject: its card is the overlay host's painted + // rectangle, its sprite a mover of the default billboard size. + trackedSubject() { + const world = engine.camera.following()?.target?.(); + return world ? { world, billboard: { width: 28, height: 28 } } : null; + }, + }); +} diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 5f52a2358..aceaf8194 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -468,7 +468,7 @@ export function attachViewerlessTools( defer(() => styleManager._clearPersistentStatusNotice?.()); // The page session's annotation board, Draw and Import on this engine // (src/app/navaraTools.js). - const tools = { engine, view: navaraViews.get(engine) }; + const tools = { engine, views: navaraViews.get(engine) }; const context = { operations, placeSearch, layerSession, styleManager }; const annotations = attachNavaraAnnotations(tools, context, { signal, diff --git a/src/app/navaraTools.js b/src/app/navaraTools.js index 2ba97f3d8..e8c1b945f 100644 --- a/src/app/navaraTools.js +++ b/src/app/navaraTools.js @@ -2,12 +2,35 @@ // Navara engine (src/app/sessionTools.js holds the tools themselves). Loaded // with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it. +import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; +import { sessionAnnotations } from './sessionTools.js'; + /** - * The annotation board drawn on this Navara scene. Returns the board, or - * null while the engine draws no annotations. + * The annotation board drawn on this Navara scene: draped areas and routes + * on the engine, every other mark on the shared screen board. The board + * belongs to the page session; an engine switch takes only its drawing off + * this engine. Returns the board (null without the Navara view). + * @param {{ engine: object, views: object }} scene `views`: the Navara view + * and its classes (src/app/navaraMode.js). */ -export function attachNavaraAnnotations() { - return null; +export function attachNavaraAnnotations( + { engine, views }, + { layerSession, placeSearch, operations }, + { defer }, +) { + if (!views?.view) return null; + const annotations = sessionAnnotations({ + layerSession, + placeSearch, + resolver: operations.annotationResolver, + defer, + }); + annotations.attachScene({ + engine, + renderer: createNavaraAnnotationRenderer(engine, views), + }); + defer(() => annotations.detachScene()); + return annotations; } /** UTILITIES ▸ Draw on this Navara scene; null while it has none. */ diff --git a/src/app/navaraTools.test.mjs b/src/app/navaraTools.test.mjs new file mode 100644 index 000000000..7e7f61dfb --- /dev/null +++ b/src/app/navaraTools.test.mjs @@ -0,0 +1,95 @@ +import '../testSupport/registerDom.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { attachNavaraAnnotations } from './navaraTools.js'; + +function fakeViews() { + const meshes = []; + class NavaraColor { + setHex() { + return this; + } + toArray() { + return [1, 1, 1]; + } + setRGBLinear() { + return this; + } + } + return { + meshes, + views: { + view: { + addMesh(config) { + const mesh = { config, deleted: false }; + meshes.push(mesh); + return { + update() {}, + delete() { + mesh.deleted = true; + }, + }; + }, + }, + NavaraColor, + }, + }; +} + +test('Navara attaches the page session board and its switch takes only the drawing', async () => { + const lifetime = new AbortController(); + const layerSession = { lifetime }; + const engine = createFakeEngine({ kind: 'navara' }); + const { views, meshes } = fakeViews(); + const context = { + layerSession, + placeSearch: null, + operations: { annotationResolver: null }, + }; + const attach = () => { + const cleanups = []; + const board = attachNavaraAnnotations({ engine, views }, context, { + defer: (cleanup) => cleanups.push(cleanup), + }); + return { board, detach: () => cleanups.reverse().forEach((c) => c()) }; + }; + const first = attach(); + assert.ok(first.board); + assert.equal(first.board, layerSession.annotations); + assert.equal(first.board.attached, true); + assert.equal(window.__gevAnnotations, first.board); + const result = await first.board.annotate( + [ + { + type: 'area', + manual: true, + ring: [ + [0, 0], + [1, 0], + [1, 1], + ], + }, + ], + { flyTo: false }, + ); + assert.equal(result.drawn, 1); + assert.equal(meshes.filter((mesh) => !mesh.deleted).length, 2); + first.detach(); + assert.equal(first.board.attached, false); + assert.equal(first.board.count(), 1, 'the marks stay on the session board'); + assert.equal(meshes.filter((mesh) => !mesh.deleted).length, 0); + const second = attach(); + assert.equal(second.board, first.board, 'one board for the page session'); + assert.equal(meshes.filter((mesh) => !mesh.deleted).length, 2); + second.detach(); + lifetime.abort(); + assert.equal(window.__gevAnnotations, undefined); + // Without the Navara view there is nothing to draw on. + assert.equal( + attachNavaraAnnotations({ engine, views: undefined }, context, { + defer() {}, + }), + null, + ); +});