Test layers on one engine and share selection and shader helpers

Layer display tests no longer re-check Cesium primitives in every layer:
they run on the fake engine, and one Cesium translation check per ground
kind lives in the Cesium engine's ground tests. Their harnesses are shared,
and the fake-engine, Cesium-engine and flights tests that repeated the
contract suite are gone, 340 tests in all.

The press-travel measurement is shared by the selection dispatcher and
CCTV, which loses its ScreenSpaceEventHandler shim and world-click module.
ALPR, installations and bikeshare share one selection keeper and selected
marker write. Layers with their own click rules keep their handlers.

Navara's pulse is a class like the ground pass, the ground and imagery
passes share the surface uniforms, and the colour, log-depth and occlusion
GLSL lives in one chunk module, each caller keeping its depth bias. The
ground suite runs inside the contract suite (--only=ground:), and fixture
and script names say what they test.

Behaviour is unchanged on both engines.
This commit is contained in:
German Khokhlov committed 2026-11-08 17:06:10 +04:00
1 parent 7f478e3815
commit b4556f9763
101 files changed
+1095 -4071

No files matched your search

+2 -2
View File
@@ -208,7 +208,7 @@ 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 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 14 ms moving, where Cesium takes 23 ms (the chunks before took 151
and 196 ms). and 196 ms).
- `scripts/qa-ground-suite.mjs` checks ground pixels and corridor looks - `scripts/qa-contract-suite.mjs --only=ground:` checks ground pixels and corridor looks
on both engines. on both engines.
- **Ground and input parity.** Space Missions draws its 500 m launch-pad - **Ground and input parity.** Space Missions draws its 500 m launch-pad
zone with the radial ground material: faint fill, bright soft rim and zone with the radial ground material: faint fill, bright soft rim and
@@ -249,7 +249,7 @@ one application, and only the globe-bound parts move to the next engine.
- Sea Marks and Ski Pistes use `engine.imagery` on engines without - Sea Marks and Ski Pistes use `engine.imagery` on engines without
`maps.addOverlay`; Navara retains its native raster path for tile detail. `maps.addOverlay`; Navara retains its native raster path for tile detail.
Imported KML/KMZ ground images use imagery on both engines. Imported KML/KMZ ground images use imagery on both engines.
- `scripts/qa-ground-suite.mjs --only=imagery:` checks imagery pixels - `scripts/qa-contract-suite.mjs --only=imagery:` checks imagery pixels
on both engines. Navara mipmaps the heatmap at orbit scale. on both engines. Navara mipmaps the heatmap at orbit scale.
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the - **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
engine-specific code outside the engines: renderer lines per engine, Cesium engine-specific code outside the engines: renderer lines per engine, Cesium
+28 -140
View File
@@ -183,78 +183,17 @@ point movers retain their independent fill and outline opacity. No story
backend is selected by engine. The authored model still uses its existing backend is selected by engine. The authored model still uses its existing
property records; removing that model dependency is separate work. property records; removing that model dependency is separate work.
### How each engine draws the ground ### Ground drawing guarantees
**Cesium** (`src/globe/cesium/ground.js`) builds at most two batches per set: Both engines preserve item order, fill and outline colours, pixel and metre
widths, visibility, and pick ownership. Non-pickable sets let picks through.
`ready()` reports when committed geometry is ready to draw. Animated corridor
looks use the shared ground time and request frames while shown.
- one `GroundPrimitive` for all the fills (polygons, ellipses, rectangles and Cesium translates ground items to classified fills and polylines. Navara
metre corridors); classifies visible scene depth and blends the ground in display space.
- one `GroundPolylinePrimitive` for all the outlines and pixel corridors. Navara picks ground items at the surface under the pointer. Packing, tiling
and bin sizes are implementation details in `src/globe/navara/ground/`.
Both use per-instance colours and the set's `classificationType`. A
corridor with a look draws in a `GroundPolylinePrimitive` of its own look
and colour, over the set's other batches. Its material does the glow, pulse
and dash. The engine writes its time before every frame, and asks for the
next frame after every frame while the set is shown. Cesium
builds them in workers; the set's `ready()` turns true once they are drawn.
A set that is not pickable stays out of the pick pass, so it never hides
what lies under it.
**Navara** (`src/globe/navara/ground/`) draws every shown set in one screen
pass after tone mapping. Each pixel's ground position comes from the scene
depth (terrain and 3D tiles), and the pixel is tested against the items in
its screen cell:
- a disc against its radius;
- an area against its edges;
- a corridor tile against the corridor segments near the pixel.
The tests run in each item's own gnomonic plane, so great-circle edges stay
straight. Fills and outlines are anti-aliased across one pixel and blended
in display space, in draw order, as Cesium blends them.
Large shapes get extra structure:
- An area with more than 32 edges gets a grid of bins. A pixel then tests
only the edges of its own bin.
- A corridor set becomes tiles, set-wide grids of bins
(`src/globe/navara/ground/corridors.js`). See below.
- The terrain heights under each item bound its screen box.
**Corridor tiles.** A set's lines are cut into pieces of at most 0.5°
along their great circles. The pieces fall into a tree of longitude and
latitude tiles, each with at most 20 000 pieces and at most 20° a side. A
tile is one pass item:
- a gnomonic plane of its own;
- the pieces that come near it, as segments in that plane;
- a pyramid of square bins. A bin of level z is 2^z times the finest bin.
It lists runs of segments, item by item in draw order: the segments that
reach within its margin. The margin is half a bin for the set's thinnest
corridor, more for a wider one, plus a width in metres.
The finest bin is sized so that it holds about two runs per piece. The
coarsest level has at most 8 bins across.
A pixel finds its ground in world space once. It takes the one tile of the
set whose cell holds it: the cells are tested with the meridians' normals
and the parallels' sines, so two neighbouring tiles never both draw a
pixel. The pixel then takes the finest level whose margin covers the
reach of the set's thinnest corridor, and tests only its bin's runs. Each
item takes its nearest segment there, and blends once, in item order. When
the pixel is wider than the coarsest margin covers (a tile under about 30
pixels), lines near a tile's edge can lose a little of a neighbour's
reach; the tile is a solid blur by then.
A commit packs the tiles once. Only the heights are sampled again when the
terrain loads later.
Picks are CPU picks at the ground under the pointer (`src/globe/ground.js`).
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 ## Imagery: rasters on the ground
@@ -324,78 +263,27 @@ On the engine:
- `surface()` says where imagery drapes now: `'globe'`, `'tiles'` (3D tiles) - `surface()` says where imagery drapes now: `'globe'`, `'tiles'` (3D tiles)
or `'none'`. or `'none'`.
### How each engine draws imagery ### Imagery drawing guarantees
**Cesium** (`src/globe/cesium/imagery.js`) gives each layer one Both engines draw imagery below ground shapes, movers, lines and models,
`ImageryLayer`: in layer order. Each texel blends by its alpha times the layer alpha.
Visibility, split position, rectangle bounds and raised-shell height follow
the imagery contract. Replacing or destroying a source releases its owned
resources; stale loads cannot replace the current source.
- on the globe's imagery layers while the globe shows, else on the shown 3D Cesium translates sources to imagery providers and raised shells. Navara
tileset's own imagery layers (Cesium drapes imagery on tiles there), as samples imagery in its surface pass, including raised ellipsoid intersections.
`engine.imagery.surface()` reports it to layers; with neither, the The adapters own texture storage, tile selection and depth reconstruction.
layers wait;
- the engine checks the surface on every frame while it holds a layer, and
builds each layer again on the new stack after a switch;
- above the base imagery, in order among the engine's layers; imagery other
code adds stays where it was put;
- tiles: a `UrlTemplateImageryProvider` in Web Mercator, bounded to the
rectangle; `maxRequests` gates `requestImage`, and a request the gate or
the provider defers asks for one delayed frame, never one per frame;
- an image: one geographic tile over the rectangle (as
`SingleTileImageryProvider`), handed to Cesium as the canvas or image
itself, with no PNG round trip;
- geographic tiles: a `GeographicTilingScheme`; a transform wraps
`requestImage` (Cesium's tile bitmaps arrive flipped, so the transform
gets them upright on a canvas);
- a credit is read through the provider's `credit` getter every frame, so a
new credit of the same source needs no new layer;
- a staged source is a second `ImageryLayer` at alpha 0 right over the
drawn one, whose tiles Cesium loads while the drawn one shows. Its own
requests are counted (deferrals too); once they are in (the globe's tiles
all loaded, or its own requests quiet for 200 ms) for two frames in a
row, it takes the drawn one's alpha and the drawn one goes after the next
frame. Throttled tiles (429, 503) retry three times; any other failed
tile fails the source;
- a raised layer is a primitive of its own (`imageryShell.js`): a
rectangle at the height with a material of the image and the detail in
its window, flat, alpha blended in the opaque pass without depth writes,
first in `scene.primitives` in the imagery order, lifted by a uniform
scale about the Earth's centre. A new image on the same rectangle swaps
once uploaded; another rectangle is a new shell at alpha 0 until drawn;
- `split` is the layer's `splitDirection`; Cesium splits on the globe only
(not raised layers).
**Navara** (`src/globe/navara/imagery/`) draws every shown layer in one Cesium moves draped layers between the visible globe and tileset surfaces;
screen pass right after tone mapping, under the other drapes and without either surface, layers wait. Its split applies to globe imagery,
`engine.ground`: not raised shells. Navara draws the top eight visible imagery slots, with
detail images consuming a slot. Staged sources keep the current image visible
until their replacement is ready; a failed source leaves it intact.
- each pixel's ground position comes from the scene depth (terrain and 3D Open map overlays use `engine.imagery` when `maps.addOverlay` is absent.
tiles), as `engine.ground`'s pass finds it; Navara's optional native raster path retains terrain detail, but does not
- up to 8 layers are sampled in draw order at its longitude and latitude, provide rectangle bounds, split, alpha, order, or drawing on 3D tiles.
each in its rectangle and on its side of the split (the top 8 when more
are shown);
- an image is one mipmapped texture, sampled in longitude and latitude;
- tiles are an atlas of at most 8×8 Web Mercator or geographic tiles over
the part of the rectangle in view, at the level the camera's height asks
for (a geographic level one below Web Mercator's); it is rebuilt when the
camera moves to another plan and filled as tiles arrive, each through the
transform, 6 loads at a time (or `maxRequests`);
- a staged source builds its own texture or atlas (for the view now, again
when the camera moves) while the drawn one shows, and takes its place
once every tile of it has settled; a failed tile fails it;
- a raised slot is sampled where the pixel's ray meets the ellipsoid grown
by its lifted height, where nothing of the scene is nearer; a detail is a
second slot right over its layer's, which draws nothing inside the
detail's rectangle;
- texels are blended in display space, as Cesium's globe blends imagery;
mip levels come from the derivatives of the ground position, taken
before any branch, so rectangle edges and the antimeridian show no seams.
Open map overlays (Sea Marks, Ski Pistes) use `engine.imagery` when the
engine has no `maps.addOverlay`. Navara keeps that optional native raster
path for its per-tile detail. It adds a native Navara raster layer: Navara's own per-tile levels on
the terrain, lit like the base map, but no rectangle, split, alpha or order,
and nothing on 3D tiles. Moving that path to imagery needs finer Navara tile detail: a global overlay seen obliquely gets coarse
near the camera from one 8×8 atlas over the whole view.
### Who draws through imagery ### Who draws through imagery
@@ -696,8 +584,8 @@ Run `createContractSuite(engine, { waitFrames, ... })` from
`src/globe/testing/contractSuite.mjs`. Supply real pointer delivery, canvases and `src/globe/testing/contractSuite.mjs`. Supply real pointer delivery, canvases and
pixel reads for browser checks; keep the fake-engine tests as fast contract pixel reads for browser checks; keep the fake-engine tests as fast contract
checks. Test ownership and repeated destruction as well as visible output. checks. Test ownership and repeated destruction as well as visible output.
The browser harness is `scripts/qa-contract-suite.mjs`; ground and imagery pixel The browser harness is `scripts/qa-contract-suite.mjs`; use
checks also run through `scripts/qa-ground-suite.mjs`. `--only=ground:,imagery:` for ground and imagery pixel checks.
The application currently registers two kinds: adding another still requires The application currently registers two kinds: adding another still requires
registration in `ENGINE_KINDS`, scene construction/choice and the corresponding registration in `ENGINE_KINDS`, scene construction/choice and the corresponding
+3 -3
View File
@@ -7,10 +7,10 @@
"navaraKindChecks": 1, "navaraKindChecks": 1,
"engineSniffCalls": 0, "engineSniffCalls": 0,
"engineExtensionReads": 3, "engineExtensionReads": 3,
"groundEffectPasses": 1, "groundEffectPasses": 0,
"cesiumSdkLoads": 3, "cesiumSdkLoads": 3,
"screenSpaceHandlerFiles": 4, "screenSpaceHandlerFiles": 2,
"engineNameLines": 27 "engineNameLines": 27
}, },
"measuredAt": "Drop leftover viewer paths" "measuredAt": "One selection path and test level"
} }
@@ -13,7 +13,7 @@ export const cameras = [
].map((position, i) => ({ ].map((position, i) => ({
...position, ...position,
osmId: 9000000 + i, osmId: 9000000 + i,
operator: i % 3 ? 'W2 fixture' : 'Cámara pública', operator: i % 3 ? 'Deterministic fixture' : 'Cámara pública',
brand: 'Fixture camera', brand: 'Fixture camera',
cameraType: 'fixed', cameraType: 'fixed',
direction: i % 5 ? String((i * 37) % 360) : '', direction: i % 5 ? String((i * 37) % 360) : '',
@@ -2,8 +2,8 @@ import sharp from 'sharp';
// Transport fixtures are parsed by the real CCTV catalog and media owners. // Transport fixtures are parsed by the real CCTV catalog and media owners.
export const cameras = Array.from({ length: 25 }, (_, i) => ({ export const cameras = Array.from({ length: 25 }, (_, i) => ({
id: `w2-cctv-${String(i).padStart(3, '0')}`, id: `fixture-cctv-${String(i).padStart(3, '0')}`,
name: i === 12 ? 'Cámara pública — Congress' : `W2 camera ${i}`, name: i === 12 ? 'Cámara pública — Congress' : `Fixture camera ${i}`,
cityId: 'austin', cityId: 'austin',
city: 'Austin', city: 'Austin',
lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018, lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018,
@@ -17,7 +17,7 @@ export const cameras = Array.from({ length: 25 }, (_, i) => ({
headingConfidence: i % 3 ? 'medium' : 'low', headingConfidence: i % 3 ? 'medium' : 'low',
sourceKind: 'configured', sourceKind: 'configured',
feedType: 'image', feedType: 'image',
provider: 'W2 deterministic fixture', provider: 'Deterministic fixture',
url: 'https://cctv-fixture.invalid/frame.png', url: 'https://cctv-fixture.invalid/frame.png',
})); }));
@@ -51,8 +51,8 @@ export async function installCctvFixture(page, { video } = {}) {
if (video) if (video)
catalog.push({ catalog.push({
...cameras[0], ...cameras[0],
id: 'w2-cctv-video', id: 'fixture-cctv-video',
name: 'W2 moving video', name: 'Fixture moving video',
lat: 30.2718, lat: 30.2718,
lon: -97.744, lon: -97.744,
feedType: 'video', feedType: 'video',
@@ -116,7 +116,7 @@ export async function installCctvFixture(page, { video } = {}) {
id, id,
status: 'online', status: 'online',
sourceKind: feedType, sourceKind: feedType,
label: 'W2 deterministic fixture', label: 'Deterministic fixture',
updatedAt: Date.now(), updatedAt: Date.now(),
})), })),
}); });
@@ -126,7 +126,7 @@ export async function installCctvFixture(page, { video } = {}) {
const id = decodeURIComponent(media[2]); const id = decodeURIComponent(media[2]);
if ( if (
!ids.has(id) || !ids.has(id) ||
(media[1] === 'media' && (id !== 'w2-cctv-video' || !video)) (media[1] === 'media' && (id !== 'fixture-cctv-video' || !video))
) { ) {
return request.respond({ status: 404, body: 'Unknown fixture camera' }); return request.respond({ status: 404, body: 'Unknown fixture camera' });
} }
@@ -4,8 +4,8 @@ const grid = (i, cols, spacing) => ({
lon: ORIGIN.lon + ((i % cols) - cols / 2) * spacing, lon: ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
}); });
const bikes = Array.from({ length: 200 }, (_, i) => ({ const bikes = Array.from({ length: 200 }, (_, i) => ({
station_id: `w2-bike-${i}`, station_id: `fixture-bike-${i}`,
name: `W2 station ${i}`, name: `Fixture station ${i}`,
capacity: 20, capacity: 20,
...grid(i, 15, 0.0015), ...grid(i, 15, 0.0015),
})); }));
File renamed without changes.
@@ -78,7 +78,7 @@ export async function installRadioFixture(page) {
stale: false, stale: false,
degraded: false, degraded: false,
acceptedGeneration: qa.generation, acceptedGeneration: qa.generation,
catalogInstance: 'w2-radio-fixture', catalogInstance: 'fixture-radio-fixture',
}; };
const mode = qa.response; const mode = qa.response;
const delay = qa.delay; const delay = qa.delay;
@@ -6,7 +6,7 @@ export function transitSnapshot(step = 8) {
fetchedAt, fetchedAt,
vehicles: [ vehicles: [
...Array.from({ length: 12 }, (_, i) => ({ ...Array.from({ length: 12 }, (_, i) => ({
id: `w2-${i}`, id: `fixture-${i}`,
routeId: routes[i % 6], routeId: routes[i % 6],
tripId: `trip-${i}`, tripId: `trip-${i}`,
lat: lat:
+8 -9
View File
@@ -1212,6 +1212,7 @@
"src/testSupport/cockpitEngine.mjs", "src/testSupport/cockpitEngine.mjs",
"src/layers/aircraft/render/contract.js", "src/layers/aircraft/render/contract.js",
"src/layers/shared/color.test.mjs", "src/layers/shared/color.test.mjs",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/shared/viewRectangle.js", "src/layers/shared/viewRectangle.js",
"src/layers/military/render/display.js", "src/layers/military/render/display.js",
@@ -1222,7 +1223,6 @@
"src/layers/vessels/renderer.test.mjs", "src/layers/vessels/renderer.test.mjs",
"src/layers/vessels/testFixtures.mjs", "src/layers/vessels/testFixtures.mjs",
"src/layers/shared/render/engineView.js", "src/layers/shared/render/engineView.js",
"src/globe/navara/ground/effect.js",
"src/layers/shared/render/moverBank.js", "src/layers/shared/render/moverBank.js",
"src/layers/shared/render/imageAtlas.js", "src/layers/shared/render/imageAtlas.js",
"src/layers/bikeshare/presentation.js", "src/layers/bikeshare/presentation.js",
@@ -1233,8 +1233,8 @@
"src/layers/cctv/lifecycle.test.mjs", "src/layers/cctv/lifecycle.test.mjs",
"src/layers/cctv/projectionMedia.test.mjs", "src/layers/cctv/projectionMedia.test.mjs",
"src/layers/cctv/ingestion.test.mjs", "src/layers/cctv/ingestion.test.mjs",
"scripts/fixtures/w2/alpr.mjs", "scripts/fixtures/layers/alpr.mjs",
"scripts/fixtures/w2/cctv.mjs", "scripts/fixtures/layers/cctv.mjs",
"src/layers/radio/screenClusters.js", "src/layers/radio/screenClusters.js",
"src/layers/radio/screenClusters.test.mjs", "src/layers/radio/screenClusters.test.mjs",
"src/globe/cesium/mapScene.js", "src/globe/cesium/mapScene.js",
@@ -1258,7 +1258,7 @@
"src/globe/cesium/testing/cameraViewer.mjs", "src/globe/cesium/testing/cameraViewer.mjs",
"src/app/globeEngine.js", "src/app/globeEngine.js",
"src/app/globeEngine.test.mjs", "src/app/globeEngine.test.mjs",
"scripts/fixtures/w2/transit.mjs", "scripts/fixtures/layers/transit.mjs",
"src/layers/localAdsb/render/display.js", "src/layers/localAdsb/render/display.js",
"src/layers/localAdsb/renderer.test.mjs", "src/layers/localAdsb/renderer.test.mjs",
"build/navaraAssets.js", "build/navaraAssets.js",
@@ -1381,16 +1381,16 @@
"src/globe/navara/movers/frameCamera.test.mjs", "src/globe/navara/movers/frameCamera.test.mjs",
"src/layers/localAdsb/qaDrawn.js", "src/layers/localAdsb/qaDrawn.js",
"src/layers/localAdsb/qaDrawn.test.mjs", "src/layers/localAdsb/qaDrawn.test.mjs",
"src/tooling/w1RuntimeEvidence.test.mjs", "src/tooling/browserRuntimeEvidence.test.mjs",
"src/layers/firms/rectangle.js", "src/layers/firms/rectangle.js",
"src/layers/firms/rectangle.test.mjs", "src/layers/firms/rectangle.test.mjs",
"src/layers/firms/viewport.test.mjs", "src/layers/firms/viewport.test.mjs",
"scripts/fixtures/w2/firms.mjs", "scripts/fixtures/layers/firms.mjs",
"src/layers/firms/lifecycle.test.mjs", "src/layers/firms/lifecycle.test.mjs",
"scripts/fixtures/w2/radio.mjs", "scripts/fixtures/layers/radio.mjs",
"scripts/qa-map-policy.mjs", "scripts/qa-map-policy.mjs",
"scripts/qa-map-policy.test.mjs", "scripts/qa-map-policy.test.mjs",
"scripts/qa-w2-imagery.mjs", "scripts/qa-imagery-contract.mjs",
"src/globe/navara/postSamplingPass.test.mjs", "src/globe/navara/postSamplingPass.test.mjs",
"src/globe/navara/emptySceneRender.test.mjs", "src/globe/navara/emptySceneRender.test.mjs",
"src/data/firmsIntensity.js", "src/data/firmsIntensity.js",
@@ -1579,7 +1579,6 @@
"src/globe/navara/ground/pack.js", "src/globe/navara/ground/pack.js",
"src/globe/navara/ground/pack.test.mjs", "src/globe/navara/ground/pack.test.mjs",
"src/globe/navara/ground/pass.js", "src/globe/navara/ground/pass.js",
"scripts/qa-ground-suite.mjs",
"docs/GLOBE-PRIMITIVES.md", "docs/GLOBE-PRIMITIVES.md",
"src/layers/airQuality/render/display.js", "src/layers/airQuality/render/display.js",
"src/layers/airQuality/render/display.test.mjs", "src/layers/airQuality/render/display.test.mjs",
+36 -20
View File
@@ -32,6 +32,7 @@
"src/geo/", "src/geo/",
"src/layers/infrastructure/index.js", "src/layers/infrastructure/index.js",
"src/layers/infrastructure/render/display.js", "src/layers/infrastructure/render/display.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/sources/requestDeadline.js", "src/sources/requestDeadline.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
@@ -1423,8 +1424,6 @@
"src/data/routePlausible.js", "src/data/routePlausible.js",
"src/geoDistance.js", "src/geoDistance.js",
"src/data/trackedModelRegime.js", "src/data/trackedModelRegime.js",
"src/data/trackingClickGesture.js",
"src/data/trackingGesture.js",
"src/layers/flights/controller.js", "src/layers/flights/controller.js",
"src/layers/flights/enrichment.js", "src/layers/flights/enrichment.js",
"src/layers/flights/evidence.js", "src/layers/flights/evidence.js",
@@ -1465,9 +1464,11 @@
"src/geo/", "src/geo/",
"src/time/clock.js", "src/time/clock.js",
"src/globe/contract.js", "src/globe/contract.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/aircraft/qaDrawn.js", "src/layers/aircraft/qaDrawn.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js",
"src/data/trackingGesture.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1495,8 +1496,6 @@
"src/data/motionModel.js", "src/data/motionModel.js",
"src/data/renderAltitude.js", "src/data/renderAltitude.js",
"src/data/trackedModelRegime.js", "src/data/trackedModelRegime.js",
"src/data/trackingClickGesture.js",
"src/data/trackingGesture.js",
"src/layers/military/controller.js", "src/layers/military/controller.js",
"src/layers/military/index.js", "src/layers/military/index.js",
"src/layers/military/ingestion.js", "src/layers/military/ingestion.js",
@@ -1530,18 +1529,21 @@
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/globe/cesium/sceneProjection.js", "src/globe/cesium/sceneProjection.js",
"src/globe/cesium/screenEvents.js", "src/globe/cesium/screenEvents.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/geo/", "src/geo/",
"src/time/clock.js", "src/time/clock.js",
"src/globe/contract.js", "src/globe/contract.js",
"src/layers/aircraft/qaDrawn.js", "src/layers/aircraft/qaDrawn.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js",
"src/data/trackingGesture.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
"aircraft-mechanics": { "aircraft-mechanics": {
"exports": ["./aircraft"], "exports": ["./aircraft"],
"modules": [ "modules": [
"src/data/trackingGesture.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftIcons.js", "src/data/aircraftIcons.js",
@@ -1563,8 +1565,6 @@
"src/data/routePlausible.js", "src/data/routePlausible.js",
"src/geoDistance.js", "src/geoDistance.js",
"src/data/trackedModelRegime.js", "src/data/trackedModelRegime.js",
"src/data/trackingClickGesture.js",
"src/data/trackingGesture.js",
"src/layers/aircraft/classification.js", "src/layers/aircraft/classification.js",
"src/layers/aircraft/index.js", "src/layers/aircraft/index.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
@@ -1614,6 +1614,7 @@
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/vessels/icons.js", "src/layers/vessels/icons.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/globe/cesium/sceneProjection.js", "src/globe/cesium/sceneProjection.js",
"src/geo/", "src/geo/",
@@ -1630,6 +1631,7 @@
"src/layers/earthquakes/source.js", "src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js", "src/layers/earthquakes/records.js",
"src/geo/", "src/geo/",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/earthquakes/render/display.js", "src/layers/earthquakes/render/display.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
@@ -1700,6 +1702,7 @@
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/geo/", "src/geo/",
"src/layers/firms/rectangle.js", "src/layers/firms/rectangle.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
], ],
@@ -1743,6 +1746,7 @@
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/geo/", "src/geo/",
"src/time/clock.js", "src/time/clock.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
], ],
@@ -1785,6 +1789,7 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/geo/", "src/geo/",
"src/time/clock.js", "src/time/clock.js",
@@ -1837,6 +1842,7 @@
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/installations/classLabel.js", "src/layers/installations/classLabel.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/geo/", "src/geo/",
"src/globe/contract.js" "src/globe/contract.js"
@@ -1900,6 +1906,7 @@
"traffic-layer": { "traffic-layer": {
"exports": ["./layers/traffic"], "exports": ["./layers/traffic"],
"modules": [ "modules": [
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/data/cameraSensitivity.js", "src/data/cameraSensitivity.js",
"src/data/flowMatch.js", "src/data/flowMatch.js",
@@ -1992,6 +1999,7 @@
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/bikeshare/selectionModel.js", "src/layers/bikeshare/selectionModel.js",
"src/layers/bikeshare/presentation.js", "src/layers/bikeshare/presentation.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/geo/", "src/geo/",
"src/globe/contract.js", "src/globe/contract.js",
@@ -2051,6 +2059,7 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/geo/", "src/geo/",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
@@ -2128,6 +2137,7 @@
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js", "src/layers/streetLevel/providers/mapillary/policy.js",
"src/geo/", "src/geo/",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/globe/cameraMath.js", "src/globe/cameraMath.js",
"src/layers/streetLevel/render/display.js", "src/layers/streetLevel/render/display.js",
@@ -2246,6 +2256,7 @@
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/geo/", "src/geo/",
"src/sources/requestDeadline.js", "src/sources/requestDeadline.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/submarineCables/render/display.js", "src/layers/submarineCables/render/display.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
@@ -2610,7 +2621,6 @@
"src/data/neighborhoodPolygons.js", "src/data/neighborhoodPolygons.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/data/sceneServices.js", "src/data/sceneServices.js",
"src/data/trackingGesture.js",
"src/data/radio.js", "src/data/radio.js",
"src/data/radioCountry.js", "src/data/radioCountry.js",
"src/data/regionalBrief.js", "src/data/regionalBrief.js",
@@ -2633,7 +2643,6 @@
"src/data/trackedCamera.js", "src/data/trackedCamera.js",
"src/data/trackedModelRegime.js", "src/data/trackedModelRegime.js",
"src/data/trackedReadout.js", "src/data/trackedReadout.js",
"src/data/trackingClickGesture.js",
"src/data/traffic.js", "src/data/traffic.js",
"src/data/trafficBounds.js", "src/data/trafficBounds.js",
"src/data/trafficFlowStyle.js", "src/data/trafficFlowStyle.js",
@@ -3496,6 +3505,7 @@
"src/layers/flights/render/display.js", "src/layers/flights/render/display.js",
"src/layers/military/render/display.js", "src/layers/military/render/display.js",
"src/data/selectionDispatcher.js", "src/data/selectionDispatcher.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/aircraft/qaDrawn.js", "src/layers/aircraft/qaDrawn.js",
"src/layers/localAdsb/render/display.js", "src/layers/localAdsb/render/display.js",
@@ -3503,6 +3513,7 @@
"src/globe/cesium/celestial.js", "src/globe/cesium/celestial.js",
"src/celestialRing/view.js", "src/celestialRing/view.js",
"src/globe/navara/ground/pulse.js", "src/globe/navara/ground/pulse.js",
"src/globe/navara/shaderChunks.js",
"src/globe/navara/sceneDepth.js", "src/globe/navara/sceneDepth.js",
"src/layers/bhoteKoshi/render/story.js", "src/layers/bhoteKoshi/render/story.js",
"src/globe/navara/windFlowMesh.js", "src/globe/navara/windFlowMesh.js",
@@ -3513,7 +3524,6 @@
"src/layers/radio/screenClusters.js", "src/layers/radio/screenClusters.js",
"src/layers/shared/render/imageAtlas.js", "src/layers/shared/render/imageAtlas.js",
"src/globe/navara/ground/surface.js", "src/globe/navara/ground/surface.js",
"src/globe/navara/ground/effect.js",
"src/annotations/outlineRungs.js", "src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js", "src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js", "src/sources/nominatimOutlines.js",
@@ -3581,7 +3591,6 @@
"src/globe/navara/ground/index.js", "src/globe/navara/ground/index.js",
"src/layers/cctv/render/meshes.js", "src/layers/cctv/render/meshes.js",
"src/layers/cctv/render/display.js", "src/layers/cctv/render/display.js",
"src/layers/cctv/worldClick.js",
"src/layers/transit/render/display.js", "src/layers/transit/render/display.js",
"src/layers/earthquakes/render/display.js", "src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js", "src/layers/airQuality/render/display.js",
@@ -3625,7 +3634,8 @@
"src/globe/exclusivePointer.js", "src/globe/exclusivePointer.js",
"src/globe/cesium/followOffset.js", "src/globe/cesium/followOffset.js",
"src/overlays/bindOverlayHost.js", "src/overlays/bindOverlayHost.js",
"src/ui/releaseTrackers.js" "src/ui/releaseTrackers.js",
"src/data/trackingGesture.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3764,7 +3774,6 @@
"src/data/neighborhoodPolygons.js", "src/data/neighborhoodPolygons.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/data/sceneServices.js", "src/data/sceneServices.js",
"src/data/trackingGesture.js",
"src/data/radioCountry.js", "src/data/radioCountry.js",
"src/data/renderAltitude.js", "src/data/renderAltitude.js",
"src/data/bundledJson.js", "src/data/bundledJson.js",
@@ -3778,7 +3787,6 @@
"src/data/trackedCamera.js", "src/data/trackedCamera.js",
"src/data/trackedModelRegime.js", "src/data/trackedModelRegime.js",
"src/data/trackedReadout.js", "src/data/trackedReadout.js",
"src/data/trackingClickGesture.js",
"src/data/trafficBounds.js", "src/data/trafficBounds.js",
"src/data/trafficFlowStyle.js", "src/data/trafficFlowStyle.js",
"src/data/trafficPresetStyle.js", "src/data/trafficPresetStyle.js",
@@ -4275,6 +4283,7 @@
"src/app/layers/displays.js", "src/app/layers/displays.js",
"src/layers/aircraft/render/display.js", "src/layers/aircraft/render/display.js",
"src/data/selectionDispatcher.js", "src/data/selectionDispatcher.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/flights/render/display.js", "src/layers/flights/render/display.js",
"src/layers/military/render/display.js", "src/layers/military/render/display.js",
@@ -4336,7 +4345,6 @@
"src/layers/cctv/render/style.js", "src/layers/cctv/render/style.js",
"src/layers/cctv/render/meshes.js", "src/layers/cctv/render/meshes.js",
"src/layers/cctv/render/display.js", "src/layers/cctv/render/display.js",
"src/layers/cctv/worldClick.js",
"src/layers/transit/render/display.js", "src/layers/transit/render/display.js",
"src/layers/earthquakes/render/display.js", "src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js", "src/layers/airQuality/render/display.js",
@@ -4368,7 +4376,6 @@
"src/layers/bhoteKoshi/render/story.js", "src/layers/bhoteKoshi/render/story.js",
"src/globe/navara/ground/surface.js", "src/globe/navara/ground/surface.js",
"src/globe/navara/windFlowMesh.js", "src/globe/navara/windFlowMesh.js",
"src/globe/navara/ground/effect.js",
"src/globe/navara/ground/pulse.js", "src/globe/navara/ground/pulse.js",
"src/globe/cesium/windFlow.js", "src/globe/cesium/windFlow.js",
"src/globe/cesium/postStages.js", "src/globe/cesium/postStages.js",
@@ -4386,7 +4393,8 @@
"src/globe/meshes.js", "src/globe/meshes.js",
"src/globe/exclusivePointer.js", "src/globe/exclusivePointer.js",
"src/globe/cesium/followOffset.js", "src/globe/cesium/followOffset.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js",
"src/data/trackingGesture.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -4730,6 +4738,7 @@
"src/data/distanceFormat.js", "src/data/distanceFormat.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/data/pointerOwnerNames.js", "src/data/pointerOwnerNames.js",
"src/layers/directions/source.js", "src/layers/directions/source.js",
@@ -5051,7 +5060,6 @@
"src/data/meshFloorSampler.js", "src/data/meshFloorSampler.js",
"src/data/pickRegistry.js", "src/data/pickRegistry.js",
"src/data/sceneServices.js", "src/data/sceneServices.js",
"src/data/trackingGesture.js",
"src/data/scenePick.js", "src/data/scenePick.js",
"src/data/spriteOrder.js", "src/data/spriteOrder.js",
"src/data/terrainHeights.js", "src/data/terrainHeights.js",
@@ -5132,6 +5140,7 @@
"src/celestialRing/view.js", "src/celestialRing/view.js",
"src/layers/shared/render/engineView.js", "src/layers/shared/render/engineView.js",
"src/globe/cesium/sceneProjection.js", "src/globe/cesium/sceneProjection.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/geo/", "src/geo/",
"src/time/clock.js", "src/time/clock.js",
@@ -5156,7 +5165,8 @@
"src/data/selectionDispatcher.js", "src/data/selectionDispatcher.js",
"src/overlays/bindOverlayHost.js", "src/overlays/bindOverlayHost.js",
"src/data/contextStore.js", "src/data/contextStore.js",
"src/data/trackedReadout.js" "src/data/trackedReadout.js",
"src/data/trackingGesture.js"
], ],
"external": ["cesium", "egm96-universal", "pbf"] "external": ["cesium", "egm96-universal", "pbf"]
}, },
@@ -5208,6 +5218,7 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/geo/", "src/geo/",
"src/globe/contract.js", "src/globe/contract.js",
@@ -5276,6 +5287,7 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/perimeters/render/display.js", "src/layers/perimeters/render/display.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
@@ -5306,6 +5318,7 @@
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
"src/layers/shared/viewRectangle.js", "src/layers/shared/viewRectangle.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/geo/math.js", "src/geo/math.js",
"src/geo/cartesian2.js", "src/geo/cartesian2.js",
@@ -5439,6 +5452,7 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/droneAirspace/render/display.js", "src/layers/droneAirspace/render/display.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
@@ -5645,6 +5659,7 @@
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/airQuality/render/display.js", "src/layers/airQuality/render/display.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
@@ -5725,6 +5740,7 @@
"src/globe/cesium/credits.js", "src/globe/cesium/credits.js",
"src/layers/wildlifeSightings/display.js", "src/layers/wildlifeSightings/display.js",
"src/data/spriteOrder.js", "src/data/spriteOrder.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
], ],
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* qa-heading-b3.mjs — throwaway QA harness for skylight Task 4 * qa-aircraft-heading.mjs — aircraft display-heading checks
* (path-derived, rate-limited display course; Batch 3 overnight run). * (path-derived, rate-limited display course; Batch 3 overnight run).
* *
* The permanent track-regression harness flies planes STRAIGHT, so it cannot * The permanent track-regression harness flies planes STRAIGHT, so it cannot
@@ -45,7 +45,7 @@
* normalized nose -X, offset 180°). * normalized nose -X, offset 180°).
* *
* VISUAL — with the turning flights plane tracked in 3D, capture screenshots * VISUAL — with the turning flights plane tracked in 3D, capture screenshots
* (two zoom levels × two orbit angles) into qa-shots/b3/ for human review: * (two zoom levels × two orbit angles) into qa-shots/aircraft-heading/ for human review:
* the nose must lead the curved trail, never sideways/backwards. * the nose must lead the curved trail, never sideways/backwards.
* *
* LOW-SPEED (heading-v2, 2026-07-03 field-test regression) — two extra * LOW-SPEED (heading-v2, 2026-07-03 field-test regression) — two extra
@@ -93,7 +93,7 @@
* exceeds 15°/s (truth is 2°/s; the pre-fix snap replayed ~120° of frozen * exceeds 15°/s (truth is 2°/s; the pre-fix snap replayed ~120° of frozen
* divergence at the 60°/s slew cap). * divergence at the 60°/s slew cap).
* *
* Run: node scripts/qa-heading-b3.mjs --url http://localhost:4300 * Run: node scripts/qa-aircraft-heading.mjs --url http://localhost:4300
* Exits non-zero if any assertion fails. Never commits anything. * Exits non-zero if any assertion fails. Never commits anything.
*/ */
@@ -120,7 +120,7 @@ if (requestedEngine) {
} }
const APP_URL = appUrl.href; const APP_URL = appUrl.href;
const HEADFUL = getFlag('--headful'); const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/b3'); const SHOT_DIR = path.resolve('qa-shots/aircraft-heading');
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2'); const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
const CHROME_EXECUTABLE_CANDIDATES = [ const CHROME_EXECUTABLE_CANDIDATES = [
@@ -774,7 +774,7 @@ async function main() {
} }
// ---- Screenshots: two zoom levels × two orbit angles -------------------- // ---- Screenshots: two zoom levels × two orbit angles --------------------
console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/b3/'); console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/aircraft-heading/');
await page.evaluate(() => { await page.evaluate(() => {
window.__godsEyeView.styleManager._setDetectionMode('DENSE'); window.__godsEyeView.styleManager._setDetectionMode('DENSE');
}); });
@@ -1,12 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* qa-sprites-b5.mjs — throwaway QA harness for skylight Tasks 8+9 * qa-aircraft-sprites.mjs — aircraft sprite checks
* (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5). * (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5).
* *
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft * Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
* classes — the flights layer via OpenSky extended categories (state[17]), * classes — the flights layer via OpenSky extended categories (state[17]),
* the military layer via adsb.lol type codes — using the same fetch-shim + * the military layer via adsb.lol type codes — using the same fetch-shim +
* history-priming pattern as scripts/qa-heading-b3.mjs (positions analytic at * history-priming pattern as scripts/qa-aircraft-heading.mjs (positions analytic at
* serve time so every fix is self-consistent through the render delay). * serve time so every fix is self-consistent through the render delay).
* *
* Machine assertions: * Machine assertions:
@@ -15,17 +15,17 @@
* per-class glyph (image data-URI === aircraftIcon(klass), * per-class glyph (image data-URI === aircraftIcon(klass),
* imported from src) and per-class scale (flights: * imported from src) and per-class scale (flights:
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D) * CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
* B3 models : with 3D on, fleet Cesium.Model.scale === the layer's * Model scales : with 3D on, fleet Cesium.Model.scale === the layer's
* MODEL_SCALE (both 1; legacy scale is baked into the GLB) * MODEL_SCALE (both 1; legacy scale is baked into the GLB)
* × CLASS_SCALE_3D[klass] per icao; tracked too * × CLASS_SCALE_3D[klass] per icao; tracked too
* B4 clean : no console errors * B4 clean : no console errors
* *
* Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows, * Visual output (qa-shots/aircraft-sprites/, untracked): top-down 2D spreads of both rows,
* per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two * per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two
* zooms per layer showing per-class model scale differences. The PNGs are the * zooms per layer showing per-class model scale differences. The PNGs are the
* acceptance test — read them. * acceptance test — read them.
* *
* Run: node scripts/qa-sprites-b5.mjs --url http://localhost:4300 * Run: node scripts/qa-aircraft-sprites.mjs --url http://localhost:4300
* Exits non-zero if any assertion fails. Never commits anything. * Exits non-zero if any assertion fails. Never commits anything.
*/ */
@@ -44,7 +44,7 @@ const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
import { aircraftIcon } from '../src/data/aircraftIcons.js'; import { aircraftIcon } from '../src/data/aircraftIcons.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Args (same shape as qa-heading-b3.mjs) // Args (same shape as qa-aircraft-heading.mjs)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const getFlag = (name) => argv.includes(name); const getFlag = (name) => argv.includes(name);
@@ -61,7 +61,7 @@ const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=ces
const APP_URL = const APP_URL =
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`; ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
const HEADFUL = getFlag('--headful'); const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/b5' : `qa-shots/b5-${ENGINE}`); const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/aircraft-sprites' : `qa-shots/aircraft-sprites-${ENGINE}`);
const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale) const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale)
const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb
@@ -137,7 +137,7 @@ const SPRITES = {
{ hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 }, { hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 },
{ hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 }, { hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 },
// course 0 like the rest — keeps the row a clean size-comparison line (2D // course 0 like the rest — keeps the row a clean size-comparison line (2D
// rotation is demoed by caa004 and gated by qa-heading-b3 anyway). // rotation is demoed by caa004 and gated by qa-aircraft-heading anyway).
{ hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 }, { hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 },
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a // 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a
// real-world-scale asset (native bounding radius ~29.8) rendered at // real-world-scale asset (native bounding radius ~29.8) rendered at
@@ -332,7 +332,7 @@ async function main() {
}; };
}); });
// ---- Prime history through the render delay (same pattern as b3) ------- // ---- Prime history through the render delay (same pattern as the heading checks) -------
console.log('Priming straight-flight history through the render delay (30 s / 15 s)...'); console.log('Priming straight-flight history through the render delay (30 s / 15 s)...');
const primed = await page.evaluate(async () => { const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager; const dm = window.__godsEyeView.dataManager;
@@ -394,7 +394,7 @@ async function main() {
// ======================================================================== // ========================================================================
// 2D screenshots // 2D screenshots
// ======================================================================== // ========================================================================
console.log('\n2D screenshots → qa-shots/b5/'); console.log('\n2D screenshots → qa-shots/aircraft-sprites/');
const topDown = async (lat, lon, height) => { const topDown = async (lat, lon, height) => {
await page.evaluate(async ({ lat, lon, height }) => { await page.evaluate(async ({ lat, lon, height }) => {
const camera = window.__godsEyeView.engine.camera; const camera = window.__godsEyeView.engine.camera;
@@ -429,21 +429,21 @@ async function main() {
const flRowLat = await rowLat('flights', 2, 30); const flRowLat = await rowLat('flights', 2, 30);
const milRowLat = await rowLat('military', 4, 15); const milRowLat = await rowLat('military', 4, 15);
await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000); await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-spread-bothrows.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-spread-bothrows.png') });
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)'); console.log(' saved aircraft-sprites-2d-spread-bothrows.png (flights row top, military row bottom)');
// Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up; // Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up;
// flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up. // flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up.
if (hasMilitary) { if (hasMilitary) {
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000); await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-military-row.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-military-row.png') });
console.log(' saved b5-2d-military-row.png (8 classes, amber)'); console.log(' saved aircraft-sprites-2d-military-row.png (8 classes, amber)');
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`); console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
} }
await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000); await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-flights-row.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-flights-row.png') });
console.log(' saved b5-2d-flights-row.png (6 category classes, white)'); console.log(' saved aircraft-sprites-2d-flights-row.png (6 category classes, white)');
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`); console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`);
// Tracked tint: commercial tracked plane goes cyan; neighbors stay white. // Tracked tint: commercial tracked plane goes cyan; neighbors stay white.
@@ -451,11 +451,11 @@ async function main() {
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao); window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
}, 'caa002'); }, 'caa002');
await sleep(3000); await sleep(3000);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-tracked-cyan.png') });
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)'); console.log(' saved aircraft-sprites-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
// ======================================================================== // ========================================================================
// B3 + 3D screenshots — flights cluster // Model scale and 3D screenshots — flights cluster
// ======================================================================== // ========================================================================
console.log('\n3D — flights cluster (airplane.glb, per-class scale)'); console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
await page.evaluate(() => { await page.evaluate(() => {
@@ -492,15 +492,15 @@ async function main() {
await sleep(1500); await sleep(1500);
await zoomToRange(900); await zoomToRange(900);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-mid.png') });
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)'); console.log(' saved aircraft-sprites-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
await zoomToRange(450); await zoomToRange(450);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-close.png') });
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)'); console.log(' saved aircraft-sprites-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
} }
// ======================================================================== // ========================================================================
// B3 + 3D screenshots — military cluster // Model scale and 3D screenshots — military cluster
// ======================================================================== // ========================================================================
if (!hasMilitary) { if (!hasMilitary) {
console.log(` [SKIP] 3D military — no military layer on ${ENGINE}`); console.log(` [SKIP] 3D military — no military layer on ${ENGINE}`);
@@ -543,11 +543,11 @@ async function main() {
// jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the // jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the
// flights cluster (close zoom no longer puts the camera inside a model). // flights cluster (close zoom no longer puts the camera inside a model).
await zoomToRange(900); await zoomToRange(900);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-far.png') });
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)'); console.log(' saved aircraft-sprites-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
await zoomToRange(450); await zoomToRange(450);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-close.png') });
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)'); console.log(' saved aircraft-sprites-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
} }
} }
+1 -1
View File
@@ -334,7 +334,7 @@ const runs = [
['--panoramax'], ['--panoramax'],
], ],
['radio-engine-switch', 'radio-engine-switch', ['radio']], ['radio-engine-switch', 'radio-engine-switch', ['radio']],
['w2-imagery', 'w2-imagery', ['recent-imagery']], ['imagery-contract', 'imagery-contract', ['recent-imagery']],
['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']], ['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']],
[ [
'fire-camera-switch--fires', 'fire-camera-switch--fires',
+1 -1
View File
@@ -41,7 +41,7 @@ import path from 'node:path';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs';
import sharp from 'sharp'; import sharp from 'sharp';
import { installAlprFixture } from './fixtures/w2/alpr.mjs'; import { installAlprFixture } from './fixtures/layers/alpr.mjs';
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs'; import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
import { import {
createScreencast, createScreencast,
+5 -1
View File
@@ -47,7 +47,11 @@ export function isExpectedPreviewSetupError(
// The runtime evidence gate shares one strict runtime verdict across its main page and // The runtime evidence gate shares one strict runtime verdict across its main page and
// every share-link page. Response diagnostics never retain query credentials. // every share-link page. Response diagnostics never retain query credentials.
export function watchW1Page(page, report, { name, preview = false, baseUrl }) { export function watchBrowserPage(
page,
report,
{ name, preview = false, baseUrl },
) {
const errors = (report.browserErrors ??= []); const errors = (report.browserErrors ??= []);
report.expectedPreviewErrors ??= []; report.expectedPreviewErrors ??= [];
report.failedResponses ??= []; report.failedResponses ??= [];
+6 -5
View File
@@ -2,7 +2,7 @@ import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { installCctvFixture } from './fixtures/w2/cctv.mjs'; import { installCctvFixture } from './fixtures/layers/cctv.mjs';
import { createStrict, localPreview } from './qa-strict-harness.mjs'; import { createStrict, localPreview } from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]); const url = localPreview(process.argv[2]);
url.search = '?engine=navara&welcome=0'; url.search = '?engine=navara&welcome=0';
@@ -64,7 +64,7 @@ await strict.run({}, async (b) => {
await dataManager.setEnabled('cctv', true, { origin: 'user' }); await dataManager.setEnabled('cctv', true, { origin: 'user' });
window.qaCctv = dataManager.layers.get('cctv').module; window.qaCctv = dataManager.layers.get('cctv').module;
window.qaCctv.setParams({ window.qaCctv.setParams({
selectedCameraId: 'w2-cctv-video', selectedCameraId: 'fixture-cctv-video',
showProjection: true, showProjection: true,
coverageMode: 'viewshed', coverageMode: 'viewshed',
calibration: { patch: { heightM: 31, pitchDeg: 20 } }, calibration: { patch: { heightM: 31, pitchDeg: 20 } },
@@ -101,11 +101,12 @@ await strict.run({}, async (b) => {
params: window.qaCctv.getParams(), params: window.qaCctv.getParams(),
plane: !!window.qaCctv plane: !!window.qaCctv
._cctvRecordsForTest() ._cctvRecordsForTest()
.find((r) => r.camera.id === 'w2-cctv-video').projection?.planeEntity, .find((r) => r.camera.id === 'fixture-cctv-video').projection
?.planeEntity,
projection: (() => { projection: (() => {
const r = window.qaCctv const r = window.qaCctv
._cctvRecordsForTest() ._cctvRecordsForTest()
.find((r) => r.camera.id === 'w2-cctv-video'); .find((r) => r.camera.id === 'fixture-cctv-video');
return { return {
show: r.projection?.planeEntity?.show === true, show: r.projection?.planeEntity?.show === true,
visible: r.projection?.planeVisible === true, visible: r.projection?.planeVisible === true,
@@ -123,7 +124,7 @@ await strict.run({}, async (b) => {
v.params.coverageMode, v.params.coverageMode,
); );
assert.ok(v.same && v.media && v.plane && !v.paused); assert.ok(v.same && v.media && v.plane && !v.paused);
assert.equal(v.params.selectedCameraId, 'w2-cctv-video'); assert.equal(v.params.selectedCameraId, 'fixture-cctv-video');
assert.equal(v.params.coverageMode, 'viewshed'); assert.equal(v.params.coverageMode, 'viewshed');
assert.equal(catalogs, count); assert.equal(catalogs, count);
await strict.passed( await strict.passed(
+3 -3
View File
@@ -77,9 +77,9 @@
*/ */
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { watchW1Page, redact } from './qa-browserEvidence.mjs'; import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { installCctvFixture } from './fixtures/w2/cctv.mjs'; import { installCctvFixture } from './fixtures/layers/cctv.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -581,7 +581,7 @@ async function main() {
if (terrainRequests.delete(request)) terrainActivityAt = Date.now(); if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
}); });
await page.setViewport({ width: 1280, height: 800 }); await page.setViewport({ width: 1280, height: 800 });
watchW1Page(page, evidence, { watchBrowserPage(page, evidence, {
name: 'cctv-legacy', name: 'cctv-legacy',
baseUrl: APP_URL, baseUrl: APP_URL,
preview: getFlag('--preview'), preview: getFlag('--preview'),
+58
View File
@@ -90,6 +90,41 @@ async function runOn(browser, kind) {
content: content:
'* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }', '* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }',
}); });
// The globe drawn and its tiles loaded: a restored view hides the globe
// for a moment after the loading screen goes.
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const probe = document.createElement('canvas');
probe.width = 1;
probe.height = 1;
const context = probe.getContext('2d', { willReadFrequently: true });
const started = performance.now();
let settled = 0;
while (performance.now() - started < 120_000 && settled < 20) {
await new Promise((resolve) => {
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
engine.render.request('contract-suite-settle');
});
context.drawImage(
canvas,
canvas.width / 2,
canvas.height / 2,
1,
1,
0,
0,
1,
1,
);
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
settled =
r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0;
}
});
// A real left click and pointer move at a canvas position. // A real left click and pointer move at a canvas position.
await page.exposeFunction('__contractPointer', async (type, x, y) => { await page.exposeFunction('__contractPointer', async (type, x, y) => {
const box = await page.evaluate(() => { const box = await page.evaluate(() => {
@@ -127,9 +162,32 @@ async function runOn(browser, kind) {
// A real 2D canvas: a sprite atlas the engines can draw and pick. // A real 2D canvas: a sprite atlas the engines can draw and pick.
const makeCanvas = (width, height) => const makeCanvas = (width, height) =>
Object.assign(document.createElement('canvas'), { width, height }); Object.assign(document.createElement('canvas'), { width, height });
// RGBA of the last drawn frame (both engines keep their drawing buffer).
const scratch = document.createElement('canvas');
const context = scratch.getContext('2d', { willReadFrequently: true });
const readRegion = (x, y, width, height) => {
const canvas = engine.host.canvas;
const scale = canvas.width / canvas.clientWidth;
scratch.width = width;
scratch.height = height;
context.clearRect(0, 0, width, height);
context.drawImage(
canvas,
x * scale,
y * scale,
width * scale,
height * scale,
0,
0,
width,
height,
);
return context.getImageData(0, 0, width, height).data;
};
for (const { name, run } of createContractSuite(engine, { for (const { name, run } of createContractSuite(engine, {
waitFrames, waitFrames,
pointer, pointer,
readRegion,
makeCanvas, makeCanvas,
})) { })) {
if (only && !only.some((prefix) => name.startsWith(prefix))) continue; if (only && !only.some((prefix) => name.startsWith(prefix))) continue;
+3 -3
View File
@@ -11,7 +11,7 @@
* them real types AMBIENTLY (nothing is ever tracked, 3D stays off), while * them real types AMBIENTLY (nothing is ever tracked, 3D stays off), while
* honoring the politeness bounds. * honoring the politeness bounds.
* *
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs): * Fetch shim (installed before app code, same pattern as qa-aircraft-sprites.mjs):
* - /api/flights → 12 straight-flying planes, category 0 * - /api/flights → 12 straight-flying planes, category 0
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60, * - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
* F16, GLID, B77W, A320, B738, PC12, R44) + one * F16, GLID, B77W, A320, B738, PC12, R44) + one
@@ -57,7 +57,7 @@ import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js';
import { aircraftIcon } from '../src/data/aircraftIcons.js'; import { aircraftIcon } from '../src/data/aircraftIcons.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Args (same shape as qa-sprites-b5.mjs) // Args (same shape as qa-aircraft-sprites.mjs)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const getFlag = (name) => argv.includes(name); const getFlag = (name) => argv.includes(name);
@@ -308,7 +308,7 @@ async function main() {
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop()); await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
console.log(' App globals ready.'); console.log(' App globals ready.');
// ---- In-page billboard probe (same as qa-sprites-b5.mjs) --------------- // ---- In-page billboard probe (same as qa-aircraft-sprites.mjs) ---------------
// What the flights layer draws, through the engine-neutral probe // What the flights layer draws, through the engine-neutral probe
// (layers.drawn: the layer's own sprite handles) on either engine. The // (layers.drawn: the layer's own sprite handles) on either engine. The
// layer owns no Cesium billboards any more: the contract's batched // layer owns no Cesium billboards any more: the contract's batched
+2 -2
View File
@@ -1,7 +1,7 @@
import { assertKeylessPreview } from './qa-strict-harness.mjs'; import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { installAlprFixture, cameras } from './fixtures/w2/alpr.mjs'; import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs';
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/w2/firms.mjs'; import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/layers/firms.mjs';
import { createStrict, localPreview } from './qa-strict-harness.mjs'; import { createStrict, localPreview } from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]), const url = localPreview(process.argv[2]),
fire = process.argv.includes('--fires'), fire = process.argv.includes('--fires'),
+2 -2
View File
@@ -9,7 +9,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import sharp from 'sharp'; import sharp from 'sharp';
import { watchW1Page, redact } from './qa-browserEvidence.mjs'; import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
const urlIndex = process.argv.indexOf('--url'); const urlIndex = process.argv.indexOf('--url');
if (urlIndex < 0) throw new Error('An explicit --url is required'); if (urlIndex < 0) throw new Error('An explicit --url is required');
@@ -87,7 +87,7 @@ async function open(mode = 'normal') {
await page.bringToFront(); await page.bringToFront();
const errors = []; const errors = [];
if (extended) if (extended)
watchW1Page(page, report, { watchBrowserPage(page, report, {
name: mode, name: mode,
baseUrl: appUrl, baseUrl: appUrl,
preview: process.argv.includes('--preview'), preview: process.argv.includes('--preview'),
-204
View File
@@ -1,204 +0,0 @@
#!/usr/bin/env node
/**
* Run the contract suite's engine.ground checks in headless Chrome on both
* real engines, with what qa-contract-suite.mjs does not give the suite: a
* readRegion of the engine canvas (the pixel check) besides the real
* pointer (the click pick). Serve the app keyless:
*
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271
* node scripts/qa-ground-suite.mjs http://localhost:4271 [--engine=cesium,navara] [--only=ground:]
*
* Exits 1 when a check fails on any engine.
*/
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-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix]',
);
process.exit(2);
}
const engines = (option('engine') ?? 'cesium,navara').split(',');
const only = (option('only') ?? 'ground:').split(',');
async function runOn(browser, kind) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(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=${kind}#v=2&lat=10&lon=20&alt=1000000&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,
);
// The app restores the view in the link with a flight that can start
// after the loading screen goes: wait until the camera is there and still,
// or that flight lands in the middle of a check.
await page.waitForFunction(
() => {
const { camera } = window.__godsEyeView.engine;
return (
!camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000
);
},
{ timeout: 120_000, polling: 250 },
);
// The globe drawn and its tiles loaded: a restored view hides the globe
// for a moment after the loading screen goes.
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const probe = document.createElement('canvas');
probe.width = 1;
probe.height = 1;
const context = probe.getContext('2d', { willReadFrequently: true });
const started = performance.now();
let settled = 0;
while (performance.now() - started < 120_000 && settled < 20) {
await new Promise((resolve) => {
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
engine.render.request('ground-suite-settle');
});
context.drawImage(
canvas,
canvas.width / 2,
canvas.height / 2,
1,
1,
0,
0,
1,
1,
);
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
settled =
r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0;
}
});
await page.evaluate(() =>
window.__godsEyeView.engine.host.canvas.setAttribute(
'data-contract-target',
'',
),
);
await page.addStyleTag({
content:
'* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }',
});
await page.exposeFunction('__contractPointer', async (type, x, y) => {
const box = await page.evaluate(() => {
const rect =
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
return { left: rect.left, top: rect.top };
});
const at = [box.left + x, box.top + y];
if (type === 'click') await page.mouse.click(...at);
else {
await page.mouse.move(at[0] - 1, at[1]);
await page.mouse.move(...at);
}
});
const results = await page.evaluate(async (only) => {
const { engine } = window.__godsEyeView;
const waitFrames = (count) =>
new Promise((resolve) => {
let left = count;
const remove = engine.frames.postRender.addEventListener(() => {
if (--left > 0) return;
remove();
resolve();
});
engine.render.request('ground-suite');
});
const pointer = {
click: (x, y) => window.__contractPointer('click', x, y),
move: (x, y) => window.__contractPointer('move', x, y),
};
// RGBA of the last drawn frame (both engines keep their drawing buffer).
const scratch = document.createElement('canvas');
const context = scratch.getContext('2d', { willReadFrequently: true });
const readRegion = (x, y, width, height) => {
const canvas = engine.host.canvas;
const scale = canvas.width / canvas.clientWidth;
scratch.width = width;
scratch.height = height;
context.clearRect(0, 0, width, height);
context.drawImage(
canvas,
x * scale,
y * scale,
width * scale,
height * scale,
0,
0,
width,
height,
);
return context.getImageData(0, 0, width, height).data;
};
const makeCanvas = (width, height) =>
Object.assign(document.createElement('canvas'), { width, height });
const { createContractSuite } =
await import('/src/globe/testing/contractSuite.mjs');
const outcome = [];
for (const { name, run } of createContractSuite(engine, {
waitFrames,
pointer,
readRegion,
makeCanvas,
})) {
if (!only.some((prefix) => name.startsWith(prefix))) continue;
try {
await run();
outcome.push({ name, ok: true });
} catch (error) {
outcome.push({
name,
ok: false,
error: String(error?.message ?? error),
});
}
}
return outcome;
}, only);
await page.close();
return { engine: kind, results, pageErrors };
}
const browser = await puppeteer.launch({ headless: true });
let failed = false;
try {
for (const kind of engines) {
const { results, pageErrors } = await runOn(browser, kind);
const passed = results.filter((result) => result.ok).length;
console.log(`${kind}: ${passed}/${results.length} checks pass`);
for (const result of results)
if (!result.ok) {
failed = true;
console.log(` FAIL ${result.name}: ${result.error}`);
} else console.log(` ok ${result.name}`);
for (const message of pageErrors) console.log(` page error: ${message}`);
}
} finally {
await browser.close();
}
process.exit(failed ? 1 : 0);
@@ -4,7 +4,7 @@
// pixels; both pinned dates survive a switch to Cesium and back. The report // pixels; both pinned dates survive a switch to Cesium and back. The report
// and its screenshots go next to the evidence file (or to the temp directory). // and its screenshots go next to the evidence file (or to the temp directory).
// //
// node scripts/qa-w2-imagery.mjs http://127.0.0.1:<PORT> // node scripts/qa-imagery-contract.mjs http://127.0.0.1:<PORT>
// [--layers=recent-imagery] [--evidence=FILE] // [--layers=recent-imagery] [--evidence=FILE]
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
+14 -4
View File
@@ -1086,15 +1086,25 @@ check({
heavy: true, run: harness({ id: 'D1', script: 'track-regression.mjs', args: ['--url', APP_URL], timeoutMs: 1200000 }), heavy: true, run: harness({ id: 'D1', script: 'track-regression.mjs', args: ['--url', APP_URL], timeoutMs: 1200000 }),
}); });
check({ check({
id: 'D2', group: 'D', desc: 'qa-heading-b3 — path-derived display heading', id: 'D2',
heavy: true, run: harness({ id: 'D2', script: 'qa-heading-b3.mjs', args: ['--url', APP_URL], timeoutMs: 900000 }), group: 'D',
desc: 'qa-aircraft-heading — path-derived display heading',
heavy: true,
run: harness({
id: 'D2',
script: 'qa-aircraft-heading.mjs',
args: ['--url', APP_URL],
timeoutMs: 900000,
}),
}); });
check({ check({
id: 'D3', group: 'D', desc: 'qa-sprites-b5 — per-class billboard silhouettes', id: 'D3',
group: 'D',
desc: 'qa-aircraft-sprites — per-class billboard silhouettes',
heavy: true, heavy: true,
run: harness({ run: harness({
id: 'D3', id: 'D3',
script: 'qa-sprites-b5.mjs', script: 'qa-aircraft-sprites.mjs',
args: ['--url', APP_URL], args: ['--url', APP_URL],
timeoutMs: 900000, timeoutMs: 900000,
knownConditions: [{ knownConditions: [{
+1 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { stations, installRadioFixture } from './fixtures/w2/radio.mjs'; import { stations, installRadioFixture } from './fixtures/layers/radio.mjs';
import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs'; import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]); const url = localPreview(process.argv[2]);
// The long soak and switch timings live in qa-strict-soak.mjs. // The long soak and switch timings live in qa-strict-soak.mjs.
+1 -1
View File
@@ -15,7 +15,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { stations } from './fixtures/w2/radio.mjs'; import { stations } from './fixtures/layers/radio.mjs';
import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs'; import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
+1 -1
View File
@@ -44,7 +44,7 @@ import {
slope, slope,
startSession, startSession,
} from './qa-strict-session.mjs'; } from './qa-strict-session.mjs';
import { installRadioFixture, stations } from './fixtures/w2/radio.mjs'; import { installRadioFixture, stations } from './fixtures/layers/radio.mjs';
import { trafficFixtureResponse } from './traffic-fixtures.mjs'; import { trafficFixtureResponse } from './traffic-fixtures.mjs';
import { POSITION_HISTORY_LIMIT } from '../src/layers/flights/recordPolicy.js'; import { POSITION_HISTORY_LIMIT } from '../src/layers/flights/recordPolicy.js';
import { heapSizesByType } from './qa/heapSnapshotSizes.mjs'; import { heapSizesByType } from './qa/heapSnapshotSizes.mjs';
+2 -2
View File
@@ -1,11 +1,11 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js'; import { fromDegrees } from '../src/geo/cartesian3.js';
import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs'; import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs';
import { installTransitFixture } from './fixtures/w2/transit.mjs'; import { installTransitFixture } from './fixtures/layers/transit.mjs';
import { import {
combinedPayloads, combinedPayloads,
installCombinedFixture, installCombinedFixture,
} from './fixtures/w2/combined.mjs'; } from './fixtures/layers/combined.mjs';
const url = localPreview(process.argv[2]), const url = localPreview(process.argv[2]),
bus = process.argv.includes('--transit'), bus = process.argv.includes('--transit'),
id = bus ? 'transit' : 'bikeshare'; id = bus ? 'transit' : 'bikeshare';
+1 -17
View File
@@ -1,4 +1,3 @@
import { bindCctvWorldClickGesture } from '../../layers/cctv/worldClick.js';
import { createCctvLayer } from '../../layers/cctv/index.js'; import { createCctvLayer } from '../../layers/cctv/index.js';
import { rendererFor } from './renderers.js'; import { rendererFor } from './renderers.js';
import * as credits from '../../data/dataCredits.js'; import * as credits from '../../data/dataCredits.js';
@@ -37,20 +36,5 @@ export function createApplicationCctv({
render, render,
}, },
}); });
return { return layer;
...layer,
bindCctvWorldClickGesture,
// Legacy helper; production navigation uses the initialized owner.
focusCctvRecord(engine, record, duration) {
const display = engine
? rendererFor('cctv', engine, { services: {}, state: {} })
: null;
display?.attach();
try {
return layer.focusCctvRecord(display, record, duration);
} finally {
display?.destroy();
}
},
};
} }
-2
View File
@@ -52,7 +52,6 @@ export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
export const clearProbeClampOnDeactivation = export const clearProbeClampOnDeactivation =
layer.clearProbeClampOnDeactivation; layer.clearProbeClampOnDeactivation;
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation; export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
export const bindCctvWorldClickGesture = layer.bindCctvWorldClickGesture;
export const setActiveCamera = layer.setActiveCamera; export const setActiveCamera = layer.setActiveCamera;
export const deactivateActiveCamera = layer.deactivateActiveCamera; export const deactivateActiveCamera = layer.deactivateActiveCamera;
export const cctvEmptyClickDeselects = layer.cctvEmptyClickDeselects; export const cctvEmptyClickDeselects = layer.cctvEmptyClickDeselects;
@@ -65,7 +64,6 @@ export const materializeCctvVisibleCoverageEntities =
export const _extractPickedCameraIdForTest = export const _extractPickedCameraIdForTest =
layer._extractPickedCameraIdForTest; layer._extractPickedCameraIdForTest;
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest; export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop; export const maybeAutoHop = layer.maybeAutoHop;
export const cctvCycleIndex = layer.cctvCycleIndex; export const cctvCycleIndex = layer.cctvCycleIndex;
export { export {
+30 -31
View File
@@ -39,7 +39,6 @@ import cctvLayer, {
_pushAmbientCardEntriesForTest, _pushAmbientCardEntriesForTest,
_setCctvOverlayHostForTest, _setCctvOverlayHostForTest,
activationProbeClampRange, activationProbeClampRange,
bindCctvWorldClickGesture,
clearProbeClampOnDeactivation, clearProbeClampOnDeactivation,
computeFrustumGeometry, computeFrustumGeometry,
cctvCycleIndex, cctvCycleIndex,
@@ -60,7 +59,6 @@ import cctvLayer, {
createGeometryProgressNotifier, createGeometryProgressNotifier,
normalizeCoverageMode, normalizeCoverageMode,
frameSignatureFromPixels, frameSignatureFromPixels,
focusCctvRecord,
hideCctvRecordVisuals, hideCctvRecordVisuals,
materializeCctvActiveCoverageEntities, materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities, materializeCctvVisibleCoverageEntities,
@@ -85,30 +83,28 @@ import {
} from '../layers/cctv/policy.js'; } from '../layers/cctv/policy.js';
import { loadLayerRenderers } from '../app/layers/renderers.js'; import { loadLayerRenderers } from '../app/layers/renderers.js';
import { createNavigation } from '../layers/cctv/navigation.js';
import { createCctvDisplay } from '../layers/cctv/render/display.js';
function focusCctvRecord(engine, record, duration) {
const display = engine
? createCctvDisplay(engine, { services: {}, state: {} })
: null;
display?.attach();
try {
return createNavigation({}).focusCctvRecord(display, record, duration);
} finally {
display?.destroy();
}
}
// The layers draw with the manifest's displays, which load through // The layers draw with the manifest's displays, which load through
// import() (src/app/layers/renderers.js). // import() (src/app/layers/renderers.js).
await loadLayerRenderers('cesium'); await loadLayerRenderers('cesium');
const ASPECT = 16 / 9; const ASPECT = 16 / 9;
const toRad = (deg) => (deg * Math.PI) / 180; const toRad = (deg) => (deg * Math.PI) / 180;
const GESTURE_TYPES = {
LEFT_DOWN: 'left-down',
MOUSE_MOVE: 'mouse-move',
LEFT_UP: 'left-up',
LEFT_CLICK: 'left-click',
};
function makeGestureHandler() {
const actions = new Map();
return {
setInputAction(callback, type) {
actions.set(type, callback);
},
fire(type, event) {
actions.get(type)?.(event);
},
};
}
// Same spherical earth radius projectPoint uses (R = 6371 km) so the // Same spherical earth radius projectPoint uses (R = 6371 km) so the
// lat/lon→metres conversion in the assertions matches the module's math. // lat/lon→metres conversion in the assertions matches the module's math.
const M_PER_DEG = (Math.PI / 180) * 6371000; const M_PER_DEG = (Math.PI / 180) * 6371000;
@@ -1383,14 +1379,16 @@ test('active CCTV camera is absent from host by default and protected only when
test('CCTV drag-then-release over a camera is inert, while a clean tap activates and dispatches', () => { test('CCTV drag-then-release over a camera is inert, while a clean tap activates and dispatches', () => {
let timeMs = 0; let timeMs = 0;
let activationCalls = 0; let activationCalls = 0;
const handler = makeGestureHandler(); const engine = createFakeEngine();
const display = createCctvDisplay(engine, { state: {} });
display.attach();
const handler = { fire: (type, event) => engine.fake.input(type, event) };
const target = new EventTarget(); const target = new EventTarget();
const requests = []; const requests = [];
target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) => target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) =>
requests.push(event.detail), requests.push(event.detail),
); );
bindCctvWorldClickGesture( display.bindClick(
handler,
() => { () => {
activateCctvCameraFromWorldClick( activateCctvCameraFromWorldClick(
'atx-cam-3', 'atx-cam-3',
@@ -1403,28 +1401,29 @@ test('CCTV drag-then-release over a camera is inert, while a clean tap activates
}, },
{ {
now: () => timeMs, now: () => timeMs,
eventTypes: GESTURE_TYPES,
}, },
); );
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } }); handler.fire('down', { position: { x: 10, y: 10 } });
timeMs = 20; timeMs = 20;
handler.fire(GESTURE_TYPES.MOUSE_MOVE, { endPosition: { x: 14, y: 10 } }); handler.fire('move', { endPosition: { x: 14, y: 10 } });
timeMs = 40; timeMs = 40;
handler.fire(GESTURE_TYPES.MOUSE_MOVE, { endPosition: { x: 10, y: 10 } }); handler.fire('move', { endPosition: { x: 10, y: 10 } });
timeMs = 60; timeMs = 60;
handler.fire(GESTURE_TYPES.LEFT_UP, { position: { x: 10, y: 10 } }); handler.fire('up', { position: { x: 10, y: 10 } });
handler.fire(GESTURE_TYPES.LEFT_CLICK, { position: { x: 10, y: 10 } }); handler.fire('click', { position: { x: 10, y: 10 } });
assert.equal(activationCalls, 0); assert.equal(activationCalls, 0);
assert.deepEqual(requests, []); assert.deepEqual(requests, []);
timeMs = 100; timeMs = 100;
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } }); handler.fire('down', { position: { x: 10, y: 10 } });
timeMs = 180; timeMs = 180;
handler.fire(GESTURE_TYPES.LEFT_UP, { position: { x: 11, y: 11 } }); handler.fire('up', { position: { x: 11, y: 11 } });
handler.fire(GESTURE_TYPES.LEFT_CLICK, { position: { x: 11, y: 11 } }); handler.fire('click', { position: { x: 11, y: 11 } });
assert.equal(activationCalls, 1); assert.equal(activationCalls, 1);
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]); assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
display.destroy();
engine.destroy();
}); });
test('CCTV auto-hop remains activation-only and never dispatches a focus request', () => { test('CCTV auto-hop remains activation-only and never dispatches a focus request', () => {
+3 -8
View File
@@ -114,22 +114,17 @@ test('every scene click handler consults ownership before it picks', () => {
// find and edit each of these. If a layer grows a click handler it has to // find and edit each of these. If a layer grows a click handler it has to
// appear here too. // appear here too.
const guarded = [ const guarded = [
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'], ['src/data/trackingGesture.js', 'onClick(click, gesture);'],
[ [
'src/layers/infrastructure/render/display.js', 'src/layers/infrastructure/render/display.js',
'const picked = event.picked;', 'const picked = event.picked;',
], ],
['src/data/cctvGizmo.js', 'pickGizmoPart(event)'], ['src/data/cctvGizmo.js', 'pickGizmoPart(event)'],
// ALPR and bikeshare take the engine's click with its pick. // ALPR and bikeshare take the engine's click with its pick.
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'], ['src/layers/shared/selectionKeeper.js', 'const id = pickId(event);'],
['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'], ['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'],
// FIRMS takes the engine dispatcher's click with its pick. // FIRMS takes the engine dispatcher's click with its pick.
['src/layers/firms/selection.js', 'const picked = click.picked;'], ['src/layers/firms/selection.js', 'const picked = click.picked;'],
// Mapped Installations take the engine's click with its pick.
[
'src/layers/installations/render/display.js',
"const id = typeof picked?.id === 'string' ? picked.id : null;",
],
['src/layers/launches/render/display.js', '[ctx.picked, ...ctx.drilled]'], ['src/layers/launches/render/display.js', '[ctx.picked, ...ctx.drilled]'],
[ [
'src/layers/radio/interaction.js', 'src/layers/radio/interaction.js',
@@ -173,7 +168,7 @@ test('ambient selection handlers never claim the pointer themselves', () => {
// Claiming from a selection handler would deadlock every other layer the // Claiming from a selection handler would deadlock every other layer the
// moment a user clicked anything. Only tools claim. // moment a user clicked anything. Only tools claim.
for (const file of [ for (const file of [
'src/data/trackingClickGesture.js', 'src/data/trackingGesture.js',
'src/layers/infrastructure/render/display.js', 'src/layers/infrastructure/render/display.js',
'src/data/cctvGizmo.js', 'src/data/cctvGizmo.js',
'src/layers/vessels/selection.js', 'src/layers/vessels/selection.js',
+14 -61
View File
@@ -1,34 +1,17 @@
import { sceneServicesFor } from './sceneServices.js'; import { sceneServicesFor } from './sceneServices.js';
// One selection dispatcher for the layers that draw through the globe // Shared selection for registered owners, with one pick subscription and
// engine: one click subscription with picks // press accumulator. Normal owners select their first pick, clear on a short
// (engine.input.on('click', …, { pick: true, drill: 8 })), the press // empty click, and retain selection on another owner's pick. Pass-through
// gesture and one Escape listener, instead of a ScreenSpaceEventHandler // picks yield to the next hit; drill-through owners may select below an
// per layer. Owners register as with pickRegistry (registerPickOwner, so // unregistered owner's hit. Pointer claims suppress selection.
// layers still on their own handlers keep seeing the ownership) plus // Look-under owners select below unowned picks or their declared pass-through
// handlers: // ids, clear on other owners, and ignore clicks containing only those ids.
// onSelect(id, ctx) a click whose first pick it owns; // Own-rule owners receive every pointer-free click and apply their own rules.
// onEmptyClick(ctx) a clean click (short, no drag) on the map or nothing;
// onEscape() Escape.
// The rules are the old handlers': a pick another layer owns is not empty
// space (a pick nobody owns is); `passThrough` picks (trails) never take the
// click, the next pick does; a `drillThrough` owner takes its object from
// under another layer's; a drag selects nothing; a pointer owned by a tool
// (inputOwnership) selects nothing. Layers not moved yet keep their own
// handlers: the dispatcher never acts on an id it does not own.
// A `lookUnder` owner (local ADS-B) keeps its own old click rule instead:
// its object on top, or under picks no layer owns and picks it looks under
// (`lookUnder(id)`: trails), is selected; another layer's object on top, or
// nothing, is empty space for it (it releases its selection); a click that
// only meets what it looks under does nothing; no gesture rule (its old
// handler took every click). To the others it is another layer.
// An own-rule owner (`onClick(ctx)`: vessels) keeps its whole old click
// handler: it gets every click with the top pick and decides itself (no
// gesture rule, as its old LEFT_CLICK handler); to the others it is another
// layer.
import { isPointerFree as pointerFree } from './inputOwnership.js'; import { isPointerFree as pointerFree } from './inputOwnership.js';
import { pickRegistryFor, resolvePickId } from './pickRegistry.js'; import { pickRegistryFor, resolvePickId } from './pickRegistry.js';
import { import {
createPressGesture,
isTrackingClickGesture, isTrackingClickGesture,
isTrackingSelectionGesture, isTrackingSelectionGesture,
} from './trackingGesture.js'; } from './trackingGesture.js';
@@ -58,27 +41,7 @@ export function createSelectionDispatcher(
let removers = []; let removers = [];
let destroyed = false; let destroyed = false;
// The press gesture, as bindTrackingClickGesture measures it. const press = createPressGesture(now);
let press = null;
let completed = null;
const travel = (position) => {
if (!press || !position) return;
if (press.last)
press.travelPx += Math.hypot(
position.x - press.last.x,
position.y - press.last.y,
);
press.last = { x: position.x, y: position.y };
};
const finish = (position) => {
if (!press) return;
travel(position);
completed = {
travelPx: press.travelPx,
durationMs: Math.max(0, now() - press.startedAt),
};
press = null;
};
const ownerOf = (picked) => { const ownerOf = (picked) => {
const id = resolvePickId(picked); const id = resolvePickId(picked);
@@ -116,9 +79,7 @@ export function createSelectionDispatcher(
typeof owner.lookUnder !== 'function' && !ownRule(owner); typeof owner.lookUnder !== 'function' && !ownRule(owner);
const onClick = (event) => { const onClick = (event) => {
if (press) finish(event.position); const gesture = press.click(event.position);
const gesture = completed ?? { travelPx: 0, durationMs: 0 };
completed = null;
if (!isPointerFree()) return; if (!isPointerFree()) return;
const picked = nearbyContact( const picked = nearbyContact(
event.picked, event.picked,
@@ -204,17 +165,9 @@ export function createSelectionDispatcher(
const attach = () => { const attach = () => {
if (removers.length || destroyed) return; if (removers.length || destroyed) return;
removers = [ removers = [
engine.input.on('down', (event) => { engine.input.on('down', (event) => press.down(event.position)),
press = { engine.input.on('move', (event) => press.move(event.endPosition)),
startedAt: now(), engine.input.on('up', (event) => press.up(event.position)),
travelPx: 0,
last: null,
};
completed = null;
travel(event.position);
}),
engine.input.on('move', (event) => travel(event.endPosition)),
engine.input.on('up', (event) => finish(event.position)),
engine.input.on('click', onClick, { pick: true, drill: SELECTION_DRILL }), engine.input.on('click', onClick, { pick: true, drill: SELECTION_DRILL }),
]; ];
keyTarget?.addEventListener?.('keydown', onKey); keyTarget?.addEventListener?.('keydown', onKey);
-85
View File
@@ -1,85 +0,0 @@
import { trackingEventTypes } from '../globe/cesium/screenEvents.js';
import { isPointerFree } from './inputOwnership.js';
export {
MAX_TRACKING_CLICK_TRAVEL_PX,
MAX_TRACKING_CLICK_DURATION_MS,
isTrackingSelectionGesture,
isTrackingClickGesture,
} from './trackingGesture.js';
/**
* Bind LEFT_DOWN/MOUSE_MOVE/LEFT_UP accounting ahead of a scene click.
* Travel is accumulated segment-by-segment, so an orbit nudge that returns to
* its starting pixel cannot masquerade as a zero-distance click. Every scene
* click reaches `onClick` with its gesture metadata; the caller decides whether
* selection (travel-only) or deselection (travel + duration) is allowed.
* @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler.
* @param {(click: Object, gesture: {travelPx: number, durationMs: number}) => void} onClick - Scene-click callback.
* @param {{now?: () => number, eventTypes?: Object, onMouseMove?: (event: Object) => void}} [options] - Test/interop seams.
* @returns {void}
*/
export function bindTrackingClickGesture(handler, onClick, options = {}) {
const now = options.now || (() => performance.now());
const eventTypes = options.eventTypes || trackingEventTypes();
let pressActive = false;
let pressStartedAt = 0;
let previousPosition = null;
let travelPx = 0;
let completedGesture = null;
const appendTravel = (position) => {
if (
!pressActive ||
!Number.isFinite(position?.x) ||
!Number.isFinite(position?.y)
)
return;
if (previousPosition) {
travelPx += Math.hypot(
position.x - previousPosition.x,
position.y - previousPosition.y,
);
}
previousPosition = { x: position.x, y: position.y };
};
const finishPress = (position) => {
if (!pressActive) return;
appendTravel(position);
completedGesture = {
travelPx,
durationMs: Math.max(0, now() - pressStartedAt),
};
pressActive = false;
previousPosition = null;
};
handler.setInputAction((event) => {
pressActive = true;
pressStartedAt = now();
previousPosition = null;
travelPx = 0;
completedGesture = null;
appendTravel(event?.position);
}, eventTypes.LEFT_DOWN);
handler.setInputAction((event) => {
appendTravel(event?.endPosition ?? event?.position);
options.onMouseMove?.(event);
}, eventTypes.MOUSE_MOVE);
handler.setInputAction((event) => {
finishPress(event?.position);
}, eventTypes.LEFT_UP);
handler.setInputAction((click) => {
if (pressActive) finishPress(click?.position);
const gesture = completedGesture || { travelPx: 0, durationMs: 0 };
completedGesture = null;
// A tool owns the pointer: a draw vertex placed over an aircraft is a
// vertex, not a track request (src/data/inputOwnership.js).
if (!isPointerFree()) return;
onClick(click, gesture);
}, eventTypes.LEFT_CLICK);
}
+6 -8
View File
@@ -6,21 +6,21 @@ import {
bindTrackingClickGesture, bindTrackingClickGesture,
isTrackingClickGesture, isTrackingClickGesture,
isTrackingSelectionGesture, isTrackingSelectionGesture,
} from './trackingClickGesture.js'; } from './trackingGesture.js';
import { TRACKED_MODEL_MAX_PX as CIVIL_TRACKED_MODEL_MAX_PX } from './flights.js'; import { TRACKED_MODEL_MAX_PX as CIVIL_TRACKED_MODEL_MAX_PX } from './flights.js';
import { TRACKED_MODEL_MAX_PX as MILITARY_TRACKED_MODEL_MAX_PX } from './militaryFlights.js'; import { TRACKED_MODEL_MAX_PX as MILITARY_TRACKED_MODEL_MAX_PX } from './militaryFlights.js';
const TYPES = { const TYPES = {
LEFT_DOWN: 'left-down', LEFT_DOWN: 'down',
MOUSE_MOVE: 'mouse-move', MOUSE_MOVE: 'move',
LEFT_UP: 'left-up', LEFT_UP: 'up',
LEFT_CLICK: 'left-click', LEFT_CLICK: 'click',
}; };
function makeHandler() { function makeHandler() {
const actions = new Map(); const actions = new Map();
return { return {
setInputAction(callback, type) { actions.set(type, callback); }, on(type, callback) { actions.set(type, callback); return () => actions.delete(type); },
fire(type, event) { actions.get(type)?.(event); }, fire(type, event) { actions.get(type)?.(event); },
}; };
} }
@@ -49,7 +49,6 @@ test('synthetic drag-then-click sequence does not reach the untrack callback', (
if (isTrackingClickGesture(gesture)) untrackCalls += 1; if (isTrackingClickGesture(gesture)) untrackCalls += 1;
}, { }, {
now: () => timeMs, now: () => timeMs,
eventTypes: TYPES,
}); });
handler.fire(TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } }); handler.fire(TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
@@ -81,7 +80,6 @@ test('slow clean sprite clicks select, while long presses and orbit nudges canno
if (isTrackingClickGesture(gesture)) untracks += 1; if (isTrackingClickGesture(gesture)) untracks += 1;
}, { }, {
now: () => timeMs, now: () => timeMs,
eventTypes: TYPES,
}); });
handler.fire(TYPES.LEFT_DOWN, { position: { x: 0, y: 0 } }); handler.fire(TYPES.LEFT_DOWN, { position: { x: 0, y: 0 } });
+69
View File
@@ -1,3 +1,5 @@
import { isPointerFree } from './inputOwnership.js';
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6; export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
export const MAX_TRACKING_CLICK_DURATION_MS = 400; export const MAX_TRACKING_CLICK_DURATION_MS = 400;
@@ -30,3 +32,70 @@ export function isTrackingClickGesture(gesture = {}) {
durationMs <= MAX_TRACKING_CLICK_DURATION_MS durationMs <= MAX_TRACKING_CLICK_DURATION_MS
); );
} }
/** Accumulate press travel and duration, consuming the result on click. */
export function createPressGesture(now = () => performance.now()) {
let press = null;
let completed = null;
const travel = (position) => {
if (
!press ||
!Number.isFinite(position?.x) ||
!Number.isFinite(position?.y)
)
return;
if (press.last)
press.travelPx += Math.hypot(
position.x - press.last.x,
position.y - press.last.y,
);
press.last = { x: position.x, y: position.y };
};
const finish = (position) => {
if (!press) return;
travel(position);
completed = {
travelPx: press.travelPx,
durationMs: Math.max(0, now() - press.startedAt),
};
press = null;
};
return {
down(position) {
press = { startedAt: now(), travelPx: 0, last: null };
completed = null;
travel(position);
},
move: travel,
up: finish,
click(position) {
finish(position);
const gesture = completed ?? { travelPx: 0, durationMs: 0 };
completed = null;
return gesture;
},
};
}
/** Bind engine input with press metadata; the caller chooses its click rule. */
export function bindTrackingClickGesture(
input,
onClick,
{ now, onMouseMove } = {},
) {
const press = createPressGesture(now);
const removes = [
input.on('down', (event) => press.down(event?.position)),
input.on('move', (event) => {
press.move(event?.endPosition ?? event?.position);
onMouseMove?.(event);
}),
input.on('up', (event) => press.up(event?.position)),
input.on('click', (click) => {
const gesture = press.click(click?.position);
if (!isPointerFree()) return;
onClick(click, gesture);
}),
];
return () => removes.forEach((remove) => remove());
}
@@ -171,81 +171,6 @@ function collectionsUnder(viewer) {
); );
} }
test('movers and lines draw on the scene, answer picks with their pickId and go with the engine', async () => {
const geo = await import('../../geo/index.js');
const viewer = createFakeViewer();
const reasons = [];
const engine = createCesiumEngine(viewer, {
requestRender: (reason) => reasons.push(reason),
});
const set = engine.movers.createSet({
id: 'horizon-points',
pickable: true,
kind: 'point',
capacity: 2,
point: { sizePx: 8 },
});
// The fake camera is at x = 7e6 (over 0°, 0°): 0°E is in front, 180° behind.
set.set(0, { position: geo.cartesian3.fromDegrees(0, 1, 0), pickId: 'a' });
set.set(1, { position: geo.cartesian3.fromDegrees(180, 1, 0), pickId: 'b' });
set.setCount(2);
set.commit();
const line = engine.lines.createLine({
id: 'orbit',
widthPx: 2,
color: [1, 1, 1, 1],
pickId: 'orbit-1',
});
assert.deepEqual(engine.movers.list(), [
{ id: 'horizon-points', kind: 'point', count: 2, capacity: 2 },
]);
assert.deepEqual(engine.lines.list(), [
{ id: 'orbit', points: 0, shown: true },
]);
assert.ok(reasons.includes('movers'));
assert.equal(viewer.primitiveList.length, 2, 'one root each');
const [points] = collectionsUnder(viewer).filter(
(item) => item instanceof Cesium.PointPrimitiveCollection,
);
const front = points.get(0);
const back = points.get(1);
const lineSlot = { gevLine: 'orbit' };
viewer.scene.drillPick = () => [
{ primitive: back, id: 'b' },
{ primitive: front, id: 'stale' },
{ primitive: lineSlot, id: 'orbit-1' },
];
assert.deepEqual(
engine.picker
.drillPick({ x: 1, y: 1 })
.map(({ id, owner, instance }) => [id, owner, instance]),
[
['a', 'horizon-points', 0],
['orbit-1', 'orbit', undefined],
],
'the instance behind the horizon is left out',
);
viewer.scene.pick = () => ({ primitive: front, id: 'stale' });
assert.equal(engine.picker.pick({ x: 1, y: 1 }).id, 'a');
// A mover set in a surface exclusion is hidden for the sample only.
let shownDuring;
viewer.scene.sampleHeight = (position, excluded) => {
shownDuring = [points.show, excluded];
return 5;
};
const place = geo.cartographic.fromDegrees(0, 1, 0);
assert.equal(engine.surface.sampleHeight(place, [set]), 5);
assert.deepEqual(shownDuring, [false, []]);
assert.equal(points.show, true);
engine.destroy();
assert.equal(points.isDestroyed(), true);
assert.deepEqual(viewer.primitiveList, []);
assert.deepEqual(engine.movers.list(), []);
assert.deepEqual(engine.lines.list(), []);
assert.equal(set.isDestroyed(), true);
assert.throws(() => line.show(false), /destroyed/);
});
test('the contract suite movers and lines checks pass on the Cesium engine (no pixels or pointer)', async (t) => { test('the contract suite movers and lines checks pass on the Cesium engine (no pixels or pointer)', async (t) => {
// Cesium's Material sorts uniform values with instanceof checks against // Cesium's Material sorts uniform values with instanceof checks against
// these DOM classes; Node has none of them. // these DOM classes; Node has none of them.
+31
View File
@@ -237,6 +237,14 @@ test('fills batch into GroundPrimitive instances, outlines into ground polylines
items('disc', [{ center: [0, 0], radiusM: 500, fill: [1, 1, 1, 1] }]), items('disc', [{ center: [0, 0], radiusM: 500, fill: [1, 1, 1, 1] }]),
); );
assert.ok(shapes.fills[0].geometry instanceof Cesium.EllipseGeometry); assert.ok(shapes.fills[0].geometry instanceof Cesium.EllipseGeometry);
assert.equal(shapes.fills[0].geometry._semiMajorAxis, 500);
const cells = groundInstances(
{ id: 'cells', kind: 'cell' },
items('cell', [
{ west: 0, south: 0, east: 1, north: 1, fill: [1, 0, 0, 1] },
]),
);
assert.ok(cells.fills[0].geometry instanceof Cesium.RectangleGeometry);
}); });
test('sets join scene.groundPrimitives in order and rebuild on commit', () => { test('sets join scene.groundPrimitives in order and rebuild on commit', () => {
@@ -441,3 +449,26 @@ test('corridor reveal mutates the live material and retains it through a geometr
assert.equal(built[1].appearance.material.uniforms.reveal, 0.5); assert.equal(built[1].appearance.material.uniforms.reveal, 0.5);
port.destroy(); port.destroy();
}); });
test('non-pickable ground fills skip pick commands and retain normal drawing', (t) => {
const viewer = createFakeViewer();
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
const port = createCesiumGround(viewer);
const set = port.ground.createSet({
id: 'cells',
kind: 'cell',
pickable: false,
});
set.replace([{ west: 0, south: 0, east: 1, north: 1, fill: [1, 0, 0, 1] }]);
set.commit();
const fill = viewer.scene.groundPrimitives.get(0).get(0).get(0);
assert.ok(fill instanceof Cesium.GroundPrimitive);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
port.destroy();
assert.equal(fill.isDestroyed(), true);
});
+3
View File
@@ -16,6 +16,9 @@ import * as geo from '../../../geo/index.js';
const { cartesian3, ellipsoid } = geo; const { cartesian3, ellipsoid } = geo;
const DEG = Math.PI / 180; const DEG = Math.PI / 180;
/** Reveal fraction within a packed corridor item. */
export const REVEAL_OFFSET = 20;
/** Pieces a tile holds before it splits. */ /** Pieces a tile holds before it splits. */
export const TILE_PIECES = 20000; export const TILE_PIECES = 20000;
/** A piece's longest arc, degrees. */ /** A piece's longest arc, degrees. */
-61
View File
@@ -1,61 +0,0 @@
import { Effect, EffectAttribute } from 'postprocessing';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
/**
* A screen-space pass on a Navara view, registered once per view under `key`.
* `insertAfter` and `insertBefore` place it in the view's effect chain.
* `uniforms()` lists the pass's [name, Uniform] entries; `update` and
* `destroy` run as the descriptor's updateFrame and onDestroy, with the
* descriptor as `this` (its `effect`, `owner`, `view`, `ctx` and `find`).
* The owner is the `owner` of the config added with `view.addEffect`.
*/
export function defineGroundEffect({
key,
name,
fragmentShader,
uniforms,
insertAfter,
insertBefore,
update,
destroy,
}) {
const registered = new WeakSet();
const descriptor = (EffectDesc, NavaraEffect) =>
class GroundEffectDesc extends withPassDisposal(
withFrameUpdates(EffectDesc),
) {
static allowDuplication = true;
static key = key;
static insertAfter = insertAfter;
static insertBefore = insertBefore;
constructor(view, context, config) {
super(view, context, config);
this.owner = config[key].owner;
}
createPass() {
this.effect = new Effect(name, fragmentShader, {
// Keep a separate pass: inputColor is the already tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION,
uniforms: uniforms && new Map(uniforms()),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
updateFrame() {
update.call(this);
}
onDestroy() {
destroy?.call(this);
this.effect?.dispose();
super.onDestroy();
}
};
return {
key,
/** Register the pass on `view` the first time it is used there. */
register(view, { EffectDesc, NavaraEffect }) {
if (registered.has(view)) return;
view.registerEffect(key, descriptor(EffectDesc, NavaraEffect));
registered.add(view);
},
};
}
+11 -3
View File
@@ -12,7 +12,7 @@
import { createGroundRegistry } from '../../ground.js'; import { createGroundRegistry } from '../../ground.js';
import { composeGround, packHeights, packSet } from './pack.js'; import { composeGround, packHeights, packSet } from './pack.js';
import { ITEM_TEXELS } from './corridors.js'; import { REVEAL_OFFSET, ITEM_TEXELS } from './corridors.js';
// Frames before the first new try at a set's missing terrain heights; each // Frames before the first new try at a set's missing terrain heights; each
// later try waits twice as long, up to RETRIES tries. // later try waits twice as long, up to RETRIES tries.
@@ -61,7 +61,11 @@ export function createNavaraGround(
}; };
const pack = (entry) => { const pack = (entry) => {
entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight }); entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight });
for (let at = 20; at < entry.pack.items.length; at += ITEM_TEXELS * 4) for (
let at = REVEAL_OFFSET;
at < entry.pack.items.length;
at += ITEM_TEXELS * 4
)
entry.pack.items[at] = entry.fraction; entry.pack.items[at] = entry.fraction;
entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0; entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0;
}; };
@@ -122,7 +126,11 @@ export function createNavaraGround(
if (entry.fraction === fraction) return; if (entry.fraction === fraction) return;
entry.fraction = fraction; entry.fraction = fraction;
if (entry.pack) { if (entry.pack) {
for (let at = 20; at < entry.pack.items.length; at += ITEM_TEXELS * 4) for (
let at = REVEAL_OFFSET;
at < entry.pack.items.length;
at += ITEM_TEXELS * 4
)
entry.pack.items[at] = fraction; entry.pack.items[at] = fraction;
if (entry.offset !== null) if (entry.offset !== null)
pass.reveal( pass.reveal(
+11 -21
View File
@@ -13,6 +13,7 @@
// materials, from the time uniform (src/globe/ground.js groundSeconds). // materials, from the time uniform (src/globe/ground.js groundSeconds).
// No effect remains in the chain while no item is drawn. // No effect remains in the chain while no item is drawn.
import { DISPLAY_COLOR_GLSL, VIEW_POSITION_GLSL } from '../shaderChunks.js';
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three'; import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
import { Effect, EffectAttribute } from 'postprocessing'; import { Effect, EffectAttribute } from 'postprocessing';
import { import {
@@ -20,13 +21,13 @@ import {
FloatType, FloatType,
Matrix4, Matrix4,
NearestFilter, NearestFilter,
RGBADepthPacking,
RGBAFormat, RGBAFormat,
Uniform, Uniform,
} from 'three'; } from 'three';
import { DASH_EDGE, groundSeconds } from '../../ground.js'; import { DASH_EDGE, groundSeconds } from '../../ground.js';
import { groundSurfaceUniforms, updateGroundSurface } from './surface.js';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js'; import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
import { ITEM_TEXELS, LOOK, MARGIN_RATIO } from './corridors.js'; import { REVEAL_OFFSET, ITEM_TEXELS, LOOK, MARGIN_RATIO } from './corridors.js';
import { import {
GRID_COLS, GRID_COLS,
GRID_ROWS, GRID_ROWS,
@@ -60,12 +61,8 @@ uniform float uPixelRatio;
vec4 atSlot(highp sampler2D image, int slot) { vec4 atSlot(highp sampler2D image, int slot) {
return texelFetch(image, ivec2(slot & ${ROW - 1}, slot >> ${Math.log2(ROW)}), 0); return texelFetch(image, ivec2(slot & ${ROW - 1}, slot >> ${Math.log2(ROW)}), 0);
} }
vec3 toDisplay(vec3 c) { ${DISPLAY_COLOR_GLSL}
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c)); ${VIEW_POSITION_GLSL}
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
// A corridor item over the colour, nearest metres from its line and along // A corridor item over the colour, nearest metres from its line and along
// metres from its start: its width, glow, pulse and dash, in LDR and // metres from its start: its width, glow, pulse and dash, in LDR and
// premultiplied, as Cesium writes ground polylines. // premultiplied, as Cesium writes ground polylines.
@@ -122,9 +119,9 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
outputColor = inputColor; outputColor = inputColor;
float depth = unpackRGBAToDepth(texture2D(tAllDepth, uv)); float depth = unpackRGBAToDepth(texture2D(tAllDepth, uv));
float distance = exp2(2.0 * depth / uLogDepthFC) - 1.0; float distance = viewDistance(depth, uLogDepthFC);
vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0); vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0);
vec3 point = ray.xyz * (distance / -ray.z); vec3 point = viewPosition(ray, distance);
// Derivatives before any divergent branch: neighbours test other items. // Derivatives before any divergent branch: neighbours test other items.
vec3 pointDx = dFdx(point); vec3 pointDx = dFdx(point);
vec3 pointDy = dFdy(point); vec3 pointDy = dFdy(point);
@@ -403,8 +400,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
// Its own pass: inputColor is the tone-mapped frame. // Its own pass: inputColor is the tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION, attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([ uniforms: new Map([
['tAllDepth', new Uniform(null)], ...groundSurfaceUniforms(),
['tSurfaceDepth', new Uniform(null)],
['tFrames', new Uniform(null)], ['tFrames', new Uniform(null)],
['tStyles', new Uniform(null)], ['tStyles', new Uniform(null)],
['tSegments', new Uniform(null)], ['tSegments', new Uniform(null)],
@@ -413,9 +409,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
['tItems', new Uniform(null)], ['tItems', new Uniform(null)],
['tGrid', new Uniform(null)], ['tGrid', new Uniform(null)],
['tCandidates', new Uniform(null)], ['tCandidates', new Uniform(null)],
['uInverseProjection', new Uniform(new Matrix4())],
['uViewToWorld', new Uniform(new Matrix4())], ['uViewToWorld', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
['uTime', new Uniform(0)], ['uTime', new Uniform(0)],
['uPixelRatio', new Uniform(1)], ['uPixelRatio', new Uniform(1)],
]), ]),
@@ -428,8 +422,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
const owner = this.owner; const owner = this.owner;
if (!this.effect || !camera || !owner.state) return; if (!this.effect || !camera || !owner.state) return;
const mrt = this.find('mrt'); const mrt = this.find('mrt');
if (mrt?.depthBufferPacking !== RGBADepthPacking) updateGroundSurface(this.effect, camera, this.ctx, mrt);
throw new Error('The ground pass needs packed scene depth');
const input = this.ctx.getInputBuffer(); const input = this.ctx.getInputBuffer();
const { state } = owner; const { state } = owner;
if ( if (
@@ -451,11 +444,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
textures.candidates.needsUpdate = true; textures.candidates.needsUpdate = true;
} }
const u = this.effect.uniforms; const u = this.effect.uniforms;
u.get('tAllDepth').value = mrt.depthBuffer;
u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture;
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
u.get('uViewToWorld').value.copy(camera.matrixWorld); u.get('uViewToWorld').value.copy(camera.matrixWorld);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
u.get('uTime').value = groundSeconds(); u.get('uTime').value = groundSeconds();
const cssWidth = this.view.canvas?.clientWidth; const cssWidth = this.view.canvas?.clientWidth;
u.get('uPixelRatio').value = cssWidth > 0 ? input.width / cssWidth : 1; u.get('uPixelRatio').value = cssWidth > 0 ? input.width / cssWidth : 1;
@@ -504,7 +493,8 @@ export function createGroundPass(view) {
reveal(offset, count, fraction) { reveal(offset, count, fraction) {
if (!owner.state) return; if (!owner.state) return;
for (let i = 0; i < count; i++) for (let i = 0; i < count; i++)
owner.state.items.data[offset + i * ITEM_TEXELS * 4 + 20] = fraction; owner.state.items.data[offset + i * ITEM_TEXELS * 4 + REVEAL_OFFSET] =
fraction;
owner.textures.items.needsUpdate = true; owner.textures.items.needsUpdate = true;
}, },
textureBytes: () => textureBytes(owner.state), textureBytes: () => textureBytes(owner.state),
+59 -38
View File
@@ -1,13 +1,15 @@
// Ground pulse fields over the visible globe, after tone mapping. // Ground pulse fields over the visible globe, after tone mapping.
import { DISPLAY_COLOR_GLSL, VIEW_POSITION_GLSL } from '../shaderChunks.js';
import { Matrix4, Uniform, Vector3, Vector4 } from 'three'; import { Matrix4, Uniform, Vector3, Vector4 } from 'three';
import { groundSurfaceUniforms, updateGroundSurface } from './surface.js'; import { groundSurfaceUniforms, updateGroundSurface } from './surface.js';
import { defineGroundEffect } from './effect.js'; import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
import { Effect, EffectAttribute } from 'postprocessing';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
const KEY = 'gevGroundPulses'; const KEY = 'gevGroundPulses';
const fragmentShader = /* glsl */ ` const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth; uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform mat4 uInverseProjection; uniform mat4 uInverseProjection;
uniform mat4 uInverseView; uniform mat4 uInverseView;
uniform float uLogDepthFC; uniform float uLogDepthFC;
@@ -23,12 +25,8 @@ uniform float uDepth;
uniform float uLineWidth; uniform float uLineWidth;
uniform float uHaloWidth; uniform float uHaloWidth;
uniform float uPixelRatio; uniform float uPixelRatio;
vec3 toDisplay(vec3 c) { ${DISPLAY_COLOR_GLSL}
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c)); ${VIEW_POSITION_GLSL}
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
vec4 over(vec4 top, vec4 under) { vec4 over(vec4 top, vec4 under) {
float a = top.a + under.a * (1.0 - top.a); float a = top.a + under.a * (1.0 - top.a);
if (a <= 0.0) return vec4(0.0); if (a <= 0.0) return vec4(0.0);
@@ -37,9 +35,9 @@ vec4 over(vec4 top, vec4 under) {
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor; outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv)); float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
float distance=exp2(2.0*depth/uLogDepthFC)-1.0; float distance=viewDistance(depth, uLogDepthFC);
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0); vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z); vec3 point=viewPosition(ray, distance);
vec3 origin=uInverseView[3].xyz; vec3 origin=uInverseView[3].xyz;
vec3 direction=normalize((uInverseView*vec4(ray.xyz,0.0)).xyz); vec3 direction=normalize((uInverseView*vec4(ray.xyz,0.0)).xyz);
vec3 o=origin/uRadiiM; vec3 o=origin/uRadiiM;
@@ -81,36 +79,49 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
} }
`; `;
const pulseEffect = defineGroundEffect({ class PulseEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
key: KEY, static allowDuplication = true;
name: 'GevGroundPulses', static key = KEY;
fragmentShader, static insertAfter = ['gevGround', 'gevImagery', 'toneMapping'];
insertAfter: ['gevGround', 'gevImagery', 'toneMapping'], static insertBefore = [
insertBefore: [
'gevPreSharpenOverlay', 'gevPreSharpenOverlay',
'gevSharpen', 'gevSharpen',
'smaa', 'smaa',
'fxaa', 'fxaa',
'gevOverlay', 'gevOverlay',
'final', 'final',
], ];
uniforms: () => [
...groundSurfaceUniforms(), constructor(view, context, config) {
['uInverseView', new Uniform(new Matrix4())], super(view, context, config);
['uCenter', new Uniform(new Vector3(1, 0, 0))], this.owner = config[KEY].owner;
['uRadiusAngle', new Uniform(1)], }
['uRadiiM', new Uniform(new Vector3())],
['uColor', new Uniform(new Vector4())], createPass() {
['uLineColor', new Uniform(new Vector4())], this.effect = new Effect('GevGroundPulses', fragmentShader, {
['uHaloColor', new Uniform(new Vector4())], // Input color is the tone-mapped frame.
['uRadii', new Uniform(new Vector4())], attributes: EffectAttribute.CONVOLUTION,
['uAlphas', new Uniform(new Vector4())], uniforms: new Map([
['uDepth', new Uniform(0.35)], ...groundSurfaceUniforms({ surface: false }),
['uLineWidth', new Uniform(3)], ['uInverseView', new Uniform(new Matrix4())],
['uHaloWidth', new Uniform(1)], ['uCenter', new Uniform(new Vector3(1, 0, 0))],
['uPixelRatio', new Uniform(1)], ['uRadiusAngle', new Uniform(1)],
], ['uRadiiM', new Uniform(new Vector3())],
update() { ['uColor', new Uniform(new Vector4())],
['uLineColor', new Uniform(new Vector4())],
['uHaloColor', new Uniform(new Vector4())],
['uRadii', new Uniform(new Vector4())],
['uAlphas', new Uniform(new Vector4())],
['uDepth', new Uniform(0.35)],
['uLineWidth', new Uniform(3)],
['uHaloWidth', new Uniform(1)],
['uPixelRatio', new Uniform(1)],
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
updateFrame() {
const owner = this.owner, const owner = this.owner,
camera = this.view.camera.raw; camera = this.view.camera.raw;
if (!this.effect || !camera) return; if (!this.effect || !camera) return;
@@ -139,8 +150,15 @@ const pulseEffect = defineGroundEffect({
cssWidth > 0 cssWidth > 0
? this.ctx.getInputBuffer().width / cssWidth ? this.ctx.getInputBuffer().width / cssWidth
: globalThis.devicePixelRatio || 1; : globalThis.devicePixelRatio || 1;
}, }
});
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
}
const registered = new WeakSet();
/** /**
* The pulse disc on one Navara view around `center` (Earth-fixed metres): * The pulse disc on one Navara view around `center` (Earth-fixed metres):
@@ -149,7 +167,7 @@ const pulseEffect = defineGroundEffect({
* rgba arrays in 0..1: the band, the pulse line and its halo. * rgba arrays in 0..1: the band, the pulse line and its halo.
*/ */
export function createNavaraPulseField( export function createNavaraPulseField(
{ view, EffectDesc, NavaraEffect, canvas }, { view, canvas },
{ {
center, center,
radiusM, radiusM,
@@ -162,7 +180,10 @@ export function createNavaraPulseField(
haloWidth, haloWidth,
}, },
) { ) {
pulseEffect.register(view, { EffectDesc, NavaraEffect }); if (!registered.has(view)) {
view.registerEffect(KEY, PulseEffectDesc);
registered.add(view);
}
const owner = { const owner = {
cssWidth: () => canvas?.clientWidth ?? 0, cssWidth: () => canvas?.clientWidth ?? 0,
center: new Vector3(), center: new Vector3(),
+22 -2
View File
@@ -1,7 +1,27 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createNavaraPulseField } from './pulse.js'; import { registerHooks } from 'node:module';
const hook = registerHooks({
resolve(specifier, context, next) {
if (
specifier === '@navaramap/three' &&
context.parentURL?.endsWith('/ground/pulse.js')
)
return {
url: 'data:text/javascript,export class EffectDesc {} export class Effect {}',
shortCircuit: true,
};
return next(specifier, context);
},
});
let createNavaraPulseField;
try {
({ createNavaraPulseField } = await import('./pulse.js'));
} finally {
hook.deregister();
}
test('the Navara pulse disc draws only while the pulses run', () => { test('the Navara pulse disc draws only while the pulses run', () => {
const effects = []; const effects = [];
@@ -25,7 +45,7 @@ test('the Navara pulse disc draws only while the pulses run', () => {
}, },
}; };
const disc = createNavaraPulseField( const disc = createNavaraPulseField(
{ view, EffectDesc: class {}, NavaraEffect: class {} }, { view },
{ {
center: geo.cartesian3.fromDegrees(3, 27, 0), center: geo.cartesian3.fromDegrees(3, 27, 0),
radiusM: 250_000, radiusM: 250_000,
+4 -3
View File
@@ -1,9 +1,9 @@
// Public depth inputs shared by actual terrain/tiles classifiers. // Public depth inputs shared by actual terrain/tiles classifiers.
import { Matrix4, Uniform, RGBADepthPacking } from 'three'; import { Matrix4, Uniform, RGBADepthPacking } from 'three';
export function groundSurfaceUniforms() { export function groundSurfaceUniforms({ surface = true } = {}) {
return [ return [
['tAllDepth', new Uniform(null)], ['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)], ...(surface ? [['tSurfaceDepth', new Uniform(null)]] : []),
['uInverseProjection', new Uniform(new Matrix4())], ['uInverseProjection', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)], ['uLogDepthFC', new Uniform(1)],
]; ];
@@ -13,7 +13,8 @@ export function updateGroundSurface(effect, camera, context, mrt) {
throw new Error('Ground classification requires packed scene depth'); throw new Error('Ground classification requires packed scene depth');
const u = effect.uniforms; const u = effect.uniforms;
u.get('tAllDepth').value = mrt.depthBuffer; u.get('tAllDepth').value = mrt.depthBuffer;
u.get('tSurfaceDepth').value = context.getRenderTarget().depthTexture; if (u.has('tSurfaceDepth'))
u.get('tSurfaceDepth').value = context.getRenderTarget().depthTexture;
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse); u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1); u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
} }
+12 -19
View File
@@ -15,9 +15,14 @@
// slot's position, taken outside any divergent branch. Costs nothing while // slot's position, taken outside any divergent branch. Costs nothing while
// no layer is drawn (the effect is hidden). // no layer is drawn (the effect is hidden).
import { DISPLAY_COLOR_GLSL, VIEW_POSITION_GLSL } from '../shaderChunks.js';
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three'; import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
import { Effect, EffectAttribute } from 'postprocessing'; import { Effect, EffectAttribute } from 'postprocessing';
import { DataTexture, Matrix4, RGBADepthPacking, Uniform } from 'three'; import { DataTexture, Matrix4, Uniform } from 'three';
import {
groundSurfaceUniforms,
updateGroundSurface,
} from '../ground/surface.js';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js'; import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
import { MAX_IMAGERY_SLOTS } from './index.js'; import { MAX_IMAGERY_SLOTS } from './index.js';
@@ -39,12 +44,8 @@ uniform vec4 uBounds${i};
uniform vec4 uLook${i}; uniform vec4 uLook${i};
uniform float uRaise${i};`, uniform float uRaise${i};`,
).join('\n')} ).join('\n')}
vec3 toDisplay(vec3 c) { ${DISPLAY_COLOR_GLSL}
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c)); ${VIEW_POSITION_GLSL}
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
// Geodetic longitude and latitude (Bowring) of a world point, degrees. // Geodetic longitude and latitude (Bowring) of a world point, degrees.
vec2 geodetic(vec3 point) { vec2 geodetic(vec3 point) {
float p = length(point.xy); float p = length(point.xy);
@@ -130,9 +131,9 @@ void derivatives(vec2 geo, vec2 merc, float drawn, out vec2 geoDx, out vec2 geoD
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
outputColor = inputColor; outputColor = inputColor;
float depth = unpackRGBAToDepth(texture2D(tAllDepth, uv)); float depth = unpackRGBAToDepth(texture2D(tAllDepth, uv));
float distance = exp2(2.0 * depth / uLogDepthFC) - 1.0; float distance = viewDistance(depth, uLogDepthFC);
vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0); vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0);
vec3 point = (uWorld * vec4(ray.xyz * (distance / -ray.z), 1.0)).xyz; vec3 point = (uWorld * vec4(viewPosition(ray, distance), 1.0)).xyz;
// The ground: the scene depth where it is the surface (the packed depth // The ground: the scene depth where it is the surface (the packed depth
// also holds forward meshes, which are not ground). // also holds forward meshes, which are not ground).
float ground = (1.0 - step(1.0, depth)) * float ground = (1.0 - step(1.0, depth)) *
@@ -201,11 +202,8 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
// Its own pass: inputColor is the tone-mapped frame. // Its own pass: inputColor is the tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION, attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([ uniforms: new Map([
['tAllDepth', new Uniform(null)], ...groundSurfaceUniforms(),
['tSurfaceDepth', new Uniform(null)],
['uInverseProjection', new Uniform(new Matrix4())],
['uWorld', new Uniform(new Matrix4())], ['uWorld', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
['uSplit', new Uniform(0.5)], ['uSplit', new Uniform(0.5)],
...SLOTS.flatMap((i) => [ ...SLOTS.flatMap((i) => [
[`tImage${i}`, new Uniform(null)], [`tImage${i}`, new Uniform(null)],
@@ -224,14 +222,9 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
const owner = this.owner; const owner = this.owner;
if (!this.effect || !camera) return; if (!this.effect || !camera) return;
const mrt = this.find('mrt'); const mrt = this.find('mrt');
if (mrt?.depthBufferPacking !== RGBADepthPacking) updateGroundSurface(this.effect, camera, this.ctx, mrt);
throw new Error('The imagery pass needs packed scene depth');
const u = this.effect.uniforms; const u = this.effect.uniforms;
u.get('tAllDepth').value = mrt.depthBuffer;
u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture;
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
u.get('uWorld').value.copy(camera.matrixWorld); u.get('uWorld').value.copy(camera.matrixWorld);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
u.get('uSplit').value = owner.split; u.get('uSplit').value = owner.split;
for (const i of SLOTS) { for (const i of SLOTS) {
const slot = owner.slots[i]; const slot = owner.slots[i];
+2 -10
View File
@@ -2,12 +2,7 @@
// the product way (viewOptions.js) and the GlobeEngine on it. Load it with a // 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. // dynamic import() so Navara stays out of the Cesium-mode bundle.
import ThreeView, { import ThreeView, { Color as NavaraColor, MeshDesc } from '@navaramap/three';
Color as NavaraColor,
MeshDesc,
EffectDesc,
Effect as NavaraEffect,
} from '@navaramap/three';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { Pass } from 'postprocessing'; import { Pass } from 'postprocessing';
@@ -177,10 +172,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
ground: createNavaraGround(view, { ground: createNavaraGround(view, {
createPass: createGroundPass, createPass: createGroundPass,
pulseField: (options) => pulseField: (options) =>
createNavaraPulseField( createNavaraPulseField({ view, canvas }, options),
{ view, EffectDesc, NavaraEffect, canvas },
options,
),
}), }),
imagery: (parts) => imagery: (parts) =>
createNavaraImagery(view, { ...parts, createPass: createImageryPass }), createNavaraImagery(view, { ...parts, createPass: createImageryPass }),
+3 -1
View File
@@ -1,3 +1,4 @@
import { PACKED_DEPTH_GLSL } from './shaderChunks.js';
// The depth test of gev's screen overlays on Navara (sprites, points and // The depth test of gev's screen overlays on Navara (sprites, points and
// lines drawn after tone mapping, overlay.js): there is no depth buffer in // lines drawn after tone mapping, overlay.js): there is no depth buffer in
// the post-processing chain, so each fragment compares its own logarithmic // the post-processing chain, so each fragment compares its own logarithmic
@@ -15,6 +16,7 @@ export const OVERLAY_DEPTH_MODES = Object.freeze({
/** Fragment declarations: uniforms, the varying and gevOverlayHidden(). */ /** Fragment declarations: uniforms, the varying and gevOverlayHidden(). */
export const OVERLAY_DEPTH_FRAGMENT = /* glsl */ ` export const OVERLAY_DEPTH_FRAGMENT = /* glsl */ `
${PACKED_DEPTH_GLSL}
uniform sampler2D tGevSceneDepth; uniform sampler2D tGevSceneDepth;
uniform vec2 uGevResolution; uniform vec2 uGevResolution;
uniform float uGevDepthMode; uniform float uGevDepthMode;
@@ -36,7 +38,7 @@ bool gevOverlayHidden() {
#else #else
float mine = gl_FragCoord.z; float mine = gl_FragCoord.z;
#endif #endif
bool occluded = mine > scene + 2e-6; bool occluded = packedDepthOccluded(mine, scene, 2e-6);
return uGevDepthMode < 1.5 ? occluded : !occluded; return uGevDepthMode < 1.5 ? occluded : !occluded;
} }
`; `;
+3 -1
View File
@@ -1,3 +1,4 @@
import { PACKED_DEPTH_GLSL } from './shaderChunks.js';
// Navara draws its transparent scene after the post effects, into a target // Navara draws its transparent scene after the post effects, into a target
// that does not hold the scene's depth, so a transparent mesh behind a wall, // that does not hold the scene's depth, so a transparent mesh behind a wall,
// terrain or the Earth painted through them. Moving such a mesh into the // terrain or the Earth painted through them. Moving such a mesh into the
@@ -15,6 +16,7 @@ const KEY = 'gevSceneDepth';
export const SCENE_DEPTH_BIAS = 5e-6; export const SCENE_DEPTH_BIAS = 5e-6;
const PARS = /* glsl */ ` const PARS = /* glsl */ `
${PACKED_DEPTH_GLSL}
uniform sampler2D tGevSceneDepth; uniform sampler2D tGevSceneDepth;
uniform vec2 uGevTargetSize; uniform vec2 uGevTargetSize;
uniform float uGevSceneDepthOn; uniform float uGevSceneDepthOn;
@@ -32,7 +34,7 @@ if (uGevSceneDepthOn > 0.5) {
#else #else
float gevDepth = gl_FragCoord.z; float gevDepth = gl_FragCoord.z;
#endif #endif
if (gevScene < 1.0 && gevDepth > gevScene + ${SCENE_DEPTH_BIAS.toExponential()}) if (gevScene < 1.0 && packedDepthOccluded(gevDepth, gevScene, ${SCENE_DEPTH_BIAS.toExponential()}))
discard; discard;
} }
`; `;
+4 -1
View File
@@ -32,7 +32,10 @@ test('the scene-depth test goes after the log depth and brings packing once', ()
patched.indexOf('uniform float uGevSceneDepthOn;') < patched.indexOf('uniform float uGevSceneDepthOn;') <
patched.indexOf('void main() {'), patched.indexOf('void main() {'),
); );
assert.match(patched, /gevDepth > gevScene \+ 5e-6\)\s*discard;/); assert.match(
patched,
/packedDepthOccluded\(gevDepth, gevScene, 5e-6\)\)\s*discard;/,
);
} }
assert.ok(SCENE_DEPTH_BIAS > 0 && SCENE_DEPTH_BIAS < 1e-4); assert.ok(SCENE_DEPTH_BIAS > 0 && SCENE_DEPTH_BIAS < 1e-4);
assert.throws(() => withSceneDepthTest('void main() {}'), /logdepthbuf/); assert.throws(() => withSceneDepthTest('void main() {}'), /logdepthbuf/);
+24
View File
@@ -0,0 +1,24 @@
// Shader functions shared by the surface passes and depth tests.
export const DISPLAY_COLOR_GLSL = /* glsl */ `
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
`;
export const VIEW_POSITION_GLSL = /* glsl */ `
vec3 viewPosition(vec4 ray, float distance) {
return ray.xyz * (distance / -ray.z);
}
float viewDistance(float depth, float logDepthFC) {
return exp2(2.0 * depth / logDepthFC) - 1.0;
}
`;
export const PACKED_DEPTH_GLSL = /* glsl */ `
bool packedDepthOccluded(float mine, float scene, float bias) {
return mine > scene + bias;
}
`;
+98 -115
View File
@@ -19,6 +19,40 @@ import {
} from '../contract.js'; } from '../contract.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
const throwsType = (run, label) => {
let error = null;
try {
run();
} catch (caught) {
error = caught;
}
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
};
const like = (colour, data, i) =>
colour.every(
(channel, k) => k > 2 || Math.abs(data[i + k] - channel * 255) <= 40,
);
const MAGENTA = [255, 0, 255];
const GREEN = [0, 255, 0];
const nearColour = (actual, expected, tolerance = 40) =>
actual.every((value, k) => Math.abs(value - expected[k]) <= tolerance);
const show = (colour) => colour.map((value) => value.toFixed(0)).join(',');
const screenOf = (engine, lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
const meanAt = (engine, readRegion, lon, lat, half) => {
const { x, y } = screenOf(engine, lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
2 * half,
2 * half,
);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
return mean.map((sum) => sum / (data.length / 4));
};
const WGS84 = ellipsoid.WGS84; const WGS84 = ellipsoid.WGS84;
class ContractError extends Error { class ContractError extends Error {
@@ -505,11 +539,7 @@ export function createContractSuite(
// count and centroid. // count and centroid.
// A colour as drawn: within 40 of the asked one on every channel // A colour as drawn: within 40 of the asked one on every channel
// (both engines draw overlays after tone mapping, unlit). // (both engines draw overlays after tone mapping, unlit).
const like = (colour, data, i) =>
colour.every(
(channel, k) =>
k > 2 || Math.abs(data[i + k] - channel * 255) <= 40,
);
const find = (screen, colour) => { const find = (screen, colour) => {
const half = 32; const half = 32;
const x0 = Math.round(screen.x) - half; const x0 = Math.round(screen.x) - half;
@@ -3002,17 +3032,7 @@ function groundChecks(
[lon + half, lat + half], [lon + half, lat + half],
[lon - half, lat + half], [lon - half, lat + half],
]; ];
const screenOf = (lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
const throwsType = (run, label) => {
let error = null;
try {
run();
} catch (caught) {
error = caught;
}
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
};
return [ return [
[ [
'projector: a lowered horizon ellipsoid keeps below-sea-level ground visible', 'projector: a lowered horizon ellipsoid keeps below-sea-level ground visible',
@@ -3075,7 +3095,7 @@ function groundChecks(
await until(() => set.ready(), 'reveal ground ready'); await until(() => set.ready(), 'reveal ground ready');
await settle(); await settle();
const magenta = (lon) => { const magenta = (lon) => {
const { x, y } = screenOf(lon, 10); const { x, y } = screenOf(engine, lon, 10);
const p = readRegion(Math.round(x), Math.round(y), 1, 1); const p = readRegion(Math.round(x), Math.round(y), 1, 1);
return Math.min(p[0], p[2]) - p[1]; return Math.min(p[0], p[2]) - p[1];
}; };
@@ -3414,7 +3434,8 @@ function groundChecks(
'the pickable fills drawn', 'the pickable fills drawn',
); );
await settle(3); await settle(3);
const pickAt = (lon, lat) => engine.picker.pick(screenOf(lon, lat)); const pickAt = (lon, lat) =>
engine.picker.pick(screenOf(engine, lon, lat));
const onCell = pickAt(19.5, 10.2); const onCell = pickAt(19.5, 10.2);
ensure( ensure(
onCell?.id === 'cell-a' && onCell.owner === 'contract-ground-cells', onCell?.id === 'cell-a' && onCell.owner === 'contract-ground-cells',
@@ -3451,7 +3472,7 @@ function groundChecks(
await settle(3); await settle(3);
if (!pointer) return; if (!pointer) return;
const before = clicks.length; const before = clicks.length;
const at = screenOf(19.5, 10.2); const at = screenOf(engine, 19.5, 10.2);
await pointer.click(at.x, at.y); await pointer.click(at.x, at.y);
await until(() => clicks.length > before, 'a click payload'); await until(() => clicks.length > before, 'a click payload');
const clicked = clicks.at(-1); const clicked = clicks.at(-1);
@@ -3474,14 +3495,10 @@ function groundChecks(
await nadirView(1e6); await nadirView(1e6);
const MAGENTA = [1, 0, 1, 1]; const MAGENTA = [1, 0, 1, 1];
const GREEN = [0, 1, 0, 1]; const GREEN = [0, 1, 0, 1];
const like = (colour, data, i) =>
colour.every(
(channel, k) =>
k > 2 || Math.abs(data[i + k] - channel * 255) <= 40,
);
// Pixels of a colour in a box centred on a ground point. // Pixels of a colour in a box centred on a ground point.
const countAt = (lon, lat, colour, half = 12) => { const countAt = (lon, lat, colour, half = 12) => {
const { x, y } = screenOf(lon, lat); const { x, y } = screenOf(engine, lon, lat);
const data = readRegion( const data = readRegion(
Math.round(x) - half, Math.round(x) - half,
Math.round(y) - half, Math.round(y) - half,
@@ -3554,7 +3571,7 @@ function groundChecks(
`the disc's outline is ${countAt(rimLon, 10, MAGENTA, 3).toFixed(2)} magenta at the rim`, `the disc's outline is ${countAt(rimLon, 10, MAGENTA, 3).toFixed(2)} magenta at the rim`,
); );
// A 6 px corridor: a column of pixels across it. // A 6 px corridor: a column of pixels across it.
const mid = screenOf(20, 8.5); const mid = screenOf(engine, 20, 8.5);
const column = readRegion( const column = readRegion(
Math.round(mid.x), Math.round(mid.x),
Math.round(mid.y) - 10, Math.round(mid.y) - 10,
@@ -3753,7 +3770,11 @@ function groundChecks(
const along = (lat, samples = 120) => { const along = (lat, samples = 120) => {
const out = []; const out = [];
for (let k = 0; k < samples; k++) { for (let k = 0; k < samples; k++) {
const at = screenOf(19.2 + (1.6 * k) / (samples - 1), lat); const at = screenOf(
engine,
19.2 + (1.6 * k) / (samples - 1),
lat,
);
out.push([ out.push([
...readRegion(Math.round(at.x), Math.round(at.y), 1, 1), ...readRegion(Math.round(at.x), Math.round(at.y), 1, 1),
]); ]);
@@ -3763,7 +3784,7 @@ function groundChecks(
const magenta = ([r, g, b]) => r > 200 && b > 200 && g < 60; const magenta = ([r, g, b]) => r > 200 && b > 200 && g < 60;
// The glow: white at its centre line, green toward its edges. // The glow: white at its centre line, green toward its edges.
{ {
const centre = screenOf(20, 9); const centre = screenOf(engine, 20, 9);
const column = readRegion( const column = readRegion(
Math.round(centre.x), Math.round(centre.x),
Math.round(centre.y) - 12, Math.round(centre.y) - 12,
@@ -3790,7 +3811,7 @@ function groundChecks(
let trough = 0; let trough = 0;
const started = performance.now(); const started = performance.now();
while (performance.now() - started < 1600) { while (performance.now() - started < 1600) {
const centre = screenOf(20, 10); const centre = screenOf(engine, 20, 10);
const pixel = readRegion( const pixel = readRegion(
Math.round(centre.x), Math.round(centre.x),
Math.round(centre.y), Math.round(centre.y),
@@ -3892,6 +3913,7 @@ function groundChecks(
const metresPerDegree = 111319.49 * Math.cos((10 * Math.PI) / 180); const metresPerDegree = 111319.49 * Math.cos((10 * Math.PI) / 180);
const at = (radius, angle) => { const at = (radius, angle) => {
const { x, y } = screenOf( const { x, y } = screenOf(
engine,
20 + (radius * Math.cos(angle)) / metresPerDegree, 20 + (radius * Math.cos(angle)) / metresPerDegree,
10 + (radius * Math.sin(angle)) / 110574, 10 + (radius * Math.sin(angle)) / 110574,
); );
@@ -3951,15 +3973,7 @@ function groundChecks(
// engine.imagery: raster layers on the ground (every engine). // engine.imagery: raster layers on the ground (every engine).
function imageryChecks(engine, { settle, until, nadirView, readRegion }) { function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
const { imagery } = engine; const { imagery } = engine;
const throwsType = (run, label) => {
let error = null;
try {
run();
} catch (caught) {
error = caught;
}
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
};
// A flat colour as RGBA bytes (no canvas needed), size × size texels. // A flat colour as RGBA bytes (no canvas needed), size × size texels.
const flat = (rgba, size = 8) => { const flat = (rgba, size = 8) => {
const data = new Uint8Array(size * size * 4); const data = new Uint8Array(size * size * 4);
@@ -4132,35 +4146,16 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
async () => { async () => {
if (!readRegion) return; if (!readRegion) return;
await nadirView(1e6); await nadirView(1e6);
const MAGENTA = [255, 0, 255];
const GREEN = [0, 255, 0];
const screenOf = (lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
// The mean colour in a box centred on a ground point. // The mean colour in a box centred on a ground point.
const meanAt = (lon, lat, half = 6) => {
const { x, y } = screenOf(lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
2 * half,
2 * half,
);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
return mean.map((sum) => sum / (data.length / 4));
};
const near = (actual, expected, tolerance = 40) =>
actual.every(
(value, k) => Math.abs(value - expected[k]) <= tolerance,
);
const show = (colour) =>
colour.map((value) => value.toFixed(0)).join(',');
// The map's own tiles loaded: what lies under the layers holds still. // The map's own tiles loaded: what lies under the layers holds still.
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded'); await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
await settle(4); await settle(4);
const base = meanAt(19.5, 10); const base = meanAt(engine, readRegion, 19.5, 10, 6);
ensure(!near(base, MAGENTA), 'the globe is already magenta there'); ensure(
!nearColour(base, MAGENTA),
'the globe is already magenta there',
);
const magenta = imagery.createLayer({ const magenta = imagery.createLayer({
id: 'contract-paint-magenta', id: 'contract-paint-magenta',
source: { source: {
@@ -4173,33 +4168,38 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
try { try {
await until(() => magenta.ready(), 'the magenta layer ready'); await until(() => magenta.ready(), 'the magenta layer ready');
await until( await until(
() => near(meanAt(19.5, 10), MAGENTA), () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the magenta layer drawn', 'the magenta layer drawn',
); );
ensure( ensure(
near(meanAt(21, 10), base, 60) || !near(meanAt(21, 10), MAGENTA), nearColour(meanAt(engine, readRegion, 21, 10, 6), base, 60) ||
!nearColour(meanAt(engine, readRegion, 21, 10, 6), MAGENTA),
'the layer spills outside its rectangle', 'the layer spills outside its rectangle',
); );
// Alpha: at 0 the globe under it, at 0.5 half way to magenta. // Alpha: at 0 the globe under it, at 0.5 half way to magenta.
magenta.setAlpha(0); magenta.setAlpha(0);
await until(() => !near(meanAt(19.5, 10), MAGENTA), 'alpha 0 drawn'); await until(
() => !nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'alpha 0 drawn',
);
await settle(2); await settle(2);
const below = meanAt(19.5, 10); const below = meanAt(engine, readRegion, 19.5, 10, 6);
magenta.setAlpha(0.5); magenta.setAlpha(0.5);
const half = below.map((value, k) => (value + MAGENTA[k]) / 2); const half = below.map((value, k) => (value + MAGENTA[k]) / 2);
await until( await until(
() => near(meanAt(19.5, 10), half, 30), () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), half, 30),
`alpha 0.5 drawn (${show(half)} wanted)`, `alpha 0.5 drawn (${show(half)} wanted)`,
); );
magenta.setAlpha(1); magenta.setAlpha(1);
magenta.show(false); magenta.show(false);
await until( await until(
() => near(meanAt(19.5, 10), below, 30), () =>
nearColour(meanAt(engine, readRegion, 19.5, 10, 6), below, 30),
'the hidden layer gone', 'the hidden layer gone',
); );
magenta.show(true); magenta.show(true);
await until( await until(
() => near(meanAt(19.5, 10), MAGENTA), () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the layer shown again', 'the layer shown again',
); );
// Order: a higher order draws on top, a lower one under it. // Order: a higher order draws on top, a lower one under it.
@@ -4224,12 +4224,12 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
}); });
layers.push(lower); layers.push(lower);
await until( await until(
() => near(meanAt(19.5, 10), GREEN), () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN),
'the higher order on top', 'the higher order on top',
); );
green.destroy(); green.destroy();
await until( await until(
() => near(meanAt(19.5, 10), MAGENTA), () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the destroyed layer gone', 'the destroyed layer gone',
); );
// A new source in place: the same layer, another colour. // A new source in place: the same layer, another colour.
@@ -4239,7 +4239,7 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
rectangle: box, rectangle: box,
}); });
await until( await until(
() => near(meanAt(19.5, 10), GREEN), () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN),
'the new source drawn', 'the new source drawn',
); );
magenta.setSource({ magenta.setSource({
@@ -4250,11 +4250,11 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
lower.destroy(); lower.destroy();
// Split: a left layer draws only left of the split position. // Split: a left layer draws only left of the split position.
const width = engine.host.size().width; const width = engine.host.size().width;
const middle = screenOf(19.5, 10).x; const middle = screenOf(engine, 19.5, 10).x;
imagery.setSplitPosition(middle / width); imagery.setSplitPosition(middle / width);
magenta.setSplit('left'); magenta.setSplit('left');
const left = screenOf(19.25, 10); const left = screenOf(engine, 19.25, 10);
const right = screenOf(19.75, 10); const right = screenOf(engine, 19.75, 10);
const at = ({ x, y }) => { const at = ({ x, y }) => {
const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6); const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6);
const mean = [0, 0, 0]; const mean = [0, 0, 0];
@@ -4263,17 +4263,20 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
return mean; return mean;
}; };
await until( await until(
() => near(at(left), MAGENTA) && !near(at(right), MAGENTA), () =>
nearColour(at(left), MAGENTA) && !nearColour(at(right), MAGENTA),
`the left split drawn (left ${show(at(left))}, right ${show(at(right))})`, `the left split drawn (left ${show(at(left))}, right ${show(at(right))})`,
); );
magenta.setSplit('right'); magenta.setSplit('right');
await until( await until(
() => !near(at(left), MAGENTA) && near(at(right), MAGENTA), () =>
!nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA),
'the right split drawn', 'the right split drawn',
); );
magenta.setSplit('none'); magenta.setSplit('none');
await until( await until(
() => near(at(left), MAGENTA) && near(at(right), MAGENTA), () =>
nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA),
'the unsplit layer drawn', 'the unsplit layer drawn',
); );
} finally { } finally {
@@ -4283,7 +4286,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
await until( await until(
() => () =>
[MAGENTA, GREEN, [0, 255, 255]].every( [MAGENTA, GREEN, [0, 255, 255]].every(
(colour) => !near(meanAt(19.5, 10), colour), (colour) =>
!nearColour(meanAt(engine, readRegion, 19.5, 10, 6), colour),
), ),
'every layer gone', 'every layer gone',
); );
@@ -4479,29 +4483,7 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
async () => { async () => {
if (!readRegion) return; if (!readRegion) return;
await nadirView(1e6); await nadirView(1e6);
const MAGENTA = [255, 0, 255];
const GREEN = [0, 255, 0];
const screenOf = (lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
const meanAt = (lon, lat, half = 5) => {
const { x, y } = screenOf(lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
2 * half,
2 * half,
);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
return mean.map((sum) => sum / (data.length / 4));
};
const near = (actual, expected, tolerance = 40) =>
actual.every(
(value, k) => Math.abs(value - expected[k]) <= tolerance,
);
const show = (colour) =>
colour.map((value) => value.toFixed(0)).join(',');
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded'); await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
await settle(4); await settle(4);
// Every tile, whatever the app serves, painted magenta by the // Every tile, whatever the app serves, painted magenta by the
@@ -4536,11 +4518,11 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
}); });
layers.push(tiles); layers.push(tiles);
await until( await until(
() => near(meanAt(19.5, 10), MAGENTA), () => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA),
`the transformed tiles drawn (${show(meanAt(19.5, 10))})`, `the transformed tiles drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`,
); );
ensure( ensure(
!near(meanAt(21, 10), MAGENTA), !nearColour(meanAt(engine, readRegion, 21, 10, 5), MAGENTA),
'the tiles spill outside their rectangle', 'the tiles spill outside their rectangle',
); );
// Staged: green replaces the magenta tiles once it is drawn. // Staged: green replaces the magenta tiles once it is drawn.
@@ -4553,8 +4535,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
await until(() => swapped !== undefined, 'the staged image'); await until(() => swapped !== undefined, 'the staged image');
ensure(swapped === true, 'the staged image failed'); ensure(swapped === true, 'the staged image failed');
await until( await until(
() => near(meanAt(19.5, 10), GREEN), () => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN),
`the staged image drawn (${show(meanAt(19.5, 10))})`, `the staged image drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`,
); );
tiles.destroy(); tiles.destroy();
// Raised 6 km: drawn over the box (a little parallax at 1000 km), // Raised 6 km: drawn over the box (a little parallax at 1000 km),
@@ -4570,8 +4552,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
}); });
layers.push(raisedLayer); layers.push(raisedLayer);
await until( await until(
() => near(meanAt(19.6, 10), MAGENTA), () => nearColour(meanAt(engine, readRegion, 19.6, 10, 5), MAGENTA),
`the raised layer drawn (${show(meanAt(19.6, 10))})`, `the raised layer drawn (${show(meanAt(engine, readRegion, 19.6, 10, 5))})`,
); );
raisedLayer.setDetail({ raisedLayer.setDetail({
image: flat([...GREEN, 255]), image: flat([...GREEN, 255]),
@@ -4579,13 +4561,13 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
}); });
await until( await until(
() => () =>
near(meanAt(19.25, 10), GREEN) && nearColour(meanAt(engine, readRegion, 19.25, 10, 5), GREEN) &&
near(meanAt(19.75, 10), MAGENTA), nearColour(meanAt(engine, readRegion, 19.75, 10, 5), MAGENTA),
`the detail drawn (west ${show(meanAt(19.25, 10))}, east ${show(meanAt(19.75, 10))})`, `the detail drawn (west ${show(meanAt(engine, readRegion, 19.25, 10, 5))}, east ${show(meanAt(engine, readRegion, 19.75, 10, 5))})`,
); );
raisedLayer.setDetail(null); raisedLayer.setDetail(null);
await until( await until(
() => near(meanAt(19.25, 10), MAGENTA), () => nearColour(meanAt(engine, readRegion, 19.25, 10, 5), MAGENTA),
'the detail cleared', 'the detail cleared',
); );
} finally { } finally {
@@ -4593,7 +4575,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
} }
await until( await until(
() => () =>
!near(meanAt(19.5, 10), MAGENTA) && !near(meanAt(19.5, 10), GREEN), !nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA) &&
!nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN),
'every layer gone', 'every layer gone',
); );
}, },
-159
View File
@@ -57,41 +57,6 @@ test('the fake records contract calls for shell-module tests', () => {
assert.equal(engine.fake.renderRequests, 0); assert.equal(engine.fake.renderRequests, 0);
}); });
test('fake flights advance with frames and finish or cancel exactly once', () => {
const engine = createFakeEngine();
const destination = geo.cartesian3.fromDegrees(30, 40, 5000);
const outcomes = [];
engine.camera.flyTo({
destination,
duration: 1,
complete: () => outcomes.push('complete'),
cancel: () => outcomes.push('cancel'),
});
assert.equal(engine.camera.flightActive(), true);
engine.fake.frames(30, 16);
assert.deepEqual(outcomes, []);
engine.fake.frames(40, 16);
assert.deepEqual(outcomes, ['complete']);
assert.ok(
geo.cartesian3.distance(engine.camera.pose().position, destination) < 1e-6,
);
engine.camera.cancelFlight();
assert.deepEqual(outcomes, ['complete']);
engine.camera.flyTo({
destination: geo.cartesian3.fromDegrees(0, 0, 1e6),
cancel: () => outcomes.push('cancel'),
});
engine.camera.flyTo({
destination,
duration: 0,
complete: () => outcomes.push('second'),
});
// Duration 0 lands and completes before flyTo returns, as in Cesium.
assert.deepEqual(outcomes, ['complete', 'cancel', 'second']);
assert.equal(engine.camera.flightActive(), false);
});
test('fake events can be emitted by hand and frames fire in order', () => { test('fake events can be emitted by hand and frames fire in order', () => {
const engine = createFakeEngine(); const engine = createFakeEngine();
const seen = []; const seen = [];
@@ -190,25 +155,6 @@ test('fake credits, maps and destroy keep inspectable state', () => {
assert.equal(engine.fake.destroyed, true); assert.equal(engine.fake.destroyed, true);
}); });
test('fake lookAtTransform keeps the transform until release', () => {
const engine = createFakeEngine();
const target = geo.cartesian3.fromDegrees(10, 10, 0);
const frame = geo.transforms.eastNorthUpToFixedFrame(target);
engine.camera.lookAtTransform(frame, {
heading: 0,
pitch: -0.5,
range: 1000,
});
assert.deepEqual(engine.camera.transform(), [...frame]);
engine.camera.transform()[0] = 42;
assert.notEqual(engine.camera.transform()[0], 42);
engine.camera.release();
assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]);
engine.camera.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 });
assert.deepEqual(engine.camera.transform(), [...frame]);
assert.deepEqual(engine.fake.lookAtTarget(), target);
});
test('fake input honours modifiers like Cesium: unmodified listeners skip modified input', () => { test('fake input honours modifiers like Cesium: unmodified listeners skip modified input', () => {
const engine = createFakeEngine(); const engine = createFakeEngine();
const seen = []; const seen = [];
@@ -292,33 +238,6 @@ test('fake isDestroyed, commanded and pickGlobe are inspectable', () => {
assert.equal(engine.isDestroyed(), true); assert.equal(engine.isDestroyed(), true);
}); });
test('the fake clock is the app clock the test controls, latched per frame', () => {
const start = Date.UTC(2026, 9, 16, 12);
const engine = createFakeEngine({ nowMs: start });
const seen = [];
for (const name of ['preUpdate', 'preRender', 'postRender'])
engine.frames[name].addEventListener(() =>
seen.push([name, engine.clock.frameTime()]),
);
assert.equal(engine.clock.now(), start);
engine.fake.frame(16);
assert.deepEqual(seen, [
['preUpdate', start + 16],
['preRender', start + 16],
['postRender', start + 16],
]);
// Time moves between frames; the frame time only at the next frame.
engine.fake.advanceTime(1000);
assert.equal(engine.clock.now(), start + 1016);
assert.equal(engine.clock.frameTime(), start + 16);
engine.fake.frame(16);
assert.equal(engine.clock.frameTime(), start + 1032);
// The app clock's controls work through engine.fake.appClock.
engine.fake.appClock.freeze(start);
engine.fake.frame(16);
assert.equal(engine.clock.frameTime(), start);
});
test('a fake engine can share an app clock', async () => { test('a fake engine can share an app clock', async () => {
const { createAppClock } = await import('../../time/clock.js'); const { createAppClock } = await import('../../time/clock.js');
let wall = 5000; let wall = 5000;
@@ -387,81 +306,3 @@ test('fake lines keep their positions, colour, matrix and visibility', () => {
line.destroy(); line.destroy();
assert.equal(engine.fake.line('trail'), undefined); assert.equal(engine.fake.line('trail'), undefined);
}); });
test('input listeners asking for picks get the pick and the drill in the payload', () => {
const engine = createFakeEngine();
engine.fake.script('pick', (screen) =>
screen.x === 10
? { id: 'ae01ce', owner: 'flights', instance: 3 }
: undefined,
);
engine.fake.script('drillPick', (screen, limit) =>
[{ id: 'a' }, { id: 'b' }, { id: 'c' }].slice(0, limit),
);
const seen = [];
engine.input.on('click', (event) => seen.push(['plain', event]));
engine.input.on('click', (event) => seen.push(['pick', event]), {
pick: true,
drill: 2,
});
engine.input.on('move', (event) => seen.push(['hover', event]), {
pick: true,
});
engine.fake.input('click', { position: { x: 10, y: 5 } });
engine.fake.input('move', {
startPosition: { x: 0, y: 0 },
endPosition: { x: 10, y: 5 },
});
engine.fake.input('click', { position: { x: 99, y: 5 } });
assert.deepEqual(seen[0], ['plain', { position: { x: 10, y: 5 } }]);
assert.deepEqual(seen[1][1].picked, {
id: 'ae01ce',
owner: 'flights',
instance: 3,
});
assert.deepEqual(seen[1][1].drilled, [{ id: 'a' }, { id: 'b' }]);
assert.deepEqual(seen[2][1].picked.id, 'ae01ce');
assert.equal(seen[2][1].drilled, undefined, 'no drill unless asked');
assert.equal(seen[4][1].picked, undefined);
assert.throws(
() => engine.input.on('click', () => {}, { drill: -1 }),
TypeError,
);
assert.throws(
() => engine.input.on('up', () => {}, { pick: true }),
TypeError,
);
});
test('input rejects invalid pick windows and forwards copied windows on down and move', () => {
const engine = createFakeEngine();
for (const pickWindow of [
null,
{},
{ width: 0, height: 9 },
{ width: 9, height: 0.5 },
{ width: Infinity, height: 1 },
])
assert.throws(
() => engine.input.on('click', () => {}, { pick: true, pickWindow }),
TypeError,
);
assert.throws(
() =>
engine.input.on('click', () => {}, {
pickWindow: { width: 9, height: 9 },
}),
TypeError,
);
const picked = [];
engine.fake.script('pick', (at, w, h) => ({ id: `${w}:${h}` }));
const pickWindow = { width: 11, height: 7 };
engine.input.on('down', (event) => picked.push(event.picked.id), {
pick: true,
pickWindow,
});
pickWindow.width = 1;
engine.fake.input('down', { position: { x: 2, y: 3 } });
assert.deepEqual(picked, ['11:7']);
engine.destroy();
});
+4 -26
View File
@@ -1,16 +1,12 @@
// The air-quality stations on the engine contract, on the fake engine and // Layer display behavior on the fake engine.
// on the Cesium engine (its ground batches over fake primitives): a 6 km
// disc per reading in its band colour at 0.35, no outline, picks, the view
// rectangle the readout reads, visibility through the layer and teardown.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { import {
GROUND_HARNESSES, GROUND_HARNESSES,
createFakeGroundHarness, createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs'; } from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { createAirQualityLayer } from '../index.js'; import { createAirQualityLayer } from '../index.js';
@@ -95,28 +91,10 @@ for (const makeHarness of GROUND_HARNESSES) {
); );
}); });
assert.equal(f.entries.at(-1)[0], 'air-quality', 'labels published'); assert.equal(f.entries.at(-1)[0], 'air-quality', 'labels published');
if (f.cesium) {
const { fills, lines } = f.primitives('air-quality');
assert.equal(fills.length, 1, 'one ground batch');
assert.equal(lines.length, 0, 'no outline batch');
assert.equal(
fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
fills[0].options.geometryInstances.forEach((instance, index) => {
assert.ok(instance.geometry instanceof Cesium.EllipseGeometry);
assert.equal(instance.geometry._semiMajorAxis, RADIUS_M);
const expected = rgbaFromCss(readings[index].color)
.withAlpha(0.35)
.rgba();
instanceColor(instance).forEach((channel, at) =>
near(channel, expected[at], `colour ${index}.${at}`, 0.5 / 255),
);
});
}
f.layer.disable(); f.layer.disable();
assert.equal(f.set('air-quality').shown, false); assert.equal(f.set('air-quality').shown, false);
if (f.cesium) assert.equal(f.collectionShown('air-quality'), false);
f.layer.enable(); f.layer.enable();
assert.equal(f.set('air-quality').shown, true); assert.equal(f.set('air-quality').shown, true);
} finally { } finally {
+1 -1
View File
@@ -15,4 +15,4 @@ export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js'; export * from '../../data/renderAltitude.js';
export * from '../../data/routePlausible.js'; export * from '../../data/routePlausible.js';
export * from '../../data/trackedModelRegime.js'; export * from '../../data/trackedModelRegime.js';
export * from '../../data/trackingClickGesture.js'; export * from '../../data/trackingGesture.js';
+22 -219
View File
@@ -1,3 +1,4 @@
import { createFakeGroundHarness as fakeHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// The aircraft display on the engine contract, on the fake engine and on the // The aircraft display on the engine contract, on the fake engine and on the
// Cesium engine (real Cesium collections, billboards, models and // Cesium engine (real Cesium collections, billboards, models and
// polylines over a stub scene: they draw nothing but hold what the engine // polylines over a stub scene: they draw nothing but hold what the engine
@@ -10,7 +11,7 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js';
import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js'; import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js';
import { import {
@@ -33,28 +34,7 @@ const AXIS = Cesium.Matrix4.multiplyTransformation(
); );
const SCALE_BY_DISTANCE = nearFarScale(1000, 3.0, 8000000, 0.5); const SCALE_BY_DISTANCE = nearFarScale(1000, 3.0, 8000000, 0.5);
function fakeHarness() { const HARNESSES = [fakeHarness];
const engine = createFakeEngine();
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, ...rest } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
async function setup(makeHarness, options = {}) { async function setup(makeHarness, options = {}) {
const harness = makeHarness(); const harness = makeHarness();
@@ -149,34 +129,7 @@ for (const makeHarness of HARNESSES) {
await settle(); await settle();
const [listed] = setsOf(engine, 'flights-sprites'); const [listed] = setsOf(engine, 'flights-sprites');
assert.equal(listed.count, 1); assert.equal(listed.count, 1);
if (h.cesium) { {
const collection = h.primitive(listed.id);
assert.ok(collection instanceof Cesium.BillboardCollection);
const billboard = collection.get(0);
assert.ok(Cesium.Cartesian3.equals(billboard.position, spec.position));
assert.equal(billboard.id, 'abc123');
near(billboard.scale, 1.5, 'scale');
near(billboard.rotation, 0.3, 'rotation');
assert.equal(billboard.width, 20);
assert.equal(billboard.height, 20);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.ok(
Cesium.NearFarScalar.equals(
billboard.scaleByDistance,
new Cesium.NearFarScalar(1000, 3, 8000000, 0.5),
),
);
assert.ok(
billboard.color.equalsEpsilon(
Cesium.Color.fromCssColorString('#FFB800'),
1e-6,
),
);
assert.equal(billboard.show, true);
} else {
const set = engine.fake.moverSet(listed.id); const set = engine.fake.moverSet(listed.id);
assert.equal(set.options.depthTest, 'always'); assert.equal(set.options.depthTest, 'always');
assert.deepEqual( assert.deepEqual(
@@ -195,9 +148,7 @@ for (const makeHarness of HARNESSES) {
near(store.colors[1], 184 / 255, 'green'); near(store.colors[1], 184 / 255, 'green');
} }
// Writes inside a frame listener commit at its end, once. // Writes inside a frame listener commit at its end, once.
const commits = h.cesium const commits = () => engine.fake.moverSet(listed.id).commits;
? null
: () => engine.fake.moverSet(listed.id).commits;
const before = commits?.(); const before = commits?.();
const remove = display.view.on('preRender', () => { const remove = display.view.on('preRender', () => {
sprite.color = WHITE.withAlpha(0.45); sprite.color = WHITE.withAlpha(0.45);
@@ -210,12 +161,7 @@ for (const makeHarness of HARNESSES) {
remove(); remove();
if (commits) assert.equal(commits(), before + 1); if (commits) assert.equal(commits(), before + 1);
assert.equal(sprite.color.alpha, 0.45); assert.equal(sprite.color.alpha, 0.45);
if (h.cesium) { {
const billboard = h.primitive(listed.id).get(0);
near(billboard.color.alpha, 0.45, 'alpha');
near(billboard.scale, 2.4, 'scale × width/20');
assert.equal(billboard.show, false);
} else {
const { store } = engine.fake.moverSet(listed.id); const { store } = engine.fake.moverSet(listed.id);
near(store.colors[3], 0.45, 'alpha'); near(store.colors[3], 0.45, 'alpha');
near(store.scales[0], 2.4, 'scale × width/20'); near(store.scales[0], 2.4, 'scale × width/20');
@@ -233,9 +179,7 @@ for (const makeHarness of HARNESSES) {
await settle(); await settle();
assert.equal(setsOf(engine, 'flights-sprites')[0].count, 1); assert.equal(setsOf(engine, 'flights-sprites')[0].count, 1);
assert.equal(other.gevIndex, 0); assert.equal(other.gevIndex, 0);
if (h.cesium) assert.equal(h.primitive(listed.id).get(0).id, 'def456'); assert.equal(engine.fake.moverSet(listed.id).store.pickIds[0], 'def456');
else
assert.equal(engine.fake.moverSet(listed.id).store.pickIds[0], 'def456');
// Another depth mode is another set. // Another depth mode is another set.
other.disableDepthTestDistance = 0; other.disableDepthTestDistance = 0;
await settle(); await settle();
@@ -288,16 +232,7 @@ for (const makeHarness of HARNESSES) {
await settle(); await settle();
const [listed] = setsOf(engine, 'flights-models'); const [listed] = setsOf(engine, 'flights-models');
assert.equal(listed.count, 1); assert.equal(listed.count, 1);
if (h.cesium) { {
const [instance] = h.loads;
for (let index = 0; index < 12; index++)
near(instance.modelMatrix[index], drawn[index], `drawn ${index}`);
assert.equal(instance.show, false, 'admitted hidden');
assert.equal(instance.id, 'abc123');
assert.equal(model.ready, false, 'until the model has loaded');
instance.ready = true;
assert.equal(model.ready, true);
} else {
const q = Cesium.Quaternion.fromRotationMatrix( const q = Cesium.Quaternion.fromRotationMatrix(
Cesium.Matrix4.getMatrix3(drawn, new Cesium.Matrix3()), Cesium.Matrix4.getMatrix3(drawn, new Cesium.Matrix3()),
); );
@@ -311,13 +246,11 @@ for (const makeHarness of HARNESSES) {
} }
model.show = true; model.show = true;
await settle(); await settle();
if (h.cesium) assert.equal(h.loads[0].show, true); assert.equal(
else engine.fake.moverSet(listed.id).store.shows[0],
assert.equal( 1,
engine.fake.moverSet(listed.id).store.shows[0], 'shown once placed',
1, );
'shown once placed',
);
assert.ok(model.computedScale >= model.scale); assert.ok(model.computedScale >= model.scale);
// A per-model nose offset (military per-class specs). // A per-model nose offset (military per-class specs).
display.models.setPose(model, position, 75, 90); display.models.setPose(model, position, 75, 90);
@@ -366,15 +299,6 @@ for (const makeHarness of HARNESSES) {
const [listed] = setsOf(engine, 'flights-tracked'); const [listed] = setsOf(engine, 'flights-tracked');
assert.equal(listed.count, 1); assert.equal(listed.count, 1);
const read = () => { const read = () => {
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
return {
x: billboard.position.x,
alpha: billboard.color.alpha,
rotation: billboard.rotation,
id: billboard.id,
};
}
const { store } = engine.fake.moverSet(listed.id); const { store } = engine.fake.moverSet(listed.id);
return { return {
x: store.positions[0], x: store.positions[0],
@@ -385,15 +309,7 @@ for (const makeHarness of HARNESSES) {
}; };
assert.equal(read().id, handle.id); assert.equal(read().id, handle.id);
near(read().rotation, 0.25, 'rotation'); near(read().rotation, 0.25, 'rotation');
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(billboard.width, 20);
near(billboard.scale, 1.2 * (28 / 20), 'scale × width/20');
}
state.position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000); state.position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000);
state.color = CYAN.withAlpha(0); state.color = CYAN.withAlpha(0);
h.frame(); h.frame();
@@ -409,39 +325,19 @@ for (const makeHarness of HARNESSES) {
assert.equal(following.label.model(), handle.gevLabelModel); assert.equal(following.label.model(), handle.gevLabelModel);
assert.equal(display.follow.isFollowing(handle), true); assert.equal(display.follow.isFollowing(handle), true);
assert.equal(display.follow.current(), handle); assert.equal(display.follow.current(), handle);
if (h.cesium) {
const proxy = h.viewer.trackedEntity;
assert.ok(proxy instanceof Cesium.Entity);
assert.equal(
proxy.trackingReferenceFrame,
Cesium.TrackingReferenceFrame.ENU,
);
assert.equal(proxy.gevTrackedId, 'flights:abc123');
const time = h.viewer.clock.currentTime;
assert.ok(
Cesium.Cartesian3.equals(proxy.viewFrom.getValue(time), {
x: 0,
y: -2400,
z: 1650,
}),
);
}
// The glyph draws where the follow centred in its frame. // The glyph draws where the follow centred in its frame.
const centred = state.position; const centred = state.position;
assert.equal(following.target(), centred); assert.equal(following.target(), centred);
state.position = geo.cartesian3.fromDegrees(-97.5, 30.2, 3000); state.position = geo.cartesian3.fromDegrees(-97.5, 30.2, 3000);
if (h.cesium) { h.frame();
h.frame();
assert.equal(read().x, centred.x, "the follow's sample");
h.frame();
} else h.frame();
assert.equal(read().x, state.position.x); assert.equal(read().x, state.position.x);
display.follow.release(); display.follow.release();
assert.equal(engine.camera.following(), null); assert.equal(engine.camera.following(), null);
assert.equal(display.follow.current(), undefined); assert.equal(display.follow.current(), undefined);
assert.equal(display.follow.isFollowing(handle), false); assert.equal(display.follow.isFollowing(handle), false);
if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined);
handle.resumeAircraftFollow(); handle.resumeAircraftFollow();
assert.equal(display.follow.current(), handle); assert.equal(display.follow.current(), handle);
display.tracked.remove(handle); display.tracked.remove(handle);
@@ -466,10 +362,7 @@ for (const makeHarness of HARNESSES) {
h.frame(); h.frame();
await settle(); await settle();
const [listed] = setsOf(engine, 'flights-tracked'); const [listed] = setsOf(engine, 'flights-tracked');
const drawn = () => const drawn = () => engine.fake.moverSet(listed.id).store.shows[0] === 1;
h.cesium
? h.primitive(listed.id).get(0).show
: engine.fake.moverSet(listed.id).store.shows[0] === 1;
assert.equal(drawn(), true); assert.equal(drawn(), true);
// The Cockpit hides what the engine follows, then releases the follow. // The Cockpit hides what the engine follows, then releases the follow.
const ridden = engine.camera.following().native; const ridden = engine.camera.following().native;
@@ -486,10 +379,7 @@ for (const makeHarness of HARNESSES) {
const moved = setsOf(engine, 'flights-tracked').find( const moved = setsOf(engine, 'flights-tracked').find(
({ count }) => count === 1, ({ count }) => count === 1,
); );
const shownIn = (set) => const shownIn = (set) => engine.fake.moverSet(set.id).store.shows[0] === 1;
h.cesium
? h.primitive(set.id).get(0).show
: engine.fake.moverSet(set.id).store.shows[0] === 1;
assert.equal(shownIn(moved), false); assert.equal(shownIn(moved), false);
ridden.show = true; ridden.show = true;
ridden.resumeAircraftFollow(); ridden.resumeAircraftFollow();
@@ -508,16 +398,8 @@ for (const makeHarness of HARNESSES) {
const remove = display.follow.onChanged(() => const remove = display.follow.onChanged(() =>
changes.push(display.follow.current()), changes.push(display.follow.current()),
); );
const native = h.cesium const native = { gevSelectionOrigin: 'user' };
? new Cesium.Entity({
position: geo.cartesian3.fromDegrees(0, 0, 400_000),
viewFrom: new Cesium.Cartesian3(0, -900, 400),
})
: { gevSelectionOrigin: 'user' };
if (h.cesium) {
native.gevSelectionOrigin = 'user';
h.viewer.entities.add(native);
}
engine.camera.follow({ engine.camera.follow({
native, native,
position: () => geo.cartesian3.fromDegrees(0, 0, 400_000), position: () => geo.cartesian3.fromDegrees(0, 0, 400_000),
@@ -545,19 +427,6 @@ for (const makeHarness of HARNESSES) {
assert.equal(body.points, 2, 'near-duplicates dropped'); assert.equal(body.points, 2, 'near-duplicates dropped');
const base = Cesium.Color.fromCssColorString('#00d4ff'); const base = Cesium.Color.fromCssColorString('#00d4ff');
const alphas = (id) => { const alphas = (id) => {
if (h.cesium) {
const primitive = h.primitive(id);
assert.equal(primitive.allowPicking, true);
assert.equal(
primitive.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
assert.equal(primitive.geometryInstances.id, id);
return [
primitive.appearance.material.uniforms.color.alpha,
primitive.depthFailAppearance.material.uniforms.color.alpha,
];
}
const { options } = engine.fake.line(id); const { options } = engine.fake.line(id);
assert.equal(options.arc, 'geodesic'); assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, id); assert.equal(options.pickId, id);
@@ -567,10 +436,7 @@ for (const makeHarness of HARNESSES) {
const [bodyAlpha, bodyHidden] = alphas(body.id); const [bodyAlpha, bodyHidden] = alphas(body.id);
near(bodyAlpha, 0.85, 'trail alpha'); near(bodyAlpha, 0.85, 'trail alpha');
near(bodyHidden, 0.4, 'trail occluded alpha'); near(bodyHidden, 0.4, 'trail occluded alpha');
if (h.cesium) {
const { color } = h.primitive(body.id).appearance.material.uniforms;
near(color.red, base.red, 'trail red');
}
trail.setPositions([a]); trail.setPositions([a]);
assert.equal(engine.lines.list()[0].points, 0, 'one point draws nothing'); assert.equal(engine.lines.list()[0].points, 0, 'one point draws nothing');
trail.setVisible(false); trail.setVisible(false);
@@ -785,66 +651,3 @@ test('reused sprite field records are copied, including a mutated color getter',
display.destroy(); display.destroy();
destroy(); destroy();
}); });
test("model loads on Cesium carry the layer's look; the IR boost is unlit; readiness is per model", async () => {
const h = await setup(cesiumHarness);
const { display, loads } = h;
const spec = {
url: '/models/airplane.glb',
minimumPixelSize: 24,
scale: 1,
color: CYAN,
blendAmount: 0.94,
unlit: false,
id: 'abc123',
};
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
const fleet = await display.models.loadFleet(spec);
const second = await display.models.loadFleet({ ...spec, id: 'def456' });
const boosted = await display.models.loadTracked({
...spec,
unlit: true,
minimumPixelSize: 40,
id: 'ghi789',
});
for (const model of [fleet, second, boosted]) {
display.models.add(model);
display.models.setPose(model, position, 10);
}
await settle();
assert.equal(loads.length, 3, 'one Cesium model per instance');
const [first, other, unlit] = loads;
for (const { options } of loads) {
assert.equal(options.asynchronous, false);
assert.deepEqual(options.environmentMapOptions, { enabled: false });
assert.equal(options.url, '/models/airplane.glb');
assert.equal(options.colorBlendMode, Cesium.ColorBlendMode.MIX);
assert.equal(options.colorBlendAmount, 0.94);
assert.equal(options.allowPicking, true);
}
assert.equal(first.options.minimumPixelSize, 24);
assert.equal(first.options.customShader, undefined);
assert.equal(unlit.options.minimumPixelSize, 40);
assert.equal(
unlit.options.customShader.lightingModel,
Cesium.LightingModel.UNLIT,
);
assert.equal(first.id, 'abc123', 'picks name the aircraft');
assert.equal(unlit.id, 'ghi789');
assert.ok(first.color.equalsEpsilon(Cesium.Color.CYAN, 1e-6));
// Readiness is each model's own: one loading model leaves the others up.
first.ready = true;
unlit.ready = true;
assert.equal(fleet.ready, true);
assert.equal(second.ready, false);
assert.equal(boosted.ready, true);
other.ready = true;
assert.equal(second.ready, true);
// A removed model's place goes to the last one, ready with its model.
display.models.remove(fleet);
await settle();
assert.equal(second.gevIndex, 0);
assert.equal(second.ready, true);
display.destroy();
h.destroy();
});
+17 -26
View File
@@ -1,3 +1,7 @@
import {
createSelectionKeeper,
writeSelectedMarker,
} from '../../shared/selectionKeeper.js';
// The ALPR camera badges on the globe contract (engine.movers, engine.input, // The ALPR camera badges on the globe contract (engine.movers, engine.input,
// engine.camera, engine.projector, engine.surface, engine.credits), the // engine.camera, engine.projector, engine.surface, engine.credits), the
// same on every engine. Each displayed camera is a plain marker handle: a // same on every engine. Each displayed camera is a plain marker handle: a
@@ -14,7 +18,6 @@
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { distanceScale } from '../../../globe/cpuPicks.js'; import { distanceScale } from '../../../globe/cpuPicks.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { alprCreditMarkup } from '../model.js'; import { alprCreditMarkup } from '../model.js';
import { createAlprOverlayCore } from '../overlay.js'; import { createAlprOverlayCore } from '../overlay.js';
@@ -196,8 +199,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
}; };
normal.set(marker.index, { ...fields, show: show && !isSelected }); normal.set(marker.index, { ...fields, show: show && !isSelected });
if (isSelected) { if (isSelected) {
selected.setCount(1); writeSelectedMarker(selected, fields);
selected.set(0, fields);
} }
// The overlay changes ownership while it paints, after the frame's // The overlay changes ownership while it paints, after the frame's
// render request was taken: wake the next frame, which commits. // render request was taken: wake the next frame, which commits.
@@ -215,7 +217,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
} }
function dropSelectedSprite() { function dropSelectedSprite() {
if (!initialized) return; if (!initialized) return;
selected.setCount(0); writeSelectedMarker(selected, null);
dirty = true; dirty = true;
} }
@@ -378,7 +380,6 @@ export function createAlprDisplay(engine, { state, services, source }) {
function renderRecords() { function renderRecords() {
if (!initialized) return; if (!initialized) return;
const selectedContext = getSelectedEntityContext();
// Over the render cap, keep the cameras nearest the view centre and say // Over the render cap, keep the cameras nearest the view centre and say
// coverage is limited rather than dropping an arbitrary subset. // coverage is limited rather than dropping an arbitrary subset.
const { visible: shown, capped } = viewRecords(state.records); const { visible: shown, capped } = viewRecords(state.records);
@@ -386,13 +387,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
visibleRecords = shown; visibleRecords = shown;
// A refresh may retain its own selection, never reclaim one cleared or // A refresh may retain its own selection, never reclaim one cleared or
// replaced by an aircraft, another layer, or a voice action. // replaced by an aircraft, another layer, or a voice action.
if ( if (!selection.reconcile({ layerId: LAYER_ID, records: shown })) {
selectedContext?.layerId !== LAYER_ID ||
selectedContext.id !== state.selectedId ||
!shown.some((record) => record.id === state.selectedId)
) {
state.selectedId = null;
selectionStartedAt = 0;
dropSelectedSprite(); dropSelectedSprite();
clearSelectedEntityContextForLayer(LAYER_ID); clearSelectedEntityContextForLayer(LAYER_ID);
} }
@@ -595,21 +590,17 @@ export function createAlprDisplay(engine, { state, services, source }) {
return true; return true;
} }
function onClick({ position, picked }) { const selection = createSelectionKeeper({
// A tool owns the pointer (src/data/inputOwnership.js): yield the click. state,
if (!isPointerFree()) return; getSelectedEntityContext,
if (!state.enabled) return; selectRecord,
const id = clearSelection,
pickId: ({ position, picked }) =>
overlay.pick(position) || overlay.pick(position) ||
(typeof picked?.id === 'string' ? picked.id : null); (typeof picked?.id === 'string' ? picked.id : null),
if ( reselectContext: true,
id && });
state.recordById.has(id) && const onClick = selection.onClick;
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
)
selectRecord(id);
else if (state.selectedId) clearSelection();
}
// Badges move on screen with the camera: their horizon test and a pick // Badges move on screen with the camera: their horizon test and a pick
// target's size follow it. // target's size follow it.
+10 -107
View File
@@ -1,20 +1,14 @@
// The ALPR badges on the engine contract, on the fake engine and on the // Layer display behavior on the fake engine.
// Cesium engine (real Cesium billboards over a stub scene: they draw
// nothing but hold what the engine writes): the badge sets as the layer
// styles them, floors and the horizon, clicks, selection and its readout
// anchor, the render cap, credits, the nearest-camera flight, the canvas
// overlay's cohort and pick targets, and what toggles and teardown leave.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { setLocale } from '../../../i18n/index.js'; import { setLocale } from '../../../i18n/index.js';
import { shownEntityLabel } from '../../../voice/speech.js'; import { shownEntityLabel } from '../../../voice/speech.js';
import { distanceScale } from '../../../globe/cpuPicks.js'; import { distanceScale } from '../../../globe/cpuPicks.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { MARKER_SCALE_BY_DISTANCE } from '../policy.js'; import { MARKER_SCALE_BY_DISTANCE } from '../policy.js';
import { directionWedgePositions } from '../visuals.js'; import { directionWedgePositions } from '../visuals.js';
import { createAlprDisplay } from './display.js'; import { createAlprDisplay } from './display.js';
@@ -69,81 +63,7 @@ function fakeHarness() {
}; };
} }
function cesiumHarness() { const HARNESSES = [fakeHarness];
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive, frame } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
const credits = [];
viewer.creditDisplay = {
addStaticCredit: (credit) =>
credits.push({
html: credit.html,
onScreen: credit.showOnScreen,
credit,
}),
removeStaticCredit(credit) {
const at = credits.findIndex((entry) => entry.credit === credit);
if (at >= 0) credits.splice(at, 1);
},
};
viewer.scene.globe.getHeight = () => undefined;
// What a camera flight needs of the scene.
viewer.scene.tweens = new Cesium.TweenCollection();
viewer.scene.screenSpaceCameraController = {
minimumZoomDistance: 1,
maximumZoomDistance: Number.POSITIVE_INFINITY,
};
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
primitive,
frame,
click(target) {
native =
target?.owner && primitive(target.owner)
? { primitive: primitive(target.owner).get(target.instance) }
: target
? { id: target.id, primitive: {} }
: undefined;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(-50, -50),
});
},
credits: () => credits,
surface(height) {
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => height;
},
terrain(height) {
viewer.scene.globe.getHeight = () => height;
},
badge(id, index) {
const billboard = primitive(id).get(index);
return {
shown: index < count(id) && billboard.show,
sizePx: billboard.width,
alpha: billboard.color.alpha,
scale: billboard.scale,
id: billboard.id,
position: billboard.position,
native: billboard,
};
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function services() { function services() {
const contexts = new Map(); const contexts = new Map();
@@ -252,23 +172,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(badge.id, 'alpr:0', 'a pick returns the camera id'); assert.equal(badge.id, 'alpr:0', 'a pick returns the camera id');
assert.equal(badge.alpha, 1); assert.equal(badge.alpha, 1);
assert.ok(Math.abs(heightOf(badge.position) - 151.5) < 0.01); assert.ok(Math.abs(heightOf(badge.position) - 151.5) < 0.01);
if (h.cesium) { {
const billboard = badge.native;
assert.ok(
h.primitive('alpr-cameras') instanceof Cesium.BillboardCollection,
);
assert.equal(billboard.height, 38);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.ok(
Cesium.NearFarScalar.equals(
billboard.scaleByDistance,
new Cesium.NearFarScalar(...MARKER_SCALE_BY_DISTANCE),
),
);
} else {
for (const [id, size, url] of [ for (const [id, size, url] of [
['alpr-cameras', 38, /alpr-marker-normal\.png$/], ['alpr-cameras', 38, /alpr-marker-normal\.png$/],
['alpr-selected', 60, /alpr-marker-selected\.png$/], ['alpr-selected', 60, /alpr-marker-selected\.png$/],
@@ -393,11 +297,10 @@ for (const makeHarness of HARNESSES) {
h.load(records(1600)); h.load(records(1600));
assert.equal(h.display.count(), 1500); assert.equal(h.display.count(), 1500);
assert.equal(h.state.renderSaturated, true); assert.equal(h.state.renderSaturated, true);
if (!h.cesium) assert.ok(
assert.ok( h.engine.fake.moverSet('alpr-cameras').commits < 10,
h.engine.fake.moverSet('alpr-cameras').commits < 10, 'one buffer commit per batch, not per marker',
'one buffer commit per batch, not per marker', );
);
const credit = h.credits().find((c) => /Fixture cameras/.test(c.html)); const credit = h.credits().find((c) => /Fixture cameras/.test(c.html));
assert.ok(credit, 'the source credit shows with the cameras'); assert.ok(credit, 'the source credit shows with the cameras');
assert.equal(credit.onScreen, true); assert.equal(credit.onScreen, true);
@@ -416,7 +319,7 @@ for (const makeHarness of HARNESSES) {
follow.stop(); follow.stop();
assert.equal(h.display.focusNearest(), true); assert.equal(h.display.focusNearest(), true);
assert.equal(h.services.selected.id, 'alpr:0'); assert.equal(h.services.selected.id, 'alpr:0');
if (!h.cesium) { {
const [{ args }] = h.engine.fake.callsOf('camera.flyToSphere'); const [{ args }] = h.engine.fake.callsOf('camera.flyToSphere');
assert.equal(args[0].radius, 30); assert.equal(args[0].radius, 30);
assert.ok(Math.abs(heightOf(args[0].center) - 0) < 0.01, 'no ground'); assert.ok(Math.abs(heightOf(args[0].center) - 0) < 0.01, 'no ground');
+18 -64
View File
@@ -1,30 +1,14 @@
// The Global Context display on the engine contract, on the fake engine and // Layer display behavior on the fake engine.
// on the Cesium engine: the camera it reads, the followed contact, a contact
// flight, its frames, the ring lines and the pulse field.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeGroundHarness } from '../../../testSupport/groundDisplayEngines.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { createAwarenessDisplay, RING_HALO_ID, RING_ID } from './display.js'; import { createAwarenessDisplay, RING_HALO_ID, RING_ID } from './display.js';
const HARNESSES = [ const HARNESSES = [createFakeGroundHarness];
() => ({ name: 'the fake engine', engine: createFakeEngine() }),
() => {
const { engine, viewer, primitive } = createCesiumMoversEngine();
// A viewer's camera controller, whose inputs the engine switches.
viewer.scene.screenSpaceCameraController = { enableInputs: true };
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
primitive,
};
},
];
for (const makeHarness of HARNESSES) { for (const makeHarness of HARNESSES) {
const { name } = makeHarness(); const { name } = makeHarness();
@@ -81,10 +65,7 @@ for (const makeHarness of HARNESSES) {
ends = 0; ends = 0;
const offFrame = display.onFrame(() => frames++); const offFrame = display.onFrame(() => frames++);
const offEnd = display.onFrameEnd(() => ends++); const offEnd = display.onFrameEnd(() => ends++);
if (h.cesium) { h.engine.fake.frame();
h.viewer.scene.preRender.raiseEvent(h.viewer.scene);
h.viewer.scene.postRender.raiseEvent(h.viewer.scene);
} else h.engine.fake.frame();
assert.deepEqual([frames, ends], [1, 1]); assert.deepEqual([frames, ends], [1, 1]);
offFrame(); offFrame();
offEnd(); offEnd();
@@ -112,46 +93,19 @@ for (const makeHarness of HARNESSES) {
const center = geo.cartesian3.fromDegrees(3, 27, 0); const center = geo.cartesian3.fromDegrees(3, 27, 0);
const rim = display.createRim(center); const rim = display.createRim(center);
const disc = display.createPulses(center); const disc = display.createPulses(center);
if (h.cesium) {
const core = h.primitive(RING_ID); assert.equal(h.engine.fake.line(RING_ID).options.widthPx, 3);
const halo = h.primitive(RING_HALO_ID); assert.equal(h.engine.fake.line(RING_HALO_ID).options.widthPx, 4);
assert.equal(core.geometryInstances.geometry._width, 3); assert.ok(disc);
assert.equal(halo.geometryInstances.geometry._width, 4); disc.draw({ radii: [0.5], alphas: [0.4], level: 1 });
assert.equal(core.allowPicking, false); const field = h.engine.fake.groundPulse('gev-awareness-pulses');
assert.ok(disc, 'the Cesium backend draws the pulse disc'); assert.deepEqual(field.radii, [0.5, 0, 0, 0]);
const primitives = Array.from( assert.deepEqual(field.alphas, [0.4, 0, 0, 0]);
{ length: h.viewer.scene.primitives.length }, disc.destroy();
(_, i) => h.viewer.scene.primitives.get(i), assert.equal(h.engine.fake.groundPulse('gev-awareness-pulses'), undefined);
);
const shown = primitives.find(
(p) => p.appearance instanceof Cesium.MaterialAppearance,
);
assert.equal(shown.allowPicking, false);
disc.draw({ radii: [0.5, 0, 0, 0], alphas: [0.4, 0, 0, 0], level: 1 });
assert.equal(shown.show, true);
assert.equal(shown.appearance.material.uniforms.radii.x, 0.5);
disc.draw({ radii: [0, 0, 0, 0], alphas: [0, 0, 0, 0], level: 0 });
assert.equal(shown.show, false);
disc.destroy();
assert.ok(shown.isDestroyed());
} else {
assert.equal(h.engine.fake.line(RING_ID).options.widthPx, 3);
assert.equal(h.engine.fake.line(RING_HALO_ID).options.widthPx, 4);
assert.ok(disc);
disc.draw({ radii: [0.5], alphas: [0.4], level: 1 });
const field = h.engine.fake.groundPulse('gev-awareness-pulses');
assert.deepEqual(field.radii, [0.5, 0, 0, 0]);
assert.deepEqual(field.alphas, [0.4, 0, 0, 0]);
disc.destroy();
assert.equal(
h.engine.fake.groundPulse('gev-awareness-pulses'),
undefined,
);
}
rim.setAlpha(0.4); rim.setAlpha(0.4);
const alpha = h.cesium const alpha = h.engine.fake.line(RING_ID).color[3];
? h.primitive(RING_ID).appearance.material.uniforms.color.alpha
: h.engine.fake.line(RING_ID).color[3];
assert.ok(Math.abs(alpha - 0.95 * 0.4) < 1e-9); assert.ok(Math.abs(alpha - 0.95 * 0.4) < 1e-9);
rim.destroy(); rim.destroy();
assert.equal( assert.equal(
+3 -19
View File
@@ -56,8 +56,7 @@ for (const make of GROUND_HARNESSES) {
}); });
const before = h.engine.frames.preRender.numberOfListeners; const before = h.engine.frames.preRender.numberOfListeners;
const display = drawStorySource(h.engine, source); const display = drawStorySource(h.engine, source);
const frame = () => const frame = () => h.engine.fake.frame();
h.cesium ? h.viewer.scene.preRender.raiseEvent() : h.engine.fake.frame();
frame(); frame();
assert.equal(h.sets(), 3); assert.equal(h.sets(), 3);
const haloSet = h.set('story-halo'); const haloSet = h.set('story-halo');
@@ -87,21 +86,7 @@ for (const make of GROUND_HARNESSES) {
}); });
assert.equal(h.set('story-fill').items[0].rings.length, 2); assert.equal(h.set('story-fill').items[0].rings.length, 2);
assert.equal(h.set('story-fill').items[0].fill[3], 0.055); assert.equal(h.set('story-fill').items[0].fill[3], 0.055);
if (h.cesium) {
const native = h.primitives('story-halo').lines[0].options;
assert.equal(
native.asynchronous,
false,
'reveal geometry builds within the frame',
);
assert.equal(native.appearance.material.type, 'PolylineDash');
assert.equal(native.appearance.material.uniforms.dashLength, 18);
const core =
h.primitives('story-core').lines[0].options.appearance.material
.uniforms;
assert.equal(core.glowPower, 0.24);
assert.equal(core.taperPower, 0.7);
}
frame(); frame();
assert.equal( assert.equal(
h.set('story-halo').commits, h.set('story-halo').commits,
@@ -186,8 +171,7 @@ for (const make of GROUND_HARNESSES) {
return 1200; return 1200;
}; };
const display = drawStorySource(h.engine, source); const display = drawStorySource(h.engine, source);
const frame = () => const frame = () => h.engine.fake.frame();
h.cesium ? h.viewer.scene.preRender.raiseEvent() : h.engine.fake.frame();
frame(); frame();
assert.equal(options.kind, 'point'); assert.equal(options.kind, 'point');
assert.equal(options.depthTest, 'always'); assert.equal(options.depthTest, 'always');
+13 -9
View File
@@ -1,3 +1,4 @@
import { writeSelectedMarker } from '../../shared/selectionKeeper.js';
// The bikeshare stations on the globe contract (engine.movers, engine.input, // The bikeshare stations on the globe contract (engine.movers, engine.input,
// engine.camera, engine.surface), the same on every engine: // engine.camera, engine.surface), the same on every engine:
// - rendering: one point per station in one point set, sized by capacity // - rendering: one point per station in one point set, sized by capacity
@@ -120,15 +121,18 @@ export function createBikeshareDisplay(engine, context) {
const key = state._selectedKey; const key = state._selectedKey;
const record = key ? state._stationRenderMap.get(key) : null; const record = key ? state._stationRenderMap.get(key) : null;
const shown = Boolean(record?.point?.position); const shown = Boolean(record?.point?.position);
if (shown) writeSelectedMarker(
highlight.set(0, { highlight,
position: record.point.position, shown
color: SELECTED_COLOR.rgba(), ? {
scale: 1, position: record.point.position,
show: true, color: SELECTED_COLOR.rgba(),
pickId: key, scale: 1,
}); show: true,
highlight.setCount(shown ? 1 : 0); pickId: key,
}
: null,
);
highlight.commit(); highlight.commit();
} }
// The stations show and hide together; the highlight goes with the // The stations show and hide together; the highlight goes with the
+34 -124
View File
@@ -1,17 +1,11 @@
// The bikeshare stations on the engine contract, on the fake engine and on // Layer display behavior on the fake engine.
// the Cesium engine (real Cesium point collections over a stub scene: they
// draw nothing but hold what the engine writes): the station discs and the
// selected highlight as the layer styles them, their height on the sampled
// surface, the click and Escape rules, the camera's change listener and
// sensitivity claim, and what enable/disable cycles and teardown leave.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { createBikeshareLayer } from '../index.js'; import { createBikeshareLayer } from '../index.js';
import { capacityToPixelSize } from '../presentation.js'; import { capacityToPixelSize } from '../presentation.js';
import { COLOR_GREEN, COLOR_OUTLINE, COLOR_RED } from '../policy.js'; import { COLOR_GREEN, COLOR_OUTLINE, COLOR_RED } from '../policy.js';
@@ -79,57 +73,7 @@ function fakeHarness() {
}; };
} }
function cesiumHarness() { const HARNESSES = [fakeHarness];
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
// A Cesium pick of `picked`'s set and instance: its point primitive.
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
return {
name: 'the Cesium engine',
cesium: true,
engine,
primitive,
click(picked) {
native = undefined;
if (picked?.owner?.startsWith('bikeshare'))
native = { primitive: primitive(picked.owner).get(picked.instance) };
else if (picked) native = { id: picked.id, primitive: {} };
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
},
surface(height) {
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => height;
},
point: (id, index) => {
const point = primitive(id).get(index);
return {
shown: index < count(id) && point.show,
pixelSize: point.pixelSize,
color: [
point.color.red,
point.color.green,
point.color.blue,
point.color.alpha,
],
id: point.id,
native: point,
};
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function fixture(makeHarness, getStations) { function fixture(makeHarness, getStations) {
const h = makeHarness(); const h = makeHarness();
@@ -204,47 +148,20 @@ for (const makeHarness of HARNESSES) {
COLOR_GREEN.rgba().forEach((value, i) => COLOR_GREEN.rgba().forEach((value, i) =>
near(station.color[i], value, `colour ${i}`), near(station.color[i], value, `colour ${i}`),
); );
if (f.cesium) {
const collection = f.primitive('bikeshare'); const { options } = f.engine.fake.moverSet('bikeshare');
assert.ok(collection instanceof Cesium.PointPrimitiveCollection); assert.equal(options.pickable, true);
const point = station.native; assert.equal(options.sharpen, true);
assert.equal(point.outlineWidth, 1); assert.equal(options.order, 3, 'its data/spriteOrder slot');
assert.ok( assert.deepEqual(options.depthTest, { disableWithinM: 2500 });
point.outlineColor.equalsEpsilon( assert.equal(options.point.outlinePx, 1);
new Cesium.Color(...COLOR_OUTLINE.rgba()), assert.deepEqual(options.point.outlineColor, COLOR_OUTLINE.rgba());
1e-6, assert.deepEqual(options.point.scaleByDistance, [200, 1.35, 130000, 0.4]);
), assert.deepEqual(
); options.point.translucencyByDistance,
assert.ok( [200, 1, 180000, 0.15],
Cesium.NearFarScalar.equals( );
point.scaleByDistance,
new Cesium.NearFarScalar(200, 1.35, 130000, 0.4),
),
);
assert.ok(
Cesium.NearFarScalar.equals(
point.translucencyByDistance,
new Cesium.NearFarScalar(200, 1, 180000, 0.15),
),
);
assert.equal(point.disableDepthTestDistance, 2500);
} else {
const { options } = f.engine.fake.moverSet('bikeshare');
assert.equal(options.pickable, true);
assert.equal(options.sharpen, true);
assert.equal(options.order, 3, 'its data/spriteOrder slot');
assert.deepEqual(options.depthTest, { disableWithinM: 2500 });
assert.equal(options.point.outlinePx, 1);
assert.deepEqual(options.point.outlineColor, COLOR_OUTLINE.rgba());
assert.deepEqual(
options.point.scaleByDistance,
[200, 1.35, 130000, 0.4],
);
assert.deepEqual(
options.point.translucencyByDistance,
[200, 1, 180000, 0.15],
);
}
f.setBikes(1); f.setBikes(1);
await f.layer.update(); await f.layer.update();
COLOR_RED.rgba().forEach((value, i) => COLOR_RED.rgba().forEach((value, i) =>
@@ -267,22 +184,16 @@ for (const makeHarness of HARNESSES) {
assert.equal(highlight.shown, true); assert.equal(highlight.shown, true);
near(highlight.pixelSize, 14, 'highlight size'); near(highlight.pixelSize, 14, 'highlight size');
assert.deepEqual(highlight.color, [0, 1, 1, 1]); assert.deepEqual(highlight.color, [0, 1, 1, 1]);
if (f.cesium) {
const point = highlight.native; const { options } = f.engine.fake.moverSet('bikeshare-selected');
assert.equal(point.outlineWidth, 2); assert.equal(options.depthTest, 'always');
assert.ok(point.outlineColor.equals(Cesium.Color.BLACK)); assert.equal(options.point.scaleByDistance, null);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY); assert.deepEqual(options.point.outlineColor, [0, 0, 0, 1]);
assert.equal(point.scaleByDistance, undefined); assert.ok(
} else { options.order > f.engine.fake.moverSet('bikeshare').options.order,
const { options } = f.engine.fake.moverSet('bikeshare-selected'); 'over the layer’s own points',
assert.equal(options.depthTest, 'always'); );
assert.equal(options.point.scaleByDistance, null);
assert.deepEqual(options.point.outlineColor, [0, 0, 0, 1]);
assert.ok(
options.order > f.engine.fake.moverSet('bikeshare').options.order,
'over the layer’s own points',
);
}
assert.equal( assert.equal(
f.entries.get('bikeshare-selected')[0].title, f.entries.get('bikeshare-selected')[0].title,
'中央 / Central', '中央 / Central',
@@ -360,13 +271,12 @@ for (const makeHarness of HARNESSES) {
await settle(); await settle();
const point = f.layer.getDetectableObjects()[0].position; const point = f.layer.getDetectableObjects()[0].position;
near(geo.cartographic.fromCartesian(point).height, 177, 'height'); near(geo.cartographic.fromCartesian(point).height, 177, 'height');
if (!f.cesium) {
const call = f.engine.fake.callsOf('surface.sampleHeight')[0]; const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
assert.deepEqual( assert.deepEqual(
call.args[1].map((set) => set.id), call.args[1].map((set) => set.id),
['bikeshare', 'bikeshare-selected'], ['bikeshare', 'bikeshare-selected'],
); );
}
} finally { } finally {
f.layer.destroy(); f.layer.destroy();
} }
+11 -17
View File
@@ -17,7 +17,10 @@
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js'; import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js';
import { CAMERA_ICON, IDLE_CAMERA_COLOR } from '../policy.js'; import { CAMERA_ICON, IDLE_CAMERA_COLOR } from '../policy.js';
import { bindCctvWorldClickGesture } from '../worldClick.js'; import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../../data/trackingGesture.js';
import { createCctvMeshes } from './meshes.js'; import { createCctvMeshes } from './meshes.js';
import { import {
COVERAGE_DASH, COVERAGE_DASH,
@@ -40,13 +43,6 @@ import {
const GIZMO_POINT_SET_ID = `${GIZMO_ID_PREFIX}points`; const GIZMO_POINT_SET_ID = `${GIZMO_ID_PREFIX}points`;
const GIZMO_POINT_CAPACITY = 16; const GIZMO_POINT_CAPACITY = 16;
// The tracking click gesture's handler types, as engine.input types.
const CLICK_EVENT_TYPES = Object.freeze({
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
});
const CORNERS = ['tl', 'tr', 'br', 'bl']; const CORNERS = ['tl', 'tr', 'br', 'bl'];
const capRing = (p) => [p.tl, p.tr, p.br, p.bl, p.tl]; const capRing = (p) => [p.tl, p.tr, p.br, p.bl, p.tl];
const sameDash = (a, b) => const sameDash = (a, b) =>
@@ -617,17 +613,15 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) {
onMoveStart: (fn) => onMoveStart: (fn) =>
ownListener(engine.camera.moveStart.addEventListener(fn)), ownListener(engine.camera.moveStart.addEventListener(fn)),
bindClick(onClick, options = {}) { bindClick(onClick, options = {}) {
const removes = []; return ownListener(
bindCctvWorldClickGesture( bindTrackingClickGesture(
{ engine.input,
setInputAction(fn, type) { (click, gesture) => {
removes.push(engine.input.on(type, fn)); if (isTrackingClickGesture(gesture)) onClick(click);
}, },
}, options,
onClick, ),
{ ...options, eventTypes: CLICK_EVENT_TYPES },
); );
return ownListener(() => removes.forEach((remove) => remove()));
}, },
createGizmoRenderer() { createGizmoRenderer() {
const gizmo = createGizmoRenderer(); const gizmo = createGizmoRenderer();
+8 -120
View File
@@ -1,14 +1,8 @@
// The CCTV display on the engine contract, on the fake engine and on the // Layer display behavior on the fake engine.
// Cesium engine (Cesium's own collections, primitives and entities over a
// stub scene: they draw nothing but hold what the engine writes): the
// marker set and its batched writes, the coverage wedge lines and their
// styles, picks that need this layer's own objects, the monitor plane and
// its outline, the viewshed volume, the activation probe's ray, the world
// click, the credit, focus and teardown.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { viewshedColors } from '../../../data/cctvViewshed.js'; import { viewshedColors } from '../../../data/cctvViewshed.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
@@ -90,30 +84,7 @@ for (const makeHarness of CCTV_HARNESSES) {
h.display.setMarkersVisible(false); h.display.setMarkersVisible(false);
h.display.restoreOrder(); h.display.restoreOrder();
await h.settle(); await h.settle();
if (h.cesium) { {
const billboards = h.primitive('cctv');
const billboard = billboards.get(0);
assert.equal(billboard.width, 24);
assert.equal(billboard.height, 24);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.deepEqual(
[
billboard.scaleByDistance.near,
billboard.scaleByDistance.nearValue,
billboard.scaleByDistance.far,
billboard.scaleByDistance.farValue,
],
[350, 1.25, 4_000_000, 0.42],
);
assert.equal(billboard.id, 'a');
assert.equal(billboard.scale, 1.25);
near(billboard.color.alpha, ACTIVE_CAMERA_COLOR.alpha, 'alpha');
assert.equal(billboard.show, false);
assert.equal(billboards.get(1).show, false);
} else {
const set = h.engine.fake.moverSet('cctv'); const set = h.engine.fake.moverSet('cctv');
assert.equal(set.options.order, 0, 'the bottom of the contact sprites'); assert.equal(set.options.order, 0, 'the bottom of the contact sprites');
assert.equal(set.options.depthTest, 'always'); assert.equal(set.options.depthTest, 'always');
@@ -178,14 +149,7 @@ for (const makeHarness of CCTV_HARNESSES) {
h.display.updateCoverage({ coverageEntities: lines }, moved); h.display.updateCoverage({ coverageEntities: lines }, moved);
assert.deepEqual(lines[0].polyline.positions, [moved.mount, moved.tl]); assert.deepEqual(lines[0].polyline.positions, [moved.mount, moved.tl]);
assert.equal(lines[4].polyline.positions[0], moved.tl); assert.equal(lines[4].polyline.positions[0], moved.tl);
if (h.cesium) { {
const drawn = h.primitive('cctv-a-cap');
assert.equal(
drawn.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
assert.equal(drawn.geometryInstances.geometry._width, 1.2);
} else {
const line = h.lineState('cctv-a-cap'); const line = h.lineState('cctv-a-cap');
assert.equal(line.widthPx, 1.2); assert.equal(line.widthPx, 1.2);
assert.equal(line.arc, 'geodesic'); assert.equal(line.arc, 'geodesic');
@@ -213,21 +177,7 @@ for (const makeHarness of CCTV_HARNESSES) {
dashed: true, dashed: true,
depthFail: hue.line.withAlpha(0.18), depthFail: hue.line.withAlpha(0.18),
}); });
if (h.cesium) { {
const drawn = h.primitive(edge.id);
assert.equal(drawn.appearance.material.type, 'PolylineDash');
near(
drawn.appearance.material.uniforms.color.alpha,
hue.lineActive.alpha,
'line alpha',
);
near(
drawn.depthFailAppearance.material.uniforms.color.alpha,
0.18,
'depth-fail alpha',
);
assert.equal(drawn.geometryInstances.geometry._width, 1.8);
} else {
const line = h.lineState(edge.id); const line = h.lineState(edge.id);
assert.deepEqual(line.dash, { kind: 'pixels', lengthPx: 8, gapPx: 8 }); assert.deepEqual(line.dash, { kind: 'pixels', lengthPx: 8, gapPx: 8 });
assert.equal(line.widthPx, 1.8); assert.equal(line.widthPx, 1.8);
@@ -237,12 +187,7 @@ for (const makeHarness of CCTV_HARNESSES) {
style(cap, { visible: false, width: 1, tint: IDLE_COVERAGE_CENTER_MUTED }); style(cap, { visible: false, width: 1, tint: IDLE_COVERAGE_CENTER_MUTED });
assert.equal(cap.show, false); assert.equal(cap.show, false);
style(edge, { width: 0.9, tint: IDLE_COVERAGE_EDGE_MUTED }); style(edge, { width: 0.9, tint: IDLE_COVERAGE_EDGE_MUTED });
if (h.cesium) { {
const drawn = h.primitive(edge.id);
assert.equal(drawn.appearance.material.type, 'Color');
assert.equal(drawn.depthFailAppearance, undefined);
assert.equal(drawn.geometryInstances.geometry._width, 0.9);
} else {
const line = h.lineState(edge.id); const line = h.lineState(edge.id);
assert.equal(line.dash, null); assert.equal(line.dash, null);
assert.equal(line.depthFailAlpha, 0); assert.equal(line.depthFailAlpha, 0);
@@ -354,23 +299,7 @@ for (const makeHarness of CCTV_HARNESSES) {
); );
near(geo.cartesian3.distance(corners[index], expected), 0, 'corner'); near(geo.cartesian3.distance(corners[index], expected), 0, 'corner');
} }
if (h.cesium) { {
const primitive = h.primitive('cctv-a-plane');
assert.equal(primitive.show, true);
assert.equal(primitive.appearance.material.uniforms.color.alpha, 0.95);
assert.equal(primitive.allowPicking, true);
h.scriptPicks({ native: { id: 'cctv-a-plane', primitive } });
assert.equal(
h.display.extractPickedCameraId(h.display.pick({ x: 1, y: 1 })),
null,
'a plane no record owns is not a camera',
);
record.projection = runtime;
assert.equal(
h.display.extractPickedCameraId(h.display.pick({ x: 1, y: 1 })),
'a',
);
} else {
const outline = h.lineState('cctv-a-plane-outline'); const outline = h.lineState('cctv-a-plane-outline');
assert.equal(outline.widthPx, 1); assert.equal(outline.widthPx, 1);
assert.equal(outline.pickId, 'cctv-a-plane'); assert.equal(outline.pickId, 'cctv-a-plane');
@@ -394,7 +323,6 @@ for (const makeHarness of CCTV_HARNESSES) {
h.display.removePlane(runtime); h.display.removePlane(runtime);
assert.equal(material.disposed, true); assert.equal(material.disposed, true);
assert.equal(h.engine.lines.list().length, 0); assert.equal(h.engine.lines.list().length, 0);
if (h.cesium) assert.equal(h.viewer.entities.values.length, 0);
}); });
test(`the viewshed volume and the activation probe's ray (${name})`, (t) => { test(`the viewshed volume and the activation probe's ray (${name})`, (t) => {
@@ -422,37 +350,7 @@ for (const makeHarness of CCTV_HARNESSES) {
); );
assert.equal(excludeObjects.length, 6); assert.equal(excludeObjects.length, 6);
const direction = p(0, 1, 0); const direction = p(0, 1, 0);
if (h.cesium) { {
const primitive = h.primitive(volume.mesh.id);
assert.equal(primitive.allowPicking, false);
assert.equal(primitive.asynchronous, false);
assert.ok(primitive);
let query;
h.viewer.scene.pickFromRay = (ray, exclusions) => {
query = { ray, exclusions };
return {
position: Cesium.Cartesian3.add(
ray.origin,
new Cesium.Cartesian3(0, 20, 0),
new Cesium.Cartesian3(),
),
};
};
const exclude = [...record.coverageEntities, volume];
assert.equal(
h.display.obstructionRay(POSITIONS.mount, direction, exclude, 10),
undefined,
'a hit past the range is no obstruction',
);
assert.ok(
h.display.obstructionRay(POSITIONS.mount, direction, exclude, 30),
);
assert.ok(query.ray instanceof Cesium.Ray);
assert.equal(primitive.show, true, 'the excluded volume is restored');
assert.ok(query.exclusions.every((item) => typeof item !== 'string'));
h.display.removeVolume(volume);
assert.equal(h.primitive(volume.mesh.id), undefined);
} else {
assert.equal( assert.equal(
h.display.obstructionRay(POSITIONS.mount, direction, [], 30), h.display.obstructionRay(POSITIONS.mount, direction, [], 30),
undefined, undefined,
@@ -558,16 +456,6 @@ for (const makeHarness of CCTV_HARNESSES) {
assert.deepEqual(credits.at(-1), ['hide', h.engine, 'cctv']); assert.deepEqual(credits.at(-1), ['hide', h.engine, 'cctv']);
assert.equal(h.engine.lines.list().length, 0); assert.equal(h.engine.lines.list().length, 0);
assert.equal(h.engine.movers.list().length, 0); assert.equal(h.engine.movers.list().length, 0);
if (h.cesium) {
assert.equal(h.viewer.entities.values.length, 0);
const drawn = [];
for (let i = 0; i < h.viewer.scene.primitives.length; i++)
drawn.push(h.viewer.scene.primitives.get(i));
assert.ok(
drawn.every((primitive) => !(primitive instanceof Cesium.Primitive)),
'the volume is gone',
);
}
}); });
} }
+3 -41
View File
@@ -1,7 +1,4 @@
// The CCTV calibration gizmo drawn by the display, on the fake engine and // Layer display behavior on the fake engine.
// on the Cesium engine: drags through engine.input that patch only the
// pinned record and hand the camera back, picks that need the gizmo's own
// handles, the handles' geometry, hover sizes, cancellation and teardown.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -185,40 +182,7 @@ for (const makeHarness of CCTV_HARNESSES) {
{ id: points.id, kind: points.kind, count: points.count }, { id: points.id, kind: points.kind, count: points.count },
{ id: 'cctv-gizmo-points', kind: 'point', count: 6 }, { id: 'cctv-gizmo-points', kind: 'point', count: 6 },
); );
if (h.cesium) { {
const ring = h.primitive('cctv-gizmo-ring-heading');
const first = ring.geometryInstances.geometry._positions[0];
assert.ok(
Math.abs(
geo.cartesian3.distance(first, h.record.frustumPositions.mount) - 12,
) < 1e-6,
);
const east = h.primitive('cctv-gizmo-move-east');
assert.equal(east.geometryInstances.geometry._width, 12);
assert.equal(east.appearance.material.type, 'PolylineArrow');
assert.ok(
Math.abs(
east.depthFailAppearance.material.uniforms.color.alpha - 0.45,
) < 1e-6,
);
const heading = h.primitive('cctv-gizmo-ring-heading');
assert.ok(
Math.abs(heading.appearance.material.uniforms.color.alpha - 0.9) < 1e-6,
);
assert.ok(
Math.abs(
heading.depthFailAppearance.material.uniforms.color.alpha - 0.45,
) < 1e-6,
);
const tips = h.primitive('cctv-gizmo-points');
const tip = tips.get(0);
near(tip.pixelSize, 14, 'tip size');
assert.equal(tip.outlineWidth, 2);
assert.ok(Math.abs(tip.outlineColor.alpha - 0.7) < 1e-6);
assert.equal(tip.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.ok(Math.abs(tip.position.y - 19.2) < 1e-6);
near(tips.get(3).pixelSize, 13, 'range handle size');
} else {
const ring = h.engine.fake.line('cctv-gizmo-ring-heading'); const ring = h.engine.fake.line('cctv-gizmo-ring-heading');
assert.ok( assert.ok(
Math.abs( Math.abs(
@@ -262,9 +226,7 @@ for (const makeHarness of CCTV_HARNESSES) {
h.event('move'); h.event('move');
assert.equal(h.canvas.style.cursor, 'grab'); assert.equal(h.canvas.style.cursor, 'grab');
const size = (index) => const size = (index) =>
h.cesium h.engine.fake.moverSet('cctv-gizmo-points').store.scales[index] * 10;
? h.primitive('cctv-gizmo-points').get(index).pixelSize
: h.engine.fake.moverSet('cctv-gizmo-points').store.scales[index] * 10;
near(size(3), 17, 'the hovered range handle'); near(size(3), 17, 'the hovered range handle');
near(size(0), 10, 'an arrow tip takes the handle size'); near(size(0), 10, 'an arrow tip takes the handle size');
near(size(4), 10, 'a field-of-view handle'); near(size(4), 10, 'a field-of-view handle');
+5 -17
View File
@@ -18,12 +18,7 @@ const positions = {
}; };
const identity = { x: 0, y: 0, z: 0, w: 1 }; const identity = { x: 0, y: 0, z: 0, w: 1 };
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs'; for (const [name, make] of [['fake', () => ({ engine: createFakeEngine() })]]) {
for (const [name, make] of [
['fake', () => ({ engine: createFakeEngine() })],
['Cesium', createCesiumMoversEngine],
]) {
test(`CCTV mesh ownership, live image and geometry updates (${name})`, (t) => { test(`CCTV mesh ownership, live image and geometry updates (${name})`, (t) => {
const h = make(); const h = make();
t.after(() => h.engine.destroy()); t.after(() => h.engine.destroy());
@@ -42,7 +37,7 @@ for (const [name, make] of [
assert.equal(meshes.planeFromPick({ id: plane.id }), null); assert.equal(meshes.planeFromPick({ id: plane.id }), null);
assert.equal(meshes.drawnBy(plane), plane.mesh); assert.equal(meshes.drawnBy(plane), plane.mesh);
assert.equal(meshes.drawnBy(volume), volume.mesh); assert.equal(meshes.drawnBy(volume), volume.mesh);
const image = new HTMLCanvasElement(); const image = {};
image.width = image.height = 8; image.width = image.height = 8;
material.setImage(image); material.setImage(image);
const moved = { const moved = {
@@ -50,16 +45,9 @@ for (const [name, make] of [
capCenter: { ...positions.capCenter, x: positions.capCenter.x + 3 }, capCenter: { ...positions.capCenter, x: positions.capCenter.x + 3 },
}; };
meshes.updatePlane(plane, shape, moved, identity); meshes.updatePlane(plane, shape, moved, identity);
const state = h.engine.fake?.mesh(plane.id); const state = h.engine.fake.mesh(plane.id);
const primitive = h.primitive?.(plane.id); assert.equal(state.positions[0], moved.capCenter.x - shape.halfW);
const flat = assert.equal(state.image, image);
state?.positions ??
primitive.geometryInstances.geometry.attributes.position.values;
assert.equal(flat[0], moved.capCenter.x - shape.halfW);
assert.equal(
state?.image ?? primitive.appearance.material.uniforms.image,
image,
);
assert.throws( assert.throws(
() => meshes.createPlane('b', {}, shape, positions, identity), () => meshes.createPlane('b', {}, shape, positions, identity),
/not owned/, /not owned/,
-19
View File
@@ -1,19 +0,0 @@
// The CCTV world click: a tracking click over engine.input, shared by the
// display (render/display.js) and the application layer.
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../data/trackingClickGesture.js';
/** The tracking click gesture over engine.input (the CCTV world click). */
export function bindCctvWorldClickGesture(handler, onClick, options = {}) {
bindTrackingClickGesture(
handler,
(click, gesture) => {
if (!isTrackingClickGesture(gesture)) return;
onClick(click);
},
options,
);
}
+3 -64
View File
@@ -1,11 +1,10 @@
// The cyclone display, each case on the fake engine and on a headless Cesium // Layer display behavior on the fake engine.
// engine (src/testSupport/cesiumMoversEngine.mjs).
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { createCyclonesDisplay, cycloneSphereVisible } from './display.js'; import { createCyclonesDisplay, cycloneSphereVisible } from './display.js';
import { import {
@@ -93,47 +92,6 @@ const ENGINES = {
preRenderListeners: null, preRenderListeners: null,
}; };
}, },
cesium() {
const harness = createCesiumMoversEngine();
harness.viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
const { engine, primitive, viewer } = harness;
return {
engine,
viewer,
frame: harness.frame,
points(prefix) {
const id = engine.movers
.list()
.find((set) => set.id.startsWith(prefix))?.id;
const set = engine.movers.list().find((entry) => entry.id === id);
const collection = primitive(id);
return {
id,
count: set.count,
outlinePx: collection.get(0)?.outlineWidth,
depthTest:
collection.get(0)?.disableDepthTestDistance === Infinity
? 'always'
: 'normal',
at: (index) => {
const item = collection.get(index);
return {
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
pixelSize: item.pixelSize,
show: item.show,
pickId: item.id,
};
},
};
},
preRenderListeners: () => viewer.scene.preRender.numberOfListeners,
};
},
}; };
function bothEngines(name, body) { function bothEngines(name, body) {
@@ -258,26 +216,7 @@ bothEngines(
], ],
'cones under the tracks, draped on terrain and tiles', 'cones under the tracks, draped on terrain and tiles',
); );
if (kind === 'cesium') {
const slots = viewer.scene.groundPrimitives.get(0);
const drawn = [];
for (let index = 0; index < slots.length; index++) {
const slot = slots.get(index);
for (let at = 0; at < slot.length; at++) drawn.push(slot.get(at));
}
const cone = drawn.filter(
(p) => p.gevGroundSet === 'weather-cyclones-cones:ep152026',
);
assert.equal(cone.length, 2, 'one fill batch and one outline batch');
assert.equal(cone[0].geometryInstances.length, 2);
assert.equal(cone[1].geometryInstances.length, 3, 'one per ring');
assert.equal(cone[1].geometryInstances[0].geometry.width, 1);
assert.equal(cone[0].classificationType, Cesium.ClassificationType.BOTH);
const track = drawn.find(
(p) => p.gevGroundSet === 'weather-cyclones-tracks:ep152026',
);
assert.equal(track.geometryInstances[0].geometry.width, 2.5);
}
assert.deepEqual(display.getDiagnostics(), { assert.deepEqual(display.getDiagnostics(), {
storms: 1, storms: 1,
tracks: 1, tracks: 1,
+1 -5
View File
@@ -66,11 +66,7 @@ function fixture(makeHarness = createFakeGroundHarness) {
const makeEngine = (make = makeHarness) => { const makeEngine = (make = makeHarness) => {
const harness = make(); const harness = make();
// The stub Cesium scene has no credit display; the OSM credit goes there. // The stub Cesium scene has no credit display; the OSM credit goes there.
if (harness.viewer)
harness.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
harnesses.set(harness.engine, harness); harnesses.set(harness.engine, harness);
return harness.engine; return harness.engine;
}; };
+14 -51
View File
@@ -1,13 +1,8 @@
// The Directions display on the engine contract, on the fake engine and on // Layer display behavior on the fake engine.
// the Cesium engine (real Cesium point and billboard collections and the
// engine's ground batches over fake primitives): the route's flowing
// dashes, width, colour and drape, and the render it holds; the maneuver dots' style, anchoring, selection
// and picks; the A and B markers at the surface with their letters; show and
// hide through the layer; and what teardown leaves.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'; import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
import { createApplicationDirectionsLayer } from '../../../data/directions.js'; import { createApplicationDirectionsLayer } from '../../../data/directions.js';
@@ -114,11 +109,7 @@ function cesiumPoint(viewer, id, index) {
function fixture(t, makeHarness, { document = true } = {}) { function fixture(t, makeHarness, { document = true } = {}) {
if (document) installDocument(t); if (document) installDocument(t);
const h = makeHarness(); const h = makeHarness();
if (h.viewer)
h.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
const movers = recordMovers(h.engine); const movers = recordMovers(h.engine);
let floor = 0; let floor = 0;
const requests = []; const requests = [];
@@ -192,22 +183,7 @@ for (const makeHarness of GROUND_HARNESSES) {
getRenderGovernorDiagnostics(f.engine).holds.includes('directions'), getRenderGovernorDiagnostics(f.engine).holds.includes('directions'),
'a drawn route holds continuous render', 'a drawn route holds continuous render',
); );
if (f.cesium) {
const { fills, lines } = f.primitives(IDS.route);
assert.equal(fills.length, 0);
assert.equal(lines.length, 1, 'one ground polyline batch');
assert.equal(
lines[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
const { uniforms } = lines[0].options.appearance.material;
assert.equal(uniforms.dashCells, 64);
near(uniforms.dashDuty, 0.46, 'duty');
near(uniforms.dashSpeed, 0.55, 'speed');
near(uniforms.dashGap, 0.18, 'gap');
near(uniforms.dashEmission, 0.9, 'emission');
near(uniforms.color.alpha, 0.95, 'alpha');
}
f.layer.setParams({ clear: true }); f.layer.setParams({ clear: true });
assert.equal(f.set(IDS.route), undefined); assert.equal(f.set(IDS.route), undefined);
assert.ok( assert.ok(
@@ -241,13 +217,6 @@ for (const makeHarness of GROUND_HARNESSES) {
const at = geo.cartographic.fromCartesian(anchored.position); const at = geo.cartographic.fromCartesian(anchored.position);
near(at.height, 32, 'floor + lift', 1e-3); near(at.height, 32, 'floor + lift', 1e-3);
near(geo.math.toDegrees(at.longitude), MID.lon, 'dot lon'); near(geo.math.toDegrees(at.longitude), MID.lon, 'dot lon');
if (f.cesium) {
const point = cesiumPoint(f.viewer, IDS.steps, 0);
assert.equal(point.pixelSize, 8);
assert.equal(point.outlineWidth, 2);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(point.show, true);
}
}); });
test(`a click on a dot selects it at 13 px and opens its card; a click elsewhere clears it (${name})`, async (t) => { test(`a click on a dot selects it at 13 px and opens its card; a click elsewhere clears it (${name})`, async (t) => {
@@ -258,8 +227,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(f.layer.getRowControls().list.items[1].active, true); assert.equal(f.layer.getRowControls().list.items[1].active, true);
near(f.movers(IDS.steps).rows[0].scale, 13 / 8, 'selected scale'); near(f.movers(IDS.steps).rows[0].scale, 13 / 8, 'selected scale');
assert.equal(f.cards.at(-1).id, 'directions-step-1'); assert.equal(f.cards.at(-1).id, 'directions-step-1');
if (f.cesium)
assert.equal(cesiumPoint(f.viewer, IDS.steps, 0).pixelSize, 13);
f.click(undefined); f.click(undefined);
await settle(); await settle();
assert.equal(f.layer.getRowControls().list.items[1].active, false); assert.equal(f.layer.getRowControls().list.items[1].active, false);
@@ -268,7 +236,7 @@ for (const makeHarness of GROUND_HARNESSES) {
test(`A and B are 14 px discs with a black outline and their letters, at the surface (${name})`, async (t) => { test(`A and B are 14 px discs with a black outline and their letters, at the surface (${name})`, async (t) => {
const f = fixture(t, makeHarness); const f = fixture(t, makeHarness);
if (!f.cesium) f.engine.fake.script('sampleHeight', () => 120); f.engine.fake.script('sampleHeight', () => 120);
f.layer.placeEndpoint('a', A); f.layer.placeEndpoint('a', A);
const markers = f.movers(IDS.markers); const markers = f.movers(IDS.markers);
assert.equal(markers.options.kind, 'point'); assert.equal(markers.options.kind, 'point');
@@ -281,13 +249,12 @@ for (const makeHarness of GROUND_HARNESSES) {
nearAll(markers.rows[0].color, [a.red, a.green, a.blue, 1], 'A colour'); nearAll(markers.rows[0].color, [a.red, a.green, a.blue, 1], 'A colour');
assert.equal(markers.rows[0].show, true); assert.equal(markers.rows[0].show, true);
assert.equal(markers.rows[1].show, false, 'B is not placed yet'); assert.equal(markers.rows[1].show, false, 'B is not placed yet');
if (!f.cesium) near(
near( geo.cartographic.fromCartesian(markers.rows[0].position).height,
geo.cartographic.fromCartesian(markers.rows[0].position).height, 120,
120, 'A on the sampled surface',
'A on the sampled surface', 1e-3,
1e-3, );
);
const letters = f.movers(IDS.letters); const letters = f.movers(IDS.letters);
assert.equal(letters.options.kind, 'sprite'); assert.equal(letters.options.kind, 'sprite');
assert.equal(letters.options.order, 2); assert.equal(letters.options.order, 2);
@@ -339,14 +306,10 @@ for (const makeHarness of GROUND_HARNESSES) {
} }
test('an engine switch redraws the route, dots and markers on the new engine only', async (t) => { test('an engine switch redraws the route, dots and markers on the new engine only', async (t) => {
const [makeFake, makeCesium] = GROUND_HARNESSES; const [makeFake] = GROUND_HARNESSES;
const f = fixture(t, makeFake); const f = fixture(t, makeFake);
await f.route(); await f.route();
const next = makeCesium(); const next = makeFake();
next.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
const movers = recordMovers(next.engine); const movers = recordMovers(next.engine);
t.after(() => next.destroy()); t.after(() => next.destroy());
f.layer.suspendCamera(); f.layer.suspendCamera();
@@ -1,17 +1,12 @@
// The FAA airspace zones on the engine contract, on the fake engine and on // Layer display behavior on the fake engine.
// the Cesium engine (its ground batches over fake primitives): fills with
// their holes at 0.25 and a 2 px outline at 0.9 along the outer ring only,
// in the zone type's colour; clicks through the engine's input selecting a
// zone's card; visibility and teardown.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { import {
GROUND_HARNESSES, GROUND_HARNESSES,
createFakeGroundHarness, createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs'; } from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { droneAirspaceAccent } from '../cards.js'; import { droneAirspaceAccent } from '../cards.js';
@@ -140,51 +135,9 @@ for (const makeHarness of GROUND_HARNESSES) {
lines.forEach(({ outline }, index) => lines.forEach(({ outline }, index) =>
assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()), assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()),
); );
if (f.cesium) {
const primitives = f.primitives('drone-airspace');
assert.equal(primitives.fills.length, 1, 'one fill batch');
assert.equal(primitives.lines.length, 1, 'one outline batch');
const fillInstances = primitives.fills[0].options.geometryInstances;
const lineInstances = primitives.lines[0].options.geometryInstances;
assert.equal(fillInstances.length, 3);
assert.equal(lineInstances.length, 3, 'no line along a hole');
assert.equal(
primitives.fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
assert.equal(primitives.fills[0].options.allowPicking, true);
assert.ok(fillInstances[0].geometry instanceof Cesium.PolygonGeometry);
assert.equal(
fillInstances[0].geometry._polygonHierarchy.holes.length,
1,
);
fillInstances.forEach((instance, index) =>
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.25).rgba()[at],
`fill ${index}.${at}`,
0.5 / 255,
),
),
);
lineInstances.forEach((instance, index) => {
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
assert.equal(instance.geometry.width, 2);
assert.equal(instance.geometry.loop, true);
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.9).rgba()[at],
`line ${index}.${at}`,
0.5 / 255,
),
);
});
}
f.layer.disable(); f.layer.disable();
assert.equal(f.set('drone-airspace').shown, false); assert.equal(f.set('drone-airspace').shown, false);
if (f.cesium) assert.equal(f.collectionShown('drone-airspace'), false);
} finally { } finally {
f.layer.destroy(); f.layer.destroy();
} }
+12 -45
View File
@@ -1,16 +1,12 @@
// The earthquake discs on the engine contract, on the fake engine and on the // Layer display behavior on the fake engine.
// Cesium engine (its ground batches over fake primitives): radii, depth
// colours and alphas, no outline, picks, visibility through the layer,
// retained discs across a scene handoff and a failed refresh, and teardown.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { import {
GROUND_HARNESSES, GROUND_HARNESSES,
createFakeGroundHarness, createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs'; } from '../../../testSupport/groundDisplayEngines.mjs';
import { createEarthquakesLayer } from '../index.js'; import { createEarthquakesLayer } from '../index.js';
import { createEarthquakesDisplay } from './display.js'; import { createEarthquakesDisplay } from './display.js';
@@ -113,28 +109,7 @@ for (const makeHarness of GROUND_HARNESSES) {
near(channel, FILLS[index][at], `fill ${index}.${at}`), near(channel, FILLS[index][at], `fill ${index}.${at}`),
), ),
); );
if (f.cesium) {
const { fills, lines } = f.primitives('earthquakes');
assert.equal(fills.length, 1, 'one ground batch');
assert.equal(lines.length, 0, 'no outline batch');
const { geometryInstances, classificationType, allowPicking } =
fills[0].options;
assert.equal(classificationType, Cesium.ClassificationType.BOTH);
assert.equal(allowPicking, true);
geometryInstances.forEach((instance, index) => {
const row = rows[index];
assert.ok(instance.geometry instanceof Cesium.EllipseGeometry);
assert.equal(instance.geometry._semiMajorAxis, 2 ** row.mag * 1000);
assert.equal(instance.geometry._semiMinorAxis, 2 ** row.mag * 1000);
const center = Cesium.Cartesian3.fromDegrees(row.lon, row.lat);
near(instance.geometry._center.x, center.x, 'centre x', 1e-6);
near(instance.geometry._center.y, center.y, 'centre y', 1e-6);
near(instance.geometry._center.z, center.z, 'centre z', 1e-6);
instanceColor(instance).forEach((channel, at) =>
near(channel, FILLS[index][at], `colour ${index}.${at}`, 0.5 / 255),
);
});
}
assert.deepEqual( assert.deepEqual(
f.layer.getAnalystRecords().map((r) => r.id), f.layer.getAnalystRecords().map((r) => r.id),
['us-a', 'us-b', 'QUAKE-0002'], ['us-a', 'us-b', 'QUAKE-0002'],
@@ -145,7 +120,6 @@ for (const makeHarness of GROUND_HARNESSES) {
f.layer.disable(); f.layer.disable();
assert.deepEqual(f.layer.getAnalystRecords(), []); assert.deepEqual(f.layer.getAnalystRecords(), []);
assert.equal(f.set('earthquakes').shown, false); assert.equal(f.set('earthquakes').shown, false);
if (f.cesium) assert.equal(f.collectionShown('earthquakes'), false);
} finally { } finally {
f.layer.destroy(); f.layer.destroy();
} }
@@ -169,7 +143,7 @@ for (const makeHarness of GROUND_HARNESSES) {
f.setSnapshot([]); f.setSnapshot([]);
assert.equal(await f.layer.update(), true); assert.equal(await f.layer.update(), true);
assert.equal(f.set('earthquakes').items.length, 0); assert.equal(f.set('earthquakes').items.length, 0);
if (f.cesium) assert.equal(f.primitives('earthquakes').fills.length, 0);
assert.equal(f.layer.getStats().error, null); assert.equal(f.layer.getStats().error, null);
} finally { } finally {
f.layer.destroy(); f.layer.destroy();
@@ -182,21 +156,14 @@ for (const makeHarness of GROUND_HARNESSES) {
try { try {
await f.layer.update(); await f.layer.update();
let picked; let picked;
if (f.cesium) {
const [fills] = f.primitives('earthquakes').fills; // The fake picks its ground items on the CPU, under the camera.
f.viewer.scene.pick = () => ({ f.engine.camera.setView({
id: fills.options.geometryInstances[1].id, destination: geo.cartesian3.fromDegrees(30, 20, 200_000),
primitive: fills, });
}); const { width, height } = f.engine.host.size();
picked = f.engine.picker.pick({ x: 1, y: 1 }); picked = f.engine.picker.pick({ x: width / 2, y: height / 2 });
} else {
// The fake picks its ground items on the CPU, under the camera.
f.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(30, 20, 200_000),
});
const { width, height } = f.engine.host.size();
picked = f.engine.picker.pick({ x: width / 2, y: height / 2 });
}
assert.equal(picked.id, 'earthquake:b'); assert.equal(picked.id, 'earthquake:b');
assert.equal(picked.owner, 'earthquakes'); assert.equal(picked.owner, 'earthquakes');
assert.equal(picked.instance, 1); assert.equal(picked.instance, 1);
+4 -78
View File
@@ -1,14 +1,11 @@
// The FIRMS display on the fake engine and on the Cesium engine over a stub // Layer display behavior on the fake engine.
// scene: the same calls, the same drawing, read back from each engine's own
// store (the fake's mover and ground sets, Cesium's billboards and ground
// primitives).
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { constantRgba } from '../../shared/color.js'; import { constantRgba } from '../../shared/color.js';
import { createFirmsDisplay } from './display.js'; import { createFirmsDisplay } from './display.js';
@@ -94,76 +91,7 @@ function fakeHarness() {
}; };
} }
function cesiumHarness() { const HARNESSES = [fakeHarness];
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
engine.camera.setView({ destination: at(FIRE.lon, FIRE.lat, 2_000_000) });
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const ground = watchGround(engine);
return {
name: 'the Cesium engine',
engine,
viewer,
ground,
sprites(id) {
const collection = primitive(id);
if (!collection) return null;
const listed = engine.movers.list().find((entry) => entry.id === id);
const count = listed?.count ?? 0;
return {
collection,
count,
instances: Array.from({ length: count }, (_, i) => {
const billboard = collection.get(i);
return {
show: billboard.show,
scale: billboard.scale,
alpha: billboard.color.alpha,
pickId: billboard.id,
billboard,
};
}),
};
},
/** The Cesium ground primitives of a set. */
groundPrimitives(id) {
const root = viewer.scene.groundPrimitives.get(0);
for (let i = 0; i < (root?.length ?? 0); i++) {
const slot = root.get(i);
const values = Array.from({ length: slot.length }, (_, j) =>
slot.get(j),
);
if (values.some((value) => value.gevGroundSet === id))
return { slot, values };
}
return null;
},
click(picked) {
native = picked;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(10, 20),
});
native = undefined;
},
// A Cesium pick of the instance's billboard.
pickOf(setId, instance) {
const billboard = primitive(setId).get(instance);
return { primitive: billboard, id: billboard.id };
},
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const display = (h, options = {}) => const display = (h, options = {}) =>
createFirmsDisplay(h.engine, { createFirmsDisplay(h.engine, {
id: 'firms-fixture', id: 'firms-fixture',
@@ -535,8 +463,6 @@ for (const make of HARNESSES) {
test(`${name}: the heatmap is one imagery layer over the globe, faded by its alpha`, () => { test(`${name}: the heatmap is one imagery layer over the globe, faded by its alpha`, () => {
const restore = stubDocument(); const restore = stubDocument();
const h = make(); const h = make();
// Cesium drapes it on the globe's own imagery stack.
const stack = new Cesium.ImageryLayerCollection();
if (h.viewer) { if (h.viewer) {
h.viewer.imageryLayers = stack; h.viewer.imageryLayers = stack;
h.viewer.scene.globe.show = true; h.viewer.scene.globe.show = true;
-79
View File
@@ -215,85 +215,6 @@ test('the flights logic draws and follows on the aircraft display (fake engine)'
engine.destroy(); engine.destroy();
}); });
test('the flights logic draws, tracks and follows on the Cesium engine through the same display', async () => {
const { createCesiumMoversEngine } =
await import('../../testSupport/cesiumMoversEngine.mjs');
const { createFlightsDisplay } = await import('./render/display.js');
const { aircraftAtlasLayout } = await import('../aircraft/render/display.js');
const layout = aircraftAtlasLayout();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.5, 30.3, 400_000),
});
let display = null;
const layer = createApplicationFlights({
surface: defaultSurface,
source: {
label: 'Fixture aircraft',
getSnapshot: async () => snapshotOf(['abc123', 'def456']),
},
militaryRegistry,
renderer: {
// The registry's factory, with the test's atlas.
create: ({ engine: given }) =>
(display = createFlightsDisplay(given, {
drawAtlas: async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
}),
})),
},
});
layer.init(viewer, { engine });
layer.enable();
try {
await display.whenReady();
await layer.update(null);
await new Promise((resolve) => setTimeout(resolve, 0));
const [sprites] = engine.movers
.list()
.filter(({ id }) => id.startsWith('flights-sprites'));
assert.equal(sprites.count, 2);
const billboards = primitive(sprites.id);
assert.equal(billboards.length, 2, 'one Cesium billboard per contact');
assert.deepEqual([billboards.get(0).id, billboards.get(1).id].sort(), [
'abc123',
'def456',
]);
assert.deepEqual(
layer
.qaDrawn()
.map((row) => row.id)
.sort(),
['abc123', 'def456'],
);
// Tracking follows through Cesium's trackedEntity, with the readout.
assert.equal(layer.trackById('def456', { origin: 'voice' }), true);
const following = engine.camera.following();
assert.equal(following?.label?.id, 'flights:def456');
assert.equal(viewer.trackedEntity?.gevTrackedId, 'flights:def456');
frame();
await new Promise((resolve) => setTimeout(resolve, 0));
const tracked = engine.movers
.list()
.find(({ id }) => id.startsWith('flights-tracked'));
assert.equal(tracked.count, 1);
assert.equal(
primitive(tracked.id).get(0).disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(layer.stopTracking(), true);
assert.equal(engine.camera.following(), null);
assert.equal(viewer.trackedEntity, undefined);
} finally {
layer.disable();
layer.destroy(viewer);
}
assert.equal(engine.movers.list().length, 0, 'no mover set left behind');
assert.equal(engine.lines.list().length, 0, 'no line left behind');
engine.destroy();
});
test('a failed engine switch, its rollback and the retry re-follow the tracked aircraft as a restore', async () => { test('a failed engine switch, its rollback and the retry re-follow the tracked aircraft as a restore', async () => {
const { createCesiumMoversEngine } = const { createCesiumMoversEngine } =
await import('../../testSupport/cesiumMoversEngine.mjs'); await import('../../testSupport/cesiumMoversEngine.mjs');
+14 -113
View File
@@ -1,15 +1,12 @@
// The GhostMaps display on the globe contract, on the fake engine and on the import { createFakeGroundHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// Cesium engine over a stub scene: KML polygons, lines and placemarks keep // Layer display behavior on the fake engine.
// the styles the Cesium entities had (colours, alphas, widths, scales and
// order), picks name the report part, a report drops in place, placemarks
// beyond the horizon hide, and teardown leaves nothing drawn.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { createGhostMapsDisplay } from './display.js'; import { createGhostMapsDisplay } from './display.js';
@@ -41,15 +38,10 @@ function recordGround(engine) {
} }
function fakeHarness() { function fakeHarness() {
const engine = createFakeEngine(); const harness = createFakeGroundHarness();
const { engine } = harness;
return { return {
name: 'the fake engine', ...harness,
engine,
frame: () => engine.fake.frame(),
click(picked) {
engine.fake.script('pick', picked);
engine.fake.input('click', { position: { x: 100, y: 100 } });
},
pin(id, index) { pin(id, index) {
const { store, options } = engine.fake.moverSet(id); const { store, options } = engine.fake.moverSet(id);
return { return {
@@ -63,83 +55,7 @@ function fakeHarness() {
}; };
} }
// Cesium's ground primitives as built, without a WebGL context: the const HARNESSES = [fakeHarness];
// options (instances, appearance, classification) the engine hands them.
const groundFactory = (type) => (options) => ({
type,
...options,
ready: true,
destroy() {},
isDestroyed: () => false,
});
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
groundFactories: {
fills: groundFactory('fills'),
lines: groundFactory('lines'),
},
},
});
// The stub scene has no ground collection of its own.
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
frame,
click(picked) {
native = !picked
? undefined
: picked.ground
? { id: { gevGroundSet: picked.owner, instance: picked.instance } }
: { primitive: primitive(picked.owner).get(picked.instance) };
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
},
pin(id, index) {
const item = primitive(id).get(index);
const count = engine.movers.list().find((set) => set.id === id).count;
return {
shown: index < count && item.show,
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
sizePx: item.width * item.scale,
id: item.id,
};
},
count: (id) => engine.movers.list().find((set) => set.id === id).count,
// The ground primitives Cesium builds for a set.
groundPrimitives(id) {
const found = [];
const visit = (collection) => {
for (let index = 0; index < collection.length; index++) {
const child = collection.get(index);
if (child.gevGroundSet === id) found.push(child);
else if (child instanceof Cesium.PrimitiveCollection) visit(child);
}
};
visit(viewer.scene.groundPrimitives);
return found;
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
// Seams for Node: decoded icons by URL and a canvas that records draws. // Seams for Node: decoded icons by URL and a canvas that records draws.
function iconSeams(sizes = {}) { function iconSeams(sizes = {}) {
@@ -324,26 +240,11 @@ for (const makeHarness of HARNESSES) {
assert.ok(Math.abs(big.sizePx - 20 * 1.5) < 1e-6, 'scale capped at 1.5'); assert.ok(Math.abs(big.sizePx - 20 * 1.5) < 1e-6, 'scale capped at 1.5');
const set = harness.engine.movers.list().find(({ id }) => id === pins); const set = harness.engine.movers.list().find(({ id }) => id === pins);
assert.equal(set.kind, 'sprite'); assert.equal(set.kind, 'sprite');
if (harness.cesium) {
// The per-instance colours and widths Cesium batches. const { options } = harness.engine.fake.moverSet(pins);
const [fills, outlines] = harness.groundPrimitives(polygons().options.id); assert.equal(options.depthTest, 'always');
assert.deepEqual( assert.equal(options.pickable, true);
[fills.type, outlines.type],
['fills', 'lines'],
'one fill and one outline batch',
);
assert.equal(fills.classificationType, Cesium.ClassificationType.BOTH);
assert.equal(fills.allowPicking, true);
const colour = fills.geometryInstances[0].attributes.color.value;
assert.deepEqual([...colour], [255, 90, 54, 64]);
assert.equal(outlines.geometryInstances[0].geometry.width, 1.5);
const [corridors] = harness.groundPrimitives(lines().options.id);
assert.equal(corridors.geometryInstances[0].geometry.width, 8);
} else {
const { options } = harness.engine.fake.moverSet(pins);
assert.equal(options.depthTest, 'always');
assert.equal(options.pickable, true);
}
display.destroy(); display.destroy();
}); });
+4 -55
View File
@@ -1,18 +1,12 @@
// The GNSS integrity cells on the engine contract, on the fake engine and // Layer display behavior on the fake engine.
// on the Cesium engine (its ground batches over fake primitives, and once
// over Cesium's own GroundPrimitive): one unpicked cell per bin in its
// level's colour and alpha, no outline, the camera and view anchor the
// layer reads, visibility and teardown.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { import {
GROUND_HARNESSES, GROUND_HARNESSES,
createCesiumGroundHarness,
createFakeGroundHarness, createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs'; } from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { GNSS_LEVEL_COLORS } from '../records.js'; import { GNSS_LEVEL_COLORS } from '../records.js';
@@ -71,39 +65,12 @@ for (const makeHarness of GROUND_HARNESSES) {
fill: fillOf(level), fill: fillOf(level),
})), })),
); );
if (h.cesium) {
const { fills, lines } = h.primitives('gnss-integrity');
assert.equal(fills.length, 1, 'one ground batch');
assert.equal(lines.length, 0, 'no outline batch');
const { geometryInstances, classificationType, allowPicking } =
fills[0].options;
assert.equal(classificationType, Cesium.ClassificationType.BOTH);
assert.equal(allowPicking, false);
geometryInstances.forEach((instance, index) => {
const cell = cells[index];
assert.ok(instance.geometry instanceof Cesium.RectangleGeometry);
const rectangle = instance.geometry._rectangle;
near(rectangle.west, geo.math.toRadians(cell.west), 'west');
near(rectangle.south, geo.math.toRadians(cell.south), 'south');
near(rectangle.east, geo.math.toRadians(cell.east), 'east');
near(rectangle.north, geo.math.toRadians(cell.north), 'north');
instanceColor(instance).forEach((channel, at) =>
near(
channel,
fillOf(cell.level)[at],
`colour ${index}.${at}`,
0.5 / 255,
),
);
});
}
display.setVisible(false); display.setVisible(false);
assert.equal(h.set('gnss-integrity').shown, false); assert.equal(h.set('gnss-integrity').shown, false);
if (h.cesium) assert.equal(h.collectionShown('gnss-integrity'), false);
display.replace([], ALPHA); display.replace([], ALPHA);
assert.equal(h.set('gnss-integrity').items.length, 0); assert.equal(h.set('gnss-integrity').items.length, 0);
if (h.cesium)
assert.equal(h.primitives('gnss-integrity').fills.length, 0);
} finally { } finally {
display.destroy(); display.destroy();
display.destroy(); display.destroy();
@@ -149,21 +116,3 @@ test('the anchor under a globe-wide view is the screen centre (the fake engine)'
near(anchor.longitude, -97, 'longitude', 0.01); near(anchor.longitude, -97, 'longitude', 0.01);
h.destroy(); h.destroy();
}); });
test("Cesium's ground fill emits no pick-pass commands but keeps normal rendering", (t) => {
const h = createCesiumGroundHarness({ realFills: true });
const display = createGnssDisplay(h.engine);
display.setVisible(true);
display.replace(cells, ALPHA);
const [fill] = h.primitives('gnss-integrity').fills;
assert.ok(fill instanceof Cesium.GroundPrimitive);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
display.destroy();
assert.equal(fill.isDestroyed(), true);
h.destroy();
});
@@ -12,8 +12,7 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { claimPointer, releasePointer } from '../../../data/inputOwnership.js'; import { claimPointer, releasePointer } from '../../../data/inputOwnership.js';
import { import {
INFRA_LOD_ACTIVE_MAX, INFRA_LOD_ACTIVE_MAX,
@@ -250,114 +249,7 @@ function fakeHarness() {
}; };
} }
function cesiumHarness() { const HARNESSES = [fakeHarness];
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
viewer.scene.screenSpaceCameraController = { enableInputs: true };
// The fake engine's 800 × 600 view, with Cesium's 60° frustum.
Object.assign(viewer.scene.canvas, {
clientWidth: 800,
clientHeight: 600,
width: 800,
height: 600,
});
viewer.scene.drawingBufferWidth = 800;
viewer.scene.drawingBufferHeight = 600;
viewer.camera.frustum.aspectRatio = 800 / 600;
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const watched = watch(engine);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
let sampleCalls = 0;
let photoreal = false;
engine.maps.photorealActive = () => photoreal;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
...watched,
preRender: () => viewer.scene.preRender.raiseEvent(viewer.scene),
moveEnd: () => viewer.camera.moveEnd.raiseEvent(),
surface({
supported = false,
sample = () => undefined,
terrain,
tilesLoaded = true,
photoreal: tiles = false,
} = {}) {
viewer.scene.sampleHeightSupported = supported;
viewer.scene.sampleHeight = (carto) => {
sampleCalls++;
const height = sample(carto);
// A scene whose tiles are not sampleable yet throws.
if (height === undefined) throw new Error('tiles not sampleable yet');
return height;
};
photoreal = tiles;
const globe = viewer.scene.globe;
globe.show = !tiles;
globe.getHeight = () => terrain();
Object.defineProperty(globe, 'tilesLoaded', {
configurable: true,
get: () => tilesLoaded(),
});
},
sampleCalls: () => sampleCalls,
point(id, index) {
const collection = primitive(id);
if (!collection || index >= count(id)) return undefined;
const item = collection.get(index);
return {
shown: item.show,
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
sizePx: item.pixelSize,
outlinePx: item.outlineWidth,
depthTest:
item.disableDepthTestDistance === Number.POSITIVE_INFINITY
? 'always'
: item.disableDepthTestDistance === 0
? 'normal'
: item.disableDepthTestDistance,
id: item.id,
position: item.position,
};
},
moverIds: () => engine.movers.list().map(({ id }) => id),
click(target) {
native =
target?.ground !== undefined
? { id: { gevGroundSet: target.ground, instance: target.instance } }
: target?.mover !== undefined
? { primitive: primitive(target.mover).get(target.instance) }
: target?.line !== undefined
? {
primitive: primitive(watched.line(target.line).owner),
id: watched.line(target.line).options.pickId,
}
: target;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
native = undefined;
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
/** Freezes performance.now so the 450 ms walk gate is under test control. */ /** Freezes performance.now so the 450 ms walk gate is under test control. */
function installFakeClock(t, startMs = 1_000_000) { function installFakeClock(t, startMs = 1_000_000) {
+23 -28
View File
@@ -1,3 +1,7 @@
import {
createSelectionKeeper,
writeSelectedMarker,
} from '../../shared/selectionKeeper.js';
import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js'; import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
// Mapped Installations on the globe contract (engine.movers, engine.ground, // Mapped Installations on the globe contract (engine.movers, engine.ground,
// engine.input, engine.camera), the same on every engine: // engine.input, engine.camera), the same on every engine:
@@ -19,7 +23,6 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import { import {
claimCameraSensitivity, claimCameraSensitivity,
releaseCameraSensitivity, releaseCameraSensitivity,
@@ -250,13 +253,16 @@ export function createInstallationsDisplay(
}); });
} }
normal.setCount(count); normal.setCount(count);
selected.setCount(chosen ? 1 : 0); writeSelectedMarker(
if (chosen) selected,
selected.set(0, { chosen
position: chosen.position, ? {
color: SELECTED_MARKER_COLOR, position: chosen.position,
pickId: chosen.record.id, color: SELECTED_MARKER_COLOR,
}); pickId: chosen.record.id,
}
: null,
);
normal.commit(); normal.commit();
selected.commit(); selected.commit();
} }
@@ -413,13 +419,7 @@ export function createInstallationsDisplay(
if (!initialized) return; if (!initialized) return;
// Context navigation can select another layer without a canvas click. // Context navigation can select another layer without a canvas click.
// A delayed floor/data repaint must not steal that newer selection back. // A delayed floor/data repaint must not steal that newer selection back.
const selectedContext = getSelectedEntityContext(); selection.reconcile({ claimSelection });
if (
!claimSelection &&
state.selectedId &&
selectedContext?.id !== state.selectedId
)
state.selectedId = null;
let migratedSelection = false; let migratedSelection = false;
if (state.selectedId && !state.recordById.has(state.selectedId)) { if (state.selectedId && !state.recordById.has(state.selectedId)) {
const replacement = state.records.find((record) => const replacement = state.records.find((record) =>
@@ -529,22 +529,17 @@ export function createInstallationsDisplay(
return state.selectedId === id; return state.selectedId === id;
} }
function onClick({ picked }) { const selection = createSelectionKeeper({
// A tool owns the pointer (src/data/inputOwnership.js): yield the click. state,
if (!isPointerFree()) return; getSelectedEntityContext,
if (!state.enabled) return; selectRecord,
const id = typeof picked?.id === 'string' ? picked.id : null; clearSelection() {
if (id && state.recordById.has(id) && id !== state.selectedId) {
selectRecord(id);
} else if (state.selectedId) {
// Clicking the selected site again, empty map, or another contact
// releases this layer's selection. Clear only our shared context so a
// sibling click handler's newly selected aircraft/site stays intact.
state.selectedId = null; state.selectedId = null;
clearSelectedEntityContextForLayer(LAYER_ID); clearSelectedEntityContextForLayer(LAYER_ID);
renderRecords(); renderRecords();
} },
} });
const onClick = selection.onClick;
function installInteraction() { function installInteraction() {
if (removeClick) return; if (removeClick) return;
@@ -5,7 +5,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import * as context from '../../../data/contextStore.js'; import * as context from '../../../data/contextStore.js';
import { claimPointer, releasePointer } from '../../../data/inputOwnership.js'; import { claimPointer, releasePointer } from '../../../data/inputOwnership.js';
@@ -101,21 +101,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(disc.pickId, 'osm:way:1', 'a pick names the site'); assert.equal(disc.pickId, 'osm:way:1', 'a pick names the site');
closeTo(disc.color, classColor(airfield(), 1)); closeTo(disc.color, classColor(airfield(), 1));
assert.equal(h.markers(SELECTED_MARKER_SET_ID).length, 0); assert.equal(h.markers(SELECTED_MARKER_SET_ID).length, 0);
if (h.cesium) { {
const point = disc.point;
assert.ok(
h.collection(MARKER_SET_ID) instanceof Cesium.PointPrimitiveCollection,
);
assert.equal(point.pixelSize, 9);
assert.equal(point.outlineWidth, 1);
assert.ok(
point.outlineColor.equalsEpsilon(
Cesium.Color.BLACK.withAlpha(0.8),
1e-6,
),
);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
} else {
const { options } = h.engine.fake.moverSet(MARKER_SET_ID); const { options } = h.engine.fake.moverSet(MARKER_SET_ID);
assert.equal(options.kind, 'point'); assert.equal(options.kind, 'point');
assert.equal(options.point.sizePx, 9); assert.equal(options.point.sizePx, 9);
@@ -134,7 +120,6 @@ for (const makeHarness of GROUND_HARNESSES) {
const [chosen] = h.markers(SELECTED_MARKER_SET_ID); const [chosen] = h.markers(SELECTED_MARKER_SET_ID);
assert.equal(chosen.pickId, 'osm:way:1'); assert.equal(chosen.pickId, 'osm:way:1');
closeTo(chosen.color, [1, 1, 1, 1]); closeTo(chosen.color, [1, 1, 1, 1]);
if (h.cesium) assert.equal(chosen.point.pixelSize, 13);
}); });
test(`a named area draws no disc but its outlines are drawn, as on Cesium (${name})`, (t) => { test(`a named area draws no disc but its outlines are drawn, as on Cesium (${name})`, (t) => {
@@ -180,20 +165,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(line.widthPx, 2); assert.equal(line.widthPx, 2);
assert.equal(line.pickId, 'osm:way:1'); assert.equal(line.pickId, 'osm:way:1');
closeTo(line.color, classColor(airfield(), 0.85)); closeTo(line.color, classColor(airfield(), 0.85));
if (h.cesium) {
const [primitive] = h.primitives(`${OUTLINE_SET_ID}-1`).lines;
assert.equal(
primitive.options.classificationType,
Cesium.ClassificationType.BOTH,
);
}
// Cesium builds ground lines in workers: the first set is drawn now.
const built = (n) => {
for (const primitive of h.primitives(`${OUTLINE_SET_ID}-${n}`).lines)
primitive.ready = true;
h.viewer.scene.postRender.raiseEvent();
};
if (h.cesium) built(1);
const commits = [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits); const commits = [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits);
for (let i = 0; i < 5; i++) h.parts.display.renderRecords(); for (let i = 0; i < 5; i++) h.parts.display.renderRecords();
assert.deepEqual( assert.deepEqual(
@@ -211,14 +183,7 @@ for (const makeHarness of GROUND_HARNESSES) {
h.parts.display.renderRecords(); h.parts.display.renderRecords();
const counts = () => const counts = () =>
[0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).items.length); [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).items.length);
if (h.cesium) {
assert.deepEqual(
counts(),
[2, 1],
'Cesium is still building the new set',
);
built(0);
}
assert.deepEqual(counts(), [2, 0]); assert.deepEqual(counts(), [2, 0]);
}); });
@@ -245,13 +210,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(fill.items[0].pickId, named.id); assert.equal(fill.items[0].pickId, named.id);
assert.equal(fill.items[0].outline, undefined); assert.equal(fill.items[0].outline, undefined);
closeTo(fill.items[0].fill, classColor(named, 0.22)); closeTo(fill.items[0].fill, classColor(named, 0.22));
if (h.cesium) {
const [primitive] = h.primitives(SELECTION_FILL_SET_ID).fills;
assert.equal(
primitive.options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
}
const commits = fill.commits; const commits = fill.commits;
for (let i = 0; i < 10; i++) h.parts.display.renderRecords(); for (let i = 0; i < 10; i++) h.parts.display.renderRecords();
assert.equal( assert.equal(
@@ -1,28 +1,10 @@
// Mapped Installations' display under test, on a ground harness of // Installation state and services over the fake ground display harness.
// src/testSupport/groundDisplayEngines.mjs (the fake engine or the Cesium
// engine, passed in): the layer's own
// state and model over test services, with the display's parts assigned as
// the layer assigns them (../index.js). `markers(setId)` reads a point
// set's drawn instances on either engine.
import * as contextStore from '../../../data/contextStore.js'; import * as contextStore from '../../../data/contextStore.js';
import { createModel } from '../model.js'; import { createModel } from '../model.js';
import { createState } from '../state.js'; import { createState } from '../state.js';
import { createInstallationsDisplay } from './display.js'; import { createInstallationsDisplay } from './display.js';
const children = (collection) =>
Array.from({ length: collection?.length ?? 0 }, (_, index) =>
collection.get(index),
);
/** The Cesium collection drawing a mover set. */
function cesiumCollection(viewer, id) {
for (const root of children(viewer.scene.primitives))
for (const child of children(root))
if (child.gevMoverSet === id) return child;
return undefined;
}
/** /**
* @param {Function} makeEngine A ground harness factory. * @param {Function} makeEngine A ground harness factory.
* @param {{ context?: object, overlayHost?: object, * @param {{ context?: object, overlayHost?: object,
@@ -123,23 +105,7 @@ export function createDisplayHarness(
markers(id) { markers(id) {
const count = const count =
h.engine.movers.list().find((entry) => entry.id === id)?.count ?? 0; h.engine.movers.list().find((entry) => entry.id === id)?.count ?? 0;
if (h.cesium) {
const collection = cesiumCollection(h.viewer, id);
return children(collection)
.slice(0, count)
.map((point) => ({
pickId: point.id,
color: [
point.color.red,
point.color.green,
point.color.blue,
point.color.alpha,
],
show: point.show,
position: point.position,
point,
}));
}
const { store } = h.engine.fake.moverSet(id); const { store } = h.engine.fake.moverSet(id);
return Array.from({ length: count }, (_, index) => ({ return Array.from({ length: count }, (_, index) => ({
pickId: store.pickIds[index], pickId: store.pickIds[index],
@@ -152,8 +118,6 @@ export function createDisplayHarness(
}, },
})); }));
}, },
/** The Cesium collection of a point set (Cesium only). */
collection: (id) => cesiumCollection(h.viewer, id),
destroy() { destroy() {
methods.destroy(); methods.destroy();
h.destroy(); h.destroy();
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as context from '../../data/contextStore.js'; import * as context from '../../data/contextStore.js';
import { SELECTION_FILL_SET_ID } from './render/display.js'; import { SELECTION_FILL_SET_ID } from './render/display.js';
import { createDisplayHarness } from './render/displayHarness.mjs'; import { createDisplayHarness } from './render/displayHarness.mjs';
@@ -65,11 +65,7 @@ for (const makeHarness of GROUND_HARNESSES) {
'the first footprint keeps its hole', 'the first footprint keeps its hole',
); );
assert.deepEqual(items[0].fill, items[1].fill); assert.deepEqual(items[0].fill, items[1].fill);
if (h.cesium)
assert.equal(
h.primitives(SELECTION_FILL_SET_ID).fills[0].options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
for (let i = 0; i < 100; i++) render.syncSelectionFill(); for (let i = 0; i < 100; i++) render.syncSelectionFill();
assert.equal(fill().commits, commits, 'repaints retain ground geometry'); assert.equal(fill().commits, commits, 'repaints retain ground geometry');
h.state.selectedId = null; h.state.selectedId = null;
@@ -86,11 +82,7 @@ for (const makeHarness of GROUND_HARNESSES) {
h.changeMapStack(); h.changeMapStack();
assert.equal(fill().options.drape, 'both'); assert.equal(fill().options.drape, 'both');
assert.equal(fill().items.length, 2); assert.equal(fill().items.length, 2);
if (h.cesium)
assert.equal(
h.primitives(SELECTION_FILL_SET_ID).fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
lifecycle.disable(); lifecycle.disable();
assert.equal(fill().items.length, 0); assert.equal(fill().items.length, 0);
assert.equal(h.mapStackRemoved(), 1); assert.equal(h.mapStackRemoved(), 1);
+51 -146
View File
@@ -1,3 +1,4 @@
import { createFakeGroundHarness as fakeHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// The Space Missions display on the engine contract, on the fake engine and // The Space Missions display on the engine contract, on the fake engine and
// on the Cesium engine (real Cesium collections and polylines over a stub // on the Cesium engine (real Cesium collections and polylines over a stub
// scene: they draw nothing but hold what the engine writes): the dots and the // scene: they draw nothing but hold what the engine writes): the dots and the
@@ -8,11 +9,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { dashPattern } from '../../../globe/cesium/lines.js';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js'; import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js';
import { createLaunchesDisplay } from './display.js'; import { createLaunchesDisplay } from './display.js';
@@ -21,42 +21,7 @@ import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const reticle = { width: 48, height: 48, getContext: () => null }; const reticle = { width: 48, height: 48, getContext: () => null };
function fakeHarness() { const HARNESSES = [fakeHarness];
const engine = createFakeEngine({ canvas: { style: { cursor: '' } } });
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, viewer, ...rest } = createCesiumMoversEngine({
engineOptions: {
groundFactories: {
fills: (options) => ({
...options,
ready: true,
destroy() {},
isDestroyed: () => false,
}),
},
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
viewer.scene.globe.getHeight = () => 7;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function setup(makeHarness) { function setup(makeHarness) {
const harness = makeHarness(); const harness = makeHarness();
@@ -109,29 +74,18 @@ for (const makeHarness of HARNESSES) {
assert.equal(five.kind, 'point'); assert.equal(five.kind, 'point');
assert.equal(five.count, 1); assert.equal(five.count, 1);
assert.equal(four.count, 1); assert.equal(four.count, 1);
if (h.cesium) {
const point = h.primitive(five.id).get(0); const set = engine.fake.moverSet(five.id);
assert.equal(point.pixelSize, 5); assert.equal(set.options.point.sizePx, 5);
assert.equal(point.outlineWidth, 0); assert.equal(set.options.point.outlinePx, 0);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY); assert.equal(set.options.depthTest, 'always');
assert.equal(point.id, 'rocket-launch:a'); assert.equal(set.options.pickable, true);
assert.equal(point.show, true); assert.equal(set.store.pickIds[0], 'rocket-launch:a');
assert.ok(Cesium.Cartesian3.equals(point.position, PAD)); near(set.store.colors[1], color.green, 'green');
near(point.color.green, color.green, 'green');
assert.equal(h.primitive(four.id).get(0).pixelSize, 4);
} else {
const set = engine.fake.moverSet(five.id);
assert.equal(set.options.point.sizePx, 5);
assert.equal(set.options.point.outlinePx, 0);
assert.equal(set.options.depthTest, 'always');
assert.equal(set.options.pickable, true);
assert.equal(set.store.pickIds[0], 'rocket-launch:a');
near(set.store.colors[1], color.green, 'green');
}
pad.show = false; pad.show = false;
await settle(); await settle();
if (h.cesium) assert.equal(h.primitive(five.id).get(0).show, false); assert.equal(engine.fake.moverSet(five.id).store.shows[0], 0);
else assert.equal(engine.fake.moverSet(five.id).store.shows[0], 0);
display.setVisible(false); display.setVisible(false);
assert.deepEqual( assert.deepEqual(
engine.movers.list().map(({ count }) => count), engine.movers.list().map(({ count }) => count),
@@ -155,23 +109,13 @@ for (const makeHarness of HARNESSES) {
await settle(); await settle();
const [listed] = engine.movers.list(); const [listed] = engine.movers.list();
assert.equal(listed.kind, 'sprite'); assert.equal(listed.kind, 'sprite');
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0); const set = engine.fake.moverSet(listed.id);
assert.equal(billboard.width, 24); assert.equal(set.options.sprite.sizePx, 24);
assert.equal(billboard.height, 24); assert.equal(set.options.sprite.atlas.canvas, reticle);
assert.equal( assert.equal(set.options.depthTest, 'always');
billboard.disableDepthTestDistance, assert.equal(set.store.shows[0], 0);
Number.POSITIVE_INFINITY,
);
assert.equal(billboard.id, 'rocket-launch:a');
assert.equal(billboard.show, false);
} else {
const set = engine.fake.moverSet(listed.id);
assert.equal(set.options.sprite.sizePx, 24);
assert.equal(set.options.sprite.atlas.canvas, reticle);
assert.equal(set.options.depthTest, 'always');
assert.equal(set.store.shows[0], 0);
}
display.destroy(); display.destroy();
h.destroy(); h.destroy();
}); });
@@ -200,23 +144,21 @@ for (const makeHarness of HARNESSES) {
assert.equal(reads, 2); assert.equal(reads, 2);
const [listed] = engine.movers.list(); const [listed] = engine.movers.list();
const drawn = () => { const drawn = () => {
if (h.cesium) return h.primitive(listed.id).get(0).position;
const { positions } = engine.fake.moverSet(listed.id).store; const { positions } = engine.fake.moverSet(listed.id).store;
return geo.cartesian3.create(positions[0], positions[1], positions[2]); return geo.cartesian3.create(positions[0], positions[1], positions[2]);
}; };
assert.ok(geo.cartesian3.equalsEpsilon(drawn(), at, 0, 1e-6)); assert.ok(geo.cartesian3.equalsEpsilon(drawn(), at, 0, 1e-6));
display.setVisible(false); display.setVisible(false);
if (!h.cesium) engine.fake.resetCalls(); engine.fake.resetCalls();
at = geo.cartesian3.fromDegrees(2, 0, 400_000); at = geo.cartesian3.fromDegrees(2, 0, 400_000);
h.frame(); h.frame();
h.frame(); h.frame();
assert.equal(reads, 2, 'hidden motion is not read'); assert.equal(reads, 2, 'hidden motion is not read');
if (!h.cesium) assert.equal(
assert.equal( engine.fake.renderRequests,
engine.fake.renderRequests, 0,
0, 'hidden motion must not sustain rendering',
'hidden motion must not sustain rendering', );
);
display.destroy(); display.destroy();
h.destroy(); h.destroy();
}); });
@@ -235,33 +177,19 @@ for (const makeHarness of HARNESSES) {
dash: { lengthPx: 6, gapPx: 6 }, dash: { lengthPx: 6, gapPx: 6 },
}); });
const [listed] = engine.lines.list(); const [listed] = engine.lines.list();
if (h.cesium) {
const primitive = h.primitive(listed.id); const { options } = engine.fake.line(listed.id);
const { geometry, id } = primitive.geometryInstances; assert.equal(options.widthPx, 1.5);
assert.equal(geometry._width, 1.5); assert.equal(options.arc, 'none');
assert.equal(geometry._arcType, Cesium.ArcType.NONE); assert.equal(options.pickId, 'rocket-transfer:a');
assert.equal(id, 'rocket-transfer:a'); assert.deepEqual(options.dash, {
const { material } = primitive.appearance; kind: 'pixels',
assert.equal(material.type, 'PolylineDash'); lengthPx: 6,
// Cesium's dashLength 24 px with 0xf0f0: 6 px on, 6 px off. gapPx: 6,
assert.equal(material.uniforms.dashLength, 12); });
assert.equal(material.uniforms.dashPattern, dashPattern(6, 6)); assert.deepEqual(options.color, color.rgba());
assert.equal(material.uniforms.dashPattern, 0xff00); assert.equal(options.depthFailAlpha, 0);
near(material.uniforms.color.alpha, 0.8, 'alpha');
assert.equal(primitive.depthFailAppearance, undefined);
} else {
const { options } = engine.fake.line(listed.id);
assert.equal(options.widthPx, 1.5);
assert.equal(options.arc, 'none');
assert.equal(options.pickId, 'rocket-transfer:a');
assert.deepEqual(options.dash, {
kind: 'pixels',
lengthPx: 6,
gapPx: 6,
});
assert.deepEqual(options.color, color.rgba());
assert.equal(options.depthFailAlpha, 0);
}
transfer.show = false; transfer.show = false;
assert.equal(engine.lines.list()[0].shown, false); assert.equal(engine.lines.list()[0].shown, false);
transfer.show = true; transfer.show = true;
@@ -291,27 +219,16 @@ for (const makeHarness of HARNESSES) {
const [listed] = engine.lines.list(); const [listed] = engine.lines.list();
modelMatrix[12] = 42; modelMatrix[12] = 42;
ring.setModelMatrix(modelMatrix); ring.setModelMatrix(modelMatrix);
if (h.cesium) {
const primitive = h.primitive(listed.id); const line = engine.fake.line(listed.id);
const { material } = primitive.appearance; assert.deepEqual(line.options.dash, {
assert.equal(material.type, 'GevLineTactical'); kind: 'tactical',
assert.equal(material.uniforms.groupCount, 4); groups: 4,
assert.equal(material.uniforms.dashCount, 100); dashes: 100,
near(material.uniforms.color.alpha, 0.95, 'alpha'); });
assert.equal(primitive.geometryInstances.geometry._width, 3); assert.equal(line.modelMatrix[12], 42);
assert.equal(primitive.modelMatrix[12], 42); assert.equal(line.options.pickId, undefined);
// The live ring is not picked (Cesium's polyline collection).
assert.equal(primitive.allowPicking, false);
} else {
const line = engine.fake.line(listed.id);
assert.deepEqual(line.options.dash, {
kind: 'tactical',
groups: 4,
dashes: 100,
});
assert.equal(line.modelMatrix[12], 42);
assert.equal(line.options.pickId, undefined);
}
display.destroy(); display.destroy();
assert.deepEqual(engine.lines.list(), []); assert.deepEqual(engine.lines.list(), []);
h.destroy(); h.destroy();
@@ -422,19 +339,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(record.shown, false); assert.equal(record.shown, false);
display.setVisible(true); display.setVisible(true);
assert.equal(h.set(id).shown, true); assert.equal(h.set(id).shown, true);
if (h.cesium) {
const [primitive] = h.primitives(id).fills;
assert.equal(
primitive.options.classificationType,
Cesium.ClassificationType.BOTH,
);
assert.equal(primitive.options.allowPicking, false);
const appearance = primitive.options.appearance;
assert.equal(appearance.flat, true);
assert.equal(appearance.renderState.polygonOffset.factor, -1);
for (const [key, value] of Object.entries(radial))
assert.equal(appearance.material.uniforms[key], value);
}
pad.show = false; pad.show = false;
display.setVisible(false); display.setVisible(false);
display.setVisible(true); display.setVisible(true);
+20 -62
View File
@@ -1,16 +1,12 @@
// The OSM Places points on the engine contract, on the fake engine and on // Layer display behavior on the fake engine.
// the Cesium engine (real Cesium point collections over a stub scene): the
// disc's size, outline, colour and close-range depth test, its height on the
// sampled surface, what a pick returns, and the points' life across
// toggles, a globe switch and teardown.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { createOsmPlacesLayer, OSM_PLACE_COLOR } from '../index.js'; import { createOsmPlacesLayer } from '../index.js';
import { createOsmPlacesDisplay } from './display.js'; import { createOsmPlacesDisplay } from './display.js';
const places = [ const places = [
@@ -31,22 +27,7 @@ function fakeHarness() {
}; };
} }
function cesiumHarness() { const HARNESSES = [fakeHarness];
const { engine, viewer, primitive } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
primitive,
surface(height) {
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => height;
},
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function layerOn(engine) { function layerOn(engine) {
const overlay = new Map(); const overlay = new Map();
@@ -76,38 +57,17 @@ for (const makeHarness of HARNESSES) {
display.setVisible(true); display.setVisible(true);
display.replace(places); display.replace(places);
assert.equal(count(h.engine), 2); assert.equal(count(h.engine), 2);
if (h.cesium) {
const collection = h.primitive('osm-places'); const set = h.engine.fake.moverSet('osm-places');
assert.ok(collection instanceof Cesium.PointPrimitiveCollection); assert.equal(set.options.kind, 'point');
const point = collection.get(0); assert.equal(set.options.point.sizePx, 9);
assert.equal(point.pixelSize, 9); assert.equal(set.options.point.outlinePx, 2);
assert.equal(point.outlineWidth, 2); assert.deepEqual(set.options.point.outlineColor, [0, 0, 0, 0.8]);
assert.ok( assert.deepEqual(set.options.depthTest, { disableWithinM: 2500 });
point.outlineColor.equalsEpsilon( assert.equal(set.options.pickable, true);
Cesium.Color.BLACK.withAlpha(0.8), assert.ok(set.options.order < 0, 'under the moving contacts');
1e-6, assert.equal(set.store.pickIds[1], 'osm-place:node/2');
), assert.ok(Math.abs(set.store.colors[0] - 0x7f / 255) < 1e-6);
);
assert.ok(
point.color.equalsEpsilon(
Cesium.Color.fromCssColorString(OSM_PLACE_COLOR),
1e-6,
),
);
assert.equal(point.disableDepthTestDistance, 2500);
assert.equal(point.id, 'osm-place:node/1', 'a pick names the place');
} else {
const set = h.engine.fake.moverSet('osm-places');
assert.equal(set.options.kind, 'point');
assert.equal(set.options.point.sizePx, 9);
assert.equal(set.options.point.outlinePx, 2);
assert.deepEqual(set.options.point.outlineColor, [0, 0, 0, 0.8]);
assert.deepEqual(set.options.depthTest, { disableWithinM: 2500 });
assert.equal(set.options.pickable, true);
assert.ok(set.options.order < 0, 'under the moving contacts');
assert.equal(set.store.pickIds[1], 'osm-place:node/2');
assert.ok(Math.abs(set.store.colors[0] - 0x7f / 255) < 1e-6);
}
} finally { } finally {
display.destroy(); display.destroy();
h.destroy(); h.destroy();
@@ -119,12 +79,10 @@ for (const makeHarness of HARNESSES) {
const h = makeHarness(); const h = makeHarness();
const display = createOsmPlacesDisplay(h.engine); const display = createOsmPlacesDisplay(h.engine);
const position = () => const position = () =>
h.cesium (() => {
? h.primitive('osm-places').get(0).position const { positions } = h.engine.fake.moverSet('osm-places').store;
: (() => { return { x: positions[0], y: positions[1], z: positions[2] };
const { positions } = h.engine.fake.moverSet('osm-places').store; })();
return { x: positions[0], y: positions[1], z: positions[2] };
})();
try { try {
h.surface(undefined); h.surface(undefined);
display.setVisible(true); display.setVisible(true);
+3 -50
View File
@@ -1,17 +1,12 @@
// The fire perimeters on the engine contract, on the fake engine and on the // Layer display behavior on the fake engine.
// Cesium engine (its ground batches over fake primitives): fills with their
// holes at 0.25 and a 2 px fire line at 0.9 along the outer ring only, in
// the containment colour; clicks through the engine's input selecting an
// incident's card; visibility and teardown.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { import {
GROUND_HARNESSES, GROUND_HARNESSES,
createFakeGroundHarness, createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs'; } from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { containmentAccent } from '../cards.js'; import { containmentAccent } from '../cards.js';
@@ -137,51 +132,9 @@ for (const makeHarness of GROUND_HARNESSES) {
lines.forEach(({ outline }, index) => lines.forEach(({ outline }, index) =>
assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()), assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()),
); );
if (f.cesium) {
const primitives = f.primitives('fire-perimeters');
assert.equal(primitives.fills.length, 1, 'one fill batch');
assert.equal(primitives.lines.length, 1, 'one fire-line batch');
const fillInstances = primitives.fills[0].options.geometryInstances;
const lineInstances = primitives.lines[0].options.geometryInstances;
assert.equal(fillInstances.length, 3);
assert.equal(lineInstances.length, 3, 'no line along a hole');
assert.equal(
primitives.fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
assert.equal(primitives.fills[0].options.allowPicking, true);
assert.ok(fillInstances[0].geometry instanceof Cesium.PolygonGeometry);
assert.equal(
fillInstances[0].geometry._polygonHierarchy.holes.length,
1,
);
fillInstances.forEach((instance, index) =>
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.25).rgba()[at],
`fill ${index}.${at}`,
0.5 / 255,
),
),
);
lineInstances.forEach((instance, index) => {
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
assert.equal(instance.geometry.width, 2);
assert.equal(instance.geometry.loop, true);
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.9).rgba()[at],
`line ${index}.${at}`,
0.5 / 255,
),
);
});
}
f.layer.disable(); f.layer.disable();
assert.equal(f.set('fire-perimeters').shown, false); assert.equal(f.set('fire-perimeters').shown, false);
if (f.cesium) assert.equal(f.collectionShown('fire-perimeters'), false);
} finally { } finally {
f.layer.destroy(); f.layer.destroy();
} }
+6 -97
View File
@@ -1,17 +1,11 @@
// The radio display on the engine contract, on the fake engine and on the // Layer display behavior on the fake engine.
// Cesium engine (real Cesium point and billboard collections over a stub
// scene: they draw nothing but hold what the engine writes): station,
// cluster and selection styles, screen clustering, the direct click that
// plays, the distance cut, follow ownership, the Earth disc for recentering
// and what enable/disable cycles and teardown leave.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { projectEarthDiscToViewport } from '../../../celestialRing.js'; import { projectEarthDiscToViewport } from '../../../celestialRing.js';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { createRadioLayer } from '../index.js'; import { createRadioLayer } from '../index.js';
@@ -85,54 +79,7 @@ function fakeHarness() {
}; };
} }
function cesiumHarness() { const HARNESSES = [fakeHarness];
const handlers = createFakeEventHandlers();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
frame,
primitive,
click(picked) {
native = picked
? { primitive: primitive(picked.owner).get(picked.instance) }
: undefined;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
},
instance(id, index) {
const item = primitive(id).get(index);
return {
shown: index < count(id) && item.show,
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
pixelSize: item.pixelSize ?? item.width * item.scale,
id: item.id,
native: item,
};
},
pick: (id, index) => ({ owner: id, instance: index }),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function fixture(t, makeHarness) { function fixture(t, makeHarness) {
const previous = globalThis.Audio; const previous = globalThis.Audio;
@@ -252,32 +199,7 @@ for (const makeHarness of HARNESSES) {
.forEach((value, i) => .forEach((value, i) =>
near(cluster.color[i], value, `cluster colour ${i}`), near(cluster.color[i], value, `cluster colour ${i}`),
); );
if (f.cesium) { {
const point = station.native;
assert.ok(
f.primitive('radio-stations') instanceof
Cesium.PointPrimitiveCollection,
);
assert.equal(point.outlineWidth, 1);
assert.ok(
point.outlineColor.equalsEpsilon(
Cesium.Color.fromCssColorString('#071b25'),
1e-6,
),
);
assert.ok(
Cesium.NearFarScalar.equals(
point.scaleByDistance,
new Cesium.NearFarScalar(100_000, 1.15, 12_000_000, 1),
),
);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(cluster.native.outlineWidth, 2);
assert.equal(
cluster.native.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
} else {
const { options } = f.engine.fake.moverSet('radio-stations'); const { options } = f.engine.fake.moverSet('radio-stations');
assert.equal(options.point.sizePx, 13); assert.equal(options.point.sizePx, 13);
assert.equal(options.point.outlinePx, 1); assert.equal(options.point.outlinePx, 1);
@@ -313,20 +235,7 @@ for (const makeHarness of HARNESSES) {
const bracket = f.instance('radio-bracket', 0); const bracket = f.instance('radio-bracket', 0);
near(bracket.pixelSize, 40, 'bracket size'); near(bracket.pixelSize, 40, 'bracket size');
assert.equal(bracket.id, `radio:selected:${clusterId}`); assert.equal(bracket.id, `radio:selected:${clusterId}`);
if (f.cesium) { {
assert.equal(selected.native.outlineWidth, 2);
assert.equal(selected.native.scaleByDistance, undefined);
assert.ok(
Cesium.NearFarScalar.equals(
bracket.native.scaleByDistance,
new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72),
),
);
assert.equal(
bracket.native.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
} else {
const { options } = f.engine.fake.moverSet('radio-selected'); const { options } = f.engine.fake.moverSet('radio-selected');
assert.equal(options.point.sizePx, 14); assert.equal(options.point.sizePx, 14);
assert.equal(options.point.scaleByDistance, null); assert.equal(options.point.scaleByDistance, null);
@@ -1,12 +1,10 @@
// The Recent Imagery display on the fake engine and on the Cesium engine // Layer display behavior on the fake engine.
// over a stub scene: the same calls, read back from each engine's own
// store (the fake's imagery layers, Cesium's imagery layers and their
// template providers).
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { BOX } from '../testDoubles.mjs'; import { BOX } from '../testDoubles.mjs';
import { createRecentImageryDisplay } from './display.js'; import { createRecentImageryDisplay } from './display.js';
@@ -42,62 +40,7 @@ function fakeHarness() {
}; };
} }
function cesiumHarness() { for (const make of [fakeHarness]) {
const { engine, viewer } = createCesiumMoversEngine();
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
// The map's base imagery, under the slot's layers.
const tilingScheme = new Cesium.WebMercatorTilingScheme();
const base = new Cesium.ImageryLayer({
tilingScheme,
rectangle: tilingScheme.rectangle,
tileWidth: 256,
tileHeight: 256,
minimumLevel: 0,
maximumLevel: 18,
errorEvent: new Cesium.Event(),
requestImage: () => undefined,
});
viewer.imageryLayers.add(base);
viewer.scene.globe.show = true;
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
const side = (direction) =>
({
[Cesium.SplitDirection.LEFT]: 'left',
[Cesium.SplitDirection.RIGHT]: 'right',
})[direction] ?? 'none';
const degrees = (value) => Number(Cesium.Math.toDegrees(value).toFixed(9));
return {
name: 'the Cesium engine',
engine,
drawn: () =>
Array.from({ length: viewer.imageryLayers.length - 1 }, (_, i) => {
const layer = viewer.imageryLayers.get(i + 1);
const provider = layer.imageryProvider;
const { west, south, east, north } = provider.rectangle;
return {
url: provider.url,
maxLevel: provider.maximumLevel,
opaque: !provider.hasAlphaChannel,
rectangle: {
west: degrees(west),
south: degrees(south),
east: degrees(east),
north: degrees(north),
},
credit: provider.credit?.html ?? null,
alpha: layer.alpha,
split: side(layer.splitDirection),
shown: layer.show,
};
}),
hideSurface() {
viewer.scene.globe.show = false;
},
destroy: () => engine.destroy(),
};
}
for (const make of [fakeHarness, cesiumHarness]) {
const name = make().name; const name = make().name;
test(`${name}: a slot drapes one GIBS template bounded to the box; the same day only restyles it`, () => { test(`${name}: a slot drapes one GIBS template bounded to the box; the same day only restyles it`, () => {
+17 -88
View File
@@ -1,3 +1,4 @@
import { createFakeGroundHarness as fakeHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// The satellites display on the engine contract, on the fake engine and on // The satellites display on the engine contract, on the fake engine and on
// the Cesium engine (real Cesium point collections and polylines over a stub // the Cesium engine (real Cesium point collections and polylines over a stub
// scene: they draw nothing but hold what the engine writes): the fleet's // scene: they draw nothing but hold what the engine writes): the fleet's
@@ -8,10 +9,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { CYAN, WHITE, nearFarScale } from '../../shared/color.js'; import { CYAN, WHITE, nearFarScale } from '../../shared/color.js';
import { createSatellitesDisplay } from './display.js'; import { createSatellitesDisplay } from './display.js';
@@ -34,28 +35,7 @@ const near = (actual, expected, label, tolerance = 1e-6) =>
`${label}: ${actual} vs ${expected}`, `${label}: ${actual} vs ${expected}`,
); );
function fakeHarness() { const HARNESSES = [fakeHarness];
const engine = createFakeEngine();
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, ...rest } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function setup(makeHarness) { function setup(makeHarness) {
const harness = makeHarness(); const harness = makeHarness();
@@ -70,20 +50,6 @@ const setsOf = (engine) =>
/** What a set's instance draws: its size, outline, depth mode and colour. */ /** What a set's instance draws: its size, outline, depth mode and colour. */
function drawn(h, setId, index) { function drawn(h, setId, index) {
if (h.cesium) {
const item = h.primitive(setId).get(index);
return {
position: item.position,
pixelSize: item.pixelSize,
outlineWidth: item.outlineWidth,
outline: item.outlineColor,
depth: item.disableDepthTestDistance,
scale: item.scaleByDistance,
color: item.color,
show: item.show,
id: item.id,
};
}
const { options, store } = h.engine.fake.moverSet(setId); const { options, store } = h.engine.fake.moverSet(setId);
const [r, g, b, a] = store.colors.slice(index * 4, index * 4 + 4); const [r, g, b, a] = store.colors.slice(index * 4, index * 4 + 4);
const [nearM, nearValue, farM, farValue] = const [nearM, nearValue, farM, farValue] =
@@ -139,9 +105,7 @@ for (const makeHarness of HARNESSES) {
// Reads are the display's own copies. // Reads are the display's own copies.
assert.notEqual(all[0].color, WHITE); assert.notEqual(all[0].color, WHITE);
assert.equal(all[0].color.alpha, 1); assert.equal(all[0].color.alpha, 1);
const commits = h.cesium const commits = () => engine.fake.moverSet(listed.id).commits;
? null
: () => engine.fake.moverSet(listed.id).commits;
const before = commits?.(); const before = commits?.();
const remove = display.onFrame(() => { const remove = display.onFrame(() => {
all[0].position = { x: 1, y: 2, z: 3 }; all[0].position = { x: 1, y: 2, z: 3 };
@@ -222,8 +186,7 @@ for (const makeHarness of HARNESSES) {
'yellow', 'yellow',
); );
assert.equal(dot.id, 25544); assert.equal(dot.id, 25544);
if (!h.cesium) assert.equal(engine.fake.moverSet(tracked.id).options.order, 100);
assert.equal(engine.fake.moverSet(tracked.id).options.order, 100);
assert.ok( assert.ok(
setsOf(engine) setsOf(engine)
.filter(({ id }) => id !== tracked.id) .filter(({ id }) => id !== tracked.id)
@@ -271,25 +234,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(following.label.position(), target); assert.equal(following.label.position(), target);
assert.equal(display.follow.current(), handle); assert.equal(display.follow.current(), handle);
assert.equal(following.target(), target); assert.equal(following.target(), target);
if (h.cesium) {
const proxy = h.viewer.trackedEntity;
assert.ok(proxy instanceof Cesium.Entity);
assert.equal(
proxy.trackingReferenceFrame,
Cesium.TrackingReferenceFrame.ENU,
);
assert.equal(proxy.gevTrackedId, 'satellites:25544');
assert.equal(proxy.gevSelectionOrigin, 'user');
const scratch = new Cesium.Cartesian3();
const value = proxy.viewFrom.getValue(
h.viewer.clock.currentTime,
scratch,
);
assert.deepEqual(
[value.x, value.y, value.z],
[offset.x, offset.y, offset.z],
);
}
// The display's own release is guarded; a later external change is not. // The display's own release is guarded; a later external change is not.
seen.length = 0; seen.length = 0;
const guards = []; const guards = [];
@@ -322,7 +267,7 @@ for (const makeHarness of HARNESSES) {
display.follow.start(handle); display.follow.start(handle);
display.removeTracked(handle); display.removeTracked(handle);
assert.equal(engine.camera.following(), null); assert.equal(engine.camera.following(), null);
if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined);
display.destroy(); display.destroy();
h.destroy(); h.destroy();
}); });
@@ -339,34 +284,18 @@ for (const makeHarness of HARNESSES) {
assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY); assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY);
const [before] = engine.lines.list(); const [before] = engine.lines.list();
assert.equal(before.points, 2); assert.equal(before.points, 2);
if (h.cesium) {
const primitive = h.primitive(before.id); const { options } = engine.fake.line(before.id);
assert.equal(primitive.allowPicking, false, 'rings are not picked'); assert.equal(options.widthPx, 2.5);
assert.equal( assert.equal(options.arc, 'geodesic');
primitive.geometryInstances.geometry._arcType, assert.equal(options.pickId, undefined, 'rings are not picked');
Cesium.ArcType.GEODESIC, near(options.color[3], 0.6, 'alpha');
); near(options.color[3] * options.depthFailAlpha, 0.35, 'occluded alpha');
assert.equal(primitive.geometryInstances.geometry._width, 2.5);
near(primitive.appearance.material.uniforms.color.alpha, 0.6, 'alpha');
near(
primitive.depthFailAppearance.material.uniforms.color.alpha,
0.35,
'occluded alpha',
);
assert.equal(primitive.appearance.material.uniforms.color.red, 0);
} else {
const { options } = engine.fake.line(before.id);
assert.equal(options.widthPx, 2.5);
assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, undefined, 'rings are not picked');
near(options.color[3], 0.6, 'alpha');
near(options.color[3] * options.depthFailAlpha, 0.35, 'occluded alpha');
}
orbit.modelMatrix[12] = 123; orbit.modelMatrix[12] = 123;
display.commitOrbit(orbit); display.commitOrbit(orbit);
assert.equal(engine.lines.list()[0].id, before.id, 'the same line'); assert.equal(engine.lines.list()[0].id, before.id, 'the same line');
if (h.cesium) assert.equal(h.primitive(before.id).modelMatrix[12], 123); assert.equal(engine.fake.line(before.id).modelMatrix[12], 123);
else assert.equal(engine.fake.line(before.id).modelMatrix[12], 123);
orbit.show = false; orbit.show = false;
assert.equal(orbit.show, false); assert.equal(orbit.show, false);
assert.equal(engine.lines.list()[0].shown, false); assert.equal(engine.lines.list()[0].shown, false);
+47
View File
@@ -0,0 +1,47 @@
import { isPointerFree } from '../../data/inputOwnership.js';
/** Selection state shared by record-backed marker layers. */
export function createSelectionKeeper({
state,
getSelectedEntityContext,
selectRecord,
clearSelection,
pickId = ({ picked }) => (typeof picked?.id === 'string' ? picked.id : null),
reselectContext = false,
}) {
return {
onClick(event) {
if (!isPointerFree()) return;
if (!state.enabled) return;
const id = pickId(event);
if (
id &&
state.recordById.has(id) &&
(id !== state.selectedId ||
(reselectContext && getSelectedEntityContext()?.id !== id))
)
selectRecord(id);
else if (state.selectedId) clearSelection();
},
reconcile({ claimSelection = false, layerId, records } = {}) {
const selected = getSelectedEntityContext();
if (
!claimSelection &&
(selected?.id !== state.selectedId ||
(layerId !== undefined && selected?.layerId !== layerId) ||
(records &&
!records.some((record) => record.id === state.selectedId)))
) {
state.selectedId = null;
return false;
}
return true;
},
};
}
/** Write the single selected marker; the caller owns the commit timing. */
export function writeSelectedMarker(set, fields) {
set.setCount(fields ? 1 : 0);
if (fields) set.set(0, fields);
}
@@ -1,11 +1,4 @@
// The cables display on the engine contract, on the fake engine and on the // Layer display behavior on the fake engine.
// Cesium engine: cable paths and their colours, widths, picks and drape per
// map stack; landing markers; the reference sweep (tips, horizon, labels,
// the publish skip, the winners' stems, the moveEnd and motion cadence);
// clicks that fly to a reference; hide, show and destroy; and the
// antimeridian edge cases. Each harness records what the ground sets were
// given and the flights asked for, drives preRender and moveEnd by hand, and
// clicks with a pick of a ground item, a mover instance or a line.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -13,8 +6,7 @@ import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { claimPointer, releasePointer } from '../../../data/inputOwnership.js'; import { claimPointer, releasePointer } from '../../../data/inputOwnership.js';
import { rgbaFromCss } from '../../shared/color.js'; import { rgbaFromCss } from '../../shared/color.js';
import { import {
@@ -162,81 +154,7 @@ function fakeHarness() {
}; };
} }
function cesiumHarness() { const HARNESSES = [fakeHarness];
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const watched = watch(engine);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
...watched,
preRender: () => viewer.scene.preRender.raiseEvent(viewer.scene),
moveEnd: () => viewer.camera.moveEnd.raiseEvent(),
/** The Cesium ground primitives a set drew, in its slot. */
groundPrimitives(id) {
const root = viewer.scene.groundPrimitives.get(0);
const found = [];
for (let index = 0; index < (root?.length ?? 0); index++) {
const slot = root.get(index);
for (let at = 0; at < slot.length; at++)
if (slot.get(at).gevGroundSet === id) found.push(slot.get(at));
}
return found;
},
point(id, index) {
const collection = primitive(id);
if (!collection || index >= count(id)) return undefined;
const item = collection.get(index);
return {
shown: item.show,
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
sizePx: item.pixelSize,
outlinePx: item.outlineWidth,
outlineColor: item.outlineColor,
id: item.id,
position: item.position,
native: item,
};
},
click(target) {
native =
target?.ground !== undefined
? { id: { gevGroundSet: target.ground, instance: target.instance } }
: target?.mover !== undefined
? { primitive: primitive(target.mover).get(target.instance) }
: target?.line !== undefined
? {
primitive: primitive(watched.line(target.line).owner),
id: watched.line(target.line).pickId,
}
: target;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
native = undefined;
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const PATHS = `${CABLE_OVERLAY_SOURCE_ID}-paths`; const PATHS = `${CABLE_OVERLAY_SOURCE_ID}-paths`;
const MARKERS = `${CABLE_OVERLAY_SOURCE_ID}-markers`; const MARKERS = `${CABLE_OVERLAY_SOURCE_ID}-markers`;
@@ -356,17 +274,6 @@ for (const makeHarness of HARNESSES) {
assert.equal(set.items[0].pickId, set.items[1].pickId); assert.equal(set.items[0].pickId, set.items[1].pickId);
assert.notEqual(set.items[0].pickId, set.items[2].pickId); assert.notEqual(set.items[0].pickId, set.items[2].pickId);
assert.deepEqual(set.items[0].positions, alpha.geometry.coordinates[0]); assert.deepEqual(set.items[0].positions, alpha.geometry.coordinates[0]);
if (f.cesium) {
const [batch] = f.groundPrimitives(PATHS);
assert.ok(batch instanceof Cesium.GroundPolylinePrimitive);
assert.equal(batch.geometryInstances.length, 3);
assert.equal(batch.geometryInstances[0].geometry.width, 2.5);
assert.equal(batch.classificationType, Cesium.ClassificationType.TERRAIN);
assert.deepEqual(
[...batch.geometryInstances[0].attributes.color.value],
[255, 0, 0, Math.round(0.92 * 255)],
);
}
}); });
test(`the paths drape on the active map stack's surface and follow its changes (${name})`, (t) => { test(`the paths drape on the active map stack's surface and follow its changes (${name})`, (t) => {
@@ -378,11 +285,7 @@ for (const makeHarness of HARNESSES) {
listener({ detail: { activeId: 'photoreal', status: 'ready' } }); listener({ detail: { activeId: 'photoreal', status: 'ready' } });
assert.equal(f.groundSet(PATHS).options.drape, 'tiles'); assert.equal(f.groundSet(PATHS).options.drape, 'tiles');
assert.equal(f.groundSet(PATHS).items.length, 3, 'rebuilt whole'); assert.equal(f.groundSet(PATHS).items.length, 3, 'rebuilt whole');
if (f.cesium)
assert.equal(
f.groundPrimitives(PATHS)[0].classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
listener({ detail: { activeId: 'osm', status: 'ready' } }); listener({ detail: { activeId: 'osm', status: 'ready' } });
assert.equal(f.groundSet(PATHS).options.drape, 'terrain'); assert.equal(f.groundSet(PATHS).options.drape, 'terrain');
listener({ detail: { activeId: 'some-future-stack' } }); listener({ detail: { activeId: 'some-future-stack' } });
@@ -440,19 +343,7 @@ for (const makeHarness of HARNESSES) {
const landingTip = f.point(LANDING_TIPS, 0); const landingTip = f.point(LANDING_TIPS, 0);
assert.equal(landingTip.sizePx, 8); assert.equal(landingTip.sizePx, 8);
near(landingTip.color, rgba(BASE_LANDING_COLOR, 0.94), 'landing tip'); near(landingTip.color, rgba(BASE_LANDING_COLOR, 0.94), 'landing tip');
if (f.cesium) {
assert.equal(alphaTip.native.disableDepthTestDistance, 0, 'depth-tested');
near(
[
alphaTip.outlineColor.red,
alphaTip.outlineColor.green,
alphaTip.outlineColor.blue,
alphaTip.outlineColor.alpha,
],
[0, 0, 0, 0.65],
'tip outline',
);
}
const tipHeight = geo.cartographic.fromCartesian(alphaTip.position).height; const tipHeight = geo.cartographic.fromCartesian(alphaTip.position).height;
assert.ok(tipHeight >= 700 && tipHeight <= 85_000, `tip ${tipHeight} m`); assert.ok(tipHeight >= 700 && tipHeight <= 85_000, `tip ${tipHeight} m`);
@@ -598,15 +489,14 @@ for (const makeHarness of HARNESSES) {
f.click({ id: 'cable-reference-0-alpha', owner: 'someone-else' }); f.click({ id: 'cable-reference-0-alpha', owner: 'someone-else' });
assert.equal(f.flights.length, 4); assert.equal(f.flights.length, 4);
f.display.hide(); f.display.hide();
if (!f.cesium) {
f.engine.fake.script('pick', { f.engine.fake.script('pick', {
id: 'cable-reference-0-alpha', id: 'cable-reference-0-alpha',
owner: PATHS, owner: PATHS,
}); });
f.engine.fake.input('click', { position: { x: 1, y: 1 } }); f.engine.fake.input('click', { position: { x: 1, y: 1 } });
f.engine.fake.unscript('pick'); f.engine.fake.unscript('pick');
assert.equal(f.flights.length, 4, 'a hidden display takes no clicks'); assert.equal(f.flights.length, 4, 'a hidden display takes no clicks');
}
}); });
test(`hide clears what the display drew and its labels; show draws it again (${name})`, (t) => { test(`hide clears what the display drew and its labels; show draws it again (${name})`, (t) => {
+5 -68
View File
@@ -1,9 +1,4 @@
// The traffic display on the engine contract, on the fake engine and on the // Layer display behavior on the fake engine.
// Cesium engine: the vehicle sets (outline under fill, depth-tested, not
// picked), each dot's size and fade by camera distance as Cesium's
// NearFarScalar, what is skipped, show and hide; the heat-line corridors
// (colours, widths, order, drape on 3D tiles) and the layer's jam and slow
// styles; the sets heights are sampled through; teardown.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -53,32 +48,7 @@ function drawn(h, id) {
const listed = h.engine.movers.list().find((set) => set.id === id); const listed = h.engine.movers.list().find((set) => set.id === id);
if (!listed) return undefined; if (!listed) return undefined;
const rows = []; const rows = [];
if (h.cesium) { {
const items = (collection) =>
Array.from({ length: collection?.length ?? 0 }, (_, i) =>
collection.get(i),
);
let collection;
for (const root of items(h.viewer.scene.primitives))
for (const child of items(root))
if (child.gevMoverSet === id) collection = child;
for (let i = 0; i < listed.count; i++) {
const point = collection.get(i);
rows.push({
sizePx: point.pixelSize,
color: [
point.color.red,
point.color.green,
point.color.blue,
point.color.alpha,
],
shown: point.show,
outlineWidth: point.outlineWidth,
depthTestDistance: point.disableDepthTestDistance,
scaleByDistance: point.scaleByDistance,
});
}
} else {
const { store, options } = h.engine.fake.moverSet(id); const { store, options } = h.engine.fake.moverSet(id);
for (let i = 0; i < store.count; i++) for (let i = 0; i < store.count; i++)
rows.push({ rows.push({
@@ -141,9 +111,7 @@ for (const makeHarness of GROUND_HARNESSES) {
[IDS.outline, IDS.fill], [IDS.outline, IDS.fill],
); );
for (const [index, id] of [IDS.outline, IDS.fill].entries()) { for (const [index, id] of [IDS.outline, IDS.fill].entries()) {
const { options } = h.cesium const { options } = h.engine.fake.moverSet(id);
? { options: null }
: h.engine.fake.moverSet(id);
assert.equal( assert.equal(
sets.find((set) => set.id === id).capacity, sets.find((set) => set.id === id).capacity,
MAX_DOTS, MAX_DOTS,
@@ -198,13 +166,6 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(outlines.length, 1, 'only the outlined dot has an outline'); assert.equal(outlines.length, 1, 'only the outlined dot has an outline');
near(outlines[0].sizePx, (7 + 2 * 2) * jamScale, 'outline size', 1e-4); near(outlines[0].sizePx, (7 + 2 * 2) * jamScale, 'outline size', 1e-4);
nearAll(outlines[0].color, [0, 0, 0, 0.9 * fade], 'outline colour', 1e-6); nearAll(outlines[0].color, [0, 0, 0, 0.9 * fade], 'outline colour', 1e-6);
if (h.cesium) {
for (const row of [...fills, ...outlines]) {
assert.equal(row.shown, true);
assert.equal(row.outlineWidth, 0);
assert.equal(row.depthTestDistance, 0, 'depth-tested');
}
}
}); });
test(`hidden, zero-size, faded-out and over-the-horizon dots draw nothing (${name})`, (t) => { test(`hidden, zero-size, faded-out and over-the-horizon dots draw nothing (${name})`, (t) => {
@@ -284,19 +245,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.deepEqual(jam.items[0].color, [1, 0, 0, 0.55]); assert.deepEqual(jam.items[0].color, [1, 0, 0, 0.55]);
assert.equal(slow.items.length, 1); assert.equal(slow.items.length, 1);
assert.equal(slow.items[0].widthPx, 4); assert.equal(slow.items[0].widthPx, 4);
if (h.cesium) {
const { fills, lines } = h.primitives(IDS.jam);
assert.equal(fills.length, 0);
assert.equal(lines.length, 1, 'one ground polyline batch');
assert.equal(
lines[0].options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
assert.equal(
lines[0].options.geometryInstances[0].geometry.width,
HEAT_LINE_JAM_WIDTH,
);
}
display.clearHeat(); display.clearHeat();
assert.equal(h.set(IDS.jam), undefined); assert.equal(h.set(IDS.jam), undefined);
assert.equal(h.set(IDS.slow), undefined); assert.equal(h.set(IDS.slow), undefined);
@@ -364,19 +313,7 @@ for (const makeHarness of GROUND_HARNESSES) {
h.engine.ground.list().find(({ id }) => id === IDS.jam).animated, h.engine.ground.list().find(({ id }) => id === IDS.jam).animated,
true, true,
); );
if (h.cesium) {
// The jam batch draws through the engine's animated material.
const [line] = h.primitives(IDS.jam).lines;
const { uniforms } = line.options.appearance.material;
near(uniforms.glowPower, 0.25, 'glowPower');
near(uniforms.pulseMin, 0.35, 'pulse min', 1e-6);
near(uniforms.pulseMax, 0.75, 'pulse max', 1e-6);
near(uniforms.pulsePeriod, (2 * Math.PI * 260) / 1000, 'pulse period');
assert.equal(
line.options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
}
assert.equal(slow.items.length, 1); assert.equal(slow.items.length, 1);
nearAll(slow.items[0].color, HEAT_SLOW_COLOR.rgba(), 'slow colour'); nearAll(slow.items[0].color, HEAT_SLOW_COLOR.rgba(), 'slow colour');
assert.equal(slow.items[0].widthPx, HEAT_LINE_SLOW_WIDTH); assert.equal(slow.items[0].widthPx, HEAT_LINE_SLOW_WIDTH);
+9 -171
View File
@@ -1,19 +1,11 @@
// The transit display on the engine contract, on the fake engine and on the // Layer display behavior on the fake engine.
// Cesium engine (real Cesium billboard collections and polylines over a stub
// scene: they draw nothing but hold what the engine writes): the two marker
// sets and their Billboard-shaped handles, one commit per frame, the glyph
// atlas, on-screen course rotation, the view test, the surface and credits,
// clicks (sensor styles included), the selected trail's history bands and
// head, and teardown.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { perspectiveProjectedRotation } from '../../../data/iconOrientation.js';
import { cameraSensitivityClaims } from '../../../data/cameraSensitivity.js'; import { cameraSensitivityClaims } from '../../../data/cameraSensitivity.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { transitIcon } from '../../../data/transitIcons.js'; import { transitIcon } from '../../../data/transitIcons.js';
@@ -65,91 +57,7 @@ function fakeHarness(options = {}) {
}; };
} }
function cesiumHarness() { const HARNESSES = [fakeHarness];
const handlers = createFakeEventHandlers();
const groundPrimitives = [];
const groundPrimitive = (options) => {
const primitive = {
...options,
ready: true,
destroy() {},
isDestroyed: () => false,
};
groundPrimitives.push(primitive);
return primitive;
};
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
groundFactories: {
lines: groundPrimitive,
fills: groundPrimitive,
},
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
let native;
let picked = [];
viewer.scene.pick = (position) => {
picked.push({ x: position.x, y: position.y });
return native;
};
viewer.scene.drillPick = () => (native ? [native] : []);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
groundPrimitives,
frame,
primitive,
picked: () => picked,
sprite(id, index) {
const billboard = primitive(id).get(index);
return {
shown: index < count(id) && billboard.show,
sizePx: billboard.width * billboard.scale,
color: [
billboard.color.red,
billboard.color.green,
billboard.color.blue,
billboard.color.alpha,
],
rotation: billboard.rotation,
id: billboard.id,
native: billboard,
};
},
line(id) {
const { points, shown } = engine.lines.list().find((l) => l.id === id);
const line = primitive(id);
const color = line?.appearance.material.uniforms.color;
return {
color: color && [color.red, color.green, color.blue, color.alpha],
widthPx: line?.geometryInstances.geometry._width,
depthFailAlpha: line?.depthFailAppearance
? line.depthFailAppearance.material.uniforms.color.alpha / color.alpha
: 0,
shown,
points,
};
},
click(position, pick) {
native = pick
? { primitive: primitive(pick.owner).get(pick.instance) }
: undefined;
picked = [];
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(position.x, position.y),
});
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function setup( function setup(
makeHarness, makeHarness,
@@ -325,13 +233,7 @@ for (const makeHarness of HARNESSES) {
rgbaFromCss('#5EF08A') rgbaFromCss('#5EF08A')
.rgba() .rgba()
.forEach((value, i) => near(glyph.color[i], value, `colour ${i}`)); .forEach((value, i) => near(glyph.color[i], value, `colour ${i}`));
if (f.cesium) { {
assert.equal(
glyph.native.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.ok(f.primitive('transit-1') instanceof Cesium.BillboardCollection);
} else {
const { options } = f.engine.fake.moverSet('transit-1'); const { options } = f.engine.fake.moverSet('transit-1');
assert.equal(options.depthTest, 'always'); assert.equal(options.depthTest, 'always');
assert.equal(options.pickable, true); assert.equal(options.pickable, true);
@@ -344,9 +246,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(glyph.cell, transitIcon('bus')); assert.equal(glyph.cell, transitIcon('bus'));
} }
// A frame's writes are one commit of the set they touch. // A frame's writes are one commit of the set they touch.
const before = f.cesium const before = [f.commits('transit-1'), f.commits('transit-motion-1')];
? null
: [f.commits('transit-1'), f.commits('transit-motion-1')];
let wrote = false; let wrote = false;
const remove = f.display.onPreRender(() => { const remove = f.display.onPreRender(() => {
if (wrote) return; if (wrote) return;
@@ -426,13 +326,6 @@ for (const makeHarness of HARNESSES) {
for (const course of [0, 90, 180, 270]) { for (const course of [0, 90, 180, 270]) {
const rotation = f.display.rotation(position, course, 0); const rotation = f.display.rotation(position, course, 0);
assert.ok(Number.isFinite(rotation)); assert.ok(Number.isFinite(rotation));
if (f.cesium)
near(
rotation,
perspectiveProjectedRotation(f.viewer.scene, position, course, 0),
`${height} m, heading ${heading}, course ${course}`,
1e-9,
);
} }
} }
assert.equal(f.display.rotation(position, NaN, 0.4), 0.4); assert.equal(f.display.rotation(position, NaN, 0.4), 0.4);
@@ -509,7 +402,7 @@ for (const makeHarness of HARNESSES) {
y: 50, y: 50,
collection: 'animated', collection: 'animated',
}); });
if (f.cesium) assert.deepEqual(f.picked()[0], { x: 40, y: 50 });
vehicles.get('mbta:1').markerCollection = f.state._markers; vehicles.get('mbta:1').markerCollection = f.state._markers;
f.click( f.click(
{ x: 40, y: 50 }, { x: 40, y: 50 },
@@ -623,13 +516,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(lines.length, 3); assert.equal(lines.length, 3);
const lanes = lines.map(({ id }) => ({ id, ...f.trailLine(id) })); const lanes = lines.map(({ id }) => ({ id, ...f.trailLine(id) }));
assert.ok(lanes.every((lane) => lane.options.dynamic)); assert.ok(lanes.every((lane) => lane.options.dynamic));
if (f.cesium) {
assert.equal(f.groundPrimitives.length, 3);
assert.ok(
f.groundPrimitives.every((primitive) => !primitive.asynchronous),
'history and head build immediately without worker jobs',
);
}
const [bodyBacking, bodyLane, headLane] = lanes; const [bodyBacking, bodyLane, headLane] = lanes;
assert.equal(bodyBacking.points, 2); assert.equal(bodyBacking.points, 2);
assert.equal(bodyLane.points, 2); assert.equal(bodyLane.points, 2);
@@ -649,7 +536,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(headLane.fraction, 0.5); assert.equal(headLane.fraction, 0.5);
assert.equal(headLane.commits, 1, 'head geometry built once'); assert.equal(headLane.commits, 1, 'head geometry built once');
assert.equal(headLane.options.pickable, undefined, 'picks pass through'); assert.equal(headLane.options.pickable, undefined, 'picks pass through');
if (!f.cesium) { {
assert.equal(bodyBacking.widthPx, 5); assert.equal(bodyBacking.widthPx, 5);
assert.equal(bodyLane.widthPx, 3); assert.equal(bodyLane.widthPx, 3);
} }
@@ -818,55 +705,6 @@ test('sensor styles drawn by the engine move a click to the pixel the shader sho
engine.destroy(); engine.destroy();
}); });
test("on the Cesium engine the surveillance lens and FLIR's pixel grid move a click too", () => {
const h = cesiumHarness();
// The scene's post stages, which draw the styles.
h.viewer.scene.postProcessStages = {
bloom: { enabled: false, uniforms: {} },
add() {},
remove() {},
};
Object.assign(h.viewer.scene.canvas, {
clientWidth: 1920,
clientHeight: 1080,
width: 1920,
height: 1080,
});
const state = { _stylePreset: 'surveillance', _vehicles: new Map() };
const display = createTransitDisplay(h.engine, {
state,
services: {},
drawAtlas: transitTestAtlas,
});
display.attach();
const seen = [];
display.bindClick(
({ x, y }) => seen.push([x, y]),
() => true,
);
h.click({ x: 1440, y: 540 });
assert.deepEqual(seen.at(-1), [1440, 540], 'no style, no bend');
h.engine.render.setStyle('surveillance', {
intensity: 1,
uniforms: { pixelation: 1 },
});
h.click({ x: 1440, y: 540 });
near(seen.at(-1)[0], 1472, 'barrel distortion');
near(seen.at(-1)[1], 540, 'on the axis');
near(h.picked().at(-1).x, 1472, 'the engine picks where the lens drew');
state._stylePreset = 'thermal';
h.engine.render.setStyle('surveillance', { intensity: 0 });
h.engine.render.setStyle('thermal', {
intensity: 1,
uniforms: { pixelation: 1.5 },
});
h.click({ x: 100.9, y: 200.1 });
near(seen.at(-1)[0], 100.5, 'pixel grid x');
near(seen.at(-1)[1], 201, 'pixel grid y');
display.destroy();
h.engine.destroy();
});
test('terrain answers the floor unless photoreal tiles hide the globe', async () => { test('terrain answers the floor unless photoreal tiles hide the globe', async () => {
const engine = createFakeEngine(); const engine = createFakeEngine();
const display = createTransitDisplay(engine, { const display = createTransitDisplay(engine, {
+7 -73
View File
@@ -1,3 +1,4 @@
import { createFakeGroundHarness as fakeHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// The vessels display on the engine contract, on the fake engine and on the // The vessels display on the engine contract, on the fake engine and on the
// Cesium engine (real Cesium billboards and polylines over a stub scene: // Cesium engine (real Cesium billboards and polylines over a stub scene:
// they draw nothing but hold what the engine writes): the chevron bank // they draw nothing but hold what the engine writes): the chevron bank
@@ -10,7 +11,7 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { createPickRegistry } from '../../../data/pickRegistry.js'; import { createPickRegistry } from '../../../data/pickRegistry.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { vesselTypeCss } from '../../../data/vesselLabels.js'; import { vesselTypeCss } from '../../../data/vesselLabels.js';
@@ -32,28 +33,7 @@ const near = (actual, expected, label, tolerance = 1e-6) =>
const CARGO = vesselChevronIcon(vesselTypeCss('Cargo'), false); const CARGO = vesselChevronIcon(vesselTypeCss('Cargo'), false);
const SELECTED = vesselChevronIcon('#ffffff', true); const SELECTED = vesselChevronIcon('#ffffff', true);
function fakeHarness() { const HARNESSES = [fakeHarness];
const engine = createFakeEngine();
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, ...rest } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
async function setup(makeHarness) { async function setup(makeHarness) {
const harness = makeHarness(); const harness = makeHarness();
@@ -82,17 +62,6 @@ const spec = (record, overrides = {}) => ({
/** What a chevron draws. */ /** What a chevron draws. */
function drawn(h, setId, index) { function drawn(h, setId, index) {
if (h.cesium) {
const billboard = h.primitive(setId).get(index);
return {
id: billboard.id,
position: billboard.position,
scale: billboard.scale,
rotation: billboard.rotation,
alpha: billboard.color.alpha,
show: billboard.show,
};
}
const { store } = h.engine.fake.moverSet(setId); const { store } = h.engine.fake.moverSet(setId);
return { return {
id: store.pickIds[index], id: store.pickIds[index],
@@ -147,23 +116,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(chevron.id, '111', 'picks name the MMSI'); assert.equal(chevron.id, '111', 'picks name the MMSI');
near(chevron.scale, 0.6, 'scale'); near(chevron.scale, 0.6, 'scale');
assert.equal(chevron.show, true); assert.equal(chevron.show, true);
if (h.cesium) { {
const billboard = h.primitive(listed.id).get(0);
assert.ok(h.primitive(listed.id) instanceof Cesium.BillboardCollection);
assert.equal(billboard.width, 32);
assert.equal(billboard.height, 32);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(billboard.horizontalOrigin, Cesium.HorizontalOrigin.CENTER);
assert.equal(billboard.verticalOrigin, Cesium.VerticalOrigin.CENTER);
assert.ok(
Cesium.Cartesian3.equals(billboard.alignedAxis, Cesium.Cartesian3.ZERO),
'rotation in screen space',
);
assert.equal(billboard.scaleByDistance, undefined);
} else {
const { options } = engine.fake.moverSet(listed.id); const { options } = engine.fake.moverSet(listed.id);
assert.equal(options.depthTest, 'always'); assert.equal(options.depthTest, 'always');
assert.equal(options.sprite.sizePx, 32); assert.equal(options.sprite.sizePx, 32);
@@ -173,9 +126,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(chevron.cell, CARGO); assert.equal(chevron.cell, CARGO);
} }
// Writes inside a frame listener commit at its end, once. // Writes inside a frame listener commit at its end, once.
const commits = h.cesium const commits = () => engine.fake.moverSet(listed.id).commits;
? null
: () => engine.fake.moverSet(listed.id).commits;
const before = commits?.(); const before = commits?.();
const moved = geo.cartesian3.fromDegrees(-73.8, 40.6, 1); const moved = geo.cartesian3.fromDegrees(-73.8, 40.6, 1);
const remove = display.view.on('preRender', () => { const remove = display.view.on('preRender', () => {
@@ -193,7 +144,7 @@ for (const makeHarness of HARNESSES) {
near(chevron.rotation, 0.5, 'rotation'); near(chevron.rotation, 0.5, 'rotation');
near(chevron.scale, 0.72, 'scale'); near(chevron.scale, 0.72, 'scale');
near(chevron.alpha, 0.3, 'alpha'); near(chevron.alpha, 0.3, 'alpha');
if (!h.cesium) assert.equal(chevron.cell, SELECTED); assert.equal(chevron.cell, SELECTED);
// Reads are the display's own copies. // Reads are the display's own copies.
assert.equal(handle.image, SELECTED); assert.equal(handle.image, SELECTED);
assert.equal(handle.color.alpha, 0.3); assert.equal(handle.color.alpha, 0.3);
@@ -300,24 +251,7 @@ for (const makeHarness of HARNESSES) {
assert.match(line.id, /^gev-trail:ais-live-vessels-\d+$/); assert.match(line.id, /^gev-trail:ais-live-vessels-\d+$/);
assert.equal(line.points, 2, 'near-duplicates and gaps dropped'); assert.equal(line.points, 2, 'near-duplicates and gaps dropped');
const base = Cesium.Color.fromCssColorString('#00d4ff'); const base = Cesium.Color.fromCssColorString('#00d4ff');
if (h.cesium) { {
const primitive = h.primitive(line.id);
assert.equal(primitive.allowPicking, true);
assert.equal(primitive.geometryInstances.id, line.id);
assert.equal(
primitive.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
assert.equal(primitive.geometryInstances.geometry._width, 2.5);
const { color } = primitive.appearance.material.uniforms;
near(color.red, base.red, 'red');
near(color.alpha, 0.85, 'alpha');
near(
primitive.depthFailAppearance.material.uniforms.color.alpha,
0.4,
'occluded alpha',
);
} else {
const { options } = engine.fake.line(line.id); const { options } = engine.fake.line(line.id);
assert.equal(options.arc, 'geodesic'); assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, line.id); assert.equal(options.pickId, line.id);
@@ -1,10 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
createFakeGroundHarness,
createCesiumGroundHarness,
} from '../../testSupport/groundDisplayEngines.mjs';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { import {
createRendering, createRendering,
@@ -454,7 +451,7 @@ test('a selected observation remains rendered outside the viewport and observati
h.rendering.destroy(); h.rendering.destroy();
}); });
for (const make of [createFakeGroundHarness, createCesiumGroundHarness]) { for (const make of [createFakeGroundHarness]) {
test(`privacy cells drape accent fills and outlines and release them (${make.name})`, () => { test(`privacy cells drape accent fills and outlines and release them (${make.name})`, () => {
const cell = { south: 47.6, west: -122.4, north: 47.8, east: -122.2 }; const cell = { south: 47.6, west: -122.4, north: 47.8, east: -122.2 };
const groundHarness = make(); const groundHarness = make();
-1
View File
@@ -42,7 +42,6 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(diagnostics.recentRequests[0].reason, 'tick-4'); assert.equal(diagnostics.recentRequests[0].reason, 'tick-4');
diagnostics.holds.push('external'); diagnostics.holds.push('external');
assert.deepEqual(governor.getRenderGovernorDiagnostics().holds, []); assert.deepEqual(governor.getRenderGovernorDiagnostics().holds, []);
if (harness.viewer) assert.equal(harness.viewer.scene.maximumRenderTimeChange, Infinity);
}); });
test(`${makeHarness.name}: identical owners and teardown stay in their scene`, (t) => { test(`${makeHarness.name}: identical owners and teardown stay in their scene`, (t) => {
+2 -120
View File
@@ -1,13 +1,5 @@
// The two engines the CCTV display tests run on: the fake engine and the // CCTV input tests use scripted picks and a canvas with style and window events.
// Cesium engine over a stub scene (testSupport/cesiumMoversEngine.mjs). Each
// harness scripts picks of what the display drew (a mover instance or a
// line, as the engine's own pick returns it), fires screen input through
// engine.input and gives the canvas a style and a window, as the browser's.
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -80,114 +72,4 @@ export function fakeCctvHarness() {
}; };
} }
/** Screen input for the Cesium engine's ScreenSpaceEventHandler seam. */ export const CCTV_HARNESSES = [fakeCctvHarness];
function testEventHandler() {
const actions = new Map();
return {
setInputAction: (action, type, modifier) =>
actions.set(`${type}:${modifier}`, action),
removeInputAction: (type, modifier) =>
actions.delete(`${type}:${modifier}`),
fire: (type, event) => actions.get(`${type}:undefined`)?.(event),
isDestroyed: () => false,
destroy: () => actions.clear(),
};
}
const CESIUM_TYPES = {
click: Cesium.ScreenSpaceEventType.LEFT_CLICK,
move: Cesium.ScreenSpaceEventType.MOUSE_MOVE,
down: Cesium.ScreenSpaceEventType.LEFT_DOWN,
up: Cesium.ScreenSpaceEventType.LEFT_UP,
};
export function cesiumCctvHarness() {
const handler = testEventHandler();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: () => handler,
scheduleFrame: (run) => run(),
},
});
viewer.scene.pick = () => undefined;
viewer.scene.drillPick = () => [];
const canvas = viewer.scene.canvas;
canvas.style = {};
canvas.ownerDocument = { defaultView: new EventTarget() };
viewer.scene.screenSpaceCameraController = { enableInputs: true };
// Camera inputs off at each suspension, on again at its resume.
const inputs = [];
const suspend = engine.input.suspendCameraInputs;
engine.input.suspendCameraInputs = () => {
inputs.push(false);
const resume = suspend();
return () => {
inputs.push(true);
resume();
};
};
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, VIEW.height),
});
const native = (target) => {
if (target.line) {
const drawn = primitive(target.line);
return {
primitive: drawn,
id: target.pickId ?? drawn?.geometryInstances?.id,
};
}
if (target.set) {
const item = primitive(target.set).get(target.index ?? 0);
return { primitive: item, id: target.pickId ?? item.id };
}
return target.native;
};
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
canvas,
primitive,
frame,
settle,
fire: (type, event) =>
handler.fire(CESIUM_TYPES[type], {
...(event.position
? {
position: new Cesium.Cartesian2(
event.position.x,
event.position.y,
),
}
: {}),
...(event.endPosition
? {
startPosition: new Cesium.Cartesian2(
event.startPosition?.x ?? 0,
event.startPosition?.y ?? 0,
),
endPosition: new Cesium.Cartesian2(
event.endPosition.x,
event.endPosition.y,
),
}
: {}),
}),
/** The next picks return these targets: { line } | { set, index } | { native }. */
scriptPicks(...targets) {
const results = targets.map(native);
viewer.scene.pick = () => results[0];
viewer.scene.drillPick = () => results;
},
cameraInputs: () => inputs,
lineState: (id) => {
const drawn = primitive(id);
return drawn && { drawn };
},
destroy: () => engine.destroy(),
};
}
export const CCTV_HARNESSES = [fakeCctvHarness, cesiumCctvHarness];
+3 -106
View File
@@ -1,18 +1,6 @@
// Ground displays under test, on the fake engine and on the Cesium engine. // Ground display tests record committed items and input ownership on the fake engine.
// Both harnesses record what each ground set was given (the items of its
// last commit, its commits) and count the live input listeners. The Cesium
// one is the engine over a real Cesium camera on a stub scene
// (cesiumMoversEngine.mjs) with fake GroundPrimitive and
// GroundPolylinePrimitive factories: the engine's own geometry instances,
// colours and options reach them, and they draw nothing. A click goes
// through the engine's input with a pick of a ground item (`{ set,
// instance }`), of another object (`{ id }`) or of nothing (undefined).
import * as Cesium from 'cesium';
import { normalizeGroundSetOptions } from '../globe/ground.js'; import { normalizeGroundSetOptions } from '../globe/ground.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
const CLICK_AT = { x: 100, y: 100 }; const CLICK_AT = { x: 100, y: 100 };
@@ -73,8 +61,8 @@ export function createFakeGroundHarness(options = {}) {
const watched = watch(engine); const watched = watch(engine);
return { return {
name: 'the fake engine', name: 'the fake engine',
cesium: false,
engine, engine,
frame: () => engine.fake.frame(),
...watched, ...watched,
click(target, position = CLICK_AT) { click(target, position = CLICK_AT) {
let picked = target; let picked = target;
@@ -94,95 +82,4 @@ export function createFakeGroundHarness(options = {}) {
}; };
} }
/** export const GROUND_HARNESSES = [createFakeGroundHarness];
* The Cesium engine over a stub scene, with fake ground primitives; with
* `realFills`, Cesium's own GroundPrimitive draws the fills (it builds in
* Node; GroundPolylinePrimitive does not).
*/
export function createCesiumGroundHarness({
realFills = false,
engineOptions = {},
} = {}) {
const handlers = createFakeEventHandlers();
const primitive = (type) => (options) => {
const value = {
type,
options,
ready: false,
show: true,
destroyed: false,
destroy() {
value.destroyed = true;
},
isDestroyed: () => value.destroyed,
};
return value;
};
const { engine, viewer } = createCesiumMoversEngine({
engineOptions: {
...engineOptions,
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
groundFactories: {
...(realFills ? {} : { fills: primitive('fills') }),
lines: primitive('lines'),
},
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const children = (collection) =>
Array.from({ length: collection?.length ?? 0 }, (_, index) =>
collection.get(index),
);
// The engine's collection of sets, each set's collection of primitives.
const slots = () => children(viewer.scene.groundPrimitives.get(0));
const slotOf = (id) =>
slots().find((slot) =>
children(slot).some((value) => value.gevGroundSet === id),
);
const live = (id) => children(slotOf(id));
const typeOf = (value) =>
value.type ?? (value instanceof Cesium.GroundPrimitive ? 'fills' : 'lines');
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
...watch(engine),
/** The live Cesium primitives of a set: fills and outlines. */
primitives: (id) => ({
fills: live(id).filter((value) => typeOf(value) === 'fills'),
lines: live(id).filter((value) => typeOf(value) === 'lines'),
}),
/** Whether the set's Cesium collection is shown (undefined: none). */
collectionShown: (id) => slotOf(id)?.show,
click(target, position = CLICK_AT) {
native = undefined;
if (target?.set !== undefined) {
const [first] = live(target.set);
native = {
id: { gevGroundSet: target.set, instance: target.instance },
primitive: first,
};
} else if (target) native = { id: target.id, primitive: {} };
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(position.x, position.y),
});
native = undefined;
},
destroy: () => engine.destroy(),
};
}
export const GROUND_HARNESSES = [
createFakeGroundHarness,
createCesiumGroundHarness,
];
/** A Cesium colour attribute's value as rgba in 0..1. */
export const instanceColor = (instance) =>
Array.from(instance.attributes.color.value, (byte) => byte / 255);
+23 -134
View File
@@ -1,148 +1,37 @@
// Display cases run on both engines. Cesium uses real imagery layers and a
// headless camera; only GPU shell drawing and provider requests are faked.
import nodeTest from 'node:test'; import nodeTest from 'node:test';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { normalizeImageryLayerOptions } from '../globe/imagery.js';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
function createCesiumImageryEngine() {
let renderRequests = 0;
let pending = false;
let frame;
const {
engine,
viewer,
frame: draw,
} = createCesiumMoversEngine({
engineOptions: {
requestRender() {
renderRequests++;
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
if (!engine.isDestroyed()) frame();
});
},
imageryFactories: {
tiles(options) {
const tilingScheme =
options.tilingScheme ?? new Cesium.WebMercatorTilingScheme();
return {
tilingScheme,
rectangle: options.rectangle ?? tilingScheme.rectangle,
tileWidth: 256,
tileHeight: 256,
minimumLevel: 0,
maximumLevel: 18,
errorEvent: new Cesium.Event(),
requestImage: () => undefined,
};
},
shell(options) {
let detail = null;
return {
rectangle: options.rectangle,
setImage() {},
setAlpha() {},
setShow() {},
setDetail: (image) => {
detail = image;
},
detail: () => detail,
drawn: () => true,
destroy() {},
};
},
},
},
});
frame = draw;
viewer.scene.globe.show = true;
viewer.scene.globe.tilesLoaded = true;
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
const layers = new Map();
const create = engine.imagery.createLayer;
engine.imagery.createLayer = (options) => {
const layer = create(options);
layers.set(layer.id, {
layer,
options: normalizeImageryLayerOptions(options),
});
return layer;
};
engine.fake = {
get renderRequests() {
return renderRequests;
},
script(method, value) {
engine.camera[method] = typeof value === 'function' ? value : () => value;
},
emit(name) {
if (name === 'frames.preRender') frame();
else throw new Error(`Unsupported event ${name}`);
},
frames(count) {
for (let i = 0; i < count; i++) frame();
},
imageryLayers: () => engine.imagery.list().map(({ id }) => id),
imageryLayer(id) {
const record = layers.get(id);
if (!record || record.layer.isDestroyed()) return undefined;
const { layer, options } = record;
return {
options,
source: layer.source(),
alpha: layer.alpha(),
shown: layer.shown(),
split: layer.split(),
raise: layer.raise(),
detail: layer.detail(),
staging: layer.staging(),
};
},
};
return engine;
}
/** Each test owns its engines and releases them even after an assertion fails. */ /** Each test owns its engines and releases them even after an assertion fails. */
export function imageryDisplayTests() { export function imageryDisplayTests() {
let make;
let engines; let engines;
return { return {
test(name, run) { test(name, run) {
for (const factory of [createFakeEngine, createCesiumImageryEngine]) nodeTest(name, async (t) => {
nodeTest( engines = [];
`${name} (${factory === createFakeEngine ? 'fake' : 'cesium'})`, if (!globalThis.document) {
async (t) => { globalThis.document = {
make = factory; createElement: () => ({
engines = []; getContext: () => ({
if (!globalThis.document) { createImageData: (w, h) => ({
globalThis.document = { data: new Uint8ClampedArray(w * h * 4),
createElement: () => ({
getContext: () => ({
createImageData: (w, h) => ({
data: new Uint8ClampedArray(w * h * 4),
}),
putImageData() {},
}),
}), }),
}; putImageData() {},
t.after(() => { }),
delete globalThis.document; }),
}); };
} t.after(() => {
try { delete globalThis.document;
await run(t); });
} finally { }
for (const engine of engines) engine.destroy(); try {
} await run(t);
}, } finally {
); for (const engine of engines) engine.destroy();
}
});
}, },
createEngine(options) { createEngine(options) {
const engine = make(options); const engine = createFakeEngine(options);
engines.push(engine); engines.push(engine);
return engine; return engine;
}, },
+1 -2
View File
@@ -413,10 +413,9 @@ export function cesiumStreetLevelMap({ view = true, terrain = 0 } = {}) {
}; };
} }
/** The display on each engine: the fake one and Cesium's, over Sacramento. */ /** The display on the fake engine over Sacramento. */
export const STREET_LEVEL_MAPS = Object.freeze([ export const STREET_LEVEL_MAPS = Object.freeze([
() => ({ name: 'the fake engine', ...streetLevelMap({ view: true }) }), () => ({ name: 'the fake engine', ...streetLevelMap({ view: true }) }),
() => cesiumStreetLevelMap(),
]); ]);
/** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */ /** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { watchW1Page } from '../../scripts/qa-browserEvidence.mjs'; import { watchBrowserPage } from '../../scripts/qa-browserEvidence.mjs';
const baseUrl = 'http://127.0.0.1:4195/'; const baseUrl = 'http://127.0.0.1:4195/';
const message = (type, text, url) => ({ const message = (type, text, url) => ({
type: () => type, type: () => type,
text: () => text, text: () => text,
location: () => ({ url }), location: () => ({ url }),
}); });
test('W1 main and share pages aggregate console, runtime and crash errors', () => { test('Browser main and share pages aggregate console, runtime and crash errors', () => {
const report = {}, const report = {},
main = new EventEmitter(), main = new EventEmitter(),
shared = new EventEmitter(); shared = new EventEmitter();
watchW1Page(main, report, { name: 'main', baseUrl }); watchBrowserPage(main, report, { name: 'main', baseUrl });
watchW1Page(shared, report, { name: 'share', baseUrl }); watchBrowserPage(shared, report, { name: 'share', baseUrl });
main.emit('console', message('warning', 'ordinary warning', baseUrl)); main.emit('console', message('warning', 'ordinary warning', baseUrl));
shared.emit( shared.emit(
'console', 'console',
@@ -26,10 +26,10 @@ test('W1 main and share pages aggregate console, runtime and crash errors', () =
assert.match(report.browserErrors[0], /\[share\] late error/); assert.match(report.browserErrors[0], /\[share\] late error/);
assert.ok(!JSON.stringify(report).includes('sample-secret')); assert.ok(!JSON.stringify(report).includes('sample-secret'));
}); });
test('W1 preview exception stays exact on a share page', () => { test('Browser preview exception stays exact on a share page', () => {
const report = {}, const report = {},
shared = new EventEmitter(); shared = new EventEmitter();
watchW1Page(shared, report, { name: 'share', baseUrl, preview: true }); watchBrowserPage(shared, report, { name: 'share', baseUrl, preview: true });
const failure = (url) => const failure = (url) =>
shared.emit( shared.emit(
'console', 'console',
@@ -45,10 +45,10 @@ test('W1 preview exception stays exact on a share page', () => {
assert.deepEqual(report.expectedPreviewErrors, ['/api/setup/status: 404']); assert.deepEqual(report.expectedPreviewErrors, ['/api/setup/status: 404']);
assert.equal(report.browserErrors.length, 2); assert.equal(report.browserErrors.length, 2);
}); });
test('W1 response diagnostics retain page/status/path without query credentials', () => { test('Browser response diagnostics retain page/status/path without query credentials', () => {
const report = {}, const report = {},
shared = new EventEmitter(); shared = new EventEmitter();
watchW1Page(shared, report, { name: 'share', baseUrl }); watchBrowserPage(shared, report, { name: 'share', baseUrl });
shared.emit('response', { shared.emit('response', {
status: () => 503, status: () => 503,
url: () => baseUrl + 'api/vessels?token=sample-secret', url: () => baseUrl + 'api/vessels?token=sample-secret',
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more