mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
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:
1 parent
7f478e3815
commit
b4556f9763
101 files changed
+1095
-4071
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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",
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ??= [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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';
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 } });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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`, () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+8
-8
@@ -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
Reference in new issue
Block a user