mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
5b7171f485
commit
96b5b0bb1f
13 files changed
+1142
-79
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user