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:
German Khokhlov committed 2026-11-10 01:57:11 +04:00
1 parent 0ef29781e5
commit 60fb8fee07
82 files changed
+2363 -1574

No files matched your search

+8 -2
View File
@@ -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];
+1 -1
View File
@@ -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
View File
@@ -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']);
});
+6 -3
View File
@@ -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)
+38 -2
View File
@@ -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;
+96 -42
View File
@@ -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)
+553 -6
View File
@@ -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();
},
);
-97
View File
@@ -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`);
});
+7 -2
View File
@@ -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),
+38
View File
@@ -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]);
});
-112
View File
@@ -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);
});
+9 -7
View File
@@ -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 },
+12 -1
View File
@@ -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);
},
+27
View File
@@ -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) =>
+36
View File
@@ -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');
});
+12 -3
View File
@@ -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;
},
};
}
+36
View File
@@ -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.
+6
View File
@@ -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,
-141
View File
@@ -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');
});
+33 -16
View File
@@ -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,
+137
View File
@@ -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();
});
-186
View File
@@ -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();
});
+58
View File
@@ -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();
});
-104
View File
@@ -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();
});
+38
View File
@@ -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');
});
-110
View File
@@ -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');
});
+34
View File
@@ -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 () => {
+8
View File
@@ -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));
+3 -1
View File
@@ -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.": "اختر محركًا لإعادة المحاولة."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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.": "エンジンを選んでもう一度お試しください。"
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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.": "Выберите движок, чтобы попробовать снова."
}
+3 -1
View File
@@ -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.": "Виберіть рушій, щоб спробувати знову."
}
+3 -1
View File
@@ -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.": "选择一个引擎重试。"
}
+3 -1
View File
@@ -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();
});
+19 -8
View File
@@ -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);
});
-1
View File
@@ -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(
+4
View File
@@ -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';
+4
View File
@@ -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)) {
+42
View File
@@ -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();
}
});
+4
View File
@@ -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();
+25 -101
View File
@@ -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');
},
};
+62 -6
View File
@@ -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();
}
});
+17 -4
View File
@@ -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) {
+46 -5
View File
@@ -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 {
+51 -60
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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)';
+4
View File
@@ -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,
+106
View File
@@ -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();
},
};
}
-1
View File
@@ -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'],
},
);
+4 -3
View File
@@ -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. */
+2 -2
View File
@@ -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;
+13
View File
@@ -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
View File
@@ -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,
+20
View File
@@ -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);
});
+2 -5
View File
@@ -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>
+1 -1
View File
@@ -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 });
+32
View File
@@ -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}`,
);
});