mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14: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
+1147
-84
No files matched your search
@@ -2629,6 +2629,7 @@
|
||||
"src/annotations/index.js",
|
||||
"src/annotations/resolver.js",
|
||||
"src/annotations/screenAnnotationRenderer.js",
|
||||
"src/annotations/screenScene.js",
|
||||
"src/annotations/worldAnnotationRenderer.js",
|
||||
"src/app/catalog.js",
|
||||
"src/app/constructCatalog.js",
|
||||
@@ -3599,6 +3600,7 @@
|
||||
"src/app/engineChoice.js",
|
||||
"src/app/navaraMode.js",
|
||||
"src/app/navaraTools.js",
|
||||
"src/annotations/navaraAnnotationRenderer.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/data/render/cesiumTrailRenderer.js",
|
||||
|
||||
@@ -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 * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
@@ -1713,12 +1714,9 @@ export function createAnnotationEngine({
|
||||
geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
||||
);
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
||||
viewer.camera.flyToBoundingSphere(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading,
|
||||
geo.math.toRadians(-35),
|
||||
Math.max(900, sphere.radius * 2.6),
|
||||
),
|
||||
flyToSphere(sphere.center, sphere.radius, {
|
||||
pitch: geo.math.toRadians(-35),
|
||||
range: Math.max(900, sphere.radius * 2.6),
|
||||
duration: 1.6,
|
||||
});
|
||||
} catch {
|
||||
@@ -1730,8 +1728,9 @@ export function createAnnotationEngine({
|
||||
* globe. On any API hiccup, report visible — bad data must never move the camera. */
|
||||
function isPointOnScreen(p) {
|
||||
try {
|
||||
const camera = viewer.camera;
|
||||
const pos = geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
|
||||
if (isGlobeEngine(viewer)) return engineSeesPoint(viewer, pos);
|
||||
const camera = viewer.camera;
|
||||
const cv = camera.frustum.computeCullingVolume(
|
||||
camera.position,
|
||||
camera.direction,
|
||||
@@ -1770,29 +1769,46 @@ export function createAnnotationEngine({
|
||||
: anno.ring,
|
||||
)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
geo.cartesian3.fromDegrees(
|
||||
target.lon,
|
||||
target.lat,
|
||||
anno.anchor.height || 0,
|
||||
),
|
||||
range,
|
||||
flyToSphere(
|
||||
geo.cartesian3.fromDegrees(
|
||||
target.lon,
|
||||
target.lat,
|
||||
anno.anchor.height || 0,
|
||||
),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading,
|
||||
geo.math.toRadians(-35),
|
||||
range * 2.4,
|
||||
),
|
||||
duration: 1.8,
|
||||
},
|
||||
range,
|
||||
{ pitch: geo.math.toRadians(-35), range: range * 2.4, duration: 1.8 },
|
||||
);
|
||||
} catch {
|
||||
/* framing is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fly to a sphere keeping the camera's heading: Cesium's
|
||||
* flyToBoundingSphere on a viewer, the contract's flyToSphere on a globe
|
||||
* engine (the same offset semantics).
|
||||
*/
|
||||
function flyToSphere(center, radius, { pitch, range, duration }) {
|
||||
if (isGlobeEngine(viewer)) {
|
||||
viewer.camera.flyToSphere(
|
||||
{ center, radius },
|
||||
{ heading: viewer.camera.pose().heading, pitch, range, duration },
|
||||
);
|
||||
return;
|
||||
}
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(center, radius),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading,
|
||||
pitch,
|
||||
range,
|
||||
),
|
||||
duration,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const engine = {
|
||||
annotate,
|
||||
clear,
|
||||
@@ -2045,7 +2061,7 @@ export function createAnnotationEngine({
|
||||
}) {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
viewer.camera.flyTo({
|
||||
viewer?.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: {
|
||||
heading: geo.math.toRadians(heading),
|
||||
@@ -2408,3 +2424,16 @@ function appendDistance(baseLabel, distM) {
|
||||
if (!dist) return baseLabel;
|
||||
return baseLabel ? `${baseLabel} — ${dist}` : dist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a globe engine shows a world point: in front of the camera, inside
|
||||
* the canvas and on the near side of the globe (what the Cesium path's
|
||||
* culling volume and ellipsoid occluder answer).
|
||||
*/
|
||||
function engineSeesPoint(engine, position) {
|
||||
if (!engine.projector.horizonVisible(position)) return false;
|
||||
const win = engine.projector.worldToWindow(position);
|
||||
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return false;
|
||||
const { width, height } = engine.host.size();
|
||||
return win.x >= 0 && win.x <= width && win.y >= 0 && win.y <= height;
|
||||
}
|
||||
@@ -18,14 +18,19 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
*
|
||||
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
|
||||
* one route change that spans both sub-renderers. `screen` passes options to the
|
||||
* screen renderer (its store, in tests).
|
||||
* screen renderer (its store, in tests). On a globe without a Cesium viewer
|
||||
* (Navara), `world` is that globe's world renderer and `screenScene` the
|
||||
* screen scene the board is drawn over (src/annotations/screenScene.js).
|
||||
*/
|
||||
export function createHybridAnnotationRenderer(
|
||||
viewer,
|
||||
{ screen: screenOptions } = {},
|
||||
{ screen: screenOptions, world: worldRenderer, screenScene } = {},
|
||||
) {
|
||||
const world = createWorldAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
|
||||
const world = worldRenderer ?? createWorldAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(
|
||||
screenScene ?? viewer,
|
||||
screenOptions,
|
||||
);
|
||||
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
|
||||
const routed = new Map();
|
||||
|
||||
|
||||
@@ -24,16 +24,25 @@ export function initAnnotations({
|
||||
/**
|
||||
* The page's annotation board, kept across globe engine switches: the marks
|
||||
* belong to the page session, their drawing to the attached globe.
|
||||
* `attachScene({ viewer, tileset })` draws the board on a Cesium viewer (an
|
||||
* engine without one keeps the marks undrawn); `detachScene()` releases the
|
||||
* drawing and keeps the marks.
|
||||
* `attachScene({ viewer, tileset })` draws the board on a Cesium viewer;
|
||||
* `attachScene({ engine, renderer })` on a globe engine without a viewer
|
||||
* (Navara: src/annotations/navaraAnnotationRenderer.js, built by the caller
|
||||
* so this module never loads it); with neither the marks stay undrawn.
|
||||
* `detachScene()` releases the drawing and keeps the marks.
|
||||
*/
|
||||
export function createSessionAnnotations({ placeSearch, resolver }) {
|
||||
const engine = createAnnotationEngine({
|
||||
placeSearch,
|
||||
resolveTarget: resolver?.resolveAnnotationTarget,
|
||||
});
|
||||
engine.attachScene = ({ viewer = null, tileset = null } = {}) => {
|
||||
engine.attachScene = ({
|
||||
viewer = null,
|
||||
tileset = null,
|
||||
engine: globe = null,
|
||||
renderer = null,
|
||||
} = {}) => {
|
||||
if (!viewer && globe && renderer)
|
||||
return engine.attachRenderer({ viewer: globe, renderer });
|
||||
if (!viewer) return engine.detachRenderer();
|
||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||
if (tileset) {
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
function sampleGroundHeight(viewer, lon, lat) {
|
||||
if (isGlobeEngine(viewer)) return engineGroundHeight(viewer, lon, lat);
|
||||
const scene = viewer?.scene;
|
||||
if (!scene) return 0;
|
||||
try {
|
||||
@@ -2421,6 +2422,29 @@ export function createAnnotationResolver({
|
||||
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
|
||||
}
|
||||
|
||||
/** sampleGroundHeight on a globe engine: the rendered surface, else terrain. */
|
||||
function engineGroundHeight(engine, lon, lat) {
|
||||
try {
|
||||
if (engine.surface.clampToHeightSupported) {
|
||||
const clamped = engine.surface.clampToHeight(
|
||||
geo.cartesian3.fromDegrees(lon, lat, 0),
|
||||
);
|
||||
const h = clamped && geo.cartographic.fromCartesian(clamped)?.height;
|
||||
if (Number.isFinite(h)) return h;
|
||||
}
|
||||
} catch {
|
||||
/* tiles not ready — fall through */
|
||||
}
|
||||
try {
|
||||
const h = engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(geo.cartographic.fromDegrees(lon, lat))
|
||||
: undefined;
|
||||
return Number.isFinite(h) && h > 0 ? h : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function shortLabel(formattedAddress) {
|
||||
if (!formattedAddress) return null;
|
||||
return String(formattedAddress).split(',')[0].trim() || null;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { t } from '../i18n/index.js';
|
||||
import { nextTick } from 'vue';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { isScreenScene } from './screenScene.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
@@ -67,7 +68,7 @@ function markScale(h) {
|
||||
}
|
||||
|
||||
export function createScreenAnnotationRenderer(
|
||||
viewer,
|
||||
target,
|
||||
{
|
||||
overlayPaintRect = getOverlayPaintRect,
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
@@ -78,11 +79,9 @@ export function createScreenAnnotationRenderer(
|
||||
const arrowMarkers = new Map();
|
||||
let viewBox = null;
|
||||
|
||||
const scene = viewer.scene;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
// What the board is drawn over: a Cesium viewer or an engine's screen
|
||||
// scene (src/annotations/screenScene.js).
|
||||
const view = isScreenScene(target) ? target : screenSceneFromViewer(target);
|
||||
const records = new Map(); // anno.id -> { key, anno, group, parts }
|
||||
const leaving = new Map(); // key -> a removed mark's record while it fades out
|
||||
const byKey = new Map(); // key -> record, live or fading
|
||||
@@ -121,7 +120,7 @@ export function createScreenAnnotationRenderer(
|
||||
// Re-project every annotation each rendered frame so marks stay glued to the
|
||||
// world. postRender runs after the camera/tiles update, so positions are exact.
|
||||
const onPostRender = () => projectAll();
|
||||
scene.postRender.addEventListener(onPostRender);
|
||||
const offPostRender = view.onFrame(onPostRender);
|
||||
|
||||
function color(anno) {
|
||||
return PALETTE[anno.color] || PALETTE.primary;
|
||||
@@ -141,11 +140,8 @@ export function createScreenAnnotationRenderer(
|
||||
return cached.h;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
typeof scene.clampToHeight === 'function'
|
||||
) {
|
||||
const c = scene.clampToHeight(
|
||||
if (view.clampToHeightSupported()) {
|
||||
const c = view.clampToHeight(
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
|
||||
);
|
||||
if (c) {
|
||||
@@ -369,20 +365,15 @@ export function createScreenAnnotationRenderer(
|
||||
groundHeight(lon, lat),
|
||||
);
|
||||
// Reject points behind the camera (they produce wrapped/extreme coords).
|
||||
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
|
||||
Cesium.Cartesian3.subtract(world, view.cameraPosition(), scratchDir);
|
||||
if (Cesium.Cartesian3.dot(scratchDir, view.cameraDirection()) <= 0)
|
||||
return null;
|
||||
// Reject points beyond the globe horizon.
|
||||
if (!occluder.isPointVisible(world)) return null;
|
||||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
world,
|
||||
scratch,
|
||||
);
|
||||
if (!view.horizonVisible(world)) return null;
|
||||
const win = view.worldToWindow(world, scratch);
|
||||
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
|
||||
// Reject absurd off-screen projections (anchor not in view).
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
const h = scene.canvas.clientHeight || scene.canvas.height;
|
||||
const { width: w, height: h } = view.size();
|
||||
if (
|
||||
!allowOffscreen &&
|
||||
(win.x < -w || win.x > 2 * w || win.y < -h || win.y > 2 * h)
|
||||
@@ -422,31 +413,22 @@ export function createScreenAnnotationRenderer(
|
||||
let top = painted?.y;
|
||||
let bottom = painted ? painted.y + painted.h : undefined;
|
||||
|
||||
const ent = viewer.trackedEntity;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const world =
|
||||
typeof ent?.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null;
|
||||
const win = world
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
world,
|
||||
_scratchTrackedWin,
|
||||
)
|
||||
: null;
|
||||
// The followed subject's own graphic, where the globe draws one.
|
||||
const subject = view.trackedSubject();
|
||||
const world = subject?.world ?? null;
|
||||
const win = world ? view.worldToWindow(world, _scratchTrackedWin) : null;
|
||||
|
||||
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
||||
const bb = ent?.billboard;
|
||||
const bb = subject?.billboard;
|
||||
if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
|
||||
const baseW = bb.width?.getValue?.(now) ?? 28;
|
||||
const baseH = bb.height?.getValue?.(now) ?? 28;
|
||||
const baseW = bb.width ?? 28;
|
||||
const baseH = bb.height ?? 28;
|
||||
let scale = 1;
|
||||
const sbd = bb.scaleByDistance?.getValue?.(now);
|
||||
const sbd = bb.scaleByDistance;
|
||||
if (sbd)
|
||||
scale = nearFarValue(
|
||||
sbd,
|
||||
Cesium.Cartesian3.distance(scene.camera.positionWC, world),
|
||||
Cesium.Cartesian3.distance(view.cameraPosition(), world),
|
||||
);
|
||||
const hw = (baseW * scale) / 2;
|
||||
const hh = (baseH * scale) / 2;
|
||||
@@ -477,14 +459,11 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
projGen += 1; // new frame: entries touched below are "hot" and survive trimming
|
||||
const trackedRect = trackedEntityRect();
|
||||
occluder.cameraPosition = scene.camera.positionWC;
|
||||
const h = scene.canvas.clientHeight || scene.canvas.height;
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
view.beginFrame();
|
||||
const { width: w, height: h } = view.size();
|
||||
viewBox = `0 0 ${w} ${h}`;
|
||||
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
||||
const mScale = markScale(
|
||||
viewer.camera.positionCartographic?.height ?? 1000,
|
||||
);
|
||||
const mScale = markScale(view.cameraHeight() ?? 1000);
|
||||
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group, parts } = rec;
|
||||
@@ -821,7 +800,7 @@ export function createScreenAnnotationRenderer(
|
||||
|
||||
function destroy() {
|
||||
try {
|
||||
scene.postRender.removeEventListener(onPostRender);
|
||||
offPostRender();
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
@@ -1026,3 +1005,58 @@ function placeCalloutsAboveVoicePreview(callouts, width, height) {
|
||||
function ensureArrowMarker(markers, key, c) {
|
||||
if (!markers.has(key)) markers.set(key, c);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Cesium viewer as the board's screen scene (src/annotations/screenScene.js):
|
||||
* Cesium's own window transform and ellipsoid occluder, and the viewer's
|
||||
* tracked entity as the followed subject.
|
||||
*/
|
||||
export function screenSceneFromViewer(viewer) {
|
||||
const scene = viewer.scene;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
return {
|
||||
onFrame(listener) {
|
||||
scene.postRender.addEventListener(listener);
|
||||
return () => scene.postRender.removeEventListener(listener);
|
||||
},
|
||||
beginFrame() {
|
||||
occluder.cameraPosition = scene.camera.positionWC;
|
||||
},
|
||||
cameraPosition: () => scene.camera.positionWC,
|
||||
cameraDirection: () => scene.camera.directionWC,
|
||||
cameraHeight: () => viewer.camera.positionCartographic?.height,
|
||||
horizonVisible: (world) => occluder.isPointVisible(world),
|
||||
worldToWindow: (world, result) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, result),
|
||||
size: () => ({
|
||||
width: scene.canvas.clientWidth || scene.canvas.width,
|
||||
height: scene.canvas.clientHeight || scene.canvas.height,
|
||||
}),
|
||||
clampToHeightSupported: () =>
|
||||
Boolean(scene.clampToHeightSupported) &&
|
||||
typeof scene.clampToHeight === 'function',
|
||||
clampToHeight: (position) => scene.clampToHeight(position),
|
||||
trackedSubject() {
|
||||
const ent = viewer.trackedEntity;
|
||||
if (!ent) return null;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const bb = ent.billboard;
|
||||
return {
|
||||
world:
|
||||
typeof ent.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null,
|
||||
billboard: bb
|
||||
? {
|
||||
width: bb.width?.getValue?.(now),
|
||||
height: bb.height?.getValue?.(now),
|
||||
scaleByDistance: bb.scaleByDistance?.getValue?.(now),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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?.());
|
||||
// The page session's annotation board, Draw and Import on this engine
|
||||
// (src/app/navaraTools.js).
|
||||
const tools = { engine, view: navaraViews.get(engine) };
|
||||
const tools = { engine, views: navaraViews.get(engine) };
|
||||
const context = { operations, placeSearch, layerSession, styleManager };
|
||||
const annotations = attachNavaraAnnotations(tools, context, {
|
||||
signal,
|
||||
|
||||
+27
-4
@@ -2,12 +2,35 @@
|
||||
// Navara engine (src/app/sessionTools.js holds the tools themselves). Loaded
|
||||
// with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it.
|
||||
|
||||
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
|
||||
import { sessionAnnotations } from './sessionTools.js';
|
||||
|
||||
/**
|
||||
* The annotation board drawn on this Navara scene. Returns the board, or
|
||||
* null while the engine draws no annotations.
|
||||
* The annotation board drawn on this Navara scene: draped areas and routes
|
||||
* on the engine, every other mark on the shared screen board. The board
|
||||
* belongs to the page session; an engine switch takes only its drawing off
|
||||
* this engine. Returns the board (null without the Navara view).
|
||||
* @param {{ engine: object, views: object }} scene `views`: the Navara view
|
||||
* and its classes (src/app/navaraMode.js).
|
||||
*/
|
||||
export function attachNavaraAnnotations() {
|
||||
return null;
|
||||
export function attachNavaraAnnotations(
|
||||
{ engine, views },
|
||||
{ layerSession, placeSearch, operations },
|
||||
{ defer },
|
||||
) {
|
||||
if (!views?.view) return null;
|
||||
const annotations = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch,
|
||||
resolver: operations.annotationResolver,
|
||||
defer,
|
||||
});
|
||||
annotations.attachScene({
|
||||
engine,
|
||||
renderer: createNavaraAnnotationRenderer(engine, views),
|
||||
});
|
||||
defer(() => annotations.detachScene());
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/** UTILITIES ▸ Draw on this Navara scene; null while it has none. */
|
||||
|
||||
@@ -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