mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Fix the follow-up review findings in import, layers, startup and engines
- KML/KMZ import: a per-file canvas budget and cache for ground overlays, limits on rings and vertices of raised polygons, overlays across 360° and near the poles, degenerate holes, polar and single-latitude fills, absolute overlays at their altitude, overlay order by drawOrder as on Cesium, and pins on tiles keep their measured height. - Points on the surface: OSM Places and Street Level cones share the surface sampler and sit on the terrain before they are measured, within a sampling budget per pass; Cockpit's contact-dot bank keeps the dot atlas, and a failed fleet model loads again after a pause. - Startup: the loading screen lifts after the share-link view is restored, the retry screen names the failed engine and takes focus, and the fallback toast stays for 6 s. - Engines: an engine switch lands a running camera flight at its destination (camera.completeFlight on both engines), the sighting card leaves focus on the engine picker after a switch, and a Cesium model error stays with the instance that failed. - The MCP panel builds again: its wind-mesh alias resolves to the stub. - QA: the wildlife switch waits for the camera to rest, GBFS --w2 says it needs the dev server, and test fixtures are shared instead of copied.
This commit is contained in:
1 parent
0ef29781e5
commit
60fb8fee07
82 files changed
+2363
-1574
No files matched your search
@@ -4,9 +4,15 @@ export const ENGINE_STORAGE_KEY = 'gev.engine';
|
||||
/**
|
||||
* The globe choices: Cesium, Navara at CSS pixels without MSAA (as Cesium
|
||||
* draws), and Navara HD at the device pixel ratio with MSAA 4 (on a 2x
|
||||
* screen about 1 GiB more: 2.7 GiB against 1.8).
|
||||
* screen about 1 GiB more: 2.7 GiB against 1.8). The names are product
|
||||
* names, never translated.
|
||||
*/
|
||||
export const ENGINE_CHOICES = Object.freeze(['cesium', 'navara', 'navara-hd']);
|
||||
export const ENGINE_NAMES = Object.freeze({
|
||||
cesium: 'Cesium',
|
||||
navara: 'Navara',
|
||||
'navara-hd': 'Navara HD',
|
||||
});
|
||||
export const ENGINE_CHOICES = Object.freeze(Object.keys(ENGINE_NAMES));
|
||||
|
||||
/** The choice without one made, and the one a failed start falls back to. */
|
||||
export const DEFAULT_ENGINE_CHOICE = ENGINE_CHOICES[0];
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* @property {{draw?: ReturnType<typeof import('../annotations/drawTool.js').createSessionDrawTool>, geoImport?: ReturnType<typeof import('../ui/geoFileImport.js').createGeoFileImport>}} tools Session tools.
|
||||
* @property {ReturnType<typeof import('../voice/gevRealtime.js').createSceneVoice> | null} voice Session voice.
|
||||
* @property {{locale?: () => void, placeSearch?: object, sceneChrome?: boolean}} chrome Standalone startup and scene factory.
|
||||
* @property {Map<string, {entry: object | null, enabled: boolean, durable: boolean | undefined}> | null} handoffLayers Switch coordinator: outgoing layer intentions.
|
||||
* @property {Map<string, {entry: object | null, enabled: boolean, durable: boolean | undefined, visit: boolean}> | null} handoffLayers Switch coordinator: outgoing layer intentions; visit restores the incoming address's options instead of keeping the live options.
|
||||
* @property {boolean} restoringView Switch coordinator: incoming address restoration.
|
||||
* @property {() => boolean} switching Switch coordinator: voice waits while busy.
|
||||
* @property {object | null} selectionContext Scene factory: selected record carried to the next attachment.
|
||||
|
||||
+115
-21
@@ -12,12 +12,18 @@ const settle = async () => {
|
||||
for (let i = 0; i < 15; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
};
|
||||
|
||||
function boot(t, { search = '', failures = ['navara'] } = {}) {
|
||||
function boot(
|
||||
t,
|
||||
{ search = '', failures = ['navara'], initialRestorePromise } = {},
|
||||
) {
|
||||
document.body.innerHTML =
|
||||
'<div id="loading-screen"><div class="loader-content"><p class="loader-status"></p></div></div>';
|
||||
const starts = [];
|
||||
const switches = [];
|
||||
const toasts = [];
|
||||
const toastDurations = [];
|
||||
const flushes = [];
|
||||
const addresses = [];
|
||||
const stored = new Map([[choices.ENGINE_STORAGE_KEY, 'navara']]);
|
||||
const location = { search, href: `https://example.test/${search}` };
|
||||
const localStorage = {
|
||||
@@ -46,11 +52,14 @@ function boot(t, { search = '', failures = ['navara'] } = {}) {
|
||||
search: location.search,
|
||||
storage: localStorage,
|
||||
}),
|
||||
createEngineAddress: () => ({
|
||||
restore(kind) {
|
||||
location.search = `?engine=${kind}`;
|
||||
},
|
||||
}),
|
||||
createEngineAddress: (options) => {
|
||||
addresses.push(options);
|
||||
return {
|
||||
restore(kind) {
|
||||
location.search = `?engine=${kind}`;
|
||||
},
|
||||
};
|
||||
},
|
||||
createSceneSwitcher: (options) => {
|
||||
switches.push(options);
|
||||
return {
|
||||
@@ -63,24 +72,46 @@ function boot(t, { search = '', failures = ['navara'] } = {}) {
|
||||
select: async () => {},
|
||||
};
|
||||
},
|
||||
createStandaloneApplication: ({ engineKind }) => ({
|
||||
async start() {
|
||||
starts.push(engineKind);
|
||||
if (failures.includes(engineKind)) throw new Error('Engine failed');
|
||||
},
|
||||
async destroy() {},
|
||||
getState: () => ({
|
||||
status: failures.includes(engineKind) ? 'failed' : 'ready',
|
||||
}),
|
||||
getComponents: () => ({
|
||||
scene: { engine: {} },
|
||||
controls: { styleManager: { _showToast: (text) => toasts.push(text) } },
|
||||
}),
|
||||
}),
|
||||
createStandaloneApplication: ({ engineKind }) => {
|
||||
const styleManager = {
|
||||
initialRestorePromise,
|
||||
shareLinkManager: { flush: () => flushes.push('share link') },
|
||||
_showToast(text, durationMs) {
|
||||
toasts.push(text);
|
||||
toastDurations.push(durationMs);
|
||||
},
|
||||
};
|
||||
return {
|
||||
async start() {
|
||||
starts.push(engineKind);
|
||||
if (failures.includes(engineKind)) throw new Error('Engine failed');
|
||||
},
|
||||
async destroy() {},
|
||||
getState: () => ({
|
||||
status: failures.includes(engineKind) ? 'failed' : 'ready',
|
||||
}),
|
||||
getComponents: () => ({
|
||||
scene: {
|
||||
engine: {
|
||||
camera: { completeFlight: () => flushes.push('flight') },
|
||||
},
|
||||
},
|
||||
controls: { styleManager },
|
||||
}),
|
||||
};
|
||||
},
|
||||
console: { error() {} },
|
||||
};
|
||||
new Function(...Object.keys(deps), source)(...Object.values(deps));
|
||||
return { starts, switches, stored, toasts };
|
||||
return {
|
||||
starts,
|
||||
switches,
|
||||
stored,
|
||||
toasts,
|
||||
toastDurations,
|
||||
flushes,
|
||||
addresses,
|
||||
};
|
||||
}
|
||||
|
||||
test('a failed stored engine falls back to Cesium and remembers the recovery', async (t) => {
|
||||
@@ -92,6 +123,7 @@ test('a failed stored engine falls back to Cesium and remembers the recovery', a
|
||||
assert.deepEqual(f.toasts, [
|
||||
'Globe engine failed to start. Running Cesium: Engine failed',
|
||||
]);
|
||||
assert.deepEqual(f.toastDurations, [6000]);
|
||||
});
|
||||
|
||||
test('a failed explicit first engine offers a usable retry picker', async (t) => {
|
||||
@@ -100,7 +132,16 @@ test('a failed explicit first engine offers a usable retry picker', async (t) =>
|
||||
assert.deepEqual(f.starts, ['navara']);
|
||||
const retry = document.querySelector('[data-engine="cesium"]');
|
||||
assert.ok(retry);
|
||||
assert.ok(document.activeElement === retry);
|
||||
const status = document.querySelector('.loader-status');
|
||||
assert.equal(status.textContent, 'Could not start Navara: Engine failed');
|
||||
assert.equal(status.getAttribute('role'), 'alert');
|
||||
assert.equal(
|
||||
document.getElementById('engine-switch-retry-label').textContent,
|
||||
'Choose an engine to try again.',
|
||||
);
|
||||
retry.click();
|
||||
assert.equal(status.getAttribute('role'), null);
|
||||
await settle();
|
||||
assert.deepEqual(f.starts, ['navara', 'cesium']);
|
||||
assert.equal(f.switches[0].kind, 'cesium');
|
||||
@@ -114,7 +155,60 @@ test('a recovered engine switch names the globe that remains running', async (t)
|
||||
recovered: true,
|
||||
kind: 'cesium',
|
||||
});
|
||||
await settle();
|
||||
assert.deepEqual(f.toasts, [
|
||||
'Globe engine failed to start. Running Cesium: Initialization failed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the startup cover stays until the share-link restore settles', async (t) => {
|
||||
// start() has resolved; startApplicationChrome hides the cover once the
|
||||
// share restore (initialRestorePromise) settles.
|
||||
const f = boot(t, { search: '?engine=cesium', failures: [] });
|
||||
await settle();
|
||||
assert.deepEqual(f.starts, ['cesium']);
|
||||
const loading = document.getElementById('loading-screen');
|
||||
assert.equal(loading.classList.contains('hidden'), false);
|
||||
f.switches[0].onSettled();
|
||||
assert.equal(loading.classList.contains('hidden'), false);
|
||||
});
|
||||
|
||||
test('the fallback notice waits for the restored view', async (t) => {
|
||||
let restore;
|
||||
const initialRestorePromise = new Promise((resolve) => {
|
||||
restore = resolve;
|
||||
});
|
||||
const f = boot(t, { initialRestorePromise });
|
||||
await settle();
|
||||
assert.deepEqual(f.starts, ['navara', 'cesium']);
|
||||
assert.deepEqual(f.toasts, []);
|
||||
restore();
|
||||
await settle();
|
||||
assert.deepEqual(f.toasts, [
|
||||
'Globe engine failed to start. Running Cesium: Engine failed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a successful retry keeps the failure cover until its view is restored', async (t) => {
|
||||
let restore;
|
||||
const initialRestorePromise = new Promise((resolve) => {
|
||||
restore = resolve;
|
||||
});
|
||||
boot(t, { search: '?engine=navara', initialRestorePromise });
|
||||
await settle();
|
||||
document.querySelector('[data-engine="cesium"]').click();
|
||||
await settle();
|
||||
const loading = document.getElementById('loading-screen');
|
||||
assert.equal(loading.classList.contains('hidden'), false);
|
||||
restore();
|
||||
await settle();
|
||||
assert.equal(loading.classList.contains('hidden'), true);
|
||||
assert.equal(document.querySelector('.engine-switch-retry'), null);
|
||||
});
|
||||
|
||||
test('a pick made mid-flight lands the flight before the view is written', async (t) => {
|
||||
const f = boot(t, { search: '?engine=cesium', failures: [] });
|
||||
await settle();
|
||||
f.addresses.find((options) => options.flush).flush();
|
||||
assert.deepEqual(f.flushes, ['flight', 'share link']);
|
||||
});
|
||||
@@ -1205,10 +1205,13 @@ export function createBhoteKoshiEventLayer({
|
||||
try {
|
||||
if (_engine && !_viewer) {
|
||||
const revision = _engine.maps.terrainRevision();
|
||||
const heights = await _engine.surface.sampleTerrainMostDetailed(
|
||||
observations.map(({ lon, lat }) =>
|
||||
Cesium.Cartographic.fromDegrees(lon, lat),
|
||||
const heights = await waitForSignal(
|
||||
_engine.surface.sampleTerrainMostDetailed(
|
||||
observations.map(({ lon, lat }) =>
|
||||
Cesium.Cartographic.fromDegrees(lon, lat),
|
||||
),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
// Heights of a terrain replaced meanwhile are not the one shown.
|
||||
if (_engine.maps.terrainRevision() === revision)
|
||||
|
||||
@@ -3211,7 +3211,7 @@ test('Bhote Koshi republishes its evidence cards to the incoming engine overlay'
|
||||
|
||||
// The event on a Navara engine double: its surface samples the terrain at
|
||||
// its finest level (or fails to), and its loaded terrain answers `terrain`.
|
||||
async function navaraEvidenceCard({ sample }) {
|
||||
async function navaraEvidenceCard({ sample, signal }) {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
installEventDom();
|
||||
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
|
||||
@@ -3251,7 +3251,7 @@ async function navaraEvidenceCard({ sample }) {
|
||||
renderHost: { request() {}, hold() {}, release() {} },
|
||||
});
|
||||
await layer.init(null, { engine });
|
||||
await layer.enable(null, { origin: 'local-restore' });
|
||||
await layer.enable(null, { origin: 'local-restore', signal });
|
||||
const [first] = event.evidenceSpine;
|
||||
const card = entries.find(
|
||||
(entry) => entry.id === `evidence-card-${first.id}`,
|
||||
@@ -3281,6 +3281,42 @@ test('Navara evidence anchors stand on the most detailed terrain', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Navara evidence sampling stops waiting when enable is cancelled', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
const controller = new AbortController();
|
||||
const started = Promise.withResolvers();
|
||||
const sample = Promise.withResolvers();
|
||||
const enabling = navaraEvidenceCard({
|
||||
signal: controller.signal,
|
||||
sample: () => {
|
||||
started.resolve();
|
||||
return sample.promise;
|
||||
},
|
||||
});
|
||||
let timer;
|
||||
try {
|
||||
await started.promise;
|
||||
controller.abort();
|
||||
const outcome = await Promise.race([
|
||||
enabling.then(
|
||||
() => 'enabled',
|
||||
(error) => error.name,
|
||||
),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve('still waiting'), 100);
|
||||
}),
|
||||
]);
|
||||
assert.equal(outcome, 'AbortError');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
sample.resolve([]);
|
||||
await enabling.catch(() => {});
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test('Navara evidence anchors without a terrain sample follow the terrain near the camera', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
@@ -9,18 +9,18 @@
|
||||
// geoImportPins.js); points on the ground draw over the scene and are
|
||||
// hidden beyond the horizon instead, as Cesium's clamped billboards
|
||||
// ignored the depth test;
|
||||
// - ground overlays: one image imagery layer each over its rectangle, its
|
||||
// image tinted and turned on a canvas when the KML says so, an absolute
|
||||
// one raised to its altitude; a plain colour is a ground fill.
|
||||
// - ground overlays: image layers, or textured meshes at absolute altitudes,
|
||||
// tinted and turned on a canvas when the KML says so; a plain colour is
|
||||
// a ground fill.
|
||||
// Nothing is pickable: an imported file has no card. Ground heights under
|
||||
// the points come from engine.surface, sampled again by refreshGround():
|
||||
// the terrain where the globe is drawn, else (3D tiles) the scene drawn
|
||||
// under a low camera, never an imported point (as Cesium's import kept its
|
||||
// pins out of height queries).
|
||||
|
||||
import earcut from 'earcut';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { normalizeGroundItem } from '../globe/ground.js';
|
||||
import { polygonShape, rectangleShape } from '../scenes/dataPacks/shapes.js';
|
||||
import { composeIcon } from './geoImportPins.js';
|
||||
import { recordFootprint } from './geoImportRecords.js';
|
||||
|
||||
@@ -37,8 +37,15 @@ const CLAMP_TILES_BELOW_M = 250_000;
|
||||
const BATCH = 256;
|
||||
// Canvas pixels every imported icon look shares (64 MiB of RGBA).
|
||||
const ICON_PIXEL_BUDGET = 16 * 1024 * 1024;
|
||||
// Canvas pixels every tinted or turned ground overlay of a file shares.
|
||||
const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024;
|
||||
// The longest side of a tinted or turned ground overlay's canvas.
|
||||
const MAX_OVERLAY_SIDE = 4096;
|
||||
// earcut runs on the main thread and grows quadratically with holes and
|
||||
// vertices: a raised polygon past these draws no fill (past the rings, not
|
||||
// even its outlines, one line each).
|
||||
const MAX_RAISED_RINGS = 128;
|
||||
const MAX_RAISED_VERTICES = 4096;
|
||||
let serial = 0;
|
||||
// Each engine's imported point sets, left out of each other's ground.
|
||||
const pointSets = new WeakMap();
|
||||
@@ -85,7 +92,7 @@ function overlayBox({ rectangle, rotation = 0 }) {
|
||||
// What a ground overlay's image layer draws: its image tinted by the
|
||||
// overlay's colour and turned by its rotation onto the box around the
|
||||
// turned image (transparent outside it). Null when that box passes a pole.
|
||||
async function overlayImage(overlay, loadImage, createCanvas) {
|
||||
async function overlayImage(overlay, loadImage, createCanvas, maxSide, cache) {
|
||||
const tint = overlay.color.slice(0, 3);
|
||||
const { box, center, angle } = overlayBox(overlay);
|
||||
if (tint.every((value) => value === 1) && !angle)
|
||||
@@ -102,8 +109,14 @@ async function overlayImage(overlay, loadImage, createCanvas) {
|
||||
east: center[0] + spanX / 2,
|
||||
north: center[1] + spanY / 2,
|
||||
};
|
||||
if (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360)
|
||||
if (angle && (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360))
|
||||
return null;
|
||||
const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon);
|
||||
const bounds = angle
|
||||
? { ...rectangle, west: wrap(rectangle.west), east: wrap(rectangle.east) }
|
||||
: overlay.rectangle;
|
||||
const key = JSON.stringify([overlay.url, tint, angle, width / height]);
|
||||
if (cache.has(key)) return { image: cache.get(key), rectangle: bounds };
|
||||
let image;
|
||||
try {
|
||||
image = await loadImage(overlay.url);
|
||||
@@ -116,13 +129,13 @@ async function overlayImage(overlay, loadImage, createCanvas) {
|
||||
image.naturalHeight || image.height,
|
||||
];
|
||||
// Pixels per degree: one scale on both axes when turned, so the image
|
||||
// turns in degrees as Cesium's does.
|
||||
// turns in degrees as Cesium's does (the canvas budget bounds its size).
|
||||
let [scaleX, scaleY] = [natural[0] / width, natural[1] / height];
|
||||
if (angle) scaleX = scaleY = Math.max(scaleX, scaleY);
|
||||
const fit = Math.min(
|
||||
1,
|
||||
MAX_OVERLAY_SIDE / (spanX * scaleX),
|
||||
MAX_OVERLAY_SIDE / (spanY * scaleY),
|
||||
maxSide / (spanX * scaleX),
|
||||
maxSide / (spanY * scaleY),
|
||||
);
|
||||
[scaleX, scaleY] = [scaleX * fit, scaleY * fit];
|
||||
const canvas = createCanvas();
|
||||
@@ -146,15 +159,8 @@ async function overlayImage(overlay, loadImage, createCanvas) {
|
||||
pixels.data[i + channel] *= tint[channel];
|
||||
context.putImageData(pixels, 0, 0);
|
||||
}
|
||||
const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon);
|
||||
return {
|
||||
image: canvas,
|
||||
rectangle: {
|
||||
...rectangle,
|
||||
west: wrap(rectangle.west),
|
||||
east: wrap(rectangle.east),
|
||||
},
|
||||
};
|
||||
cache.set(key, canvas);
|
||||
return { image: canvas, rectangle: bounds };
|
||||
}
|
||||
|
||||
// KML headings are from north (Cesium's billboard on the Earth's axis):
|
||||
@@ -240,27 +246,22 @@ export async function drawImportedFile(
|
||||
const fills = [];
|
||||
for (const polygon of records.polygons) {
|
||||
let any = false;
|
||||
if (polygon.raised && polygon.rings.length > MAX_RAISED_RINGS) {
|
||||
kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
if (polygon.fill && polygon.raised) {
|
||||
const vertices = polygon.rings.flat();
|
||||
const origin = vertices[0]?.[0] ?? 0;
|
||||
const xy = vertices.flatMap(([lon, lat]) => [
|
||||
origin + ((((lon - origin + 180) % 360) + 360) % 360) - 180,
|
||||
lat,
|
||||
]);
|
||||
let count = 0;
|
||||
const holes = polygon.rings
|
||||
.slice(0, -1)
|
||||
.map((ring) => (count += ring.length));
|
||||
const indices = earcut(xy, holes);
|
||||
if (indices.length) {
|
||||
const vertices = polygon.rings.reduce((n, ring) => n + ring.length, 0);
|
||||
const shape =
|
||||
vertices <= MAX_RAISED_VERTICES && polygonShape(polygon.rings);
|
||||
if (vertices > MAX_RAISED_VERTICES)
|
||||
kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1;
|
||||
if (shape) {
|
||||
meshes.push(
|
||||
engine.meshes.createMesh({
|
||||
id: `${prefix}-fill-${meshes.length}`,
|
||||
positions: vertices.flatMap((vertex) => {
|
||||
const { x, y, z } = cartesian(vertex);
|
||||
return [x, y, z];
|
||||
}),
|
||||
indices,
|
||||
positions: shape.attributes.position.values,
|
||||
indices: shape.indices,
|
||||
color: polygon.fill,
|
||||
show: shown,
|
||||
}),
|
||||
@@ -275,7 +276,7 @@ export async function drawImportedFile(
|
||||
if (item) {
|
||||
fills.push(item);
|
||||
any = true;
|
||||
}
|
||||
} else kinds.polygon = (kinds.polygon ?? 0) + 1;
|
||||
}
|
||||
if (polygon.raised && polygon.outline)
|
||||
for (const ring of polygon.rings) {
|
||||
@@ -293,18 +294,64 @@ export async function drawImportedFile(
|
||||
}
|
||||
if (any) drawn++;
|
||||
}
|
||||
for (const overlay of records.overlays) {
|
||||
const overlayCache = new Map();
|
||||
const overlayImages = new Map();
|
||||
const overlaySide = Math.min(
|
||||
MAX_OVERLAY_SIDE,
|
||||
Math.floor(
|
||||
Math.sqrt(OVERLAY_PIXEL_BUDGET / Math.max(1, records.overlays.length)),
|
||||
),
|
||||
);
|
||||
// KML drawOrder, then the document's: equal layer orders draw in
|
||||
// creation order, and a file imported later over this one.
|
||||
const overlays = [...records.overlays].sort(
|
||||
(a, b) => (a.drawOrder ?? 0) - (b.drawOrder ?? 0),
|
||||
);
|
||||
for (const overlay of overlays) {
|
||||
// Cesium raises only an absolute overlay, to its altitude.
|
||||
const raise =
|
||||
overlay.altitudeMode === 'absolute' && overlay.altitude > 0
|
||||
? overlay.altitude
|
||||
: undefined;
|
||||
if (overlay.url) {
|
||||
const prepared = await overlayImage(overlay, loadImage, createCanvas);
|
||||
const prepared = await overlayImage(
|
||||
overlay,
|
||||
loadImage,
|
||||
createCanvas,
|
||||
overlaySide,
|
||||
overlayCache,
|
||||
);
|
||||
if (!prepared) {
|
||||
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
if (raise) {
|
||||
let image = prepared.image ?? overlayImages.get(overlay.url);
|
||||
if (!image) {
|
||||
try {
|
||||
image = await loadImage(overlay.url);
|
||||
overlayImages.set(overlay.url, image);
|
||||
} catch {
|
||||
kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const { west, south, east, north } = prepared.rectangle;
|
||||
const shape = rectangleShape([west, south, east, north], raise);
|
||||
meshes.push(
|
||||
engine.meshes.createMesh({
|
||||
id: `${prefix}-overlay-${meshes.length}`,
|
||||
positions: shape.attributes.position.values,
|
||||
indices: shape.indices,
|
||||
uv: shape.attributes.st.values,
|
||||
image,
|
||||
color: [1, 1, 1, Math.min(0.99, overlay.color[3])],
|
||||
show: shown,
|
||||
}),
|
||||
);
|
||||
drawn++;
|
||||
continue;
|
||||
}
|
||||
layers.push(
|
||||
engine.imagery.createLayer({
|
||||
id: `${prefix}-overlay-${layers.length}`,
|
||||
@@ -313,7 +360,6 @@ export async function drawImportedFile(
|
||||
alpha: Math.min(0.99, overlay.color[3]),
|
||||
show: shown,
|
||||
order: OVERLAY_ORDER,
|
||||
...(raise ? { raise } : {}),
|
||||
}),
|
||||
);
|
||||
drawn++;
|
||||
@@ -336,7 +382,12 @@ export async function drawImportedFile(
|
||||
[box.west, box.north],
|
||||
].map(turn),
|
||||
];
|
||||
fills.push({ rings, fill: overlay.color });
|
||||
const fill = checked('polygon', { rings, fill: overlay.color });
|
||||
if (!fill) {
|
||||
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
fills.push(fill);
|
||||
drawn++;
|
||||
}
|
||||
if (fills.length) {
|
||||
@@ -345,10 +396,10 @@ export async function drawImportedFile(
|
||||
kind: 'polygon',
|
||||
order: POLYGON_ORDER,
|
||||
});
|
||||
groundSets.push(set);
|
||||
set.replace(fills);
|
||||
set.commit();
|
||||
set.show(shown);
|
||||
groundSets.push(set);
|
||||
}
|
||||
|
||||
const corridors = [];
|
||||
@@ -379,10 +430,10 @@ export async function drawImportedFile(
|
||||
kind: 'corridor',
|
||||
order: LINE_ORDER,
|
||||
});
|
||||
groundSets.push(set);
|
||||
set.replace(corridors);
|
||||
set.commit();
|
||||
set.show(shown);
|
||||
groundSets.push(set);
|
||||
}
|
||||
|
||||
for (const point of records.points) {
|
||||
@@ -507,6 +558,9 @@ export async function drawImportedFile(
|
||||
? Math.max(0, terrain)
|
||||
: terrain
|
||||
: (pin.ground ?? 0);
|
||||
// On tiles the last height found stays until the tiles answer again:
|
||||
// the terrain under them is not where the pin stands.
|
||||
if (tiles) ground = pin.ground ?? ground;
|
||||
if (
|
||||
tiles &&
|
||||
inWindow(geo.cartesian3.fromDegrees(pin.lon, pin.lat, ground), size)
|
||||
|
||||
@@ -6,10 +6,15 @@ import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
|
||||
import { drawImportedFile } from './geoImportDisplay.js';
|
||||
import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
|
||||
|
||||
// A 2D context that only records what draws.
|
||||
// A 2D context that only records what draws; its image data is one pixel.
|
||||
const createCanvas = () => ({
|
||||
getContext: () =>
|
||||
new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }),
|
||||
new Proxy(
|
||||
{ getImageData: () => ({ data: new Uint8ClampedArray(4) }) },
|
||||
{
|
||||
get: (target, key) => target[key] ?? (key in {} ? undefined : () => {}),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
for (const make of GROUND_HARNESSES) {
|
||||
@@ -234,6 +239,49 @@ for (const make of GROUND_HARNESSES) {
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a pin on the tiles keeps its height while out of the window',
|
||||
async ({ engine }) => {
|
||||
engine.imagery.surface = () => 'tiles';
|
||||
engine.camera.height = () => 1000;
|
||||
engine.projector.horizonVisible = () => true;
|
||||
let visible = true;
|
||||
engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null);
|
||||
engine.surface.clampToHeight = (position) => {
|
||||
const at = geo.cartographic.fromCartesian(position);
|
||||
return geo.cartesian3.fromRadians(at.longitude, at.latitude, 500);
|
||||
};
|
||||
const drawing = await drawImportedFile(
|
||||
engine,
|
||||
geoJsonRecords({ type: 'Point', coordinates: [1, 2] }, '#39d0ff'),
|
||||
{ createCanvas },
|
||||
);
|
||||
const height = () => Math.round(drawing.footprint()[0][2]);
|
||||
assert.equal(height(), 500);
|
||||
visible = false;
|
||||
await drawing.refreshGround();
|
||||
assert.equal(height(), 500, 'the measured tile height was replaced');
|
||||
let projected;
|
||||
engine.projector.worldToWindow = (position) => {
|
||||
projected = Math.round(geo.cartographic.fromCartesian(position).height);
|
||||
return projected >= 500 ? { x: 1, y: 1 } : null;
|
||||
};
|
||||
engine.surface.clampToHeight = () =>
|
||||
geo.cartesian3.fromDegrees(1, 2, 700);
|
||||
await drawing.refreshGround();
|
||||
assert.equal(projected, 500, 'visibility uses the last measured height');
|
||||
assert.equal(height(), 700);
|
||||
engine.surface.clampToHeight = () => undefined;
|
||||
await drawing.refreshGround();
|
||||
assert.equal(
|
||||
height(),
|
||||
700,
|
||||
'a missing tile sample keeps the last height',
|
||||
);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'fills keep holes, raised outlines and plain overlays',
|
||||
async ({ engine, set }) => {
|
||||
@@ -288,6 +336,240 @@ for (const make of GROUND_HARNESSES) {
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'icon variants of one file stay within a canvas budget',
|
||||
async ({ engine }) => {
|
||||
const records = emptyRecords();
|
||||
for (let i = 0; i < 2000; i++)
|
||||
records.points.push({
|
||||
position: [1, 2, 0],
|
||||
altitude: 'ground',
|
||||
icon: { image: { url: 'blob:icon' }, scale: 10 + i / 1000 },
|
||||
});
|
||||
const canvases = [];
|
||||
const drawing = await drawImportedFile(engine, records, {
|
||||
createCanvas: () => {
|
||||
const canvas = createCanvas();
|
||||
canvases.push(canvas);
|
||||
return canvas;
|
||||
},
|
||||
loadImage: async () => ({ width: 32, height: 32 }),
|
||||
pixelRatio: 2,
|
||||
});
|
||||
const bytes = canvases.reduce((n, c) => n + c.width * c.height * 4, 0);
|
||||
assert.ok(bytes <= 64 * 2 ** 20, `canvas bytes ${bytes / 2 ** 20} MiB`);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest('a raised polygon fill keeps its heights', async ({ engine }) => {
|
||||
const meshes = [];
|
||||
const create = engine.meshes.createMesh;
|
||||
engine.meshes.createMesh = (options) => {
|
||||
meshes.push(options);
|
||||
return create(options);
|
||||
};
|
||||
const records = emptyRecords();
|
||||
records.polygons.push({
|
||||
rings: [
|
||||
[
|
||||
[0, 0, 1000],
|
||||
[1, 0, 1000],
|
||||
[1, 1, 1000],
|
||||
],
|
||||
],
|
||||
raised: true,
|
||||
fill: [1, 0, 0, 1],
|
||||
outline: { color: [1, 1, 1, 1], widthPx: 1 },
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
assert.equal(engine.ground.list().length, 0);
|
||||
assert.equal(meshes.length, 1);
|
||||
assert.equal(meshes[0].positions[0], 6379137);
|
||||
assert.equal(meshes[0].indices.length, 3);
|
||||
drawing.destroy();
|
||||
});
|
||||
|
||||
displayTest(
|
||||
'a raised polygon whose vertices share one latitude keeps its fill',
|
||||
async ({ engine }) => {
|
||||
const records = emptyRecords();
|
||||
records.polygons.push({
|
||||
rings: [
|
||||
[
|
||||
[0, 85, 100000],
|
||||
[120, 85, 100000],
|
||||
[-120, 85, 100000],
|
||||
],
|
||||
],
|
||||
raised: true,
|
||||
fill: [0, 1, 0, 0.6],
|
||||
outline: null,
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
assert.equal(engine.meshes.list().length, 1, 'no fill mesh');
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a raised polygon with 10 000 holes does not block the import',
|
||||
async ({ engine }) => {
|
||||
const records = emptyRecords();
|
||||
const outer = Array.from({ length: 1000 }, (_, i) => {
|
||||
const a = (i / 1000) * 2 * Math.PI;
|
||||
return [10 * Math.cos(a), 10 * Math.sin(a), 1000];
|
||||
});
|
||||
const holes = [];
|
||||
for (let i = 0; i < 100; i++)
|
||||
for (let j = 0; j < 100; j++) {
|
||||
const [x, y, d] = [-6 + 0.12 * i, -6 + 0.12 * j, 0.03];
|
||||
holes.push([
|
||||
[x, y, 1000],
|
||||
[x + d, y, 1000],
|
||||
[x + d, y + d, 1000],
|
||||
[x, y + d, 1000],
|
||||
]);
|
||||
}
|
||||
records.polygons.push({
|
||||
rings: [outer, ...holes],
|
||||
raised: true,
|
||||
fill: [0, 1, 0, 0.6],
|
||||
outline: { color: [1, 1, 1, 1], widthPx: 1 },
|
||||
});
|
||||
const started = performance.now();
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
const ms = Math.round(performance.now() - started);
|
||||
assert.ok(ms < 1000, `the import step took ${ms} ms (41 000 vertices)`);
|
||||
assert.deepEqual(drawing.report, {
|
||||
drawn: 0,
|
||||
undrawn: 1,
|
||||
kinds: { complexPolygon: 1 },
|
||||
});
|
||||
assert.equal(engine.meshes.list().length + engine.lines.list().length, 0);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a raised fill past the vertex bound is counted, its outline drawn',
|
||||
async ({ engine }) => {
|
||||
const records = emptyRecords();
|
||||
const outer = Array.from({ length: 5000 }, (_, i) => {
|
||||
const angle = (i * 2 * Math.PI) / 5000;
|
||||
return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000];
|
||||
});
|
||||
records.polygons.push({
|
||||
rings: [outer],
|
||||
raised: true,
|
||||
fill: [1, 0, 0, 1],
|
||||
outline: { color: [1, 1, 1, 1], widthPx: 1 },
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
assert.deepEqual(drawing.report, {
|
||||
drawn: 1,
|
||||
undrawn: 1,
|
||||
kinds: { complexPolygon: 1 },
|
||||
});
|
||||
assert.equal(engine.meshes.list().length, 0);
|
||||
assert.equal(engine.lines.list().length, 1);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a polygon with a degenerate hole draws its outer ring',
|
||||
async ({ engine }) => {
|
||||
const records = geoJsonRecords(
|
||||
{
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
[0, 0],
|
||||
],
|
||||
[
|
||||
[1, 1],
|
||||
[1.1, 1.1],
|
||||
[1, 1],
|
||||
],
|
||||
],
|
||||
},
|
||||
'#39d0ff',
|
||||
);
|
||||
assert.equal(records.polygons[0].rings.length, 1);
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
assert.deepEqual(drawing.report, { drawn: 1, undrawn: 0, kinds: {} });
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a polygon the ground contract rejects is counted undrawn',
|
||||
async ({ engine }) => {
|
||||
const records = geoJsonRecords(
|
||||
{
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
],
|
||||
],
|
||||
},
|
||||
'#39d0ff',
|
||||
);
|
||||
records.polygons[0].rings.push([
|
||||
[1, 1],
|
||||
[1.1, 1.1],
|
||||
[1, 1],
|
||||
]);
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
assert.deepEqual(drawing.report, {
|
||||
drawn: 0,
|
||||
undrawn: 1,
|
||||
kinds: { polygon: 1 },
|
||||
});
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
for (const kind of ['Polygon', 'LineString'])
|
||||
displayTest(
|
||||
`a failed ${kind} ground set is released`,
|
||||
async ({ engine }) => {
|
||||
const create = engine.ground.createSet;
|
||||
engine.ground.createSet = (options) => {
|
||||
const set = create(options);
|
||||
set.replace = () => {
|
||||
throw new Error('replacement failed');
|
||||
};
|
||||
return set;
|
||||
};
|
||||
const positions = [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
];
|
||||
const records = geoJsonRecords(
|
||||
{
|
||||
type: kind,
|
||||
coordinates: kind === 'Polygon' ? [positions] : positions,
|
||||
},
|
||||
'#39d0ff',
|
||||
);
|
||||
await assert.rejects(
|
||||
drawImportedFile(engine, records, { createCanvas }),
|
||||
/replacement failed/,
|
||||
);
|
||||
assert.equal(engine.ground.list().length, 0);
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'failed icon composition releases geometry already created',
|
||||
async ({ engine }) => {
|
||||
@@ -384,6 +666,39 @@ for (const make of GROUND_HARNESSES) {
|
||||
return layers;
|
||||
};
|
||||
|
||||
displayTest(
|
||||
'overlay drawOrder ranks images stably above the raster layers',
|
||||
async ({ engine }) => {
|
||||
const layers = overlayLayer(engine);
|
||||
const records = emptyRecords();
|
||||
records.overlays = [10, -2, undefined, 10].map((drawOrder, index) => ({
|
||||
url: `blob:overlay-${index}`,
|
||||
drawOrder,
|
||||
color: [1, 1, 1, 1],
|
||||
rectangle: { west: 0, south: 0, east: 1, north: 1 },
|
||||
}));
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
const later = emptyRecords();
|
||||
later.overlays = [{ ...records.overlays[0], url: 'blob:later' }];
|
||||
later.overlays[0].drawOrder = -5;
|
||||
const over = await drawImportedFile(engine, later, { createCanvas });
|
||||
const urls = new Map(layers.map(({ id, source }) => [id, source.url]));
|
||||
assert.deepEqual(
|
||||
engine.imagery.list().map(({ id }) => urls.get(id)),
|
||||
[
|
||||
'blob:overlay-1',
|
||||
'blob:overlay-2',
|
||||
'blob:overlay-0',
|
||||
'blob:overlay-3',
|
||||
'blob:later',
|
||||
],
|
||||
'bottom first; a file imported later draws over this one',
|
||||
);
|
||||
over.destroy();
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'colored overlays multiply image RGB and retain layer alpha',
|
||||
async ({ engine }) => {
|
||||
@@ -502,10 +817,194 @@ for (const make of GROUND_HARNESSES) {
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'an absolute overlay is raised to its altitude, a relative one drapes',
|
||||
'a turned plain-colour overlay past the pole does not fail the file',
|
||||
async ({ engine }) => {
|
||||
const records = geoJsonRecords(
|
||||
{ type: 'Point', coordinates: [1, 2] },
|
||||
'#39d0ff',
|
||||
);
|
||||
records.overlays.push({
|
||||
url: null,
|
||||
color: [1, 0, 0, 0.5],
|
||||
rectangle: { west: 0, south: 80, east: 30, north: 89 },
|
||||
rotation: 45,
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
assert.deepEqual(drawing.report, {
|
||||
drawn: 1,
|
||||
undrawn: 1,
|
||||
kinds: { rotatedOverlay: 1 },
|
||||
});
|
||||
assert.equal(engine.ground.list().length, 0);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a tinted, unturned overlay over all longitudes still draws',
|
||||
async ({ engine }) => {
|
||||
const layers = overlayLayer(engine);
|
||||
const records = emptyRecords();
|
||||
records.overlays.push({
|
||||
url: 'blob:overlay',
|
||||
color: [1, 0, 0, 1],
|
||||
rectangle: { west: -180, south: -90, east: 180, north: 90 },
|
||||
rotation: 0,
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, {
|
||||
createCanvas,
|
||||
loadImage: async () => ({ width: 64, height: 32 }),
|
||||
});
|
||||
assert.deepEqual(drawing.report.kinds, {}, 'counted undrawn');
|
||||
assert.equal(layers.length, 1);
|
||||
assert.deepEqual(
|
||||
{ ...layers[0].source.rectangle },
|
||||
{ west: -180, south: -90, east: 180, north: 90 },
|
||||
);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
// Canvases made, their pixels counted.
|
||||
const countedCanvases = () => {
|
||||
const canvases = [];
|
||||
return {
|
||||
canvases,
|
||||
pixels: () => canvases.reduce((n, c) => n + c.width * c.height, 0),
|
||||
createCanvas: () => {
|
||||
const canvas = createCanvas();
|
||||
canvases.push(canvas);
|
||||
return canvas;
|
||||
},
|
||||
};
|
||||
};
|
||||
const OVERLAY_PIXELS = 16 * 1024 * 1024;
|
||||
|
||||
displayTest(
|
||||
'turned overlays sharing one small image share one bounded canvas',
|
||||
async ({ engine }) => {
|
||||
const counted = countedCanvases();
|
||||
const records = emptyRecords();
|
||||
for (let i = 0; i < 20; i++)
|
||||
records.overlays.push({
|
||||
url: 'blob:overlay',
|
||||
color: [1, 1, 1, 1],
|
||||
rectangle: { west: i, south: 10, east: i + 10, north: 10.01 },
|
||||
rotation: 45,
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, {
|
||||
createCanvas: counted.createCanvas,
|
||||
loadImage: async () => ({ width: 64, height: 64 }),
|
||||
});
|
||||
assert.equal(drawing.report.drawn, 20);
|
||||
assert.equal(counted.canvases.length, 1, 'equal looks share a canvas');
|
||||
// Each of 20 overlays may hold a twentieth of the budget.
|
||||
const side = Math.floor(Math.sqrt(OVERLAY_PIXELS / 20));
|
||||
const [canvas] = counted.canvases;
|
||||
assert.ok(
|
||||
canvas.width <= side && canvas.height <= side,
|
||||
`${canvas.width}`,
|
||||
);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'distinct tints of a large image stay within the canvas budget',
|
||||
async ({ engine }) => {
|
||||
const counted = countedCanvases();
|
||||
const records = emptyRecords();
|
||||
for (let i = 0; i < 100; i++)
|
||||
records.overlays.push({
|
||||
url: 'blob:overlay',
|
||||
color: [1, i / 100, 0, 1],
|
||||
rectangle: { west: 0, south: 0, east: 1, north: 1 },
|
||||
rotation: 0,
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, {
|
||||
createCanvas: counted.createCanvas,
|
||||
loadImage: async () => ({ width: 4096, height: 4096 }),
|
||||
});
|
||||
assert.equal(drawing.report.drawn, 100);
|
||||
assert.equal(counted.canvases.length, 100);
|
||||
assert.ok(
|
||||
counted.pixels() <= OVERLAY_PIXELS,
|
||||
`${Math.round((counted.pixels() * 4) / 2 ** 20)} MiB of canvas RGBA`,
|
||||
);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a shared overlay canvas keeps each rectangle and its aspect ratio',
|
||||
async ({ engine }) => {
|
||||
const counted = countedCanvases();
|
||||
const layers = overlayLayer(engine);
|
||||
const records = emptyRecords();
|
||||
for (const rectangle of [
|
||||
{ west: 0, south: 0, east: 2, north: 1 },
|
||||
{ west: 10, south: 0, east: 12, north: 1 },
|
||||
{ west: 0, south: 0, east: 1, north: 2 },
|
||||
])
|
||||
records.overlays.push({
|
||||
url: 'blob:overlay',
|
||||
color: [1, 1, 1, 1],
|
||||
rectangle,
|
||||
rotation: 45,
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, {
|
||||
createCanvas: counted.createCanvas,
|
||||
loadImage: async () => ({ width: 64, height: 32 }),
|
||||
});
|
||||
assert.equal(counted.canvases.length, 2);
|
||||
assert.equal(layers[0].source.image, layers[1].source.image);
|
||||
assert.notEqual(layers[0].source.image, layers[2].source.image);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
layers[1].source.rectangle.west -
|
||||
layers[0].source.rectangle.west -
|
||||
10,
|
||||
) < 1e-9,
|
||||
);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'a turned overlay keeps every source pixel of an image squeezed on one axis',
|
||||
async ({ engine }) => {
|
||||
const { calls, canvas } = recordingCanvas();
|
||||
const records = emptyRecords();
|
||||
// 32 pixels a degree across, 64 up: both axes draw at 64.
|
||||
records.overlays.push({
|
||||
url: 'blob:overlay',
|
||||
color: [1, 1, 1, 1],
|
||||
rectangle: { west: 0, south: 0, east: 2, north: 1 },
|
||||
rotation: 30,
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, {
|
||||
createCanvas: () => canvas,
|
||||
loadImage: async () => ({ width: 64, height: 64 }),
|
||||
});
|
||||
const [, , , width, height] = calls.find(
|
||||
([name]) => name === 'drawImage',
|
||||
);
|
||||
assert.deepEqual([width, height], [128, 64]);
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'an absolute overlay stays at its altitude while a relative one drapes',
|
||||
async ({ engine }) => {
|
||||
const layers = overlayLayer(engine);
|
||||
const meshes = [];
|
||||
const create = engine.meshes.createMesh;
|
||||
engine.meshes.createMesh = (options) => {
|
||||
meshes.push(options);
|
||||
return create(options);
|
||||
};
|
||||
const records = emptyRecords();
|
||||
for (const altitudeMode of ['absolute', 'relative'])
|
||||
records.overlays.push({
|
||||
url: 'blob:overlay',
|
||||
@@ -515,10 +1014,58 @@ for (const make of GROUND_HARNESSES) {
|
||||
rectangle: { west: 0, south: 0, east: 1, north: 1 },
|
||||
rotation: 0,
|
||||
});
|
||||
const drawing = await drawImportedFile(engine, records, { createCanvas });
|
||||
const drawing = await drawImportedFile(engine, records, {
|
||||
createCanvas,
|
||||
loadImage: async () => ({ width: 32, height: 32 }),
|
||||
});
|
||||
assert.deepEqual(drawing.report, { drawn: 2, undrawn: 0, kinds: {} });
|
||||
assert.equal(layers[0].raise, 1000);
|
||||
assert.equal(layers[1].raise, undefined);
|
||||
assert.equal(meshes.length, 1);
|
||||
assert.equal(layers.length, 1);
|
||||
assert.equal(layers[0].raise, undefined);
|
||||
const mesh = meshes[0];
|
||||
assert.equal(mesh.image.width, 32);
|
||||
assert.equal(mesh.uv.length, (mesh.positions.length / 3) * 2);
|
||||
// A fixed height, not imagery's shell that rises with the camera.
|
||||
for (let i = 0; i < mesh.positions.length; i += 3) {
|
||||
const position = geo.cartesian3.create(
|
||||
...mesh.positions.slice(i, i + 3),
|
||||
);
|
||||
const { height } = geo.cartographic.fromCartesian(position);
|
||||
assert.ok(Math.abs(height - 1000) < 0.01, `${height}`);
|
||||
}
|
||||
drawing.destroy();
|
||||
assert.equal(engine.meshes.list().length, 0);
|
||||
},
|
||||
);
|
||||
|
||||
displayTest(
|
||||
'absolute overlays share decoded images and report missing ones',
|
||||
async ({ engine }) => {
|
||||
const records = emptyRecords();
|
||||
for (const url of ['blob:overlay', 'blob:overlay', 'blob:missing'])
|
||||
records.overlays.push({
|
||||
url,
|
||||
altitudeMode: 'absolute',
|
||||
altitude: 1000,
|
||||
color: [1, 1, 1, 1],
|
||||
rectangle: { west: 0, south: 0, east: 1, north: 1 },
|
||||
});
|
||||
let loads = 0;
|
||||
const drawing = await drawImportedFile(engine, records, {
|
||||
createCanvas,
|
||||
loadImage: async (url) => {
|
||||
loads++;
|
||||
if (url === 'blob:missing') throw new Error('image unavailable');
|
||||
return { width: 32, height: 32 };
|
||||
},
|
||||
});
|
||||
assert.equal(loads, 2);
|
||||
assert.equal(engine.meshes.list().length, 2);
|
||||
assert.deepEqual(drawing.report, {
|
||||
drawn: 2,
|
||||
undrawn: 1,
|
||||
kinds: { raisedOverlay: 1 },
|
||||
});
|
||||
drawing.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { drawImportedFile } from './geoImportDisplay.js';
|
||||
import { emptyRecords } from './geoImportRecords.js';
|
||||
// A globe engine double that records what drawImportedFile asks of it.
|
||||
function recordingEngine() {
|
||||
const calls = { groundSets: [], lines: [], layers: [], moverSets: [] };
|
||||
const handle = (extra = {}) => ({
|
||||
destroy() {},
|
||||
show() {},
|
||||
commit() {},
|
||||
...extra,
|
||||
});
|
||||
const engine = {
|
||||
calls,
|
||||
render: { request() {} },
|
||||
ground: {
|
||||
createSet(options) {
|
||||
const rec = { options, items: [] };
|
||||
calls.groundSets.push(rec);
|
||||
return handle({ replace: (items) => (rec.items = items) });
|
||||
},
|
||||
},
|
||||
lines: {
|
||||
createLine(options) {
|
||||
const rec = { options, positions: null };
|
||||
calls.lines.push(rec);
|
||||
return handle({ setPositions: (p) => (rec.positions = p) });
|
||||
},
|
||||
},
|
||||
imagery: {
|
||||
surface: () => 'globe',
|
||||
createLayer(options) {
|
||||
calls.layers.push(options);
|
||||
return handle();
|
||||
},
|
||||
},
|
||||
movers: {
|
||||
createSet(options) {
|
||||
const rec = { options, items: [] };
|
||||
calls.moverSets.push(rec);
|
||||
return handle({
|
||||
set: (i, f) => (rec.items[i] = { ...rec.items[i], ...f }),
|
||||
setCount() {},
|
||||
});
|
||||
},
|
||||
},
|
||||
surface: { terrainHeightSupported: false, clampToHeightSupported: false },
|
||||
camera: { height: () => 1e7, livePosition: { x: 1, y: 0, z: 0 } },
|
||||
projector: { horizonVisible: () => true },
|
||||
frames: { preRender: { addEventListener: () => () => {} } },
|
||||
};
|
||||
return engine;
|
||||
}
|
||||
const createCanvas = () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () =>
|
||||
new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }),
|
||||
});
|
||||
const draw = (engine, records, extra = {}) =>
|
||||
drawImportedFile(engine, records, {
|
||||
createCanvas,
|
||||
loadImage: async () => ({ width: 32, height: 32 }),
|
||||
pixelRatio: 2,
|
||||
...extra,
|
||||
});
|
||||
const box = { west: 10, south: 10, east: 11, north: 11 };
|
||||
|
||||
test('icon variants of one file stay within a canvas budget', async () => {
|
||||
const engine = recordingEngine();
|
||||
const points = [];
|
||||
for (let i = 0; i < 2000; i++)
|
||||
points.push({
|
||||
position: [1, 2, 0],
|
||||
altitude: 'ground',
|
||||
icon: { image: { url: 'blob:icon' }, scale: 10 + i / 1000 },
|
||||
});
|
||||
let bytes = 0;
|
||||
await draw(
|
||||
engine,
|
||||
{ ...emptyRecords(), points },
|
||||
{
|
||||
createCanvas: () => {
|
||||
const c = createCanvas();
|
||||
return new Proxy(c, {
|
||||
set(target, key, value) {
|
||||
if (key === 'height') bytes += target.width * value * 4;
|
||||
target[key] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(bytes <= 256 * 2 ** 20, `canvas bytes ${bytes / 2 ** 20} MiB`);
|
||||
});
|
||||
@@ -9,7 +9,11 @@
|
||||
// overlays as image rectangles. Labels are not drawn (the import never
|
||||
// drew them); walls and extruded polygons are counted in `undrawn`.
|
||||
|
||||
import { emptyRecords, importFeatureName } from './geoImportRecords.js';
|
||||
import {
|
||||
emptyRecords,
|
||||
hasRingVertices,
|
||||
importFeatureName,
|
||||
} from './geoImportRecords.js';
|
||||
|
||||
// KmlDataSource's billboard: 32 px, scaled by camera distance.
|
||||
const BILLBOARD_SIZE = 32;
|
||||
@@ -328,7 +332,7 @@ function readGeometry(geometry, style, name, records) {
|
||||
coordinates(first(ring, 'coordinates')),
|
||||
),
|
||||
)
|
||||
.filter((ring) => ring.length);
|
||||
.filter(hasRingVertices);
|
||||
const poly = style.poly ?? { outline: true, outlineColor: WHITE };
|
||||
const raised = altitude !== 'ground';
|
||||
if (raised && extrude)
|
||||
@@ -379,6 +383,7 @@ function readGroundOverlay(el, records) {
|
||||
url,
|
||||
rectangle,
|
||||
color,
|
||||
drawOrder: numberOf(el, 'drawOrder') ?? 0,
|
||||
rotation: numberOf(box, 'rotation') ?? 0,
|
||||
altitude: numberOf(el, 'altitude') ?? 0,
|
||||
altitudeMode: altitudeOf(el),
|
||||
|
||||
@@ -412,3 +412,41 @@ test('GroundOverlay retains altitude and altitude mode for drawing diagnostics',
|
||||
assert.equal(records.overlays[0].altitude, 1000);
|
||||
assert.equal(records.overlays[0].altitudeMode, 'absolute');
|
||||
});
|
||||
|
||||
test('GroundOverlay keeps drawOrder, including negatives and the default', () => {
|
||||
const records = readKmlRecords(
|
||||
prepared(
|
||||
kml(
|
||||
[10, -2, null, 10]
|
||||
.map(
|
||||
(order) => `
|
||||
<GroundOverlay>
|
||||
${order === null ? '' : `<drawOrder>${order}</drawOrder>`}
|
||||
<LatLonBox><west>0</west><east>1</east><south>0</south><north>1</north></LatLonBox>
|
||||
</GroundOverlay>`,
|
||||
)
|
||||
.join(''),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.overlays.map((overlay) => overlay.drawOrder),
|
||||
[10, -2, 0, 10],
|
||||
);
|
||||
});
|
||||
|
||||
test('KML polygons discard degenerate holes and retain valid ones', () => {
|
||||
const records = readKmlRecords(
|
||||
prepared(
|
||||
kml(`
|
||||
<Placemark><Polygon>
|
||||
<outerBoundaryIs><LinearRing><coordinates>0,0 2,0 2,2 0,2 0,0</coordinates></LinearRing></outerBoundaryIs>
|
||||
<innerBoundaryIs><LinearRing><coordinates>1,1 1.1,1.1 1,1</coordinates></LinearRing></innerBoundaryIs>
|
||||
<innerBoundaryIs><LinearRing><coordinates>1,1 1.1,1.1 1,1 1.1,1.1</coordinates></LinearRing></innerBoundaryIs>
|
||||
<innerBoundaryIs><LinearRing><coordinates>0.5,0.5 0.6,0.5 0.6,0.6 0.5,0.5</coordinates></LinearRing></innerBoundaryIs>
|
||||
</Polygon></Placemark>`),
|
||||
),
|
||||
);
|
||||
assert.equal(records.polygons[0].rings.length, 2);
|
||||
assert.deepEqual(records.polygons[0].rings[1][0], [0.5, 0.5, 0]);
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { drawImportedFile } from './geoImportDisplay.js';
|
||||
import { emptyRecords } from './geoImportRecords.js';
|
||||
// A globe engine double that records what drawImportedFile asks of it.
|
||||
function recordingEngine() {
|
||||
const calls = {
|
||||
meshes: [],
|
||||
groundSets: [],
|
||||
lines: [],
|
||||
layers: [],
|
||||
moverSets: [],
|
||||
};
|
||||
const handle = (extra = {}) => ({
|
||||
destroy() {},
|
||||
show() {},
|
||||
commit() {},
|
||||
...extra,
|
||||
});
|
||||
const engine = {
|
||||
calls,
|
||||
render: { request() {} },
|
||||
meshes: {
|
||||
createMesh(options) {
|
||||
calls.meshes.push(options);
|
||||
return handle();
|
||||
},
|
||||
},
|
||||
ground: {
|
||||
createSet(options) {
|
||||
const rec = { options, items: [] };
|
||||
calls.groundSets.push(rec);
|
||||
return handle({ replace: (items) => (rec.items = items) });
|
||||
},
|
||||
},
|
||||
lines: {
|
||||
createLine(options) {
|
||||
const rec = { options, positions: null };
|
||||
calls.lines.push(rec);
|
||||
return handle({ setPositions: (p) => (rec.positions = p) });
|
||||
},
|
||||
},
|
||||
imagery: {
|
||||
surface: () => 'globe',
|
||||
createLayer(options) {
|
||||
calls.layers.push(options);
|
||||
return handle();
|
||||
},
|
||||
},
|
||||
movers: {
|
||||
createSet(options) {
|
||||
const rec = { options, items: [] };
|
||||
calls.moverSets.push(rec);
|
||||
return handle({
|
||||
set: (i, f) => (rec.items[i] = { ...rec.items[i], ...f }),
|
||||
setCount() {},
|
||||
});
|
||||
},
|
||||
},
|
||||
surface: { terrainHeightSupported: false, clampToHeightSupported: false },
|
||||
camera: { height: () => 1e7, livePosition: { x: 1, y: 0, z: 0 } },
|
||||
projector: { horizonVisible: () => true },
|
||||
frames: { preRender: { addEventListener: () => () => {} } },
|
||||
};
|
||||
return engine;
|
||||
}
|
||||
const createCanvas = () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () =>
|
||||
new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }),
|
||||
});
|
||||
const draw = (engine, records, extra = {}) =>
|
||||
drawImportedFile(engine, records, {
|
||||
createCanvas,
|
||||
loadImage: async () => ({ width: 32, height: 32 }),
|
||||
pixelRatio: 2,
|
||||
...extra,
|
||||
});
|
||||
const box = { west: 10, south: 10, east: 11, north: 11 };
|
||||
|
||||
test('a raised polygon fill keeps its heights (not a ground fill)', async () => {
|
||||
const engine = recordingEngine();
|
||||
await draw(engine, {
|
||||
...emptyRecords(),
|
||||
polygons: [
|
||||
{
|
||||
rings: [
|
||||
[
|
||||
[0, 0, 1000],
|
||||
[1, 0, 1000],
|
||||
[1, 1, 1000],
|
||||
],
|
||||
],
|
||||
raised: true,
|
||||
fill: [1, 0, 0, 1],
|
||||
outline: { color: [1, 1, 1, 1], widthPx: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const groundFills = engine.calls.groundSets
|
||||
.filter((s) => s.options.kind === 'polygon')
|
||||
.flatMap((s) => s.items);
|
||||
assert.equal(
|
||||
groundFills.length,
|
||||
0,
|
||||
'the 1000 m fill is draped on the ground',
|
||||
);
|
||||
assert.equal(engine.calls.meshes.length, 1);
|
||||
assert.equal(engine.calls.meshes[0].positions[0], 6379137);
|
||||
assert.equal(engine.calls.meshes[0].indices.length, 3);
|
||||
});
|
||||
@@ -17,8 +17,8 @@
|
||||
// @property {ImportedOverlay[]} overlays
|
||||
// @property {Object<string, number>} undrawn What the file has that no
|
||||
// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted;
|
||||
// the display adds the overlays it cannot draw (`rotatedOverlay`,
|
||||
// `raisedOverlay`).
|
||||
// the display adds overlays and polygons it cannot draw (`rotatedOverlay`,
|
||||
// `raisedOverlay`, `polygon`, `complexPolygon`).
|
||||
//
|
||||
// @typedef {object} ImportedPoint
|
||||
// @property {number[]} position
|
||||
@@ -63,6 +63,7 @@
|
||||
// @property {string|null} url Its image, or null for a plain colour.
|
||||
// @property {{ west: number, south: number, east: number, north: number }} rectangle
|
||||
// @property {number[]} color Multiplies the image; its alpha fades it.
|
||||
// @property {number} drawOrder Higher overlays draw over lower ones.
|
||||
// @property {number} rotation Degrees counter-clockwise (KML LatLonBox).
|
||||
// @property {number} altitude Metres (KML altitude); Cesium raises an
|
||||
// absolute overlay to it.
|
||||
@@ -99,6 +100,10 @@ export const recordCount = (records) =>
|
||||
records.polygons.length +
|
||||
records.overlays.length;
|
||||
|
||||
/** A ring needs three different ground positions to enclose a fill. */
|
||||
export const hasRingVertices = (ring) =>
|
||||
new Set(ring?.map(([lon, lat]) => `${lon},${lat}`)).size >= 3;
|
||||
|
||||
/** `#rrggbb` as rgba in 0..1, with Cesium.Color.fromCssColorString's bytes. */
|
||||
export function rgbaOfHex(css, alpha = 1) {
|
||||
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(
|
||||
@@ -175,13 +180,10 @@ export function geoJsonRecords(geojson, color) {
|
||||
color: stroke,
|
||||
widthPx: GEOJSON_STROKE_PX,
|
||||
});
|
||||
else if (
|
||||
kind === 'Polygon' &&
|
||||
new Set(part[0]?.map(([lon, lat]) => `${lon},${lat}`)).size >= 3
|
||||
)
|
||||
else if (kind === 'Polygon' && hasRingVertices(part[0]))
|
||||
records.polygons.push({
|
||||
name,
|
||||
rings: part.map((ring) => ring.map(position)),
|
||||
rings: part.filter(hasRingVertices).map((ring) => ring.map(position)),
|
||||
raised: false,
|
||||
fill,
|
||||
outline: { color: stroke, widthPx: GEOJSON_STROKE_PX },
|
||||
|
||||
@@ -147,6 +147,7 @@ export function createCesiumCamera(viewer) {
|
||||
// The engine's own flight, so flightActive is exact even for flights that
|
||||
// completed synchronously (Cesium keeps no current flight for those).
|
||||
let flight = null;
|
||||
let completing = false;
|
||||
function trackFlight(options) {
|
||||
const token = {};
|
||||
flight = token;
|
||||
@@ -161,7 +162,9 @@ export function createCesiumCamera(viewer) {
|
||||
},
|
||||
cancel: () => {
|
||||
end();
|
||||
options.cancel?.();
|
||||
// Cesium's completeFlight removes the tween (its cancel) before it
|
||||
// lands the flight (its complete).
|
||||
if (!completing) options.cancel?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -272,6 +275,14 @@ export function createCesiumCamera(viewer) {
|
||||
cesium.cancelFlight();
|
||||
flight = null;
|
||||
},
|
||||
completeFlight() {
|
||||
completing = true;
|
||||
try {
|
||||
cesium.completeFlight();
|
||||
} finally {
|
||||
completing = false;
|
||||
}
|
||||
},
|
||||
zoomIn(amount = cesium.defaultZoomAmount) {
|
||||
cesium.zoomIn(amount);
|
||||
},
|
||||
|
||||
@@ -132,6 +132,33 @@ test('flightActive also reports a flight started straight on the Cesium camera',
|
||||
assert.equal(camera.flightActive(), false);
|
||||
});
|
||||
|
||||
test('completeFlight lands at the destination and calls complete only', () => {
|
||||
const { scene, engine } = createCameraViewer();
|
||||
// Cesium's own flight: a real tween collection under the real camera.
|
||||
scene.tweens = new Cesium.TweenCollection();
|
||||
scene.screenSpaceCameraController = { enableInputs: true };
|
||||
const outcome = [];
|
||||
const destination = geo.cartesian3.fromDegrees(86.925, 27.988, 40000);
|
||||
engine.camera.completeFlight();
|
||||
engine.camera.flyTo({
|
||||
destination,
|
||||
orientation: { heading: 0.5, pitch: -0.8, roll: 0 },
|
||||
duration: 8,
|
||||
complete: () => outcome.push('complete'),
|
||||
cancel: () => outcome.push('cancel'),
|
||||
});
|
||||
assert.equal(engine.camera.flightActive(), true);
|
||||
engine.camera.completeFlight();
|
||||
assert.deepEqual(outcome, ['complete']);
|
||||
assert.equal(engine.camera.flightActive(), false);
|
||||
assert.equal(scene.screenSpaceCameraController.enableInputs, true);
|
||||
const pose = engine.camera.pose();
|
||||
assert.ok(geo.cartesian3.distance(pose.position, destination) < 0.01);
|
||||
assert.ok(Math.abs(pose.heading - 0.5) < 1e-6);
|
||||
assert.ok(Math.abs(pose.pitch + 0.8) < 1e-6);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('setView with direction and up keeps the exact basis next to nadir', () => {
|
||||
const { camera, engine } = createCameraViewer();
|
||||
const unit = (x, y, z) =>
|
||||
|
||||
@@ -623,3 +623,39 @@ test('a raised layer is a shell over its rectangle; a staged image swaps once it
|
||||
layer.destroy();
|
||||
assert.equal(shells[1].destroyed, true);
|
||||
});
|
||||
|
||||
async function swapped(f, layer, extra = {}) {
|
||||
const staged = layer.setSource(
|
||||
tiles({ url: 'https://next.example/{z}/{x}/{y}.png', ...extra }),
|
||||
{ staged: true },
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const incoming = drawnOn(f)[1];
|
||||
const request = incoming.imageryProvider.requestImage(0, 0, 1);
|
||||
f.providers[1].pending[0]('tile');
|
||||
await request;
|
||||
f.render();
|
||||
f.render();
|
||||
assert.equal(await staged, true);
|
||||
return incoming;
|
||||
}
|
||||
|
||||
test('after a staged swap a credit-only change reaches the drawn provider', async () => {
|
||||
const f = stagingFixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
const incoming = await swapped(f, layer, { credit: 'Old' });
|
||||
layer.setSource(
|
||||
tiles({ url: 'https://next.example/{z}/{x}/{y}.png', credit: 'New' }),
|
||||
);
|
||||
assert.equal(incoming.imageryProvider.credit?.html, 'New');
|
||||
});
|
||||
|
||||
test('after a staged swap the drawn provider still loads new tiles', async () => {
|
||||
const f = stagingFixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
const incoming = await swapped(f, layer);
|
||||
f.render();
|
||||
assert.deepEqual(drawnOn(f), [incoming]);
|
||||
const next = incoming.imageryProvider.requestImage(5, 3, 4);
|
||||
assert.ok(next instanceof Promise, 'a new tile after the swap is requested');
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js';
|
||||
|
||||
const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
|
||||
const tiles = (extra = {}) => ({
|
||||
kind: 'tiles',
|
||||
url: 'https://gibs-{s}.example/{z}/{y}/{x}.png',
|
||||
maxLevel: 12,
|
||||
rectangle: BOX,
|
||||
...extra,
|
||||
});
|
||||
const pixels = (width = 4, height = 2) => ({
|
||||
width,
|
||||
height,
|
||||
data: new Uint8ClampedArray(width * height * 4),
|
||||
});
|
||||
|
||||
/** A tile provider whose requests stay pending until the test settles them. */
|
||||
class PendingProvider {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.calls = [];
|
||||
this.pending = [];
|
||||
// What a Cesium ImageryLayer reads of its provider.
|
||||
this.tilingScheme =
|
||||
options.tilingScheme ?? new Cesium.WebMercatorTilingScheme();
|
||||
this.rectangle = options.rectangle ?? this.tilingScheme.rectangle;
|
||||
this.tileWidth = this.tileHeight = 256;
|
||||
this.minimumLevel = 0;
|
||||
this.maximumLevel = options.maximumLevel ?? 18;
|
||||
this.errorEvent = new Cesium.Event();
|
||||
}
|
||||
requestImage(x, y, level) {
|
||||
this.calls.push([x, y, level]);
|
||||
return new Promise((resolve) => this.pending.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function manualTimers() {
|
||||
const pending = new Map();
|
||||
let next = 1;
|
||||
return {
|
||||
pending,
|
||||
setTimeout(run, ms) {
|
||||
const id = next++;
|
||||
pending.set(id, { run, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => pending.delete(id),
|
||||
flush() {
|
||||
const entries = [...pending.values()];
|
||||
pending.clear();
|
||||
for (const entry of entries) entry.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// `base`: the globe has base imagery under the engine's layers, as the map
|
||||
// gives it.
|
||||
function fixture({ requestImage, base = true } = {}) {
|
||||
const preRender = new Cesium.Event();
|
||||
const preUpdate = new Cesium.Event();
|
||||
const viewer = {
|
||||
imageryLayers: new Cesium.ImageryLayerCollection(),
|
||||
scene: { globe: { show: true }, preRender, preUpdate, splitPosition: 0.5 },
|
||||
};
|
||||
const renders = [];
|
||||
const timers = manualTimers();
|
||||
const tileset = {
|
||||
show: false,
|
||||
imageryLayers: new Cesium.ImageryLayerCollection(),
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
const providers = [];
|
||||
const { imagery, destroy } = createCesiumImagery(viewer, {
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
tilesets: () => [tileset],
|
||||
createCanvas: (width, height) => ({
|
||||
width,
|
||||
height,
|
||||
getContext: () => ({
|
||||
createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }),
|
||||
putImageData() {},
|
||||
}),
|
||||
}),
|
||||
factories: {
|
||||
tiles(options) {
|
||||
const provider = new PendingProvider(options);
|
||||
if (requestImage) provider.requestImage = requestImage;
|
||||
providers.push(provider);
|
||||
return provider;
|
||||
},
|
||||
},
|
||||
setTimeout: timers.setTimeout,
|
||||
clearTimeout: timers.clearTimeout,
|
||||
});
|
||||
const globe = viewer.imageryLayers;
|
||||
const baseLayer = base
|
||||
? new Cesium.ImageryLayer(new PendingProvider({}))
|
||||
: null;
|
||||
if (baseLayer) globe.add(baseLayer);
|
||||
// A frame: preUpdate, then (Cesium picks the base layer) preRender.
|
||||
const frame = () => {
|
||||
preUpdate.raiseEvent();
|
||||
preRender.raiseEvent();
|
||||
};
|
||||
return {
|
||||
base: baseLayer,
|
||||
viewer,
|
||||
imagery,
|
||||
destroy,
|
||||
globe,
|
||||
tileset,
|
||||
providers,
|
||||
renders,
|
||||
timers,
|
||||
frame,
|
||||
tileFrames: () => renders.filter((r) => r === 'imagery-tiles').length,
|
||||
};
|
||||
}
|
||||
|
||||
const layersOf = (collection) =>
|
||||
Array.from({ length: collection.length }, (_, i) => collection.get(i));
|
||||
/** The layers on the globe over the fixture's base imagery. */
|
||||
const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base);
|
||||
|
||||
// A fixture whose scene draws frames: postRender, a globe whose tiles load.
|
||||
function stagingFixture() {
|
||||
const f = fixture();
|
||||
f.viewer.scene.postRender = new Cesium.Event();
|
||||
f.viewer.scene.globe.tilesLoaded = true;
|
||||
f.render = () => f.viewer.scene.postRender.raiseEvent();
|
||||
return f;
|
||||
}
|
||||
|
||||
async function swapped(f, layer, extra = {}) {
|
||||
const staged = layer.setSource(
|
||||
tiles({ url: 'https://next.example/{z}/{x}/{y}.png', ...extra }),
|
||||
{ staged: true },
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const incoming = drawnOn(f)[1];
|
||||
const request = incoming.imageryProvider.requestImage(0, 0, 1);
|
||||
f.providers[1].pending[0]('tile');
|
||||
await request;
|
||||
f.render();
|
||||
f.render();
|
||||
assert.equal(await staged, true);
|
||||
return incoming;
|
||||
}
|
||||
|
||||
test('after a staged swap a credit-only change reaches the drawn provider', async () => {
|
||||
const f = stagingFixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
const incoming = await swapped(f, layer, { credit: 'Old' });
|
||||
layer.setSource(
|
||||
tiles({ url: 'https://next.example/{z}/{x}/{y}.png', credit: 'New' }),
|
||||
);
|
||||
assert.equal(incoming.imageryProvider.credit?.html, 'New');
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js';
|
||||
|
||||
const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
|
||||
const tiles = (extra = {}) => ({
|
||||
kind: 'tiles',
|
||||
url: 'https://gibs-{s}.example/{z}/{y}/{x}.png',
|
||||
maxLevel: 12,
|
||||
rectangle: BOX,
|
||||
...extra,
|
||||
});
|
||||
const pixels = (width = 4, height = 2) => ({
|
||||
width,
|
||||
height,
|
||||
data: new Uint8ClampedArray(width * height * 4),
|
||||
});
|
||||
|
||||
/** A tile provider whose requests stay pending until the test settles them. */
|
||||
class PendingProvider {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.calls = [];
|
||||
this.pending = [];
|
||||
// What a Cesium ImageryLayer reads of its provider.
|
||||
this.tilingScheme =
|
||||
options.tilingScheme ?? new Cesium.WebMercatorTilingScheme();
|
||||
this.rectangle = options.rectangle ?? this.tilingScheme.rectangle;
|
||||
this.tileWidth = this.tileHeight = 256;
|
||||
this.minimumLevel = 0;
|
||||
this.maximumLevel = options.maximumLevel ?? 18;
|
||||
this.errorEvent = new Cesium.Event();
|
||||
}
|
||||
requestImage(x, y, level) {
|
||||
this.calls.push([x, y, level]);
|
||||
return new Promise((resolve) => this.pending.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function manualTimers() {
|
||||
const pending = new Map();
|
||||
let next = 1;
|
||||
return {
|
||||
pending,
|
||||
setTimeout(run, ms) {
|
||||
const id = next++;
|
||||
pending.set(id, { run, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => pending.delete(id),
|
||||
flush() {
|
||||
const entries = [...pending.values()];
|
||||
pending.clear();
|
||||
for (const entry of entries) entry.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// `base`: the globe has base imagery under the engine's layers, as the map
|
||||
// gives it.
|
||||
function fixture({ requestImage, base = true } = {}) {
|
||||
const preRender = new Cesium.Event();
|
||||
const preUpdate = new Cesium.Event();
|
||||
const viewer = {
|
||||
imageryLayers: new Cesium.ImageryLayerCollection(),
|
||||
scene: { globe: { show: true }, preRender, preUpdate, splitPosition: 0.5 },
|
||||
};
|
||||
const renders = [];
|
||||
const timers = manualTimers();
|
||||
const tileset = {
|
||||
show: false,
|
||||
imageryLayers: new Cesium.ImageryLayerCollection(),
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
const providers = [];
|
||||
const { imagery, destroy } = createCesiumImagery(viewer, {
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
tilesets: () => [tileset],
|
||||
createCanvas: (width, height) => ({
|
||||
width,
|
||||
height,
|
||||
getContext: () => ({
|
||||
createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }),
|
||||
putImageData() {},
|
||||
}),
|
||||
}),
|
||||
factories: {
|
||||
tiles(options) {
|
||||
const provider = new PendingProvider(options);
|
||||
if (requestImage) provider.requestImage = requestImage;
|
||||
providers.push(provider);
|
||||
return provider;
|
||||
},
|
||||
},
|
||||
setTimeout: timers.setTimeout,
|
||||
clearTimeout: timers.clearTimeout,
|
||||
});
|
||||
const globe = viewer.imageryLayers;
|
||||
const baseLayer = base
|
||||
? new Cesium.ImageryLayer(new PendingProvider({}))
|
||||
: null;
|
||||
if (baseLayer) globe.add(baseLayer);
|
||||
// A frame: preUpdate, then (Cesium picks the base layer) preRender.
|
||||
const frame = () => {
|
||||
preUpdate.raiseEvent();
|
||||
preRender.raiseEvent();
|
||||
};
|
||||
return {
|
||||
base: baseLayer,
|
||||
viewer,
|
||||
imagery,
|
||||
destroy,
|
||||
globe,
|
||||
tileset,
|
||||
providers,
|
||||
renders,
|
||||
timers,
|
||||
frame,
|
||||
tileFrames: () => renders.filter((r) => r === 'imagery-tiles').length,
|
||||
};
|
||||
}
|
||||
|
||||
const layersOf = (collection) =>
|
||||
Array.from({ length: collection.length }, (_, i) => collection.get(i));
|
||||
/** The layers on the globe over the fixture's base imagery. */
|
||||
const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base);
|
||||
|
||||
// A fixture whose scene draws frames: postRender, a globe whose tiles load.
|
||||
function stagingFixture() {
|
||||
const f = fixture();
|
||||
f.viewer.scene.postRender = new Cesium.Event();
|
||||
f.viewer.scene.globe.tilesLoaded = true;
|
||||
f.render = () => f.viewer.scene.postRender.raiseEvent();
|
||||
return f;
|
||||
}
|
||||
|
||||
async function swapped(f, layer, extra = {}) {
|
||||
const staged = layer.setSource(
|
||||
tiles({ url: 'https://next.example/{z}/{x}/{y}.png', ...extra }),
|
||||
{ staged: true },
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const incoming = drawnOn(f)[1];
|
||||
const request = incoming.imageryProvider.requestImage(0, 0, 1);
|
||||
f.providers[1].pending[0]('tile');
|
||||
await request;
|
||||
f.render();
|
||||
f.render();
|
||||
assert.equal(await staged, true);
|
||||
return incoming;
|
||||
}
|
||||
|
||||
test('after a staged swap the drawn provider still loads new tiles', async () => {
|
||||
const f = stagingFixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
const incoming = await swapped(f, layer);
|
||||
f.render();
|
||||
assert.deepEqual(drawnOn(f), [incoming]);
|
||||
const next = incoming.imageryProvider.requestImage(5, 3, 4);
|
||||
assert.ok(next instanceof Promise, 'a new tile after the swap is requested');
|
||||
});
|
||||
@@ -231,8 +231,13 @@ function modelPart(options, store, { factories, requestRender, owners }) {
|
||||
const { model, pickable } = options;
|
||||
const models = [];
|
||||
const loading = [];
|
||||
// Instances whose model failed: not tried again (MoverSet.failed).
|
||||
// Instances whose model failed: not tried again (MoverSet.failed). An
|
||||
// index holds the pickId it failed for: a layer moves another instance
|
||||
// into a freed index, and that one loads its own model.
|
||||
const failed = [];
|
||||
const failedFor = [];
|
||||
const failedNow = (index) =>
|
||||
failed[index] === true && failedFor[index] === store.pickIds[index];
|
||||
let destroyed = false;
|
||||
const place = (item, index) => {
|
||||
const { orientations } = store;
|
||||
@@ -292,6 +297,7 @@ function modelPart(options, store, { factories, requestRender, owners }) {
|
||||
.catch((error) => {
|
||||
if (destroyed) return;
|
||||
failed[index] = true;
|
||||
failedFor[index] = store.pickIds[index];
|
||||
console.warn(`[movers] ${options.id}: model failed`, error);
|
||||
});
|
||||
};
|
||||
@@ -306,13 +312,15 @@ function modelPart(options, store, { factories, requestRender, owners }) {
|
||||
return true;
|
||||
},
|
||||
failed(index) {
|
||||
if (index !== undefined) return failed[index] === true;
|
||||
for (let at = 0; at < store.count; at++) if (failed[at]) return true;
|
||||
if (index !== undefined) return failedNow(index);
|
||||
for (let at = 0; at < store.count; at++) if (failedNow(at)) return true;
|
||||
return false;
|
||||
},
|
||||
commit(range) {
|
||||
if (range)
|
||||
for (let index = range.from; index < range.to; index++) {
|
||||
if (failed[index] && !failedNow(index))
|
||||
failed[index] = loading[index] = false;
|
||||
if (index < store.count) ensure(index);
|
||||
if (models[index]) place(models[index], index);
|
||||
}
|
||||
@@ -323,6 +331,7 @@ function modelPart(options, store, { factories, requestRender, owners }) {
|
||||
models.length = 0;
|
||||
loading.length = 0;
|
||||
failed.length = 0;
|
||||
failedFor.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -442,6 +442,42 @@ test('a model that fails to load marks its instance failed, once', async (t) =>
|
||||
assert.equal(set.failed(), false, 'a destroyed set reports nothing');
|
||||
});
|
||||
|
||||
// A layer removes an instance by moving its last one into the freed index
|
||||
// (src/layers/shared/render/moverBank.js).
|
||||
test('an instance moved into a failed index loads its own model', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let loads = 0;
|
||||
const { movers } = setup({
|
||||
loadModel: async (options) => {
|
||||
if (loads++ === 0) throw new Error('503');
|
||||
return fakeModel(options);
|
||||
},
|
||||
});
|
||||
const set = movers.createSet({
|
||||
id: 'fleet',
|
||||
kind: 'model',
|
||||
capacity: 2,
|
||||
model: { url: '/a.glb' },
|
||||
});
|
||||
set.set(0, { position: at(0, 0), pickId: 'a' });
|
||||
set.set(1, { position: at(1, 0), pickId: 'b' });
|
||||
set.setCount(2);
|
||||
set.commit();
|
||||
await settle();
|
||||
assert.equal(set.failed(0), true);
|
||||
assert.equal(set.failed(1), false);
|
||||
// 'a' is removed: 'b' moves into index 0.
|
||||
set.set(0, { position: at(1, 0), pickId: 'b' });
|
||||
set.setCount(1);
|
||||
assert.equal(set.failed(0), false, "'b' does not take the failure of 'a'");
|
||||
assert.equal(set.failed(), false);
|
||||
set.commit();
|
||||
await settle();
|
||||
assert.equal(loads, 3, 'index 0 loads again for its new instance');
|
||||
assert.equal(set.failed(0), false);
|
||||
set.destroy();
|
||||
});
|
||||
|
||||
test('identify names the set, instance and pickId of a picked billboard or point', async () => {
|
||||
const { movers, root, identify, viewer } = setup();
|
||||
// The fake camera is over (0°, ~1°), 7000 km from the centre.
|
||||
|
||||
@@ -106,6 +106,9 @@
|
||||
* @property {() => Matrix4} transform A copy of the current camera transform.
|
||||
* @property {() => void} release lookAtTransform(IDENTITY).
|
||||
* @property {() => void} cancelFlight
|
||||
* @property {() => void} completeFlight Cesium's completeFlight: a running
|
||||
* flight lands at once (its destination and end transform) and calls
|
||||
* `complete`, not `cancel`; without a flight it does nothing.
|
||||
* @property {(amount?: number) => void} zoomIn Moves `amount` metres along direction.
|
||||
* @property {(amount?: number) => void} zoomOut Moves `amount` metres against direction.
|
||||
* @property {() => ({ west: number, south: number, east: number, north: number } | undefined)} viewRectangle
|
||||
@@ -221,6 +224,7 @@
|
||||
* ellipsoid. Rejects when the terrain cannot be sampled (Cesium: one
|
||||
* without tile availability). The terrain is the one shown at the call;
|
||||
* maps.terrainRevision tells when it changed since.
|
||||
* @property {(position: Cartesian3, exclude?: any[]) => (Cartesian3 | undefined)} clampToHeight
|
||||
* The position moved onto the rendered scene along the geodetic normal.
|
||||
* @property {boolean} clampToHeightSupported
|
||||
* @property {() => boolean} tilesLoaded
|
||||
@@ -472,6 +476,7 @@
|
||||
* @property {(index?: number) => boolean} failed Whether what the set draws
|
||||
* failed to load (with an index, what that instance draws): it stays not
|
||||
* ready and is not tried again; a layer that retries makes a new set.
|
||||
* Cesium loads per instance: an index given another pickId loads again.
|
||||
* @property {() => void} destroy
|
||||
*
|
||||
* @typedef {object} GlobeMovers
|
||||
@@ -980,6 +985,7 @@ const SHAPE = {
|
||||
transform: isFunction,
|
||||
release: isFunction,
|
||||
cancelFlight: isFunction,
|
||||
completeFlight: isFunction,
|
||||
zoomIn: isFunction,
|
||||
zoomOut: isFunction,
|
||||
viewRectangle: isFunction,
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { isEngineEvent } from '../contract.js';
|
||||
import { orientationVectors } from '../cameraMath.js';
|
||||
import { createCameraModel } from './cameraModel.js';
|
||||
|
||||
const { cartesian2, cartesian3, math, matrix4 } = geo;
|
||||
const near = (actual, expected, tolerance, label) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: ${actual} is not within ${tolerance} of ${expected}`,
|
||||
);
|
||||
|
||||
// A stand-in for Navara's camera: setCamera lands on the next update with
|
||||
// Navara's ±89.99° pitch clamp; flights resolve when the test says so.
|
||||
function createFakePort() {
|
||||
const port = {
|
||||
applied: [],
|
||||
flights: [],
|
||||
stops: 0,
|
||||
frames: 0,
|
||||
drawn: null,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
apply(pose) {
|
||||
port.applied.push(pose);
|
||||
},
|
||||
read: () => port.drawn,
|
||||
fly(pose, options) {
|
||||
let settle;
|
||||
const promise = new Promise((resolve) => (settle = resolve));
|
||||
port.flights.push({ pose, options, settle });
|
||||
return promise;
|
||||
},
|
||||
stop() {
|
||||
port.stops++;
|
||||
},
|
||||
size: () => ({ width: port.width, height: port.height }),
|
||||
requestFrame() {
|
||||
port.frames++;
|
||||
},
|
||||
/** Navara's update: draw a newly applied pose (pitch clamped). */
|
||||
drawnCount: 0,
|
||||
update() {
|
||||
if (port.applied.length === port.drawnCount) return;
|
||||
port.drawnCount = port.applied.length;
|
||||
const pose = port.applied.at(-1);
|
||||
const pitch = Math.max(-89.99, Math.min(89.99, pose.pitch));
|
||||
const position = cartesian3.fromDegrees(pose.lng, pose.lat, pose.height);
|
||||
port.drawn = {
|
||||
position,
|
||||
...orientationVectors(
|
||||
cartesian3.fromDegrees(pose.lng, pose.lat, 0),
|
||||
math.toRadians(pose.heading),
|
||||
math.toRadians(pitch),
|
||||
math.toRadians(pose.roll),
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
return port;
|
||||
}
|
||||
|
||||
function createClock() {
|
||||
const clock = {
|
||||
time: 0,
|
||||
timers: [],
|
||||
now: () => clock.time,
|
||||
setTimeout(fn, ms) {
|
||||
const timer = { fn, at: clock.time + ms };
|
||||
clock.timers.push(timer);
|
||||
return timer;
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
clock.timers = clock.timers.filter((entry) => entry !== timer);
|
||||
},
|
||||
advance(ms) {
|
||||
clock.time += ms;
|
||||
for (const timer of [...clock.timers])
|
||||
if (timer.at <= clock.time) {
|
||||
clock.clearTimeout(timer);
|
||||
timer.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
return clock;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const port = createFakePort();
|
||||
const clock = createClock();
|
||||
const model = createCameraModel({ port, clock });
|
||||
// One Navara frame: update, preRender sync, postRender events.
|
||||
const frame = (ms = 16) => {
|
||||
clock.advance(ms);
|
||||
port.update();
|
||||
model.sync();
|
||||
model.afterFrame();
|
||||
};
|
||||
return { port, clock, model, camera: model.camera, frame };
|
||||
}
|
||||
|
||||
// Cesium's Camera._updateCameraChanged: direction angle / (fovy / 2), and
|
||||
// heading or roll change / π, each against percentageChanged (0.5).
|
||||
test('turning in place raises changed as Cesium does', () => {
|
||||
const { camera, frame } = setup();
|
||||
const destination = cartesian3.fromDegrees(10, 45, 5000);
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
frame();
|
||||
frame();
|
||||
let changed = 0;
|
||||
camera.changed.addEventListener(() => changed++);
|
||||
// Tilt from straight down to 45° on the spot: 45° > half the fovy.
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -(Math.PI / 4), roll: 0 },
|
||||
});
|
||||
frame();
|
||||
assert.equal(changed, 1, 'a 45° tilt in place');
|
||||
// Heading +170° looking straight down (north-up reset): direction unchanged.
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
frame();
|
||||
const before = changed;
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: {
|
||||
heading: math.toRadians(170),
|
||||
pitch: -math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
frame();
|
||||
assert.equal(changed, before + 1, 'a 170° heading turn in the nadir');
|
||||
});
|
||||
@@ -206,27 +206,34 @@ export function createCameraModel({
|
||||
options.complete?.();
|
||||
return;
|
||||
}
|
||||
const current = { options, resume: port.suspendCameraInputs?.() };
|
||||
flight = current;
|
||||
// Navara moves the camera from here on; read it back every frame.
|
||||
pushed = null;
|
||||
const pose = navaraPose(target.position, target.direction, target.up);
|
||||
const flyOptions = {};
|
||||
if (options.duration !== undefined)
|
||||
flyOptions.duration = options.duration * 1000;
|
||||
if (options.easing !== undefined)
|
||||
flyOptions.easing = NAVARA_EASINGS[options.easing];
|
||||
port
|
||||
.fly(navaraPose(target.position, target.direction, target.up), flyOptions)
|
||||
.then(
|
||||
(arrived) => {
|
||||
if (flight !== current || destroyed) return;
|
||||
if (!arrived) return endFlight(current, 'cancel');
|
||||
endFlight(current, 'complete');
|
||||
land(options, target);
|
||||
options.complete?.();
|
||||
},
|
||||
() => endFlight(current, 'cancel'),
|
||||
);
|
||||
const current = { options, target, resume: port.suspendCameraInputs?.() };
|
||||
flight = current;
|
||||
let pending;
|
||||
try {
|
||||
pending = port.fly(pose, flyOptions);
|
||||
} catch (error) {
|
||||
// As in Cesium, a flight that cannot start reports only the exception.
|
||||
endFlight(current, 'fail');
|
||||
throw error;
|
||||
}
|
||||
// Navara moves the camera from here on; read it back every frame.
|
||||
pushed = null;
|
||||
pending.then(
|
||||
(arrived) => {
|
||||
if (flight !== current || destroyed) return;
|
||||
if (!arrived) return endFlight(current, 'cancel');
|
||||
endFlight(current, 'complete');
|
||||
land(options, target);
|
||||
options.complete?.();
|
||||
},
|
||||
() => endFlight(current, 'cancel'),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads Navara's drawn camera; called once per frame before preRender. */
|
||||
@@ -384,6 +391,16 @@ export function createCameraModel({
|
||||
announce();
|
||||
cancelCurrentFlight();
|
||||
},
|
||||
completeFlight() {
|
||||
const current = flight;
|
||||
if (!current) return;
|
||||
endFlight(current, 'complete');
|
||||
// Cesium lands it through setView, a command.
|
||||
announce();
|
||||
port.stop();
|
||||
land(current.options, current.target);
|
||||
current.options.complete?.();
|
||||
},
|
||||
zoomIn(amount = DEFAULT_ZOOM_AMOUNT) {
|
||||
place({
|
||||
...state,
|
||||
|
||||
@@ -273,6 +273,67 @@ test('easings map onto Navara presets and an omitted one stays Navara’s own',
|
||||
assert.equal(port.flights.at(-1).options.duration, undefined);
|
||||
});
|
||||
|
||||
test('an invalid flight pose does not suspend camera inputs', () => {
|
||||
const { camera, port } = setup();
|
||||
let suspensions = 0;
|
||||
port.suspendCameraInputs = () => {
|
||||
suspensions++;
|
||||
return () => {};
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
camera.flyTo({
|
||||
destination: cartesian3.create(),
|
||||
orientation: {
|
||||
direction: cartesian3.create(0, 0, -1),
|
||||
up: cartesian3.create(0, 1, 0),
|
||||
},
|
||||
duration: 2,
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
assert.equal(suspensions, 0);
|
||||
assert.equal(camera.flightActive(), false);
|
||||
assert.equal(port.flights.length, 0);
|
||||
});
|
||||
|
||||
test('a synchronous flight failure releases the camera inputs', async (context) => {
|
||||
for (const method of ['flyTo', 'flyToSphere'])
|
||||
await context.test(method, () => {
|
||||
const { camera, port, model } = setup();
|
||||
const outcome = [];
|
||||
let suspensions = 0;
|
||||
port.suspendCameraInputs = () => {
|
||||
suspensions++;
|
||||
return () => suspensions--;
|
||||
};
|
||||
const error = new Error('Flight could not start');
|
||||
port.fly = () => {
|
||||
throw error;
|
||||
};
|
||||
const destination = cartesian3.fromDegrees(10, 45, 5000);
|
||||
const options = {
|
||||
duration: 2,
|
||||
complete: () => outcome.push('complete'),
|
||||
cancel: () => outcome.push('cancel'),
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
method === 'flyTo'
|
||||
? camera.flyTo({ destination, ...options })
|
||||
: camera.flyToSphere({ center: destination, radius: 100 }, options),
|
||||
(thrown) => thrown === error,
|
||||
);
|
||||
assert.equal(camera.flightActive(), false);
|
||||
assert.equal(suspensions, 0);
|
||||
assert.deepEqual(outcome, [], 'the exception alone reports it');
|
||||
camera.cancelFlight();
|
||||
model.destroy();
|
||||
assert.equal(suspensions, 0, 'the failed flight resumes only once');
|
||||
assert.deepEqual(outcome, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('cancelFlight stops Navara in place and calls cancel, never complete', async () => {
|
||||
const { camera, port } = setup();
|
||||
const outcome = [];
|
||||
@@ -290,6 +351,43 @@ test('cancelFlight stops Navara in place and calls cancel, never complete', asyn
|
||||
assert.deepEqual(outcome, ['cancel']);
|
||||
});
|
||||
|
||||
test('completeFlight lands at the destination and calls complete, as Cesium', async () => {
|
||||
const { camera, port } = setup();
|
||||
const outcome = [];
|
||||
let suspensions = 0;
|
||||
port.suspendCameraInputs = () => {
|
||||
suspensions++;
|
||||
return () => suspensions--;
|
||||
};
|
||||
const destination = cartesian3.fromDegrees(86.925, 27.988, 40000);
|
||||
camera.completeFlight();
|
||||
assert.equal(port.stops, 0, 'without a flight it does nothing');
|
||||
camera.flyTo({
|
||||
destination,
|
||||
orientation: { heading: 0.5, pitch: -0.8, roll: 0 },
|
||||
duration: 8,
|
||||
endTransform: matrix4.IDENTITY,
|
||||
complete: () => outcome.push('complete'),
|
||||
cancel: () => outcome.push('cancel'),
|
||||
});
|
||||
// Navara is midway when the flight is completed.
|
||||
const midway = cartesian3.fromDegrees(50, 15, 8e6);
|
||||
port.drawn = { position: midway, ...orientationVectors(midway, 0, -1, 0) };
|
||||
camera.completeFlight();
|
||||
assert.deepEqual(outcome, ['complete']);
|
||||
assert.equal(camera.flightActive(), false);
|
||||
assert.equal(suspensions, 0, 'the camera inputs are back');
|
||||
assert.equal(port.stops, 1);
|
||||
const pose = camera.pose();
|
||||
near(cartesian3.distance(pose.position, destination), 0, 1e-6, 'position');
|
||||
near(pose.heading, 0.5, 1e-9, 'heading');
|
||||
near(pose.pitch, -0.8, 1e-9, 'pitch');
|
||||
near(port.applied.at(-1).height, 40000, 1e-6, 'pushed to Navara');
|
||||
port.flights[0].settle(false);
|
||||
await flush();
|
||||
assert.deepEqual(outcome, ['complete']);
|
||||
});
|
||||
|
||||
test('a newer flight cancels the previous one', async () => {
|
||||
const { camera, port } = setup();
|
||||
const outcome = [];
|
||||
@@ -548,3 +646,42 @@ test('setView cancels a model flight before its promise settles', async () => {
|
||||
'setView stays',
|
||||
);
|
||||
});
|
||||
|
||||
// Cesium's Camera._updateCameraChanged: direction angle / (fovy / 2), and
|
||||
// heading or roll change / π, each against percentageChanged (0.5).
|
||||
test('turning in place raises changed as Cesium does', () => {
|
||||
const { camera, frame } = setup();
|
||||
const destination = cartesian3.fromDegrees(10, 45, 5000);
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
frame();
|
||||
frame();
|
||||
let changed = 0;
|
||||
camera.changed.addEventListener(() => changed++);
|
||||
// Tilt from straight down to 45° on the spot: 45° > half the fovy.
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -(Math.PI / 4), roll: 0 },
|
||||
});
|
||||
frame();
|
||||
assert.equal(changed, 1, 'a 45° tilt in place');
|
||||
// Heading +170° looking straight down (north-up reset): direction unchanged.
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
frame();
|
||||
const before = changed;
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: {
|
||||
heading: math.toRadians(170),
|
||||
pitch: -math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
frame();
|
||||
assert.equal(changed, before + 1, 'a 170° heading turn in the nadir');
|
||||
});
|
||||
@@ -739,3 +739,33 @@ test('ground picks use the visible surface and fall back to the globe', async ()
|
||||
assert.equal(engine.picker.pick(at)?.id, 'roof');
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
// Cesium's CameraFlightPath turns the camera inputs off for the flight, so a
|
||||
// gesture never cancels it; layers/infrastructure suspends the inputs for the
|
||||
// same reason ("Camera input would cancel the flight").
|
||||
test('a wheel during a flight with the camera inputs suspended does not cancel it', async () => {
|
||||
const { engine, view } = setup();
|
||||
const outcome = [];
|
||||
const restore = engine.input.suspendCameraInputs();
|
||||
engine.camera.flyTo({
|
||||
destination: cartesian3.fromDegrees(100, 0, 5e6),
|
||||
duration: 1.5,
|
||||
complete: () => outcome.push('complete'),
|
||||
cancel: () => outcome.push('cancel'),
|
||||
});
|
||||
view.canvas.dispatchEvent(new Event('wheel'));
|
||||
view.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
assert.deepEqual(outcome, [], 'the flight goes on');
|
||||
assert.equal(engine.camera.flightActive(), true);
|
||||
restore();
|
||||
assert.equal(
|
||||
engine.input.cameraInputsEnabled(),
|
||||
false,
|
||||
'the flight keeps its own suspension',
|
||||
);
|
||||
view.flights[0].resolve(true);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(outcome, ['complete']);
|
||||
assert.equal(engine.input.cameraInputsEnabled(), true);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { assertGlobeEngine, engineCapabilities } from '../contract.js';
|
||||
import { cesiumFovy, orientationVectors } from '../cameraMath.js';
|
||||
import { createNavaraEngine } from './createNavaraEngine.js';
|
||||
|
||||
const { cartesian2, cartesian3, math } = geo;
|
||||
|
||||
// The parts of a Navara ThreeView the engine uses.
|
||||
function fakeView() {
|
||||
const handlers = new Map();
|
||||
const canvas = new EventTarget();
|
||||
Object.assign(canvas, {
|
||||
clientWidth: 1280,
|
||||
clientHeight: 800,
|
||||
getBoundingClientRect: () => ({ left: 10, top: 20 }),
|
||||
parentElement: { id: 'container' },
|
||||
});
|
||||
const position = cartesian3.fromDegrees(0, 0, 1e7);
|
||||
const { direction, up } = orientationVectors(
|
||||
position,
|
||||
0,
|
||||
-math.PI_OVER_TWO,
|
||||
0,
|
||||
);
|
||||
// three's camera.matrixWorld: columns right, up, back (−direction), position.
|
||||
const right = cartesian3.cross(direction, up, cartesian3.create());
|
||||
const elements = [
|
||||
right.x,
|
||||
right.y,
|
||||
right.z,
|
||||
0,
|
||||
up.x,
|
||||
up.y,
|
||||
up.z,
|
||||
0,
|
||||
-direction.x,
|
||||
-direction.y,
|
||||
-direction.z,
|
||||
0,
|
||||
position.x,
|
||||
position.y,
|
||||
position.z,
|
||||
1,
|
||||
];
|
||||
const view = {
|
||||
canvas,
|
||||
animation: false,
|
||||
calls: [],
|
||||
flights: [],
|
||||
on(name, fn) {
|
||||
if (!handlers.has(name)) handlers.set(name, new Set());
|
||||
handlers.get(name).add(fn);
|
||||
},
|
||||
off(name, fn) {
|
||||
handlers.get(name)?.delete(fn);
|
||||
},
|
||||
emit(name, ...args) {
|
||||
for (const fn of [...(handlers.get(name) ?? [])]) fn(...args);
|
||||
},
|
||||
listenerCount: () =>
|
||||
[...handlers.values()].reduce((count, set) => count + set.size, 0),
|
||||
camera: {
|
||||
raw: { matrixWorld: { elements }, updateMatrixWorld() {} },
|
||||
fovs: [],
|
||||
set fov(value) {
|
||||
view.camera.fovs.push(value);
|
||||
},
|
||||
set options(value) {
|
||||
view.calls.push(['options', value]);
|
||||
},
|
||||
},
|
||||
setCamera(pose) {
|
||||
view.calls.push(['setCamera', pose]);
|
||||
},
|
||||
flyTo(pose, options) {
|
||||
return new Promise((resolve) =>
|
||||
view.flights.push({ pose, options, resolve }),
|
||||
);
|
||||
},
|
||||
forceUpdate() {
|
||||
view.calls.push(['forceUpdate']);
|
||||
},
|
||||
pickDepthPosition(x, y) {
|
||||
view.calls.push(['pickDepthPosition', x, y]);
|
||||
return x > 100 ? { x: 1, y: 2, z: 3, isVector3: true } : null;
|
||||
},
|
||||
sampleTerrainHeight: () => undefined,
|
||||
addSource: (desc) => ({ id: 's', desc, delete: () => true }),
|
||||
addLayer: (desc) => ({ id: 'l', desc, delete() {} }),
|
||||
attribution: { add() {}, remove() {} },
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
const hooks = {
|
||||
tileRoots: () => [],
|
||||
isIdle: () => true,
|
||||
retainTileGeometry() {},
|
||||
};
|
||||
|
||||
// Primitive ports for tests of engine orchestration.
|
||||
const STUB_MOVERS = Object.freeze({
|
||||
createSet() {
|
||||
throw new Error("Navara movers need the view's three runtime (movers/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const STUB_LINES = Object.freeze({
|
||||
createSegments() {
|
||||
throw new Error("Navara lines need the view's three runtime (lines/)");
|
||||
},
|
||||
createLine() {
|
||||
throw new Error("Navara lines need the view's three runtime (lines/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const STUB_MESHES = Object.freeze({
|
||||
createMesh() {
|
||||
throw new Error('Navara meshes need the view runtime');
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const STUB_GROUND = Object.freeze({
|
||||
createPulseField() {
|
||||
throw new Error('Navara ground needs the view runtime');
|
||||
},
|
||||
createSet() {
|
||||
throw new Error("Navara ground needs the view's three runtime (ground/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const STUB_IMAGERY = Object.freeze({
|
||||
createLayer() {
|
||||
throw new Error("Navara imagery needs the view's three runtime (imagery/)");
|
||||
},
|
||||
list: () => [],
|
||||
setSplitPosition() {},
|
||||
splitPosition: () => 0.5,
|
||||
surface: () => 'globe',
|
||||
});
|
||||
|
||||
const ports = {
|
||||
movers: { movers: STUB_MOVERS, destroy() {} },
|
||||
lines: { lines: STUB_LINES, destroy() {} },
|
||||
ground: { ground: STUB_GROUND, destroy() {} },
|
||||
imagery: () => ({ imagery: STUB_IMAGERY, destroy() {} }),
|
||||
meshes: () => ({ meshes: STUB_MESHES, destroy() {} }),
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const view = fakeView();
|
||||
const engine = createNavaraEngine(view, { ...ports, hooks });
|
||||
return { view, engine };
|
||||
};
|
||||
|
||||
// Cesium's CameraFlightPath turns the camera inputs off for the flight, so a
|
||||
// gesture never cancels it; layers/infrastructure suspends the inputs for the
|
||||
// same reason ("Camera input would cancel the flight").
|
||||
test('a wheel during a flight with the camera inputs suspended does not cancel it', async () => {
|
||||
const { engine, view } = setup();
|
||||
const outcome = [];
|
||||
const restore = engine.input.suspendCameraInputs();
|
||||
engine.camera.flyTo({
|
||||
destination: cartesian3.fromDegrees(100, 0, 5e6),
|
||||
duration: 1.5,
|
||||
complete: () => outcome.push('complete'),
|
||||
cancel: () => outcome.push('cancel'),
|
||||
});
|
||||
view.canvas.dispatchEvent(new Event('wheel'));
|
||||
view.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
assert.deepEqual(outcome, [], 'the flight goes on');
|
||||
assert.equal(engine.camera.flightActive(), true);
|
||||
restore();
|
||||
assert.equal(
|
||||
engine.input.cameraInputsEnabled(),
|
||||
false,
|
||||
'the flight keeps its own suspension',
|
||||
);
|
||||
view.flights[0].resolve(true);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(outcome, ['complete']);
|
||||
assert.equal(engine.input.cameraInputsEnabled(), true);
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -313,3 +313,61 @@ test('custom windows keep down synchronous, moves coalesced and clicks on one GP
|
||||
);
|
||||
destroy();
|
||||
});
|
||||
|
||||
test('a pinch whose still finger lifts first is not a click', async () => {
|
||||
const { canvas, input, destroy } = setup();
|
||||
let clicks = 0;
|
||||
input.on('click', () => clicks++);
|
||||
const touch = (type, id, x, y) =>
|
||||
pointer(type, x, y, {
|
||||
pointerId: id,
|
||||
pointerType: 'touch',
|
||||
isPrimary: id === 1,
|
||||
});
|
||||
canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
|
||||
canvas.dispatchEvent(touch('pointerdown', 2, 300, 300));
|
||||
// Only the first finger moves (a one-sided pinch).
|
||||
for (let i = 1; i <= 5; i++)
|
||||
canvas.dispatchEvent(touch('pointermove', 1, 100 - 20 * i, 300));
|
||||
canvas.dispatchEvent(touch('pointerup', 2, 300, 300));
|
||||
canvas.dispatchEvent(touch('pointerup', 1, 0, 300));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(clicks, 0);
|
||||
canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
|
||||
canvas.dispatchEvent(touch('pointercancel', 1, 100, 300));
|
||||
canvas.dispatchEvent(touch('pointerup', 1, 100, 300));
|
||||
await Promise.resolve();
|
||||
assert.equal(clicks, 0, 'a cancelled pointer cannot click');
|
||||
canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
|
||||
canvas.dispatchEvent(touch('pointerup', 1, 100, 300));
|
||||
await Promise.resolve();
|
||||
assert.equal(clicks, 1, 'the next single tap still clicks');
|
||||
destroy();
|
||||
});
|
||||
|
||||
test('a mouse released off the canvas does not hold back the next tap', async () => {
|
||||
const { canvas, input, destroy } = setup();
|
||||
let clicks = 0;
|
||||
input.on('click', () => clicks++);
|
||||
// The mouse goes down on the globe and up outside it.
|
||||
canvas.dispatchEvent(
|
||||
pointer('pointerdown', 100, 300, { pointerId: 1, isPrimary: true }),
|
||||
);
|
||||
canvas.dispatchEvent(
|
||||
pointer('pointerdown', 100, 300, {
|
||||
pointerId: 7,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
}),
|
||||
);
|
||||
canvas.dispatchEvent(
|
||||
pointer('pointerup', 100, 300, {
|
||||
pointerId: 7,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
assert.equal(clicks, 1);
|
||||
destroy();
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNavaraInput } from './input.js';
|
||||
|
||||
function fakeCanvas() {
|
||||
const canvas = new EventTarget();
|
||||
canvas.getBoundingClientRect = () => ({ left: 100, top: 50 });
|
||||
canvas.listenerCount = 0;
|
||||
const add = canvas.addEventListener.bind(canvas);
|
||||
const remove = canvas.removeEventListener.bind(canvas);
|
||||
canvas.addEventListener = (...args) => {
|
||||
canvas.listenerCount++;
|
||||
add(...args);
|
||||
};
|
||||
canvas.removeEventListener = (...args) => {
|
||||
canvas.listenerCount--;
|
||||
remove(...args);
|
||||
};
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const pointer = (type, x, y, extra = {}) =>
|
||||
Object.assign(new Event(type), {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
button: 0,
|
||||
shiftKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const canvas = fakeCanvas();
|
||||
const cameraOptions = [];
|
||||
const view = {
|
||||
canvas,
|
||||
camera: {
|
||||
set options(value) {
|
||||
cameraOptions.push(value);
|
||||
},
|
||||
},
|
||||
};
|
||||
const { input, destroy } = createNavaraInput(view);
|
||||
return { canvas, input, destroy, cameraOptions };
|
||||
}
|
||||
|
||||
test('a pinch whose still finger lifts first is not a click', async () => {
|
||||
const { canvas, input, destroy } = setup();
|
||||
let clicks = 0;
|
||||
input.on('click', () => clicks++);
|
||||
const touch = (type, id, x, y) =>
|
||||
pointer(type, x, y, {
|
||||
pointerId: id,
|
||||
pointerType: 'touch',
|
||||
isPrimary: id === 1,
|
||||
});
|
||||
canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
|
||||
canvas.dispatchEvent(touch('pointerdown', 2, 300, 300));
|
||||
// Only the first finger moves (a one-sided pinch).
|
||||
for (let i = 1; i <= 5; i++)
|
||||
canvas.dispatchEvent(touch('pointermove', 1, 100 - 20 * i, 300));
|
||||
canvas.dispatchEvent(touch('pointerup', 2, 300, 300));
|
||||
canvas.dispatchEvent(touch('pointerup', 1, 0, 300));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(clicks, 0);
|
||||
canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
|
||||
canvas.dispatchEvent(touch('pointercancel', 1, 100, 300));
|
||||
canvas.dispatchEvent(touch('pointerup', 1, 100, 300));
|
||||
await Promise.resolve();
|
||||
assert.equal(clicks, 0, 'a cancelled pointer cannot click');
|
||||
canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
|
||||
canvas.dispatchEvent(touch('pointerup', 1, 100, 300));
|
||||
await Promise.resolve();
|
||||
assert.equal(clicks, 1, 'the next single tap still clicks');
|
||||
destroy();
|
||||
});
|
||||
|
||||
test('a mouse released off the canvas does not hold back the next tap', async () => {
|
||||
const { canvas, input, destroy } = setup();
|
||||
let clicks = 0;
|
||||
input.on('click', () => clicks++);
|
||||
// The mouse goes down on the globe and up outside it.
|
||||
canvas.dispatchEvent(
|
||||
pointer('pointerdown', 100, 300, { pointerId: 1, isPrimary: true }),
|
||||
);
|
||||
canvas.dispatchEvent(
|
||||
pointer('pointerdown', 100, 300, {
|
||||
pointerId: 7,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
}),
|
||||
);
|
||||
canvas.dispatchEvent(
|
||||
pointer('pointerup', 100, 300, {
|
||||
pointerId: 7,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
assert.equal(clicks, 1);
|
||||
destroy();
|
||||
});
|
||||
@@ -238,3 +238,41 @@ test('ordinary overlays keep the direct pass without a display copy or target',
|
||||
assert.equal(h.renderer.autoClear, true);
|
||||
h.pass.dispose();
|
||||
});
|
||||
|
||||
test('static sprite glyphs are drawn again when the Cyber sonar moves', () => {
|
||||
const h = harness(),
|
||||
input = { width: 1280, height: 800, texture: {} },
|
||||
output = {},
|
||||
mesh = new InstancedMesh(
|
||||
new PlaneGeometry(1, 1),
|
||||
new MeshBasicMaterial({ map: new Texture() }),
|
||||
1,
|
||||
);
|
||||
Object.assign(h.overlay.uniforms, {
|
||||
uGevSonar: { value: [0, 1, 0.84, 1] },
|
||||
uGevSonarSector: { value: 24 },
|
||||
uGevSonarEnabled: { value: 1 },
|
||||
});
|
||||
mesh.userData.gevStaticSprite = true;
|
||||
mesh.count = 1;
|
||||
h.scene.add(mesh);
|
||||
h.setActive(true);
|
||||
h.pass.render(h.renderer, input, output);
|
||||
const drawn = () => h.draws.filter((draw) => draw.object === h.scene).length;
|
||||
assert.equal(drawn(), 1);
|
||||
// The sweep turns (sonar.js writes the shared uniform each frame).
|
||||
h.overlay.uniforms.uGevSonar.value[0] = 90;
|
||||
h.pass.render(h.renderer, input, output);
|
||||
assert.equal(drawn(), 2, 'a new sonar angle draws the glyphs again');
|
||||
h.overlay.uniforms.uGevSonarSector.value = 60;
|
||||
h.pass.render(h.renderer, input, output);
|
||||
assert.equal(drawn(), 3, 'a new sector draws the glyphs again');
|
||||
// The Cyber treatment is turned off.
|
||||
h.overlay.uniforms.uGevSonarEnabled.value = 0;
|
||||
h.pass.render(h.renderer, input, output);
|
||||
assert.equal(drawn(), 4, 'sonar off draws the glyphs again');
|
||||
// Off, the angle still moves every frame; the glyphs stay cached.
|
||||
h.overlay.uniforms.uGevSonar.value[0] = 180;
|
||||
h.pass.render(h.renderer, input, output);
|
||||
assert.equal(drawn(), 4, 'the angle of an unused sonar draws nothing');
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
Scene,
|
||||
PerspectiveCamera,
|
||||
HalfFloatType,
|
||||
NoColorSpace,
|
||||
SRGBColorSpace,
|
||||
InstancedMesh,
|
||||
MeshBasicMaterial,
|
||||
PlaneGeometry,
|
||||
Texture,
|
||||
} from 'three';
|
||||
import { GevOverlayPass } from './overlayPass.js';
|
||||
|
||||
function harness() {
|
||||
let active = false,
|
||||
target = null,
|
||||
clearColor = 0x123456,
|
||||
clearAlpha = 1;
|
||||
const overlay = {
|
||||
displayBlend: true,
|
||||
hasDrawables: () => active,
|
||||
uniforms: {
|
||||
uGevResolution: { value: [0, 0] },
|
||||
uGevDisplayBlend: { value: 0 },
|
||||
uGevSonar: { value: [0, 1, 0.84, 1] },
|
||||
uGevSonarSector: { value: 24 },
|
||||
uGevSonarEnabled: { value: 1 },
|
||||
},
|
||||
};
|
||||
const scene = new Scene(),
|
||||
pass = new GevOverlayPass(scene, new PerspectiveCamera(), overlay),
|
||||
draws = [];
|
||||
const renderer = {
|
||||
domElement: new EventTarget(),
|
||||
autoClear: true,
|
||||
outputColorSpace: SRGBColorSpace,
|
||||
getPixelRatio: () => 1,
|
||||
getClearColor(color) {
|
||||
return color.set(clearColor);
|
||||
},
|
||||
getClearAlpha() {
|
||||
return clearAlpha;
|
||||
},
|
||||
setClearColor(color, alpha) {
|
||||
clearColor = color.isColor ? color.getHex() : color;
|
||||
clearAlpha = alpha;
|
||||
},
|
||||
clear() {},
|
||||
get clearAlpha() {
|
||||
return clearAlpha;
|
||||
},
|
||||
setRenderTarget(value) {
|
||||
target = value;
|
||||
},
|
||||
render(object) {
|
||||
draws.push({
|
||||
object,
|
||||
target,
|
||||
displayBlend: overlay.uniforms.uGevDisplayBlend.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
return {
|
||||
overlay,
|
||||
scene,
|
||||
pass,
|
||||
draws,
|
||||
renderer,
|
||||
setActive(value) {
|
||||
active = value;
|
||||
},
|
||||
get target() {
|
||||
return target;
|
||||
},
|
||||
};
|
||||
}
|
||||
test('static sprite glyphs are drawn again when the Cyber sonar moves', () => {
|
||||
const h = harness(),
|
||||
input = { width: 1280, height: 800, texture: {} },
|
||||
output = {},
|
||||
mesh = new InstancedMesh(
|
||||
new PlaneGeometry(1, 1),
|
||||
new MeshBasicMaterial({ map: new Texture() }),
|
||||
1,
|
||||
);
|
||||
mesh.userData.gevStaticSprite = true;
|
||||
mesh.count = 1;
|
||||
h.scene.add(mesh);
|
||||
h.setActive(true);
|
||||
h.pass.render(h.renderer, input, output);
|
||||
const drawn = () => h.draws.filter((draw) => draw.object === h.scene).length;
|
||||
assert.equal(drawn(), 1);
|
||||
// The sweep turns (sonar.js writes the shared uniform each frame).
|
||||
h.overlay.uniforms.uGevSonar.value[0] = 90;
|
||||
h.pass.render(h.renderer, input, output);
|
||||
assert.equal(drawn(), 2, 'a new sonar angle draws the glyphs again');
|
||||
h.overlay.uniforms.uGevSonarSector.value = 60;
|
||||
h.pass.render(h.renderer, input, output);
|
||||
assert.equal(drawn(), 3, 'a new sector draws the glyphs again');
|
||||
// The Cyber treatment is turned off.
|
||||
h.overlay.uniforms.uGevSonarEnabled.value = 0;
|
||||
h.pass.render(h.renderer, input, output);
|
||||
assert.equal(drawn(), 4, 'sonar off draws the glyphs again');
|
||||
// Off, the angle still moves every frame; the glyphs stay cached.
|
||||
h.overlay.uniforms.uGevSonar.value[0] = 180;
|
||||
h.pass.render(h.renderer, input, output);
|
||||
assert.equal(drawn(), 4, 'the angle of an unused sonar draws nothing');
|
||||
});
|
||||
@@ -1490,6 +1490,40 @@ export function createContractSuite(
|
||||
ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.completeFlight: lands a running flight at once and calls complete',
|
||||
async () => {
|
||||
await nadirView();
|
||||
camera.completeFlight();
|
||||
const outcome = [];
|
||||
const destination = cartesian3.fromDegrees(100, 0, 5e6);
|
||||
camera.flyTo({
|
||||
destination,
|
||||
orientation: { heading: 0.5, pitch: -0.8, roll: 0 },
|
||||
duration: 5,
|
||||
complete: () => outcome.push('complete'),
|
||||
cancel: () => outcome.push('cancel'),
|
||||
});
|
||||
await settle(2);
|
||||
camera.completeFlight();
|
||||
ensure(
|
||||
outcome.join() === 'complete',
|
||||
`completeFlight returned with [${outcome}] instead of [complete]`,
|
||||
);
|
||||
ensure(!camera.flightActive(), 'the flight is still active');
|
||||
const pose = camera.pose();
|
||||
near(
|
||||
cartesian3.distance(pose.position, destination),
|
||||
0,
|
||||
0.01,
|
||||
'position error (m)',
|
||||
);
|
||||
near(angleBetween(pose.heading, 0.5), 0, 1e-6, 'heading error');
|
||||
near(pose.pitch, -0.8, 1e-6, 'pitch');
|
||||
await settle(3);
|
||||
ensure(outcome.join() === 'complete', `flight ended with ${outcome}`);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.setView: cancels an active flight and retains its destination',
|
||||
async () => {
|
||||
|
||||
@@ -590,6 +590,14 @@ export function createFakeEngine(options = {}) {
|
||||
command('camera.cancelFlight', []);
|
||||
cancelCurrentFlight();
|
||||
},
|
||||
completeFlight() {
|
||||
const current = flight;
|
||||
if (!current) return record('camera.completeFlight', []);
|
||||
flight = null;
|
||||
command('camera.completeFlight', []);
|
||||
land(current, current.target);
|
||||
current.complete?.();
|
||||
},
|
||||
zoomIn(amount = DEFAULT_ZOOM_AMOUNT) {
|
||||
record('camera.zoomIn', [amount]);
|
||||
state.position = sum(state.position, scaled(state.direction, amount));
|
||||
|
||||
@@ -3478,5 +3478,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "تعذّر تشغيل محرك الكرة الأرضية. يعمل الآن {engine}: {message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "صور Bing غير متاحة في محرك Navara التجريبي",
|
||||
"NOT ON THIS ENGINE": "غير متاح في هذا المحرك",
|
||||
"Kept in the link: {layers}": "محفوظة في الرابط: {layers}"
|
||||
"Kept in the link: {layers}": "محفوظة في الرابط: {layers}",
|
||||
"Could not start {engine}: {message}": "تعذّر تشغيل {engine}: {message}",
|
||||
"Choose an engine to try again.": "اختر محركًا لإعادة المحاولة."
|
||||
}
|
||||
@@ -3178,5 +3178,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "Die Globus-Engine konnte nicht starten. Es läuft {engine}: {message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "Bing-Bildmaterial ist in der experimentellen Navara-Engine nicht verfügbar",
|
||||
"NOT ON THIS ENGINE": "NICHT IN DIESER ENGINE",
|
||||
"Kept in the link: {layers}": "Im Link beibehalten: {layers}"
|
||||
"Kept in the link: {layers}": "Im Link beibehalten: {layers}",
|
||||
"Could not start {engine}: {message}": "{engine} konnte nicht starten: {message}",
|
||||
"Choose an engine to try again.": "Engine wählen, um es erneut zu versuchen."
|
||||
}
|
||||
@@ -3253,5 +3253,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "No se pudo iniciar el motor del globo. Se usa {engine}: {message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "Las imágenes de Bing no están disponibles en el motor experimental Navara",
|
||||
"NOT ON THIS ENGINE": "NO EN ESTE MOTOR",
|
||||
"Kept in the link: {layers}": "Se conservan en el enlace: {layers}"
|
||||
"Kept in the link: {layers}": "Se conservan en el enlace: {layers}",
|
||||
"Could not start {engine}: {message}": "No se pudo iniciar {engine}: {message}",
|
||||
"Choose an engine to try again.": "Elige un motor para intentarlo de nuevo."
|
||||
}
|
||||
@@ -3253,5 +3253,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "Le moteur du globe n’a pas pu démarrer. {engine} est utilisé : {message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "L’imagerie Bing n’est pas disponible avec le moteur expérimental Navara",
|
||||
"NOT ON THIS ENGINE": "PAS SUR CE MOTEUR",
|
||||
"Kept in the link: {layers}": "Conservées dans le lien : {layers}"
|
||||
"Kept in the link: {layers}": "Conservées dans le lien : {layers}",
|
||||
"Could not start {engine}: {message}": "Impossible de démarrer {engine} : {message}",
|
||||
"Choose an engine to try again.": "Choisissez un moteur pour réessayer."
|
||||
}
|
||||
@@ -3253,5 +3253,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "Impossibile avviare il motore del globo. È in uso {engine}: {message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "Le immagini Bing non sono disponibili nel motore sperimentale Navara",
|
||||
"NOT ON THIS ENGINE": "NON IN QUESTO MOTORE",
|
||||
"Kept in the link: {layers}": "Conservati nel link: {layers}"
|
||||
"Kept in the link: {layers}": "Conservati nel link: {layers}",
|
||||
"Could not start {engine}: {message}": "Impossibile avviare {engine}: {message}",
|
||||
"Choose an engine to try again.": "Scegli un motore per riprovare."
|
||||
}
|
||||
@@ -3103,5 +3103,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "地球儀エンジンを起動できませんでした。{engine} で実行中:{message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "Bing画像は実験版 Navara エンジンでは利用できません",
|
||||
"NOT ON THIS ENGINE": "このエンジンでは非対応",
|
||||
"Kept in the link: {layers}": "リンクに保持:{layers}"
|
||||
"Kept in the link: {layers}": "リンクに保持:{layers}",
|
||||
"Could not start {engine}: {message}": "{engine} を起動できませんでした:{message}",
|
||||
"Choose an engine to try again.": "エンジンを選んでもう一度お試しください。"
|
||||
}
|
||||
@@ -3253,5 +3253,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "Não foi possível iniciar o motor do globo. Usando {engine}: {message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "As imagens do Bing não estão disponíveis no motor experimental Navara",
|
||||
"NOT ON THIS ENGINE": "NÃO NESTE MOTOR",
|
||||
"Kept in the link: {layers}": "Mantidas no link: {layers}"
|
||||
"Kept in the link: {layers}": "Mantidas no link: {layers}",
|
||||
"Could not start {engine}: {message}": "Não foi possível iniciar {engine}: {message}",
|
||||
"Choose an engine to try again.": "Escolha um motor para tentar novamente."
|
||||
}
|
||||
@@ -3328,5 +3328,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "Не удалось запустить движок глобуса. Работает {engine}: {message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "Снимки Bing недоступны в экспериментальном движке Navara",
|
||||
"NOT ON THIS ENGINE": "НЕТ В ЭТОМ ДВИЖКЕ",
|
||||
"Kept in the link: {layers}": "Сохранены в ссылке: {layers}"
|
||||
"Kept in the link: {layers}": "Сохранены в ссылке: {layers}",
|
||||
"Could not start {engine}: {message}": "Не удалось запустить {engine}: {message}",
|
||||
"Choose an engine to try again.": "Выберите движок, чтобы попробовать снова."
|
||||
}
|
||||
@@ -3328,5 +3328,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "Не вдалося запустити рушій глобуса. Працює {engine}: {message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "Знімки Bing недоступні в експериментальному рушії Navara",
|
||||
"NOT ON THIS ENGINE": "НЕМАЄ В ЦЬОМУ РУШІЇ",
|
||||
"Kept in the link: {layers}": "Збережено в посиланні: {layers}"
|
||||
"Kept in the link: {layers}": "Збережено в посиланні: {layers}",
|
||||
"Could not start {engine}: {message}": "Не вдалося запустити {engine}: {message}",
|
||||
"Choose an engine to try again.": "Виберіть рушій, щоб спробувати знову."
|
||||
}
|
||||
@@ -3103,5 +3103,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "地球渲染引擎启动失败。当前运行 {engine}:{message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "实验性 Navara 引擎不支持 Bing 影像",
|
||||
"NOT ON THIS ENGINE": "此引擎不支持",
|
||||
"Kept in the link: {layers}": "已保留在链接中:{layers}"
|
||||
"Kept in the link: {layers}": "已保留在链接中:{layers}",
|
||||
"Could not start {engine}: {message}": "{engine} 启动失败:{message}",
|
||||
"Choose an engine to try again.": "选择一个引擎重试。"
|
||||
}
|
||||
@@ -3103,5 +3103,7 @@
|
||||
"Globe engine failed to start. Running {engine}: {message}": "地球渲染引擎啟動失敗。當前運行 {engine}:{message}",
|
||||
"Bing imagery is not available on the experimental Navara engine": "實驗性 Navara 引擎不支持 Bing 影像",
|
||||
"NOT ON THIS ENGINE": "此引擎不支持",
|
||||
"Kept in the link: {layers}": "已保留在鏈接中:{layers}"
|
||||
"Kept in the link: {layers}": "已保留在鏈接中:{layers}",
|
||||
"Could not start {engine}: {message}": "{engine} 啟動失敗:{message}",
|
||||
"Choose an engine to try again.": "選擇一個引擎重試。"
|
||||
}
|
||||
@@ -66,3 +66,51 @@ test('a cockpit contact draws the contact dot, not the aircraft silhouette', asy
|
||||
display.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
// The dot bank, rebuilt for capacity after its first contact turned back
|
||||
// into a silhouette, keeps the dot atlas.
|
||||
test('a regrown cockpit-dot bank keeps the dot atlas', async () => {
|
||||
const engine = createFakeEngine();
|
||||
const display = createAircraftDisplay(engine, {
|
||||
layerId: 'flights',
|
||||
order: 8,
|
||||
headingOffsetDeg: 180,
|
||||
drawAtlas,
|
||||
});
|
||||
await display.whenReady();
|
||||
display.fleet.attach();
|
||||
const add = (i) =>
|
||||
display.fleet.add({
|
||||
position: geo.cartesian3.fromDegrees(-97.7 + i * 0.001, 30.2, 3000),
|
||||
image: aircraftIcon('airliner'),
|
||||
width: 20,
|
||||
height: 20,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
color: { red: 1, green: 1, blue: 1, alpha: 1 },
|
||||
id: `c${i}`,
|
||||
show: true,
|
||||
});
|
||||
const first = add(0);
|
||||
await settle();
|
||||
first.image = cockpitContactDotImage(); // far: the dot bank is created from it
|
||||
const dotBank = first.gevBank;
|
||||
first.image = aircraftIcon('airliner'); // near again: back to the silhouette
|
||||
const others = [];
|
||||
for (let i = 1; i <= 1100; i++) {
|
||||
const s = add(i);
|
||||
s.image = cockpitContactDotImage();
|
||||
others.push(s);
|
||||
}
|
||||
engine.fake.frames(2);
|
||||
await settle();
|
||||
assert.equal(others[0].gevBank, dotBank);
|
||||
const url = engine.fake.moverSet(dotBank.set.id).options.sprite.atlas.url;
|
||||
assert.equal(
|
||||
url,
|
||||
cockpitContactDotImage(),
|
||||
'the regrown dot bank draws another image',
|
||||
);
|
||||
display.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -29,6 +29,9 @@ import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
|
||||
import { createMoverBank, writeFields } from '../../shared/render/moverBank.js';
|
||||
|
||||
const BASE_SIZE_PX = 20;
|
||||
// A failed glTF is fetched again no sooner than this (the layers' tracked
|
||||
// model backoff), not on the next fleet tick.
|
||||
const MODEL_RETRY_BACKOFF_MS = 1500;
|
||||
|
||||
/**
|
||||
* The model frame → the glTF's own axes (the movers' orientation): Cesium's
|
||||
@@ -194,7 +197,10 @@ export function createAircraftDisplay(
|
||||
pickable: true,
|
||||
sprite: {
|
||||
atlas: dot
|
||||
? { url: sprite.image, cells: { 'cockpit-dot': [0, 0, 1, 1] } }
|
||||
? {
|
||||
url: cockpitContactDotImage(),
|
||||
cells: { 'cockpit-dot': [0, 0, 1, 1] },
|
||||
}
|
||||
: atlas,
|
||||
sizePx: BASE_SIZE_PX,
|
||||
scaleByDistance: scalar
|
||||
@@ -454,6 +460,7 @@ export function createAircraftDisplay(
|
||||
// --- models ----------------------------------------------------------------
|
||||
let modelsVisible = true;
|
||||
const modelBanks = new Map();
|
||||
const modelRetryAt = new Map();
|
||||
const scratchMatrix3 = geo.matrix3.create();
|
||||
const scratchCorrected = geo.matrix4.create();
|
||||
const scratchQuaternion = geo.quaternion.create();
|
||||
@@ -596,13 +603,15 @@ export function createAircraftDisplay(
|
||||
}
|
||||
}
|
||||
|
||||
// A set does not load a failed glTF again: the tracked model's retry
|
||||
// loads it in a new one.
|
||||
// A failed glTF needs a fresh set, created only when a retry is admitted.
|
||||
const dropModel = (model) => {
|
||||
const bank = model?.gevBank;
|
||||
const retry = model?.tracked && model.failed;
|
||||
const retry = model?.failed;
|
||||
bank?.remove(model);
|
||||
if (retry) bank.rebuild();
|
||||
if (retry) {
|
||||
modelRetryAt.set(model.spec.url, Date.now() + MODEL_RETRY_BACKOFF_MS);
|
||||
bank.rebuild();
|
||||
}
|
||||
};
|
||||
|
||||
const models = {
|
||||
@@ -611,9 +620,11 @@ export function createAircraftDisplay(
|
||||
modelsVisible = visible;
|
||||
for (const bank of modelBanks.values()) bank.setVisible(visible);
|
||||
},
|
||||
loadFleet: (spec) => Promise.resolve(new ModelHandle(spec)),
|
||||
loadTracked: (spec) =>
|
||||
Promise.resolve(Object.assign(new ModelHandle(spec), { tracked: true })),
|
||||
loadFleet: (spec) =>
|
||||
Date.now() < (modelRetryAt.get(spec.url) ?? 0)
|
||||
? Promise.reject(new Error('model retry backoff'))
|
||||
: Promise.resolve(new ModelHandle(spec)),
|
||||
loadTracked: (spec) => Promise.resolve(new ModelHandle(spec)),
|
||||
add(model) {
|
||||
modelBankFor(model).add(model);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// A tracked aircraft's glTF that the engine fails to load: the glyph stays
|
||||
// the visual, the load is tried again after the backoff in a new mover set
|
||||
// (a set does not load a failed glTF again), and a contact gives up after its
|
||||
// failure budget.
|
||||
// An aircraft glTF that the engine fails to load: the glyph stays the
|
||||
// visual, the load is tried again after the backoff in a new mover set (a set
|
||||
// does not load a failed glTF again), and a tracked contact gives up after its
|
||||
// failure budget. A failed fleet model gives its slot back at once, and a
|
||||
// later aircraft of its class loads the glTF again after the backoff.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
@@ -23,6 +24,11 @@ import militaryFlightsLayer, {
|
||||
} from '../../data/militaryFlights.js';
|
||||
import { clearTr3bRegistry } from '../../data/tr3bRegistry.js';
|
||||
import { recordingAircraftRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs';
|
||||
import { fleetTickFixture } from '../../testSupport/fleetTickFixture.mjs';
|
||||
import { createFlightState as civilState } from '../flights/state.js';
|
||||
import { createRendering as civilRendering } from '../flights/rendering.js';
|
||||
import { createFlightState as militaryState } from '../military/state.js';
|
||||
import { createRendering as militaryRendering } from '../military/rendering.js';
|
||||
|
||||
globalThis.window ??= {
|
||||
dispatchEvent: () => true,
|
||||
@@ -58,6 +64,11 @@ test('a failed tracked model loads again in a new set once it is removed', async
|
||||
failing.add(failedSet);
|
||||
assert.equal(first.failed, true, 'the engine reports the failed load');
|
||||
display.models.remove(first);
|
||||
assert.equal(
|
||||
engine.movers.list().filter((set) => set.kind === 'model').length,
|
||||
0,
|
||||
'removal does not start another load',
|
||||
);
|
||||
|
||||
const retry = await display.models.loadTracked(spec);
|
||||
display.models.add(retry);
|
||||
@@ -181,3 +192,93 @@ for (const fixture of LAYERS) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed fleet model does not condemn later aircraft of its class', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1000 });
|
||||
const engine = createFakeEngine();
|
||||
t.after(() => engine.destroy());
|
||||
const display = createAircraftDisplay(engine, {
|
||||
layerId: 'flights',
|
||||
headingOffsetDeg: 180,
|
||||
drawAtlas,
|
||||
});
|
||||
t.after(() => display.destroy());
|
||||
await display.whenReady();
|
||||
display.fleet.attach();
|
||||
const failing = new Set();
|
||||
engine.fake.script('moverFailed', (id) => failing.has(id));
|
||||
const spec = (id) => ({
|
||||
url: 'airliner.glb',
|
||||
minimumPixelSize: 64,
|
||||
scale: 1,
|
||||
id,
|
||||
});
|
||||
const first = await display.models.loadFleet(spec('a1'));
|
||||
display.models.add(first);
|
||||
failing.add(first.gevBank.set.id); // a 5xx or a network blip on the GLB
|
||||
assert.equal(first.failed, true);
|
||||
display.models.remove(first);
|
||||
await assert.rejects(display.models.loadFleet(spec('a2')), /retry/);
|
||||
t.mock.timers.tick(1500);
|
||||
const next = await display.models.loadFleet(spec('a2'));
|
||||
display.models.add(next);
|
||||
assert.equal(
|
||||
next.failed,
|
||||
false,
|
||||
'the next airliner joined the failed set and never draws its model',
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, createFlightState, createRendering] of [
|
||||
['flights', civilState, civilRendering],
|
||||
['military', militaryState, militaryRendering],
|
||||
])
|
||||
for (const inView of [true, false])
|
||||
test(`${name}: the fleet tick frees a failed model's slot (${inView ? 'in view' : 'beyond the horizon'})`, () => {
|
||||
const fleet = fleetTickFixture({ createFlightState, createRendering });
|
||||
if (inView)
|
||||
fleet.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
|
||||
fleet.flightState._models3dEnabled = true;
|
||||
const model = { failed: true, ready: false, show: false };
|
||||
fleet.flightState._models.set(fleet.id, model);
|
||||
fleet.tick();
|
||||
assert.equal(fleet.flightState._models.has(fleet.id), false);
|
||||
assert.deepEqual(fleet.removed, [model]);
|
||||
});
|
||||
|
||||
test('removing failures from a shared bank does not reload until the backoff ends', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1000 });
|
||||
const engine = createFakeEngine();
|
||||
t.after(() => engine.destroy());
|
||||
let builds = 0;
|
||||
const createSet = engine.movers.createSet;
|
||||
engine.movers.createSet = (options) => {
|
||||
if (options.kind === 'model') builds++;
|
||||
return createSet(options);
|
||||
};
|
||||
const display = createAircraftDisplay(engine, {
|
||||
layerId: 'flights',
|
||||
drawAtlas,
|
||||
});
|
||||
t.after(() => display.destroy());
|
||||
const spec = { url: 'airliner.glb', minimumPixelSize: 64 };
|
||||
const first = await display.models.loadFleet({ ...spec, id: 'a' });
|
||||
const second = await display.models.loadFleet({ ...spec, id: 'b' });
|
||||
display.models.add(first);
|
||||
display.models.add(second);
|
||||
const failedId = first.gevBank.set.id;
|
||||
engine.fake.script('moverFailed', (id) => id === failedId);
|
||||
display.models.remove(first);
|
||||
assert.equal(second.failed, true);
|
||||
assert.equal(builds, 1, 'another handle in the bank does not trigger a load');
|
||||
display.models.remove(second);
|
||||
assert.equal(builds, 1);
|
||||
t.mock.timers.tick(1499);
|
||||
await assert.rejects(display.models.loadFleet(spec), /retry/);
|
||||
t.mock.timers.tick(1);
|
||||
const retry = await display.models.loadFleet(spec);
|
||||
assert.equal(builds, 1, 'a load starts only when the handle is admitted');
|
||||
display.models.add(retry);
|
||||
assert.equal(builds, 2);
|
||||
assert.equal(retry.failed, false);
|
||||
});
|
||||
@@ -31,7 +31,6 @@ test('icon thinning uses the camera vertical FOV, not a pi/3 fallback', () => {
|
||||
horizonTest: () => () => true,
|
||||
project: () => ({ x: 960, y: 540 }),
|
||||
distance: () => 2000,
|
||||
// Accessor the fix is expected to add (any name works if the stub follows).
|
||||
cameraPose: () => pose,
|
||||
fovy: () => pose.fovy,
|
||||
engine: { camera: { pose: () => pose } },
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { horizonVisible } from '../../globe/cameraMath.js';
|
||||
import { fleetTickFixture } from '../../testSupport/fleetTickFixture.mjs';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_INTERVAL_MS,
|
||||
@@ -45,95 +45,14 @@ test('a worldwide fleet is spread evenly enough that no tick carries a burst', (
|
||||
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
|
||||
});
|
||||
|
||||
/** Real fleet tick and horizon culling, with motion and presentation injected. */
|
||||
function fleetFixture() {
|
||||
let now = 1000;
|
||||
let reckonings = 0;
|
||||
const services = {
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||
militaryRegistry: { isMilitaryIcao: () => false },
|
||||
aircraftPresentation: { isTr3b: () => false },
|
||||
camera: {},
|
||||
focus: {
|
||||
focusNowMs: () => now,
|
||||
getFocusTarget: () => null,
|
||||
nearFarScalarValueAtDistance: () => 1,
|
||||
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
|
||||
},
|
||||
recession: {
|
||||
applyAircraftBillboardTreatment: () => ({
|
||||
factors: { scale: 1 },
|
||||
alpha: 1,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const flightState = createFlightState({ services });
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
|
||||
positionCartographic: { height: 100 },
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
roll: 0,
|
||||
frustum: {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const id = 'abc123';
|
||||
// 111 km away at ground level is beyond a 100 m camera's horizon, but
|
||||
// inside the model keep radius. No WebGL scene is needed for the culler.
|
||||
const billboard = {
|
||||
show: false,
|
||||
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
|
||||
};
|
||||
// The flights renderer's fleet and view, as the Cesium renderer gives them.
|
||||
flightState._renderer = {
|
||||
fleet: { visible: () => true },
|
||||
view: {
|
||||
camera,
|
||||
scene: {},
|
||||
horizon: () => ({
|
||||
isPointVisible: (position) =>
|
||||
horizonVisible(camera.positionWC, position),
|
||||
}),
|
||||
frustumTester: () => () => true,
|
||||
},
|
||||
};
|
||||
flightState._billboards.set(id, billboard);
|
||||
flightState.records.data.set(id, {});
|
||||
flightState._models3dEnabled = false;
|
||||
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
|
||||
flightState._cockpitContactMode = true;
|
||||
const parts = {
|
||||
tracking: { _refreshCockpitNearContacts() {} },
|
||||
motion: {
|
||||
_deadReckon() {
|
||||
reckonings++;
|
||||
return billboard.position;
|
||||
},
|
||||
_floorGroundedDisplayPosition(id, info, position) {
|
||||
return position;
|
||||
},
|
||||
},
|
||||
};
|
||||
const rendering = createRendering({ flightState, services, parts });
|
||||
return {
|
||||
flightState,
|
||||
camera,
|
||||
billboard,
|
||||
id,
|
||||
reckonings: () => reckonings,
|
||||
tick() {
|
||||
now += FLEET_DR_INTERVAL_MS;
|
||||
rendering._fleetTick();
|
||||
},
|
||||
offTurn() {
|
||||
flightState._fleetTickSeq =
|
||||
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
|
||||
},
|
||||
};
|
||||
}
|
||||
// The real fleet tick and horizon culling, with motion and presentation injected.
|
||||
const fleetFixture = () =>
|
||||
fleetTickFixture({ createFlightState, createRendering });
|
||||
// The next tick is not this contact's turn in the hidden rotation.
|
||||
const offTurn = (f) => {
|
||||
f.flightState._fleetTickSeq =
|
||||
(hiddenContactDrPhase(f.id) + 1) % HIDDEN_CONTACT_DR_TICKS;
|
||||
};
|
||||
|
||||
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
|
||||
const f = fleetFixture();
|
||||
@@ -144,11 +63,11 @@ test('the fleet tick dead-reckons a hidden contact once per complete rotation',
|
||||
|
||||
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
|
||||
const f = fleetFixture();
|
||||
f.offTurn();
|
||||
offTurn(f);
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 0);
|
||||
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
|
||||
f.offTurn();
|
||||
offTurn(f);
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, true);
|
||||
@@ -159,7 +78,7 @@ test('a hidden contact with a retained fleet model is never rotation-skipped', (
|
||||
f.flightState._models3dEnabled = true;
|
||||
const model = { ready: true, show: true };
|
||||
f.flightState._models.set(f.id, model);
|
||||
f.offTurn();
|
||||
offTurn(f);
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(
|
||||
|
||||
@@ -825,6 +825,10 @@ export function createRendering({
|
||||
const occluder = view.horizon();
|
||||
const focusTarget = getFocusTarget();
|
||||
|
||||
// A model the engine failed to load gives its slot back, in view or not;
|
||||
// the renderer loads its class again after a backoff.
|
||||
for (const [icao24, model] of flightState._models)
|
||||
if (model.failed) _releaseModel(icao24);
|
||||
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
|
||||
// Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume.
|
||||
const useModels = _modelRegimeActive();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// get_entity_context in_view must list a datacenter/dam drawn in the middle of
|
||||
// the screen. The infrastructure display registers its context rows without
|
||||
// __localBaseCartesian, which visibleEntityContexts (src/voice/gevActions.js)
|
||||
// requires, so every infrastructure record is skipped.
|
||||
// In-view entity context includes infrastructure drawn on screen.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
|
||||
@@ -208,6 +208,10 @@ export function createLocalAdsbModels({
|
||||
for (const [id, marker] of markers) {
|
||||
const model = models.get(id);
|
||||
marker.modelOwnsVisual = false;
|
||||
if (model?.failed) {
|
||||
release(id);
|
||||
continue;
|
||||
}
|
||||
if (!model || !marker.position) continue;
|
||||
const position = placement(id, marker);
|
||||
if (!position || !occluder.isPointVisible(marker.position)) {
|
||||
|
||||
@@ -48,3 +48,45 @@ test('ground snapping excludes the aircraft by its engine pick id', async () =>
|
||||
assert.deepEqual(exclusions, [['local-adsb:abc123']]);
|
||||
models.destroy();
|
||||
});
|
||||
|
||||
test('a failed model frees its slot and leaves the marker visible', async () => {
|
||||
const position = geo.cartesian3.fromDegrees(0, 0, 100);
|
||||
const removed = [];
|
||||
let loads = 0;
|
||||
const renderer = {
|
||||
models: {
|
||||
loadFleet: async () => ({ failed: loads++ === 0, ready: loads > 1 }),
|
||||
add() {},
|
||||
remove: (model) => removed.push(model),
|
||||
destroy() {},
|
||||
setPose() {},
|
||||
},
|
||||
view: {
|
||||
camera: { positionWC: position, positionCartographic: { height: 100 } },
|
||||
frustumTester: () => () => true,
|
||||
horizon: () => ({ isPointVisible: () => true }),
|
||||
},
|
||||
};
|
||||
const models = createLocalAdsbModels({
|
||||
renderer,
|
||||
color: {},
|
||||
resolveAsset: (url) => url,
|
||||
});
|
||||
const marker = { position, klass: 'airliner' };
|
||||
const markers = new Map([['local-adsb:abc123', marker]]);
|
||||
try {
|
||||
models.frame(markers, { models3d: true }, 1000);
|
||||
await new Promise(setImmediate);
|
||||
models.frame(markers, { models3d: true }, 1100);
|
||||
assert.equal(models.size, 0);
|
||||
assert.equal(removed.length, 1);
|
||||
assert.equal(marker.modelOwnsVisual, false);
|
||||
models.frame(markers, { models3d: true }, 2700);
|
||||
await new Promise(setImmediate);
|
||||
models.frame(markers, { models3d: true }, 2800);
|
||||
assert.equal(models.size, 1);
|
||||
assert.equal(marker.modelOwnsVisual, true);
|
||||
} finally {
|
||||
models.destroy();
|
||||
}
|
||||
});
|
||||
@@ -805,6 +805,10 @@ export function createRendering({
|
||||
const occluder = view.horizon();
|
||||
const focusTarget = getFocusTarget();
|
||||
|
||||
// A model the engine failed to load gives its slot back, in view or not;
|
||||
// the renderer loads its class again after a backoff.
|
||||
for (const [icao24, model] of flightState._models)
|
||||
if (model.failed) _releaseModel(icao24);
|
||||
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
|
||||
// Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume.
|
||||
const useModels = _modelRegimeActive();
|
||||
|
||||
@@ -6,17 +6,13 @@
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { createSurfacePoints } from '../../shared/render/surfacePoints.js';
|
||||
import { OSM_PLACES_LAYER_ID, OSM_PLACE_COLOR } from '../model.js';
|
||||
|
||||
// Above the sampled surface, so a point does not sink into it.
|
||||
const HEIGHT_OFFSET_M = 2;
|
||||
// The voice search's result cap; a larger set grows the batch.
|
||||
const INITIAL_CAPACITY = 256;
|
||||
// While the surface under a place has not loaded, its height is asked again.
|
||||
const RESAMPLE_MS = 500;
|
||||
const RESAMPLE_TRIES = 20;
|
||||
const SAMPLES_PER_PASS = 16;
|
||||
const SAMPLE_BUDGET_MS = 4;
|
||||
// Under the moving contacts (src/data/spriteOrder.js), as map data.
|
||||
const POINT_ORDER = -1;
|
||||
|
||||
@@ -32,11 +28,14 @@ export function createOsmPlacesDisplay(engine) {
|
||||
let set = null,
|
||||
capacity = 0,
|
||||
visible = false,
|
||||
records = [],
|
||||
destroyed = false,
|
||||
retry = null,
|
||||
revision = 0;
|
||||
const heights = new Map();
|
||||
points = [],
|
||||
destroyed = false;
|
||||
const surface = createSurfacePoints(engine, {
|
||||
groups: () => [{ set, points: visible ? points : [] }],
|
||||
request: () => engine.render.request(OSM_PLACES_LAYER_ID),
|
||||
heightOffset: HEIGHT_OFFSET_M,
|
||||
estimateGround: true,
|
||||
});
|
||||
function ensure(count) {
|
||||
if (set && count <= capacity) return;
|
||||
set?.destroy();
|
||||
@@ -53,98 +52,20 @@ export function createOsmPlacesDisplay(engine) {
|
||||
}
|
||||
function draw() {
|
||||
if (destroyed) return;
|
||||
clearTimeout(retry);
|
||||
retry = null;
|
||||
const shown = visible ? records : [];
|
||||
const shown = visible ? points : [];
|
||||
ensure(shown.length);
|
||||
const sampling = engine.surface.supported;
|
||||
let sampleTime = 0;
|
||||
const now = Date.now();
|
||||
const size = engine.host.size();
|
||||
let samples = 0;
|
||||
let delay = Infinity;
|
||||
shown.forEach((record, index) => {
|
||||
let cached = heights.get(record);
|
||||
if (!cached || cached.revision !== revision) {
|
||||
cached = {
|
||||
height: cached?.height ?? 0,
|
||||
revision,
|
||||
attempts: 0,
|
||||
nextAt: 0,
|
||||
resolved: false,
|
||||
};
|
||||
heights.set(record, cached);
|
||||
}
|
||||
let position = geo.cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
cached.height + HEIGHT_OFFSET_M,
|
||||
);
|
||||
const screen = engine.projector.horizonVisible(position)
|
||||
? engine.projector.worldToWindow(position)
|
||||
: null;
|
||||
const inView =
|
||||
screen &&
|
||||
screen.x >= 0 &&
|
||||
screen.y >= 0 &&
|
||||
screen.x < size.width &&
|
||||
screen.y < size.height;
|
||||
if (
|
||||
sampling &&
|
||||
inView &&
|
||||
!cached.resolved &&
|
||||
cached.attempts < RESAMPLE_TRIES
|
||||
) {
|
||||
if (
|
||||
now >= cached.nextAt &&
|
||||
samples < SAMPLES_PER_PASS &&
|
||||
sampleTime < SAMPLE_BUDGET_MS
|
||||
) {
|
||||
const started = performance.now();
|
||||
const sampled = engine.surface.sampleHeight(
|
||||
geo.cartographic.fromDegrees(record.lon, record.lat),
|
||||
[set],
|
||||
);
|
||||
sampleTime += performance.now() - started;
|
||||
samples++;
|
||||
cached.attempts++;
|
||||
cached.nextAt = now + RESAMPLE_MS;
|
||||
// Cesium samples kilometres underground while tiles stream.
|
||||
if (Number.isFinite(sampled) && sampled >= -500 && sampled <= 9000) {
|
||||
cached.height = sampled;
|
||||
cached.resolved = true;
|
||||
position = geo.cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
sampled + HEIGHT_OFFSET_M,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!cached.resolved && cached.attempts < RESAMPLE_TRIES)
|
||||
delay = Math.min(delay, Math.max(0, cached.nextAt - now));
|
||||
}
|
||||
shown.forEach((point, index) => {
|
||||
set.set(index, {
|
||||
position,
|
||||
position: point.position,
|
||||
color,
|
||||
show: true,
|
||||
pickId: osmPlacePickId(record),
|
||||
pickId: osmPlacePickId(point.record),
|
||||
});
|
||||
});
|
||||
set.setCount(shown.length);
|
||||
set.commit();
|
||||
if (Number.isFinite(delay)) retry = setTimeout(draw, delay);
|
||||
surface.place();
|
||||
}
|
||||
function refine() {
|
||||
revision++;
|
||||
if (visible && records.length) draw();
|
||||
}
|
||||
const removeMoveEnd = engine.camera.moveEnd.addEventListener(refine);
|
||||
let tilesLoaded = engine.surface.tilesLoaded();
|
||||
const removeSurface = engine.frames.preRender.addEventListener(() => {
|
||||
const loaded = engine.surface.tilesLoaded();
|
||||
if (loaded && !tilesLoaded) refine();
|
||||
tilesLoaded = loaded;
|
||||
});
|
||||
return {
|
||||
setVisible(show) {
|
||||
if (visible === Boolean(show)) return;
|
||||
@@ -152,21 +73,24 @@ export function createOsmPlacesDisplay(engine) {
|
||||
draw();
|
||||
},
|
||||
replace(next) {
|
||||
records = next.slice();
|
||||
heights.clear();
|
||||
revision++;
|
||||
points = next.map((record) => ({
|
||||
record,
|
||||
cartographic: geo.cartographic.fromDegrees(record.lon, record.lat),
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
HEIGHT_OFFSET_M,
|
||||
),
|
||||
}));
|
||||
draw();
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
clearTimeout(retry);
|
||||
removeMoveEnd();
|
||||
removeSurface();
|
||||
heights.clear();
|
||||
surface.destroy();
|
||||
set?.destroy();
|
||||
set = null;
|
||||
records = [];
|
||||
points = [];
|
||||
engine.render.request('osm-places-destroy');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ function fakeHarness() {
|
||||
engine,
|
||||
/** The surface under every place answers `height` (undefined: none). */
|
||||
surface(height) {
|
||||
engine.fake.script('sampleHeight', height);
|
||||
engine.fake.script('terrainHeight', height);
|
||||
},
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
@@ -79,7 +79,6 @@ for (const makeHarness of HARNESSES) {
|
||||
});
|
||||
|
||||
test(`places sit 2 m over the sampled surface, read again after a move (${name})`, (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = makeHarness();
|
||||
const display = createOsmPlacesDisplay(h.engine);
|
||||
const position = () =>
|
||||
@@ -89,14 +88,17 @@ for (const makeHarness of HARNESSES) {
|
||||
})();
|
||||
try {
|
||||
h.surface(undefined);
|
||||
h.engine.fake.script('tilesLoaded', false);
|
||||
display.setVisible(true);
|
||||
display.replace(places);
|
||||
assert.ok(Math.abs(heightOf(position()) - 2) < 1e-6, 'no surface yet');
|
||||
h.surface(1340);
|
||||
t.mock.timers.tick(500);
|
||||
h.engine.fake.frames(1);
|
||||
h.engine.fake.script('tilesLoaded', true);
|
||||
h.engine.fake.frames(1);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(position()) - 1342) < 1e-6,
|
||||
'asked again shortly after',
|
||||
'asked again once the terrain loads',
|
||||
);
|
||||
h.surface(1360);
|
||||
h.engine.camera.moveEnd.raiseEvent();
|
||||
@@ -151,7 +153,7 @@ test('places refine loaded heights after tiles settle', (t) => {
|
||||
engine.projector.horizonVisible = () => true;
|
||||
let height = 50;
|
||||
let loaded = false;
|
||||
engine.fake.script('sampleHeight', () => height);
|
||||
engine.fake.script('terrainHeight', () => height);
|
||||
engine.fake.script('tilesLoaded', () => loaded);
|
||||
const display = createOsmPlacesDisplay(engine);
|
||||
t.after(() => display.destroy());
|
||||
@@ -169,11 +171,12 @@ test('places refine loaded heights after tiles settle', (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('large searches only sample visible places within a per-pass budget', (t) => {
|
||||
test('large searches only sample visible places within a per-pass budget', async (t) => {
|
||||
const engine = createFakeEngine();
|
||||
t.after(() => engine.destroy());
|
||||
engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
|
||||
engine.projector.horizonVisible = () => true;
|
||||
await engine.maps.setPhotorealTiles({ id: 'photoreal' });
|
||||
let samples = 0;
|
||||
engine.fake.script('sampleHeight', () => {
|
||||
samples++;
|
||||
@@ -191,3 +194,56 @@ test('large searches only sample visible places within a per-pass budget', (t) =
|
||||
engine.camera.moveEnd.raiseEvent();
|
||||
assert.equal(samples, 0, 'off-screen places are not sampled');
|
||||
});
|
||||
|
||||
// A place mid-view whose unmeasured point (2 m above the ellipsoid)
|
||||
// projects off screen is still measured.
|
||||
// Under 3D tiles Cesium hides the globe, and with it the terrain.
|
||||
for (const [tiles, terrain] of [
|
||||
[false, 1300],
|
||||
[true, 1300],
|
||||
[true, undefined],
|
||||
])
|
||||
test(`a visible place on high ground is measured before culling (tiles: ${tiles}, terrain: ${terrain})`, async () => {
|
||||
const engine = createFakeEngine({
|
||||
longitude: geo.math.toRadians(85.31),
|
||||
latitude: geo.math.toRadians(27.7),
|
||||
altitude: 1500,
|
||||
});
|
||||
// The ground under the place is at 1300 m; the camera, 200 m above it,
|
||||
// sees it mid-frame, while the point 1300 m lower projects below the frame.
|
||||
if (tiles) await engine.maps.setPhotorealTiles({ id: 'photoreal' });
|
||||
engine.fake.script('terrainHeight', terrain);
|
||||
let samples = 0;
|
||||
engine.fake.script('sampleHeight', () => (samples++, 1300));
|
||||
engine.projector.horizonVisible = () => true;
|
||||
engine.projector.worldToWindow = (position) =>
|
||||
geo.cartographic.fromCartesian(position).height > 1000
|
||||
? { x: 600, y: 450 }
|
||||
: { x: 600, y: 2400 };
|
||||
const display = createOsmPlacesDisplay(engine);
|
||||
try {
|
||||
display.replace([
|
||||
{ id: 'node/1', name: 'Town hall', lat: 27.7, lon: 85.31 },
|
||||
]);
|
||||
display.setVisible(true);
|
||||
engine.fake.emit('camera.moveEnd');
|
||||
assert.equal(
|
||||
samples > 0,
|
||||
tiles,
|
||||
'pick samples are only needed under 3D tiles',
|
||||
);
|
||||
const { positions } = engine.fake.moverSet('osm-places').store;
|
||||
const height = geo.cartographic.fromCartesian({
|
||||
x: positions[0],
|
||||
y: positions[1],
|
||||
z: positions[2],
|
||||
}).height;
|
||||
assert.ok(
|
||||
Math.abs(height - 1302) < 0.01,
|
||||
'the place stands above the visible ground',
|
||||
);
|
||||
} finally {
|
||||
display.destroy();
|
||||
engine.destroy();
|
||||
}
|
||||
});
|
||||
@@ -35,11 +35,13 @@ export function createMoverBank(engine, { options, write, onCommit }) {
|
||||
let capacity = 0;
|
||||
let visible = true;
|
||||
let dirty = false;
|
||||
let rebuildPending = false;
|
||||
const build = (wanted) => {
|
||||
const next = engine.movers.createSet(options(wanted, ++generation));
|
||||
set?.destroy();
|
||||
set = next;
|
||||
capacity = wanted;
|
||||
rebuildPending = false;
|
||||
handles.forEach((handle, index) => write(handle, set, index));
|
||||
set.setCount(visible ? handles.length : 0);
|
||||
};
|
||||
@@ -55,8 +57,14 @@ export function createMoverBank(engine, { options, write, onCommit }) {
|
||||
handle.gevBank = bank;
|
||||
handle.gevIndex = handles.length;
|
||||
handles.push(handle);
|
||||
if (!set || handles.length > capacity)
|
||||
build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length));
|
||||
if (!set || rebuildPending || handles.length > capacity)
|
||||
build(
|
||||
Math.max(
|
||||
FIRST_CAPACITY,
|
||||
handles.length > capacity ? capacity * 2 : capacity,
|
||||
handles.length,
|
||||
),
|
||||
);
|
||||
else {
|
||||
write(handle, set, handle.gevIndex);
|
||||
if (visible) set.setCount(handles.length);
|
||||
@@ -75,6 +83,7 @@ export function createMoverBank(engine, { options, write, onCommit }) {
|
||||
handle.gevBank = null;
|
||||
handle.gevIndex = -1;
|
||||
set?.setCount(visible ? handles.length : 0);
|
||||
if (!handles.length && rebuildPending) bank.rebuild();
|
||||
bank.touch();
|
||||
},
|
||||
/** Writes `fields` of one handle (when it is in this bank). */
|
||||
@@ -89,9 +98,13 @@ export function createMoverBank(engine, { options, write, onCommit }) {
|
||||
bank.touch();
|
||||
},
|
||||
visible: () => visible,
|
||||
/** The same handles in a new set (one that loads its assets again). */
|
||||
/** Reload assets on the next add, after the caller's retry backoff. */
|
||||
rebuild() {
|
||||
if (set) build(capacity);
|
||||
rebuildPending = true;
|
||||
if (!handles.length) {
|
||||
set?.destroy();
|
||||
set = null;
|
||||
}
|
||||
},
|
||||
touch() {
|
||||
if (!dirty) {
|
||||
|
||||
@@ -31,14 +31,22 @@ const plausible = (height) =>
|
||||
* placement replaces (keeping the height in `surfaceHeight`); one with
|
||||
* `show === false` is skipped.
|
||||
* @param {() => void} options.request Asks for a frame after a placement.
|
||||
* @param {number} [options.heightOffset=0] Metres above the surface.
|
||||
* @param {boolean} [options.estimateGround=false] Screen-test a point not
|
||||
* measured yet on the loaded terrain (under 3D tiles without it, at the
|
||||
* ground under the camera) rather than where it stands now.
|
||||
* @returns {{ place: () => void, destroy: () => void }} `place()` measures
|
||||
* the points not yet placed on this surface (new points, for example).
|
||||
*/
|
||||
export function createSurfacePoints(engine, { groups, request }) {
|
||||
export function createSurfacePoints(
|
||||
engine,
|
||||
{ groups, request, heightOffset = 0, estimateGround = false },
|
||||
) {
|
||||
let revision = 0;
|
||||
let timer = null;
|
||||
let destroyed = false;
|
||||
let tilesLoaded = engine.surface.tilesLoaded();
|
||||
let photoreal = engine.maps?.photorealActive?.() === true;
|
||||
|
||||
function onScreen(position, size) {
|
||||
const screen = engine.projector.worldToWindow(position);
|
||||
@@ -53,6 +61,16 @@ export function createSurfacePoints(engine, { groups, request }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Under 3D tiles Cesium hides the globe, which then loads no terrain: the
|
||||
// ground under the camera stands in for it in the screen test.
|
||||
function groundUnderCamera(exclude) {
|
||||
const height = engine.surface.sampleHeight(
|
||||
geo.cartographic.fromCartesian(engine.camera.livePosition),
|
||||
exclude,
|
||||
);
|
||||
return plausible(height) ? height : Number.NaN;
|
||||
}
|
||||
|
||||
// Points off screen are passed over until the next move or tile load.
|
||||
function place() {
|
||||
clearTimeout(timer);
|
||||
@@ -63,6 +81,7 @@ export function createSurfacePoints(engine, { groups, request }) {
|
||||
const started = performance.now();
|
||||
let samples = 0;
|
||||
let moved = false;
|
||||
let cameraGround;
|
||||
for (const { set, points, exclude = [set] } of groups()) {
|
||||
if (!set) continue;
|
||||
let changed = false;
|
||||
@@ -79,20 +98,40 @@ export function createSurfacePoints(engine, { groups, request }) {
|
||||
return;
|
||||
}
|
||||
point.surfaceRevision = revision;
|
||||
if (point.show === false || !onScreen(point.position, size)) continue;
|
||||
if (point.show === false) continue;
|
||||
const estimate = estimateGround && point.surfaceHeight === undefined;
|
||||
const terrain = estimate
|
||||
? engine.surface.terrainHeight(point.cartographic)
|
||||
: undefined;
|
||||
let ground = terrain;
|
||||
if (estimate && tiles && !plausible(ground)) {
|
||||
if (cameraGround === undefined) {
|
||||
samples++;
|
||||
cameraGround = groundUnderCamera(exclude);
|
||||
}
|
||||
ground = cameraGround;
|
||||
}
|
||||
const position = plausible(ground)
|
||||
? geo.cartesian3.fromRadians(
|
||||
point.cartographic.longitude,
|
||||
point.cartographic.latitude,
|
||||
ground + heightOffset,
|
||||
)
|
||||
: point.position;
|
||||
if (!onScreen(position, size)) continue;
|
||||
samples++;
|
||||
let height = tiles
|
||||
? engine.surface.sampleHeight(point.cartographic, exclude)
|
||||
: undefined;
|
||||
if (!plausible(height))
|
||||
height = engine.surface.terrainHeight(point.cartographic);
|
||||
height = terrain ?? engine.surface.terrainHeight(point.cartographic);
|
||||
if (!plausible(height)) continue;
|
||||
if (Math.abs(height - point.surfaceHeight) < HEIGHT_EPSILON_M) continue;
|
||||
point.surfaceHeight = height;
|
||||
point.position = geo.cartesian3.fromRadians(
|
||||
point.cartographic.longitude,
|
||||
point.cartographic.latitude,
|
||||
height,
|
||||
height + heightOffset,
|
||||
);
|
||||
set.set(index, { position: point.position });
|
||||
changed = true;
|
||||
@@ -110,8 +149,10 @@ export function createSurfacePoints(engine, { groups, request }) {
|
||||
const removeMoveEnd = engine.camera.moveEnd.addEventListener(refine);
|
||||
const removeFrame = engine.frames.preRender.addEventListener(() => {
|
||||
const loaded = engine.surface.tilesLoaded();
|
||||
if (loaded && !tilesLoaded) refine();
|
||||
const tiles = engine.maps?.photorealActive?.() === true;
|
||||
if ((loaded && !tilesLoaded) || tiles !== photoreal) refine();
|
||||
tilesLoaded = loaded;
|
||||
photoreal = tiles;
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
// on the same engine (`display.engine`).
|
||||
//
|
||||
// The contract has no ground clamping for sprites: a sprite sits on the
|
||||
// terrain, or under Google 3D on the rendered tiles (one sample each), and
|
||||
// is placed again whenever the surface settles.
|
||||
// terrain, or under Google 3D on the rendered tiles, measured on screen a
|
||||
// few per pass and again after moves and tile loads
|
||||
// (../../shared/render/surfacePoints.js).
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { plausibleSurfaceHeight } from '../view.js';
|
||||
import { createSurfacePoints } from '../../shared/render/surfacePoints.js';
|
||||
|
||||
/** Lines per ground set: smaller sets build, and show, sooner. */
|
||||
export const SEQUENCE_LINE_BATCH = 120;
|
||||
@@ -89,39 +90,6 @@ export function createStreetLevelDisplay(engine) {
|
||||
const live = new Set();
|
||||
let serial = 0;
|
||||
|
||||
const tilesShown = () => engine.maps?.photorealActive?.() === true;
|
||||
|
||||
/**
|
||||
* Ground height (m) under a point and whether it came from the surface
|
||||
* shown: the terrain on the globe; under Google 3D the rendered tiles
|
||||
* (`exclude`: the sprites themselves), else the terrain. Null when
|
||||
* neither answers.
|
||||
*/
|
||||
function groundHeight(lon, lat, exclude) {
|
||||
const carto = geo.cartographic.fromDegrees(lon, lat);
|
||||
let terrain;
|
||||
try {
|
||||
terrain = engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(carto)
|
||||
: undefined;
|
||||
} catch {
|
||||
terrain = undefined;
|
||||
}
|
||||
const known = Number.isFinite(terrain);
|
||||
if (!tilesShown()) return { height: known ? terrain : null, exact: known };
|
||||
let sampled;
|
||||
try {
|
||||
sampled = engine.surface.supported
|
||||
? engine.surface.sampleHeight(carto, exclude)
|
||||
: undefined;
|
||||
} catch {
|
||||
sampled = undefined;
|
||||
}
|
||||
if (plausibleSurfaceHeight(sampled))
|
||||
return { height: sampled, exact: true };
|
||||
return { height: known ? terrain : null, exact: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of sprites, each `{ pickId, lon, lat, bearing, image, imageKey,
|
||||
* sizePx, scaleByDistance? }`: one sprite set per `imageKey`, at `order`
|
||||
@@ -136,8 +104,8 @@ export function createStreetLevelDisplay(engine) {
|
||||
let visible = true;
|
||||
let dirty = true;
|
||||
let lastPose = '';
|
||||
let settled = null;
|
||||
let tiles = tilesShown();
|
||||
let surfaceGroups = [];
|
||||
let placementPending = true;
|
||||
let destroyed = false;
|
||||
|
||||
const wake = () => {
|
||||
@@ -147,10 +115,26 @@ export function createStreetLevelDisplay(engine) {
|
||||
};
|
||||
const ownSets = () => [...banks.values()].map(({ bank }) => bank);
|
||||
|
||||
function place(row) {
|
||||
const { height, exact } = groundHeight(row.lon, row.lat, ownSets());
|
||||
row.position = geo.cartesian3.fromDegrees(row.lon, row.lat, height ?? 0);
|
||||
row.exact = exact;
|
||||
// Rows change their instance index: a placement pass still queued must
|
||||
// not write by the old one.
|
||||
function changed() {
|
||||
placementPending = true;
|
||||
surfaceGroups = [];
|
||||
wake();
|
||||
}
|
||||
|
||||
// On the loaded terrain until measured: a sprite off screen waits for
|
||||
// its measure, and a camera move can bring it into view first.
|
||||
function locate(row) {
|
||||
row.cartographic = geo.cartographic.fromDegrees(row.lon, row.lat);
|
||||
const terrain = engine.surface.terrainHeight(row.cartographic);
|
||||
row.position = geo.cartesian3.fromDegrees(
|
||||
row.lon,
|
||||
row.lat,
|
||||
Number.isFinite(terrain) ? terrain : 0,
|
||||
);
|
||||
delete row.surfaceHeight;
|
||||
delete row.surfaceRevision;
|
||||
}
|
||||
|
||||
function bankFor(key, first, count) {
|
||||
@@ -179,15 +163,6 @@ export function createStreetLevelDisplay(engine) {
|
||||
|
||||
function paint() {
|
||||
if (destroyed) return;
|
||||
// Loaded detail can replace the surface under an already placed sprite.
|
||||
const loaded = engine.surface.tilesLoaded();
|
||||
const nowTiles = tilesShown();
|
||||
if ((loaded && settled === false) || nowTiles !== tiles) {
|
||||
for (const row of rows) place(row);
|
||||
dirty = true;
|
||||
}
|
||||
settled = loaded;
|
||||
tiles = nowTiles;
|
||||
if (!rows.size && !dirty) return;
|
||||
const pose = engine.camera.pose();
|
||||
const signature = [
|
||||
@@ -199,7 +174,6 @@ export function createStreetLevelDisplay(engine) {
|
||||
pose.roll,
|
||||
].join('/');
|
||||
if (!dirty && signature === lastPose) return;
|
||||
dirty = false;
|
||||
lastPose = signature;
|
||||
const groups = new Map();
|
||||
if (visible)
|
||||
@@ -219,6 +193,17 @@ export function createStreetLevelDisplay(engine) {
|
||||
record.bank.commit();
|
||||
}
|
||||
}
|
||||
if (placementPending) {
|
||||
placementPending = false;
|
||||
surfaceGroups = [...groups].map(([key, points]) => ({
|
||||
set: bankFor(key, points[0], points.length),
|
||||
points,
|
||||
}));
|
||||
const exclude = ownSets();
|
||||
for (const group of surfaceGroups) group.exclude = exclude;
|
||||
surface.place();
|
||||
}
|
||||
dirty = false;
|
||||
for (const [key, group] of groups) {
|
||||
const bank = bankFor(key, group[0], group.length);
|
||||
group.forEach((row, index) => {
|
||||
@@ -246,13 +231,18 @@ export function createStreetLevelDisplay(engine) {
|
||||
}
|
||||
|
||||
const removePaint = engine.frames.preRender.addEventListener(paint);
|
||||
const surface = createSurfacePoints(engine, {
|
||||
groups: () => surfaceGroups,
|
||||
request: wake,
|
||||
estimateGround: true,
|
||||
});
|
||||
const group = {
|
||||
/** Adds a sprite; the returned row is its handle. */
|
||||
add(spec) {
|
||||
const row = { ...spec, rotation: 0, position: null, exact: false };
|
||||
place(row);
|
||||
const row = { ...spec, rotation: 0 };
|
||||
locate(row);
|
||||
rows.add(row);
|
||||
wake();
|
||||
changed();
|
||||
return row;
|
||||
},
|
||||
/** Moves or turns a sprite (`lon`, `lat`, `bearing`). */
|
||||
@@ -260,19 +250,19 @@ export function createStreetLevelDisplay(engine) {
|
||||
if (!rows.has(row)) return;
|
||||
const moved = lon !== row.lon || lat !== row.lat;
|
||||
Object.assign(row, { lon, lat, bearing });
|
||||
if (moved) place(row);
|
||||
wake();
|
||||
if (moved) locate(row);
|
||||
changed();
|
||||
},
|
||||
remove(row) {
|
||||
if (rows.delete(row)) wake();
|
||||
if (rows.delete(row)) changed();
|
||||
},
|
||||
removeAll() {
|
||||
rows.clear();
|
||||
wake();
|
||||
changed();
|
||||
},
|
||||
show(value) {
|
||||
visible = value !== false;
|
||||
wake();
|
||||
changed();
|
||||
},
|
||||
shown: () => visible,
|
||||
count: () => rows.size,
|
||||
@@ -280,6 +270,7 @@ export function createStreetLevelDisplay(engine) {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
removePaint();
|
||||
surface.destroy();
|
||||
for (const { bank } of banks.values()) bank.destroy();
|
||||
banks.clear();
|
||||
rows.clear();
|
||||
|
||||
@@ -212,6 +212,16 @@ test('a sprite sits on the terrain; under Google 3D on the tiles, placed again w
|
||||
assert.ok(Math.abs(heightOf(google.drawn().position) - 12) < 1e-6);
|
||||
});
|
||||
|
||||
test('a sprite off screen waits on the terrain, not on the ellipsoid', () => {
|
||||
const harness = streetLevelMap({ view: true });
|
||||
harness.engine.fake.script('terrainHeight', () => 120);
|
||||
harness.engine.projector.worldToWindow = () => undefined;
|
||||
const { group, row, drawn } = oneSprite(harness);
|
||||
assert.ok(Math.abs(heightOf(drawn().position) - 120) < 1e-6);
|
||||
group.move(row, { lon: -121.4945 });
|
||||
assert.ok(Math.abs(heightOf(drawn().position) - 120) < 1e-6, 'moved');
|
||||
});
|
||||
|
||||
test('a sprite without any ground sits on the ellipsoid', () => {
|
||||
const harness = streetLevelMap({ view: true });
|
||||
harness.engine.fake.script('terrainHeight', () => undefined);
|
||||
@@ -318,3 +328,96 @@ test('destroying the display takes everything it still draws', () => {
|
||||
assert.equal(harness.engine.movers.list().length, 0);
|
||||
harness.map.destroy();
|
||||
});
|
||||
|
||||
// Under Google 3D a settle (tilesLoaded false -> true) measures a long
|
||||
// sequence's cones with sampleHeight (a pick render each on Cesium) a few per
|
||||
// pass, not all in one frame.
|
||||
test('a settle under Google 3D samples a bounded number of cones per frame', async (t) => {
|
||||
const harness = streetLevelMap({ view: true });
|
||||
t.after(() => harness.engine.destroy());
|
||||
t.after(() => harness.map.destroy());
|
||||
const { fake } = harness.engine;
|
||||
fake.script('terrainHeight', () => 12);
|
||||
await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' });
|
||||
let samples = 0;
|
||||
fake.script('sampleHeight', (_point, exclude) => {
|
||||
assert.ok(
|
||||
harness.engine.movers
|
||||
.list()
|
||||
.every(({ id }) => exclude.some((set) => set.id === id)),
|
||||
'samples exclude every current sprite set',
|
||||
);
|
||||
samples++;
|
||||
return 31;
|
||||
});
|
||||
fake.script('tilesLoaded', true);
|
||||
const group = harness.map.sprites('test-cones', { order: -1 });
|
||||
for (let i = 0; i < 400; i++)
|
||||
group.add({
|
||||
pickId: `p:${i}`,
|
||||
lon: -121.4944 + i * 1e-5,
|
||||
lat: 38.5816,
|
||||
bearing: 0,
|
||||
image: 'data:image/svg+xml;utf8,<svg/>',
|
||||
imageKey: 'glyph',
|
||||
sizePx: 26,
|
||||
});
|
||||
assert.equal(
|
||||
samples,
|
||||
0,
|
||||
'adding cones does not synchronously pick the surface',
|
||||
);
|
||||
harness.frame();
|
||||
assert.ok(samples > 0 && samples <= 16, `initial pass sampled ${samples}`);
|
||||
fake.script('tilesLoaded', false);
|
||||
harness.frame();
|
||||
samples = 0;
|
||||
fake.script('tilesLoaded', true);
|
||||
harness.frame();
|
||||
assert.ok(
|
||||
samples <= 16,
|
||||
`one settle frame ran ${samples} sampleHeight calls for 400 cones`,
|
||||
);
|
||||
harness.engine.projector.worldToWindow = () => undefined;
|
||||
samples = 0;
|
||||
harness.engine.camera.moveEnd.raiseEvent();
|
||||
assert.equal(samples, 0, 'off-screen cones are not sampled');
|
||||
group.destroy();
|
||||
});
|
||||
|
||||
test('slow surface samples stop at the time budget and remaining cones resume', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const harness = streetLevelMap({ view: true });
|
||||
t.after(() => harness.engine.destroy());
|
||||
t.after(() => harness.map.destroy());
|
||||
await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' });
|
||||
harness.engine.fake.script('tilesLoaded', true);
|
||||
harness.engine.fake.frames(2);
|
||||
harness.engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
|
||||
let clock = 0;
|
||||
let samples = 0;
|
||||
t.mock.method(performance, 'now', () => clock);
|
||||
harness.engine.fake.script('sampleHeight', () => {
|
||||
samples++;
|
||||
clock += 3;
|
||||
return 31;
|
||||
});
|
||||
const group = harness.map.sprites('slow-cones');
|
||||
for (let i = 0; i < 6; i++)
|
||||
group.add({
|
||||
pickId: `cone:${i}`,
|
||||
lon: 0,
|
||||
lat: 0,
|
||||
bearing: 0,
|
||||
image: 'data:,',
|
||||
imageKey: 'cone',
|
||||
sizePx: 26,
|
||||
});
|
||||
harness.frame();
|
||||
assert.equal(samples, 2, 'a pass stops once its samples have spent 4 ms');
|
||||
t.mock.timers.runAll();
|
||||
assert.equal(samples, 6, 'all visible cones are eventually placed');
|
||||
const before = samples;
|
||||
harness.frame();
|
||||
assert.equal(samples, before, 'a parked surface is not sampled again');
|
||||
});
|
||||
+55
-29
@@ -7,6 +7,7 @@ import { describeError } from './standalone/errors.js';
|
||||
import { createEngineAddress } from './standalone/engineSwitch.js';
|
||||
import {
|
||||
DEFAULT_ENGINE_CHOICE,
|
||||
ENGINE_NAMES,
|
||||
ENGINE_STORAGE_KEY,
|
||||
engineOfChoice,
|
||||
resolveEngineKind,
|
||||
@@ -30,6 +31,7 @@ const geospatial =
|
||||
|
||||
const display = useDisplayStore(applicationPinia());
|
||||
const loading = document.getElementById('loading-screen');
|
||||
let showingStartFailure = false;
|
||||
let application;
|
||||
let engineSwitch = null;
|
||||
// ?parkedMs= shortens how long a parked globe waits before it is released,
|
||||
@@ -125,56 +127,77 @@ function present() {
|
||||
button.disabled = busy;
|
||||
}
|
||||
|
||||
// Product names: never translated.
|
||||
const ENGINE_NAMES = {
|
||||
cesium: 'Cesium',
|
||||
navara: 'Navara',
|
||||
'navara-hd': 'Navara HD',
|
||||
};
|
||||
|
||||
// No engine runs after a failed recovery. The error stays on the loading
|
||||
// screen with an engine picker, so a new attempt needs no reload.
|
||||
function offerEngineRetry() {
|
||||
const content = loading.querySelector('.loader-content') ?? loading;
|
||||
if (content.querySelector('.engine-switch-retry')) return;
|
||||
const existing = content.querySelector('.engine-switch-retry');
|
||||
if (existing) {
|
||||
existing.querySelector('button')?.focus();
|
||||
return;
|
||||
}
|
||||
const group = document.createElement('div');
|
||||
group.className = 'engine-switch-retry';
|
||||
group.setAttribute('role', 'group');
|
||||
group.setAttribute('aria-label', t('Globe Engine'));
|
||||
const label = document.createElement('p');
|
||||
label.id = 'engine-switch-retry-label';
|
||||
label.textContent = t('Choose an engine to try again.');
|
||||
group.setAttribute('aria-labelledby', label.id);
|
||||
group.append(label);
|
||||
for (const [kind, name] of Object.entries(ENGINE_NAMES)) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.dataset.engine = kind;
|
||||
button.textContent = name;
|
||||
button.setAttribute('translate', 'no');
|
||||
button.addEventListener('click', () => select(kind));
|
||||
button.addEventListener('click', () => {
|
||||
// The retry's progress replaces the error: no longer an alert.
|
||||
loading.querySelector('.loader-status').removeAttribute('role');
|
||||
select(kind);
|
||||
});
|
||||
group.append(button);
|
||||
}
|
||||
content.append(group);
|
||||
group.querySelector('button').focus();
|
||||
}
|
||||
|
||||
function withdrawEngineRetry() {
|
||||
loading.querySelector('.engine-switch-retry')?.remove();
|
||||
if (!showingStartFailure) return;
|
||||
showingStartFailure = false;
|
||||
loading.classList.add('hidden');
|
||||
}
|
||||
|
||||
function report(error) {
|
||||
function report(error, kind) {
|
||||
showingStartFailure = true;
|
||||
console.error("God's Eye View initialization failed:", error);
|
||||
loading.querySelector('.loader-status').textContent = t('Error: {message}', {
|
||||
message: describeError(error),
|
||||
});
|
||||
const status = loading.querySelector('.loader-status');
|
||||
status.setAttribute('role', 'alert');
|
||||
status.textContent = kind
|
||||
? t('Could not start {engine}: {message}', {
|
||||
engine: ENGINE_NAMES[kind] ?? kind,
|
||||
message: describeError(error),
|
||||
})
|
||||
: t('Error: {message}', { message: describeError(error) });
|
||||
loading.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// A globe that failed to start while another runs: say which one runs.
|
||||
function showStartFailure(error, running) {
|
||||
const style = liveComponents()?.controls.styleManager;
|
||||
style?._showToast(
|
||||
t('Globe engine failed to start. Running {engine}: {message}', {
|
||||
engine: ENGINE_NAMES[running] ?? running,
|
||||
message: describeError(error),
|
||||
}),
|
||||
);
|
||||
return Boolean(style);
|
||||
if (!style) return false;
|
||||
void Promise.resolve(style.initialRestorePromise).then(() => {
|
||||
if (signal.aborted || liveComponents()?.controls.styleManager !== style)
|
||||
return;
|
||||
style._showToast(
|
||||
t('Globe engine failed to start. Running {engine}: {message}', {
|
||||
engine: ENGINE_NAMES[running] ?? running,
|
||||
message: describeError(error),
|
||||
}),
|
||||
6000,
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function select(next) {
|
||||
@@ -232,15 +255,17 @@ async function start(kind, { fallback = false, rememberChoice = false } = {}) {
|
||||
renderers,
|
||||
diagnostics: session,
|
||||
keepFrame: keepGlobeFrame,
|
||||
onSettled() {
|
||||
withdrawEngineRetry();
|
||||
loading.classList.add('hidden');
|
||||
},
|
||||
onSettled: withdrawEngineRetry,
|
||||
address: createEngineAddress({
|
||||
location,
|
||||
history,
|
||||
flush: () =>
|
||||
liveComponents()?.controls.styleManager.shareLinkManager?.flush(),
|
||||
flush: () => {
|
||||
const live = liveComponents();
|
||||
// A pick made mid-flight hands over the flight's destination, as
|
||||
// if it had landed (Cesium's completeFlight), not a midpoint.
|
||||
live?.scene.engine.camera.completeFlight();
|
||||
live?.controls.styleManager.shareLinkManager?.flush();
|
||||
},
|
||||
}),
|
||||
remember,
|
||||
onChange: present,
|
||||
@@ -250,8 +275,9 @@ async function start(kind, { fallback = false, rememberChoice = false } = {}) {
|
||||
offerEngineRetry();
|
||||
},
|
||||
});
|
||||
if (showingStartFailure)
|
||||
await liveComponents()?.controls.styleManager.initialRestorePromise;
|
||||
withdrawEngineRetry();
|
||||
loading.classList.add('hidden');
|
||||
present();
|
||||
// A history visit made while the first engine started.
|
||||
void engineSwitch.reconcile();
|
||||
@@ -262,7 +288,7 @@ async function start(kind, { fallback = false, rememberChoice = false } = {}) {
|
||||
await start(DEFAULT_ENGINE_CHOICE, { rememberChoice: true });
|
||||
showStartFailure(error, DEFAULT_ENGINE_CHOICE);
|
||||
} else {
|
||||
report(error);
|
||||
report(error, kind);
|
||||
offerEngineRetry();
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
export const SENTINEL2_URL =
|
||||
'https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg';
|
||||
export const SENTINEL2_MAX_ZOOM = 14;
|
||||
// The attribution EOX's licence requires for the 2020 mosaic (DATA_SOURCES.md).
|
||||
export const SENTINEL2_CREDIT =
|
||||
'<a href="https://maps.eox.at/">EOX Maps</a> · <a href="https://s2maps.eu/">Sentinel-2 cloudless 2020</a> by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020)';
|
||||
'<a href="https://cloudless.eox.at/">EOxCloudless</a> by <a href="https://eox.at/">EOX IT Services GmbH</a> (Contains modified Copernicus Sentinel data 2020)';
|
||||
@@ -66,6 +66,10 @@ export function createEngineAddress({ location, history, flush = () => {} }) {
|
||||
* (`addressLayerIds`): the layer-state coordinator keeps it running as it
|
||||
* was where the incoming address agrees (always after a pick) and follows a
|
||||
* visited entry where it differs (handoffTargetEnabled).
|
||||
* @param {import('../app/layerSession.js').LayerSession} session
|
||||
* @param {object} [options]
|
||||
* @param {Iterable<string> | null} [options.addressLayerIds] Layers listed by the outgoing address.
|
||||
* @param {boolean} [options.visit=false] Restore a history entry's layer options; a picker change keeps the live options.
|
||||
*/
|
||||
export function handOffSession(
|
||||
session,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// A layer's real fleet tick and horizon culling (flights or military
|
||||
// rendering.js) over one contact, with motion and presentation injected: no
|
||||
// WebGL scene. The camera is 100 m over (0°, 0°); the contact, on the ground
|
||||
// at (1°, 0°), is 111 km away: beyond the camera's horizon, but inside the
|
||||
// model keep radius.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonVisible } from '../globe/cameraMath.js';
|
||||
import { FLEET_DR_INTERVAL_MS } from '../layers/flights/policy.js';
|
||||
|
||||
/**
|
||||
* @param {{ createFlightState: Function, createRendering: Function }} layer
|
||||
* The layer's state.js and rendering.js factories.
|
||||
*/
|
||||
export function fleetTickFixture({ createFlightState, createRendering }) {
|
||||
let now = 1000;
|
||||
let reckonings = 0;
|
||||
const services = {
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||
militaryRegistry: { isMilitaryIcao: () => false },
|
||||
aircraftPresentation: { isTr3b: () => false },
|
||||
camera: {},
|
||||
focus: {
|
||||
focusNowMs: () => now,
|
||||
getFocusTarget: () => null,
|
||||
nearFarScalarValueAtDistance: () => 1,
|
||||
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
|
||||
},
|
||||
recession: {
|
||||
applyAircraftBillboardTreatment: () => ({
|
||||
factors: { scale: 1 },
|
||||
alpha: 1,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const flightState = createFlightState({ services });
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
|
||||
positionCartographic: { height: 100 },
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
roll: 0,
|
||||
frustum: {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const id = 'abc123';
|
||||
const billboard = {
|
||||
show: false,
|
||||
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
|
||||
};
|
||||
const removed = [];
|
||||
// The renderer's fleet, models and view, as the Cesium renderer gives
|
||||
// them; no model loads.
|
||||
flightState._renderer = {
|
||||
fleet: { visible: () => true },
|
||||
models: {
|
||||
live: () => true,
|
||||
loadFleet: () => Promise.reject(new Error('no models here')),
|
||||
remove: (model) => removed.push(model),
|
||||
},
|
||||
view: {
|
||||
camera,
|
||||
scene: {},
|
||||
horizon: () => ({
|
||||
isPointVisible: (position) =>
|
||||
horizonVisible(camera.positionWC, position),
|
||||
}),
|
||||
frustumTester: () => () => true,
|
||||
},
|
||||
};
|
||||
flightState._billboards.set(id, billboard);
|
||||
flightState.records.data.set(id, {});
|
||||
flightState._models3dEnabled = false;
|
||||
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
|
||||
flightState._cockpitContactMode = true;
|
||||
const parts = {
|
||||
tracking: { _refreshCockpitNearContacts() {} },
|
||||
enrichment: { _requestTypeEnrichment() {} },
|
||||
motion: {
|
||||
_deadReckon() {
|
||||
reckonings++;
|
||||
return billboard.position;
|
||||
},
|
||||
_floorGroundedDisplayPosition(id, info, position) {
|
||||
return position;
|
||||
},
|
||||
},
|
||||
};
|
||||
const rendering = createRendering({ flightState, services, parts });
|
||||
return {
|
||||
flightState,
|
||||
camera,
|
||||
billboard,
|
||||
id,
|
||||
/** Fleet models the tick released. */
|
||||
removed,
|
||||
reckonings: () => reckonings,
|
||||
tick() {
|
||||
now += FLEET_DR_INTERVAL_MS;
|
||||
rendering._fleetTick();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -297,7 +297,6 @@ test('module analysis can tell dynamic imports from static ones', () => {
|
||||
{
|
||||
imports: ['one', 'two', 'three', 'four'],
|
||||
browser: [],
|
||||
dynamicImports: ['three', 'four'],
|
||||
staticImports: ['one', 'two'],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1295,12 +1295,13 @@ export class ShellFacade {
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a temporary toast notification for 2 seconds.
|
||||
* Displays a temporary toast notification.
|
||||
* @param {string} message - Text to show in the toast.
|
||||
* @param {number} [durationMs=2000] How long the notice stays visible.
|
||||
* @returns {void}
|
||||
*/
|
||||
_showToast(message) {
|
||||
return this._feedback._showToast(message);
|
||||
_showToast(message, durationMs) {
|
||||
return this._feedback._showToast(message, durationMs);
|
||||
}
|
||||
|
||||
/** One 3D toggle drives BOTH aircraft layers (commercial + military) so all planes flip together. */
|
||||
|
||||
@@ -374,9 +374,9 @@ export class ShellFeedback {
|
||||
this._loadingFeedbackTicker = null;
|
||||
}
|
||||
|
||||
_showToast(message) {
|
||||
_showToast(message, durationMs) {
|
||||
if (this.destroyed) return;
|
||||
this._toast.show(message);
|
||||
this._toast.show(message, durationMs);
|
||||
}
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
|
||||
@@ -436,14 +436,27 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* A start error is steady text, not pulsing progress. */
|
||||
.loader-status[role='alert'] {
|
||||
color: var(--text-primary);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* After a failed globe switch: start an engine again without a reload. */
|
||||
.engine-switch-retry {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.engine-switch-retry p {
|
||||
flex-basis: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.engine-switch-retry button {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
|
||||
+3
-3
@@ -25,11 +25,11 @@ export function createToast({
|
||||
store.publishToast({ ...store.toast, visible: false });
|
||||
};
|
||||
return {
|
||||
/** @param {string} message */
|
||||
show(message) {
|
||||
/** @param {string} message @param {number} [dwellMs] */
|
||||
show(message, dwellMs = durationMs) {
|
||||
store.publishToast({ text: message, visible: true, shown: ++shown });
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(hide, durationMs);
|
||||
timer = setTimeout(hide, dwellMs);
|
||||
},
|
||||
/** Hide the toast now and drop the timer. */
|
||||
hide,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useStatusStore } from './stores/status.js';
|
||||
import { createToast } from './toast.js';
|
||||
import { ShellFeedback } from './shellFeedback.js';
|
||||
import { ShellFacade } from './shellFacade.js';
|
||||
import SceneChromeView from './views/SceneChromeView.vue';
|
||||
|
||||
function fixture(t) {
|
||||
@@ -77,3 +79,21 @@ test('a repeated message is written again so the live region announces it', asyn
|
||||
assert.ok(toast().firstChild !== first, 'the repeat is a new text node');
|
||||
assert.equal(toast().classList.contains('visible'), true);
|
||||
});
|
||||
|
||||
test('an engine failure can stay readable longer than an ordinary shell toast', async (t) => {
|
||||
const { store, toast } = fixture(t);
|
||||
const feedback = new ShellFeedback({ readLayers: () => [], store });
|
||||
t.after(() => feedback.destroy());
|
||||
const shell = { _feedback: feedback };
|
||||
ShellFacade.prototype._showToast.call(shell, 'Engine failed', 6000);
|
||||
t.mock.timers.tick(2000);
|
||||
await nextTick();
|
||||
assert.equal(toast().classList.contains('visible'), true);
|
||||
t.mock.timers.tick(4000);
|
||||
await nextTick();
|
||||
assert.equal(toast().classList.contains('visible'), false);
|
||||
ShellFacade.prototype._showToast.call(shell, 'Link copied');
|
||||
t.mock.timers.tick(2000);
|
||||
await nextTick();
|
||||
assert.equal(toast().classList.contains('visible'), false);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ENGINE_NAMES } from '../../app/engineChoice.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { vPanelState } from '../rail/panelState.js';
|
||||
import UiButton from '../kit/UiButton.vue';
|
||||
@@ -19,8 +20,6 @@ import VoiceControl from './panels/VoiceControl.vue';
|
||||
// button calls the Display controls' owner, a chip the map source controls.
|
||||
const display = useDisplayStore();
|
||||
const { style, mapSource, mapSourceControls, globeEngine } = storeToRefs(display);
|
||||
// Product names, as the picker's options show them.
|
||||
const ENGINE_NAMES = { cesium: 'Cesium', navara: 'Navara', 'navara-hd': 'Navara HD' };
|
||||
// LocationControls publishes the location tray; pills call the controls,
|
||||
// and the search field keeps their listeners.
|
||||
const { view: loc, controls: locationControls } = storeToRefs(useLocationStore());
|
||||
@@ -120,9 +119,7 @@ function selectMapSource(chip) {
|
||||
{{ t('Globe Engine') }}
|
||||
<UiSelect id="globe-engine" class="gev-field gev-field--compact" translate="no" :model-value="globeEngine.kind" :aria-disabled="String(globeEngine.busy)" :aria-busy="String(globeEngine.busy)"
|
||||
@update:model-value="!globeEngine.busy && globeEngine.select($event)">
|
||||
<option value="cesium">Cesium</option>
|
||||
<option value="navara">Navara</option>
|
||||
<option value="navara-hd">Navara HD</option>
|
||||
<option v-for="(name, kind) in ENGINE_NAMES" :key="kind" :value="kind">{{ name }}</option>
|
||||
</UiSelect>
|
||||
</label>
|
||||
<span id="globe-engine-status" role="status">{{ globeEngine.busy ? t('Initializing systems...') : ENGINE_NAMES[globeEngine.kind] }}</span>
|
||||
|
||||
@@ -195,7 +195,7 @@ export function createWildlifeSightingCard({
|
||||
position({ allowPan: metadata?.explicitSelection === true });
|
||||
if (moving) startFrameUpdates();
|
||||
// Focus the container so keyboard navigation starts inside the card.
|
||||
if (fresh)
|
||||
if (metadata?.explicitSelection === true)
|
||||
host
|
||||
.querySelector('[data-wildlife-sighting-card]')
|
||||
?.focus?.({ preventScroll: true });
|
||||
|
||||
@@ -836,3 +836,35 @@ test('remounting a selected sighting does not pan the new engine', async (contex
|
||||
await settle();
|
||||
assert.equal(pans, 0);
|
||||
});
|
||||
|
||||
test('remounting a selected sighting leaves keyboard focus where it is', async (context) => {
|
||||
const f = fixture(context);
|
||||
await f.click();
|
||||
f.card.destroy();
|
||||
// The engine switch is made from the Globe Engine picker.
|
||||
const picker = document.body.appendChild(document.createElement('select'));
|
||||
picker.focus();
|
||||
const ui = createUiRuntime();
|
||||
const card = createWildlifeSightingCard({
|
||||
layer: f.layer,
|
||||
ui,
|
||||
engine: f.engine,
|
||||
documentRef: document,
|
||||
windowRef: f.windowRef,
|
||||
now: () => NOW,
|
||||
project: () => ({ x: 100, y: 100 }),
|
||||
pan: () => true,
|
||||
});
|
||||
context.after(() => {
|
||||
card.destroy();
|
||||
ui.destroy();
|
||||
});
|
||||
await settle();
|
||||
assert.ok(f.cardElement(), 'the carried selection opens the card');
|
||||
// A primitive: the reporter would print two DOM elements very slowly.
|
||||
assert.equal(
|
||||
document.activeElement === picker,
|
||||
true,
|
||||
`focus moved to ${document.activeElement?.tagName}`,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user