Fix the review findings across the engines, layers, data and QA

Navara now matches Cesium where the review found it diverging: a flight
holds the camera inputs, camera.changed counts turns as Cesium does,
render requests made during a frame survive it, follow pinches zoom,
ground widths follow the pixel ratio and Cyber sonar reaches glyph caches
and models. On Cesium, a staged imagery swap keeps loading tiles, setView
replaces a running flight, and a cleared weather detail goes away.

The contract gains MoverSet.failed, maps.terrainRevision,
render.maxTextureSize and surface.sampleTerrainMostDetailed on every
engine, so layers retry failed models, key road heights by terrain,
size weather textures to the GPU and place Bhote Koshi cards on detailed
terrain.

Engine switches keep the live view, follows, focus, layer options and
cards: a pick hands over the exact pose, applies no address options and
pans no carried-over card; a history visit still applies its entry.

Layers put GhostMaps pins, cable landings, OSM Places and directions on
the measured surface without per-frame sampling, draw the Cockpit contact
dot, hide disabled CCTV on attach, and keep ALPR running outside secure
contexts. KML imports draw raised polygon fills, rotated and coloured
ground overlays and north-referenced icon headings, and report files
with nothing to draw.

QA scripts read the contract instead of Cesium internals (test:track is
109/109 again), wait for the camera to rest before clicking, and give
cold switches and parked engines realistic time. Docs follow the code.
This commit is contained in:
German Khokhlov committed 2026-11-09 21:34:42 +04:00
1 parent 69fbf5b38d
commit 0ef29781e5
276 files changed
+7217 -1517

No files matched your search

@@ -245,6 +245,7 @@ export function createNavaraWorldAnnotationRenderer(
export function createNavaraAnnotationRenderer(engine, views, { screen } = {}) {
const world = createNavaraWorldAnnotationRenderer(engine, views);
const renderer = createHybridAnnotationRenderer(null, {
engine,
world,
screenScene: screenSceneFromEngine(engine),
screen,
@@ -12,6 +12,7 @@ import {
createNavaraWorldAnnotationRenderer,
drapedParts,
} from './navaraAnnotationRenderer.js';
import { createSceneServices } from '../data/sceneServices.js';
import { screenSceneFromEngine } from './screenScene.js';
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
@@ -265,3 +266,39 @@ test('marks on the Navara board are drawn, horizon-culled and survive an engine
engine.fake.frame();
assert.equal(live().length, 0);
});
test('screen annotations read the attached engines tracked card footprint', async (t) => {
const { store, groups } = board(t);
const engine = createFakeEngine({
longitude: 0,
latitude: 0,
altitude: 10000,
});
const services = createSceneServices(engine);
const queries = [];
services.overlay.getPaintRect = (...args) => {
queries.push(args);
return { x: 0, y: 0, w: 4000, h: 4000 };
};
const { views } = fakeViews();
const renderer = createNavaraAnnotationRenderer(engine, views, {
screen: { store, activeTrackedReadoutId: () => 'flight:test' },
});
t.after(() => {
renderer.destroy();
services.destroy();
engine.destroy();
});
renderer.add({
id: 'overlap',
type: 'label',
color: 'primary',
label: 'Overlap',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
engine.fake.frame();
await settle();
assert.deepEqual(queries.at(-1), ['tracked', 'flight:test']);
assert.ok(Number(groups()[0].getAttribute('opacity')) < 1);
});
+11 -5
View File
@@ -2297,13 +2297,19 @@ export function createAnnotationResolver({
/**
* Whether a depth pick landed on the camera itself: something drawn into
* the depth buffer at the near plane (Navara's screen-space preview lines
* and sprites) reads back as a point a metre or so from the camera. Such a
* pick is a miss; the ray's globe hit answers instead.
* and sprites) reads back as a point near the camera, the nearer the lower
* the camera. Such a pick is a miss; the ray's globe hit answers instead.
* The scale is the rendered surface under the ray, not sea level, so a
* camera just above high terrain keeps its ground.
*/
function depthAtCamera(engine, screen, hit) {
const camera = engine.camera.livePosition;
const ground = engine.camera.pickEllipsoid(screen);
const reference = ground ? geo.cartesian3.distance(ground, camera) : 0;
let ground = engine.picker.pickGlobe(screen);
if (!isPickedWorldPosition(ground))
ground = engine.camera.pickEllipsoid(screen);
const reference = isPickedWorldPosition(ground)
? geo.cartesian3.distance(ground, camera)
: 0;
return geo.cartesian3.distance(hit, camera) < Math.max(2, 0.01 * reference);
}
@@ -2340,8 +2346,8 @@ export function createAnnotationResolver({
const hit = engine.picker.pickPosition(screen);
return hit && !depthAtCamera(engine, screen, hit) ? hit : undefined;
},
() => engine.camera.pickEllipsoid(screen),
() => engine.picker.pickGlobe(screen),
() => engine.camera.pickEllipsoid(screen),
];
for (const stage of stages) {
let cart = null;
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as geo from '../geo/index.js';
import { pickWorldFromScreen } from './annotationResolver.js';
// Camera 30 m above 4000 m terrain, looking straight down: the depth pick
// hits the ground 30 m away; the ellipsoid lies 4030 m away.
test('a depth pick on high terrain close to the camera is kept', () => {
const camera = geo.cartesian3.fromDegrees(86.9, 27.9, 4030);
const ground = geo.cartesian3.fromDegrees(86.9, 27.9, 4000);
const seaLevel = geo.cartesian3.fromDegrees(86.9, 27.9, 0);
const engine = {
host: { size: () => ({ width: 100, height: 100 }) },
picker: {
pickPositionSupported: true,
pickPosition: () => geo.cartesian3.clone(ground),
pickGlobe: () => geo.cartesian3.clone(ground),
},
camera: {
livePosition: camera,
pickEllipsoid: () => geo.cartesian3.clone(seaLevel),
},
};
const picked = pickWorldFromScreen(engine, 0.5, 0.5);
assert.ok(
Math.abs(picked.height - 4000) < 1,
`picked height ${picked.height}`,
);
});
test('a near-camera depth artifact falls back to terrain before sea level', () => {
const engine = {
host: { size: () => ({ width: 100, height: 100 }) },
picker: {
pickPositionSupported: true,
pickPosition: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4029),
pickGlobe: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4000),
},
camera: {
livePosition: geo.cartesian3.fromDegrees(86.9, 27.9, 4030),
pickEllipsoid: () => geo.cartesian3.fromDegrees(86.9, 27.9, 0),
},
};
assert.ok(Math.abs(pickWorldFromScreen(engine, 0.5, 0.5).height - 4000) < 1);
});
test('a far camera still treats a near-plane depth hit as a miss', () => {
// 100 km up: a preview drawn at the near plane reads back 50 m away.
const camera = geo.cartesian3.fromDegrees(86.9, 27.9, 100_000);
const engine = {
host: { size: () => ({ width: 100, height: 100 }) },
picker: {
pickPositionSupported: true,
pickPosition: () => geo.cartesian3.fromDegrees(86.9, 27.9, 99_950),
pickGlobe: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4000),
},
camera: {
livePosition: camera,
pickEllipsoid: () => geo.cartesian3.fromDegrees(86.9, 27.9, 0),
},
};
assert.ok(Math.abs(pickWorldFromScreen(engine, 0.5, 0.5).height - 4000) < 1);
});
+187 -325
View File
@@ -5,7 +5,6 @@ import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { rendererFor } from './layers/renderers.js';
import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js';
@@ -34,7 +33,7 @@ import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { OSM_OVERLAYS, createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -113,351 +112,214 @@ export function createApplicationCatalog({
});
}
}
// Shared instances (awareness and launches read others) are made once,
// when the first layer that needs them is built.
const once = (create) => {
let value;
return () => (value ??= create());
};
const sessionModel = (id, create) => {
if (!layerSession) return create();
const models = (layerSession.models ??= new Map());
if (!models.has(id)) models.set(id, create());
return models.get(id);
};
const flights = once(() =>
sessionModel('flights', () =>
createApplicationFlights({
const flights = sessionModel('flights', () =>
createApplicationFlights({
surface,
source: sources.flights,
militaryRegistry,
resolveAsset,
}),
);
const military = sessionModel('military', () =>
createApplicationMilitary({
surface,
source: sources.military,
militaryRegistry,
resolveAsset,
}),
);
const vessels = sessionModel('ais-live-vessels', () =>
createApplicationVessels({
source: sources.vessels,
options: vesselOptions,
}),
);
const installations = sessionModel('military-installations', () =>
createApplicationInstallations({
surface,
source: sources.installations,
}),
);
const satellites = sessionModel('satellites', () =>
createApplicationSatellites({
source: sources.satellites,
}),
);
const layers = [
sessionModel('bhote-koshi-2026', () =>
createBhoteKoshiEventLayer({
createNativeRenderer: (engine) =>
rendererFor('bhote-koshi-2026', engine),
}),
),
sessionModel('bhote-koshi-locator', () =>
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
createNativeRenderer: (engine, data) =>
rendererFor('bhote-koshi-locator', engine, { data }),
}),
),
flights,
military,
sessionModel('local-adsb', () =>
createApplicationLocalAdsb({
surface,
source: sources.flights,
militaryRegistry,
resolveAsset,
// QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
...(sources.localAdsbFeeds ? { feeds: sources.localAdsbFeeds } : {}),
enrichment: sources.flights,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
),
);
const military = once(() =>
sessionModel('military', () =>
createApplicationMilitary({
sessionModel('earthquakes', () =>
createApplicationEarthquakes({ source: sources.earthquakes }),
),
sessionModel('air-quality', () =>
createApplicationAirQuality({ source: sources['air-quality'] }),
),
sessionModel('fire-perimeters', () =>
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
),
sessionModel('gnss-integrity', () =>
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
),
sessionModel('s2-ghostmaps', () =>
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
),
sessionModel('drone-airspace', () =>
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
),
sessionModel('wildlife-sightings', () =>
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
}),
),
sessionModel('alpr-cameras', () =>
createApplicationAlpr({ surface, source: sources.alpr }),
),
sessionModel('street-level', () =>
createApplicationStreetLevel({
surface,
source: sources.military,
militaryRegistry,
resolveAsset,
nativeRenderer: (engine) => rendererFor('street-level', engine),
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
),
);
const vessels = once(() =>
sessionModel('ais-live-vessels', () =>
createApplicationVessels({
source: sources.vessels,
options: vesselOptions,
satellites,
sessionModel('rocket-launches', () =>
createApplicationLaunches({
source: sources.launches,
satellites: satellites,
}),
),
);
const installations = once(() =>
sessionModel('military-installations', () =>
createApplicationInstallations({
sessionModel('traffic', () =>
createApplicationTraffic({
source: sources.traffic,
surface,
source: sources.installations,
}),
),
);
const satellites = once(() =>
sessionModel('satellites', () =>
createApplicationSatellites({
source: sources.satellites,
sessionModel('cctv', () =>
createApplicationCctv({ surface, source: sources.cctv }),
),
sessionModel('radio', () =>
createApplicationRadio({ surface, source: sources.radio }),
),
sessionModel('transit', () =>
createApplicationTransit({ surface, source: sources.transit }),
),
sessionModel('bikeshare', () =>
createApplicationBikeshare({ source: sources.bikeshare }),
),
sessionModel('directions', () =>
createApplicationDirections({
source: sources.directions,
surface,
}),
),
);
// Catalog order: [layer id(s), construction]. Only the layers the
// engine can show are constructed (renderers.js).
const entries = [
[
'bhote-koshi-2026',
() =>
sessionModel('bhote-koshi-2026', () =>
createBhoteKoshiEventLayer({
createNativeRenderer: (engine) =>
rendererFor('bhote-koshi-2026', engine),
}),
),
],
[
'bhote-koshi-locator',
() =>
sessionModel('bhote-koshi-locator', () =>
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
createNativeRenderer: (engine, data) =>
rendererFor('bhote-koshi-locator', engine, { data }),
}),
),
],
['flights', flights],
['military', military],
[
'local-adsb',
() =>
sessionModel('local-adsb', () =>
createApplicationLocalAdsb({
surface,
// QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
...(sources.localAdsbFeeds
? { feeds: sources.localAdsbFeeds }
: {}),
enrichment: sources.flights,
displayParams: () => flights().getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
),
],
[
'earthquakes',
() =>
sessionModel('earthquakes', () =>
createApplicationEarthquakes({ source: sources.earthquakes }),
),
],
[
'air-quality',
() =>
sessionModel('air-quality', () =>
createApplicationAirQuality({ source: sources['air-quality'] }),
),
],
[
'fire-perimeters',
() =>
sessionModel('fire-perimeters', () =>
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
),
],
[
'gnss-integrity',
() =>
sessionModel('gnss-integrity', () =>
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
),
],
[
's2-ghostmaps',
() =>
sessionModel('s2-ghostmaps', () =>
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
),
],
[
'drone-airspace',
() =>
sessionModel('drone-airspace', () =>
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
),
],
[
'wildlife-sightings',
() =>
sessionModel('wildlife-sightings', () =>
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
}),
),
],
[
'alpr-cameras',
() =>
sessionModel('alpr-cameras', () =>
createApplicationAlpr({ surface, source: sources.alpr }),
),
],
[
'street-level',
() =>
sessionModel('street-level', () =>
createApplicationStreetLevel({
surface,
nativeRenderer: (engine) => rendererFor('street-level', engine),
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
),
],
['satellites', satellites],
[
'rocket-launches',
() =>
sessionModel('rocket-launches', () =>
createApplicationLaunches({
source: sources.launches,
satellites: satellites(),
}),
),
],
[
'traffic',
() =>
sessionModel('traffic', () =>
createApplicationTraffic({
source: sources.traffic,
surface,
}),
),
],
[
'cctv',
() =>
sessionModel('cctv', () =>
createApplicationCctv({ surface, source: sources.cctv }),
),
],
[
'radio',
() =>
sessionModel('radio', () =>
createApplicationRadio({ surface, source: sources.radio }),
),
],
[
'transit',
() =>
sessionModel('transit', () =>
createApplicationTransit({ surface, source: sources.transit }),
),
],
[
'bikeshare',
() =>
sessionModel('bikeshare', () =>
createApplicationBikeshare({ source: sources.bikeshare }),
),
],
[
'directions',
() =>
sessionModel('directions', () =>
createApplicationDirections({
source: sources.directions,
surface,
}),
),
],
[
'recent-imagery',
() =>
sessionModel('recent-imagery', () =>
createApplicationRecentImagery({
source: sources['recent-imagery'],
}),
),
],
[
'osm-places',
() => sessionModel('osm-places', () => createApplicationOsmPlaces()),
],
['ais-live-vessels', vessels],
['military-installations', installations],
[
'military-awareness',
() =>
sessionModel('military-awareness', () =>
createApplicationAwareness({
flights: flights(),
military: military(),
vessels: vessels(),
installations: installations(),
}),
),
],
[
'wind',
() =>
sessionModel('wind', () =>
createWindLayer({
feed: sources.wind,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor('wind', engine, options),
}),
),
],
...['weather-radar', 'weather-satellite', 'weather-lightning'].map(
(id) => [
id,
() =>
sessionModel(id, () =>
createWeatherLayer({
feed: sources.weather,
id,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor(id, engine, options),
sessionModel('recent-imagery', () =>
createApplicationRecentImagery({
source: sources['recent-imagery'],
}),
),
sessionModel('osm-places', () => createApplicationOsmPlaces()),
vessels,
installations,
sessionModel('military-awareness', () =>
createApplicationAwareness({
flights: flights,
military: military,
vessels: vessels,
installations: installations,
}),
),
sessionModel('wind', () =>
createWindLayer({
feed: sources.wind,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor('wind', engine, options),
}),
),
...['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) =>
sessionModel(id, () =>
createWeatherLayer({
feed: sources.weather,
id,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor(id, engine, options),
}),
),
),
sessionModel('weather-cyclones', () =>
createApplicationCyclones({
feed: sources.cyclones,
createNativeRendering: (engine, options) =>
rendererFor('weather-cyclones', engine, options),
}),
),
...sessionModel('osm-overlays', () => createOsmOverlayLayers()),
...sessionModel('local-infrastructure', () =>
createInfrastructureLayers(localGeoJsonServices, (config, services) =>
createInfrastructureSessionLayer(
config,
services,
(engine, descriptor, ops) =>
rendererFor(config.id, engine, {
config: descriptor,
services: ops,
}),
),
],
),
),
),
sessionModel('telegeography-submarine-cables', () =>
createApplicationCables({ source: sources.cables, session: true }),
),
sessionModel('local-firms', () =>
createApplicationFirms({
surface,
id: 'local-firms',
name: 'FIRMS Active Fires',
icon: '▲',
source: 'NASA FIRMS · LIVE',
feed: sources.firms,
}),
),
[
'weather-cyclones',
() =>
sessionModel('weather-cyclones', () =>
createApplicationCyclones({
feed: sources.cyclones,
createNativeRendering: (engine, options) =>
rendererFor('weather-cyclones', engine, options),
}),
),
],
[
OSM_OVERLAYS.map(({ id }) => id),
() => sessionModel('osm-overlays', () => createOsmOverlayLayers()),
],
[
['local-datacenters', 'local-dams'],
() =>
sessionModel('local-infrastructure', () =>
createInfrastructureLayers(
localGeoJsonServices,
(config, services) =>
createInfrastructureSessionLayer(
config,
services,
(engine, descriptor, ops) =>
rendererFor(config.id, engine, {
config: descriptor,
services: ops,
}),
),
),
),
],
[
'telegeography-submarine-cables',
() =>
sessionModel('telegeography-submarine-cables', () =>
createApplicationCables({ source: sources.cables, session: true }),
),
],
[
'local-firms',
() =>
sessionModel('local-firms', () =>
createApplicationFirms({
surface,
id: 'local-firms',
name: 'FIRMS Active Fires',
icon: '▲',
source: 'NASA FIRMS · LIVE',
feed: sources.firms,
}),
),
],
];
const layers = entries.flatMap(([, create]) => create());
const catalog = createLayerCatalog(layers, metadata);
return Object.freeze({
...catalog,
-2
View File
@@ -46,8 +46,6 @@ export async function createApplicationControls({
const styleManager = new Controls(viewer, {
services: {
...sharedServices,
// The catalog's layers; the ones this engine cannot show (Navara)
// are empty stand-ins.
...catalogControlServices(catalog),
},
requestServices: operations.requests,
+11 -4
View File
@@ -8,6 +8,9 @@ export const ENGINE_STORAGE_KEY = 'gev.engine';
*/
export const ENGINE_CHOICES = Object.freeze(['cesium', 'navara', 'navara-hd']);
/** The choice without one made, and the one a failed start falls back to. */
export const DEFAULT_ENGINE_CHOICE = ENGINE_CHOICES[0];
/** The engine a choice runs: both Navara choices are the Navara engine. */
export const engineOfChoice = (choice) =>
choice === 'cesium' ? 'cesium' : 'navara';
@@ -21,12 +24,16 @@ const known = (value) => {
/**
* The globe choice for this page: `?engine=cesium|navara|navara-hd`, else
* the stored `gev.engine`, else Cesium. Navara is experimental; an unknown or
* the stored `gev.engine`, else `fallback` (Cesium). Navara is experimental; an unknown or
* unreadable value never selects it.
* @param {{ search?: string, storage?: { getItem(key: string): string | null } | null }} [where]
* @param {{ search?: string, storage?: { getItem(key: string): string | null } | null, fallback?: string }} [where]
* @returns {'cesium' | 'navara' | 'navara-hd'}
*/
export function resolveEngineKind({ search = '', storage = null } = {}) {
export function resolveEngineKind({
search = '',
storage = null,
fallback = DEFAULT_ENGINE_CHOICE,
} = {}) {
const fromQuery = known(new URLSearchParams(search).get('engine'));
if (fromQuery) return fromQuery;
let stored = null;
@@ -35,5 +42,5 @@ export function resolveEngineKind({ search = '', storage = null } = {}) {
} catch {
// Storage can be blocked (private mode, sandboxed frames).
}
return known(stored) ?? 'cesium';
return known(stored) ?? known(fallback) ?? DEFAULT_ENGINE_CHOICE;
}
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSceneHost } from './sceneHost.js';
import { resolveEngineKind, ENGINE_STORAGE_KEY } from './engineChoice.js';
test('a history visit without ?engine= keeps the engine the page started with', async () => {
// Tab A: no ?engine= in its address, started Navara from the stored choice.
const store = new Map([[ENGINE_STORAGE_KEY, 'navara']]);
const storage = { getItem: (key) => store.get(key) ?? null };
const search = '?view=1'; // no engine parameter
const initial = resolveEngineKind({ search, storage });
const requested = () => resolveEngineKind({ search, fallback: initial });
assert.equal(initial, 'navara');
const entered = [];
const switcher = createSceneHost().createSwitcher({
kind: initial,
requested,
signal: new AbortController().signal,
leave: async () => {},
enter: async (kind) => entered.push(kind),
address: { select: () => null, commit() {}, restore() {} },
settle: async () => {},
});
// Tab B picks Cesium and remembers it in the shared storage.
store.set(ENGINE_STORAGE_KEY, 'cesium');
// Tab A: a popstate (a pasted share link that changes only the hash).
await switcher.reconcile();
assert.deepEqual(entered, [], `tab A switched to ${entered.join(',')}`);
assert.equal(switcher.state().engine, 'navara');
});
+1
View File
@@ -29,6 +29,7 @@ test('a session retains layer intentions through handoff but never after page te
entry,
enabled: true,
durable: true,
visit: false,
});
resumeSession(session);
assert.deepEqual(calls, ['suspend', 'viewport', 'resume']);
-1
View File
@@ -12,7 +12,6 @@ import * as selection from '../../data/selectionDispatcher.js';
import { rendererFor } from './renderers.js';
import * as readouts from '../../hudReadouts.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({
source,
options = {},
-5
View File
@@ -9,11 +9,6 @@ import * as picking from '../../data/pickRegistry.js';
import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationAlpr({
surface,
source,
-5
View File
@@ -4,11 +4,6 @@ import * as render from '../../renderGovernor.js';
import * as picking from '../../data/pickRegistry.js';
import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationBikeshare({ source }) {
return createBikeshareLayer({
source,
-5
View File
@@ -8,11 +8,6 @@ import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationCctv({
surface,
source,
-1
View File
@@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationFlights({
surface,
source,
-1
View File
@@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationMilitary({
surface,
source,
-5
View File
@@ -6,11 +6,6 @@ import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationInstallations({ surface, source }) {
const { groundFloor: ground, anchors } = surface;
return createInstallationsLayer({
+5 -1
View File
@@ -31,6 +31,9 @@ import { satelliteFixture } from '../../bench/satelliteFixture.js';
import { createSatelliteSource } from '../../layers/satellites/source.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
// The largest synthetic fleet used by the QA scenarios.
const MAX_QA_COUNT = 10_000;
const KINDS = Object.freeze({
flights: new Set(['synthetic']),
military: new Set(['synthetic']),
@@ -49,7 +52,8 @@ export function parseQaFeed(search = '') {
const count = rawCount === undefined ? null : Number(rawCount);
if (
!KINDS[layer]?.has(kind) ||
(count !== null && !(Number.isInteger(count) && count > 0))
(count !== null &&
!(Number.isSafeInteger(count) && count > 0 && count <= MAX_QA_COUNT))
)
throw new TypeError(
`qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, launches:fixture, local-adsb:fixture)`,
+16
View File
@@ -133,3 +133,19 @@ test('vessel fixture answers a relative track URL and retains its full snapshot'
});
assert.equal((await vessels.getSnapshot({})).records.length, 12000);
});
test('fixture counts cannot exceed the QA fleet budget', () => {
for (const count of [
'10001',
'1e9',
'9007199254740992',
'Infinity',
'0',
'1.5',
])
assert.throws(
() => parseQaFeed(`?qaFeed=flights:synthetic:${count}`),
TypeError,
);
assert.equal(parseQaFeed('?qaFeed=flights:synthetic:10000')[0].count, 10000);
});
-5
View File
@@ -5,11 +5,6 @@ import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
import * as globe from '../../celestialRing.js';
import * as render from '../../renderGovernor.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationRadio({
surface,
source,
-1
View File
@@ -4,7 +4,6 @@ import * as geometry from '../../celestialRing.js';
import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationLaunches({
source,
satellites,
-1
View File
@@ -8,7 +8,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as layerState from '../../data/layerState.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationSatellites({
source,
// Test seam: the renderer factory (default: the engine's, from the
-5
View File
@@ -4,11 +4,6 @@ import * as ground from '../../data/groundFloor.js';
import * as render from '../../renderGovernor.js';
import { rendererFor } from './renderers.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationTraffic({ source, surface }) {
return createTrafficLayer({
source,
+5 -1
View File
@@ -7,7 +7,7 @@
// - `viewer()`: a Cesium globe is about to start: the SDK and the widget
// styles;
// - `sdk()`: a feature that still builds on Cesium's entity model (the Nepal
// event and locator, imported KML) is used on Navara: the SDK alone.
// event and locator) is used on Navara: the SDK alone.
export function createCesiumLoader({
document = globalThis.document,
globals = globalThis,
@@ -45,6 +45,9 @@ export function createCesiumLoader({
const ready = () =>
!script ||
Object.getOwnPropertyDescriptor(globals, 'Cesium')?.value != null;
// A page whose markup ran Cesium.js before this loader also carries its
// widget styles (the MCP panel build, which cannot load files by URL).
const preloaded = script && ready();
const sdk = () => {
if (ready()) return Promise.resolve();
sdkPending ??= load('script', `${root()}Cesium.js`).catch((error) => {
@@ -54,6 +57,7 @@ export function createCesiumLoader({
return sdkPending;
};
const styles = () => {
if (preloaded) return Promise.resolve();
stylesPending ??= load('link', `${root()}Widgets/widgets.css`).catch(
(error) => {
stylesPending = null;
+8
View File
@@ -76,6 +76,14 @@ test('the SDK is not loaded where the page already has it or imports its module'
assert.equal(dev.elements.length, 0);
});
test('a page that ran Cesium.js from its markup loads no styles either', async () => {
// The panel build's HTML lists Cesium.js and widgets.css; the panel host
// answers only those, not a stylesheet added later.
const { elements, loader } = fixture({ Cesium: {} });
await loader.viewer();
assert.equal(elements.length, 0);
});
test('a failed Cesium asset load can be retried and preserves the configured base', async () => {
const { elements, globals, loader } = fixture({
CESIUM_BASE_URL: '/custom/',
+120
View File
@@ -0,0 +1,120 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as choices from './engineChoice.js';
const source = readFileSync(new URL('../main.js', import.meta.url), 'utf8')
.replace(/^import[\s\S]*?;\n/gm, '')
.replaceAll('import.meta.env', 'env')
.replace(/export \{ application \};/, '');
const settle = async () => {
for (let i = 0; i < 15; i++) await new Promise((r) => setTimeout(r, 0));
};
function boot(t, { search = '', failures = ['navara'] } = {}) {
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 stored = new Map([[choices.ENGINE_STORAGE_KEY, 'navara']]);
const location = { search, href: `https://example.test/${search}` };
const localStorage = {
getItem: (key) => stored.get(key),
setItem: (key, value) => stored.set(key, value),
};
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const deps = {
...choices,
env: {},
location,
localStorage,
history: { replaceState() {} },
guardCesiumGlobal() {},
t: (key, values = {}) =>
key.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? ''),
describeError: (error) => error.message,
createSessionDiagnostics: () => ({ count() {} }),
createRendererCache: () => ({ clear() {} }),
createLayerSession: () => ({ lifetime }),
useDisplayStore: () => ({ presentGlobeEngine() {} }),
applicationPinia() {},
pageEngineKind: () =>
choices.resolveEngineKind({
search: location.search,
storage: localStorage,
}),
createEngineAddress: () => ({
restore(kind) {
location.search = `?engine=${kind}`;
},
}),
createSceneSwitcher: (options) => {
switches.push(options);
return {
state: () => ({
kind: options.kind,
engine: options.kind,
busy: false,
}),
reconcile: async () => {},
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) } },
}),
}),
console: { error() {} },
};
new Function(...Object.keys(deps), source)(...Object.values(deps));
return { starts, switches, stored, toasts };
}
test('a failed stored engine falls back to Cesium and remembers the recovery', async (t) => {
const f = boot(t);
await settle();
assert.deepEqual(f.starts, ['navara', 'cesium']);
assert.equal(f.stored.get(choices.ENGINE_STORAGE_KEY), 'cesium');
assert.equal(f.switches[0].kind, 'cesium');
assert.deepEqual(f.toasts, [
'Globe engine failed to start. Running Cesium: Engine failed',
]);
});
test('a failed explicit first engine offers a usable retry picker', async (t) => {
const f = boot(t, { search: '?engine=navara' });
await settle();
assert.deepEqual(f.starts, ['navara']);
const retry = document.querySelector('[data-engine="cesium"]');
assert.ok(retry);
retry.click();
await settle();
assert.deepEqual(f.starts, ['navara', 'cesium']);
assert.equal(f.switches[0].kind, 'cesium');
assert.equal(document.querySelector('.engine-switch-retry'), null);
});
test('a recovered engine switch names the globe that remains running', async (t) => {
const f = boot(t, { search: '?engine=cesium', failures: [] });
await settle();
f.switches[0].onFailure(new Error('Initialization failed'), {
recovered: true,
kind: 'cesium',
});
assert.deepEqual(f.toasts, [
'Globe engine failed to start. Running Cesium: Initialization failed',
]);
});
+6 -5
View File
@@ -9,6 +9,7 @@ import { registerDataCredits } from '../data/dataCredits.js';
import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
import { LAYER_MANIFEST } from './layers/renderers.js';
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
import { localizeLayerName } from '../ui/layerNames.js';
/** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () =>
@@ -26,8 +27,10 @@ export function navaraModeNotice(manifest = LAYER_MANIFEST) {
return Object.freeze({
label: t('Experimental Navara engine'),
detail: names.length
? `Data layers: ${names.join(', ')}`
: 'No registered data layers',
? t('Data layers: {layers}', {
layers: names.map(localizeLayerName).join(', '),
})
: t('No registered data layers'),
});
}
@@ -101,7 +104,7 @@ export async function createNavaraScene({
/**
* The Navara tool backend (src/app/tools.js) of one Navara scene: where the
* annotation board and Draw draw (on the engine and Navara's view; null
* annotation board draws (on the engine and Navara's view; null
* without the view), the Recent Imagery panel's globe, the render
* governor on the engine, and this engine's own parts: the daylight of a
* restored view and the engine notice.
@@ -115,8 +118,6 @@ export function navaraToolBackend({ engine }) {
drawn
? { engine, renderer: createNavaraAnnotationRenderer(engine, views) }
: null,
// The session's tool on the engine's input and preview.
draw: () => ({ engine }),
recentImagery: () => ({ engine }),
attach({ styleManager }, { signal, defer }) {
void styleManager.initialRestorePromise?.then(() => {
+4 -5
View File
@@ -272,7 +272,6 @@ function cesiumToolBackend({ engine, tileset }) {
viewer,
startupChrome: {},
board: () => ({ viewer, engine, tileset }),
draw: () => ({ engine }),
// The tileset lets the layer drape while the globe is hidden.
recentImagery(layer) {
layer.attachTileset(tileset);
@@ -353,7 +352,7 @@ async function attachEngineTools(
{ layerSession, placeSearch, operations },
defer,
);
attachDraw(backend.draw, { layerSession, annotations, styleManager }, defer);
attachDraw(engine, { layerSession, annotations, styleManager }, defer);
// Import draws through the globe contract, the same on every engine.
attachGeoImport(
{ engine },
@@ -483,13 +482,13 @@ function attachBoard(
}
/**
* UTILITIES ▸ Draw on this globe (`drawScene()`: its input and preview):
* UTILITIES ▸ Draw on this globe (its engine input and preview):
* the tool claims the globe's pointer while a sketch is open, so a switch
* ends an open sketch and keeps the chosen shape and colour. Returns the
* tool (null on a page without it or without the board).
*/
function attachDraw(
drawScene,
engine,
{ layerSession, annotations, styleManager },
defer,
) {
@@ -500,7 +499,7 @@ function attachDraw(
styleManager,
defer,
});
drawTool?.attachScene({ ...drawScene(), navigation: styleManager });
drawTool?.attachScene({ engine, navigation: styleManager });
defer(() => drawTool?.detachScene());
return drawTool;
}
+6 -2
View File
@@ -410,6 +410,7 @@ export class CelestialRing {
viewer,
{ enabled = true, onAutoDisable = null, store, engine = null } = {},
) {
this._engine = engine ?? (viewer?.kind ? viewer : null);
this._engineView = viewer?.kind ? createCelestialView(viewer) : null;
this.viewer = this._engineView?.view ?? viewer;
this._projector = engine?.projector ?? null;
@@ -603,7 +604,7 @@ export class CelestialRing {
this._focusInProgress = false;
this.viewer.scene.requestRender?.();
};
flyCelestialViewer(this.viewer, {
const flight = {
destination: geo.cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
@@ -620,7 +621,10 @@ export class CelestialRing {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
});
};
if (this._engine)
this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' });
else flyCelestialViewer(this.viewer, flight);
return true;
}
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CelestialRing } from './celestialRing.js';
test('full globe focus uses the engine without a Cesium global', () => {
let flight;
const engine = {
camera: {
flyTo: (options) => {
flight = options;
},
},
};
const ring = Object.assign(Object.create(CelestialRing.prototype), {
_engine: engine,
viewer: {
scene: {
canvas: { clientWidth: 1200, clientHeight: 800 },
requestRender() {},
},
camera: {
positionCartographic: { longitude: 0, latitude: 0 },
heading: 0,
frustum: { fovy: Math.PI / 3 },
},
},
});
assert.equal(globalThis.Cesium, undefined);
assert.equal(ring.focusFullGlobe(), true);
assert.equal(flight.easing, 'cubicInOut');
assert.equal(ring._focusInProgress, true);
flight.complete();
assert.equal(ring._focusInProgress, false);
});
+1 -3
View File
@@ -662,9 +662,7 @@ class CockpitCloudEffectsController {
}
/**
* Free the GPU objects and the canvas's WebGL context. The application is
* rebuilt on every globe switch, and browsers cap live contexts, so a
* destroyed controller must not leave its context for garbage collection.
* Free GPU objects and the canvas's WebGL context: browsers cap live contexts.
*/
releaseRenderer() {
const gl = this.gl;
+8 -10
View File
@@ -1,6 +1,6 @@
// The Cyber HUD sonar's contact treatment on the globe engine, which draws
// it (render.setContactSonar, the `contactSonar` capability: Navara's
// sprite and point movers, Cesium's native draw commands and models). Each
// movers, Cesium's native draw commands and models). Each
// drawn frame hands the engine the theme's state, the sweep's settings and
// its angle on the shared clock (the CSS sweep's), so the contacts and the
// rings turn together; while the sweep runs over visible contacts a parked
@@ -36,12 +36,9 @@ export function cyberSonarEngineState(
};
}
/** Sprite and point instances the engine draws (models keep their colours). */
/** Every mover instance the engine holds, on screen or not. */
const contactCount = (engine) =>
engine.movers
.list()
.filter((set) => set.kind !== 'model')
.reduce((count, set) => count + set.count, 0);
engine.movers.list().reduce((count, set) => count + set.count, 0);
const UNSUPPORTED_MESSAGE =
'Contact GPU unavailable. Native contacts remain visible.';
@@ -85,12 +82,13 @@ export function createCyberSonarEngineScene(
if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root);
engine.render.setContactSonar(cyberSonarEngineState(now(), root));
});
// Contacts the sweep passes over: the engine's movers, and what its own
// treatment drew (Cesium's tinted models and visible native batches).
// Contacts the sweep passes over on screen, as the engine's treatment
// reports them (Navara's movers in view; Cesium's tinted models and
// visible native batches); an engine without a report, all its movers.
const contactsShown = () => {
if (contactCount(engine)) return true;
const drawn = diagnostics();
return Boolean(drawn?.contacts || drawn?.gpu?.visibleCommands);
if (!drawn) return contactCount(engine) > 0;
return Boolean(drawn.contacts || drawn.gpu?.visibleCommands);
};
const removePostRender = engine.frames.postRender.addEventListener(() => {
// A treatment lost while the scene is parked (Sonar OFF, reduced
+33
View File
@@ -166,3 +166,36 @@ test("an engine's own contacts keep the sweep drawing, and a lost treatment wake
dispose();
assert.equal(root.dataset.cyberSonarGpu, undefined);
}));
test('contacts held off screen leave the sweep idle', () =>
withCyberPage(async ({ root }) => {
const engine = createFakeEngine({ kind: 'navara', contactSonar: true });
// Navara's report (createNavaraEngine.js): the movers in view.
const report = { supported: true, contacts: 0 };
engine.contactSonarDiagnostics = () => report;
const timers = new Map();
let nextTimer = 1;
const dispose = createCyberSonarEngineScene(engine, null, {
root,
setTimer: (callback, ms) => {
timers.set(nextTimer, [callback, ms]);
return nextTimer++;
},
clearTimer: (id) => timers.delete(id),
});
const set = engine.movers.createSet({
id: 'qa-contacts',
kind: 'point',
capacity: 1,
point: { sizePx: 8 },
});
set.set(0, { position: { x: 6378137, y: 0, z: 0 } });
set.setCount(1);
set.commit();
engine.fake.frame();
assert.equal(timers.size, 0, 'a contact out of view draws nothing');
report.contacts = 1;
engine.fake.frame();
assert.equal(timers.size, 1, 'one in view keeps the sweep moving');
dispose();
}));
+48 -12
View File
@@ -44,6 +44,8 @@ const SCENE_REVEAL_RENDER_HOLD = 'bhote-koshi-scene-beat-reveal';
const SCENE_PATH_RENDER_HOLD = 'bhote-koshi-scene-path-travel';
const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6;
const SCENE_MEDIA_EXIT_SECONDS = 0.65;
// A camera this near an evidence anchor has the valley's terrain loaded.
const EVIDENCE_REFINE_M = 25_000;
const BHOTE_EVENT_ACCENT = WORLD_OVERLAY_STYLE.accent;
const FLOOD_COLOR = () =>
Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68);
@@ -1050,6 +1052,9 @@ export function createBhoteKoshiEventLayer({
let _evidenceTimeline = [];
let _evidenceAnchors = [];
let _evidenceFrameSlots = [];
let _evidenceEntries = [];
// Evidence anchors at the pack's elevations (no terrain sample).
const _fallbackAnchors = new WeakSet();
let _evidenceVideo = null;
let _evidenceVideoIndex = -1;
let _evidenceVideoReady = false;
@@ -1198,14 +1203,17 @@ export function createBhoteKoshiEventLayer({
: [];
let sampled = null;
try {
sampled =
_engine && !_viewer
? observations.map(({ lon, lat }) => ({
height: _engine.surface.terrainHeight(
Cesium.Cartographic.fromDegrees(lon, lat),
),
}))
: await terrainSampler(_viewer, observations, { signal });
if (_engine && !_viewer) {
const revision = _engine.maps.terrainRevision();
const heights = await _engine.surface.sampleTerrainMostDetailed(
observations.map(({ lon, lat }) =>
Cesium.Cartographic.fromDegrees(lon, lat),
),
);
// Heights of a terrain replaced meanwhile are not the one shown.
if (_engine.maps.terrainRevision() === revision)
sampled = heights.map((height) => ({ height }));
} else sampled = await terrainSampler(_viewer, observations, { signal });
} catch (error) {
if (error?.name === 'AbortError') throw error;
console.warn(
@@ -1217,14 +1225,35 @@ export function createBhoteKoshiEventLayer({
return observations.map((observation, index) => {
const sampledHeight = Number(sampled?.[index]?.height);
const fallbackHeight = Number(observation.fallbackElevationM) || 0;
return Cesium.Cartesian3.fromDegrees(
const anchor = Cesium.Cartesian3.fromDegrees(
observation.lon,
observation.lat,
(Number.isFinite(sampledHeight) ? sampledHeight : fallbackHeight) + 18,
);
if (!Number.isFinite(sampledHeight)) _fallbackAnchors.add(anchor);
return anchor;
});
}
// An anchor at the pack's elevation, on Navara, moves onto the terrain
// loaded under a near camera (the valley's, not an overview tile's), in
// place, so the card's cull position follows.
function refineEvidenceAnchor(index) {
const anchor = _evidenceAnchors[index];
if (!anchor || _viewer || !_engine || !_fallbackAnchors.has(anchor))
return anchor || null;
const live = _engine.camera.livePosition;
if (!live || Cesium.Cartesian3.distance(live, anchor) > EVIDENCE_REFINE_M)
return anchor;
const { lon, lat } = _evidenceTimeline[index]?.observation ?? {};
const height = Number.isFinite(lon)
? _engine.surface.terrainHeight(Cesium.Cartographic.fromDegrees(lon, lat))
: undefined;
if (Number.isFinite(height))
Cesium.Cartesian3.fromDegrees(lon, lat, height + 18, undefined, anchor);
return anchor;
}
function evidencePositionGetter(index, breadcrumb = false) {
return () => {
if (!_enabled) return null;
@@ -1245,13 +1274,13 @@ export function createBhoteKoshiEventLayer({
_sceneMediaPlaybackCompletedBeatId
)
return null;
return _evidenceAnchors[index] || null;
return refineEvidenceAnchor(index);
}
const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline);
if (breadcrumb ? index >= activeIndex : index !== activeIndex)
return null;
if (!breadcrumb && index === _embeddedEvidenceIndex) return null;
return _evidenceAnchors[index] || null;
return refineEvidenceAnchor(index);
};
}
@@ -2448,7 +2477,12 @@ export function createBhoteKoshiEventLayer({
placement: 'above',
});
}
overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, entries, {
_evidenceEntries = entries;
restoreEvidenceEntries();
}
function restoreEvidenceEntries() {
overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, _evidenceEntries, {
cohortLimit: 16,
collisionCapacity: 12,
moving: false,
@@ -3557,6 +3591,7 @@ export function createBhoteKoshiEventLayer({
return;
}
createSplitControl(_event);
restoreEvidenceEntries();
syncPresentationMode();
attachCameraInputRelease();
attachEvidenceCardActivation();
@@ -3739,6 +3774,7 @@ export function createBhoteKoshiEventLayer({
_corridorPositionCache = { visibleCount: -1, positions: [] };
_evidenceTimeline = [];
_evidenceAnchors = [];
_evidenceEntries = [];
_cinematicKeyframes = [];
for (const slot of _evidenceFrameSlots) {
closeFrame(slot.frame);
+179
View File
@@ -3133,3 +3133,182 @@ test('Bhote Koshi stops waiting for a stalled map switch when its scene is relea
globalThis.window = originalWindow;
}
});
test('Bhote Koshi republishes its evidence cards to the incoming engine overlay', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
installEventDom();
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
const first = eventViewer();
const second = eventViewer();
const engineB = {
id: 'B',
host: { canvas: second.scene.canvas },
camera: second.camera,
};
const hosts = new Map();
const hostFor = (engine) => {
const key = engine ?? 'A';
if (!hosts.has(key))
hosts.set(key, {
entries: new Map(),
setEntries(id, e) {
this.entries.set(id, e);
},
setVisible() {},
clearSource(id) {
this.entries.delete(id);
},
hitTest() {
return null;
},
});
return hosts.get(key);
};
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageLoader: async (url) => ({ url }),
embeddedMediaFactory: () => ({
show() {},
hide() {},
pause() {},
destroy() {},
}),
overlayHost: hostFor,
renderHost: { request() {}, hold() {}, release() {} },
});
layer.attachMapStackController({
getActiveId: () => 'esri-imagery',
getSwitchGeneration: () => 1,
async setStack() {},
});
const manager = new LayerLifecycle(first);
manager.register(layer);
try {
assert.equal(
await manager.setEnabled(layer.id, true, { origin: 'local-restore' }),
true,
);
const a = hostFor(null);
assert.ok(
[...a.entries.values()].some((e) => e.length > 0),
'first engine got the cards',
);
await layer.detachRenderer();
await layer.attachRenderer(second, { engine: engineB });
const b = hostFor(engineB);
assert.ok(
[...b.entries.values()].some((e) => e.length > 0),
'incoming engine overlay should get the cards',
);
} finally {
await manager.destroyLayer(layer.id);
globalThis.document = originalDocument;
globalThis.window = originalWindow;
}
});
// 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 }) {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
installEventDom();
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
const entries = [];
const engine = {
terrain: 3500,
host: { canvas: eventViewer().scene.canvas },
surface: {
terrainHeight: () => engine.terrain,
sampleTerrainMostDetailed: sample,
},
maps: { terrainRevision: () => 1 },
camera: { livePosition: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) },
};
const layer = buildEventLayer({
eventLoader: async () => event,
imageLoader: async (url) => ({ url }),
createNativeRenderer: () => ({
loadImages: async () => {},
draw: () => ({ destroy() {} }),
setVisible() {},
setSplit() {},
clear() {},
destroy() {},
}),
embeddedMediaFactory: () => ({
show() {},
hide() {},
pause() {},
destroy() {},
}),
overlayHost: {
setEntries: (_id, value) => entries.push(...value),
setVisible() {},
clearSource() {},
},
renderHost: { request() {}, hold() {}, release() {} },
});
await layer.init(null, { engine });
await layer.enable(null, { origin: 'local-restore' });
const [first] = event.evidenceSpine;
const card = entries.find(
(entry) => entry.id === `evidence-card-${first.id}`,
);
return {
engine,
layer,
card,
first,
height: () => Cesium.Cartographic.fromCartesian(card.cullPosition).height,
};
}
test('Navara evidence anchors stand on the most detailed terrain', async () => {
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const { layer, height } = await navaraEvidenceCard({
sample: async (places) => places.map(() => 1234),
});
try {
assert.ok(Math.abs(height() - 1252) < 0.01, `${height()}`);
} finally {
await layer.disable();
await layer.destroy();
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;
const { engine, layer, card, first, height } = await navaraEvidenceCard({
sample: async () => {
throw new Error('no terrain');
},
});
try {
const pack = first.fallbackElevationM + 18;
assert.ok(Math.abs(height() - pack) < 0.01, 'the pack elevation');
card.position();
assert.ok(Math.abs(height() - pack) < 0.01, 'a far camera keeps it');
engine.camera.livePosition = Cesium.Cartesian3.fromDegrees(
first.lon,
first.lat,
6000,
);
engine.terrain = 2000;
assert.ok(card.position(), 'the first card is shown');
assert.ok(
Math.abs(height() - 2018) < 0.01,
'a near camera reads the terrain',
);
} finally {
await layer.disable();
await layer.destroy();
globalThis.document = originalDocument;
globalThis.window = originalWindow;
}
});
+27
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { LayerLifecycle } from './lifecycle.js';
import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js';
test('an arrival owned on the engine yields to a cancel on the engine', () => {
@@ -33,3 +34,29 @@ test('a newer arrival replaces the older one and release is idempotent', () => {
cancelCameraArrival(null);
assert.deepEqual(seen, ['old', 'new']);
});
test('disabling a layer cancels an engine arrival without a native viewer', async () => {
const engine = createFakeEngine();
const manager = new LayerLifecycle(null, { engine });
manager.register({
id: 'arrival',
name: 'Arrival',
init() {},
enable() {},
disable() {},
update() {},
updateInterval: -1,
});
let cancelled = 0;
const release = ownCameraArrival(engine, () => {
cancelled++;
});
try {
await manager.setEnabled('arrival', false);
assert.equal(cancelled, 1);
} finally {
release();
await manager.destroyLayer('arrival');
engine.destroy();
}
});
+35
View File
@@ -0,0 +1,35 @@
// A file whose only geometry is degenerate (a one-point line, a two-point
// ring) draws nothing; main refused it with "That file has nothing to draw.".
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadGeoFile, GeoImportError } from './geoFileImport.js';
const geojson = (geometry) => {
const blob = new Blob([
JSON.stringify({ type: 'Feature', properties: {}, geometry }),
]);
blob.name = 'degenerate.geojson';
return blob;
};
for (const geometry of [
{ type: 'LineString', coordinates: [[1, 2]] },
{
type: 'Polygon',
coordinates: [
[
[1, 2],
[2, 3],
],
],
},
]) {
test(`a ${geometry.type} with nothing to draw is refused`, async () => {
await assert.rejects(
loadGeoFile(geojson(geometry)),
(error) =>
error instanceof GeoImportError &&
/nothing to draw/.test(error.message),
);
});
}
+234 -45
View File
@@ -1,9 +1,7 @@
// One imported file (UTILITIES ▸ Import) drawn on the globe contract, the
// same on every engine, from its plain records (src/data/geoImportRecords.js),
// with Cesium's look as the reference:
// - polygons: one ground 'polygon' set of their fills; a raised polygon
// (KML absolute or relative) also gets its outline as lines at its
// heights, as Cesium drew it (its fill drapes: the ground has no heights);
// - polygons: ground fills, or meshes and outlines at the vertices' heights;
// - ground lines: one ground 'corridor' set in pixels, over the fills;
// - lines in the air: one engine line each;
// - points: one sprite set per look, each sprite a square centred on its
@@ -11,13 +9,16 @@
// 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.
// - 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.
// 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 { composeIcon } from './geoImportPins.js';
@@ -34,6 +35,10 @@ const HORIZON_LIFT_M = 2000;
// drawn now; higher up the terrain answers (Cesium's 250 km).
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;
// The longest side of a tinted or turned ground overlay's canvas.
const MAX_OVERLAY_SIDE = 4096;
let serial = 0;
// Each engine's imported point sets, left out of each other's ground.
const pointSets = new WeakMap();
@@ -60,6 +65,103 @@ function checked(kind, item) {
}
}
// A ground overlay's box with east past west across 180°, and how a corner
// turns by its rotation: KML's degrees counter-clockwise about the centre,
// in longitude and latitude (Cesium's rectangle rotation).
function overlayBox({ rectangle, rotation = 0 }) {
const { west, south, north } = rectangle;
const east = rectangle.east < west ? rectangle.east + 360 : rectangle.east;
const box = { west, south, east, north };
const center = [(west + east) / 2, (south + north) / 2];
const angle = (rotation * Math.PI) / 180;
const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
const turn = ([lon, lat]) => {
const [x, y] = [lon - center[0], lat - center[1]];
return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos];
};
return { box, center, angle, turn };
}
// 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) {
const tint = overlay.color.slice(0, 3);
const { box, center, angle } = overlayBox(overlay);
if (tint.every((value) => value === 1) && !angle)
return { url: overlay.url, rectangle: overlay.rectangle };
const [width, height] = [box.east - box.west, box.north - box.south];
const [cos, sin] = [Math.abs(Math.cos(angle)), Math.abs(Math.sin(angle))];
const [spanX, spanY] = [
width * cos + height * sin,
width * sin + height * cos,
];
const rectangle = {
west: center[0] - spanX / 2,
south: center[1] - spanY / 2,
east: center[0] + spanX / 2,
north: center[1] + spanY / 2,
};
if (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360)
return null;
let image;
try {
image = await loadImage(overlay.url);
} catch {
// An image that does not decode draws nothing, as on the plain path.
return { url: overlay.url, rectangle: overlay.rectangle };
}
const natural = [
image.naturalWidth || image.width,
image.naturalHeight || image.height,
];
// Pixels per degree: one scale on both axes when turned, so the image
// turns in degrees as Cesium's does.
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),
);
[scaleX, scaleY] = [scaleX * fit, scaleY * fit];
const canvas = createCanvas();
canvas.width = Math.max(1, Math.round(spanX * scaleX));
canvas.height = Math.max(1, Math.round(spanY * scaleY));
const context = canvas.getContext('2d');
context.translate(canvas.width / 2, canvas.height / 2);
// The canvas's y runs south: a counter-clockwise turn is a negative one.
context.rotate(-angle);
context.drawImage(
image,
(-width * scaleX) / 2,
(-height * scaleY) / 2,
width * scaleX,
height * scaleY,
);
if (tint.some((value) => value !== 1)) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < pixels.data.length; i += 4)
for (let channel = 0; channel < 3; channel++)
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),
},
};
}
// KML headings are from north (Cesium's billboard on the Earth's axis):
// the sprite turns on screen with the camera's heading.
const turnsWithCamera = (look) => look.pins.some((pin) => pin.rotation);
const onScreen = (pin, heading) => (pin.rotation ? pin.rotation + heading : 0);
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
const cartesian = ([lon, lat, height = 0]) =>
geo.cartesian3.fromDegrees(lon, lat, height);
@@ -110,6 +212,9 @@ export async function drawImportedFile(
let removeCull = null;
const groundSets = [];
const lines = [];
const meshes = [];
const pins = [];
const kinds = { ...records.undrawn };
const layers = [];
const looks = new Map();
let drawn = 0;
@@ -120,12 +225,14 @@ export async function drawImportedFile(
removeCull = null;
for (const set of groundSets.splice(0)) set.destroy();
for (const line of lines.splice(0)) line.destroy();
for (const mesh of meshes.splice(0)) mesh.destroy();
for (const layer of layers.splice(0)) layer.destroy();
for (const look of looks.values()) {
pointSetsOf(engine).delete(look.set);
look.set?.destroy();
}
looks.clear();
pins.length = 0;
}
try {
@@ -133,7 +240,34 @@ export async function drawImportedFile(
const fills = [];
for (const polygon of records.polygons) {
let any = false;
if (polygon.fill) {
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) {
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,
color: polygon.fill,
show: shown,
}),
);
any = true;
}
} else if (polygon.fill) {
const item = checked('polygon', {
rings: polygon.rings.map(flat),
fill: polygon.fill,
@@ -160,34 +294,47 @@ export async function drawImportedFile(
if (any) drawn++;
}
for (const overlay of records.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);
if (!prepared) {
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
continue;
}
layers.push(
engine.imagery.createLayer({
id: `${prefix}-overlay-${layers.length}`,
source: {
kind: 'image',
url: overlay.url,
rectangle: overlay.rectangle,
},
source: { kind: 'image', ...prepared },
// Cesium's transparent image material: at most 0.99.
alpha: Math.min(0.99, overlay.color[3]),
show: shown,
order: OVERLAY_ORDER,
...(raise ? { raise } : {}),
}),
);
drawn++;
continue;
}
// A plain colour draws as a ground fill.
if (raise) {
kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1;
continue;
}
const { west, south, east, north } = overlay.rectangle;
const item = checked('cell', { west, south, east, north });
if (!item) continue;
const { box, turn } = overlayBox(overlay);
const rings = [
[
[west, south],
[east < west ? east + 360 : east, south],
[east < west ? east + 360 : east, north],
[west, north],
],
[box.west, box.south],
[box.east, box.south],
[box.east, box.north],
[box.west, box.north],
].map(turn),
];
fills.push({ rings, fill: overlay.color });
drawn++;
@@ -261,6 +408,11 @@ export async function drawImportedFile(
rotation: point.icon.rotation ?? 0,
});
}
pins.push(...[...looks.values()].flatMap((look) => look.pins));
const maxSide = Math.min(
512,
Math.floor(Math.sqrt(ICON_PIXEL_BUDGET / Math.max(1, looks.size))),
);
await Promise.all(
[...looks.values()].map(async (look) => {
const url = look.icon.image?.url;
@@ -279,6 +431,7 @@ export async function drawImportedFile(
image,
createCanvas,
ratio: pixelRatio,
maxSide,
});
look.set = engine.movers.createSet({
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
@@ -299,7 +452,11 @@ export async function drawImportedFile(
}),
);
if (destroyed) throw new Error('The imported file was removed');
settle(heightContext());
for (let from = 0; from < pins.length; from += BATCH) {
settle(heightContext(), from, from + BATCH);
if (from + BATCH < pins.length)
await new Promise((resolve) => setTimeout(resolve, 0));
}
for (const look of looks.values()) writePins(look);
syncCull();
} catch (error) {
@@ -316,11 +473,30 @@ export async function drawImportedFile(
(engine.imagery?.surface?.() ?? 'globe') !== 'globe' &&
surface.clampToHeightSupported &&
engine.camera.height() < CLAMP_TILES_BELOW_M;
return { surface, tiles, exclude: [...pointSetsOf(engine)] };
return {
surface,
tiles,
exclude: [...pointSetsOf(engine)],
size: tiles ? engine.host.size() : null,
};
}
// Whether a position is in the window now: clamping to 3D tiles reads
// the tiles drawn, and a point elsewhere would only cost the query.
function inWindow(position, { width, height }) {
if (!engine.projector.horizonVisible(position)) return false;
const screen = engine.projector.worldToWindow(position);
return (
Boolean(screen) &&
screen.x >= 0 &&
screen.y >= 0 &&
screen.x <= width &&
screen.y <= height
);
}
// The ground under a pin, falling back to the last known one.
function groundUnder(pin, { surface, tiles, exclude }) {
function groundUnder(pin, { surface, tiles, exclude, size }) {
const at = geo.cartographic.fromDegrees(pin.lon, pin.lat, 0);
const terrain = surface.terrainHeightSupported
? surface.terrainHeight(at)
@@ -331,7 +507,10 @@ export async function drawImportedFile(
? Math.max(0, terrain)
: terrain
: (pin.ground ?? 0);
if (tiles) {
if (
tiles &&
inWindow(geo.cartesian3.fromDegrees(pin.lon, pin.lat, ground), size)
) {
const top = surface.clampToHeight(
geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)),
exclude,
@@ -342,33 +521,33 @@ export async function drawImportedFile(
return ground;
}
function settle(context, from = 0, to = Infinity) {
let at = 0;
for (const look of looks.values())
for (const pin of look.pins) {
if (at++ < from || at > to) continue;
if (pin.height === null) pin.ground = groundUnder(pin, context);
const height = pin.height ?? pin.ground + pin.offset;
pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height);
pin.horizon = geo.cartesian3.fromDegrees(
pin.lon,
pin.lat,
height + HORIZON_LIFT_M,
);
}
function settle(context, from = 0, to = pins.length) {
for (let i = from; i < Math.min(to, pins.length); i++) {
const pin = pins[i];
if (pin.height === null) pin.ground = groundUnder(pin, context);
const height = pin.height ?? pin.ground + pin.offset;
pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height);
pin.horizon = geo.cartesian3.fromDegrees(
pin.lon,
pin.lat,
height + HORIZON_LIFT_M,
);
}
}
function writePins(look) {
const { set, pins } = look;
if (!set) return;
const culled = look.depth === 'always';
const heading = turnsWithCamera(look) ? engine.camera.pose().heading : 0;
pins.forEach((pin, index) => {
if (culled) pin.show = engine.projector.horizonVisible(pin.horizon);
pin.drawnRotation = onScreen(pin, heading);
set.set(index, {
position: pin.position,
cell: 'icon',
color: pin.color,
rotation: pin.rotation,
rotation: pin.drawnRotation,
show: pin.show,
});
});
@@ -377,14 +556,18 @@ export async function drawImportedFile(
set.commit();
}
// Hides the ground points beyond the horizon; true when any show changed.
function cull(look) {
// Hides the ground points beyond the horizon and turns the headed ones
// with the camera; true when any changed.
function cull(look, heading) {
let changed = false;
look.pins.forEach((pin, index) => {
const show = engine.projector.horizonVisible(pin.horizon);
if (show === pin.show) return;
const show =
look.depth !== 'always' || engine.projector.horizonVisible(pin.horizon);
const rotation = onScreen(pin, heading);
if (show === pin.show && rotation === pin.drawnRotation) return;
pin.show = show;
look.set.set(index, { show });
pin.drawnRotation = rotation;
look.set.set(index, { show, rotation });
changed = true;
});
if (changed) look.set.commit();
@@ -392,33 +575,39 @@ export async function drawImportedFile(
}
function syncCull() {
const culled = [...looks.values()].filter(
(look) => look.set && look.depth === 'always',
(look) => look.set && (look.depth === 'always' || turnsWithCamera(look)),
);
if (!culled.length || !shown) {
removeCull?.();
removeCull = null;
return;
}
const turning = culled.some(turnsWithCamera);
const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0);
let lastHeading = Number.NaN;
removeCull ??= engine.frames.preRender.addEventListener(() => {
const live = engine.camera.livePosition;
if (geo.cartesian3.equals(live, lastCamera)) return;
const heading = turning ? engine.camera.pose().heading : 0;
if (geo.cartesian3.equals(live, lastCamera) && heading === lastHeading)
return;
lastHeading = heading;
geo.cartesian3.clone(live, lastCamera);
let changed = false;
for (const look of culled) changed = cull(look) || changed;
for (const look of culled) changed = cull(look, heading) || changed;
if (changed) request();
});
}
const undrawn = Object.values(records.undrawn).reduce((n, c) => n + c, 0);
const undrawn = Object.values(kinds).reduce((n, c) => n + c, 0);
return {
report: { drawn, undrawn, kinds: { ...records.undrawn } },
report: { drawn, undrawn, kinds },
setShow(visibleNow) {
visibleNow = Boolean(visibleNow);
if (destroyed || visibleNow === shown) return;
shown = visibleNow;
for (const set of groundSets) set.show(shown);
for (const line of lines) line.show(shown);
for (const mesh of meshes) mesh.show(shown);
for (const layer of layers) layer.show(shown);
for (const look of looks.values()) {
look.set?.setCount(shown ? look.count : 0);
@@ -444,7 +633,7 @@ export async function drawImportedFile(
},
/** Sample the ground under the points again (a map or the terrain changed). */
async refreshGround(signal) {
const total = [...looks.values()].reduce((n, l) => n + l.pins.length, 0);
const total = pins.length;
for (let from = 0; from < total; from += BATCH) {
signal?.throwIfAborted();
if (destroyed) return;
+222 -3
View File
@@ -4,7 +4,7 @@ import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
import { drawImportedFile } from './geoImportDisplay.js';
import { geoJsonRecords } from './geoImportRecords.js';
import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
// A 2D context that only records what draws.
const createCanvas = () => ({
@@ -191,6 +191,8 @@ for (const make of GROUND_HARNESSES) {
viewer.scene.clampToHeightSupported = true;
} else engine.imagery.surface = () => 'tiles';
engine.camera.height = () => 1000;
engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
engine.projector.horizonVisible = () => true;
const excluded = [];
engine.surface.clampToHeight = (position, exclude) => {
excluded.push(exclude);
@@ -270,8 +272,9 @@ for (const make of GROUND_HARNESSES) {
visible: false,
});
const fill = set(engine.ground.list()[0].id);
assert.equal(fill.items.length, 2);
assert.equal(fill.items[0].rings.length, 2);
assert.equal(fill.items.length, 1);
assert.equal(engine.meshes.list().length, 1);
assert.equal(fill.items[0].rings.length, 1);
assert.ok(fill.items[0].rings.flat().every((p) => p.length === 2));
assert.equal(fill.shown, false);
assert.equal(engine.lines.list().length, 2);
@@ -339,6 +342,222 @@ for (const make of GROUND_HARNESSES) {
},
);
displayTest(
'KML headings follow north as the camera rotates',
async ({ engine, pins }) => {
let heading = 0;
engine.camera.pose = () => ({ heading });
const records = geoJsonRecords(
{ type: 'Point', coordinates: [1, 2] },
'#39d0ff',
);
records.points[0].icon.rotation = -Math.PI / 4;
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.equal(pins[0].items[0].rotation, -Math.PI / 4);
heading = Math.PI / 2;
engine.frames.preRender.raiseEvent();
assert.equal(pins[0].items[0].rotation, Math.PI / 4);
drawing.destroy();
},
);
// A canvas whose context records the transform and holds one pixel.
const recordingCanvas = () => {
const calls = [];
const pixels = { data: new Uint8ClampedArray([128, 64, 32, 128]) };
const context = {
translate: (...args) => calls.push(['translate', ...args]),
rotate: (angle) => calls.push(['rotate', angle]),
drawImage: (_image, ...args) => calls.push(['drawImage', ...args]),
getImageData: () => pixels,
putImageData: (value) => calls.push(['putImageData', value]),
};
return { calls, pixels, canvas: { getContext: () => context } };
};
const overlayLayer = (engine) => {
const layers = [];
const create = engine.imagery.createLayer;
engine.imagery.createLayer = (options) => {
layers.push(options);
return create(options);
};
return layers;
};
displayTest(
'colored overlays multiply image RGB and retain layer alpha',
async ({ engine }) => {
const { calls, pixels, canvas } = recordingCanvas();
const layers = overlayLayer(engine);
const records = emptyRecords();
records.overlays.push({
url: 'blob:overlay',
color: [1, 0, 0, 0.5],
rectangle: { west: 0, south: 0, east: 1, north: 1 },
rotation: 0,
});
const drawing = await drawImportedFile(engine, records, {
createCanvas: () => canvas,
loadImage: async () => ({ width: 32, height: 16 }),
});
assert.equal(layers[0].source.image, canvas);
assert.deepEqual(
{ ...layers[0].source.rectangle },
{ west: 0, south: 0, east: 1, north: 1 },
);
assert.equal(layers[0].alpha, 0.5);
assert.deepEqual([canvas.width, canvas.height], [32, 16]);
assert.ok(
calls.some(
([name, value]) => name === 'putImageData' && value === pixels,
),
);
assert.deepEqual([...pixels.data], [128, 0, 0, 128]);
drawing.destroy();
},
);
displayTest(
'a rotated overlay turns its image counter-clockwise onto the box around it',
async ({ engine }) => {
const { calls, canvas } = recordingCanvas();
const layers = overlayLayer(engine);
const records = emptyRecords();
records.overlays.push({
url: 'blob:overlay',
color: [1, 1, 1, 1],
// Two degrees by one, across 180°, turned a quarter.
rectangle: { west: 179, south: 10, east: -179, north: 11 },
rotation: 90,
});
const drawing = await drawImportedFile(engine, records, {
createCanvas: () => canvas,
loadImage: async () => ({ width: 200, height: 100 }),
});
assert.equal(drawing.report.drawn, 1);
assert.equal(drawing.report.undrawn, 0);
const { rectangle } = layers[0].source;
assert.ok(Math.abs(rectangle.west - 179.5) < 1e-9);
assert.ok(Math.abs(rectangle.east + 179.5) < 1e-9);
assert.ok(Math.abs(rectangle.south - 9.5) < 1e-9);
assert.ok(Math.abs(rectangle.north - 11.5) < 1e-9);
// 100 pixels a degree on both axes: the turned box is 1° by 2°.
assert.deepEqual([canvas.width, canvas.height], [100, 200]);
assert.deepEqual(
calls.find(([name]) => name === 'rotate'),
['rotate', -Math.PI / 2],
);
assert.deepEqual(
calls.find(([name]) => name === 'drawImage'),
['drawImage', -100, -50, 200, 100],
);
assert.equal(
calls.some(([name]) => name === 'putImageData'),
false,
);
drawing.destroy();
},
);
displayTest(
'a rotated overlay whose box passes a pole is reported undrawn',
async ({ engine }) => {
const records = emptyRecords();
records.overlays.push({
url: 'blob:overlay',
color: [1, 1, 1, 1],
rectangle: { west: 0, south: 80, east: 30, north: 89 },
rotation: 45,
});
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.deepEqual(drawing.report, {
drawn: 0,
undrawn: 1,
kinds: { rotatedOverlay: 1 },
});
assert.equal(engine.imagery.list().length, 0);
drawing.destroy();
},
);
displayTest(
'a rotated plain-colour overlay fills its turned corners',
async ({ engine, set }) => {
const records = emptyRecords();
records.overlays.push({
url: null,
color: [0, 1, 0, 0.5],
rectangle: { west: 0, south: 0, east: 2, north: 2 },
rotation: 90,
});
const drawing = await drawImportedFile(engine, records, { createCanvas });
const fill = set(engine.ground.list()[0].id);
const [ring] = fill.items[0].rings;
const near = (a, b) => a.every((v, i) => Math.abs(v - b[i]) < 1e-9);
assert.ok(near(ring[0], [2, 0]), JSON.stringify(ring));
assert.ok(near(ring[1], [2, 2]), JSON.stringify(ring));
assert.ok(near(ring[2], [0, 2]), JSON.stringify(ring));
drawing.destroy();
},
);
displayTest(
'an absolute overlay is raised to its altitude, a relative one drapes',
async ({ engine }) => {
const layers = overlayLayer(engine);
const records = emptyRecords();
for (const altitudeMode of ['absolute', 'relative'])
records.overlays.push({
url: 'blob:overlay',
altitudeMode,
altitude: 1000,
color: [1, 1, 1, 1],
rectangle: { west: 0, south: 0, east: 1, north: 1 },
rotation: 0,
});
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.deepEqual(drawing.report, { drawn: 2, undrawn: 0, kinds: {} });
assert.equal(layers[0].raise, 1000);
assert.equal(layers[1].raise, undefined);
drawing.destroy();
},
);
displayTest(
'tile clamps only sample visible pins and yield between batches',
async ({ engine }) => {
engine.imagery.surface = () => 'tiles';
engine.camera.height = () => 1000;
engine.projector.horizonVisible = () => true;
let visible = false;
engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null);
let clamps = 0;
engine.surface.clampToHeight = (position) => {
clamps++;
return position;
};
const records = geoJsonRecords(
{
type: 'MultiPoint',
coordinates: Array.from({ length: 600 }, () => [1, 2]),
},
'#39d0ff',
);
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.equal(clamps, 0);
visible = true;
let beforeYield;
const heartbeat = setTimeout(() => {
beforeYield = clamps;
}, 0);
await drawing.refreshGround();
clearTimeout(heartbeat);
assert.equal(beforeYield, 256);
assert.equal(clamps, 600);
drawing.destroy();
},
);
displayTest(
'an aborted ground refresh leaves the existing drawing owned',
async ({ engine }) => {
+97
View File
@@ -0,0 +1,97 @@
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`);
});
+2
View File
@@ -380,6 +380,8 @@ function readGroundOverlay(el, records) {
rectangle,
color,
rotation: numberOf(box, 'rotation') ?? 0,
altitude: numberOf(el, 'altitude') ?? 0,
altitudeMode: altitudeOf(el),
});
}
+12
View File
@@ -400,3 +400,15 @@ test('a KML file loads to records with its name, count and outside content remov
assert.equal(loaded.records.points[0].name, 'QA KML pin');
loaded.revoke();
});
test('GroundOverlay retains altitude and altitude mode for drawing diagnostics', () => {
const records = readKmlRecords(
parseXml(
kml(
`<GroundOverlay><altitude>1000</altitude><altitudeMode>absolute</altitudeMode><LatLonBox><west>0</west><east>1</east><south>0</south><north>1</north></LatLonBox></GroundOverlay>`,
),
),
);
assert.equal(records.overlays[0].altitude, 1000);
assert.equal(records.overlays[0].altitudeMode, 'absolute');
});
+5 -2
View File
@@ -87,7 +87,10 @@ export function iconBox(icon, natural) {
* `ratio` canvas pixels per CSS pixel. Returns the canvas and the sprite's
* side in CSS pixels.
*/
export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) {
export function composeIcon(
icon,
{ image, createCanvas, ratio = 1, maxSide = 512 } = {},
) {
const natural = image
? {
width: image.naturalWidth || image.width,
@@ -96,7 +99,7 @@ export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) {
: undefined;
const box = iconBox(icon, natural);
// At least one canvas pixel per icon pixel, at most 512 a side.
const k = Math.min(ratio, 512 / box.side);
const k = Math.min(ratio, maxSide / box.side);
const canvas = createCanvas();
canvas.width = Math.max(1, Math.ceil(box.side * k));
canvas.height = canvas.width;
+112
View File
@@ -0,0 +1,112 @@
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);
});
+11 -3
View File
@@ -16,7 +16,9 @@
// @property {ImportedPolygon[]} polygons
// @property {ImportedOverlay[]} overlays
// @property {Object<string, number>} undrawn What the file has that no
// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted.
// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted;
// the display adds the overlays it cannot draw (`rotatedOverlay`,
// `raisedOverlay`).
//
// @typedef {object} ImportedPoint
// @property {number[]} position
@@ -62,6 +64,9 @@
// @property {{ west: number, south: number, east: number, north: number }} rectangle
// @property {number[]} color Multiplies the image; its alpha fades it.
// @property {number} rotation Degrees counter-clockwise (KML LatLonBox).
// @property {number} altitude Metres (KML altitude); Cesium raises an
// absolute overlay to it.
// @property {'ground'|'relative'|'absolute'} altitudeMode
/** Longest plain-text name kept from a file's features. */
const MAX_IMPORT_NAME_LENGTH = 120;
@@ -162,7 +167,7 @@ export function geoJsonRecords(geojson, color) {
origin: 'bottom',
},
});
else if (kind === 'LineString')
else if (kind === 'LineString' && part.length >= 2)
records.lines.push({
name,
positions: part.map(position),
@@ -170,7 +175,10 @@ export function geoJsonRecords(geojson, color) {
color: stroke,
widthPx: GEOJSON_STROKE_PX,
});
else if (kind === 'Polygon' && part[0]?.length)
else if (
kind === 'Polygon' &&
new Set(part[0]?.map(([lon, lat]) => `${lon},${lat}`)).size >= 3
)
records.polygons.push({
name,
rings: part.map((ring) => ring.map(position)),
+31 -19
View File
@@ -404,25 +404,6 @@ export class LayerStateCoordinator {
live,
enabled.has(entry.id),
);
// A same-document engine choice retains operational layer intents.
// The share still owns durable preferences and later history visits.
if (
live &&
this.dataManager.layers.get(entry.id) === live.entry &&
!live.entry.lifecycleUncertain &&
live.entry.enabled === targetEnabled
) {
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'retained',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
persistenceWrite: false,
succeeded: true,
};
}
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
@@ -445,6 +426,37 @@ export class LayerStateCoordinator {
succeeded: false,
};
}
// A same-document engine choice retains operational layer intents.
// The share still owns durable preferences and later history visits:
// on a visit, the entry's options that differ from the live layer
// apply; a pick keeps the live layer as it is.
if (
live &&
this.dataManager.layers.get(entry.id) === live.entry &&
!live.entry.lifecycleUncertain &&
live.entry.enabled === targetEnabled
) {
const current = this.dataManager.getLayerParams(entry.id);
const changed =
live.visit === true &&
options &&
Object.entries(options).some(
([key, value]) => !Object.is(current?.[key], value),
);
const succeeded =
!changed ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'retained',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: changed && succeeded ? options : {},
persistenceWrite: false,
succeeded,
};
}
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
+130
View File
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager } from './manager.js';
import { LAYER_STATE_REGISTRY, REGISTERED_LAYER_IDS } from './layerState.js';
import { LayerStateCoordinator } from './layerStateCoordinator.js';
let windParamWrites = 0;
function layer(id) {
let params =
id === 'wind'
? { model: 'gfs', overlay: 'none', units: 'km/h', paused: false }
: null;
return {
id,
name: id,
icon: '',
source: 'test',
async init() {
return true;
},
async enable() {
return true;
},
async update() {
return true;
},
async disable() {
return true;
},
attachRenderer() {},
...(params
? {
setParams(next = {}) {
windParamWrites++;
params = { ...params, ...next };
return true;
},
getParams() {
return { ...params };
},
}
: {}),
};
}
function manager() {
const m = new DataLayerManager({});
for (const id of REGISTERED_LAYER_IDS) m.register(layer(id));
m.finalizeRegistrations(LAYER_STATE_REGISTRY);
return m;
}
const storage = () => {
const v = new Map();
return { getItem: (k) => v.get(k) ?? null, setItem: (k, x) => v.set(k, x) };
};
const sink = () => ({ setLayerStateProvider() {}, onLayerStateChange() {} });
const cesiumEntry = {
enabledLayerIds: ['wind'],
options: { wind: { model: 'gfs' } },
};
test('same-engine Back to a gfs entry restores gfs (reference)', async () => {
const m = manager();
const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
assert.equal(m.getLayerParams('wind').model, 'ifs');
await c.applyShareLayerState({ shareLayerState: cesiumEntry });
assert.equal(m.getLayerParams('wind').model, 'gfs');
});
const handoffOf = (m) =>
new Map(
[...m.layers].map(([id, entry]) => [
id,
{ entry, enabled: m.isEffectivelyEnabled(id), durable: id === 'wind' },
]),
);
test('cross-engine Back to a gfs entry restores gfs', async () => {
const m = manager();
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
const handoffLayers = new Map(
[...m.layers].map(([id, entry]) => [
id,
{
entry,
enabled: m.isEffectivelyEnabled(id),
durable: id === 'wind',
visit: true,
},
]),
);
const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
await c.start({ shareLayerState: cesiumEntry, handoffLayers });
assert.equal(c.getDurableState().options.wind.model, 'gfs');
assert.equal(
m.getLayerParams('wind').model,
'gfs',
'live wind should follow the visited entry',
);
});
test('a picked engine keeps the live layer options, not the address', async () => {
const m = manager();
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
const writes = windParamWrites;
const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
await c.start({ shareLayerState: cesiumEntry, handoffLayers: handoffOf(m) });
assert.equal(windParamWrites, writes, 'a pick writes no params');
assert.equal(m.getLayerParams('wind').model, 'ifs');
});
test('an engine switch leaves a retained layer whose options match alone', async () => {
const m = manager();
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'gfs' }, { origin: 'user' });
const writes = windParamWrites;
const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
const results = await c.start({
shareLayerState: cesiumEntry,
handoffLayers: handoffOf(m),
});
assert.equal(windParamWrites, writes, 'no params write for equal options');
const wind = results.find((result) => result.layerId === 'wind');
assert.equal(wind.phase, 'retained');
assert.equal(wind.succeeded, true);
assert.deepEqual(wind.appliedOptions, {});
});
+2 -3
View File
@@ -1207,9 +1207,8 @@ export class LayerLifecycle extends LayerSceneBinding {
const entry = this.layers.get(layerId);
if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
const desiredState = Boolean(shouldEnable);
// A layer leaving ends the camera arrival correction on a globe with a
// viewer (Cesium).
if (!desiredState && this.viewer) cancelCameraArrival(this.engine);
// A layer leaving ends its camera arrival correction.
if (!desiredState) cancelCameraArrival(this.engine);
if (entry.destroying) {
return {
intentEpoch: null,
+15 -3
View File
@@ -169,10 +169,17 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
height(native.gevDisplayPosition()) < 100,
'a ready tracked model releases the billboard floor',
);
// While the model owns the visual the card sits on it, not on the
// released billboard floor below it.
const card = { ...publications.at(-1).position() };
assert.deepEqual(
publications.at(-1).position(),
follow.label.position(),
'the card uses the follow label cache',
card,
{ ...native.gevVisualPosition() },
'the card anchors on the tracked model',
);
assert.ok(
height(card) >= 204.5 && height(card) < 217,
'the card stands with the model on the sampled ground',
);
const cached = { ...native.gevDisplayPosition() };
publications.at(-1).position();
@@ -185,6 +192,11 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
await frames();
const zoomedOut = layer.qaDrawn().find((row) => row.trackedModel)?.model;
assert.ok(!zoomedOut?.show, 'zooming out releases the tracked model');
assert.deepEqual(
{ ...publications.at(-1).position() },
{ ...follow.label.position() },
'without a model the card uses the follow label cache',
);
if (layerId === 'flights')
assert.ok(
height(native.gevDisplayPosition()) >= 205.5,
+1
View File
@@ -191,6 +191,7 @@ export function createTrackedReadout(
return typeof model === 'function' ? model() : model;
},
gevDisplayPosition: () => (handle.label ?? label).position(),
gevVisualPosition: () => handle.native?.gevVisualPosition?.(),
};
_labelSubjects.set(handle, subject);
}
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTrackedReadout } from './trackedReadout.js';
test('followed aircraft card anchors to the model the person sees (gevVisualPosition)', () => {
const display = { x: 1, y: 0, z: 0 };
const visual = { x: 1, y: 0, z: 100 };
const native = {
gevTrackedId: 'flight:abc',
gevLabelModel: { title: 'ABC123' },
gevDisplayPosition: () => display,
gevVisualPosition: () => visual,
};
const handle = {
native,
label: {
id: 'flight:abc',
model: () => native.gevLabelModel,
position: () => native.gevDisplayPosition(),
},
};
const listeners = [];
const engine = {
camera: {
following: () => handle,
followChanged: {
addEventListener: (f) => {
listeners.push(f);
return () => {};
},
},
},
};
let entries = null;
const overlay = {
setEntries(_id, e) {
entries = e;
},
setVisible() {},
clearSource() {
entries = null;
},
};
const readout = createTrackedReadout(engine, {
overlay,
events: new EventTarget(),
selectedContext: () => null,
});
readout.init();
assert.ok(entries?.length === 1, 'card published');
assert.deepEqual(entries[0].position(), visual);
});
-24
View File
@@ -51,30 +51,6 @@ export const VESSEL_TYPE_CSS_COLORS = Object.freeze([
...new Set([...TYPE_STYLES, DEFAULT_STYLE].map(({ css }) => css)),
]);
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
-6
View File
@@ -287,12 +287,6 @@ export function getColumn(matrix, index, result) {
/** The inverse of a rotation-plus-translation matrix (no scale or shear). */
export function inverseTransformation(matrix, result) {
// This function is an optimized version of the below 4 lines.
// const rT = Matrix3.transpose(Matrix4.getMatrix3(matrix));
// const rTN = Matrix3.negate(rT);
// const rTT = Matrix3.multiplyByVector(rTN, Matrix4.getTranslation(matrix));
// return Matrix4.fromRotationTranslation(rT, rTT, result);
const matrix0 = matrix[0];
const matrix1 = matrix[1];
const matrix2 = matrix[2];
-2
View File
@@ -99,8 +99,6 @@ export function scaleToGeodeticSurface(
func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0;
// "denominator" here refers to the use of this expression in the velocity and acceleration
// computations in the sections to follow.
denominator =
x2 * xMultiplier3 * oneOverRadiiSquaredX +
y2 * yMultiplier3 * oneOverRadiiSquaredY +
+6
View File
@@ -203,6 +203,12 @@ export function createCesiumCamera(viewer) {
if (!view?.destination)
throw new TypeError('setView needs a destination');
const orientation = cesiumOrientation(view.orientation);
// A view replaces a running flight, as on Navara (Cesium's own tween
// would otherwise overwrite it on the next frame).
if (flight || viewer.scene.tweens?.length > 0) {
cesium.cancelFlight();
flight = null;
}
cesium.setView({
destination: cesiumDestination(view.destination),
orientation,
+36
View File
@@ -516,3 +516,39 @@ test('commanded leaves a newer hook on the camera in place', () => {
remove();
assert.equal(cesium.flyTo, newer);
});
test('setView cancels a running flight before placing the camera', () => {
const { camera, cesium } = setup();
camera.flyTo({
destination: geo.cartesian3.fromDegrees(0, 0, 50000),
duration: 2,
});
const calls = [];
cesium.cancelFlight = () => calls.push('cancel');
cesium.setView = () => calls.push('place');
camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) });
assert.deepEqual(calls, ['cancel', 'place']);
});
test('setView without a flight places the camera only', () => {
const { camera, cesium } = setup();
const calls = [];
cesium.cancelFlight = () => calls.push('cancel');
cesium.setView = () => calls.push('place');
camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) });
assert.deepEqual(calls, ['place']);
});
test('setView ends the engine flight through its cancellation callback', () => {
const { camera } = setup();
const outcome = [];
camera.flyTo({
destination: geo.cartesian3.fromDegrees(0, 0, 50000),
duration: 2,
cancel: () => outcome.push('cancel'),
complete: () => outcome.push('complete'),
});
camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) });
assert.deepEqual(outcome, ['cancel']);
assert.equal(camera.flightActive(), false);
});
+7 -1
View File
@@ -492,7 +492,7 @@ export function createCesiumGround(
for (const primitive of drawn) {
primitive.gevGroundSet = options.id;
slot.add(primitive);
building.add(primitive);
if (slot.show) building.add(primitive);
}
watch();
animate();
@@ -505,6 +505,12 @@ export function createCesiumGround(
},
show(visible) {
slot.show = visible;
for (const primitive of drawn) {
if (visible && (!primitive.ready || !surfaceReady()))
building.add(primitive);
else building.delete(primitive);
}
if (building.size) watch();
animate();
requestRender('ground');
},
+27
View File
@@ -472,3 +472,30 @@ test('non-pickable ground fills skip pick commands and retain normal drawing', (
port.destroy();
assert.equal(fill.isDestroyed(), true);
});
test('hidden unready ground stops build frames and resumes when shown', () => {
const viewer = createFakeViewer();
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
const requests = [];
const primitive = () => ({
ready: false,
destroy() {},
isDestroyed: () => false,
});
const part = createCesiumGround(viewer, {
factories: { fills: primitive, lines: primitive },
requestRender: (why) => requests.push(why),
});
const set = part.ground.createSet({ id: 'hidden', kind: 'disc' });
set.replace([{ center: [0, 0], radiusM: 100, fill: [1, 0, 0, 1] }]);
set.commit();
set.show(false);
requests.length = 0;
viewer.scene.postRender.raiseEvent();
assert.ok(!requests.includes('ground-build'));
set.show(true);
requests.length = 0;
viewer.scene.postRender.raiseEvent();
assert.ok(requests.includes('ground-build'));
part.destroy();
});
+9
View File
@@ -517,6 +517,9 @@ export function createCesiumImagery(
job.collection = collection;
const provider = providerFor(job, job.source);
const requestImage = provider.requestImage.bind(provider);
job.restoreRequests = () => {
provider.requestImage = requestImage;
};
provider.requestImage = (x, y, level, request) => {
if (job.closed) return undefined;
const result = requestImage(x, y, level, request);
@@ -621,6 +624,12 @@ export function createCesiumImagery(
function swapIn(entry, job) {
entry.staging = null;
const layer = job.layer;
job.restoreRequests?.();
if (layer)
Object.defineProperty(layer.imageryProvider, 'credit', {
configurable: true,
get: () => entry.credit,
});
job.layer = null;
closeJob(job);
entry.loading = null;
@@ -0,0 +1,162 @@
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');
});
@@ -0,0 +1,162 @@
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');
});
+8
View File
@@ -97,6 +97,12 @@ export function createCesiumMaps(
let currentTiles = null;
let creditOwner = null;
let destroyed = false;
// Every terrain the globe takes counts, a fallback to the ellipsoid too.
let terrainRevision = 0;
const offTerrain =
viewer.scene.globe?.terrainProviderChanged?.addEventListener(
() => terrainRevision++,
);
const startupTileset = () =>
(typeof tilesetRef === 'function' ? tilesetRef() : tilesetRef?.current) ??
@@ -214,6 +220,7 @@ export function createCesiumMaps(
},
photorealActive: () => tilesets().some((tileset) => tileset.show === true),
current: () => ({ baseImagery: currentBase, photoreal: currentTiles }),
terrainRevision: () => terrainRevision,
};
return {
@@ -224,6 +231,7 @@ export function createCesiumMaps(
if (destroyed) return;
destroyed = true;
abort.abort();
offTerrain?.();
mapScene.destroy();
for (const promise of providers.values())
void promise.then(dispose, () => {});
+13
View File
@@ -201,6 +201,19 @@ test('setTerrain applies provider and terrain results', async () => {
);
});
test('terrainRevision counts every terrain the globe takes', async () => {
const { viewer, maps, destroy } = setup();
assert.equal(maps.terrainRevision(), 0);
await maps.setTerrain({ kind: 'ellipsoid' });
assert.equal(maps.terrainRevision(), 1);
// A fallback applied by other code counts too.
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
assert.equal(maps.terrainRevision(), 2);
destroy();
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
assert.equal(maps.terrainRevision(), 2, 'not counted after destroy');
});
test('setPhotorealTiles shows a given tileset and hides the globe; null reverses it', async () => {
const { viewer, maps } = setup();
const google = tileset();
+20 -4
View File
@@ -150,6 +150,7 @@ function spritePart(scene, options, store, ports) {
}
}
};
let atlasFailed = false;
// Instances wait for the atlas; then everything written so far is applied.
factories
.cropCells(sprite.atlas, store.cellKeys)
@@ -159,10 +160,14 @@ function spritePart(scene, options, store, ports) {
apply(0, Math.max(store.count, billboards.length));
requestRender('movers');
})
.catch((error) => console.warn(`[movers] ${id}: atlas failed`, error));
.catch((error) => {
atlasFailed = true;
console.warn(`[movers] ${id}: atlas failed`, error);
});
return {
primitive: collection,
ready: () => cells !== null,
failed: () => atlasFailed,
commit(range) {
if (cells && range) apply(range.from, range.to);
requestRender('movers');
@@ -226,6 +231,8 @@ function modelPart(options, store, { factories, requestRender, owners }) {
const { model, pickable } = options;
const models = [];
const loading = [];
// Instances whose model failed: not tried again (MoverSet.failed).
const failed = [];
let destroyed = false;
const place = (item, index) => {
const { orientations } = store;
@@ -282,9 +289,11 @@ function modelPart(options, store, { factories, requestRender, owners }) {
collection.add(item);
requestRender('movers');
})
.catch((error) =>
console.warn(`[movers] ${options.id}: model failed`, error),
);
.catch((error) => {
if (destroyed) return;
failed[index] = true;
console.warn(`[movers] ${options.id}: model failed`, error);
});
};
return {
primitive: collection,
@@ -296,6 +305,11 @@ function modelPart(options, store, { factories, requestRender, owners }) {
if (!models[at]?.ready) return false;
return true;
},
failed(index) {
if (index !== undefined) return failed[index] === true;
for (let at = 0; at < store.count; at++) if (failed[at]) return true;
return false;
},
commit(range) {
if (range)
for (let index = range.from; index < range.to; index++) {
@@ -308,6 +322,7 @@ function modelPart(options, store, { factories, requestRender, owners }) {
destroyed = true;
models.length = 0;
loading.length = 0;
failed.length = 0;
},
};
}
@@ -360,6 +375,7 @@ export function createCesiumMovers(
return {
primitive,
ready: part.ready,
failed: part.failed,
commit: () => part.commit(store.dirty()),
destroy() {
part.destroy();
+30
View File
@@ -412,6 +412,36 @@ test('a model that loads after its set is destroyed is released', async () => {
assert.equal(root().length, 0);
});
test('a model that fails to load marks its instance failed, once', async (t) => {
t.mock.method(console, 'warn', () => {});
let loads = 0;
const { movers } = setup({
loadModel: async () => {
loads++;
throw new Error('404');
},
});
const set = movers.createSet({
id: 'broken',
kind: 'model',
capacity: 1,
model: { url: '/a.glb' },
});
set.set(0, { position: at(0, 0) });
set.setCount(1);
set.commit();
assert.equal(set.failed(), false, 'still loading');
await settle();
assert.equal(set.failed(0), true);
assert.equal(set.failed(), true);
assert.equal(set.ready(), false);
set.commit();
await settle();
assert.equal(loads, 1, 'not tried again');
set.destroy();
assert.equal(set.failed(), false, 'a destroyed set reports nothing');
});
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.
+2
View File
@@ -1,5 +1,6 @@
// Cesium owns its render mode and scene effects. Scene services drive this API.
import * as Cesium from 'cesium';
import { CONTACT_SONAR_INITIAL, mergeContactSonar } from '../contract.js';
import { createCesiumPostStages } from './postStages.js';
import { createCesiumSonar } from './sonar.js';
@@ -58,6 +59,7 @@ export function createCesiumRender(
viewer.scene.requestRenderMode = !enabled;
},
isContinuous: () => viewer.scene.requestRenderMode === false,
maxTextureSize: () => Cesium.ContextLimits.maximumTextureSize,
...effects.render,
setContactSonar(options) {
const next = mergeContactSonar(contactSonar, options);
+17
View File
@@ -98,6 +98,23 @@ export function createCesiumSurface(
get terrainHeightSupported() {
return Boolean(scene.globe);
},
async sampleTerrainMostDetailed(positions) {
const provider = scene.terrainProvider;
if (!provider || provider instanceof Cesium.EllipsoidTerrainProvider)
return positions.map(() => 0);
// Cesium reads the finest level per position from it.
if (!provider.availability)
throw new Error('The terrain has no tile availability');
const sampled = await Cesium.sampleTerrainMostDetailed(
provider,
positions.map(
(place) => new Cesium.Cartographic(place.longitude, place.latitude),
),
);
return sampled.map(({ height }) =>
Number.isFinite(height) ? height : undefined,
);
},
clampToHeight(position, exclude) {
if (!scene.clampToHeightSupported) return undefined;
try {
+15
View File
@@ -59,6 +59,21 @@ test('terrainHeight asks the globe', () => {
assert.equal(surface.terrainHeight(place), undefined);
});
test('sampleTerrainMostDetailed: 0 on the ellipsoid; a terrain without availability rejects', async () => {
const { surface, scene } = setup();
const place = geo.cartographic.fromDegrees(85.9, 27.9, 0);
scene.terrainProvider = new Cesium.EllipsoidTerrainProvider();
assert.deepEqual(
await surface.sampleTerrainMostDetailed([place, place]),
[0, 0],
);
scene.terrainProvider = { availability: undefined };
await assert.rejects(
surface.sampleTerrainMostDetailed([place]),
/no tile availability/,
);
});
test('clampToHeight returns a geo value', () => {
const { viewer, surface } = setup();
const exclude = [];
+10 -1
View File
@@ -125,11 +125,13 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
),
),
};
let terrainProvider;
const globe = {
show: true,
tilesLoaded: true,
getHeight: record('globe.getHeight', () => 12.5),
pick: record('globe.pick', () => new Cesium.Cartesian3(6378137, 3, 4)),
terrainProviderChanged: new Cesium.Event(),
};
const scene = {
camera,
@@ -222,7 +224,14 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
currentTime: Cesium.JulianDate.fromIso8601('2026-10-16T12:00:00Z'),
},
imageryList,
terrainProvider: undefined,
// As Viewer's: the globe takes it and says so.
get terrainProvider() {
return terrainProvider;
},
set terrainProvider(provider) {
terrainProvider = provider;
globe.terrainProviderChanged.raiseEvent(provider);
},
imageryLayers: {
add: record('imageryLayers.add', (layer, index) =>
imageryList.splice(index ?? imageryList.length, 0, layer),
+22 -5
View File
@@ -92,7 +92,7 @@
* @property {() => number} height The camera's height above the WGS84
* ellipsoid (the pose's cartographic height), without building a pose.
* @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView
* Missing angles default to heading 0, pitch −π/2, roll 0.
* Cancels a running flight. Missing angles default to heading 0, pitch −π/2, roll 0.
* @property {(options: FlightOptions) => void} flyTo Cancels a running flight first.
* @property {(sphere: { center: Cartesian3, radius: number }, options?: SphereFlightOptions) => void} flyToSphere
* Ends unlocked (identity transform).
@@ -214,7 +214,13 @@
* @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight
* Height of the terrain/globe surface only (Cesium's globe.getHeight).
* @property {boolean} terrainHeightSupported
* @property {(position: Cartesian3, exclude?: any[]) => (Cartesian3 | undefined)} clampToHeight
* @property {(positions: Cartographic[]) => Promise<(number | undefined)[]>} sampleTerrainMostDetailed
* Terrain heights (as terrainHeight) at the terrain's finest level,
* fetched where it is not loaded (Cesium's sampleTerrainMostDetailed), one
* per position, in order: undefined where no tile answers, 0 on the
* 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.
* The position moved onto the rendered scene along the geodetic normal.
* @property {boolean} clampToHeightSupported
* @property {() => boolean} tilesLoaded
@@ -237,6 +243,9 @@
* Owns whatever the engine needs to render every frame or only on request
* (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity).
* @property {() => boolean} isContinuous
* @property {() => number} maxTextureSize The longest texture side (px) the
* GPU takes (Cesium's ContextLimits.maximumTextureSize, three's
* capabilities.maxTextureSize): a layer sizes its images under it.
* @property {(options: { enabled?: boolean, amount?: number }) => void} setSharpen
* An unsharp mask on the final image, after tone mapping and before
* anti-aliasing: each pixel moves away from its 3×3 box blur by `amount`
@@ -368,6 +377,9 @@
* @property {() => { baseImagery: object | null, photoreal: object | null }} current
* The source objects on screen, as passed: the base imagery layer on the
* globe and the photoreal tiles shown (null for none).
* @property {() => number} terrainRevision Changes whenever the terrain the
* globe draws is replaced (a setTerrain applied, a fallback to the
* ellipsoid): heights cached against the terrain are stale once it does.
*
* @typedef {object} GlobeClock
* The app's own time (src/time/clock.js), in milliseconds since the Unix
@@ -457,6 +469,9 @@
* sprite set's atlas. With an index, what that instance draws (Cesium: its
* own model; the other engines load a set's glTF once).
* A layer keeps its fallback visual until then.
* @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.
* @property {() => void} destroy
*
* @typedef {object} GlobeMovers
@@ -527,8 +542,7 @@
* with a pickId); false by default: picks pass through to what lies under.
* @property {'both'|'terrain'|'tiles'} [drape] What the fills drape on:
* terrain and 3D tiles ('both', the default), or only one of them
* (Cesium's classificationType). Navara drapes on whatever surface it
* draws.
* (Cesium's classificationType).
*
* @typedef {object} GroundRadial
* @property {number} fillAlpha Interior alpha multiplier, 0..1.
@@ -679,7 +693,7 @@
* differs only in its credit keeps its pixels. `staged`: the drawn
* source stays until the new one is complete (an image loaded and
* uploaded; the tiles in view loaded — Cesium: its requests in and quiet,
* at alpha 0 under the drawn one; Navara: its own atlas), then replaces
* at alpha 0 above the drawn one; Navara: its own atlas), then replaces
* it; resolves true then, false when it failed (an image or a tile that
* would not load), was aborted by `signal`, or was replaced by another
* setSource first. A hidden layer takes a staged source at once.
@@ -1001,6 +1015,7 @@ const SHAPE = {
request: isFunction,
setContinuous: isFunction,
isContinuous: isFunction,
maxTextureSize: isFunction,
setSharpen: isFunction,
sharpen: isFunction,
},
@@ -1010,6 +1025,7 @@ const SHAPE = {
supported: isBoolean,
terrainHeight: isFunction,
terrainHeightSupported: isBoolean,
sampleTerrainMostDetailed: isFunction,
clampToHeight: isFunction,
clampToHeightSupported: isBoolean,
tilesLoaded: isFunction,
@@ -1049,6 +1065,7 @@ const SHAPE = {
releasePhotorealTiles: isFunction,
photorealActive: isFunction,
current: isFunction,
terrainRevision: isFunction,
},
};
+18 -3
View File
@@ -602,13 +602,28 @@ const GROUND_PICK_PX = 3;
* @param {{ pickGlobe: (at: object) => (object | undefined),
* camera: { livePosition: object, pose: () => { fovy: number } },
* size: () => { width: number, height: number },
* sets: () => { options: object, items: object[], shown: boolean }[] }} ports
* sets: () => { options: object, items: object[], shown: boolean }[],
* surface?: () => 'terrain' | 'tiles' }} ports `surface`: what the ground
* is now (3D tiles while they show); a set draped only on the other one
* draws nothing there and is not picked.
* @returns {(at: { x: number, y: number }) => { onTop: number, order: number, depth: number, result: object }[]}
*/
export function createGroundPicks({ pickGlobe, camera, size, sets }) {
export function createGroundPicks({
pickGlobe,
camera,
size,
sets,
surface = () => 'terrain',
}) {
return (at) => {
const under = surface();
const candidates = sets().filter(
(state) => state.shown && state.options.pickable && state.items.length,
(state) =>
state.shown &&
state.options.pickable &&
state.items.length &&
(state.options.drape ?? 'both') !==
(under === 'tiles' ? 'terrain' : 'tiles'),
);
if (!candidates.length) return [];
const world = pickGlobe(at);
+38
View File
@@ -394,6 +394,44 @@ test('CPU picks find the item under the picked ground, on top first', () => {
assert.deepEqual(ids(), []);
});
test('CPU picks skip a set draped only on the other surface', () => {
const { ground, live } = createGroundRegistry(() => ({
commit() {},
show() {},
destroy() {},
}));
for (const drape of ['terrain', 'tiles', 'both']) {
const set = ground.createSet({
id: drape,
kind: 'disc',
pickable: true,
drape,
});
set.replace([
{ center: [1, 1], radiusM: 1e4, fill: [1, 1, 1, 1], pickId: drape },
]);
set.commit();
}
let under = 'terrain';
const picks = createGroundPicks({
pickGlobe: () => geo.cartesian3.fromDegrees(1, 1),
camera: {
livePosition: geo.cartesian3.fromDegrees(1, 1, 1e5),
pose: () => ({ fovy: Math.PI / 3 }),
},
size: () => ({ width: 1000, height: 1000 }),
sets: () => [...live.values()],
surface: () => under,
});
const ids = () =>
picks({ x: 0, y: 0 })
.map(({ result }) => result.id)
.sort();
assert.deepEqual(ids(), ['both', 'terrain']);
under = 'tiles';
assert.deepEqual(ids(), ['both', 'tiles']);
});
test('radial discs copy and validate their alpha envelope', () => {
const radial = {
fillAlpha: 0.105,
+19 -9
View File
@@ -394,9 +394,11 @@ export function createImageryRegistry(
if (options.raise === null)
fail('only a raised layer takes a detail');
const detail = normalizeImageryDetail(next ?? null);
const pending = detailing !== null;
dropDetail();
if (!detail) {
if (state.detail === null) return Promise.resolve(true);
// A detail still loading is cleared too: the backend may draw it.
if (state.detail === null && !pending) return Promise.resolve(true);
state.detail = null;
part.detail?.(null, () => {});
return Promise.resolve(true);
@@ -504,12 +506,23 @@ export const geographicY = (lat) =>
*/
export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') {
const geographic = scheme === 'geographic';
let area = { ...box };
if (rectangle && rectangle.east >= rectangle.west) {
const sourceEast = box.east < box.west ? box.east + 360 : box.east;
let area = { ...box, east: sourceEast };
if (rectangle) {
const degrees = 180 / Math.PI;
const west = rectangle.west * degrees;
const east =
rectangle.east * degrees + (rectangle.east < rectangle.west ? 360 : 0);
const spans = [-360, 0, 360]
.map((shift) => [
Math.max(box.west + shift, west),
Math.min(sourceEast + shift, east),
])
.filter(([left, right]) => left < right);
if (!spans.length) return null;
area = {
west: Math.max(box.west, rectangle.west * degrees),
east: Math.min(box.east, rectangle.east * degrees),
west: Math.min(...spans.map(([left]) => left)),
east: Math.max(...spans.map(([, right]) => right)),
south: Math.max(box.south, rectangle.south * degrees),
north: Math.min(box.north, rectangle.north * degrees),
};
@@ -522,10 +535,7 @@ export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') {
const rows = 2 ** z;
const x = Math.max(0, Math.floor(toX(area.west) * columns));
const y = Math.max(0, Math.floor(toY(area.north) * rows));
const right = Math.min(
columns - 1,
Math.ceil(toX(area.east) * columns) - 1,
);
const right = Math.ceil(toX(area.east) * columns) - 1;
const bottom = Math.min(rows - 1, Math.ceil(toY(area.south) * rows) - 1);
const width = right - x + 1,
height = bottom - y + 1;
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { imageryTilePlan } from './imagery.js';
const r = Math.PI / 180;
test('a view across the antimeridian plans the tiles it shows, not the whole source', () => {
// A view over Fiji, one degree wide across 180°.
const view = {
west: 179.5 * r,
east: -179.5 * r,
south: -17 * r,
north: -16 * r,
};
const global = { west: -180, east: 180, south: -85, north: 85 };
const plan = imageryTilePlan(global, view, 10);
// The same view one degree west (not crossing) plans level 10.
assert.ok(
plan.z >= 9,
`planned level ${plan.z}, a ${plan.width}×${plan.height} mosaic`,
);
});
test('antimeridian plans intersect each side of the source in both tilings', () => {
const view = {
west: 179.5 * r,
east: -179.5 * r,
south: -17 * r,
north: -16 * r,
};
for (const scheme of ['webMercator', 'geographic']) {
const columns = 2 ** (10 + (scheme === 'geographic' ? 1 : 0));
const global = { west: -180, east: 180, south: -85, north: 85 };
const plan = imageryTilePlan(global, view, 10, scheme);
assert.equal(plan.z, 10);
assert.ok(plan.x < columns && plan.x + plan.width > columns);
const west = imageryTilePlan({ ...global, west: 179 }, view, 10, scheme);
assert.ok(west.x + west.width <= columns);
const east = imageryTilePlan({ ...global, east: -179 }, view, 10, scheme);
assert.ok(east.x >= columns);
assert.equal(
imageryTilePlan({ ...global, west: 0, east: 10 }, view, 10, scheme),
null,
);
}
});
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createImageryRegistry } from './imagery.js';
const image = { width: 2, height: 2, data: new Uint8ClampedArray(16) };
test('clearing a detail still loading clears it on the engine too', async () => {
const details = [];
const { imagery } = createImageryRegistry(() => ({
source() {},
detail: (detail, finish) => details.push([detail, finish]),
look() {},
destroy() {},
}));
const raised = imagery.createLayer({
id: 'shell',
source: { kind: 'image', image },
raise: 6000,
});
const rectangle = { west: 1, south: 1, east: 2, north: 2 };
// The engine already holds the detail (Cesium's shell takes it at once);
// it has not drawn yet.
const first = raised.setDetail({ image, rectangle });
assert.equal(await raised.setDetail(null), true);
assert.equal(await first, false);
assert.equal(raised.detail(), null);
assert.equal(details.at(-1)[0], null, 'the engine is told to clear it');
// Nothing shown or loading: another clear asks nothing of the engine.
const calls = details.length;
assert.equal(await raised.setDetail(null), true);
assert.equal(details.length, calls);
});
+4 -1
View File
@@ -347,7 +347,7 @@ export function createMoverStore(options) {
*/
export function createMoverSet(
store,
{ id, commit, destroy, ready = () => true },
{ id, commit, destroy, ready = () => true, failed = () => false },
) {
let destroyed = false;
const alive = () => {
@@ -373,6 +373,8 @@ export function createMoverSet(
// Whether what it draws has loaded (a model's glTF, an atlas image); with
// an index, what that instance draws.
ready: (index) => !destroyed && ready(index) === true,
// Whether that load failed for good (the set is not tried again).
failed: (index) => !destroyed && failed(index) === true,
destroy() {
if (destroyed) return;
destroyed = true;
@@ -409,6 +411,7 @@ export function createMoverRegistry(build) {
part.destroy();
},
ready: (index) => (part.ready ? part.ready(index) : true),
failed: (index) => part.failed?.(index) === true,
});
sets.set(options.id, entry);
return entry.set;
+141
View File
@@ -0,0 +1,141 @@
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');
});
+29 -9
View File
@@ -12,7 +12,9 @@
// wall time (Cesium's cameraEventWaitTime), changed past percentageChanged.
//
// The port is the engine's thin layer over the Navara view (apply, read, fly,
// stop, size, requestFrame); see createNavaraEngine.js.
// stop, size, requestFrame, suspendCameraInputs); see createNavaraEngine.js.
// A flight suspends the camera inputs until it ends, as Cesium's
// CameraFlightPath turns them off: a gesture cannot cut it short.
import * as geo from '../../geo/index.js';
import { createEngineEvent } from '../contract.js';
@@ -81,6 +83,7 @@ function posesNear(a, b, distance, angle) {
* stop: () => void,
* size: () => { width: number, height: number },
* requestFrame: () => void,
* suspendCameraInputs?: () => () => void,
* },
* clock?: { now: () => number, setTimeout: Function, clearTimeout: Function },
* }} options
@@ -124,6 +127,11 @@ export function createCameraModel({
let lastSeen = initial ? copyPose(initial) : null;
let lastFramePose = copyPose(state);
let lastChangedPose = copyPose(state);
let changedAngles = headingPitchRoll(
state.position,
state.direction,
state.up,
);
let moving = false;
let lastMoveAt = 0;
let moveEndTimer = null;
@@ -175,6 +183,7 @@ export function createCameraModel({
function endFlight(current, outcome) {
if (flight !== current) return;
flight = null;
current.resume?.();
if (outcome === 'cancel') current.options.cancel?.();
}
@@ -197,7 +206,7 @@ export function createCameraModel({
options.complete?.();
return;
}
const current = { options };
const current = { options, resume: port.suspendCameraInputs?.() };
flight = current;
// Navara moves the camera from here on; read it back every frame.
pushed = null;
@@ -212,7 +221,7 @@ export function createCameraModel({
(arrived) => {
if (flight !== current || destroyed) return;
if (!arrived) return endFlight(current, 'cancel');
flight = null;
endFlight(current, 'complete');
land(options, target);
options.complete?.();
},
@@ -261,13 +270,28 @@ export function createCameraModel({
}
lastFramePose = pose;
const turn =
angleBetween(pose.direction, lastChangedPose.direction) / Math.PI;
angleBetween(pose.direction, lastChangedPose.direction) /
(view().fovy / 2);
const altitude = Math.max(
1,
cartographic.fromCartesian(pose.position, WGS84).height,
);
const shift =
cartesian3.distance(pose.position, lastChangedPose.position) / altitude;
const angles = headingPitchRoll(pose.position, pose.direction, pose.up);
const delta = (a, b) => {
const angle = Math.abs(a - b) % (2 * Math.PI);
return Math.min(angle, 2 * Math.PI - angle) / Math.PI;
};
// Heading and roll are raised on their own, as Camera._updateCameraChanged
// does: a turn about the line of sight (heading at nadir, roll) moves
// neither the direction nor the position.
for (const angle of ['heading', 'roll']) {
const amount = delta(angles[angle], changedAngles[angle]);
if (amount <= percentageChanged) continue;
changedAngles[angle] = angles[angle];
events.changed.raiseEvent(amount);
}
const amount = Math.max(turn, shift);
if (amount > percentageChanged) {
lastChangedPose = pose;
@@ -311,6 +335,7 @@ export function createCameraModel({
if (!options?.destination)
throw new TypeError('setView needs a destination');
announce();
cancelCurrentFlight();
place(viewFor(options.destination, options.orientation));
},
flyTo(options) {
@@ -395,11 +420,6 @@ export function createCameraModel({
camera,
sync,
afterFrame,
/**
* Ends a running flight (its `cancel` runs) without camera.commanded:
* for the user's own gestures, which are not a new camera owner.
*/
stopFlight: () => cancelCurrentFlight(),
/**
* Whether nothing else drives the camera: no flight and no lookAt target
* (identity transform). The engine's ground floor acts only then.
+23
View File
@@ -525,3 +525,26 @@ test('pose carries the view aspect ratio', () => {
const { width, height } = port.size();
assert.equal(camera.pose().aspectRatio, width / height);
});
test('setView cancels a model flight before its promise settles', async () => {
const { camera, port } = setup();
const outcome = [];
camera.flyTo({
destination: cartesian3.fromDegrees(0, 0, 50000),
duration: 2,
cancel: () => outcome.push('cancel'),
complete: () => outcome.push('complete'),
});
const destination = cartesian3.fromDegrees(0, 0, 200000);
camera.setView({ destination });
assert.equal(camera.flightActive(), false);
assert.deepEqual(outcome, ['cancel']);
port.flights[0].settle(true);
await Promise.resolve();
near(
cartesian3.distance(camera.pose().position, destination),
0,
1e-6,
'setView stays',
);
});
+83 -10
View File
@@ -34,6 +34,8 @@ import { createNavaraSurface } from './surface.js';
import { createGroundFloor } from './groundFloor.js';
const { cartesian3, math } = geo;
// Past the far side of the Earth: a ray from any camera to any tile.
const TILE_PICK_FAR_M = 2e7;
// Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in
// Earth-fixed metres): matrixWorld columns are right, up, back, position.
@@ -129,6 +131,7 @@ export function createNavaraEngine(
stop: () => view.setCamera({}),
size,
requestFrame: () => view.forceUpdate(),
suspendCameraInputs: () => input.input.suspendCameraInputs(),
},
...(clock ? { clock } : {}),
});
@@ -162,6 +165,15 @@ export function createNavaraEngine(
// Between the view's preRender and postRender: a change made then shows
// in the frame being drawn and asks for no other.
let drawing = false;
// Navara clears its frame request after the frame it draws, so a
// render.request made during one (preRender to the end of postRender) is
// made again once that frame is done: the next frame follows, as on Cesium.
let frameOpen = false;
let requestedInFrame = false;
const requestRender = () => {
view.forceUpdate();
if (frameOpen) requestedInFrame = true;
};
const handlers = {
preUpdate: (time) => {
// Before the view decides whether it is idle (internals.js).
@@ -171,6 +183,13 @@ export function createNavaraEngine(
},
preRender: (time) => {
drawing = true;
frameOpen = true;
queueMicrotask(() => {
frameOpen = false;
if (!requestedInFrame || destroyed) return;
requestedInFrame = false;
view.forceUpdate();
});
model.sync();
// After the user's camera is read, before anything draws.
followStep.raiseEvent();
@@ -202,11 +221,7 @@ export function createNavaraEngine(
lines: () => linesPort?.pickables?.() ?? [],
resolveBatch: (batchId) => moversPort?.resolvePick?.(batchId) ?? null,
});
const input = createNavaraInput(view, {
// A gesture is the user, not a new camera owner: no camera.commanded.
onCameraInput: () => model.stopFlight(),
picker: picking,
});
const input = createNavaraInput(view, { picker: picking });
const credits = createNavaraCredits(view);
const maps = createNavaraMaps(view, fetch ? { fetch } : {});
const imagery =
@@ -216,7 +231,9 @@ export function createNavaraEngine(
credits: credits.credits,
photorealActive: () => maps.maps.photorealActive(),
}) ?? null;
const { surface } = createNavaraSurface(view, hooks);
const { surface } = createNavaraSurface(view, hooks, (positions) =>
maps.sampleTerrain(positions),
);
// Navara's controller zooms through the ground; keep the camera above it.
// With the camera inputs off or suspended nothing zooms, and the app places
// the camera itself (the cockpit eye, 12 m up): the floor's 30 m would
@@ -233,14 +250,28 @@ export function createNavaraEngine(
...(clock ? { now: () => clock.now() } : {}),
});
// Ground items are picked on the CPU at the globe under the pointer
// (src/globe/ground.js), under every mover and line.
// Ground items are picked on the CPU at the visible surface under the pointer
// (src/globe/ground.js), under every mover and line: the 3D tiles while
// they show, else the globe. A CPU ray, not the depth read, which answers
// with the near plane until a frame after Navara's pick of a click.
const tilesUnder = (screen) => {
if (!maps.maps.photorealActive()) return undefined;
const ray = model.camera.pickRay(screen);
return ray
? surface.raycast({
origin: ray.origin,
direction: ray.direction,
far: TILE_PICK_FAR_M,
})?.position
: undefined;
};
const removeGroundPicks = picking.registerMeshPicker(
createGroundPicks({
pickGlobe: (screen) => picker.pickGlobe(screen),
pickGlobe: (screen) => tilesUnder(screen) ?? picker.pickGlobe(screen),
camera: model.camera,
size,
sets: () => groundPort?.pickables?.() ?? [],
surface: () => (maps.maps.photorealActive() ? 'tiles' : 'terrain'),
}),
);
const picker = {
@@ -326,6 +357,33 @@ export function createNavaraEngine(
},
}
: {};
let maxTextureSize = null;
// Movers on screen now: shown, not transparent, their anchor in the view
// and over the horizon.
const clip = geo.matrix4.create();
const at = geo.cartesian3.create();
const visibleContacts = () => {
model.viewProjection(clip);
let shown = 0;
for (const { store } of moversPort?.pickables?.() ?? []) {
const { positions, shows, colors, count } = store;
for (let index = 0; index < count; index++) {
if (shows[index] !== 1 || colors[index * 4 + 3] <= 0) continue;
at.x = positions[index * 3];
at.y = positions[index * 3 + 1];
at.z = positions[index * 3 + 2];
const w = clip[3] * at.x + clip[7] * at.y + clip[11] * at.z + clip[15];
if (w <= 0) continue;
const x =
(clip[0] * at.x + clip[4] * at.y + clip[8] * at.z + clip[12]) / w;
const y =
(clip[1] * at.x + clip[5] * at.y + clip[9] * at.z + clip[13]) / w;
if (Math.abs(x) > 1 || Math.abs(y) > 1) continue;
if (model.horizonVisible(at)) shown++;
}
}
return shown;
};
let bloom = { ...BLOOM_INITIAL };
const bloomRender = bloomPort
? {
@@ -385,11 +443,19 @@ export function createNavaraEngine(
},
frames,
render: {
request: () => view.forceUpdate(),
request: requestRender,
setContinuous(enabled) {
view.animation = Boolean(enabled);
},
isContinuous: () => view.animation === true,
// The canvas's own context (getContext hands back the one the view
// made); Navara has no public renderer to ask.
maxTextureSize: () =>
(maxTextureSize ??= (() => {
const gl =
canvas.getContext?.('webgl2') ?? canvas.getContext?.('webgl');
return gl?.getParameter(gl.MAX_TEXTURE_SIZE) ?? 0;
})()),
setSharpen(options) {
const next = mergeSharpen(sharpen, options);
const changed =
@@ -415,6 +481,13 @@ export function createNavaraEngine(
ground: groundPort.ground,
meshes: meshes.meshes,
imagery: imagery.imagery,
// Not in the contract, as Cesium's (src/cyberSonarEngine.js reads it):
// the treatment runs in the movers' shaders; `contacts` is how many are
// on screen, so an empty view lets the sonar go idle.
contactSonarDiagnostics: () => ({
supported: true,
contacts: visibleContacts(),
}),
destroy() {
if (destroyed) return;
destroyed = true;
+112 -17
View File
@@ -446,7 +446,79 @@ test("Navara's fov follows Cesium's vertical fov, also after a resize", () => {
near(engine.camera.pose().fovy, Math.PI / 3, 1e-15, 'pose fovy');
});
test('cancelFlight stops Navara with an empty setCamera; user input cancels a flight', () => {
test('contactSonarDiagnostics counts the movers on screen', () => {
const view = fakeView();
// The camera is 10 000 km over (0°, 0°), looking down.
const places = [
[0, 0, 1],
[1, 1, 1],
[0, 0, 0],
[180, 0, 1],
];
const positions = new Float64Array(places.length * 3);
const colors = new Float32Array(places.length * 4).fill(1);
const shows = new Uint8Array(places.length).fill(1);
places.forEach(([lon, lat, show], index) => {
const position = cartesian3.fromDegrees(lon, lat, 0);
positions.set([position.x, position.y, position.z], index * 3);
shows[index] = show;
});
colors[1 * 4 + 3] = 0;
const store = { positions, colors, shows, count: places.length };
const engine = createNavaraEngine(view, {
...ports,
movers: { ...ports.movers, pickables: () => [{ store }] },
hooks,
});
// Shown and opaque, in view: the first; the second is transparent, the
// third hidden, the fourth behind the Earth.
assert.deepEqual(engine.contactSonarDiagnostics(), {
supported: true,
contacts: 1,
});
engine.destroy();
});
test("maxTextureSize reads the canvas's WebGL limit once", () => {
const view = fakeView();
const asked = [];
view.canvas.getContext = (type) => {
asked.push(type);
return type === 'webgl2'
? { MAX_TEXTURE_SIZE: 0x0d33, getParameter: () => 8192 }
: null;
};
const engine = createNavaraEngine(view, { ...ports, hooks });
assert.equal(engine.render.maxTextureSize(), 8192);
assert.equal(engine.render.maxTextureSize(), 8192);
assert.deepEqual(asked, ['webgl2']);
engine.destroy();
});
test('a render requested while a frame draws asks for the next frame', async () => {
const { engine, view } = setup();
const requests = () =>
view.calls.filter(([name]) => name === 'forceUpdate').length;
// The wind's flow asks for its next frame from preRender.
const off = engine.frames.preRender.addEventListener(() =>
engine.render.request('wind'),
);
view.emit('preRender', 1);
view.emit('postRender', 1);
// Navara clears its request flag once the frame is drawn.
const drawn = requests();
await Promise.resolve();
assert.equal(requests(), drawn + 1, 'requested again after the frame');
off();
view.emit('preRender', 2);
view.emit('postRender', 2);
const idle = requests();
await Promise.resolve();
assert.equal(requests(), idle, 'no request, no next frame');
engine.destroy();
});
test('cancelFlight stops Navara with an empty setCamera; user input leaves a flight running', () => {
const { engine, view } = setup();
const outcome = [];
engine.camera.flyTo({
@@ -463,8 +535,9 @@ test('cancelFlight stops Navara with an empty setCamera; user input cancels a fl
cancel: () => outcome.push('input'),
});
view.canvas.dispatchEvent(new Event('wheel'));
assert.deepEqual(outcome, ['cancel', 'input']);
assert.equal(engine.camera.flightActive(), false);
assert.deepEqual(outcome, ['cancel']);
assert.equal(engine.camera.flightActive(), true);
engine.destroy();
});
test('pickPosition reads Navara depth at client coordinates; pick finds no tiles', () => {
@@ -536,7 +609,7 @@ test('pickGlobe meets the terrain along the pick ray, or the ellipsoid', () => {
);
});
test('a user gesture cancels a flight without raising camera.commanded', () => {
test('a user gesture leaves a flight running without raising camera.commanded', () => {
const { engine, view } = setup();
const outcome = [];
let commanded = 0;
@@ -548,8 +621,8 @@ test('a user gesture cancels a flight without raising camera.commanded', () => {
engine.camera.commanded.addEventListener(() => commanded++);
view.canvas.dispatchEvent(new Event('pointerdown'));
view.canvas.dispatchEvent(new Event('wheel'));
assert.deepEqual(outcome, ['cancel']);
assert.equal(engine.camera.flightActive(), false);
assert.deepEqual(outcome, []);
assert.equal(engine.camera.flightActive(), true);
assert.equal(commanded, 0, 'a gesture is the user, not a new camera owner');
});
@@ -630,17 +703,39 @@ test('the floor stands aside while the camera inputs are off (the cockpit drives
engine.destroy();
});
test('the floor stands aside while the camera inputs are suspended (the cockpit holds them)', () => {
const { engine, view } = setup();
view.sampleTerrainHeight = () => 100;
const resume = engine.input.suspendCameraInputs();
engine.camera.setView({
destination: cartesian3.fromDegrees(2.35, 48.85, 112),
test('ground picks use the visible surface and fall back to the globe', async () => {
const { normalizeGroundItem } = await import('../ground.js');
const view = fakeView();
// Right after a click Navara's depth read answers with its near plane.
view.pickDepthPosition = () => cartesian3.fromDegrees(10.5, 10, 45900);
const options = { id: 'rooftop', kind: 'disc', pickable: true, order: 0 };
const item = normalizeGroundItem(
'disc',
{ center: [10, 10], radiusM: 100, fill: [1, 0, 0, 1], pickId: 'roof' },
0,
);
const engine = createNavaraEngine(view, {
...ports,
hooks,
ground: {
...ports.ground,
pickables: () => [{ options, items: [item], shown: true }],
},
});
view.emit('preRender', 1);
near(heightOf(engine), 112, 1e-6, 'the cockpit eye 12 m up is kept');
resume();
view.emit('preRender', 2);
near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back');
const at = { x: 640, y: 400 };
let globe = cartesian3.fromDegrees(10, 10, 0);
engine.picker.pickGlobe = () => globe;
let tiles = null;
engine.surface.raycast = () => (tiles ? { position: tiles } : undefined);
assert.equal(engine.picker.pick(at)?.id, 'roof', 'the globe, not the depth');
// Under 3D tiles a tilted view meets the roof before the terrain.
engine.maps.photorealActive = () => true;
globe = cartesian3.fromDegrees(10.01, 10, 0);
tiles = cartesian3.fromDegrees(10, 10, 40);
assert.equal(engine.picker.pick(at)?.id, 'roof', 'the tiles shown');
tiles = null;
assert.equal(engine.picker.pick(at), undefined, 'a tile miss: the globe');
globe = cartesian3.fromDegrees(10, 10, 0);
assert.equal(engine.picker.pick(at)?.id, 'roof');
engine.destroy();
});
+186
View File
@@ -0,0 +1,186 @@
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();
});
+27 -6
View File
@@ -9,7 +9,9 @@
// window ratio uses Cesium's maximumMovementRatio cap; a drag
// right turns the heading clockwise;
// - right drag: a dolly by dy / height (down closes in);
// - wheel: one notch (deltaY 100) is 20 % of the range.
// - wheel: one notch (deltaY 100) is 20 % of the range;
// - two-finger pinch: the range scales by the fingers' distance ratio
// (pointers are told apart by pointerId).
const WHEEL_STEP = 1.2;
const ROTATE_RATE = 1.77;
@@ -29,16 +31,32 @@ export function navaraFollowGestures(
) {
return ({ orbit, zoom, dolly }) => {
const resume = suspendCameraInputs();
let last = null;
const pointers = new Map();
const distance = () => {
const [a, b] = pointers.values();
return Math.hypot(a.x - b.x, a.y - b.y);
};
const down = (event) => {
if (event.button !== 0 && event.button !== 2) return;
last = { x: event.clientX, y: event.clientY, button: event.button };
pointers.set(event.pointerId, {
x: event.clientX,
y: event.clientY,
button: event.button,
});
};
const move = (event) => {
const last = pointers.get(event.pointerId);
if (!last) return;
const before = pointers.size === 2 ? distance() : 0;
const dx = event.clientX - last.x;
const dy = event.clientY - last.y;
last = { ...last, x: event.clientX, y: event.clientY };
last.x = event.clientX;
last.y = event.clientY;
if (pointers.size > 1) {
const after = pointers.size === 2 ? distance() : 0;
if (before > 0 && after > 0) zoom(before / after);
return;
}
const width = canvas.clientWidth || 1;
const height = canvas.clientHeight || 1;
if (last.button === 2) {
@@ -50,8 +68,8 @@ export function navaraFollowGestures(
ROTATE_RATE * Math.min(-dy / height, MAX_MOVEMENT_RATIO) * Math.PI,
);
};
const up = () => {
last = null;
const up = (event) => {
pointers.delete(event.pointerId);
};
const wheel = (event) => {
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return;
@@ -63,6 +81,7 @@ export function navaraFollowGestures(
canvas.addEventListener('pointerdown', down);
windowTarget.addEventListener('pointermove', move);
windowTarget.addEventListener('pointerup', up);
windowTarget.addEventListener('pointercancel', up);
canvas.addEventListener('wheel', wheel, { passive: false });
canvas.addEventListener('contextmenu', contextMenu);
return () => {
@@ -70,6 +89,8 @@ export function navaraFollowGestures(
canvas.removeEventListener('pointerdown', down);
windowTarget.removeEventListener('pointermove', move);
windowTarget.removeEventListener('pointerup', up);
windowTarget.removeEventListener('pointercancel', up);
pointers.clear();
canvas.removeEventListener('wheel', wheel, { passive: false });
resume();
};
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { navaraFollowGestures } from './followGestures.js';
const event = (type, fields = {}) =>
Object.assign(new Event(type, { cancelable: true }), fields);
test('a two-finger pinch while following zooms and does not jerk the heading', () => {
const canvas = new EventTarget();
canvas.clientWidth = 400;
canvas.clientHeight = 800;
const win = new EventTarget();
const gestures = navaraFollowGestures(canvas, () => () => {}, win);
const calls = [];
const release = gestures({
orbit: (h, p) => calls.push(['orbit', h, p]),
zoom: (f) => calls.push(['zoom', f]),
dolly: (r) => calls.push(['dolly', r]),
});
const touch = (type, pointerId, x, y, target = win) =>
target.dispatchEvent(
event(type, {
pointerId,
pointerType: 'touch',
isPrimary: pointerId === 1,
button: type === 'pointermove' ? -1 : 0,
clientX: x,
clientY: y,
}),
);
// Two fingers 200 px apart spread out symmetrically (a pinch-out).
touch('pointerdown', 1, 100, 400, canvas);
touch('pointerdown', 2, 300, 400, canvas);
for (let i = 1; i <= 4; i++) {
touch('pointermove', 1, 100 - 5 * i, 400);
touch('pointermove', 2, 300 + 5 * i, 400);
}
const turn = calls
.filter(([kind]) => kind === 'orbit')
.reduce((most, [, h]) => Math.max(most, Math.abs(h)), 0);
assert.ok(
turn < 0.2,
`a pinch turned the heading by up to ${turn.toFixed(2)} rad in one move`,
);
assert.ok(
calls.some(([kind]) => kind === 'zoom' || kind === 'dolly'),
'a pinch changes the range',
);
touch('pointercancel', 2, 320, 400);
const count = calls.length;
touch('pointermove', 2, 350, 400);
assert.equal(
calls.length,
count,
'a cancelled finger no longer moves the camera',
);
touch('pointermove', 1, 75, 400);
assert.equal(
calls.at(-1)[0],
'orbit',
'the remaining finger resumes dragging',
);
touch('pointerup', 1, 75, 400);
const ended = calls.length;
touch('pointermove', 1, 90, 400);
assert.equal(calls.length, ended);
release();
});
+1
View File
@@ -61,6 +61,7 @@ export function createNavaraGround(
};
const pack = (entry) => {
entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight });
entry.pack.drape = entry.options.drape;
for (
let at = REVEAL_OFFSET;
at < entry.pack.items.length;
+19
View File
@@ -274,3 +274,22 @@ test('destroying a revealed corridor leaves the next ground set with fresh pass
}
port.destroy();
});
test('ground sets carry their surface classification into the frame texture', async () => {
const { matrix4 } = await import('../../../geo/index.js');
const pass = fakePass();
const port = createNavaraGround(fakeView(), { createPass: () => pass });
for (const [drape, expected] of [
['terrain', 1],
['tiles', 2],
['both', 0],
]) {
const set = port.ground.createSet({ id: drape, kind: 'polygon', drape });
set.replace([square(0)]);
set.commit();
const state = pass.owner.state;
state.update(matrix4.IDENTITY, matrix4.IDENTITY, 800, 600);
assert.equal(state.frames.data[(state.count - 1) * 16 + 15], expected);
}
port.destroy();
});
+17 -5
View File
@@ -524,6 +524,7 @@ export function composeGround(packs) {
0,
);
const frames64 = new Float64Array(count * 13);
const drapes = new Uint8Array(count);
const volumes = new Float64Array(count * 4);
const heights = new Float64Array(count * 2);
const styles = texelStorage(count * STYLE_TEXELS);
@@ -543,6 +544,11 @@ export function composeGround(packs) {
let run = 0;
let look = 0;
for (const pack of packs) {
drapes.fill(
pack.drape === 'terrain' ? 1 : pack.drape === 'tiles' ? 2 : 0,
item,
item + pack.count,
);
frames64.set(pack.frames, item * 13);
volumes.set(pack.volumes, item * 4);
heights.set(pack.heights, item * 2);
@@ -585,6 +591,7 @@ export function composeGround(packs) {
const box = new Float64Array(4);
const lastView = new Float64Array(16).fill(Number.NaN);
const lastProjection = new Float64Array(16).fill(Number.NaN);
let lastPixelRatio = 1;
let lastWidth = 0;
let lastHeight = 0;
const clampTo = (value, max) => Math.min(max - 1, Math.max(0, value));
@@ -602,8 +609,11 @@ export function composeGround(packs) {
return candidates;
},
/** True when the frames, grid or candidates changed (upload them). */
update(view, projection, width, height) {
let changed = width !== lastWidth || height !== lastHeight;
update(view, projection, width, height, pixelRatio = 1) {
let changed =
width !== lastWidth ||
height !== lastHeight ||
pixelRatio !== lastPixelRatio;
for (let index = 0; index < 16 && !changed; index++)
changed =
view[index] !== lastView[index] ||
@@ -613,6 +623,7 @@ export function composeGround(packs) {
lastProjection.set(projection);
lastWidth = width;
lastHeight = height;
lastPixelRatio = pixelRatio;
const m = view;
counts.fill(0);
for (let index = 0; index < count; index++) {
@@ -636,11 +647,12 @@ export function composeGround(packs) {
frames.data.set(viewFrame.subarray(3, 6), t + 4);
frames.data.set(viewFrame.subarray(6, 9), t + 8);
frames.data.set(viewFrame.subarray(9, 12), t + 12);
frames.data[t + 15] = drapes[index];
const v = index * 4;
// The item's extent in its plane and its padding in pixels, for
// the shader's early rejection.
frames.data[t + 7] = volumes[v];
frames.data[t + 11] = volumes[v + 3];
frames.data[t + 11] = volumes[v + 3] * pixelRatio;
const distance = Math.hypot(viewFrame[0], viewFrame[1], viewFrame[2]);
// Heights relative to the plane's origin on the ellipsoid; the
// plane dips under the curve by r²/2R at the item's edge.
@@ -665,8 +677,8 @@ export function composeGround(packs) {
ranges[r] = -1;
continue;
}
const padX = (2 * volumes[v + 3]) / Math.max(1, width);
const padY = (2 * volumes[v + 3]) / Math.max(1, height);
const padX = (2 * volumes[v + 3] * pixelRatio) / Math.max(1, width);
const padY = (2 * volumes[v + 3] * pixelRatio) / Math.max(1, height);
ranges[r] = clampTo(
Math.floor(((box[0] - padX) * 0.5 + 0.5) * GRID_COLS),
GRID_COLS,
+19
View File
@@ -502,3 +502,22 @@ test('radial discs pack both transitions, alpha and emission without segment dat
);
assert.equal(pack.segments.length, 0);
});
test('screen padding follows the render pixel ratio', () => {
const pack = packSet(
'disc',
items('disc', [
{
center: [0, 0],
radiusM: 100,
outline: { color: [1, 1, 1, 1], widthPx: 8 },
},
]),
);
const state = composeGround([pack]);
const identity = geo.matrix4.IDENTITY;
state.update(identity, identity, 800, 600, 1);
const padding = state.frames.data[11];
assert.equal(state.update(identity, identity, 800, 600, 2), true);
assert.equal(state.frames.data[11], padding * 2);
});
+16 -7
View File
@@ -44,6 +44,7 @@ const float = (value) => (Number.isInteger(value) ? `${value}.0` : `${value}`);
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform sampler2D tGlobeDepth;
uniform highp sampler2D tFrames;
uniform highp sampler2D tStyles;
uniform highp sampler2D tSegments;
@@ -73,7 +74,7 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along
// Metres a pixel spans across the line (the screen gradient of the
// distance to it): its width in pixels the same at any angle, as Cesium's.
float pixel = max(length(vec2(dot(normal, localDx), dot(normal, localDy))), 1e-4);
float halfWidth = shape.x > 0.0 ? shape.x * 0.5 * pixel : shape.y * 0.5;
float halfWidth = shape.x > 0.0 ? shape.x * uPixelRatio * 0.5 * pixel : shape.y * 0.5;
float cover = clamp((halfWidth - nearest) / pixel + 0.5, 0.0, 1.0);
if (cover <= 0.0) return color;
vec4 fill = atSlot(tItems, at);
@@ -137,6 +138,8 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
float surface = texture2D(tSurfaceDepth, uv).r;
// The packed depth also holds forward meshes, which are not ground.
if (depth < surface - 2e-7) return;
float globeDepth = unpackRGBAToDepth(texture2D(tGlobeDepth, uv));
bool onTiles = surface < globeDepth - 2e-7;
vec3 color = toDisplay(inputColor.rgb);
// The pixel's ground in world space, once: every corridor tile tests the
// same value, so exactly one tile of a set holds it.
@@ -150,7 +153,9 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
vec4 origin = atSlot(tFrames, id * ${TEXELS});
vec4 east = atSlot(tFrames, id * ${TEXELS} + 1);
vec4 north = atSlot(tFrames, id * ${TEXELS} + 2);
vec3 up = atSlot(tFrames, id * ${TEXELS} + 3).xyz;
vec4 surfaceFrame = atSlot(tFrames, id * ${TEXELS} + 3);
if ((surfaceFrame.w == 1.0 && onTiles) || (surfaceFrame.w == 2.0 && !onTiles)) continue;
vec3 up = surfaceFrame.xyz;
vec4 span = atSlot(tStyles, id * ${STYLE_TEXELS} + 3);
vec3 delta = point - origin.xyz;
float height = dot(delta, up);
@@ -190,7 +195,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
// A pixel past the bins (beyond an open side) takes the bin nearest
// it, with a margin that reaches it too.
float past = length(max(vec2(0.0), max(place.xy - local, local - open.zw)));
float need = (lineColor.z * pixelMax + past) / (${float(MARGIN_RATIO)} * shape.y);
float need = (lineColor.z * uPixelRatio * pixelMax + past) / (${float(MARGIN_RATIO)} * shape.y);
int level = need <= 1.0 ? 0 : min(int(lineColor.w + 0.5), int(ceil(log2(need))));
int columns = int(shape.z + 0.5);
int rows = int(shape.w + 0.5);
@@ -264,7 +269,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
vec2 centre = vec2(0.0);
vec4 grid = atSlot(tStyles, id * ${STYLE_TEXELS} + 4);
if (grid.x > 0.5 &&
(length(localDx) + length(localDy)) * (shape.y * 0.5 + 1.5) <= grid.z * 0.5) {
(length(localDx) + length(localDy)) * (shape.y * uPixelRatio * 0.5 + 1.5) <= grid.z * 0.5) {
vec2 origin = atSlot(tStyles, id * ${STYLE_TEXELS} + 5).xy;
ivec2 cell = ivec2(floor((local - origin) / grid.z));
// Off the grid: outside the area, beyond any edge's reach.
@@ -317,7 +322,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
if (fillColor.a > 0.0)
color = mix(color, fillColor.rgb, fillColor.a * clamp(inward / pixel + 0.5, 0.0, 1.0));
if (lineColor.a > 0.0 && shape.y > 0.0)
color = mix(color, lineColor.rgb, lineColor.a * clamp(shape.y * 0.5 - nearest / pixel + 0.5, 0.0, 1.0));
color = mix(color, lineColor.rgb, lineColor.a * clamp(shape.y * uPixelRatio * 0.5 - nearest / pixel + 0.5, 0.0, 1.0));
}
outputColor = vec4(toLinear(color), inputColor.a);
}
@@ -401,6 +406,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['tGlobeDepth', new Uniform(null)],
['tFrames', new Uniform(null)],
['tStyles', new Uniform(null)],
['tSegments', new Uniform(null)],
@@ -425,12 +431,15 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
updateGroundSurface(this.effect, camera, this.ctx, mrt);
const input = this.ctx.getInputBuffer();
const { state } = owner;
const cssWidth = this.view.canvas?.clientWidth;
const pixelRatio = cssWidth > 0 ? input.width / cssWidth : 1;
if (
state.update(
camera.matrixWorldInverse.elements,
camera.projectionMatrix.elements,
input.width,
input.height,
pixelRatio,
)
) {
const textures = owner.textures;
@@ -444,10 +453,10 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
textures.candidates.needsUpdate = true;
}
const u = this.effect.uniforms;
u.get('tGlobeDepth').value = mrt.globeDepthBuffer;
u.get('uViewToWorld').value.copy(camera.matrixWorld);
u.get('uTime').value = groundSeconds();
const cssWidth = this.view.canvas?.clientWidth;
u.get('uPixelRatio').value = cssWidth > 0 ? input.width / cssWidth : 1;
u.get('uPixelRatio').value = pixelRatio;
u.get('tFrames').value = owner.textures.frames;
u.get('tStyles').value = owner.textures.styles;
u.get('tSegments').value = owner.textures.segments;
+10 -7
View File
@@ -345,14 +345,13 @@ export function createNavaraImagery(
current.canvas.width = current.canvas.height = 0;
queue.prune();
};
// The source's rectangle within the tiling's latitudes; across the
// antimeridian the atlas covers every longitude.
// The source's rectangle within the tiling's latitudes.
const limit = scheme === 'geographic' ? 90 : WEB_MERCATOR_LIMIT;
const box = () => {
const { west, south, east, north } = source.rectangle;
return {
west: west < east ? west : -180,
east: west < east ? east : 180,
west,
east,
south: Math.max(south, -limit),
north: Math.min(north, limit),
};
@@ -416,7 +415,8 @@ export function createNavaraImagery(
tasks.push({
url: imageryTileUrl(
source.url,
plan.x + x,
(plan.x + x) %
2 ** (plan.z + (scheme === 'geographic' ? 1 : 0)),
plan.y + y,
plan.z,
source.subdomains,
@@ -625,7 +625,7 @@ export function createNavaraImagery(
},
);
const offCamera = camera.changed.addEventListener(() => {
const viewChanged = () => {
let changed = false;
for (const entry of entries.values()) {
changed = entry.drawing.cameraChanged() || changed;
@@ -633,12 +633,15 @@ export function createNavaraImagery(
}
// Raised layers rise with the camera.
if (changed || liftNow() !== lift) compose();
});
};
const offCamera = camera.changed.addEventListener(viewChanged);
view.on?.('resize', viewChanged);
return {
imagery: registry.imagery,
destroy() {
offCamera();
view.off?.('resize', viewChanged);
registry.destroyAll();
pass.destroy();
},
+51 -3
View File
@@ -6,7 +6,7 @@ import { MAX_IMAGERY_SLOTS, createNavaraImagery } from './index.js';
const BOX = { west: -97.77, south: 30.25, east: -97.71, north: 30.29 };
const tick = () => new Promise((resolve) => setImmediate(resolve));
function fixture() {
function fixture(rectangle = null) {
const canvases = [];
const draws = [];
const jobs = [];
@@ -15,7 +15,11 @@ function fixture() {
const changed = createEngineEvent();
let photoreal = false;
let frames = 0;
let height = 800;
const events = new Map();
const view = {
on: (name, run) => events.set(name, run),
off: (name) => events.delete(name),
forceUpdate: () => frames++,
};
const { imagery, destroy } = createNavaraImagery(view, {
@@ -25,9 +29,9 @@ function fixture() {
cartographic: { height: 12_000, latitude: 0.53 },
fovy: 0.7,
}),
viewRectangle: () => null,
viewRectangle: () => rectangle,
},
size: () => ({ width: 1280, height: 800 }),
size: () => ({ width: 1280, height }),
credits: {
addStatic(html) {
credits.set(html, (credits.get(html) ?? 0) + 1);
@@ -52,6 +56,10 @@ function fixture() {
},
});
return {
resize: (next) => {
height = next;
events.get('resize')?.();
},
imagery,
destroy,
canvases,
@@ -300,3 +308,43 @@ test('a raised image is lifted with the camera; its detail is a slot over it', a
assert.equal(f.last().slots.length, 1);
assert.equal(f.last().slots[0].look[3], 0);
});
test('resizing replans a tile atlas without a camera change', async () => {
const f = fixture();
f.imagery.createLayer({ id: 'resize', source: tiles({ maxLevel: 20 }) });
await tick();
const before = f.canvases.length;
f.resize(1600);
await tick();
assert.ok(f.canvases.length > before);
f.destroy();
});
test('an atlas crossing the antimeridian requests valid columns on both sides', async () => {
const r = Math.PI / 180;
const f = fixture({
west: 179.5 * r,
east: -179.5 * r,
south: -17 * r,
north: -16 * r,
});
f.imagery.createLayer({
id: 'seam',
source: tiles({
minLevel: 10,
maxLevel: 10,
rectangle: { west: -180, east: 180, south: -85, north: 85 },
}),
});
let loaded = 0;
for (let i = 0; i < 20; i++) {
await tick();
while (loaded < f.jobs.length) f.jobs[loaded++].resolve({});
}
const columns = f.jobs.map(({ url }) =>
Number(url.match(/\/(\d+)\.png$/)[1]),
);
assert.ok(columns.includes(0) && columns.includes(1023));
assert.ok(columns.every((x) => x >= 0 && x < 1024));
f.destroy();
});
+2 -2
View File
@@ -91,17 +91,17 @@ vec3 paint(vec3 color, sampler2D image, vec4 rect, vec4 bounds, vec4 look, vec4
if ((look.y < 0.0 && x > uSplit) || (look.y > 0.0 && x < uSplit)) return color;
if (!inRect(geo, rect)) return color;
if (look.w > 0.5 && inRect(geo, cut)) return color;
float lon = geo.x < rect.x ? geo.x + 360.0 : geo.x;
vec2 at;
vec2 dx;
vec2 dy;
if (look.z > 0.5) {
vec2 m = vec2(merc.x + (lon - geo.x) / 360.0, merc.y);
vec2 m = vec2(merc.x < bounds.x ? merc.x + 1.0 : merc.x, merc.y);
at = (m - bounds.xy) / bounds.zw;
dx = mercDx / bounds.zw;
dy = mercDy / bounds.zw;
} else {
vec2 span = vec2(bounds.z - bounds.x, bounds.y - bounds.w);
float lon = geo.x < bounds.x ? geo.x + 360.0 : geo.x;
at = vec2(lon - bounds.x, geo.y - bounds.w) / span;
dx = geoDx / span;
dy = geoDy / span;
+26 -23
View File
@@ -6,15 +6,19 @@ import { createExclusivePointer } from '../exclusivePointer.js';
// that key is held (shift over ctrl over alt), one without only while none is.
//
// Camera inputs map to Navara's controller flags (view.camera.options).
// Navara 0.2.1 lets a flyTo run on under user input, where Cesium cancels it;
// `onCameraInput` reports user camera input so the engine can do the same.
// Flights suspend camera inputs, matching Cesium's flight controller.
import { INPUT_TYPES, normalizeInputOptions } from '../contract.js';
import { createPickDelivery } from '../pickDelivery.js';
const CLICK_PIXEL_TOLERANCE = 5; // Cesium's _clickPixelTolerance
const SCREEN_EVENTS = ['pointerdown', 'pointerup', 'pointermove', 'dblclick'];
const CAMERA_EVENTS = ['pointerdown', 'wheel', 'touchstart'];
const SCREEN_EVENTS = [
'pointerdown',
'pointerup',
'pointermove',
'pointercancel',
'dblclick',
];
function modifierOf(event) {
if (event.shiftKey) return 'shift';
@@ -25,8 +29,7 @@ function modifierOf(event) {
/**
* @param {{ canvas: HTMLCanvasElement, camera: { options: object } }} view
* @param {{ onCameraInput?: (type: string) => void,
* picker?: { clickPick: Function, pick: Function, drillPick: Function },
* @param {{ picker?: { clickPick: Function, pick: Function, drillPick: Function },
* scheduleFrame?: (run: () => void) => void }} [options] `picker`
* answers listeners with { pick, drill } (picking.js): clicks from the
* GPU pick of the gesture, moves from the CPU pick once a frame.
@@ -34,11 +37,7 @@ function modifierOf(event) {
*/
export function createNavaraInput(
view,
{
onCameraInput = () => {},
picker = null,
scheduleFrame = (run) => requestAnimationFrame(run),
} = {},
{ picker = null, scheduleFrame = (run) => requestAnimationFrame(run) } = {},
) {
const { canvas } = view;
const listeners = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
@@ -46,6 +45,7 @@ export function createNavaraInput(
let destroyed = false;
let cameraOptions = {};
let downAt = null;
const activePointers = new Set();
let last = null;
const pointer = createExclusivePointer();
@@ -92,23 +92,37 @@ export function createNavaraInput(
const position = positionOf(event);
switch (event.type) {
case 'pointerdown':
// A primary pointer starts a new gesture: pointers released off the
// canvas cannot hold its click back.
if (event.isPrimary === true) activePointers.clear();
activePointers.add(event.pointerId);
if (activePointers.size > 1 || event.isPrimary === false) {
downAt = null;
break;
}
if (event.button !== 0) break;
downAt = position;
downAt = { ...position, pointerId: event.pointerId };
emit('down', event, { position });
break;
case 'pointerup': {
activePointers.delete(event.pointerId);
if (event.button !== 0) break;
emit('up', event, { position });
const start = downAt;
downAt = null;
if (
start &&
start.pointerId === event.pointerId &&
Math.hypot(position.x - start.x, position.y - start.y) <
CLICK_PIXEL_TOLERANCE
)
emit('click', event, { position });
break;
}
case 'pointercancel':
activePointers.delete(event.pointerId);
downAt = null;
break;
case 'pointermove':
emit('move', event, {
startPosition: last ?? position,
@@ -121,15 +135,6 @@ export function createNavaraInput(
}
last = position;
};
const onCameraEvent = (event) => {
onCameraInput(event.type);
};
for (const type of CAMERA_EVENTS)
canvas.addEventListener(type, onCameraEvent, {
capture: true,
passive: true,
});
let suspended = 0;
const sendCameraInputs = () => {
const enabled = suspended === 0;
@@ -199,8 +204,6 @@ export function createNavaraInput(
if (destroyed) return;
pointer.destroy();
destroyed = true;
for (const type of CAMERA_EVENTS)
canvas.removeEventListener(type, onCameraEvent, { capture: true });
if (attached)
for (const name of SCREEN_EVENTS)
canvas.removeEventListener(name, onScreenEvent);
+4 -12
View File
@@ -33,7 +33,6 @@ const pointer = (type, x, y, extra = {}) =>
function setup() {
const canvas = fakeCanvas();
const cameraOptions = [];
const cameraInputs = [];
const view = {
canvas,
camera: {
@@ -42,10 +41,8 @@ function setup() {
},
},
};
const { input, destroy } = createNavaraInput(view, {
onCameraInput: (type) => cameraInputs.push(type),
});
return { canvas, input, destroy, cameraOptions, cameraInputs };
const { input, destroy } = createNavaraInput(view);
return { canvas, input, destroy, cameraOptions };
}
test('down, up and click carry canvas-relative positions', () => {
@@ -119,14 +116,9 @@ test('on validates its arguments and returns an idempotent remover', () => {
assert.equal(count, 0);
});
test('suspending Navara controls preserves user input notifications', () => {
const { canvas, input, cameraOptions, cameraInputs } = setup();
canvas.dispatchEvent(pointer('pointerdown', 1, 1));
canvas.dispatchEvent(new Event('wheel'));
assert.deepEqual(cameraInputs, ['pointerdown', 'wheel']);
test("suspending turns Navara's controls off until the resume", () => {
const { input, cameraOptions } = setup();
const resume = input.suspendCameraInputs();
canvas.dispatchEvent(pointer('pointerdown', 1, 1));
assert.deepEqual(cameraInputs, ['pointerdown', 'wheel', 'pointerdown']);
resume();
assert.deepEqual(cameraOptions, [
{ enableSpin: false, enableZoom: false, enableTilt: false },
+104
View File
@@ -0,0 +1,104 @@
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();
});
+37 -4
View File
@@ -13,9 +13,9 @@
// - photoreal: Google 3D tiles with a browser key only (no server proxy),
// with Google's logo and the tiles' own
// copyright lines (creditLayerId) on screen. Showing them hides the base
// imagery, as MapSourceController removes it; clearing them does not bring
// it back until setBaseImagery. Hidden, not deleted: deleting the last
// raster layer stops Navara 0.2.1 loading terrain at the current view.
// imagery; clearing them restores the last base imagery and its credit.
// Hidden, not deleted: deleting the last raster layer stops Navara 0.2.1
// loading terrain at the current view.
import {
SENTINEL2_CREDIT,
@@ -124,6 +124,8 @@ export function createNavaraMaps(
// call made last decides what shows.
const generations = { display: 0, terrain: 0 };
let destroyed = false;
// maps.terrainRevision: one more for every terrain shown or cleared.
let terrainRevision = 0;
function retire(slot) {
if (!slot) return;
@@ -137,6 +139,7 @@ export function createNavaraMaps(
const slot = slots[name];
slots[name] = null;
retire(slot);
if (name === 'terrain' && slot) terrainRevision++;
}
// The new layer goes in before the old one goes: deleting the last raster
@@ -150,6 +153,7 @@ export function createNavaraMaps(
typeof credit === 'function' ? credit(layer) : credit,
);
retire(previous);
if (name === 'terrain') terrainRevision++;
if (shown.length) view.attribution?.add(shown);
slots[name] = {
key,
@@ -204,6 +208,7 @@ export function createNavaraMaps(
type: 'quantized-mesh',
url: `${endpoint.url}{z}/{x}/{y}.terrain?v=1.2.0`,
token: endpoint.accessToken,
attributions: endpoint.attributions ?? [],
};
}
default:
@@ -249,7 +254,16 @@ export function createNavaraMaps(
return;
}
if (slots.terrain?.key === description.url) return;
show('terrain', description.url, description, { type: 'terrain' });
const { attributions = [], ...sourceDesc } = description;
show(
'terrain',
description.url,
sourceDesc,
{ type: 'terrain' },
attributions.map(({ html }) => ({ attributionHtml: html })),
);
if (attributions.some(({ collapsible }) => collapsible === false))
view.attribution?.show();
},
async setPhotorealTiles(source) {
const generation = ++generations.display;
@@ -329,10 +343,29 @@ export function createNavaraMaps(
};
},
current: () => ({ baseImagery: currentBase, photoreal: tilesSource }),
terrainRevision: () => terrainRevision,
};
return {
maps,
/**
* surface.sampleTerrainMostDetailed: the terrain shown sampled at its
* finest level (Navara fetches the tiles); 0 on the ellipsoid.
*/
async sampleTerrain(positions) {
const source = slots.terrain?.source;
if (!source) return positions.map(() => 0);
const sampled = await view.sampleTerrainMostDetailed(
source,
positions.map((place) => ({
lat: (place.latitude * 180) / Math.PI,
lng: (place.longitude * 180) / Math.PI,
})),
);
return sampled.map(({ height }) =>
Number.isFinite(height) ? height : undefined,
);
},
destroy() {
if (destroyed) return;
destroyed = true;
+46
View File
@@ -392,3 +392,49 @@ test('a tile overlay stays above the base imagery across map switches and credit
remove();
destroy();
});
test('world terrain publishes endpoint credits and removes them on replacement', async () => {
const view = fakeView();
const { maps, destroy } = createNavaraMaps(view, {
fetch: async () => ({
ok: true,
json: async () => ({
url: 'https://terrain.example/',
accessToken: 'temporary',
attributions: [{ html: 'Terrain provider', collapsible: false }],
}),
}),
});
await maps.setTerrain({ kind: 'world', accessToken: 'configured' });
assert.ok(
view.shown.some((credit) => credit.attributionHtml === 'Terrain provider'),
);
assert.ok(view.opened > 0);
await maps.setTerrain({ kind: 'ellipsoid' });
assert.deepEqual(view.shown, []);
destroy();
});
test('terrainRevision counts terrain shown or cleared; sampling reads the shown source', async () => {
const view = fakeView();
const sampled = [];
view.sampleTerrainMostDetailed = async (source, positions) => {
sampled.push({ source, positions });
return positions.map(({ lat, lng }) => ({ lat, lng, height: lat + lng }));
};
const navara = createNavaraMaps(view);
const { maps } = navara;
const place = { longitude: Math.PI / 4, latitude: Math.PI / 6, height: 0 };
assert.deepEqual(await navara.sampleTerrain([place]), [0], 'the ellipsoid');
await maps.setTerrain({ kind: 'keyless' });
assert.equal(maps.terrainRevision(), 1);
const [height] = await navara.sampleTerrain([place]);
assert.ok(Math.abs(height - 75) < 1e-9);
assert.equal(sampled[0].source, layersOf(view, 'terrain')[0].desc.source);
await maps.setTerrain({ kind: 'keyless' });
assert.equal(maps.terrainRevision(), 1, 'the same terrain again');
await maps.setTerrain({ kind: 'ellipsoid' });
assert.equal(maps.terrainRevision(), 2);
await maps.setTerrain({ kind: 'ellipsoid' });
assert.equal(maps.terrainRevision(), 2, 'nothing to clear');
});
+2
View File
@@ -130,6 +130,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
);
return {
ready: mover.ready,
failed: mover.failed,
commit() {
mover.commit(store.dirty());
request();
@@ -168,6 +169,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
});
return {
ready: mover.ready,
failed: mover.failed,
pickRadiusPx: mover.pickRadiusPx,
commit() {
mover.commit(store.dirty());
+6 -2
View File
@@ -199,10 +199,14 @@ export function createModelMover(
};
const group = { meshes: [], pickMeshes: [], ready: null };
let failed = false;
const asset = load(model.url).catch(() => {
// A missing asset keeps the layer's glyph fallback. Do not leak the
// rejected loader promise (or its URL) through descriptor creation.
if (!disposed) console.warn('[globe] Model asset unavailable');
if (!disposed) {
failed = true;
console.warn('[globe] Model asset unavailable');
}
return null;
});
group.ready = asset.then((gltf) => {
@@ -259,7 +263,6 @@ export function createModelMover(
overlay.add(tint);
}
materials.push(material);
if (tint) materials.push(tint.material);
parts.push({ mesh, tint, local: child.matrixWorld.clone() });
group.meshes.push(mesh);
group.pickMeshes.push(tint ?? mesh);
@@ -288,6 +291,7 @@ export function createModelMover(
group,
uniforms,
ready: () => loaded,
failed: () => failed,
origin: () => origin,
/**
* The drawn radius (px) of an instance at its last write, or null when
@@ -204,6 +204,7 @@ test('a failed model load stays not-ready without an unhandled rejection or leak
});
await mover.group.ready;
assert.equal(mover.ready(), false);
assert.equal(mover.failed(), true);
assert.equal(mover.group.meshes.length, 0);
assert.deepEqual(warnings, ['[globe] Model asset unavailable']);
mover.dispose();
@@ -278,12 +279,21 @@ test('display tint shares transforms, preserves fade opacity and leaves no overl
shader.fragmentShader,
/mix\(gevLitSrgb, vGevColor.rgb, uGevBlend\)/,
);
assert.match(shader.vertexShader, /vGevSonarFactor = gevSonarFactor/);
assert.match(shader.fragmentShader, /gevSonarColor\(vec4\(mix/);
assert.equal(base.material.colorWrite, false);
assert.equal(
mover.group.pickMeshes[0],
tint,
'GPU picking keeps its color-writing material',
);
store.set(0, { color: [1, 1, 1, 1] });
mover.commit(store.dirty());
assert.equal(
tint.material.transparent,
true,
'sonar can fade an opaque model',
);
store.setCount(0);
mover.commit(store.dirty());
assert.equal(tint.count, 0);
+7 -5
View File
@@ -2,6 +2,7 @@
// the view's HDR effects. This keeps MSAA from spreading HDR tint into edges.
import { InstancedMesh } from 'three';
import { OVERLAY_DEPTH_FRAGMENT } from '../overlayDepth.js';
import { DETACHED_SONAR, SONAR_VERTEX } from '../sonar.js';
// three's pinned ACESFilmicToneMapping, with the view's public exposure.
const ACES = `
@@ -18,6 +19,7 @@ vec3 gevModelAces(vec3 color) {
export function createModelTint(mesh, uniforms, overlayUniforms) {
const material = mesh.material.clone();
material.transparent = true;
material.depthTest = false;
material.depthWrite = false;
material.toneMapped = false;
@@ -25,22 +27,22 @@ export function createModelTint(mesh, uniforms, overlayUniforms) {
? 'gl_FragColor.rgb'
: 'gevModelAces(gl_FragColor.rgb)';
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms, overlayUniforms, {
Object.assign(shader.uniforms, DETACHED_SONAR, uniforms, overlayUniforms, {
uGevDepthMode: { value: 1 },
});
shader.vertexShader = shader.vertexShader
.replace(
'void main() {',
'attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvoid main() {',
`attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvarying float vGevSonarFactor;\nuniform vec2 uGevResolution;\n${SONAR_VERTEX}\nvoid main() {`,
)
.replace(
'#include <begin_vertex>',
'#include <begin_vertex>\nvGevColor = gevColor;\nvGevDepthFree = 0.0;',
'#include <begin_vertex>\nvGevColor = gevColor;\nvGevDepthFree = 0.0;\nvGevSonarFactor = gevSonarFactor(projectionMatrix * modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0), uGevResolution);',
);
shader.fragmentShader = shader.fragmentShader
.replace(
'void main() {',
`uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`,
`uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\nvarying float vGevSonarFactor;\n${SONAR_VERTEX}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`,
)
.replace(
'#include <logdepthbuf_fragment>',
@@ -56,7 +58,7 @@ export function createModelTint(mesh, uniforms, overlayUniforms) {
)
.replace(
'#include <opaque_fragment>',
`#include <opaque_fragment>\nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\ngl_FragColor.rgb = sRGBTransferEOTF(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 1.0)).rgb;`,
`#include <opaque_fragment>\nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\nvec4 gevContact = gevSonarColor(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), gl_FragColor.a), vGevSonarFactor);\ngl_FragColor = vec4(sRGBTransferEOTF(gevContact).rgb, gevContact.a);`,
);
};
material.customProgramCacheKey = () => 'gev-model-lit-display-color';
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff. Show more