mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Delete dead render code and consolidate QA helpers
Remove code that nothing in the app, the runners or CI uses: - the one-off migration compare, look and bench scripts and the vector ground prototype they measured, with the bench tables in the docs; - the unused Navara-named shared render modules (polygons, ground picks, mover bank, ground ring); - the Cesium-shaped aircraft and vessel test renderers, and the trail, engineFromViewer and horizon-occluder helpers only they kept alive. Their tests now run on the production displays over the fake engine; - Navara's unread diagnostics, fallback ports, dead exports and parameters, stale effect-chain anchors and the unread capability flags. The QA scripts share one keyless guard and readiness wait, the session harness reuses the strict harness's browser flags and block list, and the harness honours QA_ANGLE and QA_RESERVED_PORTS. The bikeshare fixture keeps only what its scenario reads. The docs name the engine picker where it is (VISUAL PRESETS) and drop dated run results. No behaviour changes on either engine. Navara layer renderer lines fall from 189 to 0 and viewer lines from 891 to 849.
This commit is contained in:
1 parent
18f69c2699
commit
371d848eec
130 files changed
+633
-4542
No files matched your search
@@ -1,5 +1,5 @@
|
||||
- Add Navara, an experimental three.js/WASM globe, as a second engine next to
|
||||
Cesium, switchable live under Display → Globe Engine or with
|
||||
Cesium, switchable live under VISUAL PRESETS → Globe Engine or with
|
||||
`?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD
|
||||
(`?engine=navara-hd`) draws at the screen's full resolution and needs about
|
||||
1 GiB more on a Retina screen. Layers keep their data across a switch and
|
||||
|
||||
+9
-23
@@ -154,7 +154,7 @@ implementations.
|
||||
## Navara globe engine — November 5, 2026
|
||||
|
||||
Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs
|
||||
next to Cesium. It is picked under Display → Globe Engine or with
|
||||
next to Cesium. It is picked under VISUAL PRESETS → Globe Engine or with
|
||||
`?engine=navara`; Cesium stays the default. Switching is live: the page keeps
|
||||
one application, and only the globe-bound parts move to the next engine.
|
||||
|
||||
@@ -183,10 +183,7 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
`node scripts/qa-contract-suite.mjs <origin>` runs them in headless Chrome
|
||||
on the real Cesium and Navara of a keyless dev server, with a real pointer
|
||||
and real canvases; checks of state the app drives itself (contact sonar)
|
||||
are skipped and named. On November 7, 2026, with the imagery checks,
|
||||
Cesium passed 68 of 68 and Navara 71 of 71 (the camera-events check now
|
||||
and then misses its moveEnd on a busy machine on either engine and passes
|
||||
when run again). `movers` and `lines` are required of every engine: Cesium
|
||||
are skipped and named. `movers` and `lines` are required of every engine: Cesium
|
||||
draws them with billboards, points, models and polyline primitives
|
||||
(`src/globe/cesium/movers.js`, `lines.js`).
|
||||
- **Fills on the ground** (`engine.ground`) are required of every engine
|
||||
@@ -211,13 +208,8 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
segments (`corridors.js`): 10,000 corridors draw in about 12 ms still
|
||||
and 14 ms moving, where Cesium takes 23 ms (the chunks before took 151
|
||||
and 196 ms).
|
||||
- Navara's vector layers were measured against that pass and lost
|
||||
(`docs/GLOBE-PRIMITIVES.md`).
|
||||
- On November 7, 2026, `scripts/qa-ground-suite.mjs` (the ground checks
|
||||
with pixels, the corridor looks among them) passed 4 of 4 on both
|
||||
engines, and `scripts/qa-ground-looks.mjs` drew the jam line and the
|
||||
route on Cesium as the old materials did, with no pixel changed at four
|
||||
moments of the pulse and the dashes.
|
||||
- `scripts/qa-ground-suite.mjs` checks ground pixels and corridor looks
|
||||
on both engines.
|
||||
- **Ground and input parity.** Space Missions draws its 500 m launch-pad
|
||||
zone with the radial ground material: faint fill, bright soft rim and
|
||||
emission, classified on terrain and tiles. Transit draws its age-banded
|
||||
@@ -257,12 +249,8 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
- Sea Marks and Ski Pistes use `engine.imagery` on engines without
|
||||
`maps.addOverlay`; Navara retains its native raster path for tile detail.
|
||||
Imported KML/KMZ ground images use imagery on both engines.
|
||||
- On November 7, 2026, `scripts/qa-ground-suite.mjs --only=imagery:` (the
|
||||
imagery checks with pixels) passed 2 of 2 on both engines, and
|
||||
`scripts/qa-imagery-compare.mjs` found the heatmap and Recent Imagery
|
||||
drawn as before on Cesium (no pixel changed where they draw) and on
|
||||
Navara (no channel off by more than 1 of 255, but for the heatmap from
|
||||
orbit, which Navara now mipmaps as Cesium does).
|
||||
- `scripts/qa-ground-suite.mjs --only=imagery:` checks imagery pixels
|
||||
on both engines. Navara mipmaps the heatmap at orbit scale.
|
||||
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
|
||||
engine-specific code outside the engines: renderer lines per engine, Cesium
|
||||
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
|
||||
@@ -412,10 +400,8 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
A per-view pool hands released ids back out
|
||||
(`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS
|
||||
objects and native memory stay flat.
|
||||
- **Navara 0.2.1 workaround.** The first `layer.update({ show: false })` after
|
||||
a vector layer is added is sometimes dropped, so a zone turned off right
|
||||
after a switch stayed drawn. Polygon displays hide by deleting their layers
|
||||
and rebuild them when shown (`src/layers/shared/render/navaraPolygons.js`).
|
||||
- **Ground visibility.** Polygon displays show and hide engine ground sets
|
||||
through the screen pass on Navara.
|
||||
|
||||
## Wildlife Sightings — iNaturalist and Acartia
|
||||
|
||||
@@ -2787,7 +2773,7 @@ same hues, from a bright red edge through orange and gold to a pale
|
||||
white-hot core. The edge starts more than half opaque, so regions far below
|
||||
the hottest ones still read over forest and savanna imagery. Nothing is painted where
|
||||
nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery
|
||||
tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt
|
||||
tile on the active imagery host (`src/layers/firms/render/display.js`), rebuilt
|
||||
on each data refresh or filter change (the first far-band paint at once,
|
||||
later ones 150 ms after the last change). Heatmap and markers are both kept
|
||||
built at every zoom and cross-fade by camera height between 3,600 and
|
||||
|
||||
+4
-107
@@ -251,113 +251,10 @@ terrain loads later.
|
||||
|
||||
Picks are CPU picks at the ground under the pointer (`src/globe/ground.js`).
|
||||
|
||||
Navara's vector layers (`src/globe/navara/ground/vector.js`) were measured
|
||||
as the other candidate and kept only as a prototype. The measurements are
|
||||
below.
|
||||
|
||||
### Measurements
|
||||
|
||||
Setup:
|
||||
|
||||
- Dubai, 1280×800, keyless, daylight;
|
||||
- headless Chrome on an Apple M2 Pro (ANGLE Metal);
|
||||
- `scripts/qa-ground-bench.mjs`.
|
||||
|
||||
Frame times are medians, from preUpdate to a 1-pixel readPixels after
|
||||
postRender. "Still" means the camera is at rest; "moving" means it turns
|
||||
every frame. The empty scene takes about 6–9 ms in both cases. Memory is
|
||||
measured after a GC, against the scene before the commit.
|
||||
|
||||
Small polygons (10 vertices, 100–400 m, eight colours, 1 px outline):
|
||||
|
||||
| N | Cesium still / moving | Navara pass still / moving | Navara vector still / moving |
|
||||
| ------ | --------------------- | -------------------------- | ---------------------------- |
|
||||
| 10 | 4.9 / 5.0 ms | 5.7 / 9.5 ms | 5.7 / 24.5 ms |
|
||||
| 1 000 | 5.8 / 5.9 ms | 6.2 / 10.9 ms | 5.9 / 29.0 ms |
|
||||
| 10 000 | 12.0 / 11.2 ms | 10.9 / 15.9 ms | 5.7 / 29.0 ms |
|
||||
|
||||
What each approach costs, apart from frame time:
|
||||
|
||||
| N | Cesium | Navara pass | Navara vector |
|
||||
| ------ | ---------------------------- | ------------------------- | -------------------------------- |
|
||||
| 10 | 0.2 s build, +0.2 MB WebGL | 16 ms build, +0.04 MB GPU | 0.24 s build, about +70 MB WebGL |
|
||||
| 1 000 | 0.23 s build, +19.5 MB WebGL | 68 ms build, +0.45 MB GPU | 0.23 s build, +144 MB WebGL |
|
||||
| 10 000 | 0.8 s build, +40 MB WebGL | 0.26 s build, +4.0 MB GPU | 0.5 s build, +146 MB WebGL |
|
||||
|
||||
The JS heap at 10 000 polygons: Cesium +47 MB, Navara pass +16 MB, Navara
|
||||
vector +19 MB.
|
||||
|
||||
One polygon of N vertices across the view (2 px outline), still:
|
||||
|
||||
| N | Cesium | Navara pass | Navara vector |
|
||||
| ----- | ------ | ----------- | ------------- |
|
||||
| 100 | 6.2 ms | 6.6 ms | 5.3 ms |
|
||||
| 1 000 | 5.9 ms | 7.0 ms | 5.3 ms |
|
||||
| 5 000 | 6.4 ms | 7.3 ms | 6.3 ms |
|
||||
|
||||
Without the bins, the pass took 46 ms at 1 000 vertices and 137 ms at 5 000.
|
||||
|
||||
Corridors (40-point lines, 2.5 px), still / moving:
|
||||
|
||||
| N | Cesium | Navara pass | Navara vector |
|
||||
| ------ | -------------- | -------------- | ------------- |
|
||||
| 10 | 5.8 / 5.8 ms | 5.7 / 9.7 ms | 6.6 / 25.8 ms |
|
||||
| 1 000 | 11.2 / 11.2 ms | 18.6 / 24.4 ms | 6.7 / 28.4 ms |
|
||||
| 10 000 | 21.6 / 21.6 ms | 135 / 174 ms | 6.3 / 28.0 ms |
|
||||
|
||||
At 10 000 corridors Cesium allocated 613 MB of WebGL memory, the pass 44 MB
|
||||
and the vector layers about 70 MB.
|
||||
|
||||
The pass with corridor tiles, measured again with the pass before them on
|
||||
the same day (the machine busier, the empty scene 6.7–8.1 ms):
|
||||
|
||||
| N | Cesium | pass before | pass with tiles |
|
||||
| ------ | -------------- | -------------- | --------------- |
|
||||
| 1 000 | 12.8 / 13.9 ms | 19.8 / 24.9 ms | 8.7 / 10.0 ms |
|
||||
| 10 000 | 23.1 / 23.2 ms | 151 / 196 ms | 11.8 / 13.8 ms |
|
||||
| 15 000 | 31.8 / 31.4 ms | 226 / 289 ms | 17.9 / 19.9 ms |
|
||||
|
||||
The tiles hold about what the chunks held: 43 MB of textures at 10 000
|
||||
corridors (46 MB before), 66 MB at 15 000 (69 MB). Packing takes longer:
|
||||
the commit of 10 000 corridors went from 0.6 s to 1.1 s, and 15 000 from
|
||||
0.8 s to 1.7 s.
|
||||
|
||||
A pixel corridor's width is now measured across the line (the gradient of
|
||||
the distance to it), so it is the same at any angle, as on Cesium. Before,
|
||||
a diagonal line drew up to √2 wider.
|
||||
|
||||
Edge quality over the Hajar mountains, oblique view:
|
||||
|
||||
- **Cesium:** a sharp drape with its outline.
|
||||
- **Navara pass:** the same.
|
||||
- **Navara vector layers:** they are lit and tone-mapped like the scene, so
|
||||
a fill's colour is not the one asked for. They draw under Navara's clouds.
|
||||
A clamped polygon's outline is not drawn at all.
|
||||
|
||||
Show and hide (`scripts/qa-ground-switch.mjs`), before and after a Navara →
|
||||
Cesium → Navara switch:
|
||||
|
||||
- **The pass:** correct every time.
|
||||
- **Vector layers hidden by `layer.update({ show: false })`:** never hidden,
|
||||
before or after a switch.
|
||||
- **Vector layers hidden by deleting them** (today's workaround in
|
||||
`navaraPolygons.js`): correct.
|
||||
|
||||
### Decision
|
||||
|
||||
**Navara draws every kind through the screen pass.** For polygons, discs and
|
||||
cells it is as fast as Cesium up to 10 000 items. It holds a few megabytes
|
||||
instead of a hundred or more. It draws exact colours and outlines, and its
|
||||
show and hide are reliable.
|
||||
|
||||
The vector layers draw thin lines cheaply at rest. They cost about 20 ms
|
||||
more on every frame the camera moves, whatever their count. They draw no
|
||||
ground outlines and no metre widths. Their colours are lit, and hiding them
|
||||
needs a rebuild.
|
||||
|
||||
Corridors were the pass's limit: 10 000 lines took 135 ms. With a
|
||||
set-wide grid of bins (corridor tiles), 10 000 take 12 ms still and 14 ms
|
||||
moving, under Cesium's 23 ms.
|
||||
Navara draws ground shapes through the screen pass. It keeps colours and
|
||||
outlines exact, supports pixel and metre widths, and reliably shows and
|
||||
hides sets. Binned corridor tiles keep draw cost bounded while the camera
|
||||
moves, with compact texture storage.
|
||||
|
||||
## Imagery: rasters on the ground
|
||||
|
||||
|
||||
@@ -30,16 +30,12 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
|
||||
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
|
||||
// Navara renders through three.js and its postprocessing peer.
|
||||
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||
// Navara renderers outside a layer's render/ directory: like those, they
|
||||
// reach the app only through the Navara mode's import().
|
||||
const NAVARA_SCENE_RENDERERS = new Set();
|
||||
// The modules that load Navara renderers, each through import().
|
||||
const NAVARA_RENDERER_LOADERS = new Set([
|
||||
'src/app/navaraMode.js',
|
||||
'src/app/layers/renderers.js',
|
||||
]);
|
||||
const navaraLayerRenderer = (file) =>
|
||||
/\/render\/navara[^/]*\.js$/.test(file) || NAVARA_SCENE_RENDERERS.has(file);
|
||||
const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file);
|
||||
const renderer = (file) =>
|
||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 0,
|
||||
"navaraLayerRendererLines": 189,
|
||||
"navaraLayerRendererLines": 0,
|
||||
"cesiumImportFiles": 3,
|
||||
"viewerLines": 891,
|
||||
"viewerLines": 849,
|
||||
"navaraKindChecks": 1,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 3,
|
||||
@@ -12,5 +12,5 @@
|
||||
"screenSpaceHandlerFiles": 4,
|
||||
"engineNameLines": 32
|
||||
},
|
||||
"measuredAt": "Scope render demand to scene attachments and make the Cesium render backend independent"
|
||||
"measuredAt": "Delete dead render code"
|
||||
}
|
||||
@@ -1,25 +1,7 @@
|
||||
import { cameras as alprCameras, cameraTile } from './alpr.mjs';
|
||||
import { cameras as cctvCameras, cameraFrame } from './cctv.mjs';
|
||||
import { stations as radioStations } from './radio.mjs';
|
||||
import { earthquakePerformanceFixture } from './earthquakes.mjs';
|
||||
|
||||
export const COMBINED_EPOCH = Date.parse('2026-10-28T18:00:00Z');
|
||||
export const COMBINED_ORIGIN = { lat: 42.3554, lon: -71.0605 };
|
||||
const ORIGIN = { lat: 42.3554, lon: -71.0605 };
|
||||
const grid = (i, cols, spacing) => ({
|
||||
lat: COMBINED_ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing,
|
||||
lon: COMBINED_ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
|
||||
});
|
||||
const radio = radioStations.map((r, i) => ({ ...r, ...grid(i, 28, 0.001) }));
|
||||
const cctv = cctvCameras.map((r, i) => ({
|
||||
...r,
|
||||
...grid(i, 5, 0.002),
|
||||
cityId: 'boston',
|
||||
city: 'Boston',
|
||||
groundElevationM: 20,
|
||||
}));
|
||||
const alpr = alprCameras.map((r, i) => {
|
||||
const p = grid(i, 19, 0.001);
|
||||
return { ...r, latitude: p.lat, longitude: p.lon };
|
||||
lat: ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing,
|
||||
lon: ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
|
||||
});
|
||||
const bikes = Array.from({ length: 200 }, (_, i) => ({
|
||||
station_id: `w2-bike-${i}`,
|
||||
@@ -27,154 +9,24 @@ const bikes = Array.from({ length: 200 }, (_, i) => ({
|
||||
capacity: 20,
|
||||
...grid(i, 15, 0.0015),
|
||||
}));
|
||||
const earthquakes = earthquakePerformanceFixture(500);
|
||||
earthquakes.features = earthquakes.features.map((r, i) => {
|
||||
const p = grid(i, 23, 0.002);
|
||||
return {
|
||||
...r,
|
||||
properties: { ...r.properties, time: COMBINED_EPOCH - 3600000 },
|
||||
geometry:
|
||||
i < 5 ? { ...r.geometry, coordinates: [p.lon, p.lat, 5] } : r.geometry,
|
||||
};
|
||||
});
|
||||
const fires = Array.from({ length: 6000 }, (_, i) => ({
|
||||
...grid(i, 78, 0.001),
|
||||
frp: 5 + (i % 120),
|
||||
confidence: ['low', 'nominal', 'high'][i % 3],
|
||||
brightness: 280 + (i % 90),
|
||||
daynight: i % 2 ? 'N' : 'D',
|
||||
acqDate: '2026-10-28',
|
||||
acqTime: '1730',
|
||||
instrument: 'VIIRS',
|
||||
satellite: 'NOAA-20',
|
||||
}));
|
||||
const elements = Array.from({ length: 20 }, (_, i) => {
|
||||
const p = grid(i, 5, 0.006),
|
||||
d = 0.0007;
|
||||
return {
|
||||
type: 'way',
|
||||
id: 980000 + i,
|
||||
center: p,
|
||||
tags: { military: 'airfield', name: `W2 facility ${i}` },
|
||||
bounds: {
|
||||
minlat: p.lat - d,
|
||||
minlon: p.lon - d,
|
||||
maxlat: p.lat + d,
|
||||
maxlon: p.lon + d,
|
||||
},
|
||||
geometry: [
|
||||
[p.lon - d, p.lat - d],
|
||||
[p.lon + d, p.lat - d],
|
||||
[p.lon + d, p.lat + d],
|
||||
[p.lon - d, p.lat + d],
|
||||
[p.lon - d, p.lat - d],
|
||||
].map(([lon, lat]) => ({ lon, lat })),
|
||||
};
|
||||
});
|
||||
export const combinedPayloads = {
|
||||
radio,
|
||||
cctv,
|
||||
alpr,
|
||||
bikes,
|
||||
earthquakes,
|
||||
fires,
|
||||
elements,
|
||||
};
|
||||
export const combinedPayloads = { bikes };
|
||||
|
||||
/** One source transport fixture, shared unchanged by both engines. */
|
||||
export async function installCombinedFixture(
|
||||
page,
|
||||
{ denseQuakes = false } = {},
|
||||
) {
|
||||
const quakePayload = denseQuakes
|
||||
? {
|
||||
...earthquakes,
|
||||
features: earthquakes.features.map((r, i) => {
|
||||
const p = grid(i, 23, 0.002);
|
||||
return {
|
||||
...r,
|
||||
geometry: { ...r.geometry, coordinates: [p.lon, p.lat, 5] },
|
||||
};
|
||||
}),
|
||||
}
|
||||
: earthquakes;
|
||||
const frame = await cameraFrame(),
|
||||
requests = [];
|
||||
/** Bikeshare and startup feed fixtures shared by both engines. */
|
||||
export async function installCombinedFixture(page) {
|
||||
const requests = [];
|
||||
const session = await page.createCDPSession();
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: [
|
||||
...['127.0.0.1', 'localhost'].map((host) => ({
|
||||
urlPattern: `http://${host}:*/api/*`,
|
||||
})),
|
||||
{ urlPattern: 'https://earthquake.usgs.gov/*all_day.geojson' },
|
||||
{ urlPattern: 'https://tiles.dontgetflocked.com/*' },
|
||||
{ urlPattern: '*://*.earthdata.nasa.gov/*' },
|
||||
],
|
||||
patterns: ['127.0.0.1', 'localhost'].map((host) => ({
|
||||
urlPattern: `http://${host}:*/api/*`,
|
||||
})),
|
||||
});
|
||||
let disposed = false;
|
||||
session.on('Fetch.requestPaused', async (event) => {
|
||||
try {
|
||||
const u = new URL(event.request.url);
|
||||
let body,
|
||||
type = 'application/json';
|
||||
let body;
|
||||
const p = u.pathname;
|
||||
if (u.hostname === 'earthquake.usgs.gov') body = quakePayload;
|
||||
else if (u.hostname === 'tiles.dontgetflocked.com') {
|
||||
if (p.endsWith('.json'))
|
||||
body = {
|
||||
tiles: [
|
||||
'https://tiles.dontgetflocked.com/cameras-us-hourly/{z}/{x}/{y}.mvt',
|
||||
],
|
||||
bounds: [-160, 17, -64, 59],
|
||||
};
|
||||
else {
|
||||
const m = p.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
|
||||
body = m
|
||||
? cameraTile(...m.slice(1).map(Number), alpr)
|
||||
: Buffer.alloc(0);
|
||||
type = 'application/x-protobuf';
|
||||
}
|
||||
} else if (u.hostname === 'cmr.earthdata.nasa.gov')
|
||||
body = {
|
||||
hits: 2,
|
||||
items: ['2026-10-28', '2026-10-27'].map((day) => ({
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: `${day}T12:00:00Z` },
|
||||
},
|
||||
AdditionalAttributes: [{ Name: 'CLOUD_COVERAGE', Values: ['0'] }],
|
||||
SpatialExtent: {
|
||||
HorizontalSpatialDomain: {
|
||||
Geometry: {
|
||||
BoundingRectangles: [
|
||||
{
|
||||
WestBoundingCoordinate: -72,
|
||||
EastBoundingCoordinate: -70,
|
||||
SouthBoundingCoordinate: 41,
|
||||
NorthBoundingCoordinate: 43,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
else if (u.hostname.endsWith('.earthdata.nasa.gov')) {
|
||||
body = frame;
|
||||
type = 'image/png';
|
||||
} else if (p === '/api/firms')
|
||||
body = { fires, fetchedAt: COMBINED_EPOCH - 120000, stale: false };
|
||||
else if (p === '/api/radio/stations')
|
||||
body = {
|
||||
stations: radio,
|
||||
updatedAt: new Date(COMBINED_EPOCH).toISOString(),
|
||||
stale: false,
|
||||
degraded: false,
|
||||
acceptedGeneration: 1,
|
||||
catalogInstance: 'w2-combined-fixture',
|
||||
};
|
||||
else if (p.startsWith('/api/gbfs/'))
|
||||
if (p.startsWith('/api/gbfs/'))
|
||||
body = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
@@ -189,40 +41,11 @@ export async function installCombinedFixture(
|
||||
})),
|
||||
},
|
||||
};
|
||||
else if (p === '/api/military-installations')
|
||||
body = {
|
||||
status: 'ready',
|
||||
retrievedAt: new Date(COMBINED_EPOCH).toISOString(),
|
||||
saturated: false,
|
||||
elements,
|
||||
};
|
||||
else if (p === '/api/cctv/sources') body = { sources: cctv };
|
||||
else if (p === '/api/cctv/health')
|
||||
body = {
|
||||
cameras: cctv.map(({ id }) => ({
|
||||
id,
|
||||
status: 'online',
|
||||
sourceKind: 'image',
|
||||
updatedAt: COMBINED_EPOCH,
|
||||
})),
|
||||
};
|
||||
else if (p.startsWith('/api/cctv/frame/')) {
|
||||
body = frame;
|
||||
type = 'image/png';
|
||||
} else if (p.startsWith('/api/transit/'))
|
||||
body = {
|
||||
fetchedAt: COMBINED_EPOCH,
|
||||
vehicles: [],
|
||||
fixes: [],
|
||||
epochs: [],
|
||||
};
|
||||
else if (p === '/api/flights') body = { states: [] };
|
||||
else if (p === '/api/military') body = { ac: [] };
|
||||
else if (p === '/api/vessels') body = { connected: false, vessels: [] };
|
||||
else if (p === '/api/google/nearby-places') body = { places: [] };
|
||||
else if (p === '/api/setup/status') body = { ready: true };
|
||||
else if (p === '/api/openai/hud-summary')
|
||||
body = { summary: 'W2 performance fixture' };
|
||||
else if (p === '/api/terrain/heights')
|
||||
body = {
|
||||
results: (u.searchParams.get('points') || '')
|
||||
@@ -244,15 +67,12 @@ export async function installCombinedFixture(
|
||||
requestId: event.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [
|
||||
{ name: 'Content-Type', value: type },
|
||||
{ name: 'Content-Type', value: 'application/json' },
|
||||
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
|
||||
{ name: 'Data-Present', value: 'true' },
|
||||
],
|
||||
body: (Buffer.isBuffer(body)
|
||||
? body
|
||||
: Buffer.from(JSON.stringify(body))
|
||||
).toString('base64'),
|
||||
body: Buffer.from(JSON.stringify(body)).toString('base64'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// USGS-shaped fixed observations; source parsers remain the production ones.
|
||||
export const W2_EPOCH_MS = 1792152000000;
|
||||
export function earthquakeFixture() {
|
||||
const features = [];
|
||||
for (const [region, lon, lat] of [
|
||||
['sf', -122.42, 37.77],
|
||||
['austin', -97.74, 30.27],
|
||||
['alaska', -150.4, 61.0],
|
||||
]) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: `qa-${region}-${i}`,
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [
|
||||
lon + (i - 2) * 0.24,
|
||||
lat + (i % 2) * 0.3,
|
||||
[8, 70, 300, 301, 45][i],
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
mag: [2.5, 3, 4, 5, 5.5][i],
|
||||
place: `W2 ${region} event ${i}`,
|
||||
time: W2_EPOCH_MS - i * 60000,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: 'qa-below',
|
||||
geometry: { type: 'Point', coordinates: [-122.42, 37.77, 5] },
|
||||
properties: { mag: 2.4 },
|
||||
});
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: 'qa-missing-mag',
|
||||
geometry: { type: 'Point', coordinates: [-122.4, 37.7, 5] },
|
||||
properties: { mag: null },
|
||||
});
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
metadata: { generated: W2_EPOCH_MS },
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dense regional and worldwide USGS-shaped load, separate from the oracle. */
|
||||
export function earthquakePerformanceFixture(count = 500) {
|
||||
let seed = 17;
|
||||
const random = () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: Array.from({ length: count }, (_, i) => ({
|
||||
type: 'Feature',
|
||||
id: `perf-quake-${i}`,
|
||||
properties: {
|
||||
mag: 2.5 + (i % 7) * 0.5,
|
||||
place: `Recorded performance event ${i}`,
|
||||
time: W2_EPOCH_MS - i * 1000,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates:
|
||||
i < Math.min(100, count)
|
||||
? [
|
||||
-122.42 + (random() - 0.5),
|
||||
37.77 + (random() - 0.5),
|
||||
[8, 70, 301][i % 3],
|
||||
]
|
||||
: [
|
||||
-180 + random() * 360,
|
||||
-70 + random() * 140,
|
||||
[8, 70, 301][i % 3],
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1210,7 +1210,6 @@
|
||||
"src/layers/military/renderer.test.mjs",
|
||||
"src/layers/military/policy.test.mjs",
|
||||
"src/testSupport/aircraftRecordingRenderer.mjs",
|
||||
"src/testSupport/aircraftViewerRenderer.mjs",
|
||||
"src/testSupport/cesiumMoversEngine.mjs",
|
||||
"src/testSupport/cockpitEngine.mjs",
|
||||
"src/layers/aircraft/render/contract.js",
|
||||
@@ -1226,7 +1225,6 @@
|
||||
"src/layers/vessels/testFixtures.mjs",
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/globe/navara/ground/effect.js",
|
||||
"src/layers/shared/render/navaraMoverBank.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
@@ -1258,8 +1256,6 @@
|
||||
"src/globe/cesium/createCesiumEngine.test.mjs",
|
||||
"src/globe/cesium/movers.test.mjs",
|
||||
"src/globe/cesium/lines.test.mjs",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/cesium/engineFromViewer.test.mjs",
|
||||
"src/globe/cesium/testing/fakeViewer.mjs",
|
||||
"src/globe/cesium/testing/cameraViewer.mjs",
|
||||
"src/app/globeEngine.js",
|
||||
@@ -1376,7 +1372,6 @@
|
||||
"src/layers/satellites/interaction.test.mjs",
|
||||
"src/layers/launches/lifecycleSnapshot.test.mjs",
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/globe/cesium/trail.js",
|
||||
"src/layers/flights/trackingRefresh.test.mjs",
|
||||
"scripts/qa-heading-navara.mjs",
|
||||
"src/layers/aircraft/qaDrawn.test.mjs",
|
||||
@@ -1389,7 +1384,6 @@
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/layers/localAdsb/qaDrawn.test.mjs",
|
||||
"src/tooling/w1RuntimeEvidence.test.mjs",
|
||||
"scripts/fixtures/w2/earthquakes.mjs",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/firms/rectangle.test.mjs",
|
||||
"src/layers/firms/viewport.test.mjs",
|
||||
@@ -1561,7 +1555,6 @@
|
||||
"src/layers/satellites/render/display.test.mjs",
|
||||
"src/layers/vessels/render/display.js",
|
||||
"src/layers/vessels/render/display.test.mjs",
|
||||
"src/testSupport/vesselViewerRenderer.mjs",
|
||||
"scripts/qa-wildlife-switch.mjs",
|
||||
"scripts/fixtures/wildlife.mjs",
|
||||
"src/layers/cctv/render/display.js",
|
||||
@@ -1588,10 +1581,7 @@
|
||||
"src/globe/navara/ground/pack.js",
|
||||
"src/globe/navara/ground/pack.test.mjs",
|
||||
"src/globe/navara/ground/pass.js",
|
||||
"src/globe/navara/ground/vector.js",
|
||||
"scripts/qa-ground-suite.mjs",
|
||||
"scripts/qa-ground-bench.mjs",
|
||||
"scripts/qa-ground-switch.mjs",
|
||||
"docs/GLOBE-PRIMITIVES.md",
|
||||
"src/layers/airQuality/render/display.js",
|
||||
"src/layers/airQuality/render/display.test.mjs",
|
||||
@@ -1639,8 +1629,6 @@
|
||||
"src/layers/traffic/render/display.test.mjs",
|
||||
"src/layers/directions/edgeCases.test.mjs",
|
||||
"src/layers/streetLevel/edgeCases.test.mjs",
|
||||
"scripts/qa-imagery-compare.mjs",
|
||||
"scripts/qa-weather-compare.mjs",
|
||||
"src/globe/cesium/imagery.js",
|
||||
"src/globe/cesium/imagery.test.mjs",
|
||||
"src/globe/cesium/imageryShell.test.mjs",
|
||||
@@ -1654,9 +1642,8 @@
|
||||
"src/layers/weather/render/display.test.mjs",
|
||||
"src/layers/wind/render/display.test.mjs",
|
||||
"src/layers/bhoteKoshi/render/display.test.mjs",
|
||||
"src/layers/shared/render/navaraGroundOrder.test.mjs",
|
||||
"src/globe/navara/navaraGroundOrder.test.mjs",
|
||||
"src/globe/navara/ground/corridors.js",
|
||||
"scripts/qa-ground-looks.mjs",
|
||||
"src/globe/cesium/windFlow.js",
|
||||
"src/testSupport/imageryDisplayEngines.mjs",
|
||||
"src/data/geoImportDisplay.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"gzipBytes": 2066560,
|
||||
"allowanceBytes": 20666,
|
||||
"measuredAt": "HEAD 47d63128 with the layer displays loaded through import() (src/app/layers/displays.js), keyless build (GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN empty), entry index-DEjtUM5w.js, 6 322 899 bytes, gzip level 9; HEAD 47d63128 itself measured 2 113 641 and the merge of main 92c8f8d9 2 090 274",
|
||||
"measuredAt": "Keyless production build with dynamically loaded layer displays",
|
||||
"allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose"
|
||||
}
|
||||
@@ -1214,7 +1214,6 @@
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/cameraAngles.js",
|
||||
"src/globe/contract.js",
|
||||
"src/geo/",
|
||||
@@ -1377,7 +1376,6 @@
|
||||
"src/ui/stores/sceneInteractions.js",
|
||||
"src/ui/stores/dataPacks.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/contract.js",
|
||||
"src/geo/"
|
||||
],
|
||||
@@ -1466,7 +1464,6 @@
|
||||
"src/geo/",
|
||||
"src/time/clock.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/data/bindContextStore.js"
|
||||
@@ -1536,7 +1533,6 @@
|
||||
"src/geo/",
|
||||
"src/time/clock.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
@@ -1574,8 +1570,7 @@
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/globe/cesium/screenEvents.js",
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
"src/globe/contract.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1622,7 +1617,6 @@
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/bindContextStore.js"
|
||||
],
|
||||
@@ -1797,7 +1791,6 @@
|
||||
"src/geo/",
|
||||
"src/time/clock.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -1848,8 +1841,7 @@
|
||||
"src/layers/installations/classLabel.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
"src/globe/contract.js"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -1903,8 +1895,7 @@
|
||||
"src/ui/unitLabels.js",
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
"src/globe/contract.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1966,7 +1957,6 @@
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/layers/traffic/render/display.js"
|
||||
],
|
||||
"external": ["pbf", "@mapbox/vector-tile"]
|
||||
@@ -2007,7 +1997,6 @@
|
||||
"src/layers/shared/color.js",
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/overlays/bindOverlayHost.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -2652,7 +2641,6 @@
|
||||
"src/data/trafficFlowStyle.js",
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/firstRunExperience.js",
|
||||
@@ -3441,7 +3429,6 @@
|
||||
"src/globe/cesium/surface.js",
|
||||
"src/globe/cesium/movers.js",
|
||||
"src/globe/cesium/lines.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/cameraMath.js",
|
||||
"src/globe/navara/cameraModel.js",
|
||||
"src/globe/navara/createNavaraEngine.js",
|
||||
@@ -3468,7 +3455,6 @@
|
||||
"src/annotations/drawSurface.js",
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/globe/cesium/screenEvents.js",
|
||||
"src/globe/cesium/trail.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
@@ -3520,17 +3506,13 @@
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/globe/cesium/celestial.js",
|
||||
"src/celestialRing/view.js",
|
||||
"src/layers/shared/render/navaraPolygons.js",
|
||||
"src/globe/navara/ground/pulse.js",
|
||||
"src/globe/navara/sceneDepth.js",
|
||||
"src/layers/bhoteKoshi/render/story.js",
|
||||
"src/globe/navara/windFlowMesh.js",
|
||||
"src/layers/shared/render/navaraGroundPicks.js",
|
||||
"src/layers/shared/render/groundRing.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/shared/viewRectangle.js",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/shared/render/navaraMoverBank.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
"src/layers/radio/screenClusters.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
@@ -3805,7 +3787,6 @@
|
||||
"src/data/trafficFlowStyle.js",
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/keylessGeocoder.js",
|
||||
@@ -4259,7 +4240,6 @@
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/hudReadouts.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/cesium/createCesiumEngine.js",
|
||||
"src/globe/cesium/maps.js",
|
||||
"src/globe/cesium/terrain.js",
|
||||
@@ -4284,7 +4264,6 @@
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/globe/cesium/screenEvents.js",
|
||||
"src/globe/cesium/trail.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
@@ -4492,7 +4471,6 @@
|
||||
"src/sources/capability.js",
|
||||
"src/data/cameraArrival.js",
|
||||
"src/data/cameraFraming.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/cesium/credits.js",
|
||||
"src/globe/contract.js",
|
||||
"src/geo/",
|
||||
@@ -4524,7 +4502,6 @@
|
||||
"layer-lifecycle": {
|
||||
"exports": ["./data/lifecycle"],
|
||||
"modules": [
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/contract.js",
|
||||
"src/data/lifecycle.js",
|
||||
"src/data/layerSceneBinding.js",
|
||||
@@ -4552,7 +4529,6 @@
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/i18n/index.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/contract.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -5154,7 +5130,6 @@
|
||||
"src/globe/cesium/terrain.js",
|
||||
"src/globe/cesium/maps.js",
|
||||
"src/globe/cesium/createCesiumEngine.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/cesium/projector.js",
|
||||
"src/globe/cesium/render.js",
|
||||
"src/globe/cesium/credits.js",
|
||||
@@ -5752,7 +5727,6 @@
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/geo/",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/cesium/credits.js",
|
||||
"src/layers/wildlifeSightings/display.js",
|
||||
"src/data/spriteOrder.js",
|
||||
|
||||
@@ -18,6 +18,7 @@ import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js';
|
||||
import {
|
||||
assertPreviewPortAvailable,
|
||||
createStrict,
|
||||
ENGINES,
|
||||
localPreview,
|
||||
@@ -147,6 +148,7 @@ if (flag('serve')) {
|
||||
? resolve(String(flag('scratch')))
|
||||
: tmpdir();
|
||||
mkdirSync(scratchRoot, { recursive: true });
|
||||
await assertPreviewPortAvailable(base.href);
|
||||
const scratch = mkdtempSync(join(scratchRoot, 'gev-acceptance-build-'));
|
||||
const worktree = join(scratch, 'checkout');
|
||||
const dist = join(scratch, 'dist');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localPreview, waitForEngine } from './qa-strict-harness.mjs';
|
||||
// The application is created once per page: switching engines detaches and
|
||||
// attaches only the engine-bound parts. This gate runs three Cesium→Navara→
|
||||
// Cesium round trips and checks that every session object keeps its identity,
|
||||
@@ -14,15 +15,13 @@ import {
|
||||
listenerCounts,
|
||||
listenerDifference,
|
||||
openSessionPage,
|
||||
previewBase,
|
||||
readSession,
|
||||
requestEngine,
|
||||
sessionAddress,
|
||||
switchEngine,
|
||||
waitReady,
|
||||
} from './qa/appSessionHarness.mjs';
|
||||
|
||||
const base = previewBase(process.argv[2]);
|
||||
const base = localPreview(process.argv[2]);
|
||||
const reportOnly = process.argv.includes('--report');
|
||||
const report = { switches: [], failures: [] };
|
||||
const check = (condition, message, detail) => {
|
||||
@@ -139,7 +138,7 @@ try {
|
||||
browser,
|
||||
sessionAddress(base, { engine: 'cesium' }),
|
||||
);
|
||||
await waitReady(page, 'cesium');
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
// Let the first boot's deferred listeners land before counting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const boot = await readSession(page);
|
||||
@@ -335,7 +334,7 @@ try {
|
||||
await page.waitForFunction(() => window.__qaRollback === true, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitReady(page, 'cesium');
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
const rolledBack = await readSession(page);
|
||||
const ui = await readUi(page);
|
||||
report.rollback = {
|
||||
@@ -388,7 +387,7 @@ try {
|
||||
await page.waitForFunction(() => window.__qaDataRefused === true, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitReady(page, 'cesium');
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
const afterDataFailure = await readSession(page);
|
||||
report.dataFailure = {
|
||||
ids: idDifference(boot.ids, afterDataFailure.ids),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile } from 'node:child_process';
|
||||
@@ -55,10 +56,10 @@ await strict.run({}, async (b) => {
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Expected keyless preview');
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await styleManager.initialRestorePromise;
|
||||
await dataManager.setEnabled('cctv', true, { origin: 'user' });
|
||||
window.qaCctv = dataManager.layers.get('cctv').module;
|
||||
|
||||
@@ -90,9 +90,8 @@ await strict.run({}, async (b) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Require keyless preview');
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||
window.qaBefore = [...dataManager.getEnabledLayerIds()].sort();
|
||||
const outcome = await styleManager.setContextMode('contacts');
|
||||
|
||||
@@ -56,9 +56,8 @@ await strict.run({}, async (b) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { tileset, dataManager } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaRoute = dataManager.layers.get('directions').module;
|
||||
await dataManager.setEnabled('directions', true, { origin: 'user' });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
@@ -181,10 +182,10 @@ await strict.run({}, async (b) => {
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async (project) => {
|
||||
const a = window.__godsEyeView;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset)
|
||||
throw Error('Expected keyless preview');
|
||||
|
||||
await a.styleManager.initialRestorePromise;
|
||||
window.director = a.sceneDirector;
|
||||
await window.director.importProjectFile(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
@@ -69,10 +70,9 @@ await strict.run({}, async (browser) => {
|
||||
{ timeout: 90000 },
|
||||
kind,
|
||||
);
|
||||
await assertKeylessPreview(page);
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.styleManager.initialRestorePromise;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset)
|
||||
throw new Error('Keyless preview required');
|
||||
});
|
||||
assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installAlprFixture, cameras } from './fixtures/w2/alpr.mjs';
|
||||
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/w2/firms.mjs';
|
||||
@@ -46,10 +47,10 @@ await strict.run({}, async (b) => {
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async (id) => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await styleManager.initialRestorePromise;
|
||||
window.qaGround = dataManager.layers.get(id).module;
|
||||
await dataManager.setEnabled(id, true, { origin: 'user' });
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Measure engine.ground at Dubai (1280×800, keyless) with N polygons:
|
||||
* Cesium (GroundPrimitive), Navara's screen pass (engine.ground,
|
||||
* src/globe/navara/ground/pass.js) and the Navara vector-layer prototype
|
||||
* (src/globe/navara/ground/vector.js). For each N: the time from commit to
|
||||
* the first item drawn, the frame time with the camera still and turning
|
||||
* (preUpdate to postRender, closed by a 1-pixel readPixels so the GPU work
|
||||
* of the frame is in it), the JS heap after a GC and the WebGL bytes the
|
||||
* page allocated (a tally of bufferData, texImage/texStorage and
|
||||
* renderbuffer storage, less deletions), each against the empty scene; then
|
||||
* one polygon over the Hajar mountains, obliquely, for the edge quality.
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
|
||||
* node scripts/qa-ground-bench.mjs http://localhost:4271 [--counts=10,1000,10000]
|
||||
* [--paths=cesium,navara-pass,navara-vector] [--out=DIR]
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
const args = process.argv.slice(2);
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
if (!origin) {
|
||||
console.error('Usage: node scripts/qa-ground-bench.mjs <origin> [options]');
|
||||
process.exit(2);
|
||||
}
|
||||
const counts = (option('counts') ?? '10,1000,10000').split(',').map(Number);
|
||||
// polygons: N small polygons; corridors: N 40-point lines across the view,
|
||||
// 2.5 px; outline: one polygon of N vertices round the view.
|
||||
const shape = option('shape') ?? 'polygons';
|
||||
const paths = (option('paths') ?? 'cesium,navara-pass,navara-vector').split(
|
||||
',',
|
||||
);
|
||||
const out = option('out') ?? null;
|
||||
if (out) fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
// The page's WebGL allocations, before any script of the app runs.
|
||||
function installGpuTally() {
|
||||
const proto = WebGL2RenderingContext.prototype;
|
||||
const sizes = new Map();
|
||||
let total = 0;
|
||||
const boundBuffers = new WeakMap();
|
||||
const boundTextures = new WeakMap();
|
||||
const boundRenderbuffers = new WeakMap();
|
||||
const set = (key, bytes) => {
|
||||
total += bytes - (sizes.get(key) ?? 0);
|
||||
sizes.set(key, bytes);
|
||||
};
|
||||
const drop = (key) => {
|
||||
total -= sizes.get(key) ?? 0;
|
||||
sizes.delete(key);
|
||||
};
|
||||
const per = (map, gl) => {
|
||||
if (!map.has(gl)) map.set(gl, new Map());
|
||||
return map.get(gl);
|
||||
};
|
||||
const FLOAT = 0x1406;
|
||||
const HALF_FLOAT = 0x140b;
|
||||
const internalBytes = {
|
||||
0x8814: 16, // RGBA32F
|
||||
0x881a: 8, // RGBA16F
|
||||
0x8815: 12, // RGB32F
|
||||
0x822e: 4, // R32F
|
||||
0x8230: 8, // RG32F
|
||||
0x8058: 4, // RGBA8
|
||||
0x8c43: 4, // SRGB8_ALPHA8
|
||||
0x88f0: 4, // DEPTH24_STENCIL8
|
||||
0x8cad: 8, // DEPTH32F_STENCIL8
|
||||
0x8cac: 4, // DEPTH_COMPONENT32F
|
||||
0x81a6: 4, // DEPTH_COMPONENT24
|
||||
0x81a5: 2, // DEPTH_COMPONENT16
|
||||
0x8229: 1, // R8
|
||||
0x822b: 2, // RG8
|
||||
};
|
||||
const texelBytes = (internal, type) =>
|
||||
internalBytes[internal] ??
|
||||
(type === FLOAT ? 16 : type === HALF_FLOAT ? 8 : 4);
|
||||
const wrap = (name, after) => {
|
||||
const original = proto[name];
|
||||
proto[name] = function (...call) {
|
||||
const result = original.apply(this, call);
|
||||
try {
|
||||
after.call(this, call, result);
|
||||
} catch {
|
||||
// A tally error never breaks the page.
|
||||
}
|
||||
return result;
|
||||
};
|
||||
};
|
||||
wrap('bindBuffer', function ([target, buffer]) {
|
||||
per(boundBuffers, this).set(target, buffer);
|
||||
});
|
||||
wrap('bufferData', function ([target, data]) {
|
||||
const buffer = per(boundBuffers, this).get(target);
|
||||
if (buffer)
|
||||
set(buffer, typeof data === 'number' ? data : (data?.byteLength ?? 0));
|
||||
});
|
||||
wrap('deleteBuffer', ([buffer]) => drop(buffer));
|
||||
wrap('bindTexture', function ([target, texture]) {
|
||||
per(boundTextures, this).set(target, texture);
|
||||
});
|
||||
wrap('texImage2D', function (call) {
|
||||
const texture = per(boundTextures, this).get(call[0]);
|
||||
if (!texture || call[1] !== 0) return;
|
||||
let bytes;
|
||||
if (call.length >= 8 && typeof call[3] === 'number')
|
||||
bytes = call[3] * call[4] * texelBytes(call[2], call[7]);
|
||||
else {
|
||||
const source = call[5];
|
||||
bytes = (source?.width ?? 0) * (source?.height ?? 0) * 4;
|
||||
}
|
||||
set(texture, bytes);
|
||||
});
|
||||
wrap('texStorage2D', function ([target, levels, internal, width, height]) {
|
||||
const texture = per(boundTextures, this).get(target);
|
||||
if (texture)
|
||||
set(
|
||||
texture,
|
||||
width * height * texelBytes(internal) * (levels > 1 ? 4 / 3 : 1),
|
||||
);
|
||||
});
|
||||
wrap('texImage3D', function (call) {
|
||||
const texture = per(boundTextures, this).get(call[0]);
|
||||
if (texture && call[1] === 0)
|
||||
set(texture, call[3] * call[4] * call[5] * texelBytes(call[2], call[8]));
|
||||
});
|
||||
wrap('texStorage3D', function ([target, , internal, width, height, depth]) {
|
||||
const texture = per(boundTextures, this).get(target);
|
||||
if (texture) set(texture, width * height * depth * texelBytes(internal));
|
||||
});
|
||||
wrap('deleteTexture', ([texture]) => drop(texture));
|
||||
wrap('bindRenderbuffer', function ([, renderbuffer]) {
|
||||
boundRenderbuffers.set(this, renderbuffer);
|
||||
});
|
||||
wrap('renderbufferStorage', function ([, internal, width, height]) {
|
||||
const renderbuffer = boundRenderbuffers.get(this);
|
||||
if (renderbuffer) set(renderbuffer, width * height * texelBytes(internal));
|
||||
});
|
||||
wrap(
|
||||
'renderbufferStorageMultisample',
|
||||
function ([, samples, internal, width, height]) {
|
||||
const renderbuffer = boundRenderbuffers.get(this);
|
||||
if (renderbuffer)
|
||||
set(
|
||||
renderbuffer,
|
||||
width * height * texelBytes(internal) * Math.max(1, samples),
|
||||
);
|
||||
},
|
||||
);
|
||||
wrap('deleteRenderbuffer', ([renderbuffer]) => drop(renderbuffer));
|
||||
window.__gpuBytes = () => total;
|
||||
}
|
||||
|
||||
const DUBAI = { lon: 55.27, lat: 25.15, alt: 70000 };
|
||||
|
||||
async function openEngine(browser, kind) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('pageerror', (error) => console.log(' page error:', error.message));
|
||||
await page.evaluateOnNewDocument(installGpuTally);
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${DUBAI.lat}&lon=${DUBAI.lon}&alt=${DUBAI.alt}&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
// Daylight in Dubai (Navara lights the scene with the app clock's sun).
|
||||
await page.evaluate(async () => {
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.UTC(2026, 10, 7, 8, 0));
|
||||
window.__godsEyeView.engine.render.request('ground-bench-daylight');
|
||||
});
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('HeapProfiler.enable');
|
||||
// Helpers in the page: polygons, the approach under test, timing.
|
||||
await page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const random = (seed) => () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 2 ** 32;
|
||||
};
|
||||
const PALETTE = [
|
||||
[1, 0.2, 0.2],
|
||||
[1, 0.6, 0],
|
||||
[1, 1, 0.2],
|
||||
[0.2, 0.9, 0.3],
|
||||
[0, 0.8, 1],
|
||||
[0.3, 0.4, 1],
|
||||
[0.7, 0.3, 1],
|
||||
[0.9, 0.9, 0.9],
|
||||
];
|
||||
window.__bench = {
|
||||
engine,
|
||||
// N polygons over Dubai (deterministic): 10 vertices, 100–400 m;
|
||||
// item 0 a 3 km magenta polygon at the view centre.
|
||||
polygons(count) {
|
||||
const next = random(7);
|
||||
const items = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const centre =
|
||||
index === 0
|
||||
? [55.27, 25.15]
|
||||
: [54.95 + next() * 0.65, 24.95 + next() * 0.4];
|
||||
const radius = index === 0 ? 3000 : 100 + next() * 300;
|
||||
const ring = [];
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const angle = (k / 10) * 2 * Math.PI;
|
||||
const r = radius * (0.75 + 0.25 * (index === 0 ? 1 : next()));
|
||||
ring.push([
|
||||
centre[0] +
|
||||
(r * Math.cos(angle)) /
|
||||
(111320 * Math.cos((centre[1] * Math.PI) / 180)),
|
||||
centre[1] + (r * Math.sin(angle)) / 110540,
|
||||
]);
|
||||
}
|
||||
items.push({
|
||||
rings: [ring],
|
||||
// Eight colours, as the layers use a handful (depth bands,
|
||||
// levels); the vector prototype makes one layer per style.
|
||||
fill:
|
||||
index === 0
|
||||
? [1, 0, 1, 0.9]
|
||||
: [...PALETTE[Math.floor(next() * PALETTE.length)], 0.35],
|
||||
outline: { color: [1, 1, 1, 0.9], widthPx: 1 },
|
||||
pickId: index,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
// N - 1 lines, then the magenta mark on top of them.
|
||||
corridors(count) {
|
||||
const next = random(11);
|
||||
const items = [];
|
||||
for (let index = 1; index < count; index++) {
|
||||
let lon = 54.95 + next() * 0.65;
|
||||
let lat = 24.95 + next() * 0.4;
|
||||
const heading = next() * 2 * Math.PI;
|
||||
const positions = [];
|
||||
for (let k = 0; k < 40; k++) {
|
||||
positions.push([lon, lat]);
|
||||
const turn = heading + (next() - 0.5) * 1.2;
|
||||
lon += 0.004 * Math.cos(turn);
|
||||
lat += 0.004 * Math.sin(turn);
|
||||
}
|
||||
items.push({
|
||||
positions,
|
||||
color: [...PALETTE[Math.floor(next() * PALETTE.length)], 0.9],
|
||||
widthPx: 2.5,
|
||||
pickId: index,
|
||||
});
|
||||
}
|
||||
items.push(window.__bench.centreDisc());
|
||||
return items;
|
||||
},
|
||||
// The magenta mark of the corridors scenario: a 40 px corridor.
|
||||
centreDisc: () => ({
|
||||
positions: [
|
||||
[55.255, 25.15],
|
||||
[55.285, 25.15],
|
||||
],
|
||||
color: [1, 0, 1, 0.9],
|
||||
widthPx: 40,
|
||||
pickId: 0,
|
||||
}),
|
||||
outline(count) {
|
||||
const ring = [];
|
||||
for (let k = 0; k < count; k++) {
|
||||
const angle = (k / count) * 2 * Math.PI;
|
||||
const wobble = 1 + 0.03 * Math.sin(angle * 37);
|
||||
ring.push([
|
||||
55.27 + 0.28 * wobble * Math.cos(angle),
|
||||
25.15 + 0.2 * wobble * Math.sin(angle),
|
||||
]);
|
||||
}
|
||||
return [
|
||||
{
|
||||
rings: [ring],
|
||||
fill: [1, 0, 1, 0.5],
|
||||
outline: { color: [1, 1, 1, 1], widthPx: 2 },
|
||||
pickId: 0,
|
||||
},
|
||||
];
|
||||
},
|
||||
async ground(kind) {
|
||||
if (kind !== 'navara-vector') return engine.ground;
|
||||
const { createNavaraVectorGround } =
|
||||
await import('/src/globe/navara/ground/vector.js');
|
||||
window.__bench.vector ??= createNavaraVectorGround(
|
||||
engine.extensions.navara.view,
|
||||
);
|
||||
return window.__bench.vector.ground;
|
||||
},
|
||||
frame: () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('ground-bench');
|
||||
}),
|
||||
// Frame times in ms (preUpdate → postRender + 1 px readPixels).
|
||||
async frameTimes(frames, turn = false) {
|
||||
const gl = engine.host.canvas.getContext('webgl2');
|
||||
const pixel = new Uint8Array(4);
|
||||
const times = [];
|
||||
let start = 0;
|
||||
const removeStart = engine.frames.preUpdate.addEventListener(() => {
|
||||
start = performance.now();
|
||||
});
|
||||
const removeEnd = engine.frames.postRender.addEventListener(() => {
|
||||
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
|
||||
times.push(performance.now() - start);
|
||||
});
|
||||
const pose = engine.camera.pose();
|
||||
try {
|
||||
for (let index = 0; index < frames; index++) {
|
||||
if (turn)
|
||||
engine.camera.setView({
|
||||
destination: pose.position,
|
||||
orientation: {
|
||||
heading: pose.heading + (index % 2 ? 0.002 : -0.002),
|
||||
pitch: pose.pitch,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
await window.__bench.frame();
|
||||
}
|
||||
} finally {
|
||||
removeStart();
|
||||
removeEnd();
|
||||
engine.camera.setView({
|
||||
destination: pose.position,
|
||||
orientation: { heading: pose.heading, pitch: pose.pitch, roll: 0 },
|
||||
});
|
||||
}
|
||||
times.sort((a, b) => a - b);
|
||||
const mean = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
return {
|
||||
mean,
|
||||
median: times[Math.floor(times.length / 2)],
|
||||
p95: times[Math.floor(times.length * 0.95)],
|
||||
};
|
||||
},
|
||||
// The centre pixel shows the magenta item.
|
||||
centreMagenta() {
|
||||
const canvas = engine.host.canvas;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 4;
|
||||
c.height = 4;
|
||||
const context = c.getContext('2d');
|
||||
context.drawImage(
|
||||
canvas,
|
||||
canvas.width / 2 - 2,
|
||||
canvas.height / 2 - 2,
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
);
|
||||
const [r, g, b] = context.getImageData(1, 1, 1, 1).data;
|
||||
// Lit (the vector layers) or exact (the pass, Cesium).
|
||||
return r > 150 && b > 150 && g < Math.min(r, b) - 60;
|
||||
},
|
||||
};
|
||||
});
|
||||
return { page, cdp };
|
||||
}
|
||||
|
||||
async function settleTiles(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { engine, frame } = window.__bench;
|
||||
const started = performance.now();
|
||||
let loadedFrames = 0;
|
||||
while (performance.now() - started < 60000 && loadedFrames < 30) {
|
||||
await frame();
|
||||
loadedFrames = engine.surface.tilesLoaded() ? loadedFrames + 1 : 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function memory(page, cdp) {
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
return page.evaluate(() => ({
|
||||
js: performance.memory.usedJSHeapSize,
|
||||
gpu: window.__gpuBytes(),
|
||||
navara:
|
||||
window.__bench.engine.extensions?.navara?.view?.memoryStats?.()
|
||||
?.gpuBytesEst ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
const mb = (bytes) => (bytes / 2 ** 20).toFixed(2);
|
||||
const rows = [];
|
||||
|
||||
async function measure(browser, kind) {
|
||||
const engineKind = kind === 'cesium' ? 'cesium' : 'navara';
|
||||
const { page, cdp } = await openEngine(browser, engineKind);
|
||||
const renderer = await page.evaluate(() => {
|
||||
const gl = window.__bench.engine.host.canvas.getContext('webgl2');
|
||||
const info = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : 'unknown';
|
||||
});
|
||||
console.log(`${kind}: ${renderer}`);
|
||||
await settleTiles(page);
|
||||
// Warm the approach (shader compile) with one item, then measure empty.
|
||||
await page.evaluate(async (kind) => {
|
||||
const ground = await window.__bench.ground(kind);
|
||||
const set = ground.createSet({ id: 'bench-warm', kind: 'polygon' });
|
||||
set.replace(window.__bench.polygons(1));
|
||||
set.commit();
|
||||
const started = performance.now();
|
||||
while (
|
||||
!window.__bench.centreMagenta() &&
|
||||
performance.now() - started < 30000
|
||||
)
|
||||
await window.__bench.frame();
|
||||
set.destroy();
|
||||
for (let index = 0; index < 10; index++) await window.__bench.frame();
|
||||
}, kind);
|
||||
const emptyFrames = {
|
||||
still: await page.evaluate(() => window.__bench.frameTimes(90)),
|
||||
turning: await page.evaluate(() => window.__bench.frameTimes(90, true)),
|
||||
};
|
||||
rows.push({ path: kind, shape, count: 0, ...emptyFrames });
|
||||
for (const count of counts) {
|
||||
const before = await memory(page, cdp);
|
||||
const built = await page.evaluate(
|
||||
async (kind, count, shape) => {
|
||||
const ground = await window.__bench.ground(kind);
|
||||
const items =
|
||||
shape === 'corridors'
|
||||
? window.__bench.corridors(count)
|
||||
: shape === 'outline'
|
||||
? window.__bench.outline(count)
|
||||
: window.__bench.polygons(count);
|
||||
const set = ground.createSet({
|
||||
id: 'bench',
|
||||
kind: shape === 'corridors' ? 'corridor' : 'polygon',
|
||||
});
|
||||
const started = performance.now();
|
||||
set.replace(items);
|
||||
set.commit();
|
||||
const committed = performance.now() - started;
|
||||
while (
|
||||
!window.__bench.centreMagenta() &&
|
||||
performance.now() - started < 120000
|
||||
)
|
||||
await window.__bench.frame();
|
||||
window.__bench.set = set;
|
||||
const drawnMs = performance.now() - started;
|
||||
for (let index = 0; index < 10; index++) await window.__bench.frame();
|
||||
return { commitMs: committed, drawnMs };
|
||||
},
|
||||
kind,
|
||||
count,
|
||||
shape,
|
||||
);
|
||||
// Right after the build, before tiles stream again.
|
||||
const loaded = await memory(page, cdp);
|
||||
const passBytes = await page.evaluate(
|
||||
() => window.__bench.engine.groundDiagnostics?.()?.textureBytes ?? null,
|
||||
);
|
||||
await settleTiles(page);
|
||||
const frames = {
|
||||
still: await page.evaluate(() => window.__bench.frameTimes(90)),
|
||||
turning: await page.evaluate(() => window.__bench.frameTimes(90, true)),
|
||||
};
|
||||
if (out)
|
||||
await page.screenshot({
|
||||
path: path.join(out, `dubai-${shape}-${kind}-${count}.png`),
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
window.__bench.set.destroy();
|
||||
for (let index = 0; index < 20; index++) await window.__bench.frame();
|
||||
});
|
||||
const released = await memory(page, cdp);
|
||||
const row = {
|
||||
path: kind,
|
||||
shape,
|
||||
count,
|
||||
...built,
|
||||
...frames,
|
||||
jsMB: mb(loaded.js - before.js),
|
||||
gpuMB: mb(loaded.gpu - before.gpu),
|
||||
navaraGpuMB:
|
||||
loaded.navara == null ? null : mb(loaded.navara - before.navara),
|
||||
passTexturesMB: passBytes == null ? null : mb(passBytes),
|
||||
jsAfterDestroyMB: mb(released.js - before.js),
|
||||
gpuAfterDestroyMB: mb(released.gpu - before.gpu),
|
||||
};
|
||||
rows.push(row);
|
||||
console.log(
|
||||
` N=${count}: drawn ${row.drawnMs.toFixed(0)} ms (commit ${row.commitMs.toFixed(0)}), frame still ${frames.still.median.toFixed(2)} ms (empty ${emptyFrames.still.median.toFixed(2)}), turning ${frames.turning.median.toFixed(2)} ms (empty ${emptyFrames.turning.median.toFixed(2)}), JS +${row.jsMB} MB, WebGL +${row.gpuMB} MB${row.navaraGpuMB == null ? '' : `, Navara gpuBytesEst +${row.navaraGpuMB} MB`}${row.passTexturesMB == null || kind !== 'navara-pass' ? '' : `, pass textures ${row.passTexturesMB} MB`}; after destroy JS +${row.jsAfterDestroyMB}, WebGL +${row.gpuAfterDestroyMB} MB`,
|
||||
);
|
||||
}
|
||||
// Edge quality over terrain: one polygon on the Hajar mountains, oblique.
|
||||
if (out && shape === 'polygons') {
|
||||
await page.evaluate(async (kind) => {
|
||||
const { engine, frame } = window.__bench;
|
||||
const ground = await window.__bench.ground(kind);
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(56.05, 24.98, 9000),
|
||||
orientation: { heading: 0.5, pitch: -0.45, roll: 0 },
|
||||
});
|
||||
const set = ground.createSet({ id: 'bench-terrain', kind: 'polygon' });
|
||||
const ring = [];
|
||||
for (let k = 0; k < 24; k++) {
|
||||
const angle = (k / 24) * 2 * Math.PI;
|
||||
ring.push([
|
||||
56.12 + 0.03 * Math.cos(angle),
|
||||
25.07 + 0.022 * Math.sin(angle),
|
||||
]);
|
||||
}
|
||||
set.replace([
|
||||
{
|
||||
rings: [ring],
|
||||
fill: [1, 0.3, 0, 0.45],
|
||||
outline: { color: [1, 1, 0, 1], widthPx: 2 },
|
||||
},
|
||||
]);
|
||||
set.commit();
|
||||
for (let index = 0; index < 20; index++) await frame();
|
||||
}, kind);
|
||||
await settleTiles(page);
|
||||
await page.screenshot({ path: path.join(out, `terrain-${kind}.png`) });
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
try {
|
||||
for (const kind of paths) await measure(browser, kind);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (out)
|
||||
fs.writeFileSync(
|
||||
path.join(out, `bench-${shape}.json`),
|
||||
JSON.stringify(rows, null, 2),
|
||||
);
|
||||
@@ -218,8 +218,7 @@ await strict.run({}, async (b) => {
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(() => {
|
||||
const d = window.__godsEyeView;
|
||||
if (d.tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless required');
|
||||
|
||||
window.qaAir = d.dataManager.layers.get('air-quality').module;
|
||||
window.qaGnss = d.dataManager.layers.get('gnss-integrity').module;
|
||||
});
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compare the animated ground corridors with the Cesium drawing they
|
||||
* replace, on the Cesium engine (keyless, headless): Street Traffic's jam
|
||||
* heat lines (PolylineGlow, glowPower 0.25, alpha 0.55 ± 0.2 on
|
||||
* sin(now / 260 ms)) and the Directions route (the annotation route's
|
||||
* GevRouteFlow material, 9 px). One road and one route over Dubai, nadir,
|
||||
* drawn at fixed page times (performance.now() frozen), so every variant
|
||||
* shows the same phase of the pulse and the dashes:
|
||||
*
|
||||
* --variant=old the old drawing, rebuilt with Cesium directly: a
|
||||
* GroundPolylinePrimitive with PolylineGlow, its alpha
|
||||
* set from the frozen time, and the Directions route
|
||||
* entity with FlowMaterialProperty;
|
||||
* --variant=engine engine.ground: the jam as the traffic layer gives it
|
||||
* (glow and pulse when the build has them), the route
|
||||
* through the Directions display.
|
||||
*
|
||||
* Both drape on terrain and tiles ('both'): keyless there are no 3D tiles,
|
||||
* where the traffic layer drapes its heat lines.
|
||||
*
|
||||
* `--engine=navara` draws the engine variant on Navara (daylight app clock;
|
||||
* its frames at the live page time, 400 ms apart: Navara's frame loop stops
|
||||
* on a frozen one).
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
|
||||
* node scripts/qa-ground-looks.mjs http://localhost:4271 --variant=old --out=DIR
|
||||
* node scripts/qa-ground-looks.mjs --compare=DIR_A,DIR_B
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
// Page times (ms) the frames are drawn at: across one pulse (1.63 s) and
|
||||
// a few dash cells.
|
||||
const TIMES = [100_000, 100_400, 100_800, 101_200];
|
||||
const CROP = { left: 340, top: 200, width: 600, height: 400 };
|
||||
// Metres over the ground: the road and the route fill the crop.
|
||||
const ALTITUDE = 6000;
|
||||
|
||||
async function compare([a, b]) {
|
||||
const rows = [];
|
||||
for (const name of fs
|
||||
.readdirSync(a)
|
||||
.filter((file) => file.endsWith('.png'))) {
|
||||
if (!fs.existsSync(path.join(b, name))) continue;
|
||||
const read = (dir) =>
|
||||
sharp(path.join(dir, name))
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const [left, right] = await Promise.all([read(a), read(b)]);
|
||||
let sum = 0;
|
||||
let over = 0;
|
||||
const pixels = left.info.width * left.info.height;
|
||||
for (let i = 0; i < left.data.length; i += 3) {
|
||||
let most = 0;
|
||||
for (let k = 0; k < 3; k++)
|
||||
most = Math.max(most, Math.abs(left.data[i + k] - right.data[i + k]));
|
||||
sum += most;
|
||||
if (most > 24) over++;
|
||||
}
|
||||
rows.push({
|
||||
image: name,
|
||||
meanDiff: +(sum / pixels).toFixed(2),
|
||||
over24: +(over / pixels).toFixed(4),
|
||||
});
|
||||
}
|
||||
console.table(rows);
|
||||
}
|
||||
|
||||
async function draw(origin, variant, out, engineKind) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 600_000,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('pageerror', (error) => console.log('page error:', error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${engineKind}#v=2&lat=25.2&lon=55.3&alt=${ALTITUDE}&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(kind) =>
|
||||
window.__godsEyeView?.engine?.kind === kind &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
engineKind,
|
||||
);
|
||||
await page.evaluate(
|
||||
async (variant, altitude) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.UTC(2026, 10, 7, 8, 0));
|
||||
const frame = () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-ground-looks');
|
||||
});
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(55.3, 25.2, altitude),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
// Tiles in.
|
||||
const started = performance.now();
|
||||
let loaded = 0;
|
||||
while (performance.now() - started < 60_000 && loaded < 30) {
|
||||
await frame();
|
||||
loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0;
|
||||
}
|
||||
// A jam road across the view, and a route with turns below it.
|
||||
const road = [
|
||||
[55.288, 25.204],
|
||||
[55.296, 25.206],
|
||||
[55.304, 25.203],
|
||||
[55.312, 25.205],
|
||||
];
|
||||
const route = [
|
||||
[55.286, 25.196],
|
||||
[55.294, 25.197],
|
||||
[55.297, 25.192],
|
||||
[55.306, 25.193],
|
||||
[55.314, 25.196],
|
||||
];
|
||||
const policy = await import('/src/layers/traffic/policy.js');
|
||||
const jamRgba = policy.HEAT_JAM_COLOR.rgba();
|
||||
if (variant === 'old') {
|
||||
// The app's own Cesium module: the URL the dev server gave the
|
||||
// engine's import of it.
|
||||
const served = await (
|
||||
await fetch('/src/globe/cesium/ground.js')
|
||||
).text();
|
||||
const Cesium = await import(
|
||||
served.match(/from\s+["']([^"']*cesium[^"']*)["']/)[1]
|
||||
);
|
||||
const { FlowMaterialProperty, ensureFlowFabricRegistered } =
|
||||
await import('/src/globe/cesium/annotations/worldAnnotationRenderer.js');
|
||||
const { viewer } = engine.extensions.cesium;
|
||||
const color = new Cesium.Color(...jamRgba.slice(0, 3), 0.55);
|
||||
const jam = viewer.scene.groundPrimitives.add(
|
||||
new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: [
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.GroundPolylineGeometry({
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(road.flat()),
|
||||
width: 9,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
appearance: new Cesium.PolylineMaterialAppearance({
|
||||
material: Cesium.Material.fromType('PolylineGlow', {
|
||||
color,
|
||||
glowPower: 0.25,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
ensureFlowFabricRegistered();
|
||||
viewer.entities.add({
|
||||
id: 'qa-route',
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(route.flat()),
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty('#39d0ff'),
|
||||
clampToGround: true,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
// The traffic animation set the jam's alpha every frame.
|
||||
engine.frames.preRender.addEventListener(() => {
|
||||
jam.appearance.material.uniforms.color.alpha =
|
||||
policy.HEAT_JAM_BASE_ALPHA +
|
||||
policy.HEAT_JAM_PULSE_ALPHA * Math.sin(performance.now() / 260);
|
||||
});
|
||||
window.__qaReady = () => jam.ready;
|
||||
} else {
|
||||
const { createDirectionsDisplay } =
|
||||
await import('/src/layers/directions/render/display.js');
|
||||
const animated = policy.HEAT_JAM_GLOW_POWER !== undefined;
|
||||
const set = engine.ground.createSet({
|
||||
id: 'qa-jam',
|
||||
kind: 'corridor',
|
||||
drape: 'both',
|
||||
});
|
||||
// As src/layers/traffic/rendering.js gives the jam bucket.
|
||||
set.replace([
|
||||
{
|
||||
positions: road,
|
||||
widthPx: policy.HEAT_LINE_JAM_WIDTH,
|
||||
...(animated
|
||||
? {
|
||||
color: [...jamRgba.slice(0, 3), 1],
|
||||
glow: { power: policy.HEAT_JAM_GLOW_POWER },
|
||||
pulse: {
|
||||
periodMs: policy.HEAT_JAM_PULSE_PERIOD_MS,
|
||||
min:
|
||||
policy.HEAT_JAM_BASE_ALPHA -
|
||||
policy.HEAT_JAM_PULSE_ALPHA,
|
||||
max:
|
||||
policy.HEAT_JAM_BASE_ALPHA +
|
||||
policy.HEAT_JAM_PULSE_ALPHA,
|
||||
},
|
||||
}
|
||||
: {
|
||||
color: [...jamRgba.slice(0, 3), policy.HEAT_JAM_BASE_ALPHA],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
set.commit();
|
||||
const display = createDirectionsDisplay(engine);
|
||||
display.setRoute(route);
|
||||
window.__qaReady = () => set.ready();
|
||||
}
|
||||
const until = Date.now() + 60_000;
|
||||
while (!window.__qaReady() && Date.now() < until) await frame();
|
||||
loaded = 0;
|
||||
while (Date.now() < until + 60_000 && loaded < 30) {
|
||||
await frame();
|
||||
loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0;
|
||||
}
|
||||
},
|
||||
variant,
|
||||
ALTITUDE,
|
||||
);
|
||||
for (const time of TIMES) {
|
||||
await page.evaluate(async (time) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
// Navara's frame loop stops on a frozen page clock: there the frames
|
||||
// are drawn at the live time, 400 ms apart.
|
||||
if (engine.kind === 'navara')
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
else performance.now = () => time;
|
||||
for (let k = 0; k < 4; k++)
|
||||
await new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-ground-looks');
|
||||
});
|
||||
}, time);
|
||||
await page.screenshot({
|
||||
path: path.join(out, `looks-${time}.png`),
|
||||
clip: {
|
||||
x: CROP.left,
|
||||
y: CROP.top,
|
||||
width: CROP.width,
|
||||
height: CROP.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
const pair = option('compare');
|
||||
if (pair) await compare(pair.split(','));
|
||||
else {
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
if (!origin) {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-ground-looks.mjs <origin> --variant=old|engine --out=DIR | --compare=A,B',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const variant = option('variant') ?? 'engine';
|
||||
// The old drawing is Cesium's own; engine.ground also draws on Navara.
|
||||
const engineKind =
|
||||
variant === 'old' ? 'cesium' : (option('engine') ?? 'cesium');
|
||||
await draw(origin, variant, option('out') ?? 'qa-ground-looks', engineKind);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Show and hide of ground fills on Navara before and after an engine switch
|
||||
* (Navara → Cesium → Navara, the parked view taken up again): Navara 0.2.1
|
||||
* dropped the first `show: false` of a vector layer added right after a
|
||||
* switch. For engine.ground's screen pass and the vector-layer prototype
|
||||
* (hidden by layer.update with show false, or by deleting its layers): a
|
||||
* magenta cell hidden right after its commit must not show, then show, then
|
||||
* hide again. The pixels are read from the engine canvas.
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
|
||||
* node scripts/qa-ground-switch.mjs http://localhost:4271
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { pickEngine, waitForEngine } from './qa-strict-harness.mjs';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
const origin = process.argv[2];
|
||||
if (!origin) {
|
||||
console.error('Usage: node scripts/qa-ground-switch.mjs <origin>');
|
||||
process.exit(2);
|
||||
}
|
||||
const APPROACHES = ['pass', 'vector-update', 'vector-delete'];
|
||||
|
||||
async function trial(page, approach, round) {
|
||||
return page.evaluate(
|
||||
async (approach, round) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const frame = () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('ground-switch');
|
||||
});
|
||||
let ground = engine.ground;
|
||||
let owner = null;
|
||||
if (approach !== 'pass') {
|
||||
const { createNavaraVectorGround } =
|
||||
await import('/src/globe/navara/ground/vector.js');
|
||||
owner = createNavaraVectorGround(engine.extensions.navara.view, {
|
||||
hide: approach === 'vector-update' ? 'update' : 'delete',
|
||||
});
|
||||
ground = owner.ground;
|
||||
}
|
||||
const lon = 20 + round * 1.5 + APPROACH_INDEX[approach] * 0.5;
|
||||
const spot = engine.projector.worldToWindow(
|
||||
geo.cartesian3.fromDegrees(lon + 0.15, 10),
|
||||
);
|
||||
const magenta = () => {
|
||||
const canvas = engine.host.canvas;
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = 16;
|
||||
copy.height = 16;
|
||||
const context = copy.getContext('2d');
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
context.drawImage(
|
||||
canvas,
|
||||
(spot.x - 8) * scale,
|
||||
(spot.y - 8) * scale,
|
||||
16 * scale,
|
||||
16 * scale,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
16,
|
||||
);
|
||||
const data = context.getImageData(0, 0, 16, 16).data;
|
||||
let lit = 0;
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
if (
|
||||
data[i] > 150 &&
|
||||
data[i + 2] > 150 &&
|
||||
data[i + 1] < Math.min(data[i], data[i + 2]) - 60
|
||||
)
|
||||
lit++;
|
||||
return lit / 256;
|
||||
};
|
||||
// Frames until `want` (shown or not) holds, up to 4 s.
|
||||
const until = async (want) => {
|
||||
const started = performance.now();
|
||||
while (performance.now() - started < 4000) {
|
||||
await frame();
|
||||
if (magenta() > 0.5 === want) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const set = ground.createSet({
|
||||
id: `switch-${approach}-${round}`,
|
||||
kind: 'cell',
|
||||
});
|
||||
set.replace([
|
||||
{
|
||||
west: lon,
|
||||
south: 9.85,
|
||||
east: lon + 0.3,
|
||||
north: 10.15,
|
||||
fill: [1, 0, 1, 1],
|
||||
},
|
||||
]);
|
||||
set.commit();
|
||||
set.show(false);
|
||||
// Hidden right after the commit: never drawn.
|
||||
let drawnWhileHidden = false;
|
||||
for (let index = 0; index < 90; index++) {
|
||||
await frame();
|
||||
if (magenta() > 0.5) drawnWhileHidden = true;
|
||||
}
|
||||
set.show(true);
|
||||
const shows = await until(true);
|
||||
set.show(false);
|
||||
const hides = await until(false);
|
||||
set.destroy();
|
||||
owner?.destroy();
|
||||
return { hiddenAfterCommit: !drawnWhileHidden, shows, hides };
|
||||
},
|
||||
approach,
|
||||
round,
|
||||
);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
let failed = false;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('pageerror', (error) => console.log(' page error:', error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.evaluateOnNewDocument((approaches) => {
|
||||
window.APPROACH_INDEX = Object.fromEntries(
|
||||
approaches.map((name, index) => [name, index]),
|
||||
);
|
||||
}, APPROACHES);
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=navara#v=2&lat=10&lon=21&alt=900000&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await waitForEngine(page, 'navara', { timeout: 300_000 });
|
||||
await page.evaluate(async () => {
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.UTC(2026, 10, 7, 11, 0));
|
||||
});
|
||||
const report = (label, result) => {
|
||||
const ok = result.hiddenAfterCommit && result.shows && result.hides;
|
||||
if (!ok) failed = true;
|
||||
console.log(
|
||||
` ${label}: hidden right after commit ${result.hiddenAfterCommit ? 'yes' : 'NO'}, shows ${result.shows ? 'yes' : 'NO'}, hides again ${result.hides ? 'yes' : 'NO'}`,
|
||||
);
|
||||
};
|
||||
console.log('before a switch:');
|
||||
for (const approach of APPROACHES)
|
||||
report(approach, await trial(page, approach, 0));
|
||||
await pickEngine(page, 'cesium');
|
||||
await waitForEngine(page, 'cesium', { timeout: 300_000 });
|
||||
await pickEngine(page, 'navara');
|
||||
await waitForEngine(page, 'navara', { timeout: 300_000 });
|
||||
await page.evaluate(async () => {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(21, 10, 900000),
|
||||
});
|
||||
});
|
||||
console.log('after Navara → Cesium → Navara:');
|
||||
for (const approach of APPROACHES)
|
||||
report(approach, await trial(page, approach, 1));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -1,438 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Draw the rasters that moved to engine.imagery — the Active Fires far-zoom
|
||||
* heatmap and Recent Imagery — on both real engines, read the canvas back,
|
||||
* and compare two runs pixel by pixel (before and after a change), and the
|
||||
* two engines against each other. Run it against a keyless dev server of
|
||||
* each version, then compare:
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
|
||||
* node scripts/qa-imagery-compare.mjs shoot http://localhost:4291 --out=DIR
|
||||
* node scripts/qa-imagery-compare.mjs compare BEFORE_DIR AFTER_DIR
|
||||
*
|
||||
* Every scene freezes the app clock at local noon (Navara lights the scene
|
||||
* with the app clock's sun) and waits for the map tiles. The heatmap is a
|
||||
* fixed set of fires through the layer's own raster builder; Recent Imagery
|
||||
* draws generated tiles (a gradient per tile, intercepted at qa-tiles.test)
|
||||
* through the layer's renderer from the registry, falling back to the
|
||||
* renderers of older versions. Each scene is shot with and without the
|
||||
* raster: `overlay` is where the raster changed the frame. Google and
|
||||
* Cesium ion are blocked, so no engine draws photoreal tiles.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
const TILE_HOST = 'qa-tiles.test';
|
||||
const args = process.argv.slice(2);
|
||||
const [command, first, second] = args.filter((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const SIZE = { width: 1280, height: 800 };
|
||||
|
||||
// Fires on three continents, a dense cluster and a sparse field each.
|
||||
const FIRES = [
|
||||
...Array.from({ length: 400 }, (_, i) => ({
|
||||
lat: 8 + (i % 20) * 0.08,
|
||||
lon: 18 + Math.floor(i / 20) * 0.09,
|
||||
frp: 40 + (i % 7) * 30,
|
||||
})),
|
||||
...Array.from({ length: 120 }, (_, i) => ({
|
||||
lat: -12 + (i % 12) * 0.9,
|
||||
lon: 22 + Math.floor(i / 12) * 1.1,
|
||||
frp: 8 + (i % 5) * 6,
|
||||
})),
|
||||
...Array.from({ length: 60 }, (_, i) => ({
|
||||
lat: 4 + (i % 6) * 0.5,
|
||||
lon: 30 + Math.floor(i / 6) * 0.4,
|
||||
frp: 200,
|
||||
})),
|
||||
];
|
||||
|
||||
const SCENES = [
|
||||
{
|
||||
id: 'heat-orbit',
|
||||
kind: 'heat',
|
||||
view: { lon: 22, lat: 2, height: 12_000_000 },
|
||||
noonUtc: 10.5,
|
||||
},
|
||||
{
|
||||
id: 'heat-regional',
|
||||
kind: 'heat',
|
||||
view: { lon: 19, lat: 8.8, height: 1_500_000 },
|
||||
noonUtc: 10.5,
|
||||
},
|
||||
{
|
||||
id: 'imagery-box',
|
||||
kind: 'imagery',
|
||||
view: { lon: -97.75, lat: 30.25, height: 16_000 },
|
||||
noonUtc: 18.5,
|
||||
slots: [{ id: 'a', product: 'S30', alpha: 1, split: 'none' }],
|
||||
},
|
||||
{
|
||||
id: 'imagery-swipe',
|
||||
kind: 'imagery',
|
||||
view: { lon: -97.75, lat: 30.25, height: 16_000 },
|
||||
noonUtc: 18.5,
|
||||
split: 0.5,
|
||||
slots: [
|
||||
{ id: 'a', product: 'S30', alpha: 0.7, split: 'left' },
|
||||
{ id: 'b', product: 'VIIRS', alpha: 1, split: 'right' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
|
||||
|
||||
// A 256 px tile: red across, green down, blue by the tile's parity; the
|
||||
// VIIRS product (JPEG on GIBS) is a darker variant.
|
||||
const tileCache = new Map();
|
||||
async function tile(z, x, y, dark) {
|
||||
const key = `${z}/${x}/${y}/${dark}`;
|
||||
if (tileCache.has(key)) return tileCache.get(key);
|
||||
const data = Buffer.alloc(256 * 256 * 4);
|
||||
const parity = (x + y) % 2 ? 200 : 40;
|
||||
for (let row = 0; row < 256; row++)
|
||||
for (let column = 0; column < 256; column++) {
|
||||
const i = (row * 256 + column) * 4;
|
||||
const scale = dark ? 0.6 : 1;
|
||||
data[i] = column * scale;
|
||||
data[i + 1] = row * scale;
|
||||
data[i + 2] = parity * scale;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
const png = await sharp(data, {
|
||||
raw: { width: 256, height: 256, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
tileCache.set(key, png);
|
||||
return png;
|
||||
}
|
||||
|
||||
async function shootScene(browser, origin, kind, scene) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(SIZE);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
let tiles = 0;
|
||||
page.on('request', async (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.hostname === TILE_HOST) {
|
||||
tiles++;
|
||||
const [, product, z, y, x] = url.pathname.match(
|
||||
/^\/(\w+)\/(\d+)\/(\d+)\/(\d+)\.png$/,
|
||||
);
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: await tile(+z, +x, +y, product === 'VIIRS'),
|
||||
});
|
||||
} else if (BLOCKED_HOSTS.test(url.hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
const { lon, lat, height } = scene.view;
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${lat}&lon=${lon}&alt=${height}&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.engine.camera.flightActive(),
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
);
|
||||
// The scene: the view, noon, and helpers the shots below use.
|
||||
await page.evaluate(
|
||||
async ({ scene, fires, box, tileHost }) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
const noon = Date.UTC(2026, 10, 7) + scene.noonUtc * 3_600_000;
|
||||
appClock.freeze(noon);
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(
|
||||
scene.view.lon,
|
||||
scene.view.lat,
|
||||
scene.view.height,
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
// `count` frames, each asked for (a scene at rest draws only on request).
|
||||
const frames = (count) =>
|
||||
new Promise((resolve) => {
|
||||
let left = count;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--left > 0) {
|
||||
setTimeout(() => engine.render.request('qa-imagery'), 0);
|
||||
return;
|
||||
}
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-imagery');
|
||||
});
|
||||
const { rendererFor } = await import('/src/app/layers/renderers.js');
|
||||
const qa = { frames, raster: null };
|
||||
window.__qaImagery = qa;
|
||||
if (scene.kind === 'heat') {
|
||||
const [{ buildHeatRaster }, { HEAT_RASTER }] = await Promise.all([
|
||||
import('/src/data/firmsHeatRaster.js'),
|
||||
import('/src/layers/firms/policy.js'),
|
||||
]);
|
||||
const display = rendererFor('local-firms', engine, { id: 'qa-firms' });
|
||||
const heat = display.createHeatImagery();
|
||||
const raster = buildHeatRaster(fires, HEAT_RASTER);
|
||||
qa.show = () => {
|
||||
heat.setRaster(raster);
|
||||
heat.setOpacity(1);
|
||||
heat.setShow(true);
|
||||
};
|
||||
qa.hide = () => heat.setShow(false);
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
tileTemplate: (product) =>
|
||||
`https://${tileHost}/${product}/{z}/{y}/{x}.png`,
|
||||
credit: 'QA tiles',
|
||||
};
|
||||
let renderer = rendererFor('recent-imagery', engine, options);
|
||||
const viewer = engine.extensions?.cesium?.viewer;
|
||||
if (!renderer) {
|
||||
// Versions before engine.imagery: Cesium's own renderer on the globe.
|
||||
const { createRecentImageryRenderer } =
|
||||
await import('/src/layers/recentImagery/rendering.js');
|
||||
renderer = createRecentImageryRenderer({
|
||||
...options,
|
||||
requestRender: () => engine.render.request('qa-imagery'),
|
||||
});
|
||||
renderer.rebind({ collection: viewer.imageryLayers, kind: 'globe' });
|
||||
} else renderer.rebind(renderer.resolveHost());
|
||||
const candidate = (product) => ({
|
||||
key: `${product}:2026-10-28`,
|
||||
product,
|
||||
day: '2026-10-28',
|
||||
});
|
||||
qa.show = () => {
|
||||
if (scene.split != null) {
|
||||
renderer.setSplit?.(scene.split);
|
||||
if (viewer) viewer.scene.splitPosition = scene.split;
|
||||
}
|
||||
for (const slot of scene.slots)
|
||||
renderer.showSlot(slot.id, candidate(slot.product), box, {
|
||||
alpha: slot.alpha,
|
||||
splitDirection: slot.split,
|
||||
});
|
||||
};
|
||||
qa.hide = () => {
|
||||
for (const slot of scene.slots) renderer.hideSlot(slot.id);
|
||||
};
|
||||
},
|
||||
{ scene, fires: FIRES, box: BOX, tileHost: TILE_HOST },
|
||||
);
|
||||
// Settle: the map tiles loaded, then tile requests quiet for 2 s.
|
||||
const settle = async () => {
|
||||
for (let round = 0; round < 120; round++) {
|
||||
const before = tiles;
|
||||
await page.evaluate(() => window.__qaImagery.frames(10));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const loaded = await page.evaluate(() =>
|
||||
window.__godsEyeView.engine.surface.tilesLoaded(),
|
||||
);
|
||||
if (loaded && tiles === before && round >= 4) break;
|
||||
}
|
||||
await page.evaluate(() => window.__qaImagery.frames(20));
|
||||
};
|
||||
const read = () =>
|
||||
page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const scratch = document.createElement('canvas');
|
||||
scratch.width = canvas.clientWidth;
|
||||
scratch.height = canvas.clientHeight;
|
||||
const context = scratch.getContext('2d');
|
||||
context.drawImage(canvas, 0, 0, scratch.width, scratch.height);
|
||||
return scratch.toDataURL('image/png');
|
||||
});
|
||||
await settle();
|
||||
const without = await read();
|
||||
await page.evaluate(() => window.__qaImagery.show());
|
||||
await settle();
|
||||
const withRaster = await read();
|
||||
await page.close();
|
||||
return { without, with: withRaster, errors };
|
||||
}
|
||||
|
||||
async function shoot(origin, out) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 600_000,
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
||||
const only = option('only')?.split(',') ?? null;
|
||||
try {
|
||||
for (const kind of engines)
|
||||
for (const scene of SCENES) {
|
||||
if (only && !only.includes(scene.id)) continue;
|
||||
const shot = await shootScene(browser, origin, kind, scene);
|
||||
for (const name of ['without', 'with'])
|
||||
fs.writeFileSync(
|
||||
path.join(out, `${kind}-${scene.id}-${name}.png`),
|
||||
Buffer.from(shot[name].split(',')[1], 'base64'),
|
||||
);
|
||||
console.log(
|
||||
`${kind} ${scene.id}: shot${shot.errors.length ? ` (page errors: ${shot.errors.join('; ')})` : ''}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
const raw = async (file) =>
|
||||
(await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }))
|
||||
.data;
|
||||
|
||||
// Where the raster changed the frame (any channel by more than 6).
|
||||
function overlayMask(without, withRaster) {
|
||||
const mask = new Uint8Array(without.length / 4);
|
||||
for (let p = 0; p < mask.length; p++)
|
||||
for (let k = 0; k < 3; k++)
|
||||
if (Math.abs(without[p * 4 + k] - withRaster[p * 4 + k]) > 6) {
|
||||
mask[p] = 1;
|
||||
break;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
// Per-pixel differences of two frames inside `mask` (or everywhere).
|
||||
function difference(a, b, mask) {
|
||||
let count = 0,
|
||||
sum = 0,
|
||||
over8 = 0,
|
||||
over24 = 0,
|
||||
max = 0;
|
||||
for (let p = 0; p < a.length / 4; p++) {
|
||||
if (mask && !mask[p]) continue;
|
||||
let worst = 0;
|
||||
for (let k = 0; k < 3; k++)
|
||||
worst = Math.max(worst, Math.abs(a[p * 4 + k] - b[p * 4 + k]));
|
||||
count++;
|
||||
sum += worst;
|
||||
if (worst > 8) over8++;
|
||||
if (worst > 24) over24++;
|
||||
max = Math.max(max, worst);
|
||||
}
|
||||
return {
|
||||
pixels: count,
|
||||
meanMaxChannel: count ? +(sum / count).toFixed(2) : 0,
|
||||
over8: count ? +((100 * over8) / count).toFixed(2) : 0,
|
||||
over24: count ? +((100 * over24) / count).toFixed(2) : 0,
|
||||
max,
|
||||
};
|
||||
}
|
||||
|
||||
// The raster's own change of the frame: with minus without, per channel.
|
||||
function contribution(without, withRaster) {
|
||||
const out = new Int16Array(without.length);
|
||||
for (let i = 0; i < without.length; i++) out[i] = withRaster[i] - without[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function compare(beforeDir, afterDir) {
|
||||
const report = {};
|
||||
const engines = ['cesium', 'navara'];
|
||||
for (const scene of SCENES) {
|
||||
const row = (report[scene.id] = {});
|
||||
const loaded = {};
|
||||
for (const engine of engines)
|
||||
for (const [version, dir] of [
|
||||
['before', beforeDir],
|
||||
['after', afterDir],
|
||||
])
|
||||
for (const name of ['without', 'with']) {
|
||||
const file = path.join(dir, `${engine}-${scene.id}-${name}.png`);
|
||||
if (fs.existsSync(file))
|
||||
loaded[`${engine}-${version}-${name}`] = await raw(file);
|
||||
}
|
||||
for (const engine of engines) {
|
||||
const before = loaded[`${engine}-before-with`];
|
||||
const after = loaded[`${engine}-after-with`];
|
||||
if (!before || !after) continue;
|
||||
const mask = overlayMask(loaded[`${engine}-before-without`], before);
|
||||
row[`${engine}: before vs after, where the raster draws`] = difference(
|
||||
before,
|
||||
after,
|
||||
mask,
|
||||
);
|
||||
row[`${engine}: before vs after, whole frame`] = difference(
|
||||
before,
|
||||
after,
|
||||
);
|
||||
row[`${engine}: base map before vs after`] = difference(
|
||||
loaded[`${engine}-before-without`],
|
||||
loaded[`${engine}-after-without`],
|
||||
);
|
||||
}
|
||||
// Navara against Cesium: the raster's change of each frame (the base
|
||||
// maps are drawn differently), where Cesium's raster draws.
|
||||
const reference = loaded['cesium-after-with'];
|
||||
const navara = loaded['navara-after-with'];
|
||||
if (reference && navara) {
|
||||
const mask = overlayMask(loaded['cesium-after-without'], reference);
|
||||
const a = contribution(loaded['cesium-after-without'], reference);
|
||||
const b = contribution(loaded['navara-after-without'], navara);
|
||||
const shift = (values) =>
|
||||
Uint8Array.from(values, (value) =>
|
||||
Math.max(0, Math.min(255, value + 128)),
|
||||
);
|
||||
row['navara vs cesium after: the raster’s change of the frame'] =
|
||||
difference(shift(a), shift(b), mask);
|
||||
row['navara vs cesium after: frames with the raster'] = difference(
|
||||
reference,
|
||||
navara,
|
||||
mask,
|
||||
);
|
||||
const beforeReference = loaded['cesium-before-with'];
|
||||
const beforeNavara = loaded['navara-before-with'];
|
||||
if (beforeReference && beforeNavara)
|
||||
row['navara vs cesium before: frames with the raster'] = difference(
|
||||
beforeReference,
|
||||
beforeNavara,
|
||||
overlayMask(loaded['cesium-before-without'], beforeReference),
|
||||
);
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
if (command === 'shoot' && first) {
|
||||
await shoot(first, option('out') ?? 'qa-imagery');
|
||||
} else if (command === 'compare' && first && second) {
|
||||
const report = await compare(first, second);
|
||||
const json = option('json');
|
||||
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
|
||||
for (const [scene, rows] of Object.entries(report)) {
|
||||
console.log(scene);
|
||||
for (const [label, stats] of Object.entries(rows))
|
||||
console.log(
|
||||
` ${label}: ${stats.pixels} px, mean ${stats.meanMaxChannel}, >8 ${stats.over8}%, >24 ${stats.over24}%, max ${stats.max}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-imagery-compare.mjs shoot <origin> [--out=DIR] [--engine=cesium,navara] [--only=scene,...]\n' +
|
||||
' node scripts/qa-imagery-compare.mjs compare <beforeDir> <afterDir> [--json=FILE]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -54,9 +54,8 @@ await strict.run({}, async (b) => {
|
||||
await waitForEngine(p, 'navara');
|
||||
const ids = ['local-datacenters', 'local-dams'];
|
||||
const dataset = await p.evaluate(async (ids) => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaSites = [];
|
||||
const records = [];
|
||||
for (const id of ids) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
@@ -125,10 +126,10 @@ await strict.run({}, async (browser) => {
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.getCockpitState().active,
|
||||
);
|
||||
await assertKeylessPreview(page);
|
||||
await page.evaluate(() => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
styleManager.cockpitView.setVisionMode('thermal');
|
||||
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||
window.__handoff = {
|
||||
|
||||
@@ -54,9 +54,8 @@ await strict.run({}, async (b) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
const mod = dataManager.layers.get('local-adsb').module;
|
||||
window.qaAdsb = {
|
||||
mod,
|
||||
|
||||
@@ -115,3 +115,18 @@ export function strictBlockPatterns(
|
||||
{ urlPattern: '*://*:*/*', block: true },
|
||||
];
|
||||
}
|
||||
|
||||
/** Paid or live model endpoints no switch gate may reach. */
|
||||
export const SESSION_BLOCKED_URLS = Object.freeze([
|
||||
'*://tile.googleapis.com/*',
|
||||
'*://*.googleapis.com/*3dtiles*',
|
||||
'*://api.cesium.com/*',
|
||||
'*://assets.ion.cesium.com/*',
|
||||
'*://maps.googleapis.com/*',
|
||||
'*/api/google/*',
|
||||
'*/api/openai/*',
|
||||
'*/api/realtime/*',
|
||||
'*/api/gemini/*',
|
||||
'*://api.openai.com/*',
|
||||
'*://generativelanguage.googleapis.com/*',
|
||||
]);
|
||||
@@ -46,13 +46,6 @@ await strict.run(
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
await waitForEngine(page, 'navara');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||
),
|
||||
false,
|
||||
'keyless preview required',
|
||||
);
|
||||
|
||||
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
|
||||
const canvasAmber = async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// the shapes drawn on either engine stay on the board and draw on both.
|
||||
//
|
||||
// node scripts/qa-navara-draw.mjs http://127.0.0.1:<PORT>/
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
@@ -58,13 +59,7 @@ await strict.run(
|
||||
);
|
||||
};
|
||||
await ready('navara');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||
),
|
||||
false,
|
||||
'keyless preview required',
|
||||
);
|
||||
await assertKeylessPreview(page);
|
||||
const tool = () =>
|
||||
page.evaluate(() => {
|
||||
const draw = window.__gevDrawTool;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// outside the preview and the free tile cache).
|
||||
//
|
||||
// node scripts/qa-navara-geo-import.mjs http://127.0.0.1:<PORT>/
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -162,13 +163,7 @@ await strict.run(
|
||||
);
|
||||
};
|
||||
await ready('navara');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||
),
|
||||
false,
|
||||
'keyless preview required',
|
||||
);
|
||||
await assertKeylessPreview(page);
|
||||
const state = () =>
|
||||
page.evaluate(() => ({
|
||||
files: window.__gevGeoImport.list(),
|
||||
|
||||
@@ -46,13 +46,6 @@ await strict.run(
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
await waitForEngine(page, 'navara');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||
),
|
||||
false,
|
||||
'keyless preview required',
|
||||
);
|
||||
const edgeLuminance = async () => {
|
||||
await wait(900);
|
||||
const { data, info } = await sharp(await page.screenshot({ clip: EDGE }))
|
||||
|
||||
@@ -77,8 +77,7 @@ await strict.run({}, async (b) => {
|
||||
const { tileset, dataManager, sceneDirector } = window.__godsEyeView;
|
||||
if (!sceneDirector?.listScenes().length)
|
||||
throw Error('No scenes available on Navara');
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
|
||||
const mod = dataManager.layers.get('bhote-koshi-2026').module;
|
||||
window.qaEvent = mod;
|
||||
await dataManager.setEnabled(mod.id, true, { origin: 'user' });
|
||||
|
||||
@@ -21,9 +21,8 @@ await strict.run({}, async (b) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { tileset, dataManager } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaLocator = dataManager.layers.get('bhote-koshi-locator').module;
|
||||
window.qaLocator.setParams({ presentation: 'bhote-koshi-path-overview' });
|
||||
await dataManager.setEnabled('bhote-koshi-locator', true, {
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { stations, installRadioFixture } from './fixtures/w2/radio.mjs';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs';
|
||||
const url = localPreview(process.argv[2]);
|
||||
// The long soak and switch timings live in qa-strict-soak.mjs.
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash = 'v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: url.origin, layers: ['radio'] });
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
const { page: page, errors } = await openPage(browser);
|
||||
await page.setViewport({ width: 800, height: 600 });
|
||||
await installRadioFixture(page);
|
||||
await page.goto(url.href);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await waitForEngine(page, 'navara');
|
||||
await page.evaluate(async (id) => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
await styleManager.initialRestorePromise;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||
throw Error('Expected keyless preview');
|
||||
|
||||
window.qaLayer = dataManager.layers.get('radio').module;
|
||||
await dataManager.setEnabled('radio', true, { origin: 'user' });
|
||||
}, stations[0].id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { launchFixture } from '../src/bench/launchFixture.js';
|
||||
import {
|
||||
@@ -62,10 +63,10 @@ await strict.run({}, async (b) => {
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async (missions) => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Expected keyless preview');
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await styleManager.initialRestorePromise;
|
||||
window.sa = dataManager.layers.get('satellites').module;
|
||||
window.la = dataManager.layers.get('rocket-launches').module;
|
||||
|
||||
@@ -57,9 +57,8 @@ await strict.run({}, async (browser) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async (provider) => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Expected keyless preview');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaStreet = dataManager.layers.get('street-level').module;
|
||||
await dataManager.setEnabled('street-level', true, { origin: 'user' });
|
||||
window.qaStreet.setProviderEnabled(provider, true);
|
||||
|
||||
@@ -34,18 +34,11 @@ const CHOICES = Object.freeze([...ENGINES, 'navara-hd']);
|
||||
/** Local ports that belong to the user's own servers: never a QA preview. */
|
||||
export const RESERVED_PORTS = Object.freeze([
|
||||
'',
|
||||
'4173',
|
||||
'4174',
|
||||
'4185',
|
||||
'4186',
|
||||
'4187',
|
||||
'4188',
|
||||
'4189',
|
||||
'4194',
|
||||
'4196',
|
||||
...(process.env.QA_RESERVED_PORTS ?? '4173,4174,4185,4186,4187,4188,4189,4194,4196')
|
||||
.split(',').map((port) => port.trim()).filter(Boolean),
|
||||
]);
|
||||
/** Chrome flags every scenario's browser starts with (the GPU path). */
|
||||
const CHROME_ARGS = Object.freeze(['--use-angle=metal', '--enable-gpu']);
|
||||
export const CHROME_ARGS = Object.freeze([`--use-angle=${process.env.QA_ANGLE || 'metal'}`, '--enable-gpu']);
|
||||
|
||||
/** The values of --layers=a,b and --evidence=FILE (null when absent). */
|
||||
export function strictArguments(argv = process.argv) {
|
||||
@@ -69,6 +62,27 @@ export function localPreview(href) {
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Refuse to start a preview over an existing local server. */
|
||||
export async function assertPreviewPortAvailable(href, request = fetch) {
|
||||
const url = localPreview(href);
|
||||
let response;
|
||||
try {
|
||||
response = await request(url.origin, { signal: AbortSignal.timeout(1000) });
|
||||
} catch (error) {
|
||||
if (error.cause?.code === 'ECONNREFUSED') return;
|
||||
throw error;
|
||||
}
|
||||
if (response) throw new Error(`Preview port ${url.port} already answers`);
|
||||
}
|
||||
|
||||
/** Check the running page before any scenario touches its layers. */
|
||||
export async function assertKeylessPreview(page) {
|
||||
await page.evaluate(() => {
|
||||
if (window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw new Error('Keyless preview required');
|
||||
});
|
||||
}
|
||||
|
||||
/** The URL with no remote IP: fulfilled by a fixture or a cache, or a data URL. */
|
||||
const live = (response) => Boolean(response.remoteIPAddress);
|
||||
|
||||
@@ -567,6 +581,7 @@ export async function waitForEngine(page, kind, { timeout = 120000 } = {}) {
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await assertKeylessPreview(page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,8 +120,7 @@ export async function openApp(
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await waitForEngine(page, engine);
|
||||
await page.evaluate(() => {
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset)
|
||||
throw Error('Keyless preview required');
|
||||
|
||||
window.__qaEngines = new Map([
|
||||
[window.__godsEyeView.engine.kind, window.__godsEyeView.engine],
|
||||
]);
|
||||
|
||||
@@ -42,9 +42,8 @@ await strict.run({}, async (b) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaCables = dataManager.layers.get(
|
||||
'telegeography-submarine-cables',
|
||||
).module;
|
||||
|
||||
@@ -75,9 +75,8 @@ await strict.run({}, async (b) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { tileset, dataManager } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaTraffic = dataManager.layers.get('traffic').module;
|
||||
window.qaTraffic.setParams({ jamViz: 'both' });
|
||||
await dataManager.setEnabled('traffic', true, { origin: 'user' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs';
|
||||
import { installTransitFixture } from './fixtures/w2/transit.mjs';
|
||||
import {
|
||||
combinedPayloads,
|
||||
@@ -22,37 +22,19 @@ const VEHICLES = 12;
|
||||
const strict = createStrict({
|
||||
origin: url.origin,
|
||||
layers: [id],
|
||||
// installCombinedFixture answers these hosts too.
|
||||
fixtures: bus
|
||||
? []
|
||||
: [
|
||||
'https://earthquake.usgs.gov/*',
|
||||
'https://tiles.dontgetflocked.com/*',
|
||||
'*://*.earthdata.nasa.gov/*',
|
||||
],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.setViewport({ width: 1280, height: 900 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message);
|
||||
});
|
||||
const fx = bus
|
||||
? await installTransitFixture(p)
|
||||
: await installCombinedFixture(p);
|
||||
fx.setStep?.(9);
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async (id) => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Expected keyless preview');
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await styleManager.initialRestorePromise;
|
||||
window.qaUrban = dataManager.layers.get(id).module;
|
||||
await dataManager.setEnabled(id, true, { origin: 'user' });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localPreview, waitForEngine } from './qa-strict-harness.mjs';
|
||||
// An active voice session survives engine switches. With a fake microphone
|
||||
// and a fake Realtime transport (no model is reached: /api/openai, /api/realtime
|
||||
// and api.openai.com are blocked or answered in the page), this gate starts
|
||||
@@ -17,16 +18,14 @@ import {
|
||||
idDifference,
|
||||
launch,
|
||||
openSessionPage,
|
||||
previewBase,
|
||||
readSession,
|
||||
readVoiceEvidence,
|
||||
requestEngine,
|
||||
sessionAddress,
|
||||
switchEngine,
|
||||
waitReady,
|
||||
} from './qa/appSessionHarness.mjs';
|
||||
|
||||
const base = previewBase(process.argv[2]);
|
||||
const base = localPreview(process.argv[2]);
|
||||
|
||||
// Play one server function call on the fake data channel and wait for the
|
||||
// client's function_call_output.
|
||||
@@ -117,7 +116,7 @@ try {
|
||||
browser,
|
||||
sessionAddress(base, { engine: 'cesium' }),
|
||||
);
|
||||
await waitReady(page, 'cesium');
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
await page.evaluate(async () => {
|
||||
const close = document.querySelector('[data-key-setup-close]');
|
||||
if (close?.offsetParent) close.click();
|
||||
@@ -184,7 +183,7 @@ try {
|
||||
{ layerId: 'earthquakes', enabled: true },
|
||||
{ beforeCall: 'switch-cesium' },
|
||||
);
|
||||
await waitReady(page, 'cesium');
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
assert.equal(midSwitch.ok, true, JSON.stringify(midSwitch));
|
||||
assert.equal(midSwitch.engine, 'cesium', JSON.stringify(midSwitch));
|
||||
results.push('PASS a call made during the switch ran on the incoming engine');
|
||||
@@ -204,7 +203,7 @@ try {
|
||||
{ layerId: 'earthquakes', enabled: false },
|
||||
{ beforeCall: 'switch-navara' },
|
||||
);
|
||||
await waitReady(page, 'navara');
|
||||
await waitForEngine(page, 'navara', { timeout: 90_000 });
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.voiceCommands.runner.switchWaitMs = 15_000;
|
||||
});
|
||||
|
||||
@@ -1,644 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Draw the rasters that moved to engine.imagery's tile schemes, staged
|
||||
* sources and raised layers — the rain radar, lightning density, regional
|
||||
* and global satellite clouds, the wind field and the Bhote Koshi
|
||||
* before/after split — on both real engines, read the canvas back, and
|
||||
* compare two runs pixel by pixel (before and after a change), and the two
|
||||
* engines against each other. Run it against a keyless dev server of each
|
||||
* version, then compare:
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
|
||||
* node scripts/qa-weather-compare.mjs shoot http://localhost:4291 --out=DIR
|
||||
* node scripts/qa-weather-compare.mjs compare BEFORE_DIR AFTER_DIR
|
||||
*
|
||||
* The page must serve `/qa-cesium.js` (`export * from 'cesium';` at the
|
||||
* root of the served copy): older versions build their Cesium renderers
|
||||
* from the SDK. Every scene freezes the app clock at local noon and waits
|
||||
* for the map tiles. The weather tiles and images are generated here
|
||||
* (intercepted /api/weather/tile and /api/weather/image) from one field of
|
||||
* longitude and latitude, so a tile and a whole-extent image of the same
|
||||
* place agree; the wind is a fixed field, paused. Each scene is shot with
|
||||
* and without the raster: `overlay` is where the raster changed the frame.
|
||||
* Google and Cesium ion are blocked, so no engine draws photoreal tiles.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
const args = process.argv.slice(2);
|
||||
const [command, first, second] = args.filter((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const SIZE = { width: 1280, height: 800 };
|
||||
const TIME = '2026-11-07T12:00:00.000Z';
|
||||
const US = { west: -130, south: 20, east: -60, north: 55 };
|
||||
const GLOBE = { west: -180, south: -90, east: 180, north: 90 };
|
||||
const NEPAL = { west: 85.295, south: 28.135, east: 85.379, north: 28.292 };
|
||||
|
||||
const SCENES = [
|
||||
{
|
||||
id: 'radar',
|
||||
kind: 'weather',
|
||||
product: 'radar',
|
||||
bounds: US,
|
||||
alpha: 0.8,
|
||||
view: { lon: -95, lat: 37.5, height: 6_000_000 },
|
||||
noonUtc: 18,
|
||||
},
|
||||
{
|
||||
id: 'lightning',
|
||||
kind: 'weather',
|
||||
product: 'lightning',
|
||||
bounds: US,
|
||||
alpha: 0.8,
|
||||
view: { lon: -95, lat: 37.5, height: 6_000_000 },
|
||||
noonUtc: 18,
|
||||
},
|
||||
{
|
||||
id: 'clouds-regional',
|
||||
kind: 'weather',
|
||||
product: 'clouds-regional',
|
||||
bounds: US,
|
||||
alpha: 0.7,
|
||||
view: { lon: -95, lat: 37.5, height: 6_000_000 },
|
||||
noonUtc: 18,
|
||||
},
|
||||
{
|
||||
id: 'clouds-global',
|
||||
kind: 'weather',
|
||||
product: 'clouds',
|
||||
bounds: GLOBE,
|
||||
alpha: 0.7,
|
||||
view: { lon: -40, lat: 15, height: 14_000_000 },
|
||||
noonUtc: 14.5,
|
||||
},
|
||||
{
|
||||
id: 'wind',
|
||||
kind: 'wind',
|
||||
view: { lon: -40, lat: 20, height: 12_000_000 },
|
||||
noonUtc: 14.5,
|
||||
},
|
||||
{
|
||||
id: 'nepal-split',
|
||||
kind: 'nepal',
|
||||
view: { lon: 85.337, lat: 28.2135, height: 22_000 },
|
||||
noonUtc: 6.5,
|
||||
},
|
||||
];
|
||||
|
||||
// The weather field at a longitude and latitude: blobs with soft edges
|
||||
// (radar, lightning) or a grey sky to mask (the infrared products).
|
||||
function weatherPixel(product, lon, lat, out, at) {
|
||||
const v =
|
||||
0.5 + 0.25 * Math.sin(lon * 0.35) + 0.25 * Math.cos(lat * 0.5 + lon * 0.1);
|
||||
if (product === 'clouds' || product === 'clouds-regional') {
|
||||
const grey = Math.round(255 * Math.max(0, Math.min(1, v)));
|
||||
out[at] = out[at + 1] = out[at + 2] = grey;
|
||||
out[at + 3] = 255;
|
||||
return;
|
||||
}
|
||||
out[at] = Math.round(255 * Math.max(0, Math.min(1, v)));
|
||||
out[at + 1] = Math.round(255 * Math.max(0, Math.min(1, 1 - v)));
|
||||
out[at + 2] = product === 'lightning' ? 220 : 90;
|
||||
out[at + 3] = Math.round(255 * Math.max(0, Math.min(1, (v - 0.45) * 4)));
|
||||
}
|
||||
|
||||
async function weatherPng(product, box, width, height) {
|
||||
const data = Buffer.alloc(width * height * 4);
|
||||
for (let row = 0; row < height; row++) {
|
||||
const lat = box.north - ((row + 0.5) / height) * (box.north - box.south);
|
||||
for (let column = 0; column < width; column++) {
|
||||
const lon = box.west + ((column + 0.5) / width) * (box.east - box.west);
|
||||
weatherPixel(product, lon, lat, data, (row * width + column) * 4);
|
||||
}
|
||||
}
|
||||
return sharp(data, { raw: { width, height, channels: 4 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const LARGEST = {
|
||||
radar: [4096, 2048],
|
||||
lightning: [4096, 2048],
|
||||
'clouds-regional': [4096, 2048],
|
||||
clouds: [2048, 1024],
|
||||
};
|
||||
const pngCache = new Map();
|
||||
// One generated /api/weather/tile or /api/weather/image response, or null.
|
||||
async function weatherResponse(url) {
|
||||
const key = url.pathname + url.search;
|
||||
if (pngCache.has(key)) return pngCache.get(key);
|
||||
const query = url.searchParams;
|
||||
const product = query.get('product');
|
||||
const scene = SCENES.find((entry) => entry.product === product);
|
||||
if (!scene) return null;
|
||||
let png;
|
||||
if (url.pathname === '/api/weather/tile') {
|
||||
const z = +query.get('z');
|
||||
const x = +query.get('x');
|
||||
const y = +query.get('y');
|
||||
const span = 180 / 2 ** z;
|
||||
const size = +(query.get('size') ?? 256);
|
||||
png = await weatherPng(
|
||||
product,
|
||||
{
|
||||
west: -180 + x * span,
|
||||
east: -180 + (x + 1) * span,
|
||||
north: 90 - y * span,
|
||||
south: 90 - (y + 1) * span,
|
||||
},
|
||||
size,
|
||||
size,
|
||||
);
|
||||
} else {
|
||||
const bbox = query.get('bbox');
|
||||
const [west, south, east, north] = bbox
|
||||
? bbox.split(',').map(Number)
|
||||
: [
|
||||
scene.bounds.west,
|
||||
scene.bounds.south,
|
||||
scene.bounds.east,
|
||||
scene.bounds.north,
|
||||
];
|
||||
const [width, height] = query.get('size')
|
||||
? query.get('size').split('x').map(Number)
|
||||
: bbox
|
||||
? [4096, 2048]
|
||||
: LARGEST[product];
|
||||
png = await weatherPng(
|
||||
product,
|
||||
{ west, south, east, north },
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
pngCache.set(key, png);
|
||||
return png;
|
||||
}
|
||||
|
||||
// A fixed 5° wind field: westerlies, trades and a jet.
|
||||
function windSnapshot() {
|
||||
const nx = 72;
|
||||
const ny = 37;
|
||||
const u = new Float32Array(nx * ny);
|
||||
const v = new Float32Array(nx * ny);
|
||||
for (let j = 0; j < ny; j++) {
|
||||
const lat = 90 - j * 5;
|
||||
for (let i = 0; i < nx; i++) {
|
||||
const lon = i * 5;
|
||||
u[j * nx + i] =
|
||||
14 * Math.cos((lat * Math.PI) / 60) +
|
||||
6 * Math.sin((lon * Math.PI) / 45);
|
||||
v[j * nx + i] = 5 * Math.sin(((lon + lat) * Math.PI) / 50);
|
||||
}
|
||||
}
|
||||
return {
|
||||
model: 'gfs',
|
||||
level: '10m',
|
||||
units: 'm/s',
|
||||
cycle: { runIso: TIME, validIso: TIME, forecastHour: 0 },
|
||||
grid: { nx, ny, lo1: 0, la1: 90, dx: 5, dy: 5 },
|
||||
u: Array.from(u),
|
||||
v: Array.from(v),
|
||||
};
|
||||
}
|
||||
|
||||
async function shootScene(browser, origin, kind, scene) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(SIZE);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
let requests = 0;
|
||||
page.on('request', async (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.startsWith('/api/weather/')) {
|
||||
requests++;
|
||||
const png = await weatherResponse(url).catch(() => null);
|
||||
if (png)
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: png,
|
||||
});
|
||||
else await request.respond({ status: 404, body: '' });
|
||||
} else if (BLOCKED_HOSTS.test(url.hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
const { lon, lat, height } = scene.view;
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${lat}&lon=${lon}&alt=${height}&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.engine.camera.flightActive(),
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
);
|
||||
const version = await page.evaluate(
|
||||
async ({ scene, time, wind }) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.UTC(2026, 10, 7) + scene.noonUtc * 3_600_000);
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(
|
||||
scene.view.lon,
|
||||
scene.view.lat,
|
||||
scene.view.height,
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
const frames = (count) =>
|
||||
new Promise((resolve) => {
|
||||
let left = count;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--left > 0) {
|
||||
setTimeout(() => engine.render.request('qa-weather'), 0);
|
||||
return;
|
||||
}
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-weather');
|
||||
});
|
||||
const tryImport = async (url) => {
|
||||
try {
|
||||
return await import(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const viewer = engine.extensions?.cesium?.viewer ?? null;
|
||||
const native = engine.extensions?.navara ?? null;
|
||||
const cesium = viewer ? await import('/qa-cesium.js') : null;
|
||||
const qa = { frames };
|
||||
window.__qaWeather = qa;
|
||||
if (scene.kind === 'weather') {
|
||||
const snapshot = {
|
||||
schemaVersion: 1,
|
||||
product: scene.product,
|
||||
bounds: scene.bounds,
|
||||
times: [time],
|
||||
latest: time,
|
||||
tileSize: 256,
|
||||
maxLevel: 6,
|
||||
tilingScheme: 'geographic',
|
||||
};
|
||||
const display = await tryImport(
|
||||
'/src/layers/weather/render/display.js',
|
||||
);
|
||||
let renderer;
|
||||
let used;
|
||||
if (display) {
|
||||
renderer = display.createWeatherDisplay(engine, {});
|
||||
used = 'display';
|
||||
} else if (viewer) {
|
||||
const { createWeatherRendering } =
|
||||
await import('/src/layers/weather/rendering.js');
|
||||
renderer = createWeatherRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
getHost: () => ({
|
||||
collection: viewer.imageryLayers,
|
||||
kind: 'globe',
|
||||
}),
|
||||
});
|
||||
used = 'cesium';
|
||||
} else {
|
||||
const { createWeatherNavaraRenderer } =
|
||||
await import('/src/layers/weather/render/navara.js');
|
||||
renderer = createWeatherNavaraRenderer(engine, native, {
|
||||
getHost: () => ({ kind: 'globe', collection: engine }),
|
||||
});
|
||||
used = 'navara';
|
||||
}
|
||||
renderer.setAlpha(scene.alpha);
|
||||
qa.show = () =>
|
||||
renderer.setFrame(snapshot, time, {
|
||||
infrared: scene.product.startsWith('clouds') ? 100 : 'filtered',
|
||||
});
|
||||
qa.state = () => renderer.getDiagnostics();
|
||||
return used;
|
||||
}
|
||||
if (scene.kind === 'wind') {
|
||||
const display = await tryImport('/src/layers/wind/render/display.js');
|
||||
const { createWindRendering } =
|
||||
await import('/src/layers/wind/rendering.js');
|
||||
const cesiumFlow = (options = {}) =>
|
||||
createWindRendering({
|
||||
cesium,
|
||||
container: viewer.container,
|
||||
getViewer: () => viewer,
|
||||
getHost: () => ({
|
||||
collection: viewer.imageryLayers,
|
||||
kind: 'globe',
|
||||
}),
|
||||
...options,
|
||||
});
|
||||
let renderer;
|
||||
let used;
|
||||
if (display) {
|
||||
const navaraFlow = native
|
||||
? (
|
||||
(await tryImport('/src/globe/navara/windFlow.js')) ??
|
||||
(await import('/src/layers/wind/render/navaraFlow.js'))
|
||||
).createWindNavaraFlow
|
||||
: null;
|
||||
const createWindFlowMesh = native
|
||||
? (await import('/src/globe/navara/windFlowMesh.js'))
|
||||
.createWindFlowMesh
|
||||
: null;
|
||||
renderer = display.createWindDisplay(engine, {
|
||||
flow: viewer
|
||||
? () => cesiumFlow({ field: false })
|
||||
: (e) => navaraFlow(e, native, createWindFlowMesh),
|
||||
});
|
||||
used = 'display';
|
||||
} else if (viewer) {
|
||||
renderer = cesiumFlow();
|
||||
used = 'cesium';
|
||||
} else {
|
||||
const { createWindNavaraRenderer } =
|
||||
await import('/src/layers/wind/render/navara.js');
|
||||
renderer = createWindNavaraRenderer(engine, native);
|
||||
used = 'navara';
|
||||
}
|
||||
renderer.attach();
|
||||
renderer.setOptions({ overlay: 'none', paused: true });
|
||||
renderer.setField(wind);
|
||||
renderer.start();
|
||||
qa.show = () => renderer.setOptions({ overlay: 'speed', paused: true });
|
||||
qa.state = () => renderer.getDiagnostics();
|
||||
return used;
|
||||
}
|
||||
// The Bhote Koshi before and after images, split at the middle.
|
||||
const box = {
|
||||
west: 85.295,
|
||||
south: 28.135,
|
||||
east: 85.379,
|
||||
north: 28.292,
|
||||
};
|
||||
const urls = [
|
||||
'/events/bhote-koshi-2026/pre.webp',
|
||||
'/events/bhote-koshi-2026/post.webp',
|
||||
].map((url) => new URL(url, location.href).href);
|
||||
const display = await tryImport(
|
||||
'/src/layers/bhoteKoshi/render/display.js',
|
||||
);
|
||||
if (display) {
|
||||
const renderer = display.createBhoteKoshiDisplay(engine);
|
||||
qa.show = async () => {
|
||||
await renderer.loadImages(box, urls);
|
||||
renderer.setSplit(0.5);
|
||||
};
|
||||
return 'display';
|
||||
}
|
||||
const Cesium = cesium ?? (await import('/qa-cesium.js'));
|
||||
const rectangle = Cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
);
|
||||
if (viewer) {
|
||||
qa.show = async () => {
|
||||
const providers = await Promise.all(
|
||||
urls.map((url) =>
|
||||
Cesium.SingleTileImageryProvider.fromUrl(url, { rectangle }),
|
||||
),
|
||||
);
|
||||
providers.forEach((provider, index) => {
|
||||
const layer = viewer.imageryLayers.addImageryProvider(provider);
|
||||
layer.splitDirection = index
|
||||
? Cesium.SplitDirection.RIGHT
|
||||
: Cesium.SplitDirection.LEFT;
|
||||
layer.alpha = 0.96;
|
||||
});
|
||||
viewer.scene.splitPosition = 0.5;
|
||||
};
|
||||
return 'cesium';
|
||||
}
|
||||
const { createNepalEventDisplay } =
|
||||
await import('/src/layers/bhoteKoshi/render/navara.js');
|
||||
const renderer = createNepalEventDisplay(engine, native);
|
||||
qa.show = async () => {
|
||||
const providers = await Promise.all(
|
||||
urls.map((url) =>
|
||||
Cesium.SingleTileImageryProvider.fromUrl(url, { rectangle }),
|
||||
),
|
||||
);
|
||||
await renderer.loadImages(box, providers);
|
||||
renderer.setSplit(0.5);
|
||||
};
|
||||
return 'navara';
|
||||
},
|
||||
{ scene, time: TIME, wind: windSnapshot() },
|
||||
);
|
||||
// Settle: the map tiles loaded, then requests quiet for 2 s.
|
||||
const settle = async () => {
|
||||
for (let round = 0; round < 120; round++) {
|
||||
const before = requests;
|
||||
await page.evaluate(() => window.__qaWeather.frames(10));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const loaded = await page.evaluate(() =>
|
||||
window.__godsEyeView.engine.surface.tilesLoaded(),
|
||||
);
|
||||
if (loaded && requests === before && round >= 4) break;
|
||||
}
|
||||
await page.evaluate(() => window.__qaWeather.frames(20));
|
||||
};
|
||||
const read = () =>
|
||||
page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const scratch = document.createElement('canvas');
|
||||
scratch.width = canvas.clientWidth;
|
||||
scratch.height = canvas.clientHeight;
|
||||
const context = scratch.getContext('2d');
|
||||
context.drawImage(canvas, 0, 0, scratch.width, scratch.height);
|
||||
return scratch.toDataURL('image/png');
|
||||
});
|
||||
await settle();
|
||||
const without = await read();
|
||||
const shown = await page.evaluate(async () => {
|
||||
const result = await window.__qaWeather.show();
|
||||
return result === undefined ? null : result;
|
||||
});
|
||||
await settle();
|
||||
const withRaster = await read();
|
||||
const state = await page.evaluate(() => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(window.__qaWeather.state?.() ?? null));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
await page.close();
|
||||
return { without, with: withRaster, errors, version, shown, state };
|
||||
}
|
||||
|
||||
async function shoot(origin, out) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 600_000,
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
||||
const only = option('only')?.split(',') ?? null;
|
||||
try {
|
||||
for (const kind of engines)
|
||||
for (const scene of SCENES) {
|
||||
if (only && !only.includes(scene.id)) continue;
|
||||
const shot = await shootScene(browser, origin, kind, scene);
|
||||
for (const name of ['without', 'with'])
|
||||
fs.writeFileSync(
|
||||
path.join(out, `${kind}-${scene.id}-${name}.png`),
|
||||
Buffer.from(shot[name].split(',')[1], 'base64'),
|
||||
);
|
||||
console.log(
|
||||
`${kind} ${scene.id}: ${shot.version}, shown ${JSON.stringify(shot.shown)}, error ${shot.state?.error ?? shot.state?.imageryError ?? null}${shot.errors.length ? ` (page errors: ${shot.errors.join('; ')})` : ''}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
const raw = async (file) =>
|
||||
(await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }))
|
||||
.data;
|
||||
|
||||
// Where the raster changed the frame (any channel by more than 6).
|
||||
function overlayMask(without, withRaster) {
|
||||
const mask = new Uint8Array(without.length / 4);
|
||||
for (let p = 0; p < mask.length; p++)
|
||||
for (let k = 0; k < 3; k++)
|
||||
if (Math.abs(without[p * 4 + k] - withRaster[p * 4 + k]) > 6) {
|
||||
mask[p] = 1;
|
||||
break;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
// Per-pixel differences of two frames inside `mask` (or everywhere).
|
||||
function difference(a, b, mask) {
|
||||
let count = 0,
|
||||
sum = 0,
|
||||
over8 = 0,
|
||||
over24 = 0,
|
||||
max = 0;
|
||||
for (let p = 0; p < a.length / 4; p++) {
|
||||
if (mask && !mask[p]) continue;
|
||||
let worst = 0;
|
||||
for (let k = 0; k < 3; k++)
|
||||
worst = Math.max(worst, Math.abs(a[p * 4 + k] - b[p * 4 + k]));
|
||||
count++;
|
||||
sum += worst;
|
||||
if (worst > 8) over8++;
|
||||
if (worst > 24) over24++;
|
||||
max = Math.max(max, worst);
|
||||
}
|
||||
return {
|
||||
pixels: count,
|
||||
meanMaxChannel: count ? +(sum / count).toFixed(2) : 0,
|
||||
over8: count ? +((100 * over8) / count).toFixed(2) : 0,
|
||||
over24: count ? +((100 * over24) / count).toFixed(2) : 0,
|
||||
max,
|
||||
};
|
||||
}
|
||||
|
||||
// The raster's own change of the frame: with minus without, per channel.
|
||||
function contribution(without, withRaster) {
|
||||
const out = new Int16Array(without.length);
|
||||
for (let i = 0; i < without.length; i++) out[i] = withRaster[i] - without[i];
|
||||
return out;
|
||||
}
|
||||
const shift = (values) =>
|
||||
Uint8Array.from(values, (value) => Math.max(0, Math.min(255, value + 128)));
|
||||
|
||||
async function compare(beforeDir, afterDir) {
|
||||
const report = {};
|
||||
const engines = ['cesium', 'navara'];
|
||||
for (const scene of SCENES) {
|
||||
const row = (report[scene.id] = {});
|
||||
const loaded = {};
|
||||
for (const engine of engines)
|
||||
for (const [version, dir] of [
|
||||
['before', beforeDir],
|
||||
['after', afterDir],
|
||||
])
|
||||
for (const name of ['without', 'with']) {
|
||||
const file = path.join(dir, `${engine}-${scene.id}-${name}.png`);
|
||||
if (fs.existsSync(file))
|
||||
loaded[`${engine}-${version}-${name}`] = await raw(file);
|
||||
}
|
||||
for (const engine of engines) {
|
||||
const before = loaded[`${engine}-before-with`];
|
||||
const after = loaded[`${engine}-after-with`];
|
||||
if (!before || !after) continue;
|
||||
const mask = overlayMask(loaded[`${engine}-before-without`], before);
|
||||
row[`${engine}: before vs after, where the raster draws`] = difference(
|
||||
before,
|
||||
after,
|
||||
mask,
|
||||
);
|
||||
// The raster's own change, before and after: the base map drawn
|
||||
// differently between runs (tiles, lighting) cancels out.
|
||||
row[`${engine}: the raster's change, before vs after`] = difference(
|
||||
shift(contribution(loaded[`${engine}-before-without`], before)),
|
||||
shift(contribution(loaded[`${engine}-after-without`], after)),
|
||||
mask,
|
||||
);
|
||||
row[`${engine}: base map before vs after`] = difference(
|
||||
loaded[`${engine}-before-without`],
|
||||
loaded[`${engine}-after-without`],
|
||||
);
|
||||
}
|
||||
// Navara against Cesium, where Cesium's raster draws: each engine's
|
||||
// change of its own frame (the base maps are drawn differently).
|
||||
for (const version of ['before', 'after']) {
|
||||
const reference = loaded[`cesium-${version}-with`];
|
||||
const navara = loaded[`navara-${version}-with`];
|
||||
if (!reference || !navara) continue;
|
||||
const mask = overlayMask(loaded[`cesium-${version}-without`], reference);
|
||||
row[`navara vs cesium ${version}: the raster's change`] = difference(
|
||||
shift(contribution(loaded[`cesium-${version}-without`], reference)),
|
||||
shift(contribution(loaded[`navara-${version}-without`], navara)),
|
||||
mask,
|
||||
);
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
if (command === 'shoot' && first) {
|
||||
await shoot(first, option('out') ?? 'qa-weather');
|
||||
} else if (command === 'compare' && first && second) {
|
||||
const report = await compare(first, second);
|
||||
const json = option('json');
|
||||
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
|
||||
for (const [scene, rows] of Object.entries(report)) {
|
||||
console.log(scene);
|
||||
for (const [label, value] of Object.entries(rows))
|
||||
console.log(
|
||||
` ${label}: ${value.pixels} px, mean ${value.meanMaxChannel}, >8 ${value.over8}%, >24 ${value.over24}%, max ${value.max}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-weather-compare.mjs shoot <origin> [--out=DIR] [--engine=cesium,navara] [--only=scene,...]\n' +
|
||||
' node scripts/qa-weather-compare.mjs compare <before> <after> [--json=FILE]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -195,8 +195,7 @@ await strict.run({}, async (b) => {
|
||||
);
|
||||
};
|
||||
await p.evaluate(() => {
|
||||
if (window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
|
||||
});
|
||||
for (const id of [ids[1], ids[0], ids[2]]) {
|
||||
await p.evaluate(
|
||||
|
||||
@@ -62,9 +62,8 @@ await strict.run({}, async (b) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'cesium');
|
||||
await p.evaluate(async (id) => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
await dataManager.setEnabled(id, true, { origin: 'user' });
|
||||
window.qaWild = dataManager.layers.get(id).module;
|
||||
// Every selection the layer reports, in order.
|
||||
|
||||
@@ -71,9 +71,8 @@ await strict.run({}, async (b) => {
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless preview required');
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
const mod = dataManager.layers.get('wind').module;
|
||||
window.qaWind = mod;
|
||||
mod.setParams({ overlay: 'temperature', paused: true, units: 'm/s' });
|
||||
|
||||
@@ -4,28 +4,8 @@
|
||||
// endpoint blocked, a fake microphone and Realtime transport, and readers for
|
||||
// the session ids, listener counts and transport counters.
|
||||
import puppeteer from 'puppeteer';
|
||||
import { blockPaidQaMaps } from '../qa-map-policy.mjs';
|
||||
import { localPreview, waitForEngine } from '../qa-strict-harness.mjs';
|
||||
|
||||
/** Paid or live model endpoints no switch gate may reach. */
|
||||
export const BLOCKED_URLS = Object.freeze([
|
||||
'*://tile.googleapis.com/*',
|
||||
'*://*.googleapis.com/*3dtiles*',
|
||||
'*://api.cesium.com/*',
|
||||
'*://assets.ion.cesium.com/*',
|
||||
'*://maps.googleapis.com/*',
|
||||
'*/api/google/*',
|
||||
'*/api/openai/*',
|
||||
'*/api/realtime/*',
|
||||
'*/api/gemini/*',
|
||||
'*://api.openai.com/*',
|
||||
'*://generativelanguage.googleapis.com/*',
|
||||
]);
|
||||
|
||||
/** The preview origin from argv, refusing the ports that belong to the user. */
|
||||
export function previewBase(argument) {
|
||||
return localPreview(argument ?? '');
|
||||
}
|
||||
import { SESSION_BLOCKED_URLS } from '../qa-map-policy.mjs';
|
||||
import { CHROME_ARGS, waitForEngine } from '../qa-strict-harness.mjs';
|
||||
|
||||
/** The page address: Boston on OSM, no welcome, on the given engine. */
|
||||
export function sessionAddress(base, { engine = 'cesium', extra = {} } = {}) {
|
||||
@@ -46,8 +26,7 @@ export function launch() {
|
||||
return puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--use-angle=metal',
|
||||
'--enable-gpu',
|
||||
...CHROME_ARGS,
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
@@ -216,10 +195,9 @@ export async function openSessionPage(browser, address) {
|
||||
await browser
|
||||
.defaultBrowserContext()
|
||||
.overridePermissions(new URL(address).origin, ['microphone']);
|
||||
await blockPaidQaMaps(page);
|
||||
const network = await page.createCDPSession();
|
||||
await network.send('Network.enable');
|
||||
await network.send('Network.setBlockedURLs', { urls: [...BLOCKED_URLS] });
|
||||
await network.send('Network.setBlockedURLs', { urls: [...SESSION_BLOCKED_URLS] });
|
||||
await page.evaluateOnNewDocument(installFakeVoice);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const errors = [];
|
||||
@@ -228,11 +206,6 @@ export async function openSessionPage(browser, address) {
|
||||
return { page, errors };
|
||||
}
|
||||
|
||||
/** The engine runs, its picker is idle and the loading screen is gone. */
|
||||
export function waitReady(page, kind) {
|
||||
return waitForEngine(page, kind, { timeout: 90_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an engine the way the picker does, without moving focus to it, so a
|
||||
* focused field keeps its focus through the switch.
|
||||
@@ -247,7 +220,7 @@ export function requestEngine(page, kind) {
|
||||
|
||||
export async function switchEngine(page, kind) {
|
||||
await requestEngine(page, kind);
|
||||
await waitReady(page, kind);
|
||||
await waitForEngine(page, kind, { timeout: 90_000 });
|
||||
}
|
||||
|
||||
/** Session object ids and counters (src/app/sessionDiagnostics.js). */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertKeylessPreview } from '../qa-strict-harness.mjs';
|
||||
// Shared by the Navara visual QA scenarios (qa-navara-visual-styles,
|
||||
// qa-navara-bloom, qa-navara-sonar, qa-navara-cockpit-clouds): read the
|
||||
// engine canvas, compare frames, settle a still view, follow a style
|
||||
@@ -134,10 +135,10 @@ export const histogramShift = (a, b) =>
|
||||
) / 2;
|
||||
|
||||
export async function freeze(page) {
|
||||
await assertKeylessPreview(page);
|
||||
await page.evaluate(async () => {
|
||||
const a = window.__godsEyeView;
|
||||
if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset)
|
||||
throw new Error('Keyless preview required');
|
||||
|
||||
a.styleManager.orbitController.stop();
|
||||
a.engine.camera.cancelFlight();
|
||||
a.styleManager._setDetectionMode?.('OFF');
|
||||
|
||||
@@ -92,13 +92,12 @@ export const ENGINE_COUPLING_METRICS = Object.freeze([
|
||||
},
|
||||
{
|
||||
id: 'engineSniffCalls',
|
||||
label: 'isGlobeEngine / engineFromViewer calls outside src/globe',
|
||||
label: 'isGlobeEngine calls outside src/globe',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => !inGlobe(file))
|
||||
.reduce(
|
||||
(sum, { text }) =>
|
||||
sum + countMatches(text, /\b(?:isGlobeEngine|engineFromViewer)\(/),
|
||||
(sum, { text }) => sum + countMatches(text, /\bisGlobeEngine\(/),
|
||||
0,
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js';
|
||||
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
/**
|
||||
* Build the page's one globe engine on the application viewer and tie its
|
||||
* teardown to the scene lifetime; it stays registered for the viewer
|
||||
* (engineFromViewer) while it lives. Call after the viewer's own cleanup is
|
||||
* teardown to the scene lifetime. Call after the viewer's own cleanup is
|
||||
* deferred, so the engine is destroyed first. Render holds taken before the
|
||||
* render governor is installed apply when it is (src/renderGovernor.js).
|
||||
* @param {Cesium.Viewer} viewer
|
||||
@@ -20,9 +18,7 @@ export function createSceneEngine(
|
||||
{ tilesetRef, defer, createEngine = createCesiumEngine },
|
||||
) {
|
||||
const engine = createEngine(viewer, { tilesetRef });
|
||||
const unregister = registerViewerEngine(viewer, engine);
|
||||
defer(() => {
|
||||
unregister();
|
||||
engine.destroy();
|
||||
});
|
||||
return engine;
|
||||
|
||||
@@ -69,24 +69,3 @@ test('the debug handle exposes the engine beside the viewer', () => {
|
||||
/Object\.assign\(debug, \{\n\s+viewer: next\.viewer \?\? null,\n\s+engine: next\.engine,/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the scene engine is the one engineFromViewer returns for its viewer until teardown', async () => {
|
||||
const { engineFromViewer } =
|
||||
await import('../globe/cesium/engineFromViewer.js');
|
||||
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
|
||||
const viewer = { name: 'viewer' };
|
||||
const cleanups = [];
|
||||
const engine = createSceneEngine(viewer, {
|
||||
tilesetRef: null,
|
||||
defer: (dispose) => cleanups.push(dispose),
|
||||
createEngine: () => createFakeEngine(),
|
||||
});
|
||||
assert.equal(engineFromViewer(viewer), engine);
|
||||
for (const cleanup of cleanups.reverse()) cleanup();
|
||||
assert.equal(engine.fake.destroyed, true);
|
||||
assert.equal(
|
||||
engineFromViewer(viewer),
|
||||
null,
|
||||
'no longer registered, and none is built',
|
||||
);
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
rendererFor,
|
||||
} from './renderers.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||
import { ENGINE_KINDS, engineCapabilities } from '../../globe/contract.js';
|
||||
import { ENGINE_KINDS, assertGlobeEngine } from '../../globe/contract.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
|
||||
test('the manifest lists every directory layer, in its order', () => {
|
||||
@@ -25,9 +25,9 @@ test('the manifest lists every directory layer, in its order', () => {
|
||||
});
|
||||
|
||||
test('the primitives a display needs are the contract primitives every engine has', () => {
|
||||
const capabilities = engineCapabilities(createFakeEngine());
|
||||
for (const name of CONTRACT_PRIMITIVES)
|
||||
assert.equal(capabilities[name], true, name);
|
||||
const engine = createFakeEngine();
|
||||
assertGlobeEngine(engine);
|
||||
for (const name of CONTRACT_PRIMITIVES) assert.ok(engine[name], name);
|
||||
for (const [id, entry] of Object.entries(LAYER_MANIFEST))
|
||||
for (const name of entry.needs ?? [])
|
||||
assert.ok(CONTRACT_PRIMITIVES.includes(name), `${id} needs ${name}`);
|
||||
|
||||
@@ -34,7 +34,7 @@ import { registerEntityContext, selectEntityContext } from './contextStore.js';
|
||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||||
import { pickRegistryFor } from './pickRegistry.js';
|
||||
const wireEngine = {};
|
||||
const wireEngine = createFakeEngine({ width: 1600, height: 900 });
|
||||
const { registerPickOwner, unregisterPickOwner } = pickRegistryFor(wireEngine);
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
@@ -42,12 +42,20 @@ import {
|
||||
presentSelectedVessel,
|
||||
subscribeSelectedVessel,
|
||||
} from '../hudReadouts.js';
|
||||
import { vesselViewerRenderer } from '../testSupport/vesselViewerRenderer.mjs';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createVesselsDisplay, vesselAtlasLayout } from '../layers/vessels/render/display.js';
|
||||
|
||||
function vesselDisplay(engine = createFakeEngine({ width: 1600, height: 900 })) {
|
||||
const layout = vesselAtlasLayout();
|
||||
const renderer = createVesselsDisplay(engine, {
|
||||
drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }),
|
||||
});
|
||||
renderer.fleet.attach({ show: true });
|
||||
return renderer;
|
||||
}
|
||||
|
||||
// The layer's state over the test's viewer, drawn by the Cesium-shaped test
|
||||
// renderer (its billboard collection, projection and trackedEntityChanged).
|
||||
const _setVesselStateForTest = (options = {}) =>
|
||||
setVesselStateForTest({ renderer: vesselViewerRenderer, ...options });
|
||||
setVesselStateForTest({ renderer: () => options.enabled === false ? null : vesselDisplay(options.engine), ...options });
|
||||
|
||||
test('open feed with vessels is healthy (null)', () => {
|
||||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||||
@@ -953,16 +961,17 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
};
|
||||
// The test renderer draws in no scene; the harness viewer's
|
||||
// trackedEntityChanged is its follow signal.
|
||||
const followViewer = {
|
||||
get trackedEntity() {
|
||||
return viewer.trackedEntity;
|
||||
},
|
||||
trackedEntityChanged,
|
||||
Object.defineProperty(wireEngine.camera, 'followChanged', { configurable: true, value: trackedEntityChanged });
|
||||
wireEngine.camera.following = () => viewer.trackedEntity ?? null;
|
||||
const pickedRecord = picked?.id && typeof picked.id === 'object' && Object.hasOwn(picked.id, 'mmsi') ? picked.id : null;
|
||||
const renderer = () => {
|
||||
const display = vesselDisplay(wireEngine);
|
||||
if (pickedRecord) {
|
||||
const handle = display.fleet.add({ id: pickedRecord, position: pickedRecord.position });
|
||||
picked = { id: handle.pickId };
|
||||
}
|
||||
return display;
|
||||
};
|
||||
const renderer = ({ billboardCollection }) =>
|
||||
({ ...vesselViewerRenderer({ viewer: followViewer, billboardCollection }), engine: wireEngine });
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
@@ -1413,7 +1422,8 @@ test('buildSelectedVesselCard: full detail card with MMSI + position time', () =
|
||||
|
||||
test('vessel host publication preserves the shipped grid winner and separation selector', () => {
|
||||
const publications = [];
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
const engine = createFakeEngine({ width: 1600, height: 900 });
|
||||
engine.projector.worldToWindow = (position) => position.screen;
|
||||
const makeCandidate = (mmsi, name, x, y, speed = 1) => makeRecord({
|
||||
mmsi,
|
||||
name,
|
||||
@@ -1423,7 +1433,6 @@ test('vessel host publication preserves the shipped grid winner and separation s
|
||||
const low = makeCandidate('100', 'VESSEL', 20, 20, 1);
|
||||
const winner = makeCandidate('200', 'NAMED WINNER', 40, 30, 16);
|
||||
const separated = makeCandidate('300', 'SEPARATED', 400, 300, 3);
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) => position.screen;
|
||||
_setVesselOverlayHostForTest({
|
||||
setEntries(sourceId, entries, options) { publications.push({ sourceId, entries, options }); },
|
||||
setVisible() {},
|
||||
@@ -1432,6 +1441,7 @@ test('vessel host publication preserves the shipped grid winner and separation s
|
||||
_setVesselStateForTest({
|
||||
viewer: { scene: { canvas: { clientWidth: 1600, clientHeight: 900 } } },
|
||||
records: [low, winner, separated],
|
||||
engine,
|
||||
});
|
||||
try {
|
||||
_updateVesselCardsForTest([low, winner, separated]);
|
||||
@@ -1444,7 +1454,7 @@ test('vessel host publication preserves the shipped grid winner and separation s
|
||||
assert.equal(publications[0].options.cohortLimit, 112);
|
||||
assert.equal(publications[0].options.collisionCapacity, 112);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
engine.destroy();
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
_setVesselOverlayHostForTest();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import militaryFlightsLayer, {
|
||||
_setCockpitDetectionSubjectForTest as setMilitaryCockpitSubject,
|
||||
_setTrackedMilitaryRefreshStateForTest,
|
||||
} from './militaryFlights.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
const SUBJECT = 'abc123';
|
||||
const NEXT_SUBJECT = 'def456';
|
||||
@@ -26,7 +26,9 @@ const LAYERS = [
|
||||
setSubject: setFlightsCockpitSubject,
|
||||
seed(icao24) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: candidateBillboard(icao24),
|
||||
@@ -48,7 +50,9 @@ const LAYERS = [
|
||||
setSubject: setMilitaryCockpitSubject,
|
||||
seed(icao24) {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: candidateBillboard(icao24),
|
||||
|
||||
+47
-75
@@ -28,8 +28,9 @@ import {
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from './trackedReadout.js';
|
||||
import { trackingEngine } from '../testSupport/viewerEngine.mjs';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createAircraftDisplay, aircraftAtlasLayout } from '../layers/aircraft/render/display.js';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
test('share-Follow absence requires an accepted OpenSky snapshot', async () => {
|
||||
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
|
||||
@@ -185,7 +186,9 @@ test('flights first update forwards caller cancellation into the feed request',
|
||||
|
||||
test('nonempty OpenSky payload with zero usable rows cannot prove share target absence', async () => {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'abc123',
|
||||
entity: { gevLabelModel: { title: 'WARM', details: [] } },
|
||||
billboard: { show: false },
|
||||
@@ -241,7 +244,9 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a
|
||||
};
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
billboard: {
|
||||
@@ -330,7 +335,9 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
|
||||
const billboardCollection = { show: false, remove() {} };
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity,
|
||||
billboard,
|
||||
@@ -411,45 +418,17 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
const icao24 = 'civ001';
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668);
|
||||
const now = Cesium.JulianDate.now();
|
||||
const entities = new Cesium.EntityCollection();
|
||||
const trackedEntityChanged = new Cesium.Event();
|
||||
let trackedEntity;
|
||||
let cancelledFlights = 0;
|
||||
let appliedFrames = 0;
|
||||
const viewer = {
|
||||
entities,
|
||||
trackedEntityChanged,
|
||||
clock: { currentTime: now },
|
||||
camera: {
|
||||
cancelFlight() {
|
||||
cancelledFlights += 1;
|
||||
},
|
||||
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
direction: Cesium.Cartesian3.UNIT_Y,
|
||||
transform: Cesium.Matrix4.IDENTITY,
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
lookAtTransform() {
|
||||
appliedFrames += 1;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
frameState: { frameNumber: 41, mode: Cesium.SceneMode.SCENE3D },
|
||||
preUpdate: new Cesium.Event(),
|
||||
screenSpaceCameraController: null,
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
Object.defineProperty(viewer, 'trackedEntity', {
|
||||
get: () => trackedEntity,
|
||||
set(value) {
|
||||
trackedEntity = value;
|
||||
trackedEntityChanged.raiseEvent(value);
|
||||
},
|
||||
const engine = createFakeEngine();
|
||||
let followCalls = 0;
|
||||
const follow = engine.camera.follow;
|
||||
engine.camera.follow = (options) => { followCalls++; return follow(options); };
|
||||
const layout = aircraftAtlasLayout();
|
||||
const renderer = createAircraftDisplay(engine, {
|
||||
layerId: 'flights',
|
||||
drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }),
|
||||
});
|
||||
viewer.scene.camera = viewer.camera;
|
||||
renderer.fleet.attach();
|
||||
renderer.fleet.setVisible(true);
|
||||
const billboard = {
|
||||
position,
|
||||
rotation: 0.3,
|
||||
@@ -478,17 +457,15 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
const engine = trackingEngine(viewer);
|
||||
const readoutServices = createSceneServices(engine, { trackedOverlay: host });
|
||||
try {
|
||||
initTrackedReadout(engine);
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: (options) => aircraftViewerRenderer({ ...options, engine }),
|
||||
renderer: () => renderer,
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer,
|
||||
tracked: false,
|
||||
history: [
|
||||
{
|
||||
@@ -525,27 +502,19 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
flightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
const entity = viewer.trackedEntity;
|
||||
assert.ok(
|
||||
entity instanceof Cesium.Entity,
|
||||
'trackById must create the real Cesium entity',
|
||||
);
|
||||
assert.equal(entity.label, undefined);
|
||||
assert.ok(
|
||||
entities.values.every((candidate) => candidate.label === undefined),
|
||||
);
|
||||
const entity = renderer.follow.current();
|
||||
assert.ok(entity, 'tracking creates a display handle');
|
||||
assert.deepEqual(entity.gevLabelModel, {
|
||||
title: 'N12345 · FL350 · 486 kts',
|
||||
details: ['TEST AIR · A320', 'AUS → LAX'],
|
||||
accent: '#39d0ff',
|
||||
});
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
const initialAppliedFrames = appliedFrames;
|
||||
const initialCancelledFlights = cancelledFlights;
|
||||
engine.fake.frame();
|
||||
const initialFollowCalls = followCalls;
|
||||
assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights,
|
||||
followCalls,
|
||||
initialFollowCalls,
|
||||
'ordinary repeated tracking stays camera-idempotent',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -565,21 +534,16 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
'same-target refocus forwards explicit authority',
|
||||
);
|
||||
assert.equal(flightsLayer.refocusTrackedById(icao24), true);
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
engine.fake.frame();
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
renderer.follow.current(),
|
||||
entity,
|
||||
'refocus must retain the exact tracked entity',
|
||||
);
|
||||
assert.equal(
|
||||
appliedFrames,
|
||||
initialAppliedFrames + 1,
|
||||
'repeated refocus keeps one camera-frame owner',
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights + 2,
|
||||
'only explicit refocus requests cancel camera flights',
|
||||
followCalls,
|
||||
initialFollowCalls + 2,
|
||||
'only explicit refocus requests restart following',
|
||||
);
|
||||
const publication = publications.at(-1);
|
||||
assert.equal(publication.sourceId, 'tracked');
|
||||
@@ -589,7 +553,7 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
assert.equal(entry.title, entity.gevLabelModel.title);
|
||||
assert.deepEqual(entry.details, entity.gevLabelModel.details);
|
||||
|
||||
const display = entity.position.getValue(now);
|
||||
const display = entity.spec.position();
|
||||
assert.ok(display, 'tracked position callback must seed its frame cache');
|
||||
assert.equal(
|
||||
entry.position(),
|
||||
@@ -710,9 +674,11 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
assert.equal(flightsLayer.getParams().selectedFlightsTrackingId, 'late005');
|
||||
} finally {
|
||||
flightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
engine.fake.frame();
|
||||
destroyTrackedReadout(engine);
|
||||
readoutServices.destroy();
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
globalThis.fetch = realFetch;
|
||||
globalThis.window = realWindow;
|
||||
}
|
||||
@@ -743,7 +709,9 @@ function seedLabelContact({
|
||||
meta: metaOverrides = {},
|
||||
}) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: LABEL_ICAO,
|
||||
entity: null,
|
||||
billboard: {
|
||||
@@ -952,7 +920,9 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: [] } };
|
||||
seedLabelContact({ callsign: ' ', registration: ' N123AB ', tracked: true });
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: LABEL_ICAO,
|
||||
entity,
|
||||
billboard: {
|
||||
@@ -1091,7 +1061,9 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi
|
||||
};
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
billboard: {
|
||||
|
||||
@@ -21,10 +21,7 @@
|
||||
* keeps rotation continuous through tracked orbits; field evidence, not an
|
||||
* unverified math swap, decides whether exact projection becomes preferred.
|
||||
*/
|
||||
import {
|
||||
worldToWindowCoordinates,
|
||||
horizonOccluder,
|
||||
} from '../globe/cesium/sceneProjection.js';
|
||||
import { worldToWindowCoordinates } from '../globe/cesium/sceneProjection.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
|
||||
// A window position through the engine projector the scene carries (the
|
||||
@@ -148,7 +145,7 @@ const _wgs84OneOverRadii = geo.ellipsoid.WGS84.oneOverRadii;
|
||||
* ABOVE the ellipsoid the horizon is the tangent silhouette and the test is a
|
||||
* genuine ray/ellipsoid hit test: 1 means the view ray through the point misses
|
||||
* the planet entirely. Here the function is the exact complement of
|
||||
* {@link horizonOccluder} — the occluder asks whether the planet is in FRONT of
|
||||
* the horizon visibility test — the occluder asks whether the planet is in FRONT of
|
||||
* a point (cull it), this asks whether it is BEHIND (what a screen-space label
|
||||
* anchored there is read against). For any point the occluder keeps, a ray that
|
||||
* hits the ellipsoid must hit it beyond the point, so the hit/miss test alone
|
||||
@@ -252,7 +249,6 @@ export function skyBackdropFactor(
|
||||
* @param {Cesium.Camera} camera - The scene camera.
|
||||
* @returns {Cesium.EllipsoidalOccluder}
|
||||
*/
|
||||
export { horizonOccluder };
|
||||
|
||||
/**
|
||||
* Cheap camera pose signature for "did the camera move" gating of rotation
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
HORIZON_FEATHER_RAD,
|
||||
horizonOccluder,
|
||||
screenProjectedRotation,
|
||||
skyBackdropFactor,
|
||||
stabilizeScreenRotation,
|
||||
@@ -483,7 +482,7 @@ test('below the surface the backdrop test agrees with the occluder beside it', (
|
||||
// halves use that plane. The old fail-closed broke exactly this: the occluder
|
||||
// kept a contact against sky and the backdrop test called it ground.
|
||||
const camera = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, -18);
|
||||
const occluder = horizonOccluder({ positionWC: camera });
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(camera);
|
||||
const offsetBy = (up) =>
|
||||
Cesium.Cartesian3.add(
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
presentAwarenessDistance,
|
||||
} from '../layers/awareness/panel.js';
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
import { registerLayerRenderers } from '../app/layers/renderers.js';
|
||||
|
||||
// The layer's view of a stub viewer (render/display.js's interface), drawn
|
||||
@@ -979,7 +979,9 @@ test('Contacts activation adopts the production military tracked-subject descrip
|
||||
|
||||
try {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1062,7 +1064,9 @@ test('Contacts activation adopts the production military tracked-subject descrip
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1090,7 +1094,9 @@ test('production military tracked-subject label falls back to registration befor
|
||||
|
||||
try {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1144,7 +1150,9 @@ test('production military tracked-subject label falls back to registration befor
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1191,7 +1199,9 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
|
||||
try {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, color: Cesium.Color.WHITE, show: true },
|
||||
@@ -1280,7 +1290,9 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1303,7 +1315,9 @@ test('Contacts activation adopts the production civilian tracked-subject descrip
|
||||
|
||||
try {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1393,7 +1407,9 @@ test('Contacts activation adopts the production civilian tracked-subject descrip
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
registerMilitaryIcaos,
|
||||
setMilitaryLayerActive,
|
||||
} from './militaryRegistry.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
const SHARED_HEX = 'ae5e01';
|
||||
const CIVIL_HEX = 'a0c1v1';
|
||||
@@ -85,7 +85,9 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita
|
||||
const civilBillboards = collection();
|
||||
_armFlightTrackingRestoreForTest(null);
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'seedm0',
|
||||
entity: null,
|
||||
billboard: { show: false, position: Cesium.Cartesian3.ZERO },
|
||||
@@ -95,7 +97,9 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita
|
||||
tracked: false,
|
||||
});
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'seedf0',
|
||||
entity: null,
|
||||
billboard: { show: false, position: Cesium.Cartesian3.ZERO },
|
||||
@@ -173,8 +177,10 @@ test('edge:military: losing the tracked contact from the owning feed releases th
|
||||
entities: { add: (value) => value, remove: () => true },
|
||||
trackedEntity: entity,
|
||||
};
|
||||
const renderer = recordingAircraftRenderer(null, { fleet: { shown: true } });
|
||||
renderer.follow.start(entity);
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: () => renderer,
|
||||
icao24: SHARED_HEX,
|
||||
entity,
|
||||
billboard: {
|
||||
@@ -214,7 +220,7 @@ test('edge:military: losing the tracked contact from the owning feed releases th
|
||||
}
|
||||
assert.equal(militaryFlightsLayer.hasContact(SHARED_HEX), false);
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
renderer.follow.current(),
|
||||
undefined,
|
||||
'the follow camera is released',
|
||||
);
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from './trackedReadout.js';
|
||||
import { trackingEngine } from '../testSupport/viewerEngine.mjs';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createAircraftDisplay, aircraftAtlasLayout } from '../layers/aircraft/render/display.js';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
test('share-Follow absence requires an accepted adsb.lol snapshot', async () => {
|
||||
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
|
||||
@@ -148,7 +149,9 @@ test('military first update forwards caller cancellation into the feed request',
|
||||
|
||||
test('nonempty adsb.lol payload with zero usable rows cannot prove share target absence', async () => {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'ae1234',
|
||||
entity: { gevLabelModel: { title: 'WARM', details: [] } },
|
||||
billboard: { show: false },
|
||||
@@ -195,7 +198,9 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
|
||||
const billboardCollection = { show: false, remove() {} };
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity,
|
||||
billboard,
|
||||
@@ -267,7 +272,9 @@ test('cached military snapshots retain their observation time and expose stale f
|
||||
const history = [];
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'ae01ce',
|
||||
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
|
||||
billboard: {
|
||||
@@ -339,45 +346,17 @@ test('real military track path creates no native label and publishes every cache
|
||||
const icao24 = 'ae01ce';
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.03, 31.05, 8_534.4);
|
||||
const now = Cesium.JulianDate.now();
|
||||
const entities = new Cesium.EntityCollection();
|
||||
const trackedEntityChanged = new Cesium.Event();
|
||||
let trackedEntity;
|
||||
let cancelledFlights = 0;
|
||||
let appliedFrames = 0;
|
||||
const viewer = {
|
||||
entities,
|
||||
trackedEntityChanged,
|
||||
clock: { currentTime: now },
|
||||
camera: {
|
||||
cancelFlight() {
|
||||
cancelledFlights += 1;
|
||||
},
|
||||
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
direction: Cesium.Cartesian3.UNIT_Y,
|
||||
transform: Cesium.Matrix4.IDENTITY,
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
lookAtTransform() {
|
||||
appliedFrames += 1;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
frameState: { frameNumber: 61, mode: Cesium.SceneMode.SCENE3D },
|
||||
preUpdate: new Cesium.Event(),
|
||||
screenSpaceCameraController: null,
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
viewer.scene.camera = viewer.camera;
|
||||
Object.defineProperty(viewer, 'trackedEntity', {
|
||||
get: () => trackedEntity,
|
||||
set(value) {
|
||||
trackedEntity = value;
|
||||
trackedEntityChanged.raiseEvent(value);
|
||||
},
|
||||
const engine = createFakeEngine();
|
||||
let followCalls = 0;
|
||||
const follow = engine.camera.follow;
|
||||
engine.camera.follow = (options) => { followCalls++; return follow(options); };
|
||||
const layout = aircraftAtlasLayout();
|
||||
const renderer = createAircraftDisplay(engine, {
|
||||
layerId: 'military',
|
||||
drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }),
|
||||
});
|
||||
renderer.fleet.attach();
|
||||
renderer.fleet.setVisible(true);
|
||||
const billboard = {
|
||||
position,
|
||||
rotation: 0.4,
|
||||
@@ -406,17 +385,15 @@ test('real military track path creates no native label and publishes every cache
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
const engine = trackingEngine(viewer);
|
||||
const readoutServices = createSceneServices(engine, { trackedOverlay: host });
|
||||
try {
|
||||
initTrackedReadout(engine);
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: (options) => aircraftViewerRenderer({ ...options, engine }),
|
||||
renderer: () => renderer,
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer,
|
||||
tracked: false,
|
||||
history: [
|
||||
{
|
||||
@@ -440,30 +417,22 @@ test('real military track path creates no native label and publishes every cache
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
const entity = viewer.trackedEntity;
|
||||
assert.ok(
|
||||
entity instanceof Cesium.Entity,
|
||||
'trackById must create the real Cesium entity',
|
||||
);
|
||||
assert.equal(entity.label, undefined);
|
||||
assert.ok(
|
||||
entities.values.every((candidate) => candidate.label === undefined),
|
||||
);
|
||||
const entity = renderer.follow.current();
|
||||
assert.ok(entity, 'tracking creates a display handle');
|
||||
assert.deepEqual(entity.gevLabelModel, {
|
||||
title: 'RCH451',
|
||||
details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
|
||||
accent: '#ffd166',
|
||||
});
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
const initialAppliedFrames = appliedFrames;
|
||||
const initialCancelledFlights = cancelledFlights;
|
||||
engine.fake.frame();
|
||||
const initialFollowCalls = followCalls;
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights,
|
||||
followCalls,
|
||||
initialFollowCalls,
|
||||
'ordinary repeated tracking stays camera-idempotent',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -486,21 +455,16 @@ test('real military track path creates no native label and publishes every cache
|
||||
'same-target refocus forwards explicit authority',
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
engine.fake.frame();
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
renderer.follow.current(),
|
||||
entity,
|
||||
'refocus must retain the exact tracked entity',
|
||||
);
|
||||
assert.equal(
|
||||
appliedFrames,
|
||||
initialAppliedFrames + 1,
|
||||
'repeated refocus keeps one camera-frame owner',
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights + 2,
|
||||
'only explicit refocus requests cancel camera flights',
|
||||
followCalls,
|
||||
initialFollowCalls + 2,
|
||||
'only explicit refocus requests restart following',
|
||||
);
|
||||
const publication = publications.at(-1);
|
||||
assert.equal(publication.sourceId, 'tracked');
|
||||
@@ -510,7 +474,7 @@ test('real military track path creates no native label and publishes every cache
|
||||
assert.equal(entry.title, entity.gevLabelModel.title);
|
||||
assert.deepEqual(entry.details, entity.gevLabelModel.details);
|
||||
|
||||
const display = entity.position.getValue(now);
|
||||
const display = entity.spec.position();
|
||||
assert.ok(display, 'tracked position callback must seed its frame cache');
|
||||
assert.equal(
|
||||
entry.position(),
|
||||
@@ -649,9 +613,11 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
} finally {
|
||||
militaryFlightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
engine.fake.frame();
|
||||
destroyTrackedReadout(engine);
|
||||
readoutServices.destroy();
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
globalThis.fetch = realFetch;
|
||||
globalThis.window = realWindow;
|
||||
}
|
||||
@@ -851,7 +817,9 @@ test('the military tracked descriptor publishes the reported vertical rate', ()
|
||||
// every military contact as level flight.
|
||||
const seedRate = (verticalRateMps) =>
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'ae7f01',
|
||||
entity: { gevLabelModel: { title: 'TEST', details: [] } },
|
||||
billboard: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, { _setTrackedFlightRefreshStateForTest } from './flights.js';
|
||||
import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
const ICAO = 'abc123';
|
||||
const OTHER = 'def456';
|
||||
@@ -42,7 +42,9 @@ const LAYERS = [
|
||||
visualCenterM: Cesium.Cartesian3.ZERO,
|
||||
seed({ billboardShow, models = [] }) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: ICAO,
|
||||
entity: null,
|
||||
billboard: contactBillboard(billboardShow),
|
||||
@@ -65,7 +67,9 @@ const LAYERS = [
|
||||
visualCenterM: Cesium.Cartesian3.ZERO,
|
||||
seed({ billboardShow, models = [] }) {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: ICAO,
|
||||
entity: null,
|
||||
billboard: contactBillboard(billboardShow),
|
||||
|
||||
@@ -33,7 +33,7 @@ import militaryFlightsLayer, {
|
||||
import { findCompatibleHistoryIndex } from './militaryAwareness.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { ANALYST_LAYERS, createAnalystEngine } from './analystEngine.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
/** Strip block and line comments so source pins scan CODE, not prose. */
|
||||
function stripComments(source) {
|
||||
@@ -162,7 +162,9 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
|
||||
};
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity,
|
||||
billboard,
|
||||
@@ -297,7 +299,9 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
|
||||
setTr3b(icao24, true);
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: {
|
||||
@@ -462,7 +466,9 @@ test('cockpit class filter matches a converted contact end to end', async () =>
|
||||
|
||||
// 2) Real getNearby record for a real (converted) contact in the layer.
|
||||
const seed = () => _setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: {
|
||||
@@ -509,7 +515,9 @@ test('military records and detection cards agree with the conversion', () => {
|
||||
const icao24 = 'ae01ce';
|
||||
const center = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 200);
|
||||
const seed = () => _setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
FLEET_MODEL_ALT_CEIL_M,
|
||||
TRACKED_MODEL_EXIT_RATIO,
|
||||
} from './trackedModelRegime.js';
|
||||
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
@@ -46,7 +45,7 @@ import militaryFlightsLayer, {
|
||||
} from './militaryFlights.js';
|
||||
import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js';
|
||||
import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
// Sample altitudes are expressed RELATIVE to the band rather than as absolute
|
||||
// offsets. The thresholds have already been retuned once (1_000_000 → 150_000
|
||||
@@ -173,9 +172,7 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef
|
||||
trackedEntity: undefined,
|
||||
entities: { remove() {} },
|
||||
};
|
||||
// The ground snap samples through the viewer's engine (the test
|
||||
// renderer's surfaceOwner, engineFromViewer): the engine surface over this stub's tileset and scene.sampleHeight.
|
||||
registerViewerEngine(viewer, {
|
||||
viewer.engine = {
|
||||
isDestroyed: () => false,
|
||||
surface: {
|
||||
tilesLoaded: () => viewer.tileset.tilesLoaded === true,
|
||||
@@ -184,7 +181,7 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef
|
||||
return Number.isFinite(h) ? { height: h, source: 'tiles' } : undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
return viewer;
|
||||
}
|
||||
|
||||
@@ -207,6 +204,28 @@ function recordingModelCollection() {
|
||||
return { added, add(m) { added.push(m); }, remove() {}, isDestroyed() { return false; } };
|
||||
}
|
||||
|
||||
let loadModel = () => Promise.reject(new Error('No model fixture'));
|
||||
function rendererFor(viewer, collection) {
|
||||
return recordingAircraftRenderer(viewer.engine, {
|
||||
view: { camera: viewer.camera, scene: viewer.scene },
|
||||
fleet: { shown: true },
|
||||
models: {
|
||||
live: () => Boolean(collection) && !collection.isDestroyed(),
|
||||
loadFleet: (spec) => loadModel(spec),
|
||||
loadTracked: (spec) => loadModel(spec),
|
||||
preload: (url) => loadModel({ url }),
|
||||
add: (model) => collection.add(model),
|
||||
remove: (model) => collection?.remove(model),
|
||||
destroy: (model) => model.destroy(),
|
||||
setPose(model, position, headingDeg, offsetDeg = 180) {
|
||||
return Cesium.Transforms.headingPitchRollToFixedFrame(position,
|
||||
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians((headingDeg || 0) + offsetDeg), 0, 0),
|
||||
Cesium.Ellipsoid.WGS84, undefined, model.modelMatrix);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const LAYERS = [
|
||||
{
|
||||
name: 'flights',
|
||||
@@ -220,7 +239,7 @@ const LAYERS = [
|
||||
seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) {
|
||||
const billboard = contactBillboard();
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: () => rendererFor(viewer, modelCollection),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
@@ -246,7 +265,7 @@ const LAYERS = [
|
||||
seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) {
|
||||
const billboard = contactBillboard();
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: () => rendererFor(viewer, modelCollection),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
@@ -268,7 +287,7 @@ for (const fixture of LAYERS) {
|
||||
fixture.layer.stopTracking();
|
||||
fixture.layer.setParams({ models3d: true });
|
||||
|
||||
const realFrom = Cesium.Model.fromGltfAsync;
|
||||
const realFrom = loadModel;
|
||||
const viewer = viewerAtHeight(30_000, { tilesLoaded: false });
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
|
||||
const loadedCollection = recordingModelCollection();
|
||||
@@ -282,7 +301,7 @@ for (const fixture of LAYERS) {
|
||||
|
||||
try {
|
||||
const loadingIcao = fixture.name === 'flights' ? 'a0f201' : 'b0f201';
|
||||
Cesium.Model.fromGltfAsync = () => Promise.resolve(loadingModel);
|
||||
loadModel = () => Promise.resolve(loadingModel);
|
||||
const billboard = fixture.seed({
|
||||
viewer,
|
||||
tracked: false,
|
||||
@@ -316,7 +335,7 @@ for (const fixture of LAYERS) {
|
||||
|
||||
const failedIcao = fixture.name === 'flights' ? 'a0f202' : 'b0f202';
|
||||
const failedCollection = recordingModelCollection();
|
||||
Cesium.Model.fromGltfAsync = () => Promise.reject(new Error('simulated fleet GLB failure'));
|
||||
loadModel = () => Promise.reject(new Error('simulated fleet GLB failure'));
|
||||
const failedBillboard = fixture.seed({
|
||||
viewer,
|
||||
tracked: false,
|
||||
@@ -333,7 +352,7 @@ for (const fixture of LAYERS) {
|
||||
.some((object) => object.icao24 === failedIcao),
|
||||
'fleet load rejection leaves the contact in the billboard proximity cohort');
|
||||
} finally {
|
||||
Cesium.Model.fromGltfAsync = realFrom;
|
||||
loadModel = realFrom;
|
||||
fixture.layer.setParams({ models3d: false });
|
||||
fixture.layer.stopTracking();
|
||||
}
|
||||
@@ -593,7 +612,7 @@ for (const fixture of LAYERS) {
|
||||
fixture.layer.stopTracking();
|
||||
fixture.layer.setParams({ models3d: false });
|
||||
|
||||
const realFrom = Cesium.Model.fromGltfAsync;
|
||||
const realFrom = loadModel;
|
||||
const realNow = Date.now;
|
||||
let now = 3_000_000;
|
||||
Date.now = () => now;
|
||||
@@ -611,7 +630,7 @@ for (const fixture of LAYERS) {
|
||||
scale: 1,
|
||||
destroy() {},
|
||||
};
|
||||
Cesium.Model.fromGltfAsync = () => Promise.resolve(model);
|
||||
loadModel = () => Promise.resolve(model);
|
||||
|
||||
try {
|
||||
const billboard = fixture.seed({
|
||||
@@ -704,7 +723,7 @@ for (const fixture of LAYERS) {
|
||||
assert.equal(fixture.trackedBillboardColor().alpha, 1,
|
||||
'there is no invisible-owner gap while the replacement snap is unavailable');
|
||||
} finally {
|
||||
Cesium.Model.fromGltfAsync = realFrom;
|
||||
loadModel = realFrom;
|
||||
Date.now = realNow;
|
||||
fixture.layer.stopTracking();
|
||||
}
|
||||
@@ -851,14 +870,14 @@ for (const fixture of LAYERS) {
|
||||
clearTr3bRegistry();
|
||||
fixture.layer.setParams({ models3d: false });
|
||||
|
||||
const realFrom = Cesium.Model.fromGltfAsync;
|
||||
const realFrom = loadModel;
|
||||
const realNow = Date.now;
|
||||
let now = 1_000_000;
|
||||
const clock = { advance(ms) { now += ms; } };
|
||||
Date.now = () => now;
|
||||
let attempts = 0;
|
||||
const requested = [];
|
||||
Cesium.Model.fromGltfAsync = ({ url }) => {
|
||||
loadModel = ({ url }) => {
|
||||
attempts++; requested.push(url);
|
||||
return Promise.reject(new Error('simulated missing GLB'));
|
||||
};
|
||||
@@ -901,7 +920,7 @@ for (const fixture of LAYERS) {
|
||||
await driveFrames(fixture, 3, clock);
|
||||
assert.ok(attempts > settled, 'selecting another contact restores the retry budget');
|
||||
} finally {
|
||||
Cesium.Model.fromGltfAsync = realFrom;
|
||||
loadModel = realFrom;
|
||||
Date.now = realNow;
|
||||
console.warn = realWarn;
|
||||
fixture.layer.stopTracking();
|
||||
@@ -912,14 +931,14 @@ for (const fixture of LAYERS) {
|
||||
clearTr3bRegistry();
|
||||
fixture.layer.setParams({ models3d: false });
|
||||
|
||||
const realFrom = Cesium.Model.fromGltfAsync;
|
||||
const realFrom = loadModel;
|
||||
const realNow = Date.now;
|
||||
const realWarn = console.warn;
|
||||
let now = 2_000_000;
|
||||
const clock = { advance(ms) { now += ms; } };
|
||||
Date.now = () => now;
|
||||
let attempts = 0;
|
||||
Cesium.Model.fromGltfAsync = () => { attempts++; return Promise.reject(new Error('simulated missing GLB')); };
|
||||
loadModel = () => { attempts++; return Promise.reject(new Error('simulated missing GLB')); };
|
||||
console.warn = () => {};
|
||||
|
||||
try {
|
||||
@@ -938,7 +957,7 @@ for (const fixture of LAYERS) {
|
||||
assert.ok(attempts > latched,
|
||||
're-tracking the same contact is a new selection and gets its retries back');
|
||||
} finally {
|
||||
Cesium.Model.fromGltfAsync = realFrom;
|
||||
loadModel = realFrom;
|
||||
Date.now = realNow;
|
||||
console.warn = realWarn;
|
||||
fixture.layer.stopTracking();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { createTrail } from '../globe/cesium/trail.js';
|
||||
@@ -1,39 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createTrail } from './trailRenderer.js';
|
||||
|
||||
test('trail visibility can change without discarding its accumulated geometry', () => {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const viewer = {
|
||||
isDestroyed: () => false,
|
||||
entities: {
|
||||
add(definition) {
|
||||
added.push(definition);
|
||||
return definition;
|
||||
},
|
||||
remove(entity) {
|
||||
removed.push(entity);
|
||||
},
|
||||
},
|
||||
};
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const positions = [
|
||||
new Cesium.Cartesian3(1, 2, 3),
|
||||
new Cesium.Cartesian3(4, 5, 6),
|
||||
];
|
||||
|
||||
trail.setVisible(false);
|
||||
trail.setPositions(positions);
|
||||
assert.equal(added.length, 1);
|
||||
assert.equal(added[0].show, false);
|
||||
assert.deepEqual(added[0].polyline.positions.getValue(), positions);
|
||||
|
||||
trail.setVisible(true);
|
||||
assert.equal(added[0].show, true);
|
||||
assert.deepEqual(added[0].polyline.positions.getValue(), positions);
|
||||
|
||||
trail.destroy();
|
||||
assert.deepEqual(removed, [added[0]]);
|
||||
});
|
||||
@@ -56,11 +56,6 @@ test('the engine draws the visual styles, bloom and contact sonar, not the cloud
|
||||
bloom: true,
|
||||
cloudCover: false,
|
||||
contactSonar: true,
|
||||
movers: true,
|
||||
lines: true,
|
||||
ground: true,
|
||||
imagery: true,
|
||||
meshes: true,
|
||||
});
|
||||
assert.equal(engine.contactSonarDiagnostics().contacts, 0);
|
||||
engine.destroy();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// The bridge for shell callers that hold only a Cesium viewer: the engine
|
||||
// registered for that viewer. The app registers
|
||||
// its scene engine (src/app/globeEngine.js) and unregisters it before
|
||||
// destroying it. Nothing is ever built here, so no engine can outlive its
|
||||
// viewer without a destroy path: an unregistered or torn-down viewer has none.
|
||||
|
||||
import { isGlobeEngine } from '../contract.js';
|
||||
|
||||
const engines = new WeakMap();
|
||||
|
||||
/**
|
||||
* Make `engine` the one returned for `viewer`.
|
||||
* @returns {() => void} Unregister (only while it is still the registered one).
|
||||
*/
|
||||
export function registerViewerEngine(viewer, engine) {
|
||||
// The viewer's scene and Cesium camera name the same engine, so callers
|
||||
// that hold only viewer.scene (surface samplers) or viewer.camera (camera
|
||||
// sensitivity claims) reach the engine.
|
||||
const keys = [
|
||||
viewer,
|
||||
viewer?.scene,
|
||||
viewer?.camera,
|
||||
viewer?.scene?.camera,
|
||||
].filter((key, index, all) => key != null && all.indexOf(key) === index);
|
||||
for (const key of keys) engines.set(key, engine);
|
||||
return () => {
|
||||
for (const key of keys)
|
||||
if (engines.get(key) === engine) engines.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object | null | undefined} viewer A Cesium viewer, its scene or
|
||||
* camera, or an engine (returned as is).
|
||||
* @returns {import('../contract.js').GlobeEngine | null} The registered
|
||||
* engine, or null.
|
||||
*/
|
||||
export function engineFromViewer(viewer) {
|
||||
if (viewer == null) return null;
|
||||
if (isGlobeEngine(viewer)) return viewer;
|
||||
return engines.get(viewer) ?? null;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isGlobeEngine } from '../contract.js';
|
||||
import { createFakeEngine } from '../testing/fakeEngine.js';
|
||||
import { engineFromViewer, registerViewerEngine } from './engineFromViewer.js';
|
||||
import { createFakeViewer } from './testing/fakeViewer.mjs';
|
||||
|
||||
test('a viewer without a registered engine has none: nothing is built', () => {
|
||||
const viewer = createFakeViewer();
|
||||
assert.equal(engineFromViewer(viewer), null);
|
||||
assert.deepEqual(viewer.calls, []);
|
||||
});
|
||||
|
||||
test('the engine registered for a viewer is returned until it is unregistered', () => {
|
||||
const viewer = createFakeViewer();
|
||||
const engine = createFakeEngine();
|
||||
const unregister = registerViewerEngine(viewer, engine);
|
||||
assert.equal(engineFromViewer(viewer), engine);
|
||||
unregister();
|
||||
assert.equal(engineFromViewer(viewer), null, 'a torn-down viewer has none');
|
||||
const next = createFakeEngine();
|
||||
registerViewerEngine(viewer, next);
|
||||
unregister();
|
||||
assert.equal(
|
||||
engineFromViewer(viewer),
|
||||
next,
|
||||
'a stale unregister keeps the new one',
|
||||
);
|
||||
});
|
||||
|
||||
test('an engine passes through, and no viewer is no engine', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineFromViewer(engine), engine);
|
||||
assert.equal(engineFromViewer(null), null);
|
||||
assert.equal(engineFromViewer(undefined), null);
|
||||
});
|
||||
|
||||
test('isGlobeEngine tells engines from viewers', () => {
|
||||
assert.equal(isGlobeEngine(createFakeEngine()), true);
|
||||
assert.equal(isGlobeEngine(createFakeViewer()), false);
|
||||
assert.equal(isGlobeEngine(null), false);
|
||||
assert.equal(isGlobeEngine({ kind: 'cesium' }), false);
|
||||
});
|
||||
@@ -8,20 +8,16 @@ import { createEngineEvent } from '../contract.js';
|
||||
/**
|
||||
* @param {{ addEventListener: (listener: Function) => () => void }} source A
|
||||
* Cesium.Event (its addEventListener returns the remover the bridge uses).
|
||||
* @param {((...args: any[]) => any[]) | null} [mapArgs] Cesium arguments →
|
||||
* contract arguments.
|
||||
* @param {{ unary?: (...args: any[]) => any }} [options] `unary` maps the
|
||||
* Cesium arguments to the one contract argument without building an
|
||||
* argument list (for per-frame events).
|
||||
* @returns {{ event: import('../contract.js').EngineEvent, detach: () => void }}
|
||||
*/
|
||||
export function bridgeEvent(source, mapArgs, { unary } = {}) {
|
||||
export function bridgeEvent(source, { unary } = {}) {
|
||||
const event = createEngineEvent();
|
||||
const forward = unary
|
||||
? (a, b, c) => event.raiseValue(unary(a, b, c))
|
||||
: mapArgs
|
||||
? (...args) => event.raiseEvent(...mapArgs(...args))
|
||||
: (...args) => event.raiseEvent(...args);
|
||||
: (...args) => event.raiseEvent(...args);
|
||||
let removeForward = null;
|
||||
let detached = false;
|
||||
const sync = () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ test('a bridged event is a contract event that subscribes to Cesium lazily', ()
|
||||
|
||||
test('a bridged event maps Cesium arguments and keeps contract semantics', () => {
|
||||
const source = new Cesium.Event();
|
||||
const { event } = bridgeEvent(source, (scene, time) => [time * 2]);
|
||||
const { event } = bridgeEvent(source, { unary: (scene, time) => time * 2 });
|
||||
const seen = [];
|
||||
const listener = (value) => seen.push(value);
|
||||
event.addEventListener(listener);
|
||||
@@ -84,7 +84,7 @@ test('a bridge detaches through the remover the source returned', () => {
|
||||
|
||||
test('a unary bridge raises one mapped value', () => {
|
||||
const source = new Cesium.Event();
|
||||
const { event, detach } = bridgeEvent(source, null, {
|
||||
const { event, detach } = bridgeEvent(source, {
|
||||
unary: (scene, time) => time * 2,
|
||||
});
|
||||
const seen = [];
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createCesiumFrames(viewer) {
|
||||
const { scene } = viewer;
|
||||
const bridges = ['preUpdate', 'preRender', 'postRender'].map((name) => [
|
||||
name,
|
||||
bridgeEvent(scene[name], null, { unary: unixMilliseconds }),
|
||||
bridgeEvent(scene[name], { unary: unixMilliseconds }),
|
||||
]);
|
||||
return {
|
||||
frames: Object.fromEntries(
|
||||
|
||||
@@ -106,7 +106,7 @@ czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
}`;
|
||||
|
||||
/** The uniforms of an animated corridor's material (pure, for tests). */
|
||||
export function corridorUniforms(item) {
|
||||
function corridorUniforms(item) {
|
||||
const { glow, pulse, dash } = item.animation;
|
||||
return {
|
||||
color: colorOf(item.fill),
|
||||
@@ -146,7 +146,7 @@ const corridorMaterial = (item) =>
|
||||
});
|
||||
|
||||
/** The radial disc material, including its soft outside edge. */
|
||||
export function radialMaterial(item) {
|
||||
function radialMaterial(item) {
|
||||
return new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'GevGroundRadial',
|
||||
@@ -181,7 +181,7 @@ function positionsOf(flat) {
|
||||
const longitude = (degrees) => degrees - 360 * Math.round(degrees / 360);
|
||||
|
||||
/** The fill geometry of one item of a set of `kind` (pure, for tests). */
|
||||
export function fillGeometry(kind, item) {
|
||||
function fillGeometry(kind, item) {
|
||||
const vertexFormat = item.radial
|
||||
? Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat
|
||||
: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT;
|
||||
|
||||
@@ -70,7 +70,7 @@ const toColor = ([red, green, blue, alpha]) =>
|
||||
* @param {Cesium.Matrix4} [result]
|
||||
* @returns {Cesium.Matrix4}
|
||||
*/
|
||||
export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
|
||||
function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
|
||||
const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3());
|
||||
const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3());
|
||||
|
||||
@@ -27,7 +27,7 @@ const NO_HOST = Object.freeze({ collection: null, surface: 'none' });
|
||||
/** Delay before the one frame that retries tiles a provider deferred. */
|
||||
export const TILE_RETRY_DELAY_MS = 250;
|
||||
/** How often a staged source waiting out its quiet interval rechecks readiness. */
|
||||
export const STAGE_WAKE_MS = 150;
|
||||
const STAGE_WAKE_MS = 150;
|
||||
// A staged source's own tiles are in once no request was made or settled
|
||||
// for this long; two frames in a row must find it so.
|
||||
const STAGE_QUIET_MS = 200;
|
||||
@@ -118,7 +118,7 @@ function singleImageProvider(image, rectangle, credit) {
|
||||
* for one frame when it settles), and a request the provider itself
|
||||
* deferred goes through one coalesced delayed retry.
|
||||
*/
|
||||
export function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
const original = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (admission.inFlight >= admission.limit) return undefined;
|
||||
|
||||
@@ -66,7 +66,7 @@ const sameEdges = (a, b) =>
|
||||
|
||||
/** Keep the shells first in the primitive collection, by rank: higher ranks
|
||||
* draw over lower ones and other opaque content draws over both. */
|
||||
export function orderImageryShells(primitives, primitive, rank) {
|
||||
function orderImageryShells(primitives, primitive, rank) {
|
||||
ranks.set(primitive, rank);
|
||||
if (typeof primitives.lowerToBottom !== 'function') return;
|
||||
const shells = [];
|
||||
|
||||
@@ -78,7 +78,7 @@ const nearFar = (value) =>
|
||||
* A model set's colour blend as Cesium Model options. Without colorBlend the
|
||||
* instance colour only sets the alpha (MIX by 0), as on Navara.
|
||||
*/
|
||||
export function modelBlend(colorBlend) {
|
||||
function modelBlend(colorBlend) {
|
||||
if (!colorBlend)
|
||||
return { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 0 };
|
||||
return colorBlend.mode === 'replace'
|
||||
|
||||
@@ -52,7 +52,7 @@ const BLOOM_UNIFORMS = [
|
||||
* Samples a 3x3 neighborhood, computes box blur, then adds the
|
||||
* difference (center - blur) scaled by `amount` for edge enhancement.
|
||||
*/
|
||||
export const SHARPEN_SHADER = /* glsl */ `
|
||||
const SHARPEN_SHADER = /* glsl */ `
|
||||
uniform sampler2D colorTexture;
|
||||
uniform vec2 colorTextureDimensions;
|
||||
uniform float amount;
|
||||
|
||||
@@ -6,12 +6,3 @@ export function worldToWindowCoordinates(scene, position, result) {
|
||||
result,
|
||||
);
|
||||
}
|
||||
let occluder;
|
||||
export function horizonOccluder(camera) {
|
||||
occluder ??= new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
occluder.cameraPosition = camera.positionWC;
|
||||
return occluder;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* @module trailRenderer
|
||||
* @description Shared track-history renderer (PRD WS-F F4; rebuilt round 6,
|
||||
* 2026-07-06).
|
||||
*
|
||||
* One trail = one ENTITY polyline. Round 6 replaced the faded per-vertex
|
||||
* Primitive for two owner directives from the field:
|
||||
* - "the line must ALWAYS be visible": the Primitive's depthFailAppearance
|
||||
* did not reliably render segments below the photoreal mesh — entity
|
||||
* polylines with `depthFailMaterial` DO (in-repo proof: CCTV's frustum
|
||||
* wireframes read through geometry with exactly this), so occluded
|
||||
* segments now draw dimmed instead of vanishing.
|
||||
* - "show actual tracks, don't style them too much": the tail-fade is gone;
|
||||
* the whole history renders at one readable alpha (dimmer where it passes
|
||||
* behind/below geometry).
|
||||
*
|
||||
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
|
||||
* assigning a fresh positions array per update is cheap. `allowPicking` has
|
||||
* no entity equivalent; the polyline entity is excluded from clicks by never
|
||||
* carrying a pick id the layers' click handlers resolve.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerPickOwner } from '../../data/pickRegistry.js';
|
||||
|
||||
// Round 6: trail ENTITIES are pickable (the old Primitive had
|
||||
// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would
|
||||
// read as "empty space" in every layer's click handler and deselect the very
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
|
||||
/** @constant {number} Alpha where the trail passes the depth test. */
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
/** @constant {number} Alpha where the trail is behind/below scene geometry —
|
||||
* visible (owner: never vanish) but readable as occluded. */
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
|
||||
/**
|
||||
* Create an always-visible polyline trail bound to a viewer.
|
||||
* @param {Cesium.Viewer} viewer - Viewer whose entity collection owns the trail.
|
||||
* @param {object} options - Trail options.
|
||||
* @param {string} options.color - CSS color string for the trail hue.
|
||||
* @param {number} [options.width=2.5] - Polyline width in pixels.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry, setVisible temporarily
|
||||
* hides it without discarding history, clear empties it, and destroy removes
|
||||
* the entity permanently.
|
||||
*/
|
||||
export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
|
||||
let current = [];
|
||||
let destroyed = false;
|
||||
let visible = true;
|
||||
/** @type {Cesium.Entity|null} */
|
||||
let entity = null;
|
||||
|
||||
function ensureEntity() {
|
||||
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
entity = viewer.entities.add({
|
||||
id: `gev-trail:${++_trailSeq}`,
|
||||
show: visible,
|
||||
polyline: {
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Replace the trail geometry with a chronological position list
|
||||
* (oldest first). Fewer than 2 distinct positions clears the trail.
|
||||
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
|
||||
*/
|
||||
setPositions(cartesians) {
|
||||
if (destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
// Drop consecutive near-duplicates: zero-length segments add nothing.
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
ensureEntity();
|
||||
},
|
||||
|
||||
/** Temporarily hide/show the trail without discarding accumulated history. */
|
||||
setVisible(nextVisible) {
|
||||
visible = nextVisible !== false;
|
||||
if (entity) entity.show = visible;
|
||||
},
|
||||
|
||||
/** Empty the trail without removing the entity (cheap re-arm). */
|
||||
clear() {
|
||||
current = [];
|
||||
},
|
||||
|
||||
/** Remove the trail entity permanently (layer disable/teardown). */
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as Cesium from 'cesium';
|
||||
/** Whole-Earth input limit; explicit camera destinations can go farther. */
|
||||
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
|
||||
/** Below this, oblique browsing is left alone. */
|
||||
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
|
||||
const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
|
||||
// Plain numbers: on a page that starts on Navara the Cesium SDK loads only
|
||||
// with the first Cesium globe, after this module (src/app/loadCesium.js).
|
||||
const RADIANS_PER_DEGREE = Math.PI / 180;
|
||||
|
||||
+6
-16
@@ -1083,10 +1083,6 @@ const OPTIONAL_SHAPE = {
|
||||
},
|
||||
};
|
||||
|
||||
// Required capabilities engineCapabilities reports as well: true for an
|
||||
// engine that has them whole, as every valid engine does.
|
||||
const REPORTED_REQUIRED = ['movers', 'lines', 'ground', 'imagery', 'meshes'];
|
||||
|
||||
const memberAt = (engine, path) =>
|
||||
path.split('.').reduce((value, key) => value?.[key], engine);
|
||||
|
||||
@@ -1094,26 +1090,20 @@ const memberAt = (engine, path) =>
|
||||
* Which optional capabilities an engine has: { visualStyles, ... } as
|
||||
* booleans. A caller that refuses a feature on an engine without one
|
||||
* (voice, say) asks here. Cesium and Navara draw the styles, bloom and
|
||||
* contact sonar; only Navara has cloudCover. `movers`, `lines`, `ground`
|
||||
* and `imagery`, plus `meshes`, are required; they are reported too, true for every valid
|
||||
* engine.
|
||||
* contact sonar; only Navara has cloudCover.
|
||||
*/
|
||||
export function engineCapabilities(engine) {
|
||||
const whole = (members, prefix = '') =>
|
||||
const whole = (members) =>
|
||||
isObject(engine) &&
|
||||
Object.entries(members).every(([path, valid]) =>
|
||||
valid(memberAt(engine, prefix + path)),
|
||||
valid(memberAt(engine, path)),
|
||||
);
|
||||
return Object.fromEntries([
|
||||
...Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [
|
||||
return Object.fromEntries(
|
||||
Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [
|
||||
flag,
|
||||
whole(members),
|
||||
]),
|
||||
...REPORTED_REQUIRED.map((capability) => [
|
||||
capability,
|
||||
whole(SHAPE[capability], `${capability}.`),
|
||||
]),
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -293,19 +293,14 @@ test('mergeSharpen keeps omitted fields and rejects bad values', () => {
|
||||
assert.throws(() => mergeSharpen(on, bad), TypeError);
|
||||
});
|
||||
|
||||
test('movers and lines are required and reported as capabilities', () => {
|
||||
test('movers and lines are required', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).movers, true);
|
||||
assert.equal(engineCapabilities(engine).lines, true);
|
||||
const without = { ...engine };
|
||||
delete without.movers;
|
||||
delete without.lines;
|
||||
assert.throws(() => assertGlobeEngine(without), /\bmovers\b.*\blines\b/);
|
||||
assert.equal(engineCapabilities(without).movers, false);
|
||||
assert.equal(engineCapabilities(without).lines, false);
|
||||
const half = { ...engine, movers: { createSet: () => {} } };
|
||||
assert.throws(() => assertGlobeEngine(half), /movers\.list/);
|
||||
assert.equal(engineCapabilities(half).movers, false);
|
||||
// The suite draws movers and lines on every engine.
|
||||
const names = createContractSuite(engine, {
|
||||
waitFrames: async () => {},
|
||||
@@ -317,13 +312,11 @@ test('movers and lines are required and reported as capabilities', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('ground is required and reported as a capability', () => {
|
||||
test('ground is required', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).ground, true);
|
||||
const without = { ...engine };
|
||||
delete without.ground;
|
||||
assert.throws(() => assertGlobeEngine(without), /\bground\b/);
|
||||
assert.equal(engineCapabilities(without).ground, false);
|
||||
const half = { ...engine, ground: { createSet: () => {} } };
|
||||
assert.throws(
|
||||
() => assertGlobeEngine(half),
|
||||
@@ -331,13 +324,11 @@ test('ground is required and reported as a capability', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('imagery is required and reported as a capability', () => {
|
||||
test('imagery is required', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).imagery, true);
|
||||
const without = { ...engine };
|
||||
delete without.imagery;
|
||||
assert.throws(() => assertGlobeEngine(without), /\bimagery\b/);
|
||||
assert.equal(engineCapabilities(without).imagery, false);
|
||||
const half = { ...engine, imagery: { createLayer: () => {} } };
|
||||
assert.throws(() => assertGlobeEngine(half), /imagery\.list/);
|
||||
const names = createContractSuite(engine, {
|
||||
@@ -346,9 +337,8 @@ test('imagery is required and reported as a capability', () => {
|
||||
assert.ok(names.some((name) => name.startsWith('imagery:')));
|
||||
});
|
||||
|
||||
test('meshes are required and reported as a capability', () => {
|
||||
test('meshes are required', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).meshes, true);
|
||||
assert.throws(
|
||||
() => assertGlobeEngine({ ...engine, meshes: undefined }),
|
||||
/meshes/,
|
||||
|
||||
+2
-21
@@ -9,13 +9,8 @@
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createGroundPulseRegistry } from './groundPulse.js';
|
||||
|
||||
export const GROUND_KINDS = Object.freeze([
|
||||
'polygon',
|
||||
'disc',
|
||||
'cell',
|
||||
'corridor',
|
||||
]);
|
||||
export const GROUND_DRAPES = Object.freeze(['both', 'terrain', 'tiles']);
|
||||
const GROUND_KINDS = Object.freeze(['polygon', 'disc', 'cell', 'corridor']);
|
||||
const GROUND_DRAPES = Object.freeze(['both', 'terrain', 'tiles']);
|
||||
|
||||
const isFiniteNumber = (value) =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
@@ -382,20 +377,6 @@ export function pulseFactor({ periodMs, min, max }, seconds) {
|
||||
/** Half the soft edge of a dash, in cells (the route flow material's). */
|
||||
export const DASH_EDGE = 0.08;
|
||||
|
||||
/**
|
||||
* How lit a dash is (0..1) at `along` (0 at the corridor's start, 1 at its
|
||||
* end) and `seconds`: the cells scroll toward the end at `speed` a second.
|
||||
*/
|
||||
export function dashLit({ cells, duty, speed }, along, seconds) {
|
||||
const flow = along * cells - seconds * speed;
|
||||
const at = flow - Math.floor(flow);
|
||||
const t = Math.min(
|
||||
1,
|
||||
Math.max(0, (at - (duty - DASH_EDGE)) / (2 * DASH_EDGE)),
|
||||
);
|
||||
return 1 - t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/** A disc's boundary as a ring of `segments` points (degrees, flat). */
|
||||
export function discRing({ center, radiusM }, segments = 64) {
|
||||
const origin = geo.cartesian3.fromDegrees(center[0], center[1]);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
cellRing,
|
||||
createGroundPicks,
|
||||
createGroundRegistry,
|
||||
dashLit,
|
||||
discRing,
|
||||
normalizeGroundItem,
|
||||
normalizeGroundSetOptions,
|
||||
@@ -288,18 +287,11 @@ test("a pixel corridor's glow, pulse and dash are checked and defaulted", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a pulse swings between its min and max; a dash scrolls toward the end', () => {
|
||||
test('a pulse swings between its min and max', () => {
|
||||
const pulse = { periodMs: 2000, min: 0.2, max: 0.8 };
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 0) - 0.5) < 1e-9);
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 0.5) - 0.8) < 1e-9);
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 1.5) - 0.2) < 1e-9);
|
||||
const dash = { cells: 10, duty: 0.5, speed: 1 };
|
||||
// Lit for the first half of each cell, dark for the second, soft edges.
|
||||
assert.equal(dashLit(dash, 0.02, 0), 1);
|
||||
assert.equal(dashLit(dash, 0.08, 0), 0);
|
||||
assert.ok(Math.abs(dashLit(dash, 0.05, 0) - 0.5) < 1e-9);
|
||||
// A second later the pattern has moved one cell toward the end.
|
||||
assert.equal(dashLit(dash, 0.12, 1), dashLit(dash, 0.02, 0));
|
||||
});
|
||||
|
||||
test('the registry knows when a shown set animates', () => {
|
||||
|
||||
+4
-17
@@ -8,16 +8,11 @@
|
||||
// that mosaic their own tiles (Navara). Rectangles are degrees; colours are
|
||||
// the image's own. No engine imports.
|
||||
|
||||
export const IMAGERY_SOURCE_KINDS = Object.freeze(['tiles', 'image']);
|
||||
export const IMAGERY_SPLITS = Object.freeze(['none', 'left', 'right']);
|
||||
const IMAGERY_SOURCE_KINDS = Object.freeze(['tiles', 'image']);
|
||||
const IMAGERY_SPLITS = Object.freeze(['none', 'left', 'right']);
|
||||
/** How a tiles source cuts the globe: Web Mercator XYZ, or geographic
|
||||
* (EPSG:4326, 2×1 tiles at level 0, Cesium's GeographicTilingScheme). */
|
||||
export const IMAGERY_TILE_SCHEMES = Object.freeze([
|
||||
'webMercator',
|
||||
'geographic',
|
||||
]);
|
||||
/** Where imagery drapes now (GlobeImagery.surface). */
|
||||
export const IMAGERY_SURFACES = Object.freeze(['globe', 'tiles', 'none']);
|
||||
const IMAGERY_TILE_SCHEMES = Object.freeze(['webMercator', 'geographic']);
|
||||
/** The latitude Web Mercator tiles stop at. */
|
||||
export const WEB_MERCATOR_LIMIT = 85.05112878;
|
||||
|
||||
@@ -496,7 +491,6 @@ export const mercatorY = (lat) => {
|
||||
};
|
||||
|
||||
/** Geographic tiles: x across 2^(z+1) columns, y down 2^z rows from 90°. */
|
||||
export const geographicX = (lon) => (lon + 180) / 360;
|
||||
export const geographicY = (lat) =>
|
||||
(90 - Math.max(-90, Math.min(90, lat))) / 180;
|
||||
|
||||
@@ -521,7 +515,7 @@ export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') {
|
||||
};
|
||||
}
|
||||
if (area.west >= area.east || area.south >= area.north) return null;
|
||||
const toX = geographic ? geographicX : mercatorX;
|
||||
const toX = mercatorX;
|
||||
const toY = geographic ? geographicY : mercatorY;
|
||||
for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) {
|
||||
const columns = geographic ? 2 ** (z + 1) : 2 ** z;
|
||||
@@ -600,7 +594,6 @@ export function imageryTileLevel({
|
||||
*/
|
||||
export function createImageryTileQueue({ load, limit = 6 }) {
|
||||
let active = 0,
|
||||
peak = 0,
|
||||
destroyed = false;
|
||||
const pending = [],
|
||||
controllers = new Set();
|
||||
@@ -612,7 +605,6 @@ export function createImageryTileQueue({ load, limit = 6 }) {
|
||||
controllers.add(controller);
|
||||
task.controllers.add(controller);
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
Promise.resolve()
|
||||
.then(() => load(task.url, controller.signal))
|
||||
.then((image) => {
|
||||
@@ -646,11 +638,6 @@ export function createImageryTileQueue({ load, limit = 6 }) {
|
||||
for (let i = pending.length - 1; i >= 0; i--)
|
||||
if (!pending[i].live()) pending.splice(i, 1);
|
||||
},
|
||||
diagnostics: () => ({
|
||||
active,
|
||||
queued: pending.filter((t) => t.live()).length,
|
||||
peak,
|
||||
}),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
pending.length = 0;
|
||||
|
||||
@@ -430,13 +430,13 @@ test('six admissions include slow aborted decodes; stale images close without dr
|
||||
queue.add(Array.from({ length: 10 }, (_, i) => task(i)));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pending.length, 6);
|
||||
assert.equal(queue.diagnostics().active, 6);
|
||||
assert.equal(pending.length, 6);
|
||||
live = false;
|
||||
for (const c of controllers) c.abort();
|
||||
queue.prune();
|
||||
let replacement = true;
|
||||
queue.add([{ ...task(10), live: () => replacement }]);
|
||||
assert.equal(queue.diagnostics().active, 6);
|
||||
assert.equal(pending.length, 6);
|
||||
for (const p of pending) p.resolve({ close: () => closed.push(p.url) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pending.length, 7);
|
||||
@@ -449,5 +449,6 @@ test('six admissions include slow aborted decodes; stale images close without dr
|
||||
assert.deepEqual(drawn, []);
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(closed.length, 7);
|
||||
assert.deepEqual(queue.diagnostics(), { active: 0, queued: 0, peak: 6 });
|
||||
assert.equal(controllers.size, 0);
|
||||
assert.equal(pending.length, 7, 'stale queued tasks never load');
|
||||
});
|
||||
@@ -31,51 +31,10 @@ import { createGroundPicks } from '../ground.js';
|
||||
import { navaraFollowGestures } from './followGestures.js';
|
||||
import { createNavaraMaps } from './maps.js';
|
||||
import { createNavaraSurface } from './surface.js';
|
||||
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
|
||||
import { createGroundFloor } from './groundFloor.js';
|
||||
|
||||
const { cartesian3, math } = geo;
|
||||
|
||||
// engine.movers without the view's three runtime (Node tests of the engine).
|
||||
const NO_MOVERS = Object.freeze({
|
||||
createSet() {
|
||||
throw new Error("Navara movers need the view's three runtime (movers/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_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 NO_MESHES = Object.freeze({
|
||||
createMesh() {
|
||||
throw new Error('Navara meshes need the view runtime');
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_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 NO_IMAGERY = Object.freeze({
|
||||
createLayer() {
|
||||
throw new Error("Navara imagery needs the view's three runtime (imagery/)");
|
||||
},
|
||||
list: () => [],
|
||||
setSplitPosition() {},
|
||||
splitPosition: () => 0.5,
|
||||
surface: () => 'globe',
|
||||
});
|
||||
|
||||
// Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in
|
||||
// Earth-fixed metres): matrixWorld columns are right, up, back, position.
|
||||
function readCamera(raw) {
|
||||
@@ -94,7 +53,7 @@ function readCamera(raw) {
|
||||
* @param {object} view An initialised Navara ThreeView.
|
||||
* @param {{
|
||||
* hooks: { tileRoots: Function, isIdle: Function, retainTileGeometry: Function, dropOrphanFeatureChanges?: Function },
|
||||
* imagery?: (parts: { camera: object, size: Function, credits: object, photorealActive: () => boolean }) => { imagery: object, diagnostics: Function, destroy: Function },
|
||||
* imagery: (parts: { camera: object, size: Function, credits: object, photorealActive: () => boolean }) => { imagery: object, destroy: Function },
|
||||
* container?: HTMLElement,
|
||||
* fetch?: typeof fetch,
|
||||
* clock?: object,
|
||||
@@ -117,11 +76,11 @@ function readCamera(raw) {
|
||||
* capability), destroyed with the engine;
|
||||
* `appClock` (the engine clock's time) defaults to the page's app clock;
|
||||
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||
* likewise), `ground` engine.ground (ground/index.js; likewise), all
|
||||
* required), `lines` engine.lines (lines/index.js), `ground` engine.ground
|
||||
* (ground/index.js), all
|
||||
* destroyed with the engine; `imagery` makes engine.imagery from the
|
||||
* engine's camera, size, credits and photoreal state (imagery/index.js;
|
||||
* without it createLayer throws), destroyed with the engine;
|
||||
* required), destroyed with the engine;
|
||||
* `fetch` and `clock` (the camera model's timers) are test seams.
|
||||
* @returns {import('../contract.js').GlobeEngine}
|
||||
*/
|
||||
@@ -133,11 +92,11 @@ export function createNavaraEngine(
|
||||
fetch,
|
||||
clock,
|
||||
appClock,
|
||||
movers: moversPort = null,
|
||||
lines: linesPort = null,
|
||||
ground: groundPort = null,
|
||||
imagery: imageryPort = null,
|
||||
meshes: meshesPort = null,
|
||||
movers: moversPort,
|
||||
lines: linesPort,
|
||||
ground: groundPort,
|
||||
imagery: imageryPort,
|
||||
meshes: meshesPort,
|
||||
sharpen: sharpenPort = null,
|
||||
styles: stylesPort = null,
|
||||
bloom: bloomPort = null,
|
||||
@@ -451,11 +410,11 @@ export function createNavaraEngine(
|
||||
credits: credits.credits,
|
||||
maps: maps.maps,
|
||||
clock: engineClock.clock,
|
||||
movers: moversPort?.movers ?? NO_MOVERS,
|
||||
lines: linesPort?.lines ?? NO_LINES,
|
||||
ground: groundPort?.ground ?? NO_GROUND,
|
||||
meshes: meshes?.meshes ?? NO_MESHES,
|
||||
imagery: imagery?.imagery ?? NO_IMAGERY,
|
||||
movers: moversPort.movers,
|
||||
lines: linesPort.lines,
|
||||
ground: groundPort.ground,
|
||||
meshes: meshes.meshes,
|
||||
imagery: imagery.imagery,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
@@ -494,13 +453,5 @@ export function createNavaraEngine(
|
||||
},
|
||||
// Navara only, not contract: what the ground pass holds (QA reads it).
|
||||
groundDiagnostics: () => groundPort?.diagnostics?.() ?? null,
|
||||
// Navara only, not contract: what each imagery layer holds.
|
||||
imageryDiagnostics: () => imagery?.diagnostics?.() ?? null,
|
||||
// Navara only, not contract: the ground the floor last sampled under
|
||||
// the camera and the margin it keeps above it (QA measures against it).
|
||||
groundFloor: {
|
||||
ground: () => groundFloor.ground(),
|
||||
margin: GROUND_FLOOR.MIN_ABOVE,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -100,9 +100,58 @@ const hooks = {
|
||||
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, { hooks });
|
||||
const engine = createNavaraEngine(view, { ...ports, hooks });
|
||||
return { view, engine };
|
||||
};
|
||||
|
||||
@@ -135,7 +184,7 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr
|
||||
let wall = 2_000_000;
|
||||
const appClock = createAppClock({ now: () => wall });
|
||||
const view = fakeView();
|
||||
const engine = createNavaraEngine(view, { hooks, appClock });
|
||||
const engine = createNavaraEngine(view, { ...ports, hooks, appClock });
|
||||
assert.equal(engine.clock.now(), 2_000_000);
|
||||
const seen = [];
|
||||
for (const name of ['preUpdate', 'preRender', 'postRender'])
|
||||
@@ -177,6 +226,7 @@ test('setSharpen hands changed states to the sharpen port and draws a frame', ()
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
...ports,
|
||||
hooks,
|
||||
sharpen: {
|
||||
set: (state) => shown.push(state),
|
||||
@@ -225,7 +275,7 @@ function stylesPort() {
|
||||
test('setStyle hands changed style states to the styles port and draws a frame', () => {
|
||||
const view = fakeView();
|
||||
const port = stylesPort();
|
||||
const engine = createNavaraEngine(view, { hooks, styles: port });
|
||||
const engine = createNavaraEngine(view, { ...ports, hooks, styles: port });
|
||||
assertGlobeEngine(engine);
|
||||
assert.equal(engineCapabilities(engine).visualStyles, true);
|
||||
assert.deepEqual(engine.render.visualStyles, ['retro', 'noir']);
|
||||
@@ -267,6 +317,7 @@ test('setBloom hands changed states to the bloom port and draws a frame', () =>
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
...ports,
|
||||
hooks,
|
||||
bloom: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
@@ -294,6 +345,7 @@ test('setCloudCover hands changed covers to the clouds port and draws a frame',
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
...ports,
|
||||
hooks,
|
||||
clouds: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
@@ -317,6 +369,7 @@ test('setContactSonar hands changes to the sonar port, asking a frame only outsi
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
...ports,
|
||||
hooks,
|
||||
sonar: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
@@ -591,10 +644,3 @@ test('the floor stands aside while the camera inputs are suspended (the cockpit
|
||||
near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back');
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('the ground floor reports its own ground and margin (diagnostics, not contract)', () => {
|
||||
const { engine } = setup();
|
||||
assert.equal(typeof engine.groundFloor.ground, 'function');
|
||||
assert.equal(engine.groundFloor.margin, 30);
|
||||
assert.equal(engine.groundFloor.ground(), undefined, 'nothing sampled yet');
|
||||
});
|
||||
@@ -33,12 +33,3 @@ export function withPassDisposal(Base) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The base the layers' Navara effects extend (engine.LayerEffectDesc): per
|
||||
// frame updates on their own callback, and the raw pass disposed when the
|
||||
// effect is deleted. A layer re-creates its effect on every attach (an
|
||||
// engine switch), so an undisposed pass kept its material, its shader
|
||||
// program and the uniforms' data textures alive for the page's lifetime.
|
||||
export function layerEffectDesc(EffectDesc) {
|
||||
return withPassDisposal(withFrameUpdates(EffectDesc));
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
layerEffectDesc,
|
||||
withFrameUpdates,
|
||||
withPassDisposal,
|
||||
} from './frameDescriptor.js';
|
||||
@@ -81,46 +80,3 @@ test('a deleted effect disposes the pass the composer no longer holds', () => {
|
||||
assert.throws(() => new (withPassDisposal(Failing))().onDestroy(), /removal/);
|
||||
assert.deepEqual(events, ['dispose']);
|
||||
});
|
||||
|
||||
test('a layer effect re-created on every attach leaves no pass, material or frame callback behind', () => {
|
||||
const frames = new Set();
|
||||
const view = {
|
||||
on: (name, cb) => frames.add(cb),
|
||||
off: (name, cb) => frames.delete(cb),
|
||||
};
|
||||
const live = new Set();
|
||||
class EffectDesc {
|
||||
view = view;
|
||||
onCreate() {
|
||||
const pass = {
|
||||
material: {},
|
||||
dispose: () => live.delete(pass),
|
||||
};
|
||||
live.add(pass);
|
||||
this._instance = { rawPass: pass };
|
||||
}
|
||||
get raw() {
|
||||
return this._instance;
|
||||
}
|
||||
onDestroy() {
|
||||
// Navara's removePass: out of the composer, not disposed.
|
||||
this._instance = undefined;
|
||||
}
|
||||
}
|
||||
// As the earthquakes ground effect: disposes its own effect, then super.
|
||||
class LayerEffect extends layerEffectDesc(EffectDesc) {
|
||||
updateFrame() {}
|
||||
onDestroy() {
|
||||
this.effectDisposed = true;
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
for (let attach = 0; attach < 50; attach++) {
|
||||
const effect = new LayerEffect();
|
||||
effect.onCreate();
|
||||
effect.onDestroy();
|
||||
assert.equal(effect.effectDisposed, true);
|
||||
}
|
||||
assert.equal(live.size, 0, 'every pass of a deleted effect is disposed');
|
||||
assert.equal(frames.size, 0, 'no frame callback outlives its effect');
|
||||
});
|
||||
@@ -378,7 +378,7 @@ export function tileCorridors(
|
||||
* `reachOf(item)` is the item's [half width in pixels + 0.5, half width in
|
||||
* metres]; `reference` the set's least pixel reach.
|
||||
*/
|
||||
export function packTile(tile, pieces, items, frame, reachOf, reference) {
|
||||
export function packTile(tile, pieces, frame, reference) {
|
||||
const { project } = frame;
|
||||
// Its region in the plane, from its edges.
|
||||
let x0 = Infinity;
|
||||
|
||||
@@ -292,7 +292,7 @@ export function packSet(
|
||||
(tile.west + tile.east) / 2,
|
||||
(tile.south + tile.north) / 2,
|
||||
);
|
||||
const packed = packTile(tile, pieces, items, frame, reachOf, reference);
|
||||
const packed = packTile(tile, pieces, frame, reference);
|
||||
// Runs and bins relative to the tile's own: its style holds where
|
||||
// they start in the set's.
|
||||
const runStart = runs.length / 4;
|
||||
|
||||
@@ -381,14 +381,7 @@ export const textureBytes = (state) =>
|
||||
class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
|
||||
static key = GROUND_EFFECT_KEY;
|
||||
// Navara takes the first anchor that exists: the latest first.
|
||||
static insertAfter = [
|
||||
'gevFirmsGround',
|
||||
'gevEarthquakeGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevImagery',
|
||||
'toneMapping',
|
||||
];
|
||||
static insertAfter = ['gevImagery', 'toneMapping'];
|
||||
static insertBefore = [
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
|
||||
@@ -85,13 +85,7 @@ const pulseEffect = defineGroundEffect({
|
||||
key: KEY,
|
||||
name: 'GevGroundPulses',
|
||||
fragmentShader,
|
||||
insertAfter: [
|
||||
'gevGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevImagery',
|
||||
'toneMapping',
|
||||
],
|
||||
insertAfter: ['gevGround', 'gevImagery', 'toneMapping'],
|
||||
insertBefore: [
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
// Public depth inputs shared by actual terrain/tiles classifiers.
|
||||
import {
|
||||
DataTexture,
|
||||
FloatType,
|
||||
RGBAFormat,
|
||||
NearestFilter,
|
||||
Matrix4,
|
||||
Uniform,
|
||||
RGBADepthPacking,
|
||||
} from 'three';
|
||||
export function createGroundTexture(data, width, height) {
|
||||
const texture = new DataTexture(data, width, height, RGBAFormat, FloatType);
|
||||
texture.minFilter = NearestFilter;
|
||||
texture.magFilter = NearestFilter;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
export function createGroundStorage(texels) {
|
||||
const width = Math.min(1024, Math.max(1, texels));
|
||||
const height = Math.max(1, Math.ceil(texels / width));
|
||||
const data = new Float32Array(width * height * 4);
|
||||
return {
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
texture: createGroundTexture(data, width, height),
|
||||
};
|
||||
}
|
||||
import { Matrix4, Uniform, RGBADepthPacking } from 'three';
|
||||
export function groundSurfaceUniforms() {
|
||||
return [
|
||||
['tAllDepth', new Uniform(null)],
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// A prototype of engine.ground on Navara's own vector layers, kept to
|
||||
// measure against the screen pass (pass.js) and not used by the engine:
|
||||
// each set's items grouped by style into one GeoJSON source and one
|
||||
// clamped vector layer per group (discs and cells as polygons, pixel
|
||||
// corridors as polylines; metre corridors are not drawn). `hide` picks how a
|
||||
// hidden set goes: 'delete' (its layers deleted, rebuilt when shown) or
|
||||
// 'update' (layer.update with show false).
|
||||
|
||||
import { areaRings, createGroundRegistry } from '../../ground.js';
|
||||
|
||||
const toHex = (rgba) =>
|
||||
(Math.round(rgba[0] * 255) << 16) |
|
||||
(Math.round(rgba[1] * 255) << 8) |
|
||||
Math.round(rgba[2] * 255);
|
||||
|
||||
function ringCoordinates(flat) {
|
||||
const ring = [];
|
||||
for (let index = 0; index < flat.length; index += 2)
|
||||
ring.push([flat[index], flat[index + 1]]);
|
||||
ring.push(ring[0]);
|
||||
return ring;
|
||||
}
|
||||
|
||||
// Features grouped by their layer style: Map<key, { style, features }>.
|
||||
export function vectorGroups(kind, items) {
|
||||
const groups = new Map();
|
||||
const add = (key, style, feature) => {
|
||||
if (!groups.has(key)) groups.set(key, { style, features: [] });
|
||||
groups.get(key).features.push(feature);
|
||||
};
|
||||
items.forEach((item, index) => {
|
||||
if (kind === 'corridor') {
|
||||
if (!item.widthPx) return;
|
||||
const coordinates = [];
|
||||
for (let at = 0; at < item.path.length; at += 2)
|
||||
coordinates.push([item.path[at], item.path[at + 1]]);
|
||||
add(
|
||||
`line:${item.fill.join()}:${item.widthPx}`,
|
||||
{
|
||||
polyline: {
|
||||
color: toHex(item.fill),
|
||||
width: item.widthPx,
|
||||
opacity: item.fill[3],
|
||||
transparent: item.fill[3] < 1,
|
||||
clampToGround: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: index,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates },
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rings = areaRings(kind, item).map(ringCoordinates);
|
||||
const fill = item.fill ?? [0, 0, 0, 0];
|
||||
const outline = item.outline;
|
||||
add(
|
||||
`area:${fill.join()}:${outline ? `${outline.color.join()}:${outline.widthPx}` : ''}`,
|
||||
{
|
||||
polygon: {
|
||||
color: toHex(fill),
|
||||
opacity: fill[3],
|
||||
transparent: true,
|
||||
clampToGround: true,
|
||||
...(outline
|
||||
? {
|
||||
outline: true,
|
||||
outlineShow: true,
|
||||
outlineColor: toHex(outline.color),
|
||||
outlineWidth: outline.widthPx,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: index,
|
||||
properties: {},
|
||||
geometry: { type: 'Polygon', coordinates: rings },
|
||||
},
|
||||
);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} view an initialised Navara ThreeView
|
||||
* @param {{ hide?: 'delete'|'update' }} [options]
|
||||
*/
|
||||
export function createNavaraVectorGround(view, { hide = 'delete' } = {}) {
|
||||
const registry = createGroundRegistry((options) => {
|
||||
let groups = new Map();
|
||||
let batches = [];
|
||||
let shown = true;
|
||||
const release = () => {
|
||||
for (const { layer, source } of batches) {
|
||||
layer.delete();
|
||||
source.delete();
|
||||
}
|
||||
batches = [];
|
||||
};
|
||||
const build = () => {
|
||||
release();
|
||||
for (const { style, features } of groups.values()) {
|
||||
const source = view.addSource({
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features },
|
||||
});
|
||||
const config = { type: 'vector', source, ...style };
|
||||
batches.push({ source, config, layer: view.addLayer(config) });
|
||||
}
|
||||
};
|
||||
return {
|
||||
commit(items) {
|
||||
groups = vectorGroups(options.kind, items);
|
||||
if (shown || hide === 'update') build();
|
||||
if (!shown && hide === 'update')
|
||||
for (const batch of batches)
|
||||
batch.layer.update({ ...batch.config, show: false });
|
||||
view.forceUpdate();
|
||||
},
|
||||
show(visible) {
|
||||
if (visible === shown) return;
|
||||
shown = visible;
|
||||
if (hide === 'delete') {
|
||||
if (visible) build();
|
||||
else release();
|
||||
} else
|
||||
for (const batch of batches)
|
||||
batch.layer.update({ ...batch.config, show: visible });
|
||||
view.forceUpdate();
|
||||
},
|
||||
destroy() {
|
||||
release();
|
||||
view.forceUpdate();
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
ground: registry.ground,
|
||||
pickables: () => [...registry.live.values()],
|
||||
destroy: () => registry.destroyAll(),
|
||||
};
|
||||
}
|
||||
@@ -107,7 +107,7 @@ const unwrapped = ({ west, south, east, north }) => [
|
||||
* replace); `load` (a tile or an image URL to a decoded image) and
|
||||
* `createCanvas` are test seams.
|
||||
* @returns {{ imagery: import('../../contract.js').GlobeImagery,
|
||||
* diagnostics: () => object, destroy: () => void }}
|
||||
* destroy: () => void }}
|
||||
*/
|
||||
export function createNavaraImagery(
|
||||
view,
|
||||
@@ -323,12 +323,6 @@ export function createNavaraImagery(
|
||||
detail?.held.texture.dispose();
|
||||
detail = null;
|
||||
},
|
||||
diagnostics: () => ({
|
||||
kind: 'image',
|
||||
loaded: drawn !== null,
|
||||
staging: staging !== null,
|
||||
detail: detail !== null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -457,17 +451,6 @@ export function createNavaraImagery(
|
||||
queue.destroy();
|
||||
disposeAtlas();
|
||||
},
|
||||
diagnostics: () => ({
|
||||
...queue.diagnostics(),
|
||||
atlas: atlas
|
||||
? {
|
||||
level: atlas.plan.z,
|
||||
tiles: atlas.plan.width * atlas.plan.height,
|
||||
loaded: atlas.loaded,
|
||||
failed: atlas.failed,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -551,11 +534,6 @@ export function createNavaraImagery(
|
||||
tiler?.dispose();
|
||||
tiler = null;
|
||||
},
|
||||
diagnostics: () => ({
|
||||
kind: 'tiles',
|
||||
...(tiler?.diagnostics() ?? {}),
|
||||
staging: staged ? staged.tiler.diagnostics() : null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -659,16 +637,6 @@ export function createNavaraImagery(
|
||||
|
||||
return {
|
||||
imagery: registry.imagery,
|
||||
/** Not contract: what each layer holds (QA reads it). */
|
||||
diagnostics: () =>
|
||||
Object.fromEntries(
|
||||
registry
|
||||
.ordered()
|
||||
.map((state) => [
|
||||
state.options.id,
|
||||
entries.get(state)?.drawing.diagnostics(),
|
||||
]),
|
||||
),
|
||||
destroy() {
|
||||
offCamera();
|
||||
registry.destroyAll();
|
||||
|
||||
@@ -18,7 +18,7 @@ function fixture() {
|
||||
const view = {
|
||||
forceUpdate: () => frames++,
|
||||
};
|
||||
const { imagery, diagnostics, destroy } = createNavaraImagery(view, {
|
||||
const { imagery, destroy } = createNavaraImagery(view, {
|
||||
camera: {
|
||||
changed,
|
||||
pose: () => ({
|
||||
@@ -53,7 +53,6 @@ function fixture() {
|
||||
});
|
||||
return {
|
||||
imagery,
|
||||
diagnostics,
|
||||
destroy,
|
||||
canvases,
|
||||
draws,
|
||||
@@ -80,10 +79,10 @@ test('a tiles layer mosaics the visible tiles into one atlas; a new source drops
|
||||
const f = fixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
await tick();
|
||||
const first = f.diagnostics().a;
|
||||
assert.equal(first.atlas.level, 12);
|
||||
assert.ok(first.atlas.tiles >= 1 && first.atlas.tiles <= 64);
|
||||
assert.equal(f.jobs.length, Math.min(6, first.atlas.tiles), 'six at a time');
|
||||
const canvas = f.canvases[0];
|
||||
const count = (canvas.width / 256) * (canvas.height / 256);
|
||||
assert.ok(count >= 1 && count <= 64);
|
||||
assert.equal(f.jobs.length, Math.min(6, count), 'six at a time');
|
||||
assert.match(
|
||||
f.jobs[0].url,
|
||||
/^https:\/\/gibs-[abc]\.example\/12\/\d+\/\d+\.png$/,
|
||||
@@ -189,13 +188,13 @@ test('a hidden tiles layer drops its atlas; shown again it builds one at the vie
|
||||
const f = fixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
layer.show(false);
|
||||
assert.equal(f.diagnostics().a.atlas, null);
|
||||
assert.equal(f.last().slots.length, 0);
|
||||
assert.equal(f.credits.get('NASA GIBS'), 0, 'its credit goes too');
|
||||
f.changed.raiseEvent();
|
||||
assert.equal(f.diagnostics().a.atlas, null);
|
||||
assert.equal(f.last().slots.length, 0);
|
||||
layer.show(true);
|
||||
await tick();
|
||||
assert.ok(f.diagnostics().a.atlas);
|
||||
assert.ok(f.last().slots[0].texture.image.width > 0);
|
||||
assert.equal(f.last().slots.length, 1);
|
||||
});
|
||||
|
||||
@@ -215,8 +214,6 @@ test('geographic tiles draw in degrees through the source’s transform', async
|
||||
}),
|
||||
});
|
||||
await tick();
|
||||
const { atlas } = f.diagnostics().radar;
|
||||
assert.equal(atlas.level, 6, 'the source’s top level');
|
||||
const [slot] = f.last().slots;
|
||||
assert.equal(slot.look[2], 0, 'sampled in degrees');
|
||||
const [west, south, east, north] = slot.bounds;
|
||||
@@ -242,7 +239,6 @@ test('a staged tiles source builds its own atlas under the drawn one and takes i
|
||||
);
|
||||
await tick();
|
||||
assert.equal(f.last().slots[0].texture, drawn, 'the drawn atlas stays');
|
||||
assert.ok(f.diagnostics().a.staging, 'the staged atlas loads');
|
||||
const next = f.jobs.slice(firstJobs);
|
||||
assert.ok(next.length > 0 && next.every((job) => /next/.test(job.url)));
|
||||
let done;
|
||||
|
||||
@@ -21,7 +21,7 @@ import { DataTexture, Matrix4, RGBADepthPacking, Uniform } from 'three';
|
||||
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
|
||||
import { MAX_IMAGERY_SLOTS } from './index.js';
|
||||
|
||||
export const IMAGERY_EFFECT_KEY = 'gevImagery';
|
||||
const IMAGERY_EFFECT_KEY = 'gevImagery';
|
||||
|
||||
const SLOTS = Array.from({ length: MAX_IMAGERY_SLOTS }, (_, index) => index);
|
||||
|
||||
@@ -179,9 +179,6 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
|
||||
// Navara takes the first anchor that exists: right after tone mapping.
|
||||
static insertAfter = ['toneMapping'];
|
||||
static insertBefore = [
|
||||
'gevNepalEvent',
|
||||
'gevObservedWeather',
|
||||
'gevEarthquakeGround',
|
||||
'gevGroundPulses',
|
||||
'gevGround',
|
||||
'gevPreSharpenOverlay',
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
// the product way (viewOptions.js) and the GlobeEngine on it. Load it with a
|
||||
// dynamic import() so Navara stays out of the Cesium-mode bundle.
|
||||
|
||||
import { layerEffectDesc, withFrameUpdates } from './frameDescriptor.js';
|
||||
import ThreeView, {
|
||||
Color as NavaraColor,
|
||||
MeshDesc,
|
||||
EffectDesc,
|
||||
Effect as NavaraEffect,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
} from '@navaramap/three';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { DefaultPlugin } from '@navaramap/three-default-plugin';
|
||||
@@ -124,7 +121,7 @@ function groundInView(engine) {
|
||||
* `?look=` (cinematic by default); `hd` draws at the device pixel ratio
|
||||
* with MSAA (Navara HD), else at CSS pixels without it. The sun follows the engine clock (the
|
||||
* page's app clock): the look reads its frame time.
|
||||
* @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>}
|
||||
* @returns {Promise<{ setRenderLoop: (enabled: boolean) => void, engine: import('../contract.js').GlobeEngine, destroy: () => void }>}
|
||||
*/
|
||||
export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
if (!container) throw new TypeError('createNavaraGlobe needs a container');
|
||||
@@ -272,10 +269,6 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
view,
|
||||
MeshDesc,
|
||||
NavaraColor,
|
||||
EffectDesc: layerEffectDesc(EffectDesc),
|
||||
NavaraEffect,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
alignDaylightToRestoredView() {
|
||||
look.resetDaylight();
|
||||
engine.render.request('restored-daylight');
|
||||
@@ -283,20 +276,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
}),
|
||||
});
|
||||
return {
|
||||
view,
|
||||
engine,
|
||||
NavaraColor,
|
||||
MeshDesc,
|
||||
EffectDesc,
|
||||
LayerEffectDesc: layerEffectDesc(EffectDesc),
|
||||
NavaraEffect,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
// Startup can fly from the engine's placeholder camera into a shared view.
|
||||
alignDaylightToRestoredView() {
|
||||
look.resetDaylight();
|
||||
engine.render.request('restored-daylight');
|
||||
},
|
||||
setRenderLoop: (enabled) => {
|
||||
parked = !enabled;
|
||||
setRenderLoopEnabled(view, enabled);
|
||||
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user