From b4556f9763d37d1bc9efe2d25b4204b6ab3130ab Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sun, 8 Nov 2026 17:06:10 +0400 Subject: [PATCH] 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. --- docs/CURRENT-STATE.md | 4 +- docs/GLOBE-PRIMITIVES.md | 168 ++---------- scripts/engine-coupling-baseline.json | 6 +- scripts/fixtures/{w2 => layers}/alpr.mjs | 2 +- scripts/fixtures/{w2 => layers}/cctv.mjs | 14 +- scripts/fixtures/{w2 => layers}/combined.mjs | 4 +- scripts/fixtures/{w2 => layers}/firms.mjs | 0 scripts/fixtures/{w2 => layers}/radio.mjs | 2 +- scripts/fixtures/{w2 => layers}/transit.mjs | 2 +- scripts/format-scope.json | 17 +- scripts/package-boundaries.json | 56 ++-- ...heading-b3.mjs => qa-aircraft-heading.mjs} | 10 +- ...sprites-b5.mjs => qa-aircraft-sprites.mjs} | 56 ++-- scripts/qa-all-layer-switches.mjs | 2 +- scripts/qa-alpr-journey.mjs | 2 +- scripts/qa-browserEvidence.mjs | 6 +- scripts/qa-cctv-engine-switch.mjs | 11 +- scripts/qa-cctv-v2.mjs | 6 +- scripts/qa-contract-suite.mjs | 58 +++++ scripts/qa-enrich-ambient.mjs | 6 +- scripts/qa-fire-camera-switch.mjs | 4 +- scripts/qa-gbfs-names.mjs | 4 +- scripts/qa-ground-suite.mjs | 204 --------------- ...w2-imagery.mjs => qa-imagery-contract.mjs} | 2 +- scripts/qa-l9-matrix.mjs | 18 +- scripts/qa-radio-engine-switch.mjs | 2 +- scripts/qa-radio.mjs | 2 +- scripts/qa-strict-soak.mjs | 2 +- scripts/qa-urban-engine-switch.mjs | 4 +- src/app/layers/cctv.js | 18 +- src/data/cctv.js | 2 - src/data/cctv.test.mjs | 61 +++-- src/data/inputOwnership.test.mjs | 11 +- src/data/selectionDispatcher.js | 75 +----- src/data/trackingClickGesture.js | 85 ------ src/data/trackingClickGesture.test.mjs | 14 +- src/data/trackingGesture.js | 69 +++++ src/globe/cesium/createCesiumEngine.test.mjs | 75 ------ src/globe/cesium/ground.test.mjs | 31 +++ src/globe/navara/ground/corridors.js | 3 + src/globe/navara/ground/effect.js | 61 ----- src/globe/navara/ground/index.js | 14 +- src/globe/navara/ground/pass.js | 32 +-- src/globe/navara/ground/pulse.js | 97 ++++--- src/globe/navara/ground/pulse.test.mjs | 24 +- src/globe/navara/ground/surface.js | 7 +- src/globe/navara/imagery/pass.js | 31 +-- src/globe/navara/index.js | 12 +- src/globe/navara/overlayDepth.js | 4 +- src/globe/navara/sceneDepth.js | 4 +- src/globe/navara/sceneDepth.test.mjs | 5 +- src/globe/navara/shaderChunks.js | 24 ++ src/globe/testing/contractSuite.mjs | 213 +++++++--------- src/globe/testing/fakeEngine.test.mjs | 159 ------------ src/layers/airQuality/render/display.test.mjs | 30 +-- src/layers/aircraft/index.js | 2 +- src/layers/aircraft/render/display.test.mjs | 241 ++---------------- src/layers/alpr/render/display.js | 43 ++-- src/layers/alpr/render/display.test.mjs | 117 +-------- src/layers/awareness/render/display.test.mjs | 82 ++---- src/layers/bhoteKoshi/render/story.test.mjs | 22 +- src/layers/bikeshare/render/display.js | 22 +- src/layers/bikeshare/render/display.test.mjs | 158 +++--------- src/layers/cctv/render/display.js | 28 +- src/layers/cctv/render/display.test.mjs | 128 +--------- src/layers/cctv/render/displayGizmo.test.mjs | 44 +--- src/layers/cctv/render/meshes.test.mjs | 22 +- src/layers/cctv/worldClick.js | 19 -- src/layers/cyclones/render/display.test.mjs | 67 +---- src/layers/directions/edgeCases.test.mjs | 6 +- src/layers/directions/render/display.test.mjs | 65 +---- .../droneAirspace/render/display.test.mjs | 53 +--- .../earthquakes/render/display.test.mjs | 57 +---- src/layers/firms/render/display.test.mjs | 82 +----- src/layers/flights/renderer.test.mjs | 79 ------ src/layers/ghostMaps/render/display.test.mjs | 127 +-------- src/layers/gnss/render/display.test.mjs | 59 +---- .../infrastructure/render/display.test.mjs | 112 +------- src/layers/installations/render/display.js | 51 ++-- .../installations/render/display.test.mjs | 51 +--- .../installations/render/displayHarness.mjs | 40 +-- .../installations/selectionFill.test.mjs | 14 +- src/layers/launches/render/display.test.mjs | 197 ++++---------- src/layers/osmPlaces/render/display.test.mjs | 82 ++---- src/layers/perimeters/render/display.test.mjs | 53 +--- src/layers/radio/render/display.test.mjs | 103 +------- .../recentImagery/render/display.test.mjs | 67 +---- src/layers/satellites/render/display.test.mjs | 105 ++------ src/layers/shared/selectionKeeper.js | 47 ++++ .../submarineCables/render/display.test.mjs | 136 +--------- src/layers/traffic/render/display.test.mjs | 73 +----- src/layers/transit/render/display.test.mjs | 180 +------------ src/layers/vessels/render/display.test.mjs | 80 +----- .../wildlifeSightings/rendering.test.mjs | 7 +- src/renderGovernor.test.mjs | 1 - src/testSupport/cctvEngines.mjs | 122 +-------- src/testSupport/groundDisplayEngines.mjs | 109 +------- src/testSupport/imageryDisplayEngines.mjs | 157 ++---------- src/testSupport/streetLevelFakes.mjs | 3 +- ...st.mjs => browserRuntimeEvidence.test.mjs} | 16 +- src/tooling/importDirections.test.mjs | 2 +- 101 files changed, 1095 insertions(+), 4071 deletions(-) rename scripts/fixtures/{w2 => layers}/alpr.mjs (98%) rename scripts/fixtures/{w2 => layers}/cctv.mjs (94%) rename scripts/fixtures/{w2 => layers}/combined.mjs (97%) rename scripts/fixtures/{w2 => layers}/firms.mjs (100%) rename scripts/fixtures/{w2 => layers}/radio.mjs (98%) rename scripts/fixtures/{w2 => layers}/transit.mjs (99%) rename scripts/{qa-heading-b3.mjs => qa-aircraft-heading.mjs} (99%) rename scripts/{qa-sprites-b5.mjs => qa-aircraft-sprites.mjs} (92%) delete mode 100644 scripts/qa-ground-suite.mjs rename scripts/{qa-w2-imagery.mjs => qa-imagery-contract.mjs} (99%) delete mode 100644 src/data/trackingClickGesture.js delete mode 100644 src/globe/navara/ground/effect.js create mode 100644 src/globe/navara/shaderChunks.js delete mode 100644 src/layers/cctv/worldClick.js create mode 100644 src/layers/shared/selectionKeeper.js rename src/tooling/{w1RuntimeEvidence.test.mjs => browserRuntimeEvidence.test.mjs} (74%) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 381baec58..b78a64b6f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 41e97d242..43fd06cc7 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -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 diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index f65eb7ba0..5d526fb84 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -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" } diff --git a/scripts/fixtures/w2/alpr.mjs b/scripts/fixtures/layers/alpr.mjs similarity index 98% rename from scripts/fixtures/w2/alpr.mjs rename to scripts/fixtures/layers/alpr.mjs index a40db1412..0fe91d788 100644 --- a/scripts/fixtures/w2/alpr.mjs +++ b/scripts/fixtures/layers/alpr.mjs @@ -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) : '', diff --git a/scripts/fixtures/w2/cctv.mjs b/scripts/fixtures/layers/cctv.mjs similarity index 94% rename from scripts/fixtures/w2/cctv.mjs rename to scripts/fixtures/layers/cctv.mjs index 46cb857c5..e6f327854 100644 --- a/scripts/fixtures/w2/cctv.mjs +++ b/scripts/fixtures/layers/cctv.mjs @@ -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' }); } diff --git a/scripts/fixtures/w2/combined.mjs b/scripts/fixtures/layers/combined.mjs similarity index 97% rename from scripts/fixtures/w2/combined.mjs rename to scripts/fixtures/layers/combined.mjs index 00352804a..2b631e319 100644 --- a/scripts/fixtures/w2/combined.mjs +++ b/scripts/fixtures/layers/combined.mjs @@ -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), })); diff --git a/scripts/fixtures/w2/firms.mjs b/scripts/fixtures/layers/firms.mjs similarity index 100% rename from scripts/fixtures/w2/firms.mjs rename to scripts/fixtures/layers/firms.mjs diff --git a/scripts/fixtures/w2/radio.mjs b/scripts/fixtures/layers/radio.mjs similarity index 98% rename from scripts/fixtures/w2/radio.mjs rename to scripts/fixtures/layers/radio.mjs index 38b4c4eed..3a190e10b 100644 --- a/scripts/fixtures/w2/radio.mjs +++ b/scripts/fixtures/layers/radio.mjs @@ -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; diff --git a/scripts/fixtures/w2/transit.mjs b/scripts/fixtures/layers/transit.mjs similarity index 99% rename from scripts/fixtures/w2/transit.mjs rename to scripts/fixtures/layers/transit.mjs index 8f4768b61..412931398 100644 --- a/scripts/fixtures/w2/transit.mjs +++ b/scripts/fixtures/layers/transit.mjs @@ -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: diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d01a9d884..833a66213 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4b7f3acc0..40f59a2ea 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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" ], diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-aircraft-heading.mjs similarity index 99% rename from scripts/qa-heading-b3.mjs rename to scripts/qa-aircraft-heading.mjs index f5b171fd4..1e086d270 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-aircraft-heading.mjs @@ -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'); }); diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-aircraft-sprites.mjs similarity index 92% rename from scripts/qa-sprites-b5.mjs rename to scripts/qa-aircraft-sprites.mjs index 44faaa496..a1bd72d8b 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-aircraft-sprites.mjs @@ -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)'); } } diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs index 6b0784502..7bd36b237 100644 --- a/scripts/qa-all-layer-switches.mjs +++ b/scripts/qa-all-layer-switches.mjs @@ -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', diff --git a/scripts/qa-alpr-journey.mjs b/scripts/qa-alpr-journey.mjs index fe8d67844..bc1c58855 100644 --- a/scripts/qa-alpr-journey.mjs +++ b/scripts/qa-alpr-journey.mjs @@ -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, diff --git a/scripts/qa-browserEvidence.mjs b/scripts/qa-browserEvidence.mjs index 1219edd83..e2d9677b9 100644 --- a/scripts/qa-browserEvidence.mjs +++ b/scripts/qa-browserEvidence.mjs @@ -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 ??= []; diff --git a/scripts/qa-cctv-engine-switch.mjs b/scripts/qa-cctv-engine-switch.mjs index bacdb3194..0497b5e78 100644 --- a/scripts/qa-cctv-engine-switch.mjs +++ b/scripts/qa-cctv-engine-switch.mjs @@ -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( diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index b1e79d96c..aae57d47f 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -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'), diff --git a/scripts/qa-contract-suite.mjs b/scripts/qa-contract-suite.mjs index 12021b7f4..04ba768a3 100644 --- a/scripts/qa-contract-suite.mjs +++ b/scripts/qa-contract-suite.mjs @@ -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; diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs index 52caac3c5..59fc6a6c0 100644 --- a/scripts/qa-enrich-ambient.mjs +++ b/scripts/qa-enrich-ambient.mjs @@ -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 diff --git a/scripts/qa-fire-camera-switch.mjs b/scripts/qa-fire-camera-switch.mjs index bc1426f53..ba8f9379d 100644 --- a/scripts/qa-fire-camera-switch.mjs +++ b/scripts/qa-fire-camera-switch.mjs @@ -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'), diff --git a/scripts/qa-gbfs-names.mjs b/scripts/qa-gbfs-names.mjs index 0933ad83d..40aa9425b 100644 --- a/scripts/qa-gbfs-names.mjs +++ b/scripts/qa-gbfs-names.mjs @@ -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'), diff --git a/scripts/qa-ground-suite.mjs b/scripts/qa-ground-suite.mjs deleted file mode 100644 index 3d6397abc..000000000 --- a/scripts/qa-ground-suite.mjs +++ /dev/null @@ -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 [--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); diff --git a/scripts/qa-w2-imagery.mjs b/scripts/qa-imagery-contract.mjs similarity index 99% rename from scripts/qa-w2-imagery.mjs rename to scripts/qa-imagery-contract.mjs index e839da432..c0a4cd4dd 100644 --- a/scripts/qa-w2-imagery.mjs +++ b/scripts/qa-imagery-contract.mjs @@ -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: +// node scripts/qa-imagery-contract.mjs http://127.0.0.1: // [--layers=recent-imagery] [--evidence=FILE] import fs from 'node:fs/promises'; import os from 'node:os'; diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 8709366de..f5a55c826 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -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: [{ diff --git a/scripts/qa-radio-engine-switch.mjs b/scripts/qa-radio-engine-switch.mjs index 8f902bcf4..a64e8094e 100644 --- a/scripts/qa-radio-engine-switch.mjs +++ b/scripts/qa-radio-engine-switch.mjs @@ -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. diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 302961712..407847195 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.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)); diff --git a/scripts/qa-strict-soak.mjs b/scripts/qa-strict-soak.mjs index 75cab7706..747896d8e 100644 --- a/scripts/qa-strict-soak.mjs +++ b/scripts/qa-strict-soak.mjs @@ -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'; diff --git a/scripts/qa-urban-engine-switch.mjs b/scripts/qa-urban-engine-switch.mjs index f9aa944b1..c9d749d60 100644 --- a/scripts/qa-urban-engine-switch.mjs +++ b/scripts/qa-urban-engine-switch.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'; diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 584f7bfa2..ffb9909af 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -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; } diff --git a/src/data/cctv.js b/src/data/cctv.js index ad95f2794..81248f618 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -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 { diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index 2de406b0d..09de944d9 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -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', () => { diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 57313c133..4e15cbcf2 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -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', diff --git a/src/data/selectionDispatcher.js b/src/data/selectionDispatcher.js index beb1051f2..c9aac0a5f 100644 --- a/src/data/selectionDispatcher.js +++ b/src/data/selectionDispatcher.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); diff --git a/src/data/trackingClickGesture.js b/src/data/trackingClickGesture.js deleted file mode 100644 index df6008702..000000000 --- a/src/data/trackingClickGesture.js +++ /dev/null @@ -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); -} diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index b847749f6..fc53c8a49 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -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 } }); diff --git a/src/data/trackingGesture.js b/src/data/trackingGesture.js index aa5f16a96..e96217294 100644 --- a/src/data/trackingGesture.js +++ b/src/data/trackingGesture.js @@ -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()); +} diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index 8034b4b69..bb723f4f0 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -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. diff --git a/src/globe/cesium/ground.test.mjs b/src/globe/cesium/ground.test.mjs index 309962aa7..5210e9c01 100644 --- a/src/globe/cesium/ground.test.mjs +++ b/src/globe/cesium/ground.test.mjs @@ -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); +}); diff --git a/src/globe/navara/ground/corridors.js b/src/globe/navara/ground/corridors.js index 06a6eb6a4..d3b008898 100644 --- a/src/globe/navara/ground/corridors.js +++ b/src/globe/navara/ground/corridors.js @@ -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. */ diff --git a/src/globe/navara/ground/effect.js b/src/globe/navara/ground/effect.js deleted file mode 100644 index 0002ecd74..000000000 --- a/src/globe/navara/ground/effect.js +++ /dev/null @@ -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); - }, - }; -} diff --git a/src/globe/navara/ground/index.js b/src/globe/navara/ground/index.js index 58d4c5194..42a9392d3 100644 --- a/src/globe/navara/ground/index.js +++ b/src/globe/navara/ground/index.js @@ -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( diff --git a/src/globe/navara/ground/pass.js b/src/globe/navara/ground/pass.js index 980017f8a..3927e8ab6 100644 --- a/src/globe/navara/ground/pass.js +++ b/src/globe/navara/ground/pass.js @@ -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), diff --git a/src/globe/navara/ground/pulse.js b/src/globe/navara/ground/pulse.js index 206a02c7e..788eab907 100644 --- a/src/globe/navara/ground/pulse.js +++ b/src/globe/navara/ground/pulse.js @@ -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(), diff --git a/src/globe/navara/ground/pulse.test.mjs b/src/globe/navara/ground/pulse.test.mjs index 2a7b7caec..bee9e2bb0 100644 --- a/src/globe/navara/ground/pulse.test.mjs +++ b/src/globe/navara/ground/pulse.test.mjs @@ -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, diff --git a/src/globe/navara/ground/surface.js b/src/globe/navara/ground/surface.js index 57ec54ed0..5ef26eb43 100644 --- a/src/globe/navara/ground/surface.js +++ b/src/globe/navara/ground/surface.js @@ -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); } diff --git a/src/globe/navara/imagery/pass.js b/src/globe/navara/imagery/pass.js index 7fefca038..bb66d7e38 100644 --- a/src/globe/navara/imagery/pass.js +++ b/src/globe/navara/imagery/pass.js @@ -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]; diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 3730feafa..ffbdafcd5 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -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 }), diff --git a/src/globe/navara/overlayDepth.js b/src/globe/navara/overlayDepth.js index 1c06a5e93..c26a2f88f 100644 --- a/src/globe/navara/overlayDepth.js +++ b/src/globe/navara/overlayDepth.js @@ -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; } `; diff --git a/src/globe/navara/sceneDepth.js b/src/globe/navara/sceneDepth.js index adacf9c99..3f75bc031 100644 --- a/src/globe/navara/sceneDepth.js +++ b/src/globe/navara/sceneDepth.js @@ -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; } `; diff --git a/src/globe/navara/sceneDepth.test.mjs b/src/globe/navara/sceneDepth.test.mjs index 971adb861..c80ac369c 100644 --- a/src/globe/navara/sceneDepth.test.mjs +++ b/src/globe/navara/sceneDepth.test.mjs @@ -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/); diff --git a/src/globe/navara/shaderChunks.js b/src/globe/navara/shaderChunks.js new file mode 100644 index 000000000..13536acb7 --- /dev/null +++ b/src/globe/navara/shaderChunks.js @@ -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; +} +`; diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 75cf4d94e..2c2774a44 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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', ); }, diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 0b0d7f19b..c9fd7a959 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -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(); -}); diff --git a/src/layers/airQuality/render/display.test.mjs b/src/layers/airQuality/render/display.test.mjs index 93527e9f6..2ce9722c1 100644 --- a/src/layers/airQuality/render/display.test.mjs +++ b/src/layers/airQuality/render/display.test.mjs @@ -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 { diff --git a/src/layers/aircraft/index.js b/src/layers/aircraft/index.js index e5f048164..d7ee832ae 100644 --- a/src/layers/aircraft/index.js +++ b/src/layers/aircraft/index.js @@ -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'; diff --git a/src/layers/aircraft/render/display.test.mjs b/src/layers/aircraft/render/display.test.mjs index 8849b317c..d91470a7a 100644 --- a/src/layers/aircraft/render/display.test.mjs +++ b/src/layers/aircraft/render/display.test.mjs @@ -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(); -}); diff --git a/src/layers/alpr/render/display.js b/src/layers/alpr/render/display.js index 1ec743674..3964279bc 100644 --- a/src/layers/alpr/render/display.js +++ b/src/layers/alpr/render/display.js @@ -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. diff --git a/src/layers/alpr/render/display.test.mjs b/src/layers/alpr/render/display.test.mjs index c4886c4de..16ca1e8a4 100644 --- a/src/layers/alpr/render/display.test.mjs +++ b/src/layers/alpr/render/display.test.mjs @@ -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'); diff --git a/src/layers/awareness/render/display.test.mjs b/src/layers/awareness/render/display.test.mjs index 5880eb161..fd4f66e4c 100644 --- a/src/layers/awareness/render/display.test.mjs +++ b/src/layers/awareness/render/display.test.mjs @@ -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( diff --git a/src/layers/bhoteKoshi/render/story.test.mjs b/src/layers/bhoteKoshi/render/story.test.mjs index c357ead43..03001e450 100644 --- a/src/layers/bhoteKoshi/render/story.test.mjs +++ b/src/layers/bhoteKoshi/render/story.test.mjs @@ -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'); diff --git a/src/layers/bikeshare/render/display.js b/src/layers/bikeshare/render/display.js index 8ad67d24f..167c8e5fd 100644 --- a/src/layers/bikeshare/render/display.js +++ b/src/layers/bikeshare/render/display.js @@ -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 diff --git a/src/layers/bikeshare/render/display.test.mjs b/src/layers/bikeshare/render/display.test.mjs index ab257eae2..0f09391a9 100644 --- a/src/layers/bikeshare/render/display.test.mjs +++ b/src/layers/bikeshare/render/display.test.mjs @@ -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(); } diff --git a/src/layers/cctv/render/display.js b/src/layers/cctv/render/display.js index 070de946f..083462942 100644 --- a/src/layers/cctv/render/display.js +++ b/src/layers/cctv/render/display.js @@ -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(); diff --git a/src/layers/cctv/render/display.test.mjs b/src/layers/cctv/render/display.test.mjs index 35d443d6d..160abd685 100644 --- a/src/layers/cctv/render/display.test.mjs +++ b/src/layers/cctv/render/display.test.mjs @@ -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', - ); - } }); } diff --git a/src/layers/cctv/render/displayGizmo.test.mjs b/src/layers/cctv/render/displayGizmo.test.mjs index 8ca7a2300..789023687 100644 --- a/src/layers/cctv/render/displayGizmo.test.mjs +++ b/src/layers/cctv/render/displayGizmo.test.mjs @@ -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'); diff --git a/src/layers/cctv/render/meshes.test.mjs b/src/layers/cctv/render/meshes.test.mjs index 7e4716d00..c69d8fdf0 100644 --- a/src/layers/cctv/render/meshes.test.mjs +++ b/src/layers/cctv/render/meshes.test.mjs @@ -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/, diff --git a/src/layers/cctv/worldClick.js b/src/layers/cctv/worldClick.js deleted file mode 100644 index 092188ff2..000000000 --- a/src/layers/cctv/worldClick.js +++ /dev/null @@ -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, - ); -} diff --git a/src/layers/cyclones/render/display.test.mjs b/src/layers/cyclones/render/display.test.mjs index dec0b0c7e..9f28c06d8 100644 --- a/src/layers/cyclones/render/display.test.mjs +++ b/src/layers/cyclones/render/display.test.mjs @@ -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, diff --git a/src/layers/directions/edgeCases.test.mjs b/src/layers/directions/edgeCases.test.mjs index 048b232b5..99f6e9239 100644 --- a/src/layers/directions/edgeCases.test.mjs +++ b/src/layers/directions/edgeCases.test.mjs @@ -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; }; diff --git a/src/layers/directions/render/display.test.mjs b/src/layers/directions/render/display.test.mjs index bf6c3217f..efd2aefda 100644 --- a/src/layers/directions/render/display.test.mjs +++ b/src/layers/directions/render/display.test.mjs @@ -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(); diff --git a/src/layers/droneAirspace/render/display.test.mjs b/src/layers/droneAirspace/render/display.test.mjs index 8e12b9dce..46413965b 100644 --- a/src/layers/droneAirspace/render/display.test.mjs +++ b/src/layers/droneAirspace/render/display.test.mjs @@ -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(); } diff --git a/src/layers/earthquakes/render/display.test.mjs b/src/layers/earthquakes/render/display.test.mjs index e03068a0b..450aeb1a0 100644 --- a/src/layers/earthquakes/render/display.test.mjs +++ b/src/layers/earthquakes/render/display.test.mjs @@ -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); diff --git a/src/layers/firms/render/display.test.mjs b/src/layers/firms/render/display.test.mjs index 6eaf96fbf..d586f9b89 100644 --- a/src/layers/firms/render/display.test.mjs +++ b/src/layers/firms/render/display.test.mjs @@ -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; diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs index 27d0385a1..6ee9b98d0 100644 --- a/src/layers/flights/renderer.test.mjs +++ b/src/layers/flights/renderer.test.mjs @@ -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'); diff --git a/src/layers/ghostMaps/render/display.test.mjs b/src/layers/ghostMaps/render/display.test.mjs index 29915b005..c3a06aea7 100644 --- a/src/layers/ghostMaps/render/display.test.mjs +++ b/src/layers/ghostMaps/render/display.test.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(); }); diff --git a/src/layers/gnss/render/display.test.mjs b/src/layers/gnss/render/display.test.mjs index 7772194b5..a8989b548 100644 --- a/src/layers/gnss/render/display.test.mjs +++ b/src/layers/gnss/render/display.test.mjs @@ -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(); -}); diff --git a/src/layers/infrastructure/render/display.test.mjs b/src/layers/infrastructure/render/display.test.mjs index 7a806105c..9c0cf2fc8 100644 --- a/src/layers/infrastructure/render/display.test.mjs +++ b/src/layers/infrastructure/render/display.test.mjs @@ -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) { diff --git a/src/layers/installations/render/display.js b/src/layers/installations/render/display.js index ce2750008..1e8b61a4b 100644 --- a/src/layers/installations/render/display.js +++ b/src/layers/installations/render/display.js @@ -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; diff --git a/src/layers/installations/render/display.test.mjs b/src/layers/installations/render/display.test.mjs index 5d90f0af6..a608bf06e 100644 --- a/src/layers/installations/render/display.test.mjs +++ b/src/layers/installations/render/display.test.mjs @@ -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( diff --git a/src/layers/installations/render/displayHarness.mjs b/src/layers/installations/render/displayHarness.mjs index c13c396bb..dd571ba2c 100644 --- a/src/layers/installations/render/displayHarness.mjs +++ b/src/layers/installations/render/displayHarness.mjs @@ -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(); diff --git a/src/layers/installations/selectionFill.test.mjs b/src/layers/installations/selectionFill.test.mjs index a5e417d32..2c51a217d 100644 --- a/src/layers/installations/selectionFill.test.mjs +++ b/src/layers/installations/selectionFill.test.mjs @@ -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); diff --git a/src/layers/launches/render/display.test.mjs b/src/layers/launches/render/display.test.mjs index 3ce5394ef..b278fed16 100644 --- a/src/layers/launches/render/display.test.mjs +++ b/src/layers/launches/render/display.test.mjs @@ -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); diff --git a/src/layers/osmPlaces/render/display.test.mjs b/src/layers/osmPlaces/render/display.test.mjs index fab50dd77..4759b8a91 100644 --- a/src/layers/osmPlaces/render/display.test.mjs +++ b/src/layers/osmPlaces/render/display.test.mjs @@ -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); diff --git a/src/layers/perimeters/render/display.test.mjs b/src/layers/perimeters/render/display.test.mjs index 9fab7f4a3..1ac295c7a 100644 --- a/src/layers/perimeters/render/display.test.mjs +++ b/src/layers/perimeters/render/display.test.mjs @@ -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(); } diff --git a/src/layers/radio/render/display.test.mjs b/src/layers/radio/render/display.test.mjs index 5307e811a..33dd19a35 100644 --- a/src/layers/radio/render/display.test.mjs +++ b/src/layers/radio/render/display.test.mjs @@ -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); diff --git a/src/layers/recentImagery/render/display.test.mjs b/src/layers/recentImagery/render/display.test.mjs index 5e60aa3b1..d6a778fc9 100644 --- a/src/layers/recentImagery/render/display.test.mjs +++ b/src/layers/recentImagery/render/display.test.mjs @@ -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`, () => { diff --git a/src/layers/satellites/render/display.test.mjs b/src/layers/satellites/render/display.test.mjs index 942b6548e..176ec7acb 100644 --- a/src/layers/satellites/render/display.test.mjs +++ b/src/layers/satellites/render/display.test.mjs @@ -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); diff --git a/src/layers/shared/selectionKeeper.js b/src/layers/shared/selectionKeeper.js new file mode 100644 index 000000000..efeac5d3e --- /dev/null +++ b/src/layers/shared/selectionKeeper.js @@ -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); +} diff --git a/src/layers/submarineCables/render/display.test.mjs b/src/layers/submarineCables/render/display.test.mjs index 7b4d2cc3d..41246a545 100644 --- a/src/layers/submarineCables/render/display.test.mjs +++ b/src/layers/submarineCables/render/display.test.mjs @@ -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) => { diff --git a/src/layers/traffic/render/display.test.mjs b/src/layers/traffic/render/display.test.mjs index 9b9cd8f55..f8093f80b 100644 --- a/src/layers/traffic/render/display.test.mjs +++ b/src/layers/traffic/render/display.test.mjs @@ -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); diff --git a/src/layers/transit/render/display.test.mjs b/src/layers/transit/render/display.test.mjs index 8330bea0e..522c635b0 100644 --- a/src/layers/transit/render/display.test.mjs +++ b/src/layers/transit/render/display.test.mjs @@ -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, { diff --git a/src/layers/vessels/render/display.test.mjs b/src/layers/vessels/render/display.test.mjs index 4bed56858..c73c1fba6 100644 --- a/src/layers/vessels/render/display.test.mjs +++ b/src/layers/vessels/render/display.test.mjs @@ -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); diff --git a/src/layers/wildlifeSightings/rendering.test.mjs b/src/layers/wildlifeSightings/rendering.test.mjs index 232a8f89e..41bd2debc 100644 --- a/src/layers/wildlifeSightings/rendering.test.mjs +++ b/src/layers/wildlifeSightings/rendering.test.mjs @@ -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(); diff --git a/src/renderGovernor.test.mjs b/src/renderGovernor.test.mjs index 89dab79bf..d380a79af 100644 --- a/src/renderGovernor.test.mjs +++ b/src/renderGovernor.test.mjs @@ -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) => { diff --git a/src/testSupport/cctvEngines.mjs b/src/testSupport/cctvEngines.mjs index f243ddbdb..d540631fc 100644 --- a/src/testSupport/cctvEngines.mjs +++ b/src/testSupport/cctvEngines.mjs @@ -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]; diff --git a/src/testSupport/groundDisplayEngines.mjs b/src/testSupport/groundDisplayEngines.mjs index b1774b2c2..6ea421dec 100644 --- a/src/testSupport/groundDisplayEngines.mjs +++ b/src/testSupport/groundDisplayEngines.mjs @@ -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]; diff --git a/src/testSupport/imageryDisplayEngines.mjs b/src/testSupport/imageryDisplayEngines.mjs index 16623e748..62a6fa18d 100644 --- a/src/testSupport/imageryDisplayEngines.mjs +++ b/src/testSupport/imageryDisplayEngines.mjs @@ -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; }, diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index 0665d08b9..8c5cc9783 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -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. */ diff --git a/src/tooling/w1RuntimeEvidence.test.mjs b/src/tooling/browserRuntimeEvidence.test.mjs similarity index 74% rename from src/tooling/w1RuntimeEvidence.test.mjs rename to src/tooling/browserRuntimeEvidence.test.mjs index d5f21186f..0388ce818 100644 --- a/src/tooling/w1RuntimeEvidence.test.mjs +++ b/src/tooling/browserRuntimeEvidence.test.mjs @@ -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', diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index c5867101d..b77fdcae5 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -109,7 +109,7 @@ for (const [name, files, pattern] of [ ], [ 'W1 shared trackingClickGesture keeps SDK imports in the engine', - { 'src/data/trackingClickGesture.js': "import 'cesium';" }, + { 'src/data/trackingGesture.js': "import 'cesium';" }, /cesium imports belong in src\/globe\/cesium\//, ], [