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
and 14 ms moving, where Cesium takes 23 ms (the chunks before took 151
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.
- **Ground and input parity.** Space Missions draws its 500 m launch-pad
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
`maps.addOverlay`; Navara retains its native raster path for tile detail.
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.
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
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
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
metre corridors);
- one `GroundPolylinePrimitive` for all the outlines and pixel corridors.
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.
Cesium translates ground items to classified fills and polylines. Navara
classifies visible scene depth and blends the ground in display space.
Navara picks ground items at the surface under the pointer. Packing, tiling
and bin sizes are implementation details in `src/globe/navara/ground/`.
## Imagery: rasters on the ground
@@ -324,78 +263,27 @@ On the engine:
- `surface()` says where imagery drapes now: `'globe'`, `'tiles'` (3D tiles)
or `'none'`.
### How each engine draws imagery
### Imagery drawing guarantees
**Cesium** (`src/globe/cesium/imagery.js`) gives each layer one
`ImageryLayer`:
Both engines draw imagery below ground shapes, movers, lines and models,
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
tileset's own imagery layers (Cesium drapes imagery on tiles there), as
`engine.imagery.surface()` reports it to layers; with neither, the
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).
Cesium translates sources to imagery providers and raised shells. Navara
samples imagery in its surface pass, including raised ellipsoid intersections.
The adapters own texture storage, tile selection and depth reconstruction.
**Navara** (`src/globe/navara/imagery/`) draws every shown layer in one
screen pass right after tone mapping, under the other drapes and
`engine.ground`:
Cesium moves draped layers between the visible globe and tileset surfaces;
without either surface, layers wait. Its split applies to globe imagery,
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
tiles), as `engine.ground`'s pass finds it;
- up to 8 layers are sampled in draw order at its longitude and latitude,
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.
Open map overlays use `engine.imagery` when `maps.addOverlay` is absent.
Navara's optional native raster path retains terrain detail, but does not
provide rectangle bounds, split, alpha, order, or drawing on 3D tiles.
### Who draws through imagery
@@ -696,8 +584,8 @@ Run `createContractSuite(engine, { waitFrames, ... })` from
`src/globe/testing/contractSuite.mjs`. Supply real pointer delivery, canvases and
pixel reads for browser checks; keep the fake-engine tests as fast contract
checks. Test ownership and repeated destruction as well as visible output.
The browser harness is `scripts/qa-contract-suite.mjs`; ground and imagery pixel
checks also run through `scripts/qa-ground-suite.mjs`.
The browser harness is `scripts/qa-contract-suite.mjs`; use
`--only=ground:,imagery:` for ground and imagery pixel checks.
The application currently registers two kinds: adding another still requires
registration in `ENGINE_KINDS`, scene construction/choice and the corresponding
+3 -3
View File
@@ -7,10 +7,10 @@
"navaraKindChecks": 1,
"engineSniffCalls": 0,
"engineExtensionReads": 3,
"groundEffectPasses": 1,
"groundEffectPasses": 0,
"cesiumSdkLoads": 3,
"screenSpaceHandlerFiles": 4,
"screenSpaceHandlerFiles": 2,
"engineNameLines": 27
},
"measuredAt": "Drop leftover viewer paths"
"measuredAt": "One selection path and test level"
}
@@ -13,7 +13,7 @@ export const cameras = [
].map((position, i) => ({
...position,
osmId: 9000000 + i,
operator: i % 3 ? 'W2 fixture' : 'Cámara pública',
operator: i % 3 ? 'Deterministic fixture' : 'Cámara pública',
brand: 'Fixture camera',
cameraType: 'fixed',
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.
export const cameras = Array.from({ length: 25 }, (_, i) => ({
id: `w2-cctv-${String(i).padStart(3, '0')}`,
name: i === 12 ? 'Cámara pública — Congress' : `W2 camera ${i}`,
id: `fixture-cctv-${String(i).padStart(3, '0')}`,
name: i === 12 ? 'Cámara pública — Congress' : `Fixture camera ${i}`,
cityId: 'austin',
city: 'Austin',
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',
sourceKind: 'configured',
feedType: 'image',
provider: 'W2 deterministic fixture',
provider: 'Deterministic fixture',
url: 'https://cctv-fixture.invalid/frame.png',
}));
@@ -51,8 +51,8 @@ export async function installCctvFixture(page, { video } = {}) {
if (video)
catalog.push({
...cameras[0],
id: 'w2-cctv-video',
name: 'W2 moving video',
id: 'fixture-cctv-video',
name: 'Fixture moving video',
lat: 30.2718,
lon: -97.744,
feedType: 'video',
@@ -116,7 +116,7 @@ export async function installCctvFixture(page, { video } = {}) {
id,
status: 'online',
sourceKind: feedType,
label: 'W2 deterministic fixture',
label: 'Deterministic fixture',
updatedAt: Date.now(),
})),
});
@@ -126,7 +126,7 @@ export async function installCctvFixture(page, { video } = {}) {
const id = decodeURIComponent(media[2]);
if (
!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' });
}
@@ -4,8 +4,8 @@ const grid = (i, cols, spacing) => ({
lon: ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
});
const bikes = Array.from({ length: 200 }, (_, i) => ({
station_id: `w2-bike-${i}`,
name: `W2 station ${i}`,
station_id: `fixture-bike-${i}`,
name: `Fixture station ${i}`,
capacity: 20,
...grid(i, 15, 0.0015),
}));
File renamed without changes.
@@ -78,7 +78,7 @@ export async function installRadioFixture(page) {
stale: false,
degraded: false,
acceptedGeneration: qa.generation,
catalogInstance: 'w2-radio-fixture',
catalogInstance: 'fixture-radio-fixture',
};
const mode = qa.response;
const delay = qa.delay;
@@ -6,7 +6,7 @@ export function transitSnapshot(step = 8) {
fetchedAt,
vehicles: [
...Array.from({ length: 12 }, (_, i) => ({
id: `w2-${i}`,
id: `fixture-${i}`,
routeId: routes[i % 6],
tripId: `trip-${i}`,
lat:
+8 -9
View File
@@ -1212,6 +1212,7 @@
"src/testSupport/cockpitEngine.mjs",
"src/layers/aircraft/render/contract.js",
"src/layers/shared/color.test.mjs",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/shared/viewRectangle.js",
"src/layers/military/render/display.js",
@@ -1222,7 +1223,6 @@
"src/layers/vessels/renderer.test.mjs",
"src/layers/vessels/testFixtures.mjs",
"src/layers/shared/render/engineView.js",
"src/globe/navara/ground/effect.js",
"src/layers/shared/render/moverBank.js",
"src/layers/shared/render/imageAtlas.js",
"src/layers/bikeshare/presentation.js",
@@ -1233,8 +1233,8 @@
"src/layers/cctv/lifecycle.test.mjs",
"src/layers/cctv/projectionMedia.test.mjs",
"src/layers/cctv/ingestion.test.mjs",
"scripts/fixtures/w2/alpr.mjs",
"scripts/fixtures/w2/cctv.mjs",
"scripts/fixtures/layers/alpr.mjs",
"scripts/fixtures/layers/cctv.mjs",
"src/layers/radio/screenClusters.js",
"src/layers/radio/screenClusters.test.mjs",
"src/globe/cesium/mapScene.js",
@@ -1258,7 +1258,7 @@
"src/globe/cesium/testing/cameraViewer.mjs",
"src/app/globeEngine.js",
"src/app/globeEngine.test.mjs",
"scripts/fixtures/w2/transit.mjs",
"scripts/fixtures/layers/transit.mjs",
"src/layers/localAdsb/render/display.js",
"src/layers/localAdsb/renderer.test.mjs",
"build/navaraAssets.js",
@@ -1381,16 +1381,16 @@
"src/globe/navara/movers/frameCamera.test.mjs",
"src/layers/localAdsb/qaDrawn.js",
"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.test.mjs",
"src/layers/firms/viewport.test.mjs",
"scripts/fixtures/w2/firms.mjs",
"scripts/fixtures/layers/firms.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.test.mjs",
"scripts/qa-w2-imagery.mjs",
"scripts/qa-imagery-contract.mjs",
"src/globe/navara/postSamplingPass.test.mjs",
"src/globe/navara/emptySceneRender.test.mjs",
"src/data/firmsIntensity.js",
@@ -1579,7 +1579,6 @@
"src/globe/navara/ground/pack.js",
"src/globe/navara/ground/pack.test.mjs",
"src/globe/navara/ground/pass.js",
"scripts/qa-ground-suite.mjs",
"docs/GLOBE-PRIMITIVES.md",
"src/layers/airQuality/render/display.js",
"src/layers/airQuality/render/display.test.mjs",
+36 -20
View File
@@ -32,6 +32,7 @@
"src/geo/",
"src/layers/infrastructure/index.js",
"src/layers/infrastructure/render/display.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/sources/requestDeadline.js",
"src/overlays/bindOverlayHost.js"
@@ -1423,8 +1424,6 @@
"src/data/routePlausible.js",
"src/geoDistance.js",
"src/data/trackedModelRegime.js",
"src/data/trackingClickGesture.js",
"src/data/trackingGesture.js",
"src/layers/flights/controller.js",
"src/layers/flights/enrichment.js",
"src/layers/flights/evidence.js",
@@ -1465,9 +1464,11 @@
"src/geo/",
"src/time/clock.js",
"src/globe/contract.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/aircraft/qaDrawn.js",
"src/overlays/bindOverlayHost.js"
"src/overlays/bindOverlayHost.js",
"src/data/trackingGesture.js"
],
"external": ["cesium"]
},
@@ -1495,8 +1496,6 @@
"src/data/motionModel.js",
"src/data/renderAltitude.js",
"src/data/trackedModelRegime.js",
"src/data/trackingClickGesture.js",
"src/data/trackingGesture.js",
"src/layers/military/controller.js",
"src/layers/military/index.js",
"src/layers/military/ingestion.js",
@@ -1530,18 +1529,21 @@
"src/i18n/locales/zh-Hant.json",
"src/globe/cesium/sceneProjection.js",
"src/globe/cesium/screenEvents.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/geo/",
"src/time/clock.js",
"src/globe/contract.js",
"src/layers/aircraft/qaDrawn.js",
"src/overlays/bindOverlayHost.js"
"src/overlays/bindOverlayHost.js",
"src/data/trackingGesture.js"
],
"external": ["cesium"]
},
"aircraft-mechanics": {
"exports": ["./aircraft"],
"modules": [
"src/data/trackingGesture.js",
"src/i18n/marker.js",
"src/data/aircraftClass.js",
"src/data/aircraftIcons.js",
@@ -1563,8 +1565,6 @@
"src/data/routePlausible.js",
"src/geoDistance.js",
"src/data/trackedModelRegime.js",
"src/data/trackingClickGesture.js",
"src/data/trackingGesture.js",
"src/layers/aircraft/classification.js",
"src/layers/aircraft/index.js",
"src/data/inputOwnership.js",
@@ -1614,6 +1614,7 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/vessels/icons.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/globe/cesium/sceneProjection.js",
"src/geo/",
@@ -1630,6 +1631,7 @@
"src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js",
"src/geo/",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/earthquakes/render/display.js",
"src/overlays/bindOverlayHost.js"
@@ -1700,6 +1702,7 @@
"src/i18n/locales/zh-Hant.json",
"src/geo/",
"src/layers/firms/rectangle.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/overlays/bindOverlayHost.js"
],
@@ -1743,6 +1746,7 @@
"src/ui/unitLabels.js",
"src/geo/",
"src/time/clock.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/overlays/bindOverlayHost.js"
],
@@ -1785,6 +1789,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/geo/",
"src/time/clock.js",
@@ -1837,6 +1842,7 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/installations/classLabel.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/geo/",
"src/globe/contract.js"
@@ -1900,6 +1906,7 @@
"traffic-layer": {
"exports": ["./layers/traffic"],
"modules": [
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/data/cameraSensitivity.js",
"src/data/flowMatch.js",
@@ -1992,6 +1999,7 @@
"src/i18n/locales/zh-Hant.json",
"src/layers/bikeshare/selectionModel.js",
"src/layers/bikeshare/presentation.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/geo/",
"src/globe/contract.js",
@@ -2051,6 +2059,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/geo/",
"src/overlays/bindOverlayHost.js"
@@ -2128,6 +2137,7 @@
"src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/geo/",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/globe/cameraMath.js",
"src/layers/streetLevel/render/display.js",
@@ -2246,6 +2256,7 @@
"src/i18n/locales/zh-Hant.json",
"src/geo/",
"src/sources/requestDeadline.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/submarineCables/render/display.js",
"src/overlays/bindOverlayHost.js"
@@ -2610,7 +2621,6 @@
"src/data/neighborhoodPolygons.js",
"src/data/pickRegistry.js",
"src/data/sceneServices.js",
"src/data/trackingGesture.js",
"src/data/radio.js",
"src/data/radioCountry.js",
"src/data/regionalBrief.js",
@@ -2633,7 +2643,6 @@
"src/data/trackedCamera.js",
"src/data/trackedModelRegime.js",
"src/data/trackedReadout.js",
"src/data/trackingClickGesture.js",
"src/data/traffic.js",
"src/data/trafficBounds.js",
"src/data/trafficFlowStyle.js",
@@ -3496,6 +3505,7 @@
"src/layers/flights/render/display.js",
"src/layers/military/render/display.js",
"src/data/selectionDispatcher.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/aircraft/qaDrawn.js",
"src/layers/localAdsb/render/display.js",
@@ -3503,6 +3513,7 @@
"src/globe/cesium/celestial.js",
"src/celestialRing/view.js",
"src/globe/navara/ground/pulse.js",
"src/globe/navara/shaderChunks.js",
"src/globe/navara/sceneDepth.js",
"src/layers/bhoteKoshi/render/story.js",
"src/globe/navara/windFlowMesh.js",
@@ -3513,7 +3524,6 @@
"src/layers/radio/screenClusters.js",
"src/layers/shared/render/imageAtlas.js",
"src/globe/navara/ground/surface.js",
"src/globe/navara/ground/effect.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -3581,7 +3591,6 @@
"src/globe/navara/ground/index.js",
"src/layers/cctv/render/meshes.js",
"src/layers/cctv/render/display.js",
"src/layers/cctv/worldClick.js",
"src/layers/transit/render/display.js",
"src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js",
@@ -3625,7 +3634,8 @@
"src/globe/exclusivePointer.js",
"src/globe/cesium/followOffset.js",
"src/overlays/bindOverlayHost.js",
"src/ui/releaseTrackers.js"
"src/ui/releaseTrackers.js",
"src/data/trackingGesture.js"
],
"external": [
"hls.js",
@@ -3764,7 +3774,6 @@
"src/data/neighborhoodPolygons.js",
"src/data/pickRegistry.js",
"src/data/sceneServices.js",
"src/data/trackingGesture.js",
"src/data/radioCountry.js",
"src/data/renderAltitude.js",
"src/data/bundledJson.js",
@@ -3778,7 +3787,6 @@
"src/data/trackedCamera.js",
"src/data/trackedModelRegime.js",
"src/data/trackedReadout.js",
"src/data/trackingClickGesture.js",
"src/data/trafficBounds.js",
"src/data/trafficFlowStyle.js",
"src/data/trafficPresetStyle.js",
@@ -4275,6 +4283,7 @@
"src/app/layers/displays.js",
"src/layers/aircraft/render/display.js",
"src/data/selectionDispatcher.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/flights/render/display.js",
"src/layers/military/render/display.js",
@@ -4336,7 +4345,6 @@
"src/layers/cctv/render/style.js",
"src/layers/cctv/render/meshes.js",
"src/layers/cctv/render/display.js",
"src/layers/cctv/worldClick.js",
"src/layers/transit/render/display.js",
"src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js",
@@ -4368,7 +4376,6 @@
"src/layers/bhoteKoshi/render/story.js",
"src/globe/navara/ground/surface.js",
"src/globe/navara/windFlowMesh.js",
"src/globe/navara/ground/effect.js",
"src/globe/navara/ground/pulse.js",
"src/globe/cesium/windFlow.js",
"src/globe/cesium/postStages.js",
@@ -4386,7 +4393,8 @@
"src/globe/meshes.js",
"src/globe/exclusivePointer.js",
"src/globe/cesium/followOffset.js",
"src/overlays/bindOverlayHost.js"
"src/overlays/bindOverlayHost.js",
"src/data/trackingGesture.js"
],
"external": [
"hls.js",
@@ -4730,6 +4738,7 @@
"src/data/distanceFormat.js",
"src/i18n/marker.js",
"src/layers/directions/index.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/data/pointerOwnerNames.js",
"src/layers/directions/source.js",
@@ -5051,7 +5060,6 @@
"src/data/meshFloorSampler.js",
"src/data/pickRegistry.js",
"src/data/sceneServices.js",
"src/data/trackingGesture.js",
"src/data/scenePick.js",
"src/data/spriteOrder.js",
"src/data/terrainHeights.js",
@@ -5132,6 +5140,7 @@
"src/celestialRing/view.js",
"src/layers/shared/render/engineView.js",
"src/globe/cesium/sceneProjection.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/geo/",
"src/time/clock.js",
@@ -5156,7 +5165,8 @@
"src/data/selectionDispatcher.js",
"src/overlays/bindOverlayHost.js",
"src/data/contextStore.js",
"src/data/trackedReadout.js"
"src/data/trackedReadout.js",
"src/data/trackingGesture.js"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
@@ -5208,6 +5218,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/geo/",
"src/globe/contract.js",
@@ -5276,6 +5287,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/perimeters/render/display.js",
"src/overlays/bindOverlayHost.js"
@@ -5306,6 +5318,7 @@
"src/ui/statusText.js",
"src/ui/layerNames.js",
"src/layers/shared/viewRectangle.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/geo/math.js",
"src/geo/cartesian2.js",
@@ -5439,6 +5452,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/droneAirspace/render/display.js",
"src/overlays/bindOverlayHost.js"
@@ -5645,6 +5659,7 @@
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js",
"src/ui/layerNames.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/airQuality/render/display.js",
"src/overlays/bindOverlayHost.js"
@@ -5725,6 +5740,7 @@
"src/globe/cesium/credits.js",
"src/layers/wildlifeSightings/display.js",
"src/data/spriteOrder.js",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/overlays/bindOverlayHost.js"
],
@@ -1,6 +1,6 @@
#!/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).
*
* The permanent track-regression harness flies planes STRAIGHT, so it cannot
@@ -45,7 +45,7 @@
* normalized nose -X, offset 180°).
*
* 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.
*
* 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
* 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.
*/
@@ -120,7 +120,7 @@ if (requestedEngine) {
}
const APP_URL = appUrl.href;
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 CHROME_EXECUTABLE_CANDIDATES = [
@@ -774,7 +774,7 @@ async function main() {
}
// ---- 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(() => {
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
});
@@ -1,12 +1,12 @@
#!/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).
*
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
* classes — the flights layer via OpenSky extended categories (state[17]),
* 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).
*
* Machine assertions:
@@ -15,17 +15,17 @@
* per-class glyph (image data-URI === aircraftIcon(klass),
* imported from src) and per-class scale (flights:
* 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)
* × CLASS_SCALE_3D[klass] per icao; tracked too
* 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
* zooms per layer showing per-class model scale differences. The PNGs are the
* 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.
*/
@@ -44,7 +44,7 @@ const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
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 getFlag = (name) => argv.includes(name);
@@ -61,7 +61,7 @@ const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=ces
const APP_URL =
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
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 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: '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
// 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 },
// 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
@@ -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)...');
const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
@@ -394,7 +394,7 @@ async function main() {
// ========================================================================
// 2D screenshots
// ========================================================================
console.log('\n2D screenshots → qa-shots/b5/');
console.log('\n2D screenshots → qa-shots/aircraft-sprites/');
const topDown = async (lat, lon, height) => {
await page.evaluate(async ({ lat, lon, height }) => {
const camera = window.__godsEyeView.engine.camera;
@@ -429,21 +429,21 @@ async function main() {
const flRowLat = await rowLat('flights', 2, 30);
const milRowLat = await rowLat('military', 4, 15);
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') });
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)');
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-spread-bothrows.png') });
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;
// flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up.
if (hasMilitary) {
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') });
console.log(' saved b5-2d-military-row.png (8 classes, amber)');
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-military-row.png') });
console.log(' saved aircraft-sprites-2d-military-row.png (8 classes, amber)');
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 page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-flights-row.png') });
console.log(' saved b5-2d-flights-row.png (6 category classes, white)');
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-flights-row.png') });
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'))}`);
// 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);
}, 'caa002');
await sleep(3000);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') });
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-tracked-cyan.png') });
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)');
await page.evaluate(() => {
@@ -492,15 +492,15 @@ async function main() {
await sleep(1500);
await zoomToRange(900);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') });
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-mid.png') });
console.log(' saved aircraft-sprites-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
await zoomToRange(450);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') });
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-close.png') });
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) {
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
// flights cluster (close zoom no longer puts the camera inside a model).
await zoomToRange(900);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') });
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-far.png') });
console.log(' saved aircraft-sprites-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
await zoomToRange(450);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') });
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-close.png') });
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'],
],
['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--fires',
+1 -1
View File
@@ -41,7 +41,7 @@ import path from 'node:path';
import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
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 {
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
// 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 ??= []);
report.expectedPreviewErrors ??= [];
report.failedResponses ??= [];
+6 -5
View File
@@ -2,7 +2,7 @@ import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict';
import { promisify } from 'node:util';
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';
const url = localPreview(process.argv[2]);
url.search = '?engine=navara&welcome=0';
@@ -64,7 +64,7 @@ await strict.run({}, async (b) => {
await dataManager.setEnabled('cctv', true, { origin: 'user' });
window.qaCctv = dataManager.layers.get('cctv').module;
window.qaCctv.setParams({
selectedCameraId: 'w2-cctv-video',
selectedCameraId: 'fixture-cctv-video',
showProjection: true,
coverageMode: 'viewshed',
calibration: { patch: { heightM: 31, pitchDeg: 20 } },
@@ -101,11 +101,12 @@ await strict.run({}, async (b) => {
params: window.qaCctv.getParams(),
plane: !!window.qaCctv
._cctvRecordsForTest()
.find((r) => r.camera.id === 'w2-cctv-video').projection?.planeEntity,
.find((r) => r.camera.id === 'fixture-cctv-video').projection
?.planeEntity,
projection: (() => {
const r = window.qaCctv
._cctvRecordsForTest()
.find((r) => r.camera.id === 'w2-cctv-video');
.find((r) => r.camera.id === 'fixture-cctv-video');
return {
show: r.projection?.planeEntity?.show === true,
visible: r.projection?.planeVisible === true,
@@ -123,7 +124,7 @@ await strict.run({}, async (b) => {
v.params.coverageMode,
);
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(catalogs, count);
await strict.passed(
+3 -3
View File
@@ -77,9 +77,9 @@
*/
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 { installCctvFixture } from './fixtures/w2/cctv.mjs';
import { installCctvFixture } from './fixtures/layers/cctv.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -581,7 +581,7 @@ async function main() {
if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
});
await page.setViewport({ width: 1280, height: 800 });
watchW1Page(page, evidence, {
watchBrowserPage(page, evidence, {
name: 'cctv-legacy',
baseUrl: APP_URL,
preview: getFlag('--preview'),
+58
View File
@@ -90,6 +90,41 @@ async function runOn(browser, kind) {
content:
'* { 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.
await page.exposeFunction('__contractPointer', async (type, x, y) => {
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.
const makeCanvas = (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, {
waitFrames,
pointer,
readRegion,
makeCanvas,
})) {
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
* 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/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
* 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';
// ---------------------------------------------------------------------------
// Args (same shape as qa-sprites-b5.mjs)
// Args (same shape as qa-aircraft-sprites.mjs)
// ---------------------------------------------------------------------------
const argv = process.argv.slice(2);
const getFlag = (name) => argv.includes(name);
@@ -308,7 +308,7 @@ async function main() {
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
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
// (layers.drawn: the layer's own sprite handles) on either engine. The
// 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 assert from 'node:assert/strict';
import { installAlprFixture, cameras } from './fixtures/w2/alpr.mjs';
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/w2/firms.mjs';
import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs';
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/layers/firms.mjs';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]),
fire = process.argv.includes('--fires'),
+2 -2
View File
@@ -9,7 +9,7 @@ import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
const urlIndex = process.argv.indexOf('--url');
if (urlIndex < 0) throw new Error('An explicit --url is required');
@@ -87,7 +87,7 @@ async function open(mode = 'normal') {
await page.bringToFront();
const errors = [];
if (extended)
watchW1Page(page, report, {
watchBrowserPage(page, report, {
name: mode,
baseUrl: appUrl,
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
// 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]
import fs from 'node:fs/promises';
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 }),
});
check({
id: 'D2', group: 'D', desc: 'qa-heading-b3 — path-derived display heading',
heavy: true, run: harness({ id: 'D2', script: 'qa-heading-b3.mjs', args: ['--url', APP_URL], timeoutMs: 900000 }),
id: 'D2',
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({
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,
run: harness({
id: 'D3',
script: 'qa-sprites-b5.mjs',
script: 'qa-aircraft-sprites.mjs',
args: ['--url', APP_URL],
timeoutMs: 900000,
knownConditions: [{
+1 -1
View File
@@ -1,5 +1,5 @@
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';
const url = localPreview(process.argv[2]);
// 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 { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { stations } from './fixtures/w2/radio.mjs';
import { stations } from './fixtures/layers/radio.mjs';
import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
+1 -1
View File
@@ -44,7 +44,7 @@ import {
slope,
startSession,
} 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 { POSITION_HISTORY_LIMIT } from '../src/layers/flights/recordPolicy.js';
import { heapSizesByType } from './qa/heapSnapshotSizes.mjs';
+2 -2
View File
@@ -1,11 +1,11 @@
import assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js';
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 {
combinedPayloads,
installCombinedFixture,
} from './fixtures/w2/combined.mjs';
} from './fixtures/layers/combined.mjs';
const url = localPreview(process.argv[2]),
bus = process.argv.includes('--transit'),
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 { rendererFor } from './renderers.js';
import * as credits from '../../data/dataCredits.js';
@@ -37,20 +36,5 @@ export function createApplicationCctv({
render,
},
});
return {
...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();
}
},
};
return layer;
}
-2
View File
@@ -52,7 +52,6 @@ export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
export const clearProbeClampOnDeactivation =
layer.clearProbeClampOnDeactivation;
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
export const bindCctvWorldClickGesture = layer.bindCctvWorldClickGesture;
export const setActiveCamera = layer.setActiveCamera;
export const deactivateActiveCamera = layer.deactivateActiveCamera;
export const cctvEmptyClickDeselects = layer.cctvEmptyClickDeselects;
@@ -65,7 +64,6 @@ export const materializeCctvVisibleCoverageEntities =
export const _extractPickedCameraIdForTest =
layer._extractPickedCameraIdForTest;
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop;
export const cctvCycleIndex = layer.cctvCycleIndex;
export {
+30 -31
View File
@@ -39,7 +39,6 @@ import cctvLayer, {
_pushAmbientCardEntriesForTest,
_setCctvOverlayHostForTest,
activationProbeClampRange,
bindCctvWorldClickGesture,
clearProbeClampOnDeactivation,
computeFrustumGeometry,
cctvCycleIndex,
@@ -60,7 +59,6 @@ import cctvLayer, {
createGeometryProgressNotifier,
normalizeCoverageMode,
frameSignatureFromPixels,
focusCctvRecord,
hideCctvRecordVisuals,
materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities,
@@ -85,30 +83,28 @@ import {
} from '../layers/cctv/policy.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
// import() (src/app/layers/renderers.js).
await loadLayerRenderers('cesium');
const ASPECT = 16 / 9;
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
// lat/lon→metres conversion in the assertions matches the module's math.
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', () => {
let timeMs = 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 requests = [];
target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) =>
requests.push(event.detail),
);
bindCctvWorldClickGesture(
handler,
display.bindClick(
() => {
activateCctvCameraFromWorldClick(
'atx-cam-3',
@@ -1403,28 +1401,29 @@ test('CCTV drag-then-release over a camera is inert, while a clean tap activates
},
{
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;
handler.fire(GESTURE_TYPES.MOUSE_MOVE, { endPosition: { x: 14, y: 10 } });
handler.fire('move', { endPosition: { x: 14, y: 10 } });
timeMs = 40;
handler.fire(GESTURE_TYPES.MOUSE_MOVE, { endPosition: { x: 10, y: 10 } });
handler.fire('move', { endPosition: { x: 10, y: 10 } });
timeMs = 60;
handler.fire(GESTURE_TYPES.LEFT_UP, { position: { x: 10, y: 10 } });
handler.fire(GESTURE_TYPES.LEFT_CLICK, { position: { x: 10, y: 10 } });
handler.fire('up', { position: { x: 10, y: 10 } });
handler.fire('click', { position: { x: 10, y: 10 } });
assert.equal(activationCalls, 0);
assert.deepEqual(requests, []);
timeMs = 100;
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
handler.fire('down', { position: { x: 10, y: 10 } });
timeMs = 180;
handler.fire(GESTURE_TYPES.LEFT_UP, { position: { x: 11, y: 11 } });
handler.fire(GESTURE_TYPES.LEFT_CLICK, { position: { x: 11, y: 11 } });
handler.fire('up', { position: { x: 11, y: 11 } });
handler.fire('click', { position: { x: 11, y: 11 } });
assert.equal(activationCalls, 1);
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
display.destroy();
engine.destroy();
});
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
// appear here too.
const guarded = [
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
['src/data/trackingGesture.js', 'onClick(click, gesture);'],
[
'src/layers/infrastructure/render/display.js',
'const picked = event.picked;',
],
['src/data/cctvGizmo.js', 'pickGizmoPart(event)'],
// 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;'],
// FIRMS takes the engine dispatcher's click with its pick.
['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/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
// moment a user clicked anything. Only tools claim.
for (const file of [
'src/data/trackingClickGesture.js',
'src/data/trackingGesture.js',
'src/layers/infrastructure/render/display.js',
'src/data/cctvGizmo.js',
'src/layers/vessels/selection.js',
+14 -61
View File
@@ -1,34 +1,17 @@
import { sceneServicesFor } from './sceneServices.js';
// One selection dispatcher for the layers that draw through the globe
// engine: one click subscription with picks
// (engine.input.on('click', …, { pick: true, drill: 8 })), the press
// gesture and one Escape listener, instead of a ScreenSpaceEventHandler
// per layer. Owners register as with pickRegistry (registerPickOwner, so
// layers still on their own handlers keep seeing the ownership) plus
// handlers:
// onSelect(id, ctx) a click whose first pick it owns;
// 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.
// Shared selection for registered owners, with one pick subscription and
// press accumulator. Normal owners select their first pick, clear on a short
// empty click, and retain selection on another owner's pick. Pass-through
// picks yield to the next hit; drill-through owners may select below an
// unregistered owner's hit. Pointer claims suppress selection.
// Look-under owners select below unowned picks or their declared pass-through
// ids, clear on other owners, and ignore clicks containing only those ids.
// Own-rule owners receive every pointer-free click and apply their own rules.
import { isPointerFree as pointerFree } from './inputOwnership.js';
import { pickRegistryFor, resolvePickId } from './pickRegistry.js';
import {
createPressGesture,
isTrackingClickGesture,
isTrackingSelectionGesture,
} from './trackingGesture.js';
@@ -58,27 +41,7 @@ export function createSelectionDispatcher(
let removers = [];
let destroyed = false;
// The press gesture, as bindTrackingClickGesture measures it.
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 press = createPressGesture(now);
const ownerOf = (picked) => {
const id = resolvePickId(picked);
@@ -116,9 +79,7 @@ export function createSelectionDispatcher(
typeof owner.lookUnder !== 'function' && !ownRule(owner);
const onClick = (event) => {
if (press) finish(event.position);
const gesture = completed ?? { travelPx: 0, durationMs: 0 };
completed = null;
const gesture = press.click(event.position);
if (!isPointerFree()) return;
const picked = nearbyContact(
event.picked,
@@ -204,17 +165,9 @@ export function createSelectionDispatcher(
const attach = () => {
if (removers.length || destroyed) return;
removers = [
engine.input.on('down', (event) => {
press = {
startedAt: now(),
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('down', (event) => press.down(event.position)),
engine.input.on('move', (event) => press.move(event.endPosition)),
engine.input.on('up', (event) => press.up(event.position)),
engine.input.on('click', onClick, { pick: true, drill: SELECTION_DRILL }),
];
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,
isTrackingClickGesture,
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 MILITARY_TRACKED_MODEL_MAX_PX } from './militaryFlights.js';
const TYPES = {
LEFT_DOWN: 'left-down',
MOUSE_MOVE: 'mouse-move',
LEFT_UP: 'left-up',
LEFT_CLICK: 'left-click',
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
};
function makeHandler() {
const actions = new Map();
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); },
};
}
@@ -49,7 +49,6 @@ test('synthetic drag-then-click sequence does not reach the untrack callback', (
if (isTrackingClickGesture(gesture)) untrackCalls += 1;
}, {
now: () => timeMs,
eventTypes: TYPES,
});
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;
}, {
now: () => timeMs,
eventTypes: TYPES,
});
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_DURATION_MS = 400;
@@ -30,3 +32,70 @@ export function isTrackingClickGesture(gesture = {}) {
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) => {
// Cesium's Material sorts uniform values with instanceof checks against
// 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] }]),
);
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', () => {
@@ -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);
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 DEG = Math.PI / 180;
/** Reveal fraction within a packed corridor item. */
export const REVEAL_OFFSET = 20;
/** Pieces a tile holds before it splits. */
export const TILE_PIECES = 20000;
/** 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 { 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
// later try waits twice as long, up to RETRIES tries.
@@ -61,7 +61,11 @@ export function createNavaraGround(
};
const pack = (entry) => {
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.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0;
};
@@ -122,7 +126,11 @@ export function createNavaraGround(
if (entry.fraction === fraction) return;
entry.fraction = fraction;
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;
if (entry.offset !== null)
pass.reveal(
+11 -21
View File
@@ -13,6 +13,7 @@
// materials, from the time uniform (src/globe/ground.js groundSeconds).
// 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 { Effect, EffectAttribute } from 'postprocessing';
import {
@@ -20,13 +21,13 @@ import {
FloatType,
Matrix4,
NearestFilter,
RGBADepthPacking,
RGBAFormat,
Uniform,
} from 'three';
import { DASH_EDGE, groundSeconds } from '../../ground.js';
import { groundSurfaceUniforms, updateGroundSurface } from './surface.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 {
GRID_COLS,
GRID_ROWS,
@@ -60,12 +61,8 @@ uniform float uPixelRatio;
vec4 atSlot(highp sampler2D image, int slot) {
return texelFetch(image, ivec2(slot & ${ROW - 1}, slot >> ${Math.log2(ROW)}), 0);
}
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));
}
${DISPLAY_COLOR_GLSL}
${VIEW_POSITION_GLSL}
// 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
// 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) {
outputColor = inputColor;
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);
vec3 point = ray.xyz * (distance / -ray.z);
vec3 point = viewPosition(ray, distance);
// Derivatives before any divergent branch: neighbours test other items.
vec3 pointDx = dFdx(point);
vec3 pointDy = dFdy(point);
@@ -403,8 +400,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
// Its own pass: inputColor is the tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)],
...groundSurfaceUniforms(),
['tFrames', new Uniform(null)],
['tStyles', new Uniform(null)],
['tSegments', new Uniform(null)],
@@ -413,9 +409,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
['tItems', new Uniform(null)],
['tGrid', new Uniform(null)],
['tCandidates', new Uniform(null)],
['uInverseProjection', new Uniform(new Matrix4())],
['uViewToWorld', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
['uTime', new Uniform(0)],
['uPixelRatio', new Uniform(1)],
]),
@@ -428,8 +422,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
const owner = this.owner;
if (!this.effect || !camera || !owner.state) return;
const mrt = this.find('mrt');
if (mrt?.depthBufferPacking !== RGBADepthPacking)
throw new Error('The ground pass needs packed scene depth');
updateGroundSurface(this.effect, camera, this.ctx, mrt);
const input = this.ctx.getInputBuffer();
const { state } = owner;
if (
@@ -451,11 +444,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
textures.candidates.needsUpdate = true;
}
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('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
u.get('uTime').value = groundSeconds();
const cssWidth = this.view.canvas?.clientWidth;
u.get('uPixelRatio').value = cssWidth > 0 ? input.width / cssWidth : 1;
@@ -504,7 +493,8 @@ export function createGroundPass(view) {
reveal(offset, count, fraction) {
if (!owner.state) return;
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;
},
textureBytes: () => textureBytes(owner.state),
+59 -38
View File
@@ -1,13 +1,15 @@
// 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 { 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 fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform mat4 uInverseProjection;
uniform mat4 uInverseView;
uniform float uLogDepthFC;
@@ -23,12 +25,8 @@ uniform float uDepth;
uniform float uLineWidth;
uniform float uHaloWidth;
uniform float uPixelRatio;
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));
}
${DISPLAY_COLOR_GLSL}
${VIEW_POSITION_GLSL}
vec4 over(vec4 top, vec4 under) {
float a = top.a + under.a * (1.0 - top.a);
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) {
outputColor=inputColor;
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);
vec3 point=ray.xyz*(distance/-ray.z);
vec3 point=viewPosition(ray, distance);
vec3 origin=uInverseView[3].xyz;
vec3 direction=normalize((uInverseView*vec4(ray.xyz,0.0)).xyz);
vec3 o=origin/uRadiiM;
@@ -81,36 +79,49 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
}
`;
const pulseEffect = defineGroundEffect({
key: KEY,
name: 'GevGroundPulses',
fragmentShader,
insertAfter: ['gevGround', 'gevImagery', 'toneMapping'],
insertBefore: [
class PulseEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
static allowDuplication = true;
static key = KEY;
static insertAfter = ['gevGround', 'gevImagery', 'toneMapping'];
static insertBefore = [
'gevPreSharpenOverlay',
'gevSharpen',
'smaa',
'fxaa',
'gevOverlay',
'final',
],
uniforms: () => [
...groundSurfaceUniforms(),
['uInverseView', new Uniform(new Matrix4())],
['uCenter', new Uniform(new Vector3(1, 0, 0))],
['uRadiusAngle', new Uniform(1)],
['uRadiiM', new Uniform(new Vector3())],
['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)],
],
update() {
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
}
createPass() {
this.effect = new Effect('GevGroundPulses', fragmentShader, {
// Input color is the tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms({ surface: false }),
['uInverseView', new Uniform(new Matrix4())],
['uCenter', new Uniform(new Vector3(1, 0, 0))],
['uRadiusAngle', new Uniform(1)],
['uRadiiM', new Uniform(new Vector3())],
['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,
camera = this.view.camera.raw;
if (!this.effect || !camera) return;
@@ -139,8 +150,15 @@ const pulseEffect = defineGroundEffect({
cssWidth > 0
? this.ctx.getInputBuffer().width / cssWidth
: 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):
@@ -149,7 +167,7 @@ const pulseEffect = defineGroundEffect({
* rgba arrays in 0..1: the band, the pulse line and its halo.
*/
export function createNavaraPulseField(
{ view, EffectDesc, NavaraEffect, canvas },
{ view, canvas },
{
center,
radiusM,
@@ -162,7 +180,10 @@ export function createNavaraPulseField(
haloWidth,
},
) {
pulseEffect.register(view, { EffectDesc, NavaraEffect });
if (!registered.has(view)) {
view.registerEffect(KEY, PulseEffectDesc);
registered.add(view);
}
const owner = {
cssWidth: () => canvas?.clientWidth ?? 0,
center: new Vector3(),
+22 -2
View File
@@ -1,7 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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', () => {
const effects = [];
@@ -25,7 +45,7 @@ test('the Navara pulse disc draws only while the pulses run', () => {
},
};
const disc = createNavaraPulseField(
{ view, EffectDesc: class {}, NavaraEffect: class {} },
{ view },
{
center: geo.cartesian3.fromDegrees(3, 27, 0),
radiusM: 250_000,
+4 -3
View File
@@ -1,9 +1,9 @@
// Public depth inputs shared by actual terrain/tiles classifiers.
import { Matrix4, Uniform, RGBADepthPacking } from 'three';
export function groundSurfaceUniforms() {
export function groundSurfaceUniforms({ surface = true } = {}) {
return [
['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)],
...(surface ? [['tSurfaceDepth', new Uniform(null)]] : []),
['uInverseProjection', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
];
@@ -13,7 +13,8 @@ export function updateGroundSurface(effect, camera, context, mrt) {
throw new Error('Ground classification requires packed scene depth');
const u = effect.uniforms;
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('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
// 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 { 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 { MAX_IMAGERY_SLOTS } from './index.js';
@@ -39,12 +44,8 @@ uniform vec4 uBounds${i};
uniform vec4 uLook${i};
uniform float uRaise${i};`,
).join('\n')}
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));
}
${DISPLAY_COLOR_GLSL}
${VIEW_POSITION_GLSL}
// Geodetic longitude and latitude (Bowring) of a world point, degrees.
vec2 geodetic(vec3 point) {
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) {
outputColor = inputColor;
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);
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
// also holds forward meshes, which are not ground).
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.
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)],
['uInverseProjection', new Uniform(new Matrix4())],
...groundSurfaceUniforms(),
['uWorld', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
['uSplit', new Uniform(0.5)],
...SLOTS.flatMap((i) => [
[`tImage${i}`, new Uniform(null)],
@@ -224,14 +222,9 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
const owner = this.owner;
if (!this.effect || !camera) return;
const mrt = this.find('mrt');
if (mrt?.depthBufferPacking !== RGBADepthPacking)
throw new Error('The imagery pass needs packed scene depth');
updateGroundSurface(this.effect, camera, this.ctx, mrt);
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('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
u.get('uSplit').value = owner.split;
for (const i of SLOTS) {
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
// dynamic import() so Navara stays out of the Cesium-mode bundle.
import ThreeView, {
Color as NavaraColor,
MeshDesc,
EffectDesc,
Effect as NavaraEffect,
} from '@navaramap/three';
import ThreeView, { Color as NavaraColor, MeshDesc } from '@navaramap/three';
import * as geo from '../../geo/index.js';
import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { Pass } from 'postprocessing';
@@ -177,10 +172,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
ground: createNavaraGround(view, {
createPass: createGroundPass,
pulseField: (options) =>
createNavaraPulseField(
{ view, EffectDesc, NavaraEffect, canvas },
options,
),
createNavaraPulseField({ view, canvas }, options),
}),
imagery: (parts) =>
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
// lines drawn after tone mapping, overlay.js): there is no depth buffer in
// 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(). */
export const OVERLAY_DEPTH_FRAGMENT = /* glsl */ `
${PACKED_DEPTH_GLSL}
uniform sampler2D tGevSceneDepth;
uniform vec2 uGevResolution;
uniform float uGevDepthMode;
@@ -36,7 +38,7 @@ bool gevOverlayHidden() {
#else
float mine = gl_FragCoord.z;
#endif
bool occluded = mine > scene + 2e-6;
bool occluded = packedDepthOccluded(mine, scene, 2e-6);
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
// 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
@@ -15,6 +16,7 @@ const KEY = 'gevSceneDepth';
export const SCENE_DEPTH_BIAS = 5e-6;
const PARS = /* glsl */ `
${PACKED_DEPTH_GLSL}
uniform sampler2D tGevSceneDepth;
uniform vec2 uGevTargetSize;
uniform float uGevSceneDepthOn;
@@ -32,7 +34,7 @@ if (uGevSceneDepthOn > 0.5) {
#else
float gevDepth = gl_FragCoord.z;
#endif
if (gevScene < 1.0 && gevDepth > gevScene + ${SCENE_DEPTH_BIAS.toExponential()})
if (gevScene < 1.0 && packedDepthOccluded(gevDepth, gevScene, ${SCENE_DEPTH_BIAS.toExponential()}))
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('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.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';
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;
class ContractError extends Error {
@@ -505,11 +539,7 @@ export function createContractSuite(
// count and centroid.
// A colour as drawn: within 40 of the asked one on every channel
// (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 half = 32;
const x0 = Math.round(screen.x) - half;
@@ -3002,17 +3032,7 @@ function groundChecks(
[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 [
[
'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 settle();
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);
return Math.min(p[0], p[2]) - p[1];
};
@@ -3414,7 +3434,8 @@ function groundChecks(
'the pickable fills drawn',
);
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);
ensure(
onCell?.id === 'cell-a' && onCell.owner === 'contract-ground-cells',
@@ -3451,7 +3472,7 @@ function groundChecks(
await settle(3);
if (!pointer) return;
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 until(() => clicks.length > before, 'a click payload');
const clicked = clicks.at(-1);
@@ -3474,14 +3495,10 @@ function groundChecks(
await nadirView(1e6);
const MAGENTA = [1, 0, 1, 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.
const countAt = (lon, lat, colour, half = 12) => {
const { x, y } = screenOf(lon, lat);
const { x, y } = screenOf(engine, lon, lat);
const data = readRegion(
Math.round(x) - 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`,
);
// 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(
Math.round(mid.x),
Math.round(mid.y) - 10,
@@ -3753,7 +3770,11 @@ function groundChecks(
const along = (lat, samples = 120) => {
const out = [];
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([
...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;
// 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(
Math.round(centre.x),
Math.round(centre.y) - 12,
@@ -3790,7 +3811,7 @@ function groundChecks(
let trough = 0;
const started = performance.now();
while (performance.now() - started < 1600) {
const centre = screenOf(20, 10);
const centre = screenOf(engine, 20, 10);
const pixel = readRegion(
Math.round(centre.x),
Math.round(centre.y),
@@ -3892,6 +3913,7 @@ function groundChecks(
const metresPerDegree = 111319.49 * Math.cos((10 * Math.PI) / 180);
const at = (radius, angle) => {
const { x, y } = screenOf(
engine,
20 + (radius * Math.cos(angle)) / metresPerDegree,
10 + (radius * Math.sin(angle)) / 110574,
);
@@ -3951,15 +3973,7 @@ function groundChecks(
// engine.imagery: raster layers on the ground (every engine).
function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
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.
const flat = (rgba, size = 8) => {
const data = new Uint8Array(size * size * 4);
@@ -4132,35 +4146,16 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
async () => {
if (!readRegion) return;
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.
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.
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
await settle(4);
const base = meanAt(19.5, 10);
ensure(!near(base, MAGENTA), 'the globe is already magenta there');
const base = meanAt(engine, readRegion, 19.5, 10, 6);
ensure(
!nearColour(base, MAGENTA),
'the globe is already magenta there',
);
const magenta = imagery.createLayer({
id: 'contract-paint-magenta',
source: {
@@ -4173,33 +4168,38 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
try {
await until(() => magenta.ready(), 'the magenta layer ready');
await until(
() => near(meanAt(19.5, 10), MAGENTA),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the magenta layer drawn',
);
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',
);
// Alpha: at 0 the globe under it, at 0.5 half way to magenta.
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);
const below = meanAt(19.5, 10);
const below = meanAt(engine, readRegion, 19.5, 10, 6);
magenta.setAlpha(0.5);
const half = below.map((value, k) => (value + MAGENTA[k]) / 2);
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)`,
);
magenta.setAlpha(1);
magenta.show(false);
await until(
() => near(meanAt(19.5, 10), below, 30),
() =>
nearColour(meanAt(engine, readRegion, 19.5, 10, 6), below, 30),
'the hidden layer gone',
);
magenta.show(true);
await until(
() => near(meanAt(19.5, 10), MAGENTA),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the layer shown again',
);
// 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);
await until(
() => near(meanAt(19.5, 10), GREEN),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN),
'the higher order on top',
);
green.destroy();
await until(
() => near(meanAt(19.5, 10), MAGENTA),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the destroyed layer gone',
);
// A new source in place: the same layer, another colour.
@@ -4239,7 +4239,7 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
rectangle: box,
});
await until(
() => near(meanAt(19.5, 10), GREEN),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN),
'the new source drawn',
);
magenta.setSource({
@@ -4250,11 +4250,11 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
lower.destroy();
// Split: a left layer draws only left of the split position.
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);
magenta.setSplit('left');
const left = screenOf(19.25, 10);
const right = screenOf(19.75, 10);
const left = screenOf(engine, 19.25, 10);
const right = screenOf(engine, 19.75, 10);
const at = ({ x, y }) => {
const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6);
const mean = [0, 0, 0];
@@ -4263,17 +4263,20 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
return mean;
};
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))})`,
);
magenta.setSplit('right');
await until(
() => !near(at(left), MAGENTA) && near(at(right), MAGENTA),
() =>
!nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA),
'the right split drawn',
);
magenta.setSplit('none');
await until(
() => near(at(left), MAGENTA) && near(at(right), MAGENTA),
() =>
nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA),
'the unsplit layer drawn',
);
} finally {
@@ -4283,7 +4286,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
await until(
() =>
[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',
);
@@ -4479,29 +4483,7 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
async () => {
if (!readRegion) return;
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 settle(4);
// Every tile, whatever the app serves, painted magenta by the
@@ -4536,11 +4518,11 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
});
layers.push(tiles);
await until(
() => near(meanAt(19.5, 10), MAGENTA),
`the transformed tiles drawn (${show(meanAt(19.5, 10))})`,
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA),
`the transformed tiles drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`,
);
ensure(
!near(meanAt(21, 10), MAGENTA),
!nearColour(meanAt(engine, readRegion, 21, 10, 5), MAGENTA),
'the tiles spill outside their rectangle',
);
// 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');
ensure(swapped === true, 'the staged image failed');
await until(
() => near(meanAt(19.5, 10), GREEN),
`the staged image drawn (${show(meanAt(19.5, 10))})`,
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN),
`the staged image drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`,
);
tiles.destroy();
// 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);
await until(
() => near(meanAt(19.6, 10), MAGENTA),
`the raised layer drawn (${show(meanAt(19.6, 10))})`,
() => nearColour(meanAt(engine, readRegion, 19.6, 10, 5), MAGENTA),
`the raised layer drawn (${show(meanAt(engine, readRegion, 19.6, 10, 5))})`,
);
raisedLayer.setDetail({
image: flat([...GREEN, 255]),
@@ -4579,13 +4561,13 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
});
await until(
() =>
near(meanAt(19.25, 10), GREEN) &&
near(meanAt(19.75, 10), MAGENTA),
`the detail drawn (west ${show(meanAt(19.25, 10))}, east ${show(meanAt(19.75, 10))})`,
nearColour(meanAt(engine, readRegion, 19.25, 10, 5), GREEN) &&
nearColour(meanAt(engine, readRegion, 19.75, 10, 5), MAGENTA),
`the detail drawn (west ${show(meanAt(engine, readRegion, 19.25, 10, 5))}, east ${show(meanAt(engine, readRegion, 19.75, 10, 5))})`,
);
raisedLayer.setDetail(null);
await until(
() => near(meanAt(19.25, 10), MAGENTA),
() => nearColour(meanAt(engine, readRegion, 19.25, 10, 5), MAGENTA),
'the detail cleared',
);
} finally {
@@ -4593,7 +4575,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
}
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',
);
},
-159
View File
@@ -57,41 +57,6 @@ test('the fake records contract calls for shell-module tests', () => {
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', () => {
const engine = createFakeEngine();
const seen = [];
@@ -190,25 +155,6 @@ test('fake credits, maps and destroy keep inspectable state', () => {
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', () => {
const engine = createFakeEngine();
const seen = [];
@@ -292,33 +238,6 @@ test('fake isDestroyed, commanded and pickGlobe are inspectable', () => {
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 () => {
const { createAppClock } = await import('../../time/clock.js');
let wall = 5000;
@@ -387,81 +306,3 @@ test('fake lines keep their positions, colour, matrix and visibility', () => {
line.destroy();
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
// 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.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.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');
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();
assert.equal(f.set('air-quality').shown, false);
if (f.cesium) assert.equal(f.collectionShown('air-quality'), false);
f.layer.enable();
assert.equal(f.set('air-quality').shown, true);
} finally {
+1 -1
View File
@@ -15,4 +15,4 @@ export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js';
export * from '../../data/routePlausible.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
// Cesium engine (real Cesium collections, billboards, models and
// 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 geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js';
import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js';
import {
@@ -33,28 +34,7 @@ const AXIS = Cesium.Matrix4.multiplyTransformation(
);
const SCALE_BY_DISTANCE = nearFarScale(1000, 3.0, 8000000, 0.5);
function 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];
const HARNESSES = [fakeHarness];
async function setup(makeHarness, options = {}) {
const harness = makeHarness();
@@ -149,34 +129,7 @@ for (const makeHarness of HARNESSES) {
await settle();
const [listed] = setsOf(engine, 'flights-sprites');
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);
assert.equal(set.options.depthTest, 'always');
assert.deepEqual(
@@ -195,9 +148,7 @@ for (const makeHarness of HARNESSES) {
near(store.colors[1], 184 / 255, 'green');
}
// Writes inside a frame listener commit at its end, once.
const commits = h.cesium
? null
: () => engine.fake.moverSet(listed.id).commits;
const commits = () => engine.fake.moverSet(listed.id).commits;
const before = commits?.();
const remove = display.view.on('preRender', () => {
sprite.color = WHITE.withAlpha(0.45);
@@ -210,12 +161,7 @@ for (const makeHarness of HARNESSES) {
remove();
if (commits) assert.equal(commits(), before + 1);
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);
near(store.colors[3], 0.45, 'alpha');
near(store.scales[0], 2.4, 'scale × width/20');
@@ -233,9 +179,7 @@ for (const makeHarness of HARNESSES) {
await settle();
assert.equal(setsOf(engine, 'flights-sprites')[0].count, 1);
assert.equal(other.gevIndex, 0);
if (h.cesium) assert.equal(h.primitive(listed.id).get(0).id, 'def456');
else
assert.equal(engine.fake.moverSet(listed.id).store.pickIds[0], 'def456');
assert.equal(engine.fake.moverSet(listed.id).store.pickIds[0], 'def456');
// Another depth mode is another set.
other.disableDepthTestDistance = 0;
await settle();
@@ -288,16 +232,7 @@ for (const makeHarness of HARNESSES) {
await settle();
const [listed] = setsOf(engine, 'flights-models');
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(
Cesium.Matrix4.getMatrix3(drawn, new Cesium.Matrix3()),
);
@@ -311,13 +246,11 @@ for (const makeHarness of HARNESSES) {
}
model.show = true;
await settle();
if (h.cesium) assert.equal(h.loads[0].show, true);
else
assert.equal(
engine.fake.moverSet(listed.id).store.shows[0],
1,
'shown once placed',
);
assert.equal(
engine.fake.moverSet(listed.id).store.shows[0],
1,
'shown once placed',
);
assert.ok(model.computedScale >= model.scale);
// A per-model nose offset (military per-class specs).
display.models.setPose(model, position, 75, 90);
@@ -366,15 +299,6 @@ for (const makeHarness of HARNESSES) {
const [listed] = setsOf(engine, 'flights-tracked');
assert.equal(listed.count, 1);
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);
return {
x: store.positions[0],
@@ -385,15 +309,7 @@ for (const makeHarness of HARNESSES) {
};
assert.equal(read().id, handle.id);
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.color = CYAN.withAlpha(0);
h.frame();
@@ -409,39 +325,19 @@ for (const makeHarness of HARNESSES) {
assert.equal(following.label.model(), handle.gevLabelModel);
assert.equal(display.follow.isFollowing(handle), true);
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.
const centred = state.position;
assert.equal(following.target(), centred);
state.position = geo.cartesian3.fromDegrees(-97.5, 30.2, 3000);
if (h.cesium) {
h.frame();
assert.equal(read().x, centred.x, "the follow's sample");
h.frame();
} else h.frame();
h.frame();
assert.equal(read().x, state.position.x);
display.follow.release();
assert.equal(engine.camera.following(), null);
assert.equal(display.follow.current(), undefined);
assert.equal(display.follow.isFollowing(handle), false);
if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined);
handle.resumeAircraftFollow();
assert.equal(display.follow.current(), handle);
display.tracked.remove(handle);
@@ -466,10 +362,7 @@ for (const makeHarness of HARNESSES) {
h.frame();
await settle();
const [listed] = setsOf(engine, 'flights-tracked');
const drawn = () =>
h.cesium
? h.primitive(listed.id).get(0).show
: engine.fake.moverSet(listed.id).store.shows[0] === 1;
const drawn = () => engine.fake.moverSet(listed.id).store.shows[0] === 1;
assert.equal(drawn(), true);
// The Cockpit hides what the engine follows, then releases the follow.
const ridden = engine.camera.following().native;
@@ -486,10 +379,7 @@ for (const makeHarness of HARNESSES) {
const moved = setsOf(engine, 'flights-tracked').find(
({ count }) => count === 1,
);
const shownIn = (set) =>
h.cesium
? h.primitive(set.id).get(0).show
: engine.fake.moverSet(set.id).store.shows[0] === 1;
const shownIn = (set) => engine.fake.moverSet(set.id).store.shows[0] === 1;
assert.equal(shownIn(moved), false);
ridden.show = true;
ridden.resumeAircraftFollow();
@@ -508,16 +398,8 @@ for (const makeHarness of HARNESSES) {
const remove = display.follow.onChanged(() =>
changes.push(display.follow.current()),
);
const native = h.cesium
? 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);
}
const native = { gevSelectionOrigin: 'user' };
engine.camera.follow({
native,
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');
const base = Cesium.Color.fromCssColorString('#00d4ff');
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);
assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, id);
@@ -567,10 +436,7 @@ for (const makeHarness of HARNESSES) {
const [bodyAlpha, bodyHidden] = alphas(body.id);
near(bodyAlpha, 0.85, 'trail 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]);
assert.equal(engine.lines.list()[0].points, 0, 'one point draws nothing');
trail.setVisible(false);
@@ -785,66 +651,3 @@ test('reused sprite field records are copied, including a mutated color getter',
display.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,
// engine.camera, engine.projector, engine.surface, engine.credits), the
// 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 { distanceScale } from '../../../globe/cpuPicks.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import { rgbaFromCss } from '../../shared/color.js';
import { alprCreditMarkup } from '../model.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 });
if (isSelected) {
selected.setCount(1);
selected.set(0, fields);
writeSelectedMarker(selected, fields);
}
// The overlay changes ownership while it paints, after the frame's
// render request was taken: wake the next frame, which commits.
@@ -215,7 +217,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
}
function dropSelectedSprite() {
if (!initialized) return;
selected.setCount(0);
writeSelectedMarker(selected, null);
dirty = true;
}
@@ -378,7 +380,6 @@ export function createAlprDisplay(engine, { state, services, source }) {
function renderRecords() {
if (!initialized) return;
const selectedContext = getSelectedEntityContext();
// Over the render cap, keep the cameras nearest the view centre and say
// coverage is limited rather than dropping an arbitrary subset.
const { visible: shown, capped } = viewRecords(state.records);
@@ -386,13 +387,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
visibleRecords = shown;
// A refresh may retain its own selection, never reclaim one cleared or
// replaced by an aircraft, another layer, or a voice action.
if (
selectedContext?.layerId !== LAYER_ID ||
selectedContext.id !== state.selectedId ||
!shown.some((record) => record.id === state.selectedId)
) {
state.selectedId = null;
selectionStartedAt = 0;
if (!selection.reconcile({ layerId: LAYER_ID, records: shown })) {
dropSelectedSprite();
clearSelectedEntityContextForLayer(LAYER_ID);
}
@@ -595,21 +590,17 @@ export function createAlprDisplay(engine, { state, services, source }) {
return true;
}
function onClick({ position, picked }) {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.enabled) return;
const id =
const selection = createSelectionKeeper({
state,
getSelectedEntityContext,
selectRecord,
clearSelection,
pickId: ({ position, picked }) =>
overlay.pick(position) ||
(typeof picked?.id === 'string' ? picked.id : null);
if (
id &&
state.recordById.has(id) &&
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
)
selectRecord(id);
else if (state.selectedId) clearSelection();
}
(typeof picked?.id === 'string' ? picked.id : null),
reselectContext: true,
});
const onClick = selection.onClick;
// Badges move on screen with the camera: their horizon test and a pick
// 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
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { setLocale } from '../../../i18n/index.js';
import { shownEntityLabel } from '../../../voice/speech.js';
import { distanceScale } from '../../../globe/cpuPicks.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 { directionWedgePositions } from '../visuals.js';
import { createAlprDisplay } from './display.js';
@@ -69,81 +63,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
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];
const HARNESSES = [fakeHarness];
function services() {
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.alpha, 1);
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 [
['alpr-cameras', 38, /alpr-marker-normal\.png$/],
['alpr-selected', 60, /alpr-marker-selected\.png$/],
@@ -393,11 +297,10 @@ for (const makeHarness of HARNESSES) {
h.load(records(1600));
assert.equal(h.display.count(), 1500);
assert.equal(h.state.renderSaturated, true);
if (!h.cesium)
assert.ok(
h.engine.fake.moverSet('alpr-cameras').commits < 10,
'one buffer commit per batch, not per marker',
);
assert.ok(
h.engine.fake.moverSet('alpr-cameras').commits < 10,
'one buffer commit per batch, not per marker',
);
const credit = h.credits().find((c) => /Fixture cameras/.test(c.html));
assert.ok(credit, 'the source credit shows with the cameras');
assert.equal(credit.onScreen, true);
@@ -416,7 +319,7 @@ for (const makeHarness of HARNESSES) {
follow.stop();
assert.equal(h.display.focusNearest(), true);
assert.equal(h.services.selected.id, 'alpr:0');
if (!h.cesium) {
{
const [{ args }] = h.engine.fake.callsOf('camera.flyToSphere');
assert.equal(args[0].radius, 30);
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
// on the Cesium engine: the camera it reads, the followed contact, a contact
// flight, its frames, the ring lines and the pulse field.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { createFakeGroundHarness } from '../../../testSupport/groundDisplayEngines.mjs';
import { createAwarenessDisplay, RING_HALO_ID, RING_ID } from './display.js';
const HARNESSES = [
() => ({ 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,
};
},
];
const HARNESSES = [createFakeGroundHarness];
for (const makeHarness of HARNESSES) {
const { name } = makeHarness();
@@ -81,10 +65,7 @@ for (const makeHarness of HARNESSES) {
ends = 0;
const offFrame = display.onFrame(() => frames++);
const offEnd = display.onFrameEnd(() => ends++);
if (h.cesium) {
h.viewer.scene.preRender.raiseEvent(h.viewer.scene);
h.viewer.scene.postRender.raiseEvent(h.viewer.scene);
} else h.engine.fake.frame();
h.engine.fake.frame();
assert.deepEqual([frames, ends], [1, 1]);
offFrame();
offEnd();
@@ -112,46 +93,19 @@ for (const makeHarness of HARNESSES) {
const center = geo.cartesian3.fromDegrees(3, 27, 0);
const rim = display.createRim(center);
const disc = display.createPulses(center);
if (h.cesium) {
const core = h.primitive(RING_ID);
const halo = h.primitive(RING_HALO_ID);
assert.equal(core.geometryInstances.geometry._width, 3);
assert.equal(halo.geometryInstances.geometry._width, 4);
assert.equal(core.allowPicking, false);
assert.ok(disc, 'the Cesium backend draws the pulse disc');
const primitives = Array.from(
{ length: h.viewer.scene.primitives.length },
(_, i) => h.viewer.scene.primitives.get(i),
);
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,
);
}
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);
const alpha = h.cesium
? h.primitive(RING_ID).appearance.material.uniforms.color.alpha
: h.engine.fake.line(RING_ID).color[3];
const alpha = h.engine.fake.line(RING_ID).color[3];
assert.ok(Math.abs(alpha - 0.95 * 0.4) < 1e-9);
rim.destroy();
assert.equal(
+3 -19
View File
@@ -56,8 +56,7 @@ for (const make of GROUND_HARNESSES) {
});
const before = h.engine.frames.preRender.numberOfListeners;
const display = drawStorySource(h.engine, source);
const frame = () =>
h.cesium ? h.viewer.scene.preRender.raiseEvent() : h.engine.fake.frame();
const frame = () => h.engine.fake.frame();
frame();
assert.equal(h.sets(), 3);
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].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();
assert.equal(
h.set('story-halo').commits,
@@ -186,8 +171,7 @@ for (const make of GROUND_HARNESSES) {
return 1200;
};
const display = drawStorySource(h.engine, source);
const frame = () =>
h.cesium ? h.viewer.scene.preRender.raiseEvent() : h.engine.fake.frame();
const frame = () => h.engine.fake.frame();
frame();
assert.equal(options.kind, 'point');
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,
// engine.camera, engine.surface), the same on every engine:
// - 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 record = key ? state._stationRenderMap.get(key) : null;
const shown = Boolean(record?.point?.position);
if (shown)
highlight.set(0, {
position: record.point.position,
color: SELECTED_COLOR.rgba(),
scale: 1,
show: true,
pickId: key,
});
highlight.setCount(shown ? 1 : 0);
writeSelectedMarker(
highlight,
shown
? {
position: record.point.position,
color: SELECTED_COLOR.rgba(),
scale: 1,
show: true,
pickId: key,
}
: null,
);
highlight.commit();
}
// 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
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.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 { capacityToPixelSize } from '../presentation.js';
import { COLOR_GREEN, COLOR_OUTLINE, COLOR_RED } from '../policy.js';
@@ -79,57 +73,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
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];
const HARNESSES = [fakeHarness];
function fixture(makeHarness, getStations) {
const h = makeHarness();
@@ -204,47 +148,20 @@ for (const makeHarness of HARNESSES) {
COLOR_GREEN.rgba().forEach((value, i) =>
near(station.color[i], value, `colour ${i}`),
);
if (f.cesium) {
const collection = f.primitive('bikeshare');
assert.ok(collection instanceof Cesium.PointPrimitiveCollection);
const point = station.native;
assert.equal(point.outlineWidth, 1);
assert.ok(
point.outlineColor.equalsEpsilon(
new Cesium.Color(...COLOR_OUTLINE.rgba()),
1e-6,
),
);
assert.ok(
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],
);
}
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);
await f.layer.update();
COLOR_RED.rgba().forEach((value, i) =>
@@ -267,22 +184,16 @@ for (const makeHarness of HARNESSES) {
assert.equal(highlight.shown, true);
near(highlight.pixelSize, 14, 'highlight size');
assert.deepEqual(highlight.color, [0, 1, 1, 1]);
if (f.cesium) {
const point = highlight.native;
assert.equal(point.outlineWidth, 2);
assert.ok(point.outlineColor.equals(Cesium.Color.BLACK));
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(point.scaleByDistance, undefined);
} else {
const { options } = f.engine.fake.moverSet('bikeshare-selected');
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',
);
}
const { options } = f.engine.fake.moverSet('bikeshare-selected');
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(
f.entries.get('bikeshare-selected')[0].title,
'中央 / Central',
@@ -360,13 +271,12 @@ for (const makeHarness of HARNESSES) {
await settle();
const point = f.layer.getDetectableObjects()[0].position;
near(geo.cartographic.fromCartesian(point).height, 177, 'height');
if (!f.cesium) {
const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
assert.deepEqual(
call.args[1].map((set) => set.id),
['bikeshare', 'bikeshare-selected'],
);
}
const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
assert.deepEqual(
call.args[1].map((set) => set.id),
['bikeshare', 'bikeshare-selected'],
);
} finally {
f.layer.destroy();
}
+11 -17
View File
@@ -17,7 +17,10 @@
import * as geo from '../../../geo/index.js';
import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.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 {
COVERAGE_DASH,
@@ -40,13 +43,6 @@ import {
const GIZMO_POINT_SET_ID = `${GIZMO_ID_PREFIX}points`;
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 capRing = (p) => [p.tl, p.tr, p.br, p.bl, p.tl];
const sameDash = (a, b) =>
@@ -617,17 +613,15 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) {
onMoveStart: (fn) =>
ownListener(engine.camera.moveStart.addEventListener(fn)),
bindClick(onClick, options = {}) {
const removes = [];
bindCctvWorldClickGesture(
{
setInputAction(fn, type) {
removes.push(engine.input.on(type, fn));
return ownListener(
bindTrackingClickGesture(
engine.input,
(click, gesture) => {
if (isTrackingClickGesture(gesture)) onClick(click);
},
},
onClick,
{ ...options, eventTypes: CLICK_EVENT_TYPES },
options,
),
);
return ownListener(() => removes.forEach((remove) => remove()));
},
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
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { viewshedColors } from '../../../data/cctvViewshed.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
@@ -90,30 +84,7 @@ for (const makeHarness of CCTV_HARNESSES) {
h.display.setMarkersVisible(false);
h.display.restoreOrder();
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');
assert.equal(set.options.order, 0, 'the bottom of the contact sprites');
assert.equal(set.options.depthTest, 'always');
@@ -178,14 +149,7 @@ for (const makeHarness of CCTV_HARNESSES) {
h.display.updateCoverage({ coverageEntities: lines }, moved);
assert.deepEqual(lines[0].polyline.positions, [moved.mount, 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');
assert.equal(line.widthPx, 1.2);
assert.equal(line.arc, 'geodesic');
@@ -213,21 +177,7 @@ for (const makeHarness of CCTV_HARNESSES) {
dashed: true,
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);
assert.deepEqual(line.dash, { kind: 'pixels', lengthPx: 8, gapPx: 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 });
assert.equal(cap.show, false);
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);
assert.equal(line.dash, null);
assert.equal(line.depthFailAlpha, 0);
@@ -354,23 +299,7 @@ for (const makeHarness of CCTV_HARNESSES) {
);
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');
assert.equal(outline.widthPx, 1);
assert.equal(outline.pickId, 'cctv-a-plane');
@@ -394,7 +323,6 @@ for (const makeHarness of CCTV_HARNESSES) {
h.display.removePlane(runtime);
assert.equal(material.disposed, true);
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) => {
@@ -422,37 +350,7 @@ for (const makeHarness of CCTV_HARNESSES) {
);
assert.equal(excludeObjects.length, 6);
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(
h.display.obstructionRay(POSITIONS.mount, direction, [], 30),
undefined,
@@ -558,16 +456,6 @@ for (const makeHarness of CCTV_HARNESSES) {
assert.deepEqual(credits.at(-1), ['hide', h.engine, 'cctv']);
assert.equal(h.engine.lines.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
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
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: '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');
assert.ok(
Math.abs(
@@ -262,9 +226,7 @@ for (const makeHarness of CCTV_HARNESSES) {
h.event('move');
assert.equal(h.canvas.style.cursor, 'grab');
const size = (index) =>
h.cesium
? h.primitive('cctv-gizmo-points').get(index).pixelSize
: h.engine.fake.moverSet('cctv-gizmo-points').store.scales[index] * 10;
h.engine.fake.moverSet('cctv-gizmo-points').store.scales[index] * 10;
near(size(3), 17, 'the hovered range handle');
near(size(0), 10, 'an arrow tip takes the handle size');
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 };
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
for (const [name, make] of [
['fake', () => ({ engine: createFakeEngine() })],
['Cesium', createCesiumMoversEngine],
]) {
for (const [name, make] of [['fake', () => ({ engine: createFakeEngine() })]]) {
test(`CCTV mesh ownership, live image and geometry updates (${name})`, (t) => {
const h = make();
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.drawnBy(plane), plane.mesh);
assert.equal(meshes.drawnBy(volume), volume.mesh);
const image = new HTMLCanvasElement();
const image = {};
image.width = image.height = 8;
material.setImage(image);
const moved = {
@@ -50,16 +45,9 @@ for (const [name, make] of [
capCenter: { ...positions.capCenter, x: positions.capCenter.x + 3 },
};
meshes.updatePlane(plane, shape, moved, identity);
const state = h.engine.fake?.mesh(plane.id);
const primitive = h.primitive?.(plane.id);
const flat =
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,
);
const state = h.engine.fake.mesh(plane.id);
assert.equal(state.positions[0], moved.capCenter.x - shape.halfW);
assert.equal(state.image, image);
assert.throws(
() => meshes.createPlane('b', {}, shape, positions, identity),
/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
// engine (src/testSupport/cesiumMoversEngine.mjs).
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { createCyclonesDisplay, cycloneSphereVisible } from './display.js';
import {
@@ -93,47 +92,6 @@ const ENGINES = {
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) {
@@ -258,26 +216,7 @@ bothEngines(
],
'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(), {
storms: 1,
tracks: 1,
+1 -5
View File
@@ -66,11 +66,7 @@ function fixture(makeHarness = createFakeGroundHarness) {
const makeEngine = (make = makeHarness) => {
const harness = make();
// 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);
return harness.engine;
};
+14 -51
View File
@@ -1,13 +1,8 @@
// The Directions display on the engine contract, on the fake engine and on
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
import { createApplicationDirectionsLayer } from '../../../data/directions.js';
@@ -114,11 +109,7 @@ function cesiumPoint(viewer, id, index) {
function fixture(t, makeHarness, { document = true } = {}) {
if (document) installDocument(t);
const h = makeHarness();
if (h.viewer)
h.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
const movers = recordMovers(h.engine);
let floor = 0;
const requests = [];
@@ -192,22 +183,7 @@ for (const makeHarness of GROUND_HARNESSES) {
getRenderGovernorDiagnostics(f.engine).holds.includes('directions'),
'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 });
assert.equal(f.set(IDS.route), undefined);
assert.ok(
@@ -241,13 +217,6 @@ for (const makeHarness of GROUND_HARNESSES) {
const at = geo.cartographic.fromCartesian(anchored.position);
near(at.height, 32, 'floor + lift', 1e-3);
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) => {
@@ -258,8 +227,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(f.layer.getRowControls().list.items[1].active, true);
near(f.movers(IDS.steps).rows[0].scale, 13 / 8, 'selected scale');
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);
await settle();
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) => {
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);
const markers = f.movers(IDS.markers);
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');
assert.equal(markers.rows[0].show, true);
assert.equal(markers.rows[1].show, false, 'B is not placed yet');
if (!f.cesium)
near(
geo.cartographic.fromCartesian(markers.rows[0].position).height,
120,
'A on the sampled surface',
1e-3,
);
near(
geo.cartographic.fromCartesian(markers.rows[0].position).height,
120,
'A on the sampled surface',
1e-3,
);
const letters = f.movers(IDS.letters);
assert.equal(letters.options.kind, 'sprite');
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) => {
const [makeFake, makeCesium] = GROUND_HARNESSES;
const [makeFake] = GROUND_HARNESSES;
const f = fixture(t, makeFake);
await f.route();
const next = makeCesium();
next.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
const next = makeFake();
const movers = recordMovers(next.engine);
t.after(() => next.destroy());
f.layer.suspendCamera();
@@ -1,17 +1,12 @@
// The FAA airspace zones on the engine contract, on the fake engine and on
// 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.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { droneAirspaceAccent } from '../cards.js';
@@ -140,51 +135,9 @@ for (const makeHarness of GROUND_HARNESSES) {
lines.forEach(({ outline }, index) =>
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();
assert.equal(f.set('drone-airspace').shown, false);
if (f.cesium) assert.equal(f.collectionShown('drone-airspace'), false);
} finally {
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
// 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.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { createEarthquakesLayer } from '../index.js';
import { createEarthquakesDisplay } from './display.js';
@@ -113,28 +109,7 @@ for (const makeHarness of GROUND_HARNESSES) {
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(
f.layer.getAnalystRecords().map((r) => r.id),
['us-a', 'us-b', 'QUAKE-0002'],
@@ -145,7 +120,6 @@ for (const makeHarness of GROUND_HARNESSES) {
f.layer.disable();
assert.deepEqual(f.layer.getAnalystRecords(), []);
assert.equal(f.set('earthquakes').shown, false);
if (f.cesium) assert.equal(f.collectionShown('earthquakes'), false);
} finally {
f.layer.destroy();
}
@@ -169,7 +143,7 @@ for (const makeHarness of GROUND_HARNESSES) {
f.setSnapshot([]);
assert.equal(await f.layer.update(), true);
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);
} finally {
f.layer.destroy();
@@ -182,21 +156,14 @@ for (const makeHarness of GROUND_HARNESSES) {
try {
await f.layer.update();
let picked;
if (f.cesium) {
const [fills] = f.primitives('earthquakes').fills;
f.viewer.scene.pick = () => ({
id: fills.options.geometryInstances[1].id,
primitive: fills,
});
picked = f.engine.picker.pick({ x: 1, y: 1 });
} 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 });
}
// 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.owner, 'earthquakes');
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
// 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).
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.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 { constantRgba } from '../../shared/color.js';
import { createFirmsDisplay } from './display.js';
@@ -94,76 +91,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
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 HARNESSES = [fakeHarness];
const display = (h, options = {}) =>
createFirmsDisplay(h.engine, {
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`, () => {
const restore = stubDocument();
const h = make();
// Cesium drapes it on the globe's own imagery stack.
const stack = new Cesium.ImageryLayerCollection();
if (h.viewer) {
h.viewer.imageryLayers = stack;
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();
});
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 () => {
const { createCesiumMoversEngine } =
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
// Cesium engine over a stub scene: KML polygons, lines and placemarks keep
// 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 { createFakeGroundHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.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 { createGhostMapsDisplay } from './display.js';
@@ -41,15 +38,10 @@ function recordGround(engine) {
}
function fakeHarness() {
const engine = createFakeEngine();
const harness = createFakeGroundHarness();
const { engine } = harness;
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
click(picked) {
engine.fake.script('pick', picked);
engine.fake.input('click', { position: { x: 100, y: 100 } });
},
...harness,
pin(id, index) {
const { store, options } = engine.fake.moverSet(id);
return {
@@ -63,83 +55,7 @@ function fakeHarness() {
};
}
// Cesium's ground primitives as built, without a WebGL context: the
// 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];
const HARNESSES = [fakeHarness];
// Seams for Node: decoded icons by URL and a canvas that records draws.
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');
const set = harness.engine.movers.list().find(({ id }) => id === pins);
assert.equal(set.kind, 'sprite');
if (harness.cesium) {
// The per-instance colours and widths Cesium batches.
const [fills, outlines] = harness.groundPrimitives(polygons().options.id);
assert.deepEqual(
[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);
}
const { options } = harness.engine.fake.moverSet(pins);
assert.equal(options.depthTest, 'always');
assert.equal(options.pickable, true);
display.destroy();
});
+4 -55
View File
@@ -1,18 +1,12 @@
// The GNSS integrity cells on the engine contract, on the fake engine and
// 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.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createCesiumGroundHarness,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { GNSS_LEVEL_COLORS } from '../records.js';
@@ -71,39 +65,12 @@ for (const makeHarness of GROUND_HARNESSES) {
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);
assert.equal(h.set('gnss-integrity').shown, false);
if (h.cesium) assert.equal(h.collectionShown('gnss-integrity'), false);
display.replace([], ALPHA);
assert.equal(h.set('gnss-integrity').items.length, 0);
if (h.cesium)
assert.equal(h.primitives('gnss-integrity').fills.length, 0);
} finally {
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);
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 geo from '../../../geo/index.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 {
INFRA_LOD_ACTIVE_MAX,
@@ -250,114 +249,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
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];
const HARNESSES = [fakeHarness];
/** Freezes performance.now so the 450 ms walk gate is under test control. */
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';
// Mapped Installations on the globe contract (engine.movers, engine.ground,
// 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 * as geo from '../../../geo/index.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
@@ -250,13 +253,16 @@ export function createInstallationsDisplay(
});
}
normal.setCount(count);
selected.setCount(chosen ? 1 : 0);
if (chosen)
selected.set(0, {
position: chosen.position,
color: SELECTED_MARKER_COLOR,
pickId: chosen.record.id,
});
writeSelectedMarker(
selected,
chosen
? {
position: chosen.position,
color: SELECTED_MARKER_COLOR,
pickId: chosen.record.id,
}
: null,
);
normal.commit();
selected.commit();
}
@@ -413,13 +419,7 @@ export function createInstallationsDisplay(
if (!initialized) return;
// Context navigation can select another layer without a canvas click.
// A delayed floor/data repaint must not steal that newer selection back.
const selectedContext = getSelectedEntityContext();
if (
!claimSelection &&
state.selectedId &&
selectedContext?.id !== state.selectedId
)
state.selectedId = null;
selection.reconcile({ claimSelection });
let migratedSelection = false;
if (state.selectedId && !state.recordById.has(state.selectedId)) {
const replacement = state.records.find((record) =>
@@ -529,22 +529,17 @@ export function createInstallationsDisplay(
return state.selectedId === id;
}
function onClick({ picked }) {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.enabled) return;
const id = typeof picked?.id === 'string' ? picked.id : null;
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.
const selection = createSelectionKeeper({
state,
getSelectedEntityContext,
selectRecord,
clearSelection() {
state.selectedId = null;
clearSelectedEntityContextForLayer(LAYER_ID);
renderRecords();
}
}
},
});
const onClick = selection.onClick;
function installInteraction() {
if (removeClick) return;
@@ -5,7 +5,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import * as context from '../../../data/contextStore.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');
closeTo(disc.color, classColor(airfield(), 1));
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);
assert.equal(options.kind, 'point');
assert.equal(options.point.sizePx, 9);
@@ -134,7 +120,6 @@ for (const makeHarness of GROUND_HARNESSES) {
const [chosen] = h.markers(SELECTED_MARKER_SET_ID);
assert.equal(chosen.pickId, 'osm:way: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) => {
@@ -180,20 +165,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(line.widthPx, 2);
assert.equal(line.pickId, 'osm:way:1');
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);
for (let i = 0; i < 5; i++) h.parts.display.renderRecords();
assert.deepEqual(
@@ -211,14 +183,7 @@ for (const makeHarness of GROUND_HARNESSES) {
h.parts.display.renderRecords();
const counts = () =>
[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]);
});
@@ -245,13 +210,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(fill.items[0].pickId, named.id);
assert.equal(fill.items[0].outline, undefined);
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;
for (let i = 0; i < 10; i++) h.parts.display.renderRecords();
assert.equal(
@@ -1,28 +1,10 @@
// Mapped Installations' display under test, on a ground harness of
// 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.
// Installation state and services over the fake ground display harness.
import * as contextStore from '../../../data/contextStore.js';
import { createModel } from '../model.js';
import { createState } from '../state.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 {{ context?: object, overlayHost?: object,
@@ -123,23 +105,7 @@ export function createDisplayHarness(
markers(id) {
const count =
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);
return Array.from({ length: count }, (_, 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() {
methods.destroy();
h.destroy();
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as context from '../../data/contextStore.js';
import { SELECTION_FILL_SET_ID } from './render/display.js';
import { createDisplayHarness } from './render/displayHarness.mjs';
@@ -65,11 +65,7 @@ for (const makeHarness of GROUND_HARNESSES) {
'the first footprint keeps its hole',
);
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();
assert.equal(fill().commits, commits, 'repaints retain ground geometry');
h.state.selectedId = null;
@@ -86,11 +82,7 @@ for (const makeHarness of GROUND_HARNESSES) {
h.changeMapStack();
assert.equal(fill().options.drape, 'both');
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();
assert.equal(fill().items.length, 0);
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
// 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
@@ -8,11 +9,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.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 { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.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 reticle = { width: 48, height: 48, getContext: () => null };
function 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];
const HARNESSES = [fakeHarness];
function setup(makeHarness) {
const harness = makeHarness();
@@ -109,29 +74,18 @@ for (const makeHarness of HARNESSES) {
assert.equal(five.kind, 'point');
assert.equal(five.count, 1);
assert.equal(four.count, 1);
if (h.cesium) {
const point = h.primitive(five.id).get(0);
assert.equal(point.pixelSize, 5);
assert.equal(point.outlineWidth, 0);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(point.id, 'rocket-launch:a');
assert.equal(point.show, true);
assert.ok(Cesium.Cartesian3.equals(point.position, PAD));
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');
}
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;
await settle();
if (h.cesium) assert.equal(h.primitive(five.id).get(0).show, false);
else assert.equal(engine.fake.moverSet(five.id).store.shows[0], 0);
assert.equal(engine.fake.moverSet(five.id).store.shows[0], 0);
display.setVisible(false);
assert.deepEqual(
engine.movers.list().map(({ count }) => count),
@@ -155,23 +109,13 @@ for (const makeHarness of HARNESSES) {
await settle();
const [listed] = engine.movers.list();
assert.equal(listed.kind, 'sprite');
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
assert.equal(billboard.width, 24);
assert.equal(billboard.height, 24);
assert.equal(
billboard.disableDepthTestDistance,
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);
}
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();
h.destroy();
});
@@ -200,23 +144,21 @@ for (const makeHarness of HARNESSES) {
assert.equal(reads, 2);
const [listed] = engine.movers.list();
const drawn = () => {
if (h.cesium) return h.primitive(listed.id).get(0).position;
const { positions } = engine.fake.moverSet(listed.id).store;
return geo.cartesian3.create(positions[0], positions[1], positions[2]);
};
assert.ok(geo.cartesian3.equalsEpsilon(drawn(), at, 0, 1e-6));
display.setVisible(false);
if (!h.cesium) engine.fake.resetCalls();
engine.fake.resetCalls();
at = geo.cartesian3.fromDegrees(2, 0, 400_000);
h.frame();
h.frame();
assert.equal(reads, 2, 'hidden motion is not read');
if (!h.cesium)
assert.equal(
engine.fake.renderRequests,
0,
'hidden motion must not sustain rendering',
);
assert.equal(
engine.fake.renderRequests,
0,
'hidden motion must not sustain rendering',
);
display.destroy();
h.destroy();
});
@@ -235,33 +177,19 @@ for (const makeHarness of HARNESSES) {
dash: { lengthPx: 6, gapPx: 6 },
});
const [listed] = engine.lines.list();
if (h.cesium) {
const primitive = h.primitive(listed.id);
const { geometry, id } = primitive.geometryInstances;
assert.equal(geometry._width, 1.5);
assert.equal(geometry._arcType, Cesium.ArcType.NONE);
assert.equal(id, 'rocket-transfer:a');
const { material } = primitive.appearance;
assert.equal(material.type, 'PolylineDash');
// Cesium's dashLength 24 px with 0xf0f0: 6 px on, 6 px off.
assert.equal(material.uniforms.dashLength, 12);
assert.equal(material.uniforms.dashPattern, dashPattern(6, 6));
assert.equal(material.uniforms.dashPattern, 0xff00);
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);
}
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;
assert.equal(engine.lines.list()[0].shown, false);
transfer.show = true;
@@ -291,27 +219,16 @@ for (const makeHarness of HARNESSES) {
const [listed] = engine.lines.list();
modelMatrix[12] = 42;
ring.setModelMatrix(modelMatrix);
if (h.cesium) {
const primitive = h.primitive(listed.id);
const { material } = primitive.appearance;
assert.equal(material.type, 'GevLineTactical');
assert.equal(material.uniforms.groupCount, 4);
assert.equal(material.uniforms.dashCount, 100);
near(material.uniforms.color.alpha, 0.95, 'alpha');
assert.equal(primitive.geometryInstances.geometry._width, 3);
assert.equal(primitive.modelMatrix[12], 42);
// 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);
}
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();
assert.deepEqual(engine.lines.list(), []);
h.destroy();
@@ -422,19 +339,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(record.shown, false);
display.setVisible(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;
display.setVisible(false);
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
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.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';
const places = [
@@ -31,22 +27,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
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];
const HARNESSES = [fakeHarness];
function layerOn(engine) {
const overlay = new Map();
@@ -76,38 +57,17 @@ for (const makeHarness of HARNESSES) {
display.setVisible(true);
display.replace(places);
assert.equal(count(h.engine), 2);
if (h.cesium) {
const collection = h.primitive('osm-places');
assert.ok(collection instanceof Cesium.PointPrimitiveCollection);
const point = collection.get(0);
assert.equal(point.pixelSize, 9);
assert.equal(point.outlineWidth, 2);
assert.ok(
point.outlineColor.equalsEpsilon(
Cesium.Color.BLACK.withAlpha(0.8),
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);
}
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 {
display.destroy();
h.destroy();
@@ -119,12 +79,10 @@ for (const makeHarness of HARNESSES) {
const h = makeHarness();
const display = createOsmPlacesDisplay(h.engine);
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 {
h.surface(undefined);
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
// 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.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { containmentAccent } from '../cards.js';
@@ -137,51 +132,9 @@ for (const makeHarness of GROUND_HARNESSES) {
lines.forEach(({ outline }, index) =>
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();
assert.equal(f.set('fire-perimeters').shown, false);
if (f.cesium) assert.equal(f.collectionShown('fire-perimeters'), false);
} finally {
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
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.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 { rgbaFromCss } from '../../shared/color.js';
import { createRadioLayer } from '../index.js';
@@ -85,54 +79,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
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];
const HARNESSES = [fakeHarness];
function fixture(t, makeHarness) {
const previous = globalThis.Audio;
@@ -252,32 +199,7 @@ for (const makeHarness of HARNESSES) {
.forEach((value, 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');
assert.equal(options.point.sizePx, 13);
assert.equal(options.point.outlinePx, 1);
@@ -313,20 +235,7 @@ for (const makeHarness of HARNESSES) {
const bracket = f.instance('radio-bracket', 0);
near(bracket.pixelSize, 40, 'bracket size');
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');
assert.equal(options.point.sizePx, 14);
assert.equal(options.point.scaleByDistance, null);
@@ -1,12 +1,10 @@
// The Recent Imagery display on the fake engine and on the Cesium 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).
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { BOX } from '../testDoubles.mjs';
import { createRecentImageryDisplay } from './display.js';
@@ -42,62 +40,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
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]) {
for (const make of [fakeHarness]) {
const name = make().name;
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 Cesium engine (real Cesium point collections and polylines over a stub
// scene: they draw nothing but hold what the engine writes): the fleet's
@@ -8,10 +9,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { CYAN, WHITE, nearFarScale } from '../../shared/color.js';
import { createSatellitesDisplay } from './display.js';
@@ -34,28 +35,7 @@ const near = (actual, expected, label, tolerance = 1e-6) =>
`${label}: ${actual} vs ${expected}`,
);
function 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];
const HARNESSES = [fakeHarness];
function setup(makeHarness) {
const harness = makeHarness();
@@ -70,20 +50,6 @@ const setsOf = (engine) =>
/** What a set's instance draws: its size, outline, depth mode and colour. */
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 [r, g, b, a] = store.colors.slice(index * 4, index * 4 + 4);
const [nearM, nearValue, farM, farValue] =
@@ -139,9 +105,7 @@ for (const makeHarness of HARNESSES) {
// Reads are the display's own copies.
assert.notEqual(all[0].color, WHITE);
assert.equal(all[0].color.alpha, 1);
const commits = h.cesium
? null
: () => engine.fake.moverSet(listed.id).commits;
const commits = () => engine.fake.moverSet(listed.id).commits;
const before = commits?.();
const remove = display.onFrame(() => {
all[0].position = { x: 1, y: 2, z: 3 };
@@ -222,8 +186,7 @@ for (const makeHarness of HARNESSES) {
'yellow',
);
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(
setsOf(engine)
.filter(({ id }) => id !== tracked.id)
@@ -271,25 +234,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(following.label.position(), target);
assert.equal(display.follow.current(), handle);
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.
seen.length = 0;
const guards = [];
@@ -322,7 +267,7 @@ for (const makeHarness of HARNESSES) {
display.follow.start(handle);
display.removeTracked(handle);
assert.equal(engine.camera.following(), null);
if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined);
display.destroy();
h.destroy();
});
@@ -339,34 +284,18 @@ for (const makeHarness of HARNESSES) {
assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY);
const [before] = engine.lines.list();
assert.equal(before.points, 2);
if (h.cesium) {
const primitive = h.primitive(before.id);
assert.equal(primitive.allowPicking, false, 'rings are not picked');
assert.equal(
primitive.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
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');
}
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;
display.commitOrbit(orbit);
assert.equal(engine.lines.list()[0].id, before.id, 'the same line');
if (h.cesium) assert.equal(h.primitive(before.id).modelMatrix[12], 123);
else assert.equal(engine.fake.line(before.id).modelMatrix[12], 123);
assert.equal(engine.fake.line(before.id).modelMatrix[12], 123);
orbit.show = false;
assert.equal(orbit.show, 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
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -13,8 +6,7 @@ import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.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 { rgbaFromCss } from '../../shared/color.js';
import {
@@ -162,81 +154,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
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 HARNESSES = [fakeHarness];
const PATHS = `${CABLE_OVERLAY_SOURCE_ID}-paths`;
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.notEqual(set.items[0].pickId, set.items[2].pickId);
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) => {
@@ -378,11 +285,7 @@ for (const makeHarness of HARNESSES) {
listener({ detail: { activeId: 'photoreal', status: 'ready' } });
assert.equal(f.groundSet(PATHS).options.drape, 'tiles');
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' } });
assert.equal(f.groundSet(PATHS).options.drape, 'terrain');
listener({ detail: { activeId: 'some-future-stack' } });
@@ -440,19 +343,7 @@ for (const makeHarness of HARNESSES) {
const landingTip = f.point(LANDING_TIPS, 0);
assert.equal(landingTip.sizePx, 8);
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;
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' });
assert.equal(f.flights.length, 4);
f.display.hide();
if (!f.cesium) {
f.engine.fake.script('pick', {
id: 'cable-reference-0-alpha',
owner: PATHS,
});
f.engine.fake.input('click', { position: { x: 1, y: 1 } });
f.engine.fake.unscript('pick');
assert.equal(f.flights.length, 4, 'a hidden display takes no clicks');
}
f.engine.fake.script('pick', {
id: 'cable-reference-0-alpha',
owner: PATHS,
});
f.engine.fake.input('click', { position: { x: 1, y: 1 } });
f.engine.fake.unscript('pick');
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) => {
+5 -68
View File
@@ -1,9 +1,4 @@
// The traffic display on the engine contract, on the fake engine and on the
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
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);
if (!listed) return undefined;
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);
for (let i = 0; i < store.count; i++)
rows.push({
@@ -141,9 +111,7 @@ for (const makeHarness of GROUND_HARNESSES) {
[IDS.outline, IDS.fill],
);
for (const [index, id] of [IDS.outline, IDS.fill].entries()) {
const { options } = h.cesium
? { options: null }
: h.engine.fake.moverSet(id);
const { options } = h.engine.fake.moverSet(id);
assert.equal(
sets.find((set) => set.id === id).capacity,
MAX_DOTS,
@@ -198,13 +166,6 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(outlines.length, 1, 'only the outlined dot has an outline');
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);
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) => {
@@ -284,19 +245,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.deepEqual(jam.items[0].color, [1, 0, 0, 0.55]);
assert.equal(slow.items.length, 1);
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();
assert.equal(h.set(IDS.jam), 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,
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);
nearAll(slow.items[0].color, HEAT_SLOW_COLOR.rgba(), 'slow colour');
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
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.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 { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { transitIcon } from '../../../data/transitIcons.js';
@@ -65,91 +57,7 @@ function fakeHarness(options = {}) {
};
}
function cesiumHarness() {
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];
const HARNESSES = [fakeHarness];
function setup(
makeHarness,
@@ -325,13 +233,7 @@ for (const makeHarness of HARNESSES) {
rgbaFromCss('#5EF08A')
.rgba()
.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');
assert.equal(options.depthTest, 'always');
assert.equal(options.pickable, true);
@@ -344,9 +246,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(glyph.cell, transitIcon('bus'));
}
// A frame's writes are one commit of the set they touch.
const before = f.cesium
? null
: [f.commits('transit-1'), f.commits('transit-motion-1')];
const before = [f.commits('transit-1'), f.commits('transit-motion-1')];
let wrote = false;
const remove = f.display.onPreRender(() => {
if (wrote) return;
@@ -426,13 +326,6 @@ for (const makeHarness of HARNESSES) {
for (const course of [0, 90, 180, 270]) {
const rotation = f.display.rotation(position, course, 0);
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);
@@ -509,7 +402,7 @@ for (const makeHarness of HARNESSES) {
y: 50,
collection: 'animated',
});
if (f.cesium) assert.deepEqual(f.picked()[0], { x: 40, y: 50 });
vehicles.get('mbta:1').markerCollection = f.state._markers;
f.click(
{ x: 40, y: 50 },
@@ -623,13 +516,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(lines.length, 3);
const lanes = lines.map(({ id }) => ({ id, ...f.trailLine(id) }));
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;
assert.equal(bodyBacking.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.commits, 1, 'head geometry built once');
assert.equal(headLane.options.pickable, undefined, 'picks pass through');
if (!f.cesium) {
{
assert.equal(bodyBacking.widthPx, 5);
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();
});
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 () => {
const engine = createFakeEngine();
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
// Cesium engine (real Cesium billboards and polylines over a stub scene:
// 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 geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { createPickRegistry } from '../../../data/pickRegistry.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.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 SELECTED = vesselChevronIcon('#ffffff', true);
function 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];
const HARNESSES = [fakeHarness];
async function setup(makeHarness) {
const harness = makeHarness();
@@ -82,17 +62,6 @@ const spec = (record, overrides = {}) => ({
/** What a chevron draws. */
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);
return {
id: store.pickIds[index],
@@ -147,23 +116,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(chevron.id, '111', 'picks name the MMSI');
near(chevron.scale, 0.6, 'scale');
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);
assert.equal(options.depthTest, 'always');
assert.equal(options.sprite.sizePx, 32);
@@ -173,9 +126,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(chevron.cell, CARGO);
}
// Writes inside a frame listener commit at its end, once.
const commits = h.cesium
? null
: () => engine.fake.moverSet(listed.id).commits;
const commits = () => engine.fake.moverSet(listed.id).commits;
const before = commits?.();
const moved = geo.cartesian3.fromDegrees(-73.8, 40.6, 1);
const remove = display.view.on('preRender', () => {
@@ -193,7 +144,7 @@ for (const makeHarness of HARNESSES) {
near(chevron.rotation, 0.5, 'rotation');
near(chevron.scale, 0.72, 'scale');
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.
assert.equal(handle.image, SELECTED);
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.equal(line.points, 2, 'near-duplicates and gaps dropped');
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);
assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, line.id);
@@ -1,10 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as geo from '../../geo/index.js';
import {
createFakeGroundHarness,
createCesiumGroundHarness,
} from '../../testSupport/groundDisplayEngines.mjs';
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import {
createRendering,
@@ -454,7 +451,7 @@ test('a selected observation remains rendered outside the viewport and observati
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})`, () => {
const cell = { south: 47.6, west: -122.4, north: 47.8, east: -122.2 };
const groundHarness = make();
-1
View File
@@ -42,7 +42,6 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(diagnostics.recentRequests[0].reason, 'tick-4');
diagnostics.holds.push('external');
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) => {
+2 -120
View File
@@ -1,13 +1,5 @@
// The two engines the CCTV display tests run on: the fake engine and the
// 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';
// CCTV input tests use scripted picks and a canvas with style and window events.
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -80,114 +72,4 @@ export function fakeCctvHarness() {
};
}
/** Screen input for the Cesium engine's ScreenSpaceEventHandler seam. */
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];
export const CCTV_HARNESSES = [fakeCctvHarness];
+3 -106
View File
@@ -1,18 +1,6 @@
// Ground displays under test, on the fake engine and on the Cesium 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';
// Ground display tests record committed items and input ownership on the fake engine.
import { normalizeGroundSetOptions } from '../globe/ground.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 };
@@ -73,8 +61,8 @@ export function createFakeGroundHarness(options = {}) {
const watched = watch(engine);
return {
name: 'the fake engine',
cesium: false,
engine,
frame: () => engine.fake.frame(),
...watched,
click(target, position = CLICK_AT) {
let picked = target;
@@ -94,95 +82,4 @@ export function createFakeGroundHarness(options = {}) {
};
}
/**
* 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);
export const GROUND_HARNESSES = [createFakeGroundHarness];
+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 * as Cesium from 'cesium';
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. */
export function imageryDisplayTests() {
let make;
let engines;
return {
test(name, run) {
for (const factory of [createFakeEngine, createCesiumImageryEngine])
nodeTest(
`${name} (${factory === createFakeEngine ? 'fake' : 'cesium'})`,
async (t) => {
make = factory;
engines = [];
if (!globalThis.document) {
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, h) => ({
data: new Uint8ClampedArray(w * h * 4),
}),
putImageData() {},
}),
nodeTest(name, async (t) => {
engines = [];
if (!globalThis.document) {
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, h) => ({
data: new Uint8ClampedArray(w * h * 4),
}),
};
t.after(() => {
delete globalThis.document;
});
}
try {
await run(t);
} finally {
for (const engine of engines) engine.destroy();
}
},
);
putImageData() {},
}),
}),
};
t.after(() => {
delete globalThis.document;
});
}
try {
await run(t);
} finally {
for (const engine of engines) engine.destroy();
}
});
},
createEngine(options) {
const engine = make(options);
const engine = createFakeEngine(options);
engines.push(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([
() => ({ 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. */
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
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 message = (type, text, url) => ({
type: () => type,
text: () => text,
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 = {},
main = new EventEmitter(),
shared = new EventEmitter();
watchW1Page(main, report, { name: 'main', baseUrl });
watchW1Page(shared, report, { name: 'share', baseUrl });
watchBrowserPage(main, report, { name: 'main', baseUrl });
watchBrowserPage(shared, report, { name: 'share', baseUrl });
main.emit('console', message('warning', 'ordinary warning', baseUrl));
shared.emit(
'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.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 = {},
shared = new EventEmitter();
watchW1Page(shared, report, { name: 'share', baseUrl, preview: true });
watchBrowserPage(shared, report, { name: 'share', baseUrl, preview: true });
const failure = (url) =>
shared.emit(
'console',
@@ -45,10 +45,10 @@ test('W1 preview exception stays exact on a share page', () => {
assert.deepEqual(report.expectedPreviewErrors, ['/api/setup/status: 404']);
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 = {},
shared = new EventEmitter();
watchW1Page(shared, report, { name: 'share', baseUrl });
watchBrowserPage(shared, report, { name: 'share', baseUrl });
shared.emit('response', {
status: () => 503,
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