Draw the session annotation board on Navara: draped areas and routes as clamped meshes, every other mark on the shared screen board over the engine's projector

This commit is contained in:
German Khokhlov committed 2026-11-03 20:59:10 +04:00
1 parent 5b7171f485
commit 96b5b0bb1f
13 files changed
+1142 -79

No files matched your search

+2
View File
@@ -2629,6 +2629,7 @@
"src/annotations/index.js", "src/annotations/index.js",
"src/annotations/resolver.js", "src/annotations/resolver.js",
"src/annotations/screenAnnotationRenderer.js", "src/annotations/screenAnnotationRenderer.js",
"src/annotations/screenScene.js",
"src/annotations/worldAnnotationRenderer.js", "src/annotations/worldAnnotationRenderer.js",
"src/app/catalog.js", "src/app/catalog.js",
"src/app/constructCatalog.js", "src/app/constructCatalog.js",
@@ -3599,6 +3600,7 @@
"src/app/engineChoice.js", "src/app/engineChoice.js",
"src/app/navaraMode.js", "src/app/navaraMode.js",
"src/app/navaraTools.js", "src/app/navaraTools.js",
"src/annotations/navaraAnnotationRenderer.js",
"src/data/render/cesiumProjection.js", "src/data/render/cesiumProjection.js",
"src/data/render/cesiumConstants.js", "src/data/render/cesiumConstants.js",
"src/data/render/cesiumTrailRenderer.js", "src/data/render/cesiumTrailRenderer.js",
+264
View File
@@ -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:<PORT>/
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',
);
},
);
+48 -19
View File
@@ -2,6 +2,7 @@ import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js'; import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { isGlobeEngine } from '../globe/contract.js';
import { greatCircleMeters } from '../geoDistance.js'; import { greatCircleMeters } from '../geoDistance.js';
import { import {
holdContinuousRender, holdContinuousRender,
@@ -1713,12 +1714,9 @@ export function createAnnotationEngine({
geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0), geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
); );
const sphere = Cesium.BoundingSphere.fromPoints(cart); const sphere = Cesium.BoundingSphere.fromPoints(cart);
viewer.camera.flyToBoundingSphere(sphere, { flyToSphere(sphere.center, sphere.radius, {
offset: new Cesium.HeadingPitchRange( pitch: geo.math.toRadians(-35),
viewer.camera.heading, range: Math.max(900, sphere.radius * 2.6),
geo.math.toRadians(-35),
Math.max(900, sphere.radius * 2.6),
),
duration: 1.6, duration: 1.6,
}); });
} catch { } catch {
@@ -1730,8 +1728,9 @@ export function createAnnotationEngine({
* globe. On any API hiccup, report visible — bad data must never move the camera. */ * globe. On any API hiccup, report visible — bad data must never move the camera. */
function isPointOnScreen(p) { function isPointOnScreen(p) {
try { try {
const camera = viewer.camera;
const pos = geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0); 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( const cv = camera.frustum.computeCullingVolume(
camera.position, camera.position,
camera.direction, camera.direction,
@@ -1770,29 +1769,46 @@ export function createAnnotationEngine({
: anno.ring, : anno.ring,
) )
: viewportRange(anno.viewport) || 600; : viewportRange(anno.viewport) || 600;
viewer.camera.flyToBoundingSphere( flyToSphere(
new Cesium.BoundingSphere(
geo.cartesian3.fromDegrees( geo.cartesian3.fromDegrees(
target.lon, target.lon,
target.lat, target.lat,
anno.anchor.height || 0, anno.anchor.height || 0,
), ),
range, range,
), { pitch: geo.math.toRadians(-35), range: range * 2.4, duration: 1.8 },
{
offset: new Cesium.HeadingPitchRange(
viewer.camera.heading,
geo.math.toRadians(-35),
range * 2.4,
),
duration: 1.8,
},
); );
} catch { } catch {
/* framing is best-effort */ /* 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 = { const engine = {
annotate, annotate,
clear, clear,
@@ -2045,7 +2061,7 @@ export function createAnnotationEngine({
}) { }) {
if (destroyed) return; if (destroyed) return;
try { try {
viewer.camera.flyTo({ viewer?.camera.flyTo({
destination: geo.cartesian3.fromDegrees(lon, lat, height), destination: geo.cartesian3.fromDegrees(lon, lat, height),
orientation: { orientation: {
heading: geo.math.toRadians(heading), heading: geo.math.toRadians(heading),
@@ -2408,3 +2424,16 @@ function appendDistance(baseLabel, distM) {
if (!dist) return baseLabel; if (!dist) return baseLabel;
return baseLabel ? `${baseLabel} — ${dist}` : dist; 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;
}
+9 -4
View File
@@ -18,14 +18,19 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
* *
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the * 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 * 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( export function createHybridAnnotationRenderer(
viewer, viewer,
{ screen: screenOptions } = {}, { screen: screenOptions, world: worldRenderer, screenScene } = {},
) { ) {
const world = createWorldAnnotationRenderer(viewer); const world = worldRenderer ?? createWorldAnnotationRenderer(viewer);
const screen = createScreenAnnotationRenderer(viewer, screenOptions); const screen = createScreenAnnotationRenderer(
screenScene ?? viewer,
screenOptions,
);
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state. // anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
const routed = new Map(); const routed = new Map();
+13 -4
View File
@@ -24,16 +24,25 @@ export function initAnnotations({
/** /**
* The page's annotation board, kept across globe engine switches: the marks * The page's annotation board, kept across globe engine switches: the marks
* belong to the page session, their drawing to the attached globe. * belong to the page session, their drawing to the attached globe.
* `attachScene({ viewer, tileset })` draws the board on a Cesium viewer (an * `attachScene({ viewer, tileset })` draws the board on a Cesium viewer;
* engine without one keeps the marks undrawn); `detachScene()` releases the * `attachScene({ engine, renderer })` on a globe engine without a viewer
* drawing and keeps the marks. * (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 }) { export function createSessionAnnotations({ placeSearch, resolver }) {
const engine = createAnnotationEngine({ const engine = createAnnotationEngine({
placeSearch, placeSearch,
resolveTarget: resolver?.resolveAnnotationTarget, 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(); if (!viewer) return engine.detachRenderer();
// World-space footprint draping; clamped marks can use the photoreal tiles. // World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) { if (tileset) {
+254
View File
@@ -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 };
}
@@ -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);
});
+24
View File
@@ -2397,6 +2397,7 @@ export function createAnnotationResolver({
* the tiles for that spot aren't loaded we fall back to the ellipsoid (0). * the tiles for that spot aren't loaded we fall back to the ellipsoid (0).
*/ */
function sampleGroundHeight(viewer, lon, lat) { function sampleGroundHeight(viewer, lon, lat) {
if (isGlobeEngine(viewer)) return engineGroundHeight(viewer, lon, lat);
const scene = viewer?.scene; const scene = viewer?.scene;
if (!scene) return 0; if (!scene) return 0;
try { try {
@@ -2421,6 +2422,29 @@ export function createAnnotationResolver({
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0; 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) { function shortLabel(formattedAddress) {
if (!formattedAddress) return null; if (!formattedAddress) return null;
return String(formattedAddress).split(',')[0].trim() || null; return String(formattedAddress).split(',')[0].trim() || null;
+81 -47
View File
@@ -8,6 +8,7 @@ import { t } from '../i18n/index.js';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { applicationPinia } from '../ui/vueRuntime.js'; import { applicationPinia } from '../ui/vueRuntime.js';
import { isScreenScene } from './screenScene.js';
/** A label whose detailed outline could not be resolved. */ /** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) { function outlineUnavailableLabel(label) {
@@ -67,7 +68,7 @@ function markScale(h) {
} }
export function createScreenAnnotationRenderer( export function createScreenAnnotationRenderer(
viewer, target,
{ {
overlayPaintRect = getOverlayPaintRect, overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId, activeTrackedReadoutId = getActiveTrackedReadoutId,
@@ -78,11 +79,9 @@ export function createScreenAnnotationRenderer(
const arrowMarkers = new Map(); const arrowMarkers = new Map();
let viewBox = null; let viewBox = null;
const scene = viewer.scene; // What the board is drawn over: a Cesium viewer or an engine's screen
const occluder = new Cesium.EllipsoidalOccluder( // scene (src/annotations/screenScene.js).
Cesium.Ellipsoid.WGS84, const view = isScreenScene(target) ? target : screenSceneFromViewer(target);
scene.camera.positionWC,
);
const records = new Map(); // anno.id -> { key, anno, group, parts } 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 leaving = new Map(); // key -> a removed mark's record while it fades out
const byKey = new Map(); // key -> record, live or fading 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 // 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. // world. postRender runs after the camera/tiles update, so positions are exact.
const onPostRender = () => projectAll(); const onPostRender = () => projectAll();
scene.postRender.addEventListener(onPostRender); const offPostRender = view.onFrame(onPostRender);
function color(anno) { function color(anno) {
return PALETTE[anno.color] || PALETTE.primary; return PALETTE[anno.color] || PALETTE.primary;
@@ -141,11 +140,8 @@ export function createScreenAnnotationRenderer(
return cached.h; return cached.h;
} }
try { try {
if ( if (view.clampToHeightSupported()) {
scene.clampToHeightSupported && const c = view.clampToHeight(
typeof scene.clampToHeight === 'function'
) {
const c = scene.clampToHeight(
Cesium.Cartesian3.fromDegrees(lon, lat, 0), Cesium.Cartesian3.fromDegrees(lon, lat, 0),
); );
if (c) { if (c) {
@@ -369,20 +365,15 @@ export function createScreenAnnotationRenderer(
groundHeight(lon, lat), groundHeight(lon, lat),
); );
// Reject points behind the camera (they produce wrapped/extreme coords). // Reject points behind the camera (they produce wrapped/extreme coords).
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir); Cesium.Cartesian3.subtract(world, view.cameraPosition(), scratchDir);
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) if (Cesium.Cartesian3.dot(scratchDir, view.cameraDirection()) <= 0)
return null; return null;
// Reject points beyond the globe horizon. // Reject points beyond the globe horizon.
if (!occluder.isPointVisible(world)) return null; if (!view.horizonVisible(world)) return null;
const win = Cesium.SceneTransforms.worldToWindowCoordinates( const win = view.worldToWindow(world, scratch);
scene,
world,
scratch,
);
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null; if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
// Reject absurd off-screen projections (anchor not in view). // Reject absurd off-screen projections (anchor not in view).
const w = scene.canvas.clientWidth || scene.canvas.width; const { width: w, height: h } = view.size();
const h = scene.canvas.clientHeight || scene.canvas.height;
if ( if (
!allowOffscreen && !allowOffscreen &&
(win.x < -w || win.x > 2 * w || win.y < -h || win.y > 2 * h) (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 top = painted?.y;
let bottom = painted ? painted.y + painted.h : undefined; let bottom = painted ? painted.y + painted.h : undefined;
const ent = viewer.trackedEntity; // The followed subject's own graphic, where the globe draws one.
const now = Cesium.JulianDate.now(); const subject = view.trackedSubject();
const world = const world = subject?.world ?? null;
typeof ent?.gevDisplayPosition === 'function' const win = world ? view.worldToWindow(world, _scratchTrackedWin) : null;
? ent.gevDisplayPosition()
: null;
const win = world
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
_scratchTrackedWin,
)
: null;
// Billboard box — centered on the anchor, magnified by scaleByDistance. // 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)) { if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
const baseW = bb.width?.getValue?.(now) ?? 28; const baseW = bb.width ?? 28;
const baseH = bb.height?.getValue?.(now) ?? 28; const baseH = bb.height ?? 28;
let scale = 1; let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now); const sbd = bb.scaleByDistance;
if (sbd) if (sbd)
scale = nearFarValue( scale = nearFarValue(
sbd, sbd,
Cesium.Cartesian3.distance(scene.camera.positionWC, world), Cesium.Cartesian3.distance(view.cameraPosition(), world),
); );
const hw = (baseW * scale) / 2; const hw = (baseW * scale) / 2;
const hh = (baseH * 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 projGen += 1; // new frame: entries touched below are "hot" and survive trimming
const trackedRect = trackedEntityRect(); const trackedRect = trackedEntityRect();
occluder.cameraPosition = scene.camera.positionWC; view.beginFrame();
const h = scene.canvas.clientHeight || scene.canvas.height; const { width: w, height: h } = view.size();
const w = scene.canvas.clientWidth || scene.canvas.width;
viewBox = `0 0 ${w} ${h}`; viewBox = `0 0 ${w} ${h}`;
// Shrink point-marker geometry as the camera pulls back (declutter the blob). // Shrink point-marker geometry as the camera pulls back (declutter the blob).
const mScale = markScale( const mScale = markScale(view.cameraHeight() ?? 1000);
viewer.camera.positionCartographic?.height ?? 1000,
);
for (const rec of records.values()) { for (const rec of records.values()) {
const { anno, group, parts } = rec; const { anno, group, parts } = rec;
@@ -821,7 +800,7 @@ export function createScreenAnnotationRenderer(
function destroy() { function destroy() {
try { try {
scene.postRender.removeEventListener(onPostRender); offPostRender();
} catch { } catch {
/* torn down */ /* torn down */
} }
@@ -1026,3 +1005,58 @@ function placeCalloutsAboveVoicePreview(callouts, width, height) {
function ensureArrowMarker(markers, key, c) { function ensureArrowMarker(markers, key, c) {
if (!markers.has(key)) markers.set(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,
};
},
};
}
+57
View File
@@ -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;
},
});
}
+1 -1
View File
@@ -468,7 +468,7 @@ export function attachViewerlessTools(
defer(() => styleManager._clearPersistentStatusNotice?.()); defer(() => styleManager._clearPersistentStatusNotice?.());
// The page session's annotation board, Draw and Import on this engine // The page session's annotation board, Draw and Import on this engine
// (src/app/navaraTools.js). // (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 context = { operations, placeSearch, layerSession, styleManager };
const annotations = attachNavaraAnnotations(tools, context, { const annotations = attachNavaraAnnotations(tools, context, {
signal, signal,
+27 -4
View File
@@ -2,12 +2,35 @@
// Navara engine (src/app/sessionTools.js holds the tools themselves). Loaded // 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. // 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 * The annotation board drawn on this Navara scene: draped areas and routes
* null while the engine draws no annotations. * 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() { export function attachNavaraAnnotations(
return null; { 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. */ /** UTILITIES ▸ Draw on this Navara scene; null while it has none. */
+95
View File
@@ -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,
);
});