From 7abf74518fa885ca7fc9d8c8c43336ce1e7cafc9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 13:32:39 +0400 Subject: [PATCH] Cut duplicated and dead code across the engines, the layers and the QA scripts - src/geo drops the ported debug argument checks and the tests that bitExact already covers; package boundaries accept a directory entry, so src/geo/ is listed once per group. - Cesium no longer carries movers and lines nobody draws with: they are optional engine capabilities. The fake engine and the Cesium camera use the shared cameraMath; unused contract tables, clock.ticked, the 'finest' surface detail and Navara click statistics are gone. - ALPR keeps one overlay core and one presentation model for both engines, bikeshare one station bookkeeping, the Navara ground effects one definition helper; image atlases, view centres, mover banks and colours are shared. - The legacy switch route, the clock offset and rate controls and other unused exports are removed. - The acceptance runs the Navara draw, geo import and scope checks; scenarios use the harness switch and page helpers; the main chunk check keeps the size budget only and runs once in CI. --- .github/workflows/ci.yml | 9 +- THIRD_PARTY_NOTICES.md | 7 +- build/initialChunkGuard.js | 3 +- docs/CODE-BOUNDARIES.md | 3 +- package.json | 14 +- scripts/check-import-directions.mjs | 6 +- scripts/check-main-chunk.mjs | 114 +- scripts/check-package-boundaries.mjs | 14 +- scripts/format-scope.json | 14 +- scripts/package-boundaries.json | 581 +---------- scripts/qa-all-layer-switches.mjs | 98 +- scripts/qa-app-once.mjs | 19 +- scripts/qa-browserEvidence.mjs | 2 +- scripts/qa-cctv-engine-switch.mjs | 2 +- scripts/qa-cctv-v2.mjs | 4 +- scripts/qa-contact-switch.mjs | 36 +- scripts/qa-directions-engine-switch.mjs | 39 +- scripts/qa-director-engine-switch.mjs | 2 +- scripts/qa-engine-switch.mjs | 904 ++++++++-------- scripts/qa-enrich-ambient.mjs | 2 +- scripts/qa-fire-camera-switch.mjs | 2 +- scripts/qa-floor-verify.mjs | 4 +- scripts/qa-ghostmaps-switch.mjs | 36 +- scripts/qa-ground-layer-switch.mjs | 42 +- scripts/qa-infrastructure-engine-switch.mjs | 36 +- scripts/qa-labels.mjs | 3 +- scripts/qa-layer-handoff.mjs | 975 +++++++++--------- scripts/qa-local-adsb-engine-switch.mjs | 36 +- scripts/qa-navara-annotations.mjs | 27 +- scripts/qa-navara-bloom.mjs | 283 +++-- scripts/qa-navara-cockpit-clouds.mjs | 279 +++-- scripts/qa-navara-draw.mjs | 2 - scripts/qa-navara-geo-import.mjs | 2 - scripts/qa-navara-scope.mjs | 27 +- scripts/qa-navara-sonar.mjs | 354 ++++--- scripts/qa-navara-visual-styles.mjs | 523 +++++----- scripts/qa-nepal-event-switch.mjs | 43 +- scripts/qa-nepal-locator-switch.mjs | 40 +- scripts/qa-open-layers-switch.mjs | 36 +- scripts/qa-radio-engine-switch.mjs | 157 ++- scripts/qa-space-engine-switch.mjs | 2 +- scripts/qa-sprites-b5.mjs | 2 +- scripts/qa-street-level-engine-switch.mjs | 326 +++--- scripts/qa-strict-cold-chunks.mjs | 2 +- scripts/qa-strict-context-loss.mjs | 2 +- scripts/qa-strict-failure-matrix.mjs | 2 +- scripts/qa-strict-harness.mjs | 144 ++- scripts/qa-strict-hidden-switch.mjs | 2 +- scripts/qa-strict-prod-smoke.mjs | 2 +- scripts/qa-strict-rapid-switch.mjs | 2 +- scripts/qa-strict-soak.mjs | 4 +- scripts/qa-submarine-cables-engine-switch.mjs | 40 +- scripts/qa-traffic-engine-switch.mjs | 40 +- scripts/qa-urban-engine-switch.mjs | 2 +- scripts/qa-vessel-cards.mjs | 2 +- scripts/qa-vessel-datum.mjs | 2 +- scripts/qa-w2-imagery.mjs | 99 +- scripts/qa-weather-engine-switch.mjs | 38 +- scripts/qa-wind-engine-switch.mjs | 36 +- scripts/qa/appSessionHarness.mjs | 38 +- scripts/qa/tileCache.mjs | 95 +- src/annotations/drawInputNavara.js | 2 +- src/app/catalog.js | 11 - src/app/catalog.test.mjs | 40 +- src/app/layers/renderers.js | 18 +- src/app/qaProbe.js | 4 +- src/app/scene.js | 10 +- src/app/sceneHost.js | 9 - src/app/tools.js | 2 +- src/app/zoomOutGuard.js | 2 +- src/bench/launchFixture.js | 2 +- src/bench/satelliteFixture.js | 4 +- src/cameraGroundGuard.test.mjs | 1 + src/cameraHandoff.test.mjs | 2 +- src/cameraVerbs.js | 14 - src/data/aircraftIcons.js | 4 +- src/data/cameraArrival.js | 4 +- src/data/cockpitModels.test.mjs | 2 +- src/data/dataCredits.js | 4 +- src/data/inputOwnership.test.mjs | 2 +- src/data/meshFloorSampler.test.mjs | 2 +- src/data/render/cesiumConstants.js | 3 +- src/data/selectionDispatcher.js | 6 +- src/data/spriteOrder.test.mjs | 2 +- src/data/trackingClickGesture.test.mjs | 2 +- src/geo/bitExact.test.mjs | 16 +- src/geo/boundingSphere.js | 7 - src/geo/cartesian2.js | 12 +- src/geo/cartesian3.js | 147 +-- src/geo/cartesian3.test.mjs | 92 +- src/geo/cartographic.js | 20 +- src/geo/cartographic.test.mjs | 29 - src/geo/check.js | 61 -- src/geo/checks.test.mjs | 486 --------- src/geo/debug.js | 12 - src/geo/developerError.js | 13 - src/geo/ellipsoid.js | 23 +- src/geo/ellipsoid.test.mjs | 45 - src/geo/ellipsoidGeodesic.js | 48 - src/geo/ellipsoidGeodesic.test.mjs | 14 - src/geo/math.js | 55 +- src/geo/math.test.mjs | 1 - src/geo/matrix.test.mjs | 75 +- src/geo/matrix3.js | 28 +- src/geo/matrix4.js | 82 +- src/geo/quaternion.js | 15 +- src/geo/releaseParity.test.mjs | 62 +- src/geo/scaleToGeodeticSurface.js | 17 - src/geo/transforms.js | 20 - src/geo/transforms.test.mjs | 57 - src/globe/{navara => }/cameraMath.js | 37 +- src/globe/{navara => }/cameraMath.test.mjs | 6 +- src/globe/cesium/camera.js | 100 +- src/globe/cesium/clock.js | 13 +- src/globe/cesium/clock.test.mjs | 21 +- src/globe/cesium/convert.js | 2 +- src/globe/cesium/createCesiumEngine.js | 36 +- src/globe/cesium/createCesiumEngine.test.mjs | 48 +- src/globe/cesium/engineFromViewer.js | 4 +- src/globe/cesium/lines.js | 225 ---- src/globe/cesium/lines.test.mjs | 165 --- src/globe/cesium/movers.js | 365 ------- src/globe/cesium/movers.test.mjs | 248 ----- src/globe/cesium/picker.js | 35 +- src/globe/cesium/picker.test.mjs | 88 +- src/globe/cesium/projector.js | 4 +- src/globe/cesium/surface.js | 48 +- src/globe/cesium/surface.test.mjs | 27 +- src/globe/contract.js | 97 +- src/globe/contract.test.mjs | 129 +-- src/globe/cpuPicks.js | 4 +- src/globe/follow.js | 7 +- src/globe/lines.js | 2 +- src/globe/movers.js | 15 +- src/globe/movers.test.mjs | 5 +- src/globe/navara/bloom.js | 2 +- src/globe/navara/cameraModel.js | 2 +- src/globe/navara/cameraModel.test.mjs | 2 +- src/globe/navara/cascadedLightCopy.js | 2 +- src/globe/navara/clock.js | 5 +- src/globe/navara/clock.test.mjs | 7 +- src/globe/navara/createNavaraEngine.js | 36 +- src/globe/navara/createNavaraEngine.test.mjs | 6 +- src/globe/navara/index.js | 28 +- src/globe/navara/input.js | 2 +- src/globe/navara/internals.js | 16 +- src/globe/navara/look.js | 2 +- src/globe/navara/maps.js | 8 +- src/globe/navara/movers/index.js | 2 +- src/globe/navara/movers/modelMover.js | 11 +- src/globe/navara/movers/pack.js | 2 +- src/globe/navara/overlay.js | 5 +- src/globe/navara/overlayPass.test.mjs | 2 +- src/globe/navara/picking.js | 24 +- src/globe/navara/picking.test.mjs | 33 - src/globe/navara/sharpen.js | 17 +- src/globe/navara/stylePass.js | 2 +- src/globe/navara/surface.js | 48 +- src/globe/navara/surface.test.mjs | 5 +- src/globe/navara/surfaceRay.test.mjs | 1 - src/globe/navara/viewOptions.js | 20 +- src/globe/navara/viewOptions.test.mjs | 7 +- src/globe/surfaceDetail.js | 14 +- src/globe/surfaceDetail.test.mjs | 8 +- src/globe/testing/contractSuite.mjs | 80 +- src/globe/testing/fakeEngine.js | 470 ++------- src/globe/testing/fakeEngine.test.mjs | 115 --- src/hud.js | 13 +- src/layers/aircraft/render/cesium.js | 5 +- src/layers/aircraft/render/cesium.test.mjs | 4 +- src/layers/aircraft/render/contract.js | 70 -- src/layers/aircraft/render/navara.js | 162 +-- src/layers/aircraft/render/navara.test.mjs | 4 +- src/layers/alpr/overlay.js | 386 +++++++ src/layers/alpr/overlay.test.mjs | 4 +- src/layers/alpr/presentation.js | 113 ++ src/layers/alpr/render/cesiumOverlay.js | 437 +------- src/layers/alpr/render/cesiumPresentation.js | 101 +- src/layers/alpr/render/navara.js | 105 +- src/layers/alpr/render/navaraOverlay.js | 477 ++------- src/layers/awareness/render/navaraPulses.js | 146 ++- .../bikeshare/render/cesium/rendering.js | 187 +--- .../bikeshare/render/cesium/selection.js | 7 +- src/layers/bikeshare/render/navara.js | 155 +-- src/layers/bikeshare/selectionModel.js | 2 +- src/layers/bikeshare/stationPoints.js | 166 +++ src/layers/cctv/lifecycle.js | 12 +- src/layers/droneAirspace/index.js | 6 - src/layers/earthquakes/render/cesium.js | 4 +- src/layers/earthquakes/render/navara.js | 121 +-- src/layers/firms/render/navara.js | 2 - src/layers/firms/render/navaraHeat.js | 100 +- src/layers/firms/render/navaraSprites.js | 31 +- src/layers/flights/lifecycle.js | 2 +- src/layers/flights/render/navara.js | 25 +- src/layers/flights/renderer.test.mjs | 11 +- src/layers/flights/snapshotRenderer.js | 2 +- src/layers/flights/tracking.js | 8 +- src/layers/gnss/render/navara.js | 11 +- src/layers/installations/render/navara.js | 79 +- src/layers/launches/render/navara.js | 2 +- src/layers/launches/render/navaraPadGround.js | 138 ++- src/layers/localAdsb/render/cesium.js | 3 +- src/layers/localAdsb/render/navara.js | 10 +- src/layers/localAdsb/renderer.test.mjs | 2 +- src/layers/military/lifecycle.js | 2 +- src/layers/military/render/navara.js | 25 +- src/layers/military/renderer.test.mjs | 11 +- src/layers/military/snapshotRenderer.js | 2 +- src/layers/military/tracking.js | 10 +- src/layers/osmPlaces/render/navara.js | 2 - .../recentImagery/render/navaraGround.js | 146 ++- src/layers/recentImagery/render/tiles.js | 2 +- src/layers/satellites/policy.js | 4 +- src/layers/satellites/render/navara.js | 2 +- src/layers/shared/color.js | 4 +- src/layers/shared/render/imageAtlas.js | 67 ++ .../shared/render/navaraCellGeometry.js | 2 +- src/layers/shared/render/navaraGroundCells.js | 112 +- .../shared/render/navaraGroundEffect.js | 57 + .../shared/render/navaraGroundOrder.test.mjs | 76 +- src/layers/shared/render/navaraMoverBank.js | 127 +++ src/layers/shared/viewRectangle.js | 15 + src/layers/submarineCables/geometry.js | 2 +- src/layers/traffic/flow.js | 2 +- src/layers/traffic/lifecycle.js | 4 +- src/layers/transit/render/navaraSprites.js | 45 +- src/layers/transit/render/navaraTrail.js | 124 +-- src/layers/transit/viewport.js | 7 +- src/layers/vessels/edgeCases.test.mjs | 124 +-- src/layers/vessels/render/cesium.js | 7 +- src/layers/vessels/render/contract.js | 39 +- src/layers/vessels/render/navara.js | 87 +- src/layers/vessels/renderer.test.mjs | 114 +- src/layers/vessels/testFixtures.mjs | 137 +++ src/locations.js | 2 +- src/main.js | 17 +- .../worldOverlayAllocation.worker.mjs | 6 +- src/scenes/dataPacks/geometry.js | 9 +- src/scenes/director.js | 2 +- src/scopeMask.js | 38 +- src/scopeMask.test.mjs | 42 +- src/standalone/application.js | 3 +- src/standalone/engineSwitch.js | 18 +- src/testSupport/geminiIntegrationFixture.mjs | 2 + src/testSupport/readLayerSource.mjs | 2 +- src/time/clock.js | 63 +- src/time/clock.test.mjs | 62 +- src/tooling/mainChunk.js | 40 +- src/tooling/mainChunkComposition.test.mjs | 42 +- src/tooling/packageBoundaries.test.mjs | 36 + src/ui/applicationShell.js | 8 +- src/ui/cameraOrientationControls.js | 4 +- src/ui/cameraOrientationControls.test.mjs | 4 +- src/ui/displayPanel.test.mjs | 6 +- src/ui/geoFileImport.js | 14 +- src/ui/geoFileImport.test.mjs | 17 +- src/ui/navigationController.js | 2 +- src/ui/pendingVisualSettings.js | 2 +- src/ui/shareRestoration.js | 2 +- src/ui/visualEffects.js | 2 +- src/voice/geminiNativeRecovery.test.mjs | 2 + src/voice/gevActions.js | 2 +- src/voice/gevActions.test.mjs | 4 + src/voice/gevActionsEngine.test.mjs | 28 +- src/voice/gevRealtime.js | 13 +- src/voice/gevRealtime.test.mjs | 24 +- src/voice/hudPlaceLabels.test.mjs | 2 +- src/voice/pointerContext.js | 15 +- src/voice/pointerCrop.test.mjs | 38 +- src/voice/providerCommands.test.mjs | 1 + src/voice/realtimeViewport.js | 30 +- 272 files changed, 4828 insertions(+), 10213 deletions(-) delete mode 100644 src/geo/check.js delete mode 100644 src/geo/checks.test.mjs delete mode 100644 src/geo/debug.js delete mode 100644 src/geo/developerError.js rename src/globe/{navara => }/cameraMath.js (93%) rename src/globe/{navara => }/cameraMath.test.mjs (98%) delete mode 100644 src/globe/cesium/lines.js delete mode 100644 src/globe/cesium/lines.test.mjs delete mode 100644 src/globe/cesium/movers.js delete mode 100644 src/globe/cesium/movers.test.mjs create mode 100644 src/layers/alpr/overlay.js create mode 100644 src/layers/alpr/presentation.js create mode 100644 src/layers/bikeshare/stationPoints.js create mode 100644 src/layers/shared/render/imageAtlas.js create mode 100644 src/layers/shared/render/navaraGroundEffect.js create mode 100644 src/layers/shared/render/navaraMoverBank.js create mode 100644 src/layers/shared/viewRectangle.js create mode 100644 src/layers/vessels/testFixtures.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0116ff9a9..a36818b3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,8 @@ jobs: - name: Build production bundle run: npm run build - - name: Main chunk (no three or Navara in Cesium mode; size budget when recorded) + - name: Main chunk size budget + if: matrix.node == '24.22.2' run: npm run check:main-chunk street-level-browser: @@ -90,9 +91,6 @@ jobs: MAPILLARY_CLIENT_TOKEN: 'MLY|0|qa-fixture' run: npm run build - - name: Main chunk (no three or Navara in Cesium mode; size budget when recorded) - run: npm run check:main-chunk - - name: Start the preview server env: MAPILLARY_CLIENT_TOKEN: 'MLY|0|qa-fixture' @@ -154,6 +152,3 @@ jobs: - name: Build production bundle run: npm run build - - - name: Main chunk (no three or Navara in Cesium mode; size budget when recorded) - run: npm run check:main-chunk diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 13246b67b..4defc06c1 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,14 +52,13 @@ resolved dependency inventory remains in `package-lock.json`. are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`, `scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`, `Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`, - `Transforms.js`, `Check.js`, `DeveloperError.js`) and say so in their first + `Transforms.js`) and say so in their first line. The changes: the classes' static functions became module functions, vectors, geodetic positions and quaternions became plain-data instances of module-private constructors (own enumerable fields only, no methods), matrices plain arrays, and the ellipsoid a frozen object; the debug - argument checks were ported with Cesium's conditions and messages and, like - Cesium's, are active in development and tests and stripped from production - builds; only the subset gev uses was kept. + argument checks were left out, as in Cesium's release build; only the subset + gev uses was kept. - Author: Copyright 2011-2024 CesiumJS Contributors - Source: - License: Apache License 2.0 diff --git a/build/initialChunkGuard.js b/build/initialChunkGuard.js index c189a70c5..9caf24d3f 100644 --- a/build/initialChunkGuard.js +++ b/build/initialChunkGuard.js @@ -2,8 +2,7 @@ // initial chunks are every entry chunk and what it imports statically; // navaraMode.js reaches Navara only through import(), so three and // @navaramap/* belong to lazy chunks. This checks the bundle's module ids -// (what Rollup put in each chunk), and fails the build otherwise; -// scripts/check-main-chunk.mjs checks the built files' text as well. +// (what Rollup put in each chunk), and fails the build otherwise. /** * Module ids that only the Navara engine may load, when no package list is diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 467ae241f..a8475f38d 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -55,7 +55,8 @@ See [application construction](APPLICATION.md) and the 2. `scripts/check-package-boundaries.mjs` builds every declared export without app Vite configuration or environment files. `scripts/package-boundaries.json` assigns each export exactly once and lists its owned modules and external - dependencies. Unused imports still count. Node exports have only a `node` + dependencies; a module entry ending in `/` (such as `src/geo/`) owns every + file under that directory. Unused imports still count. Node exports have only a `node` condition; browser groups cannot use build-only dependency exceptions. Portable source graphs cannot reach application/rendering, Node, Cesium or diff --git a/package.json b/package.json index fde7356b3..44c3745b1 100644 --- a/package.json +++ b/package.json @@ -61,21 +61,21 @@ "@jtarrio/webrtlsdr": "^3.0.6", "@mapbox/vector-tile": "^3.0.0", "@meri-imperiumi/eccodes-wasm": "^2.48.2", + "@navaramap/three": "0.2.1", + "@navaramap/three-default-plugin": "0.2.1", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", "hls.js": "^1.7.3", + "kdbush": "4.0.2", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", "pinia": "^2.3.1", - "satellite.js": "^6.0.2", - "vue": "^3.5.44", - "vue-i18n": "^11.4.15", - "@navaramap/three": "0.2.1", - "@navaramap/three-default-plugin": "0.2.1", - "kdbush": "4.0.2", "postprocessing": "6.39.4", - "three": "0.185.1" + "satellite.js": "^6.0.2", + "three": "0.185.1", + "vue": "^3.5.44", + "vue-i18n": "^11.4.15" }, "devDependencies": { "@happy-dom/global-registrator": "^20.14.6", diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 5e22de4b0..a0f65d8fa 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -30,9 +30,9 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file); const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/; // Navara renders through three.js and its postprocessing peer. const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; -// Phase 3: the layers moved onto layer renderers (the *a tasks) keep each -// engine in their renderers: cesium only in render/cesium*.js, three and -// Navara only in render/navara*.js. The list grows with each moved layer. +// Layers built on layer renderers keep each engine in their renderers: +// cesium only in render/cesium*.js, three and Navara only in +// render/navara*.js. The list grows with each layer moved onto renderers. const RENDERED_LAYER_DIRS = [ 'src/layers/alpr/', 'src/layers/airQuality/', diff --git a/scripts/check-main-chunk.mjs b/scripts/check-main-chunk.mjs index 87ae12dd1..3fabe852f 100644 --- a/scripts/check-main-chunk.mjs +++ b/scripts/check-main-chunk.mjs @@ -1,17 +1,12 @@ #!/usr/bin/env node /** - * The main-chunk gate of the Cesium-mode bundle (run after `npm run build`): + * The size budget of the Cesium-mode main chunk (run after `npm run build`): + * gzip-9 of the entry chunk the built page loads (from the Vite manifest, or + * the module script dist/index.html loads) may exceed the baseline in + * scripts/main-chunk-baseline.json by at most its allowance. What the initial + * chunks may hold is the build's own check (build/initialChunkGuard.js). * - * 1. Composition: the main chunk (the Vite manifest's entry, or the module - * script dist/index.html loads) and every chunk it imports statically carry - * no three or Navara code (text markers; the build itself also fails on - * three/@navaramap module ids in initial chunks, build/initialChunkGuard.js). - * 2. Budget, when scripts/main-chunk-baseline.json exists: gzip-9 of the - * largest dist/assets/index-*.js may exceed the recorded baseline by at - * most its allowance (the phase-4 branch's check; same exports, - * checkMainChunk and measureMainChunk, so the two merge into this script). - * - * Prints the sizes. `--json` prints the whole result; `--dist=DIR` reads + * Prints the size. `--json` prints the whole result; `--dist=DIR` reads * another build. * * npm run build && npm run check:main-chunk @@ -20,12 +15,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { gzipSync } from 'node:zlib'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { - entryFromHtml, - entryFromManifest, - forbiddenMarkersIn, - initialChunks, -} from '../src/tooling/mainChunk.js'; +import { entryFromHtml, entryFromManifest } from '../src/tooling/mainChunk.js'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); @@ -44,38 +34,14 @@ export function checkMainChunk( /** * gzip-9 size of the main chunk, with its name: the entry the built page - * loads (`entry`, or found from dist/index.html or the Vite manifest), else - * (phase 4's rule, for a dist without a page) the largest - * dist/assets/index-*.js. + * loads (`entry`, else found from the Vite manifest or dist/index.html). */ export function measureMainChunk(distDir, { entry = null } = {}) { - let file = entry; - if (!file) { - try { - file = findEntry(distDir).entry; - } catch { - file = null; - } - } - if (file) { - const bytes = fs.readFileSync(path.join(distDir, file)); - return { - name: path.basename(file), - gzipBytes: gzipSync(bytes, { level: 9 }).length, - }; - } - const assets = path.join(distDir, 'assets'); - const candidates = fs - .readdirSync(assets) - .filter((name) => /^index-.*\.js$/.test(name)) - .map((name) => ({ name, bytes: fs.readFileSync(path.join(assets, name)) })) - .sort((a, b) => b.bytes.length - a.bytes.length); - if (!candidates.length) - throw new Error('no dist/assets/index-*.js: run npm run build first'); - const [main] = candidates; + const file = entry ?? findEntry(distDir).entry; + const bytes = fs.readFileSync(path.join(distDir, file)); return { - name: main.name, - gzipBytes: gzipSync(main.bytes, { level: 9 }).length, + name: path.basename(file), + gzipBytes: gzipSync(bytes, { level: 9 }).length, }; } @@ -96,44 +62,15 @@ function findEntry(dist) { return { entry, via: 'index.html' }; } -/** Composition of the initial chunks: sizes and forbidden markers. */ -export function checkComposition(dist) { - const read = (file) => { - const absolute = path.join(dist, file); - return fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf8') : null; - }; - const { entry, via } = findEntry(dist); - const chunks = initialChunks(entry, read).map((file) => { - const code = read(file); - if (code == null) - return { file, missing: true, bytes: 0, gzipBytes: 0, markers: [] }; - return { - file, - bytes: Buffer.byteLength(code), - gzipBytes: gzipSync(code).length, - markers: forbiddenMarkersIn(code), - }; - }); - return { - entry, - via, - mainBytes: chunks[0].bytes, - mainGzipBytes: chunks[0].gzipBytes, - initialBytes: chunks.reduce((sum, chunk) => sum + chunk.bytes, 0), - chunks, - ok: chunks.every((chunk) => !chunk.missing && !chunk.markers.length), - }; -} - function main() { const option = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1]; const dist = path.resolve(ROOT, option('dist') ?? 'dist'); - const composition = checkComposition(dist); + const { entry, via } = findEntry(dist); const baselinePath = path.join(ROOT, 'scripts/main-chunk-baseline.json'); let budget = null; if (fs.existsSync(baselinePath)) { - const measured = measureMainChunk(dist, { entry: composition.entry }); + const measured = measureMainChunk(dist, { entry }); budget = { ...measured, ...checkMainChunk( @@ -142,28 +79,11 @@ function main() { ), }; } - const ok = composition.ok && (budget?.ok ?? true); + const ok = budget?.ok ?? true; if (process.argv.includes('--json')) { - console.log(JSON.stringify({ composition, budget, ok }, null, 2)); + console.log(JSON.stringify({ entry, via, budget, ok }, null, 2)); } else { - const kb = (bytes) => `${(bytes / 1000).toFixed(2)} kB`; - console.log( - `[check:main-chunk] entry ${composition.entry} (via ${composition.via})`, - ); - for (const chunk of composition.chunks) - console.log( - `[check:main-chunk] ${chunk.file}: ${kb(chunk.bytes)} (gzip ${kb(chunk.gzipBytes)})` + - (chunk.missing ? ' MISSING' : '') + - (chunk.markers.length - ? ` FORBIDDEN: ${chunk.markers.join(', ')}` - : ''), - ); - console.log( - `[check:main-chunk] main ${composition.mainBytes} bytes (gzip ${composition.mainGzipBytes}); initial chunks ${composition.initialBytes} bytes; ` + - (composition.ok - ? 'no three or Navara' - : 'three or Navara in the initial chunks'), - ); + console.log(`[check:main-chunk] entry ${entry} (via ${via})`); console.log( budget ? `[check:main-chunk] ${budget.name}: ${budget.message}` diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 700dc9a1a..7a0883207 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -37,7 +37,11 @@ export async function checkPackageBoundaries(root) { ) { throw new Error(`Invalid package boundary: ${name}`); } + // A module entry ending in "/" owns every file under that directory. const allowed = new Set(); + const allowedDirectories = []; + const owns = (id) => + allowed.has(id) || allowedDirectories.some((dir) => id.startsWith(dir)); for (const module of group.modules) { if ( typeof module !== 'string' || @@ -56,7 +60,11 @@ export async function checkPackageBoundaries(root) { ) { throw new Error(`Boundary module escapes repository: ${name}`); } - allowed.add(normalizePath(resolved)); + if (module.endsWith('/')) { + allowedDirectories.push(`${normalizePath(resolved)}/`); + } else { + allowed.add(normalizePath(resolved)); + } } for (const external of group.external) { if ( @@ -82,7 +90,7 @@ export async function checkPackageBoundaries(root) { if ( typeof target !== 'string' || !target.startsWith('./') || - !allowed.has(normalizePath(path.resolve(root, target))) + !owns(normalizePath(path.resolve(root, target))) ) { throw new Error(`Export must point to an owned module: ${key}`); } @@ -109,7 +117,7 @@ export async function checkPackageBoundaries(root) { // template blocks belong to the component file itself. if (info.id.startsWith('\0')) return; const id = info.id.split('?')[0]; - if (!allowed.has(id)) { + if (!owns(id)) { throw new Error( `Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`, ); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index bf3552c9d..eb7dcf432 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -589,6 +589,8 @@ "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/visuals.js", + "src/layers/alpr/overlay.js", + "src/layers/alpr/presentation.js", "src/layers/alpr/overlay.test.mjs", "src/layers/alpr/source.js", "src/layers/alpr/source.test.mjs", @@ -1217,6 +1219,7 @@ "src/layers/aircraft/render/contract.js", "src/layers/shared/color.test.mjs", "src/layers/shared/color.js", + "src/layers/shared/viewRectangle.js", "src/layers/military/render/cesium.js", "src/layers/military/render/navara.js", "src/layers/aircraft/qaDrawn.js", @@ -1225,13 +1228,18 @@ "src/layers/vessels/render/cesium.js", "src/layers/vessels/render/contract.js", "src/layers/vessels/renderer.test.mjs", + "src/layers/vessels/testFixtures.mjs", "src/layers/vessels/render/navara.js", "src/layers/shared/render/engineView.js", + "src/layers/shared/render/navaraGroundEffect.js", + "src/layers/shared/render/navaraMoverBank.js", + "src/layers/shared/render/imageAtlas.js", "src/layers/installations/render/cesium/lifecycle.js", "src/layers/installations/render/cesium/rendering.js", "src/layers/installations/render/cesium/selection.js", "src/layers/bikeshare/render/cesium/lifecycle.js", "src/layers/bikeshare/presentation.js", + "src/layers/bikeshare/stationPoints.js", "src/layers/bikeshare/presentation.test.mjs", "src/layers/bikeshare/selectionModel.js", "src/layers/bikeshare/render/cesium.js", @@ -1329,8 +1337,8 @@ "src/globe/cesium/render.js", "src/globe/cesium/surface.js", "src/globe/contract.js", - "src/globe/navara/cameraMath.js", - "src/globe/navara/cameraMath.test.mjs", + "src/globe/cameraMath.js", + "src/globe/cameraMath.test.mjs", "src/globe/navara/cameraModel.js", "src/globe/navara/cameraModel.test.mjs", "src/globe/navara/createNavaraEngine.js", @@ -1364,14 +1372,12 @@ "src/tooling/mainChunkComposition.test.mjs", "src/tooling/initialChunkGuard.test.mjs", "src/globe/movers.test.mjs", - "src/globe/cesium/movers.test.mjs", "src/globe/navara/movers/pack.test.mjs", "src/globe/navara/movers/pickIds.test.mjs", "src/globe/navara/movers/moverMesh.test.mjs", "src/globe/navara/movers/modelMover.test.mjs", "src/bench/syntheticFlights.test.mjs", "src/globe/lines.test.mjs", - "src/globe/cesium/lines.test.mjs", "src/globe/navara/lines/lineMesh.test.mjs", "src/globe/pickDelivery.test.mjs", "src/globe/navara/picking.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cdf804f23..973b80426 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -897,24 +897,7 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/" ], "external": ["cesium"] }, @@ -1176,24 +1159,7 @@ "src/globe/cesium/engineFromViewer.js", "src/cameraAngles.js", "src/globe/contract.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/app/layers/directory.js", "src/globe/follow.js", "src/ui/kit/UiSegmented.vue" @@ -1333,24 +1299,7 @@ "src/ui/i18n.js", "src/globe/cesium/engineFromViewer.js", "src/globe/contract.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1432,24 +1381,7 @@ "src/ui/unitLabels.js", "src/data/render/cesiumProjection.js", "src/data/render/cesiumConstants.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/time/clock.js", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js", @@ -1516,24 +1448,7 @@ "src/data/render/cesiumProjection.js", "src/data/render/cesiumConstants.js", "src/layers/shared/color.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/time/clock.js", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js", @@ -1571,24 +1486,7 @@ "src/data/inputOwnership.js", "src/data/render/cesiumProjection.js", "src/data/render/cesiumConstants.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], @@ -1635,24 +1533,7 @@ "src/layers/vessels/icons.js", "src/layers/shared/color.js", "src/data/render/cesiumProjection.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], @@ -1665,24 +1546,7 @@ "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/cartographic.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", - "src/geo/ellipsoid.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", + "src/geo/", "src/layers/shared/color.js" ], "external": [] @@ -1751,24 +1615,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/layers/firms/rectangle.js", "src/layers/shared/color.js" ], @@ -1810,24 +1657,7 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/time/clock.js", "src/layers/shared/color.js" ], @@ -1872,24 +1702,7 @@ "src/i18n/locales/zh-Hant.json", "src/layers/shared/color.js", "src/layers/launches/render/cesium.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/time/clock.js", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" @@ -1946,24 +1759,7 @@ "src/layers/installations/render/cesium/selection.js", "src/layers/shared/color.js", "src/layers/installations/render/cesium/namedMarkers.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], @@ -2015,24 +1811,7 @@ "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/data/render/cesiumProjection.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], @@ -2094,24 +1873,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], @@ -2151,24 +1913,7 @@ "src/layers/bikeshare/selectionModel.js", "src/layers/bikeshare/presentation.js", "src/layers/shared/color.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], @@ -2227,24 +1972,7 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/shared/color.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/" ], "external": ["hls.js"] }, @@ -2318,24 +2046,7 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/streetLevel/providers/mapillary/policy.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -2371,24 +2082,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/" ], "external": ["@mapbox/vector-tile", "pbf"] }, @@ -2437,24 +2131,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/" ], "external": [] }, @@ -2486,24 +2163,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/" ], "external": ["cesium"] }, @@ -3692,7 +3352,7 @@ "src/globe/cesium/render.js", "src/globe/cesium/surface.js", "src/globe/cesium/engineFromViewer.js", - "src/globe/navara/cameraMath.js", + "src/globe/cameraMath.js", "src/globe/navara/cameraModel.js", "src/globe/navara/createNavaraEngine.js", "src/globe/navara/credits.js", @@ -3722,6 +3382,8 @@ "src/data/render/cesiumTrailRenderer.js", "src/layers/alpr/render/cesiumPresentation.js", "src/layers/alpr/render/cesiumOverlay.js", + "src/layers/alpr/overlay.js", + "src/layers/alpr/presentation.js", "src/layers/alpr/render/cesium.js", "src/layers/bikeshare/selectionModel.js", "src/layers/bikeshare/presentation.js", @@ -3742,35 +3404,16 @@ "src/layers/localAdsb/qaDrawn.js", "src/ui/imageryBoxNavara.js", "src/layers/installations/render/cesium/namedMarkers.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/time/clock.js", "src/globe/navara/overlay.js", "src/globe/navara/frameDescriptor.js", "src/globe/cesium/clock.js", "src/globe/pickDelivery.js", "src/globe/navara/clock.js", - "src/globe/cesium/lines.js", "src/globe/follow.js", "src/globe/lines.js", "src/globe/navara/movers/pickIds.js", - "src/globe/cesium/movers.js", "src/globe/movers.js", "src/globe/cesium/follow.js", "src/globe/cpuPicks.js", @@ -3847,6 +3490,8 @@ "src/layers/cyclones/render/navara.js", "src/layers/shared/render/groundRing.js", "src/layers/bikeshare/render/navara.js", + "src/layers/bikeshare/stationPoints.js", + "src/layers/shared/viewRectangle.js", "src/layers/installations/render/navaraGeometry.js", "src/layers/installations/render/navaraOutlineBatch.js", "src/layers/installations/render/navaraStencilBatch.js", @@ -3857,6 +3502,7 @@ "src/layers/radio/render/navara.js", "src/layers/transit/render/navara.js", "src/layers/transit/render/navaraSprites.js", + "src/layers/shared/render/navaraMoverBank.js", "src/layers/transit/render/navaraView.js", "src/layers/transit/render/navaraTrail.js", "src/layers/recentImagery/render/navara.js", @@ -3868,14 +3514,18 @@ "src/layers/cctv/render/navaraLine.js", "src/layers/alpr/render/navara.js", "src/layers/alpr/render/navaraOverlay.js", + "src/layers/alpr/overlay.js", + "src/layers/alpr/presentation.js", "src/layers/radio/screenClusters.js", "src/layers/shared/render/navaraCellGeometry.js", "src/layers/shared/render/navaraGroundCells.js", "src/layers/firms/render/navaraSprites.js", + "src/layers/shared/render/imageAtlas.js", "src/layers/firms/render/navaraHeat.js", "src/layers/shared/render/navaraGroundGeometry.js", "src/layers/shared/render/navaraGroundGrid.js", "src/layers/shared/render/navaraGroundSurface.js", + "src/layers/shared/render/navaraGroundEffect.js", "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", @@ -4364,6 +4014,7 @@ "src/layers/transit/state.js", "src/layers/transit/testing.js", "src/layers/transit/viewport.js", + "src/layers/shared/viewRectangle.js", "src/data/cameraSensitivity.js", "src/data/contactPlayback.js", "src/data/contactTrailRenderer.js", @@ -4528,6 +4179,7 @@ "src/globe/cesium/input.js", "src/globe/cesium/convert.js", "src/globe/cesium/camera.js", + "src/globe/cameraMath.js", "src/globe/cesium/frames.js", "src/globe/cesium/picker.js", "src/globe/cesium/events.js", @@ -4543,6 +4195,8 @@ "src/data/render/cesiumTrailRenderer.js", "src/layers/alpr/render/cesiumPresentation.js", "src/layers/alpr/render/cesiumOverlay.js", + "src/layers/alpr/overlay.js", + "src/layers/alpr/presentation.js", "src/layers/alpr/render/cesium.js", "src/layers/bikeshare/selectionModel.js", "src/layers/bikeshare/presentation.js", @@ -4550,6 +4204,7 @@ "src/layers/bikeshare/render/cesium/viewport.js", "src/layers/bikeshare/render/cesium/lifecycle.js", "src/layers/bikeshare/render/cesium/rendering.js", + "src/layers/bikeshare/stationPoints.js", "src/layers/bikeshare/render/cesium/selection.js", "src/layers/installations/render/cesium/lifecycle.js", "src/layers/installations/render/cesium.js", @@ -4561,24 +4216,7 @@ "src/data/render/cesiumContactTrailRenderer.js", "src/layers/localAdsb/qaDrawn.js", "src/layers/installations/render/cesium/namedMarkers.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/time/clock.js", "src/globe/follow.js", "src/globe/cesium/follow.js", @@ -4687,24 +4325,7 @@ "src/globe/cesium/engineFromViewer.js", "src/globe/cesium/credits.js", "src/globe/contract.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/globe/surfaceDetail.js", "src/sources/openFreeMap.js", "src/sources/vectorTiles.js", @@ -4985,24 +4606,7 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/geo/julianDate.js", - "src/geo/simon1994.js", - "src/geo/developerError.js", - "src/geo/check.js", - "src/geo/math.js", - "src/geo/cartesian2.js", - "src/geo/cartographic.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/quaternion.js", - "src/geo/matrix4.js", - "src/geo/ellipsoid.js", - "src/geo/transforms.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/debug.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/index.js" + "src/geo/" ], "external": ["cesium"] }, @@ -5315,6 +4919,7 @@ "src/layers/transit/testing.js", "src/layers/transit/trails.js", "src/layers/transit/viewport.js", + "src/layers/shared/viewRectangle.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", "src/overlays/worldOverlayTokens.js", @@ -5349,6 +4954,7 @@ "src/globe/cesium/picker.js", "src/globe/cesium/frames.js", "src/globe/cesium/camera.js", + "src/globe/cameraMath.js", "src/globe/cesium/convert.js", "src/globe/cesium/input.js", "src/maps/google3d.js", @@ -5373,24 +4979,7 @@ "src/data/render/cesiumProjection.js", "src/layers/shared/color.js", "src/layers/transit/render/cesium.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/time/clock.js", "src/globe/surfaceDetail.js", "src/celestialRing/render/cesium.js" @@ -5418,6 +5007,7 @@ "src/layers/transit/state.js", "src/layers/transit/testing.js", "src/layers/transit/viewport.js", + "src/layers/shared/viewRectangle.js", "src/data/contactPlayback.js", "src/data/contactTrailRenderer.js", "src/data/transitProxy.js", @@ -5445,24 +5035,7 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/shared/color.js", - "src/geo/index.js", - "src/geo/math.js", - "src/geo/ellipsoid.js", - "src/geo/cartographic.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/transforms.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", + "src/geo/", "src/globe/contract.js" ], "external": ["cesium", "pbf"] @@ -5506,9 +5079,6 @@ "exports": ["./layers/perimeters"], "modules": [ "src/geo/cartesian3.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", "src/geo/math.js", "src/layers/perimeters/index.js", "src/layers/perimeters/render/cesium.js", @@ -5611,9 +5181,6 @@ "src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/source.js", "src/geo/cartesian3.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", "src/geo/math.js", "src/sources/httpBody.js", "src/data/geoFileImport.js", @@ -5663,24 +5230,7 @@ "exports": ["./layers/drone-airspace"], "modules": [ "src/layers/droneAirspace/index.js", - "src/geo/index.js", - "src/geo/ellipsoid.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/debug.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/boundingSphere.js", - "src/geo/transforms.js", - "src/geo/matrix4.js", - "src/geo/cartesian3.js", - "src/geo/quaternion.js", - "src/geo/math.js", - "src/geo/matrix3.js", - "src/geo/cartographic.js", - "src/geo/cartesian2.js", - "src/geo/check.js", - "src/geo/developerError.js", - "src/geo/simon1994.js", - "src/geo/julianDate.js", + "src/geo/", "src/layers/shared/render/cesiumZones.js", "src/sources/httpBody.js", "src/layers/droneAirspace/source.js", @@ -5769,24 +5319,7 @@ "src/tools/queries/panelRequest.js", "src/geoDistance.js", "src/visualStylePolicy.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/cartographic.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", - "src/geo/ellipsoid.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/index.js", - "src/geo/julianDate.js", - "src/geo/math.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/simon1994.js", - "src/geo/transforms.js" + "src/geo/" ], "external": ["satellite.js"] }, @@ -5898,9 +5431,6 @@ "exports": ["./layers/air-quality"], "modules": [ "src/geo/cartesian3.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", "src/geo/math.js", "src/layers/airQuality/index.js", "src/layers/airQuality/render/cesium.js", @@ -5946,24 +5476,7 @@ "src/loadingFeedback.js", "src/data/installationFeedback.js", "src/i18n/marker.js", - "src/geo/boundingSphere.js", - "src/geo/cartesian2.js", - "src/geo/cartesian3.js", - "src/geo/cartographic.js", - "src/geo/check.js", - "src/geo/debug.js", - "src/geo/developerError.js", - "src/geo/ellipsoid.js", - "src/geo/ellipsoidGeodesic.js", - "src/geo/index.js", - "src/geo/julianDate.js", - "src/geo/math.js", - "src/geo/matrix3.js", - "src/geo/matrix4.js", - "src/geo/quaternion.js", - "src/geo/scaleToGeodeticSurface.js", - "src/geo/simon1994.js", - "src/geo/transforms.js" + "src/geo/" ], "external": ["cesium"] } diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs index 6d591d976..4b1931b31 100644 --- a/scripts/qa-all-layer-switches.mjs +++ b/scripts/qa-all-layer-switches.mjs @@ -83,42 +83,28 @@ try { // Every name the checkout's .env files define, plus key-like names in this // environment: all blank for the build and the preview. const KEY_LIKE = /(_KEY|_TOKEN|_SECRET|_CLIENT_ID|PASSWORD)$/; -const dotenvNames = () => { - const names = new Set(); - for (const file of ['.env', '.env.local']) - if (existsSync(join(root, file))) - for (const line of readFileSync(join(root, file), 'utf8').split('\n')) { - const name = line.match(/^\s*([A-Z0-9_]+)\s*=/)?.[1]; - if (name) names.add(name); - } - return [...names].sort(); -}; -const envNames = () => { - const names = new Set(); - for (const file of ['.env', '.env.local']) - if (existsSync(join(root, file))) - for (const line of readFileSync(join(root, file), 'utf8').split('\n')) { - const name = line.match(/^\s*([A-Z0-9_]+)\s*=/)?.[1]; - if (name) names.add(name); - } - for (const name of Object.keys(process.env)) - if (KEY_LIKE.test(name)) names.add(name); - return [...names].sort(); -}; -const envValues = () => { - const values = []; - for (const file of ['.env', '.env.local']) - if (existsSync(join(root, file))) - for (const line of readFileSync(join(root, file), 'utf8').split('\n')) { - const [, name, raw] = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/) ?? []; - const value = raw?.trim().replace(/^["']|["']$/g, ''); - // Credentials only (model names and URLs are settings the code may - // also carry as defaults). - if (KEY_LIKE.test(name ?? '') && value && value.length >= 8) - values.push(value); - } - return values; -}; +/** The [name, value] pairs of the checkout's .env and .env.local, in order. */ +const dotenv = () => + ['.env', '.env.local'] + .filter((file) => existsSync(join(root, file))) + .flatMap((file) => readFileSync(join(root, file), 'utf8').split('\n')) + .map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)/)) + .filter(Boolean) + .map(([, name, raw]) => [name, raw.trim().replace(/^["']|["']$/g, '')]); +const dotenvNames = () => [...new Set(dotenv().map(([name]) => name))].sort(); +const envNames = () => + [ + ...new Set([ + ...dotenvNames(), + ...Object.keys(process.env).filter((name) => KEY_LIKE.test(name)), + ]), + ].sort(); +// Credentials only (model names and URLs are settings the code may also +// carry as defaults). +const envValues = () => + dotenv() + .filter(([name, value]) => KEY_LIKE.test(name) && value.length >= 8) + .map(([, value]) => value); const scrubbed = () => ({ ...process.env, ...Object.fromEntries(envNames().map((name) => [name, ''])), @@ -368,9 +354,9 @@ const runs = [ ['rocket-launches'], ['--missions'], ], - // Stage 3's own checks (one application per page, voice across a switch). - // They block paid routes themselves but do not run under the strict - // harness, so they add no assertion manifest and no network ledger. + // One application per page, and voice across a switch: these block paid + // routes themselves but do not run under the strict harness, so they add + // no assertion manifest and no network ledger. ['app-once', 'app-once', [], [], 600, true], ['voice-engine-switch', 'voice-engine-switch', [], [], 300, true], ['strict-rapid-switch', 'strict-rapid-switch', []], @@ -379,14 +365,18 @@ const runs = [ ['strict-failure-matrix', 'strict-failure-matrix', [], [], 600], ['strict-cold-chunks', 'strict-cold-chunks', []], ['strict-prod-smoke', 'strict-prod-smoke', []], - // Stage 6: the app's visual styles, bloom, contact sonar, cockpit clouds - // and annotations drawn on Navara (annotations is a tool, not a catalog - // layer: TOOL_IDS). + // The app's visual styles, bloom, contact sonar, cockpit clouds, and the + // annotations, Draw, Import and the scope mask with the celestial ring on + // Navara (those are tools and display overlays, not catalog layers: + // TOOL_IDS). ['navara-visual-styles', 'navara-visual-styles', []], ['navara-bloom', 'navara-bloom', []], ['navara-sonar', 'navara-sonar', []], ['navara-cockpit-clouds', 'navara-cockpit-clouds', []], ['navara-annotations', 'navara-annotations', ['annotations']], + ['navara-draw', 'navara-draw', ['draw']], + ['navara-geo-import', 'navara-geo-import', ['geo-import']], + ['navara-scope', 'navara-scope', ['scope-mask', 'celestial-ring']], ['strict-soak', 'strict-soak', [], soakRuns, 1200], [ 'strict-soak--long', @@ -398,7 +388,13 @@ const runs = [ ]; // Ids a scenario covers that are tools, not catalog layers: their // assertions are recorded, but the catalog comparison leaves them out. -const TOOL_IDS = new Set(['annotations']); +const TOOL_IDS = new Set([ + 'annotations', + 'draw', + 'geo-import', + 'scope-mask', + 'celestial-ring', +]); const scenarioIds = [ ...new Set( runs.flatMap(([, , ids]) => ids).filter((id) => !TOOL_IDS.has(id)), @@ -414,9 +410,7 @@ const liveCatalog = {}; layers: [], name: 'catalog', }); - const browser = await strict.launch({ - args: ['--use-angle=metal', '--enable-gpu'], - }); + const browser = await strict.launch(); try { for (const engine of ENGINES) { const page = await browser.newPage(); @@ -497,17 +491,7 @@ for (const [label, name, layers, extra = [], seconds = 300, external] of runs) { const script = 'scripts/qa-' + name + '.mjs'; const evidence = join(output, 'evidence', label + '.json'); rmSync(evidence, { force: true }); - const args = - name === 'w2-imagery' - ? [ - '--url', - base.origin + '/?engine=navara', - '--report', - join(output, 'evidence', 'imagery-report.json'), - '--switch', - '--preview', - ] - : [base.origin, ...extra]; + const args = [base.origin, ...extra]; if (external) args.splice(0, 1, base.origin + '/'); else args.push(`--layers=${layers.join(',')}`, `--evidence=${evidence}`); const start = Date.now(); diff --git a/scripts/qa-app-once.mjs b/scripts/qa-app-once.mjs index 2f542e973..1d94a53b1 100644 --- a/scripts/qa-app-once.mjs +++ b/scripts/qa-app-once.mjs @@ -221,7 +221,7 @@ try { await new Promise((resolve) => setTimeout(resolve, 2000)); report.firstRoundTrip = await listenerCounts(page, { bySource: true }); } - // Both engines draw the session's marks and files (Navara since Stage 6). + // Both engines draw the session's marks and files (Navara included). await page.waitForFunction( () => window.__gevGeoImport.diagnostics().drawn === 1, { timeout: 30_000 }, @@ -412,23 +412,6 @@ try { report.errors = errors; check(errors.length === 0, 'page errors', errors); - // The legacy route (?switch=legacy) still builds an application per - // engine, for one release. - const legacy = await openSessionPage( - browser, - sessionAddress(base, { engine: 'cesium', extra: { switch: 'legacy' } }), - ); - await waitReady(legacy.page, 'cesium'); - await switchEngine(legacy.page, 'navara'); - await switchEngine(legacy.page, 'cesium'); - report.legacy = await readSession(legacy.page); - check( - report.legacy.counters.applications === 3, - 'the legacy route builds an application per engine', - report.legacy.counters, - ); - check(legacy.errors.length === 0, 'legacy page errors', legacy.errors); - await legacy.page.close(); console.log(JSON.stringify(report, null, 2)); if (!reportOnly) console.log('PASS application created once; session kept'); } finally { diff --git a/scripts/qa-browserEvidence.mjs b/scripts/qa-browserEvidence.mjs index a67e81477..1219edd83 100644 --- a/scripts/qa-browserEvidence.mjs +++ b/scripts/qa-browserEvidence.mjs @@ -45,7 +45,7 @@ export function isExpectedPreviewSetupError( } } -// W1 acceptance shares one strict runtime verdict across its main page and +// The runtime evidence gate shares one strict runtime verdict across its main page and // every share-link page. Response diagnostics never retain query credentials. export function watchW1Page(page, report, { name, preview = false, baseUrl }) { const errors = (report.browserErrors ??= []); diff --git a/scripts/qa-cctv-engine-switch.mjs b/scripts/qa-cctv-engine-switch.mjs index 8608de968..cd875013e 100644 --- a/scripts/qa-cctv-engine-switch.mjs +++ b/scripts/qa-cctv-engine-switch.mjs @@ -37,7 +37,7 @@ const { stdout: video } = await promisify(execFile)( { encoding: 'buffer', maxBuffer: 16000000 }, ); const strict = createStrict({ origin: url.origin, layers: ['cctv'] }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { const p = await b.newPage(); const errors = []; p.on('pageerror', (e) => { diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index dd1adf8e6..6a889cec6 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -42,7 +42,7 @@ * * This legacy harness is the geometry, lifecycle and real-input oracle. * Migration runs use headless Metal and strict runtime error observation. - * Screenshots remain visual-review evidence here; the W2 companion must + * Screenshots remain visual-review evidence here; a separate pixel gate must * independently prove actual marker, image-plane and video pixels. * * No `cesium` package import for in-page math: the app does not expose a @@ -2245,7 +2245,7 @@ async function main() { // ----------------------------------------------------------------------- // Screenshots supplement this state/geometry oracle. Positive rendered - // media pixels are a separate mandatory W2 companion gate. + // media pixels are a separate mandatory pixel gate. // ----------------------------------------------------------------------- await sleep(1000); const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png'); diff --git a/scripts/qa-contact-switch.mjs b/scripts/qa-contact-switch.mjs index 16b40a76e..166cae7b6 100644 --- a/scripts/qa-contact-switch.mjs +++ b/scripts/qa-contact-switch.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import { fromDegrees } from '../src/geo/cartesian3.js'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; @@ -10,14 +15,8 @@ const strict = createStrict({ origin: base.origin, layers: ['military-awareness', 'military-installations'], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); await p.evaluateOnNewDocument(() => { const fetch = window.fetch.bind(window); window.fetch = (input, options) => { @@ -89,21 +88,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { ); }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { dataManager, styleManager, tileset } = window.__godsEyeView; if (tileset || window.__GOOGLE_MAPS_API_KEY__) @@ -235,8 +220,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { baseline, ); for (const kind of ['cesium', 'navara']) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); const current = await sample(); console.log('handoff', kind, JSON.stringify(current)); assert.deepEqual(current, { diff --git a/scripts/qa-directions-engine-switch.mjs b/scripts/qa-directions-engine-switch.mjs index f1ce4bdc5..17b64dba8 100644 --- a/scripts/qa-directions-engine-switch.mjs +++ b/scripts/qa-directions-engine-switch.mjs @@ -1,19 +1,18 @@ import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; base.hash = 'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; const strict = createStrict({ origin: base.origin, layers: ['directions'] }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); let routeReads = 0, release; await p.setRequestInterception(true); @@ -55,21 +54,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }); }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { tileset, dataManager } = window.__godsEyeView; if (tileset || window.__GOOGLE_MAPS_API_KEY__) @@ -91,8 +76,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { 'directions', 'A and B set by clicks on the globe; one route request', ); - await strict.pick(p, 'cesium'); - await ready('cesium'); + await strict.switchTo(p, 'cesium'); release(); await p.waitForFunction(() => window.qaRoute.getStats().count === 3); await p.waitForFunction(() => { @@ -105,8 +89,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { 'the route asked for on Navara arrives on Cesium; a step is selectable', ); const transfer = async (kind) => { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); assert.equal( await p.evaluate( () => diff --git a/scripts/qa-director-engine-switch.mjs b/scripts/qa-director-engine-switch.mjs index 947cd8e24..17e9c2cfd 100644 --- a/scripts/qa-director-engine-switch.mjs +++ b/scripts/qa-director-engine-switch.mjs @@ -135,7 +135,7 @@ const strict = createStrict({ layers: [], fixtures: ['https://earthquake.usgs.gov/*'], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { const p = await b.newPage(); await p.setViewport({ width: 1280, height: 900 }); const errors = []; diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index a69b05e69..b04aed3ae 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -7,478 +7,462 @@ base.search = '?welcome=0&look=cinematic'; base.hash = 'v=2&lat=42.3554&lon=-71.0605&alt=6000&heading=0&pitch=-90&map=osm&l=e'; const strict = createStrict({ origin: base.origin, layers: ['earthquakes'] }); -await strict.run( - { args: ['--use-angle=metal', '--enable-gpu'] }, - async (browser) => { - const page = await browser.newPage(); - await page.evaluateOnNewDocument(() => { - localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); - const fetch = window.fetch.bind(window); - // Two Boston vessels for the Contacts session; empty air and site feeds. - const fixtures = { - '/api/vessels': () => ({ - status: 'live', - rows: ['123456789', '223456789'].map((mmsi, i) => ({ - mmsi, - name: 'Boston ' + i, - lat: 42.3554, - lon: -71.0605 + i * 0.05, - last_position_epoch: Date.now() / 1000, - })), - }), - '/api/vessels/track': () => ({ samples: [] }), - '/api/flights': () => ({ time: Date.now() / 1000, states: [] }), - '/api/military': () => ({ now: Date.now(), ac: [] }), - '/api/military-installations': () => ({ elements: [], status: 'ok' }), - }; - window.fetch = (url, options) => { - if ( - String(url).startsWith( - 'https://earthquake.usgs.gov/earthquakes/feed/', - ) - ) - return Promise.resolve( - Response.json({ - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - id: 'qa-boston', - geometry: { - type: 'Point', - coordinates: [-71.0605, 42.3554, 8], - }, - properties: { mag: 4, time: Date.now(), place: 'QA Boston' }, - }, - ], - }), - ); - const fixture = - fixtures[new URL(url.url ?? url, document.baseURI).pathname]; - return fixture - ? Promise.resolve(Response.json(fixture())) - : fetch(url, options); - }; - }); - await page.setViewport({ width: 1280, height: 800 }); - const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); - async function ready(kind) { - await page.waitForFunction( - (kind) => - window.__godsEyeView?.engine?.kind === kind && - document.querySelector('#globe-engine')?.value === kind && - !document.querySelector('#globe-engine').disabled && - document - .getElementById('loading-screen') - ?.classList.contains('hidden'), - { timeout: 90000 }, - kind, - ); - await page.evaluate(async () => { - await window.__godsEyeView.styleManager.initialRestorePromise; - if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset) - throw new Error('Keyless preview required'); - }); - assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind); - } - async function verifyQuakeSurface(kind) { - const meanRed = async (enabled) => { - await page.evaluate(async (enabled) => { - const a = window.__godsEyeView; - await a.dataManager.setEnabled('earthquakes', enabled, { - origin: 'user', - }); - a.engine.render.request('qa-quake-surface'); - }, enabled); - if (enabled) - await page.waitForFunction( - () => - window.__godsEyeView.dataManager.layers - .get('earthquakes') - .module.getAnalystRecords().length === 1, - ); - await new Promise((resolve) => setTimeout(resolve, 1800)); - const bytes = await page.screenshot({ - clip: { x: 675, y: 403, width: 20, height: 20 }, - }); - const { channels } = await sharp(bytes).stats(); - return channels[0].mean - channels[1].mean; - }; - const off = await meanRed(false), - on = await meanRed(true); - assert.ok( - on > off + 8, - kind + - ' must draw the shallow red earthquake footprint: ' + - off + - ' -> ' + - on, - ); - await strict.passed(page, 'earthquakes', 'shallow red footprint drawn', { - off, - on, - }); - console.log( - 'PASS', - kind, - 'earthquake footprint red minus green:', - off, - '->', - on, - ); - } - const stored = () => - page.evaluate(() => localStorage.getItem('gev.engine')); - async function switchTo(kind) { +await strict.run({}, async (browser) => { + const page = await browser.newPage(); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + const fetch = window.fetch.bind(window); + // Two Boston vessels for the Contacts session; empty air and site feeds. + const fixtures = { + '/api/vessels': () => ({ + status: 'live', + rows: ['123456789', '223456789'].map((mmsi, i) => ({ + mmsi, + name: 'Boston ' + i, + lat: 42.3554, + lon: -71.0605 + i * 0.05, + last_position_epoch: Date.now() / 1000, + })), + }), + '/api/vessels/track': () => ({ samples: [] }), + '/api/flights': () => ({ time: Date.now() / 1000, states: [] }), + '/api/military': () => ({ now: Date.now(), ac: [] }), + '/api/military-installations': () => ({ elements: [], status: 'ok' }), + }; + window.fetch = (url, options) => { if ( - await page.$eval( - '#control-panel-toggle', - (el) => el.getAttribute('aria-expanded') !== 'true', - ) - ) { - await page.focus('#control-panel-toggle'); - await page.keyboard.press('Enter'); - } - await page.waitForSelector('#globe-engine', { visible: true }); - await page.evaluate(() => { - window.__switchDocument = document; - window.__quakeEntry = - window.__godsEyeView.dataManager.layers.get('earthquakes'); - window.__quakeTimer = window.__quakeEntry.intervalId; - window.__renderers ??= new Map(); - window.__renderers.set( - window.__godsEyeView.engine.kind, - window.__godsEyeView.engine, - ); - window.__switchSplash = false; - window.__switchObserver = new MutationObserver(() => { - if (!document.querySelector('#loading-screen.hidden')) - window.__switchSplash = true; - }); - window.__switchObserver.observe( - document.querySelector('#loading-screen'), - { attributes: true }, - ); - window.__outgoingEngine = window.__godsEyeView.engine; - }); - await strict.pick(page, kind); - await ready(kind); - assert.ok( - await page.evaluate(() => window.__switchDocument === document), - ); - assert.ok( - await page.evaluate(() => { - window.__switchObserver.disconnect(); - const { engine } = window.__godsEyeView; - const previous = window.__renderers.get(engine.kind); - return ( - !window.__switchSplash && - window.__quakeEntry === - window.__godsEyeView.dataManager.layers.get('earthquakes') && - window.__quakeTimer === window.__quakeEntry.intervalId && - (!previous || previous === engine) && - !window.__outgoingEngine.isDestroyed() && - !window.__outgoingEngine.host.canvas.isConnected && - engine.host.canvas.isConnected && - window.__godsEyeView.dataManager - .getEnabledLayerIds() - .has('earthquakes') - ); - }), - ); - await strict.passed( - page, - 'earthquakes', - 'same layer entry, model and poll timer; no splash; outgoing canvas detached', - ); - await page.waitForFunction( - () => !document.querySelector('.engine-switch-frame'), - ); - assert.equal( - await page.evaluate(async () => { - const parked = window.__outgoingEngine; - let frames = 0; - const remove = parked.frames.postRender.addEventListener(() => { - frames++; - }); - parked.render.request('qa-parked'); - await new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ); - remove(); - return frames; - }), - 0, - ); - const url = new URL(page.url()); - assert.equal(url.searchParams.get('engine'), kind); - assert.equal(url.searchParams.get('look'), 'cinematic'); - const hash = new URLSearchParams(url.hash.slice(1)); - assert.equal(hash.get('map'), 'osm'); - assert.ok(Math.abs(Number(hash.get('lat')) - 42.3554) < 0.01); - assert.ok(Math.abs(Number(hash.get('lon')) + 71.0605) < 0.01); - assert.ok(Math.abs(Number(hash.get('alt')) - 6000) < 30); - const view = await page.evaluate(() => { - const { engine, mapStackController } = window.__godsEyeView; - const { latitude, longitude, height } = - engine.camera.pose().cartographic; - return { - latitude: latitude * (180 / Math.PI), - longitude: longitude * (180 / Math.PI), - height, - map: mapStackController.getActiveId(), - }; - }); - assert.equal(view.map, 'osm'); - assert.ok(Math.abs(view.latitude - 42.3554) < 0.01); - assert.ok(Math.abs(view.longitude + 71.0605) < 0.01); - assert.ok(Math.abs(view.height - 6000) < 30); - await strict.passed( - page, - 'earthquakes', - 'parked engine draws no frame; pose, map and layers carried over', - ); - } - await page.goto(base.href, { waitUntil: 'domcontentloaded' }); - await ready('cesium'); - await verifyQuakeSurface('cesium'); - const sharpen = () => - page.evaluate( - () => window.__godsEyeView.styleManager.getControlState().sharpen, - ); - await page.evaluate(() => { - const style = window.__godsEyeView.styleManager; - style.setStyle('retro'); - style.setStyle('normal'); - }); - assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 49 }); - await switchTo('navara'); - await verifyQuakeSurface('navara'); - assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 80 }); - assert.ok( - await page.evaluate( - () => - Math.abs(window.__godsEyeView.engine.render.sharpen().amount - 1.7) < - 1e-6, - ), - ); - assert.equal(await stored(), 'navara'); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await ready('cesium'); - assert.equal((await sharpen()).intensityPct, 49); - await page.goForward(); - await ready('navara'); - assert.equal((await sharpen()).intensityPct, 80); - await page.evaluate(() => - window.__godsEyeView.styleManager.setSharpen({ - enabled: false, - intensityPct: 49, - }), - ); - await switchTo('cesium'); - await switchTo('navara'); - assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 }); - // The address carries the running layer set once its debounced write lands. - const settledAddress = async () => { - let previous; - for (;;) { - await new Promise((resolve) => setTimeout(resolve, 1200)); - const current = await page.evaluate(() => - new URLSearchParams(location.hash.slice(1)).get('l'), - ); - if (current === previous) return current; - previous = current; - } - }; - const pick = async (kind) => { - await strict.pick(page, kind); - await ready(kind); - await page.waitForFunction( - () => !document.querySelector('.engine-switch-frame'), - ); - }; - const visit = async (direction, kind) => { - await (direction === 'back' - ? page.goBack({ waitUntil: 'domcontentloaded' }) - : page.goForward()); - await ready(kind); - await page.waitForFunction( - () => !document.querySelector('.engine-switch-frame'), - ); - }; - const quakes = () => - page.evaluate(() => { - const { dataManager } = window.__godsEyeView; - return { - model: window.__quakeModel === dataManager.layers.get('earthquakes'), - enabled: dataManager.getEnabledLayerIds().has('earthquakes'), - }; - }); - // Like a same-engine Back, each entry brings back its own layer set, on - // the same layer models. - await page.evaluate(() => { - window.__quakeModel = - window.__godsEyeView.dataManager.layers.get('earthquakes'); - }); - const navaraLayers = await settledAddress(); - await pick('cesium'); - await page.evaluate(() => - window.__godsEyeView.dataManager.setEnabled('earthquakes', false, { - origin: 'user', - }), - ); - const cesiumLayers = await settledAddress(); - assert.notEqual(cesiumLayers, navaraLayers); - await visit('back', 'navara'); - assert.deepEqual(await quakes(), { model: true, enabled: true }); - await strict.passed( - page, - 'earthquakes', - 'Back restores the enabled layer on the same model', - ); - assert.equal(await settledAddress(), navaraLayers, 'Back keeps its entry'); - await visit('forward', 'cesium'); - assert.deepEqual(await quakes(), { model: true, enabled: false }); - await strict.passed( - page, - 'earthquakes', - 'Forward restores the disabled layer on the same model', - ); - assert.equal( - await settledAddress(), - cesiumLayers, - 'Forward keeps its entry', - ); - await page.evaluate(() => - window.__godsEyeView.dataManager.setEnabled('earthquakes', true, { - origin: 'user', - }), - ); - await pick('navara'); - console.log( - "PASS Back/Forward restore each entry's layers on the same models", - ); - // Back/Forward hand the session off like the picker: the Contacts subject - // and its navigation history, its layers and the Director survive. - await page.evaluate(async () => { - const { dataManager, styleManager, sceneDirector } = window.__godsEyeView; - await dataManager.setEnabled('ais-live-vessels', true, { - origin: 'user', - }); - const outcome = await styleManager.setContextMode('contacts'); - if (!outcome.ok) throw Error(JSON.stringify(outcome)); - window.qaSession = { - director: sceneDirector, - context: dataManager.layers.get('military-awareness').module, - vessels: dataManager.layers.get('ais-live-vessels'), - }; - if (!window.qaSession.vessels.module.selectById('123456789')) - throw Error('Missing first vessel'); - if ( - !window.qaSession.context.navigateNext({ - targetLayer: 'ais-live-vessels', - }) + String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/') ) - throw Error('Cannot navigate to the second vessel'); + return Promise.resolve( + Response.json({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'qa-boston', + geometry: { + type: 'Point', + coordinates: [-71.0605, 42.3554, 8], + }, + properties: { mag: 4, time: Date.now(), place: 'QA Boston' }, + }, + ], + }), + ); + const fixture = + fixtures[new URL(url.url ?? url, document.baseURI).pathname]; + return fixture + ? Promise.resolve(Response.json(fixture())) + : fetch(url, options); + }; + }); + await page.setViewport({ width: 1280, height: 800 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + async function ready(kind) { + await page.waitForFunction( + (kind) => + window.__godsEyeView?.engine?.kind === kind && + document.querySelector('#globe-engine')?.value === kind && + !document.querySelector('#globe-engine').disabled && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 90000 }, + kind, + ); + await page.evaluate(async () => { + await window.__godsEyeView.styleManager.initialRestorePromise; + if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset) + throw new Error('Keyless preview required'); }); - const session = () => - page.evaluate(() => { - const { dataManager, styleManager, sceneDirector } = - window.__godsEyeView; - const { director, context, vessels } = window.qaSession; - return { - director: director === sceneDirector && !director._destroyed, - owners: - context === dataManager.layers.get('military-awareness').module && - vessels === dataManager.layers.get('ais-live-vessels'), - mode: styleManager.getContextModeState().mode, - subject: context.getContextSnapshot()?.subject?.id, - history: context._getAwarenessNavigationStateForTest().historyKeys, - enabled: [...dataManager.getEnabledLayerIds()].sort(), - }; - }); - const live = await session(); - assert.equal(live.mode, 'flights'); - assert.equal(live.subject, '223456789'); - assert.ok(live.history.includes('ais-live-vessels:123456789')); - await settledAddress(); - const historyLength = await page.evaluate(() => history.length); - await pick('cesium'); - assert.deepEqual(await session(), live, 'a pick keeps the session'); - await settledAddress(); - await visit('back', 'navara'); - assert.deepEqual(await session(), live, 'Back keeps the session'); - await visit('forward', 'cesium'); - assert.deepEqual(await session(), live, 'Forward keeps the session'); - assert.equal(await page.evaluate(() => history.length), historyLength + 1); - assert.ok( - await page.evaluate(async () => { - const { dataManager, styleManager } = window.__godsEyeView; - const result = await styleManager.setContextMode('off'); - await dataManager.setEnabled('ais-live-vessels', false, { + assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind); + } + async function verifyQuakeSurface(kind) { + const meanRed = async (enabled) => { + await page.evaluate(async (enabled) => { + const a = window.__godsEyeView; + await a.dataManager.setEnabled('earthquakes', enabled, { origin: 'user', }); - return result.ok; - }), - ); - await pick('navara'); - console.log( - 'PASS Back/Forward keep Contacts subject, history, layers and Director', - ); - await page.goto(base.href, { waitUntil: 'domcontentloaded' }); - await ready('navara'); - await switchTo('cesium'); - assert.equal(await stored(), 'cesium'); - await page.evaluate(() => { - const write = Storage.prototype.setItem; - Storage.prototype.setItem = function (key, value) { - if (key === 'gev.engine') - throw new DOMException('Blocked', 'SecurityError'); - return write.call(this, key, value); - }; - }); - await switchTo('navara'); - assert.equal(await stored(), 'cesium'); - // The real Map Source chips move into daylight inspection and back to live lighting. - await page.$eval('[data-stack-id="sentinel2"]', (el) => el.click()); - await page.waitForFunction( - () => - window.__godsEyeView.mapStackController.getActiveId() === 'sentinel2' && - window.__godsEyeView.engine.host.container.hasAttribute( - 'data-terrain-daylight', - ), - ); - // Zooming out on the satellite map must keep the same inspection sun. + a.engine.render.request('qa-quake-surface'); + }, enabled); + if (enabled) + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('earthquakes') + .module.getAnalystRecords().length === 1, + ); + await new Promise((resolve) => setTimeout(resolve, 1800)); + const bytes = await page.screenshot({ + clip: { x: 675, y: 403, width: 20, height: 20 }, + }); + const { channels } = await sharp(bytes).stats(); + return channels[0].mean - channels[1].mean; + }; + const off = await meanRed(false), + on = await meanRed(true); assert.ok( - await page.evaluate(async () => { + on > off + 8, + kind + + ' must draw the shallow red earthquake footprint: ' + + off + + ' -> ' + + on, + ); + await strict.passed(page, 'earthquakes', 'shallow red footprint drawn', { + off, + on, + }); + console.log( + 'PASS', + kind, + 'earthquake footprint red minus green:', + off, + '->', + on, + ); + } + const stored = () => page.evaluate(() => localStorage.getItem('gev.engine')); + async function switchTo(kind) { + if ( + await page.$eval( + '#control-panel-toggle', + (el) => el.getAttribute('aria-expanded') !== 'true', + ) + ) { + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + } + await page.waitForSelector('#globe-engine', { visible: true }); + await page.evaluate(() => { + window.__switchDocument = document; + window.__quakeEntry = + window.__godsEyeView.dataManager.layers.get('earthquakes'); + window.__quakeTimer = window.__quakeEntry.intervalId; + window.__renderers ??= new Map(); + window.__renderers.set( + window.__godsEyeView.engine.kind, + window.__godsEyeView.engine, + ); + window.__switchSplash = false; + window.__switchObserver = new MutationObserver(() => { + if (!document.querySelector('#loading-screen.hidden')) + window.__switchSplash = true; + }); + window.__switchObserver.observe( + document.querySelector('#loading-screen'), + { attributes: true }, + ); + window.__outgoingEngine = window.__godsEyeView.engine; + }); + await strict.pick(page, kind); + await ready(kind); + assert.ok(await page.evaluate(() => window.__switchDocument === document)); + assert.ok( + await page.evaluate(() => { + window.__switchObserver.disconnect(); const { engine } = window.__godsEyeView; - engine.camera.zoomOut(2_000_000); - await new Promise((resolve) => { - const remove = engine.frames.postRender.addEventListener(() => { - remove(); - resolve(); - }); - engine.render.request('qa-orbital-daylight'); - }); + const previous = window.__renderers.get(engine.kind); return ( - engine.camera.height() > 1_500_000 && - engine.host.container.hasAttribute('data-terrain-daylight') + !window.__switchSplash && + window.__quakeEntry === + window.__godsEyeView.dataManager.layers.get('earthquakes') && + window.__quakeTimer === window.__quakeEntry.intervalId && + (!previous || previous === engine) && + !window.__outgoingEngine.isDestroyed() && + !window.__outgoingEngine.host.canvas.isConnected && + engine.host.canvas.isConnected && + window.__godsEyeView.dataManager + .getEnabledLayerIds() + .has('earthquakes') ); }), - 'the orbital frame keeps daylight on the inspected globe', ); - await page.$eval('[data-stack-id="osm"]', (el) => el.click()); + await strict.passed( + page, + 'earthquakes', + 'same layer entry, model and poll timer; no splash; outgoing canvas detached', + ); await page.waitForFunction( + () => !document.querySelector('.engine-switch-frame'), + ); + assert.equal( + await page.evaluate(async () => { + const parked = window.__outgoingEngine; + let frames = 0; + const remove = parked.frames.postRender.addEventListener(() => { + frames++; + }); + parked.render.request('qa-parked'); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + remove(); + return frames; + }), + 0, + ); + const url = new URL(page.url()); + assert.equal(url.searchParams.get('engine'), kind); + assert.equal(url.searchParams.get('look'), 'cinematic'); + const hash = new URLSearchParams(url.hash.slice(1)); + assert.equal(hash.get('map'), 'osm'); + assert.ok(Math.abs(Number(hash.get('lat')) - 42.3554) < 0.01); + assert.ok(Math.abs(Number(hash.get('lon')) + 71.0605) < 0.01); + assert.ok(Math.abs(Number(hash.get('alt')) - 6000) < 30); + const view = await page.evaluate(() => { + const { engine, mapStackController } = window.__godsEyeView; + const { latitude, longitude, height } = engine.camera.pose().cartographic; + return { + latitude: latitude * (180 / Math.PI), + longitude: longitude * (180 / Math.PI), + height, + map: mapStackController.getActiveId(), + }; + }); + assert.equal(view.map, 'osm'); + assert.ok(Math.abs(view.latitude - 42.3554) < 0.01); + assert.ok(Math.abs(view.longitude + 71.0605) < 0.01); + assert.ok(Math.abs(view.height - 6000) < 30); + await strict.passed( + page, + 'earthquakes', + 'parked engine draws no frame; pose, map and layers carried over', + ); + } + await page.goto(base.href, { waitUntil: 'domcontentloaded' }); + await ready('cesium'); + await verifyQuakeSurface('cesium'); + const sharpen = () => + page.evaluate( + () => window.__godsEyeView.styleManager.getControlState().sharpen, + ); + await page.evaluate(() => { + const style = window.__godsEyeView.styleManager; + style.setStyle('retro'); + style.setStyle('normal'); + }); + assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 49 }); + await switchTo('navara'); + await verifyQuakeSurface('navara'); + assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 80 }); + assert.ok( + await page.evaluate( () => - window.__godsEyeView.mapStackController.getActiveId() === 'osm' && - !window.__godsEyeView.engine.host.container.hasAttribute( - 'data-terrain-daylight', - ), + Math.abs(window.__godsEyeView.engine.render.sharpen().amount - 1.7) < + 1e-6, + ), + ); + assert.equal(await stored(), 'navara'); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await ready('cesium'); + assert.equal((await sharpen()).intensityPct, 49); + await page.goForward(); + await ready('navara'); + assert.equal((await sharpen()).intensityPct, 80); + await page.evaluate(() => + window.__godsEyeView.styleManager.setSharpen({ + enabled: false, + intensityPct: 49, + }), + ); + await switchTo('cesium'); + await switchTo('navara'); + assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 }); + // The address carries the running layer set once its debounced write lands. + const settledAddress = async () => { + let previous; + for (;;) { + await new Promise((resolve) => setTimeout(resolve, 1200)); + const current = await page.evaluate(() => + new URLSearchParams(location.hash.slice(1)).get('l'), + ); + if (current === previous) return current; + previous = current; + } + }; + const pick = async (kind) => { + await strict.pick(page, kind); + await ready(kind); + await page.waitForFunction( + () => !document.querySelector('.engine-switch-frame'), ); - assert.deepEqual(errors, []); - console.log( - 'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage', + }; + const visit = async (direction, kind) => { + await (direction === 'back' + ? page.goBack({ waitUntil: 'domcontentloaded' }) + : page.goForward()); + await ready(kind); + await page.waitForFunction( + () => !document.querySelector('.engine-switch-frame'), ); - }, -); + }; + const quakes = () => + page.evaluate(() => { + const { dataManager } = window.__godsEyeView; + return { + model: window.__quakeModel === dataManager.layers.get('earthquakes'), + enabled: dataManager.getEnabledLayerIds().has('earthquakes'), + }; + }); + // Like a same-engine Back, each entry brings back its own layer set, on + // the same layer models. + await page.evaluate(() => { + window.__quakeModel = + window.__godsEyeView.dataManager.layers.get('earthquakes'); + }); + const navaraLayers = await settledAddress(); + await pick('cesium'); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('earthquakes', false, { + origin: 'user', + }), + ); + const cesiumLayers = await settledAddress(); + assert.notEqual(cesiumLayers, navaraLayers); + await visit('back', 'navara'); + assert.deepEqual(await quakes(), { model: true, enabled: true }); + await strict.passed( + page, + 'earthquakes', + 'Back restores the enabled layer on the same model', + ); + assert.equal(await settledAddress(), navaraLayers, 'Back keeps its entry'); + await visit('forward', 'cesium'); + assert.deepEqual(await quakes(), { model: true, enabled: false }); + await strict.passed( + page, + 'earthquakes', + 'Forward restores the disabled layer on the same model', + ); + assert.equal(await settledAddress(), cesiumLayers, 'Forward keeps its entry'); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('earthquakes', true, { + origin: 'user', + }), + ); + await pick('navara'); + console.log( + "PASS Back/Forward restore each entry's layers on the same models", + ); + // Back/Forward hand the session off like the picker: the Contacts subject + // and its navigation history, its layers and the Director survive. + await page.evaluate(async () => { + const { dataManager, styleManager, sceneDirector } = window.__godsEyeView; + await dataManager.setEnabled('ais-live-vessels', true, { + origin: 'user', + }); + const outcome = await styleManager.setContextMode('contacts'); + if (!outcome.ok) throw Error(JSON.stringify(outcome)); + window.qaSession = { + director: sceneDirector, + context: dataManager.layers.get('military-awareness').module, + vessels: dataManager.layers.get('ais-live-vessels'), + }; + if (!window.qaSession.vessels.module.selectById('123456789')) + throw Error('Missing first vessel'); + if ( + !window.qaSession.context.navigateNext({ + targetLayer: 'ais-live-vessels', + }) + ) + throw Error('Cannot navigate to the second vessel'); + }); + const session = () => + page.evaluate(() => { + const { dataManager, styleManager, sceneDirector } = window.__godsEyeView; + const { director, context, vessels } = window.qaSession; + return { + director: director === sceneDirector && !director._destroyed, + owners: + context === dataManager.layers.get('military-awareness').module && + vessels === dataManager.layers.get('ais-live-vessels'), + mode: styleManager.getContextModeState().mode, + subject: context.getContextSnapshot()?.subject?.id, + history: context._getAwarenessNavigationStateForTest().historyKeys, + enabled: [...dataManager.getEnabledLayerIds()].sort(), + }; + }); + const live = await session(); + assert.equal(live.mode, 'flights'); + assert.equal(live.subject, '223456789'); + assert.ok(live.history.includes('ais-live-vessels:123456789')); + await settledAddress(); + const historyLength = await page.evaluate(() => history.length); + await pick('cesium'); + assert.deepEqual(await session(), live, 'a pick keeps the session'); + await settledAddress(); + await visit('back', 'navara'); + assert.deepEqual(await session(), live, 'Back keeps the session'); + await visit('forward', 'cesium'); + assert.deepEqual(await session(), live, 'Forward keeps the session'); + assert.equal(await page.evaluate(() => history.length), historyLength + 1); + assert.ok( + await page.evaluate(async () => { + const { dataManager, styleManager } = window.__godsEyeView; + const result = await styleManager.setContextMode('off'); + await dataManager.setEnabled('ais-live-vessels', false, { + origin: 'user', + }); + return result.ok; + }), + ); + await pick('navara'); + console.log( + 'PASS Back/Forward keep Contacts subject, history, layers and Director', + ); + await page.goto(base.href, { waitUntil: 'domcontentloaded' }); + await ready('navara'); + await switchTo('cesium'); + assert.equal(await stored(), 'cesium'); + await page.evaluate(() => { + const write = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value) { + if (key === 'gev.engine') + throw new DOMException('Blocked', 'SecurityError'); + return write.call(this, key, value); + }; + }); + await switchTo('navara'); + assert.equal(await stored(), 'cesium'); + // The real Map Source chips move into daylight inspection and back to live lighting. + await page.$eval('[data-stack-id="sentinel2"]', (el) => el.click()); + await page.waitForFunction( + () => + window.__godsEyeView.mapStackController.getActiveId() === 'sentinel2' && + window.__godsEyeView.engine.host.container.hasAttribute( + 'data-terrain-daylight', + ), + ); + // Zooming out on the satellite map must keep the same inspection sun. + assert.ok( + await page.evaluate(async () => { + const { engine } = window.__godsEyeView; + engine.camera.zoomOut(2_000_000); + await new Promise((resolve) => { + const remove = engine.frames.postRender.addEventListener(() => { + remove(); + resolve(); + }); + engine.render.request('qa-orbital-daylight'); + }); + return ( + engine.camera.height() > 1_500_000 && + engine.host.container.hasAttribute('data-terrain-daylight') + ); + }), + 'the orbital frame keeps daylight on the inspected globe', + ); + await page.$eval('[data-stack-id="osm"]', (el) => el.click()); + await page.waitForFunction( + () => + window.__godsEyeView.mapStackController.getActiveId() === 'osm' && + !window.__godsEyeView.engine.host.container.hasAttribute( + 'data-terrain-daylight', + ), + ); + assert.deepEqual(errors, []); + console.log( + 'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage', + ); +}); diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs index 3c8c62a88..6d3f74c1d 100644 --- a/scripts/qa-enrich-ambient.mjs +++ b/scripts/qa-enrich-ambient.mjs @@ -67,7 +67,7 @@ const getOpt = (name, dflt) => { }; const BASE_URL = getOpt('--url', 'http://localhost:4173'); -// --engine=cesium|navara (phase 3): Navara reads the layer through the QA +// --engine=cesium|navara: Navara reads the layer through the QA // probe (window.__godsEyeView.layers.drawn) and moves the engine camera. const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9); const APP_URL = diff --git a/scripts/qa-fire-camera-switch.mjs b/scripts/qa-fire-camera-switch.mjs index 75b635f98..c3f6d98c5 100644 --- a/scripts/qa-fire-camera-switch.mjs +++ b/scripts/qa-fire-camera-switch.mjs @@ -21,7 +21,7 @@ const strict = createStrict({ // installAlprFixture answers the camera tiles. fixtures: ['https://tiles.dontgetflocked.com/*'], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { const p = await b.newPage(); await p.setViewport({ width: 1280, height: 900 }); const errors = []; diff --git a/scripts/qa-floor-verify.mjs b/scripts/qa-floor-verify.mjs index ebb75303b..0dc8b6379 100644 --- a/scripts/qa-floor-verify.mjs +++ b/scripts/qa-floor-verify.mjs @@ -11,7 +11,7 @@ import fs from 'node:fs'; // QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so a // secondary checkout can verify against its own dev server instead of the default :4173. const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; -// --engine=cesium|navara (phase 3): on Navara the camera, the mesh probes and +// --engine=cesium|navara: on Navara the camera, the mesh probes and // the sprite census go through the engine and the QA probe. const ENGINE = (process.argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9); const APP_URL = @@ -125,7 +125,7 @@ const report = await page.evaluate(async () => { drawnBy: modelAt ? 'model' : 'sprite', meshM: meshH != null ? +meshH.toFixed(1) : null, aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null, - // Why a contact sits where it does (10b review 6): the probe's own + // Why a contact sits where it does: the probe's own // answer (source, tile size) and the coarse DEM floor (the prior). probe: answer ? { source: answer.source, tileSizeM: answer.tileSizeM ?? null } : null, demFloorM: cachedGroundFloor(c.latitude * 180 / Math.PI, c.longitude * 180 / Math.PI) ?? null, diff --git a/scripts/qa-ghostmaps-switch.mjs b/scripts/qa-ghostmaps-switch.mjs index d5633ffbe..105770799 100644 --- a/scripts/qa-ghostmaps-switch.mjs +++ b/scripts/qa-ghostmaps-switch.mjs @@ -1,21 +1,20 @@ import { fromDegrees } from '../src/geo/cartesian3.js'; import { readFile } from 'node:fs/promises'; import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; base.hash = 'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; const strict = createStrict({ origin: base.origin, layers: ['s2-ghostmaps'] }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); const file = await readFile( new URL('./fixtures/ghostmaps-scenes.kmz', import.meta.url), ); @@ -63,21 +62,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { [...file], ); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { dataManager } = window.__godsEyeView; await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' }); @@ -111,8 +96,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { await p.evaluate(() => { window.ghostStats.titles = []; }); - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); await p.waitForFunction(() => window.ghostStats.titles.some((v) => /QA zone/i.test(v)), ); diff --git a/scripts/qa-ground-layer-switch.mjs b/scripts/qa-ground-layer-switch.mjs index 96093706b..0f792a9c0 100644 --- a/scripts/qa-ground-layer-switch.mjs +++ b/scripts/qa-ground-layer-switch.mjs @@ -1,7 +1,12 @@ import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; @@ -16,14 +21,8 @@ const strict = createStrict({ 'fire-perimeters', ], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); await p.evaluateOnNewDocument(() => { const fetch = window.fetch.bind(window); window.fetch = (input, o) => { @@ -122,20 +121,6 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }; }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; const capture = async (visible, layerId = 'air-quality') => { await p.evaluate( async (show, id) => { @@ -230,7 +215,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { } return on; }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(() => { const d = window.__godsEyeView; if (d.tileset || window.__GOOGLE_MAPS_API_KEY__) @@ -240,8 +225,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }); for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); const same = await p.evaluate( () => window.__godsEyeView.dataManager.layers.get('air-quality').module === @@ -357,8 +341,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }; for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); await card(true); await strict.check( p, @@ -478,8 +461,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { let histories = await p.evaluate(() => window.qaVesselHistories); for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); } const sample = await p.evaluate(() => { const entry = diff --git a/scripts/qa-infrastructure-engine-switch.mjs b/scripts/qa-infrastructure-engine-switch.mjs index e29f08d65..7d47def14 100644 --- a/scripts/qa-infrastructure-engine-switch.mjs +++ b/scripts/qa-infrastructure-engine-switch.mjs @@ -1,7 +1,12 @@ import { fromDegrees } from '../src/geo/cartesian3.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; @@ -26,14 +31,8 @@ const strict = createStrict({ origin: base.origin, layers: ['local-dams', 'local-datacenters'], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); const reads = { datacenters: 0, dams: 0 }; p.on('request', (req) => { const path = new URL(req.url()).pathname; @@ -52,21 +51,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }); }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); const ids = ['local-datacenters', 'local-dams']; const dataset = await p.evaluate(async (ids) => { const { dataManager, tileset } = window.__godsEyeView; @@ -184,8 +169,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { await p.evaluate(() => { window.qaSelections = []; }); - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); await p.waitForFunction( () => window.qaSelections.includes('local-datacenters:342664739'), { timeout: 15000 }, diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 140ce7250..84a51b470 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -314,8 +314,7 @@ async function main() { (ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)), fieldLayers, ); - // An engine with flights but not the other field layers (Navara, W1 so - // far): the same 12,000 / 5,200 observations, all as aircraft, so the + // An engine with flights but not the other field layers: the same 12,000 / 5,200 observations, all as aircraft, so the // detection lane is measured on the layers the engine has. let fieldCounts = FIELD_COUNTS; let normalCounts = NORMAL_COUNTS; diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index 865a1ee8a..e217b69a1 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -15,514 +15,503 @@ url.hash = // left. const HIDDEN_PICKER = 'the Globe Engine picker is hidden in cockpit mode'; const strict = createStrict({ origin: url.origin, layers: [layerId] }); -await strict.run( - { args: ['--use-angle=metal', '--enable-gpu'] }, - async (browser) => { - const page = await browser.newPage(); - const errors = []; - page.on('pageerror', (e) => errors.push(e.message)); - await page.setViewport({ width: 1280, height: 720 }); - await page.evaluateOnNewDocument( - (layerId, aircraftId) => { - window.__qaAircraft = { layerId, aircraftId }; - const fetch = window.fetch.bind(window); - window.__flightPolls = 0; - const interval = window.setInterval.bind(window); - window.setInterval = function (callback, delay, ...args) { - if (typeof callback !== 'function') - return interval(callback, delay, ...args); - const id = interval( - function (...values) { - if (window.__handoff?.timer === id) window.__handoff.ticks++; - return callback.apply(this, values); - }, - delay, - ...args, - ); - return id; - }; - window.fetch = (input, options) => { - const path = new URL(input.url ?? input, document.baseURI).pathname; - const now = Math.floor(Date.now() / 1000); - if (path === '/api/flights') { - if (layerId === 'flights') window.__flightPolls++; - return Promise.resolve( - Response.json({ - time: now, - states: [ - [ - '71be26', - 'GEV123', - 'QA', - now, - now, - -125.5779, - 40.2539, - 9700, - false, - 200, - 45, - 0, - null, - 9700, - null, - false, - 0, - ], - ], - }), - ); - } - if (path === '/api/military') { - if (layerId === 'military') window.__flightPolls++; - return Promise.resolve( - Response.json({ - now: Date.now(), - ac: [ - { - hex: 'aaa052', - flight: 'GEVMIL', - lon: -125.5779, - lat: 40.2539, - alt_geom: 31825, - alt_baro: 31825, - gs: 389, - track: 45, - seen: 0, - seen_pos: 0, - dbFlags: 1, - type: 'adsb_icao', - }, - ], - }), - ); - } - if (path === '/api/flights/track') - return Promise.resolve(Response.json({ path: [] })); - if (path.startsWith('/api/flights/')) - return Promise.resolve(Response.json({})); - return fetch(input, options); - }; - }, - layerId, - aircraftId, - ); - await page.goto(url.href, { waitUntil: 'domcontentloaded' }); - const ready = (kind) => - page.waitForFunction( - (engine) => - window.__godsEyeView?.engine?.kind === engine && - !document.querySelector('#globe-engine')?.disabled && - document.querySelector('#loading-screen.hidden') && - !document.querySelector('.engine-switch-frame'), - { timeout: 90000 }, - kind, - ); - await ready('navara'); - await page.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - await page.waitForSelector('#cockpit-entry', { visible: true }); - await page.click('#cockpit-entry'); - await page.waitForFunction( - () => window.__godsEyeView.styleManager.getCockpitState().active, - ); - await page.evaluate(() => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; - if (window.__GOOGLE_MAPS_API_KEY__ || tileset) - throw Error('Keyless preview required'); - styleManager.cockpitView.setVisionMode('thermal'); - const entry = dataManager.layers.get(window.__qaAircraft.layerId); - window.__handoff = { - dataManager, - entry, - row: document.querySelector( - `[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`, - ), - timer: entry.intervalId, - ticks: 0, - model: entry.module, - pollCount: window.__flightPolls, - timestamp: entry.module.getStats().lastUpdate, +await strict.run({}, async (browser) => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 1280, height: 720 }); + await page.evaluateOnNewDocument( + (layerId, aircraftId) => { + window.__qaAircraft = { layerId, aircraftId }; + const fetch = window.fetch.bind(window); + window.__flightPolls = 0; + const interval = window.setInterval.bind(window); + window.setInterval = function (callback, delay, ...args) { + if (typeof callback !== 'function') + return interval(callback, delay, ...args); + const id = interval( + function (...values) { + if (window.__handoff?.timer === id) window.__handoff.ticks++; + return callback.apply(this, values); + }, + delay, + ...args, + ); + return id; }; - }); - // Refuse the first Cesium canvas. The application must roll back with the - // original aircraft still in the cockpit, then allow an ordinary retry. - await page.evaluate(() => { - const original = HTMLCanvasElement.prototype.getContext; - HTMLCanvasElement.prototype.getContext = function (type, ...args) { - if (String(type).includes('webgl')) { - window.__refusedContext = true; - throw new Error('QA renderer startup failure'); + window.fetch = (input, options) => { + const path = new URL(input.url ?? input, document.baseURI).pathname; + const now = Math.floor(Date.now() / 1000); + if (path === '/api/flights') { + if (layerId === 'flights') window.__flightPolls++; + return Promise.resolve( + Response.json({ + time: now, + states: [ + [ + '71be26', + 'GEV123', + 'QA', + now, + now, + -125.5779, + 40.2539, + 9700, + false, + 200, + 45, + 0, + null, + 9700, + null, + false, + 0, + ], + ], + }), + ); } - return original.call(this, type, ...args); - }; - window.__restoreContext = () => { - HTMLCanvasElement.prototype.getContext = original; - }; - }); - const historyLength = await page.evaluate(() => history.length); - await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); - await page.waitForFunction(() => window.__refusedContext === true); - await ready('navara'); - // A failed switch leaves the session usable on the engine that worked and - // adds no history entry. - const survived = (kind) => - page.evaluate( - (kind, length) => { - const { dataManager, engine, styleManager } = window.__godsEyeView; - return ( - engine.kind === kind && - new URL(location.href).searchParams.get('engine') === kind && - history.length === length && - dataManager === window.__handoff.dataManager && - dataManager.layers.get(window.__qaAircraft.layerId) === - window.__handoff.entry && - styleManager.getCockpitState().active && - styleManager.getCockpitState().visionMode === 'thermal' && - styleManager.getAircraftTrackingTarget()?.id === - window.__qaAircraft.aircraftId + if (path === '/api/military') { + if (layerId === 'military') window.__flightPolls++; + return Promise.resolve( + Response.json({ + now: Date.now(), + ac: [ + { + hex: 'aaa052', + flight: 'GEVMIL', + lon: -125.5779, + lat: 40.2539, + alt_geom: 31825, + alt_baro: 31825, + gs: 389, + track: 45, + seen: 0, + seen_pos: 0, + dbFlags: 1, + type: 'adsb_icao', + }, + ], + }), ); - }, - kind, - historyLength, - ); - await page.evaluate(() => window.__restoreContext()); - await strict.check( - page, - layerId, - 'cold Cesium failure rolls back to Navara: same session, cockpit, aircraft, no history entry', - await survived('navara'), - ); - console.log( - 'PASS cold Cesium failure rolled back to Navara with the same flight, cockpit and history', - ); - - for (const kind of ['cesium', 'navara']) { - await strict.selectEngine(page, kind, HIDDEN_PICKER); - await ready(kind); - const result = await page.evaluate(() => { - const { dataManager, engine, styleManager } = window.__godsEyeView; - const before = window.__handoff; - const entry = dataManager.layers.get(window.__qaAircraft.layerId); - const cockpit = styleManager.getCockpitState(); - return { - sameOwner: - before.dataManager === dataManager && - before.entry === entry && - before.model === entry.module, - sameRow: - Boolean(before.row) && - before.row === - document.querySelector( - `[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`, - ), - sameTimer: - before.timer === entry.intervalId && entry.intervalId != null, - sourcePolls: window.__flightPolls - before.pollCount, - timerTicks: before.ticks, - stamp: entry.module.getStats().lastUpdate, - initialStamp: before.timestamp, - id: cockpit.subject?.id, - active: cockpit.active, - mode: cockpit.visionMode, - hud: !document.querySelector('#cockpit-hud').hidden, - drawn: entry.module - .qaDrawn() - .some((row) => row.id === window.__qaAircraft.aircraftId), - height: engine.camera.height(), - }; - }); - assert.ok( - result.sameOwner && - result.sameTimer && - result.sameRow && - result.active && - result.hud && - result.drawn, - JSON.stringify(result), - ); - assert.equal(result.id, aircraftId); - assert.equal(result.mode, 'thermal'); - assert.ok( - result.sourcePolls <= result.timerTicks, - 'switch must not add a source request', - ); - if (result.timerTicks === 0) { - assert.equal(result.stamp, result.initialStamp); - } - await strict.passed( - page, - layerId, - 'same manager entry, model, row, poll timer, cockpit, thermal mode, HUD and drawn aircraft', - result, - ); - // Polling goes on after the switch: the same timer ticks and each tick - // reads the source once (no tick may be skipped, none added). - const polls = await page.evaluate(async (layerId) => { - const entry = window.__godsEyeView.dataManager.layers.get(layerId); - const every = - Number(entry.module.refreshInterval) || - Number(entry.module.updateInterval); - const start = { - polls: window.__flightPolls, - ticks: window.__handoff.ticks, - stamp: entry.module.getStats().lastUpdate, - }; - const deadline = Date.now() + 2.5 * every + 5000; - while ( - window.__handoff.ticks - start.ticks < 2 && - Date.now() < deadline - ) - await new Promise((resolve) => setTimeout(resolve, 250)); - // Let the last tick's request land. - await new Promise((resolve) => setTimeout(resolve, 1500)); - return { - every, - ticks: window.__handoff.ticks - start.ticks, - polls: window.__flightPolls - start.polls, - updated: entry.module.getStats().lastUpdate !== start.stamp, - sameTimer: entry.intervalId === window.__handoff.timer, - }; - }, layerId); - await strict.check( - page, - layerId, - 'polling continues after the switch: two timer ticks, one source read each, fresh data', - polls.every > 0 && - polls.ticks >= 2 && - polls.polls === polls.ticks && - polls.updated && - polls.sameTimer, - polls, - ); - await page.waitForFunction( - () => - Math.abs(window.__godsEyeView.engine.camera.height() - 9700) < 1000, - ); - console.log( - `PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`, - ); - } - // The other direction, warm: the parked Navara renderer fails to resume on - // the way back from Cesium. The session rolls back to Cesium, then an - // ordinary retry starts Navara cold. - await page.evaluate(() => { - window.__parkedNavara = window.__godsEyeView.engine; - }); - await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); - await ready('cesium'); - const lengthOnCesium = await page.evaluate(() => { - const request = window.__parkedNavara.render.request; - window.__parkedNavara.render.request = function (...args) { - window.__parkedNavara.render.request = request; - window.__refusedResume = true; - throw new Error('QA parked renderer failure'); + } + if (path === '/api/flights/track') + return Promise.resolve(Response.json({ path: [] })); + if (path.startsWith('/api/flights/')) + return Promise.resolve(Response.json({})); + return fetch(input, options); }; - return history.length; - }); - await strict.selectEngine(page, 'navara', HIDDEN_PICKER); - await page.waitForFunction(() => window.__refusedResume === true); - await ready('cesium'); - assert.ok( - (await page.evaluate( - (length) => history.length === length, - lengthOnCesium, - )) && - (await page.evaluate(() => { - const { styleManager } = window.__godsEyeView; - return ( - styleManager.getCockpitState().active && - styleManager.getAircraftTrackingTarget()?.id === - window.__qaAircraft.aircraftId && - new URL(location.href).searchParams.get('engine') === 'cesium' - ); - })), - 'a failed warm Navara resume must leave Cesium usable without a history entry', + }, + layerId, + aircraftId, + ); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + const ready = (kind) => + page.waitForFunction( + (engine) => + window.__godsEyeView?.engine?.kind === engine && + !document.querySelector('#globe-engine')?.disabled && + document.querySelector('#loading-screen.hidden') && + !document.querySelector('.engine-switch-frame'), + { timeout: 90000 }, + kind, ); - await strict.passed( - page, - layerId, - 'warm Navara failure rolls back to Cesium: same aircraft and cockpit, no history entry', - ); - console.log( - 'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit', - ); - await page.evaluate(() => { - window.__parkedCesium = window.__godsEyeView.engine; - }); - await strict.selectEngine(page, 'navara', HIDDEN_PICKER); - await ready('navara'); - assert.ok( - await page.evaluate(() => { - const { engine, styleManager } = window.__godsEyeView; - return ( - engine !== window.__parkedNavara && - styleManager.getCockpitState().active && - styleManager.getAircraftTrackingTarget()?.id === - window.__qaAircraft.aircraftId - ); - }), - 'the retry starts a new Navara renderer on the same session', - ); - console.log('PASS retry after the warm failure started Navara cold'); - - // Both engines fail: the warm Cesium resume and the warm Navara recovery. - // No engine runs, but the error screen offers the engine picker, and a - // retry starts Cesium cold on the same session without a reload. - const lengthBeforeDoubleFailure = await page.evaluate(() => { - window.__failResume = true; - for (const engine of [ - window.__godsEyeView.engine, - window.__parkedCesium, - ]) { - const request = engine.render.request; - engine.render.request = function (reason, ...args) { - if (reason === 'engine-resume' && window.__failResume) { - window.__refusedResumes = (window.__refusedResumes ?? 0) + 1; - throw new Error('QA parked renderer failure'); - } - return request.call(this, reason, ...args); - }; - } - window.__startDocument = document; - return history.length; - }); - await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); - await page - .waitForSelector('#loading-screen .engine-switch-retry button', { - visible: true, - timeout: 90000, - }) - .catch(async (error) => { - console.log( - 'no retry picker', - JSON.stringify( - await page.evaluate(() => ({ - refused: window.__refusedResumes, - engine: window.__godsEyeView?.engine?.kind ?? null, - retry: !!document.querySelector('.engine-switch-retry'), - loading: document.querySelector('#loading-screen')?.className, - status: document.querySelector('#loading-screen .loader-status') - ?.textContent, - address: location.search, - })), - ), - ); - throw error; - }); - assert.ok( - await page.evaluate( - (length) => - window.__refusedResumes === 2 && - // No globe runs (the page's debug object may stay, without one). - !window.__godsEyeView?.engine && - history.length === length && - !document - .querySelector('#loading-screen') - .classList.contains('hidden') && - /QA parked renderer failure/.test( - document.querySelector('#loading-screen .loader-status') - .textContent, - ), - lengthBeforeDoubleFailure, + await ready('navara'); + await page.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + await page.waitForSelector('#cockpit-entry', { visible: true }); + await page.click('#cockpit-entry'); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.getCockpitState().active, + ); + await page.evaluate(() => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + if (window.__GOOGLE_MAPS_API_KEY__ || tileset) + throw Error('Keyless preview required'); + styleManager.cockpitView.setVisionMode('thermal'); + const entry = dataManager.layers.get(window.__qaAircraft.layerId); + window.__handoff = { + dataManager, + entry, + row: document.querySelector( + `[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`, ), - 'a failed recovery shows its error and the engine picker ' + - JSON.stringify( - await page.evaluate(() => ({ - refused: window.__refusedResumes, - app: !!window.__godsEyeView, - engine: window.__godsEyeView?.engine?.kind ?? null, - history: history.length, - loading: document.querySelector('#loading-screen')?.className, - status: document.querySelector('#loading-screen .loader-status') - ?.textContent, - })), - ), - ); - await page.evaluate(() => { - window.__failResume = false; - }); - await page.click( - '#loading-screen .engine-switch-retry [data-engine="cesium"]', - ); - await ready('cesium'); - assert.ok( - await page.evaluate(() => { - const { dataManager, styleManager } = window.__godsEyeView; + timer: entry.intervalId, + ticks: 0, + model: entry.module, + pollCount: window.__flightPolls, + timestamp: entry.module.getStats().lastUpdate, + }; + }); + // Refuse the first Cesium canvas. The application must roll back with the + // original aircraft still in the cockpit, then allow an ordinary retry. + await page.evaluate(() => { + const original = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (type, ...args) { + if (String(type).includes('webgl')) { + window.__refusedContext = true; + throw new Error('QA renderer startup failure'); + } + return original.call(this, type, ...args); + }; + window.__restoreContext = () => { + HTMLCanvasElement.prototype.getContext = original; + }; + }); + const historyLength = await page.evaluate(() => history.length); + await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); + await page.waitForFunction(() => window.__refusedContext === true); + await ready('navara'); + // A failed switch leaves the session usable on the engine that worked and + // adds no history entry. + const survived = (kind) => + page.evaluate( + (kind, length) => { + const { dataManager, engine, styleManager } = window.__godsEyeView; return ( - window.__startDocument === document && - !document.querySelector('.engine-switch-retry') && + engine.kind === kind && + new URL(location.href).searchParams.get('engine') === kind && + history.length === length && dataManager === window.__handoff.dataManager && dataManager.layers.get(window.__qaAircraft.layerId) === window.__handoff.entry && styleManager.getCockpitState().active && + styleManager.getCockpitState().visionMode === 'thermal' && + styleManager.getAircraftTrackingTarget()?.id === + window.__qaAircraft.aircraftId + ); + }, + kind, + historyLength, + ); + await page.evaluate(() => window.__restoreContext()); + await strict.check( + page, + layerId, + 'cold Cesium failure rolls back to Navara: same session, cockpit, aircraft, no history entry', + await survived('navara'), + ); + console.log( + 'PASS cold Cesium failure rolled back to Navara with the same flight, cockpit and history', + ); + + for (const kind of ['cesium', 'navara']) { + await strict.selectEngine(page, kind, HIDDEN_PICKER); + await ready(kind); + const result = await page.evaluate(() => { + const { dataManager, engine, styleManager } = window.__godsEyeView; + const before = window.__handoff; + const entry = dataManager.layers.get(window.__qaAircraft.layerId); + const cockpit = styleManager.getCockpitState(); + return { + sameOwner: + before.dataManager === dataManager && + before.entry === entry && + before.model === entry.module, + sameRow: + Boolean(before.row) && + before.row === + document.querySelector( + `[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`, + ), + sameTimer: + before.timer === entry.intervalId && entry.intervalId != null, + sourcePolls: window.__flightPolls - before.pollCount, + timerTicks: before.ticks, + stamp: entry.module.getStats().lastUpdate, + initialStamp: before.timestamp, + id: cockpit.subject?.id, + active: cockpit.active, + mode: cockpit.visionMode, + hud: !document.querySelector('#cockpit-hud').hidden, + drawn: entry.module + .qaDrawn() + .some((row) => row.id === window.__qaAircraft.aircraftId), + height: engine.camera.height(), + }; + }); + assert.ok( + result.sameOwner && + result.sameTimer && + result.sameRow && + result.active && + result.hud && + result.drawn, + JSON.stringify(result), + ); + assert.equal(result.id, aircraftId); + assert.equal(result.mode, 'thermal'); + assert.ok( + result.sourcePolls <= result.timerTicks, + 'switch must not add a source request', + ); + if (result.timerTicks === 0) { + assert.equal(result.stamp, result.initialStamp); + } + await strict.passed( + page, + layerId, + 'same manager entry, model, row, poll timer, cockpit, thermal mode, HUD and drawn aircraft', + result, + ); + // Polling goes on after the switch: the same timer ticks and each tick + // reads the source once (no tick may be skipped, none added). + const polls = await page.evaluate(async (layerId) => { + const entry = window.__godsEyeView.dataManager.layers.get(layerId); + const every = + Number(entry.module.refreshInterval) || + Number(entry.module.updateInterval); + const start = { + polls: window.__flightPolls, + ticks: window.__handoff.ticks, + stamp: entry.module.getStats().lastUpdate, + }; + const deadline = Date.now() + 2.5 * every + 5000; + while (window.__handoff.ticks - start.ticks < 2 && Date.now() < deadline) + await new Promise((resolve) => setTimeout(resolve, 250)); + // Let the last tick's request land. + await new Promise((resolve) => setTimeout(resolve, 1500)); + return { + every, + ticks: window.__handoff.ticks - start.ticks, + polls: window.__flightPolls - start.polls, + updated: entry.module.getStats().lastUpdate !== start.stamp, + sameTimer: entry.intervalId === window.__handoff.timer, + }; + }, layerId); + await strict.check( + page, + layerId, + 'polling continues after the switch: two timer ticks, one source read each, fresh data', + polls.every > 0 && + polls.ticks >= 2 && + polls.polls === polls.ticks && + polls.updated && + polls.sameTimer, + polls, + ); + await page.waitForFunction( + () => Math.abs(window.__godsEyeView.engine.camera.height() - 9700) < 1000, + ); + console.log( + `PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`, + ); + } + // The other direction, warm: the parked Navara renderer fails to resume on + // the way back from Cesium. The session rolls back to Cesium, then an + // ordinary retry starts Navara cold. + await page.evaluate(() => { + window.__parkedNavara = window.__godsEyeView.engine; + }); + await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); + await ready('cesium'); + const lengthOnCesium = await page.evaluate(() => { + const request = window.__parkedNavara.render.request; + window.__parkedNavara.render.request = function (...args) { + window.__parkedNavara.render.request = request; + window.__refusedResume = true; + throw new Error('QA parked renderer failure'); + }; + return history.length; + }); + await strict.selectEngine(page, 'navara', HIDDEN_PICKER); + await page.waitForFunction(() => window.__refusedResume === true); + await ready('cesium'); + assert.ok( + (await page.evaluate( + (length) => history.length === length, + lengthOnCesium, + )) && + (await page.evaluate(() => { + const { styleManager } = window.__godsEyeView; + return ( + styleManager.getCockpitState().active && styleManager.getAircraftTrackingTarget()?.id === window.__qaAircraft.aircraftId && new URL(location.href).searchParams.get('engine') === 'cesium' ); - }), - 'the retry restores the same session on Cesium', - ); - await strict.passed( - page, - layerId, - 'after both engines fail, the retry restores the same session', - ); - console.log( - 'PASS failed recovery offered the picker; the retry restored the session on Cesium', - ); - await page.evaluate(() => - window.__godsEyeView.styleManager.controlCockpit('exit'), - ); - await page.waitForFunction( - () => !window.__godsEyeView.styleManager.getCockpitState().active, - ); - // Out of the cockpit the dock shows the picker again: a switch with - // real input keeps the same entry and timer, the aircraft drawn, and the - // source polled on its timer. + })), + 'a failed warm Navara resume must leave Cesium usable without a history entry', + ); + await strict.passed( + page, + layerId, + 'warm Navara failure rolls back to Cesium: same aircraft and cockpit, no history entry', + ); + console.log( + 'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit', + ); + await page.evaluate(() => { + window.__parkedCesium = window.__godsEyeView.engine; + }); + await strict.selectEngine(page, 'navara', HIDDEN_PICKER); + await ready('navara'); + assert.ok( await page.evaluate(() => { - window.__handoff.ticks = 0; - window.__handoff.pollCount = window.__flightPolls; - window.__handoff.timer = window.__godsEyeView.dataManager.layers.get( - window.__qaAircraft.layerId, - ).intervalId; - }); - await strict.pick(page, 'navara'); - await ready('navara'); - const after = await page.evaluate(async () => { - const { dataManager } = window.__godsEyeView; - const entry = dataManager.layers.get(window.__qaAircraft.layerId); - const every = - Number(entry.module.refreshInterval) || - Number(entry.module.updateInterval); - const deadline = Date.now() + 2.5 * every + 5000; - while (window.__handoff.ticks < 1 && Date.now() < deadline) - await new Promise((resolve) => setTimeout(resolve, 250)); - await new Promise((resolve) => setTimeout(resolve, 1500)); - return { - same: - dataManager === window.__handoff.dataManager && - entry === window.__handoff.entry, - timer: entry.intervalId === window.__handoff.timer, - drawn: entry.module - .qaDrawn() - .some((row) => row.id === window.__qaAircraft.aircraftId), - ticks: window.__handoff.ticks, - polls: window.__flightPolls - window.__handoff.pollCount, + const { engine, styleManager } = window.__godsEyeView; + return ( + engine !== window.__parkedNavara && + styleManager.getCockpitState().active && + styleManager.getAircraftTrackingTarget()?.id === + window.__qaAircraft.aircraftId + ); + }), + 'the retry starts a new Navara renderer on the same session', + ); + console.log('PASS retry after the warm failure started Navara cold'); + + // Both engines fail: the warm Cesium resume and the warm Navara recovery. + // No engine runs, but the error screen offers the engine picker, and a + // retry starts Cesium cold on the same session without a reload. + const lengthBeforeDoubleFailure = await page.evaluate(() => { + window.__failResume = true; + for (const engine of [window.__godsEyeView.engine, window.__parkedCesium]) { + const request = engine.render.request; + engine.render.request = function (reason, ...args) { + if (reason === 'engine-resume' && window.__failResume) { + window.__refusedResumes = (window.__refusedResumes ?? 0) + 1; + throw new Error('QA parked renderer failure'); + } + return request.call(this, reason, ...args); }; + } + window.__startDocument = document; + return history.length; + }); + await strict.selectEngine(page, 'cesium', HIDDEN_PICKER); + await page + .waitForSelector('#loading-screen .engine-switch-retry button', { + visible: true, + timeout: 90000, + }) + .catch(async (error) => { + console.log( + 'no retry picker', + JSON.stringify( + await page.evaluate(() => ({ + refused: window.__refusedResumes, + engine: window.__godsEyeView?.engine?.kind ?? null, + retry: !!document.querySelector('.engine-switch-retry'), + loading: document.querySelector('#loading-screen')?.className, + status: document.querySelector('#loading-screen .loader-status') + ?.textContent, + address: location.search, + })), + ), + ); + throw error; }); - await strict.check( - page, - layerId, - 'picker switch keeps the entry, timer and drawn aircraft; polling continues', - after.same && - after.timer && - after.drawn && - after.ticks >= 1 && - after.polls === after.ticks, - after, - ); - assert.deepEqual(errors, []); - }, -); + assert.ok( + await page.evaluate( + (length) => + window.__refusedResumes === 2 && + // No globe runs (the page's debug object may stay, without one). + !window.__godsEyeView?.engine && + history.length === length && + !document + .querySelector('#loading-screen') + .classList.contains('hidden') && + /QA parked renderer failure/.test( + document.querySelector('#loading-screen .loader-status').textContent, + ), + lengthBeforeDoubleFailure, + ), + 'a failed recovery shows its error and the engine picker ' + + JSON.stringify( + await page.evaluate(() => ({ + refused: window.__refusedResumes, + app: !!window.__godsEyeView, + engine: window.__godsEyeView?.engine?.kind ?? null, + history: history.length, + loading: document.querySelector('#loading-screen')?.className, + status: document.querySelector('#loading-screen .loader-status') + ?.textContent, + })), + ), + ); + await page.evaluate(() => { + window.__failResume = false; + }); + await page.click( + '#loading-screen .engine-switch-retry [data-engine="cesium"]', + ); + await ready('cesium'); + assert.ok( + await page.evaluate(() => { + const { dataManager, styleManager } = window.__godsEyeView; + return ( + window.__startDocument === document && + !document.querySelector('.engine-switch-retry') && + dataManager === window.__handoff.dataManager && + dataManager.layers.get(window.__qaAircraft.layerId) === + window.__handoff.entry && + styleManager.getCockpitState().active && + styleManager.getAircraftTrackingTarget()?.id === + window.__qaAircraft.aircraftId && + new URL(location.href).searchParams.get('engine') === 'cesium' + ); + }), + 'the retry restores the same session on Cesium', + ); + await strict.passed( + page, + layerId, + 'after both engines fail, the retry restores the same session', + ); + console.log( + 'PASS failed recovery offered the picker; the retry restored the session on Cesium', + ); + await page.evaluate(() => + window.__godsEyeView.styleManager.controlCockpit('exit'), + ); + await page.waitForFunction( + () => !window.__godsEyeView.styleManager.getCockpitState().active, + ); + // Out of the cockpit the dock shows the picker again: a switch with + // real input keeps the same entry and timer, the aircraft drawn, and the + // source polled on its timer. + await page.evaluate(() => { + window.__handoff.ticks = 0; + window.__handoff.pollCount = window.__flightPolls; + window.__handoff.timer = window.__godsEyeView.dataManager.layers.get( + window.__qaAircraft.layerId, + ).intervalId; + }); + await strict.pick(page, 'navara'); + await ready('navara'); + const after = await page.evaluate(async () => { + const { dataManager } = window.__godsEyeView; + const entry = dataManager.layers.get(window.__qaAircraft.layerId); + const every = + Number(entry.module.refreshInterval) || + Number(entry.module.updateInterval); + const deadline = Date.now() + 2.5 * every + 5000; + while (window.__handoff.ticks < 1 && Date.now() < deadline) + await new Promise((resolve) => setTimeout(resolve, 250)); + await new Promise((resolve) => setTimeout(resolve, 1500)); + return { + same: + dataManager === window.__handoff.dataManager && + entry === window.__handoff.entry, + timer: entry.intervalId === window.__handoff.timer, + drawn: entry.module + .qaDrawn() + .some((row) => row.id === window.__qaAircraft.aircraftId), + ticks: window.__handoff.ticks, + polls: window.__flightPolls - window.__handoff.pollCount, + }; + }); + await strict.check( + page, + layerId, + 'picker switch keeps the entry, timer and drawn aircraft; polling continues', + after.same && + after.timer && + after.drawn && + after.ticks >= 1 && + after.polls === after.ticks, + after, + ); + assert.deepEqual(errors, []); +}); diff --git a/scripts/qa-local-adsb-engine-switch.mjs b/scripts/qa-local-adsb-engine-switch.mjs index f0dcc2a09..7504daf18 100644 --- a/scripts/qa-local-adsb-engine-switch.mjs +++ b/scripts/qa-local-adsb-engine-switch.mjs @@ -1,19 +1,18 @@ import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; base.hash = 'v=2&lat=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; const strict = createStrict({ origin: base.origin, layers: ['local-adsb'] }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); await p.evaluateOnNewDocument(() => { const raw = window.fetch; window.fetch = function (input, ...args) { @@ -53,21 +52,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { ); }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { dataManager, tileset } = window.__godsEyeView; if (tileset || window.__GOOGLE_MAPS_API_KEY__) @@ -107,8 +92,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { await p.evaluate(() => { window.qaSelections = []; }); - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); await p.waitForFunction(() => window.qaSelections.includes('local-adsb:abc123'), ); diff --git a/scripts/qa-navara-annotations.mjs b/scripts/qa-navara-annotations.mjs index 239973a89..c65cfd60d 100644 --- a/scripts/qa-navara-annotations.mjs +++ b/scripts/qa-navara-annotations.mjs @@ -9,7 +9,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import sharp from 'sharp'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + waitForEngine, +} from './qa-strict-harness.mjs'; const LAT = 37.7749; const LON = -122.4194; @@ -31,8 +35,6 @@ const RING = [ await strict.run( { args: [ - '--use-angle=metal', - '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ], @@ -43,21 +45,7 @@ await strict.run( const errors = []; page.on('pageerror', (error) => errors.push(error.message)); await page.goto(base.href); - const ready = async (kind) => { - await page.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 120000 }, - kind, - ); - await page.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(page, 'navara'); assert.equal( await page.evaluate(() => Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), @@ -172,8 +160,7 @@ await strict.run( if ( (await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind ) { - await strict.pick(page, kind); - await ready(kind); + await strict.switchTo(page, kind); } await wait(2500); const state = await page.evaluate(() => ({ diff --git a/scripts/qa-navara-bloom.mjs b/scripts/qa-navara-bloom.mjs index e6818ace1..e6bc4a06a 100644 --- a/scripts/qa-navara-bloom.mjs +++ b/scripts/qa-navara-bloom.mjs @@ -1,4 +1,4 @@ -// The app's Bloom on Navara (Stage 6 B2): the Display bloom glows the +// The app's Bloom on Navara: the Display bloom glows the // Navara frame (brighter, more at a higher slider), it sits after the // overlays and before the visual styles, Cesium's dead zone draws nothing, // turning it off restores the frame exactly, and on Cesium the scene's own @@ -53,153 +53,150 @@ const setBloom = (page, enabled, intensityPct) => intensityPct, ); -await strict.run( - { args: ['--use-angle=metal', '--enable-gpu'] }, - async (browser) => { - const page = await browser.newPage(); - const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); - // The same sun on every run: the frame's brightness decides what blooms - // and what each style's signature reads. - await pinDaytime(page); - await page.evaluateOnNewDocument(() => { - localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); - }); - await page.setViewport({ width: 1280, height: 800 }); - await page.goto(visualPageUrl(base.origin, 'navara'), { - waitUntil: 'domcontentloaded', - }); - await waitForEngine(page, 'navara'); - await freeze(page); +await strict.run({}, async (browser) => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + // The same sun on every run: the frame's brightness decides what blooms + // and what each style's signature reads. + await pinDaytime(page); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + }); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(visualPageUrl(base.origin, 'navara'), { + waitUntil: 'domcontentloaded', + }); + await waitForEngine(page, 'navara'); + await freeze(page); - const initial = await page.evaluate(() => { - const { engine } = window.__godsEyeView; - return { bloom: engine.render.bloom(), pipeline: engine.pipeline() }; - }); - check( - 'Navara blooms itself and starts with bloom off', - initial.bloom.enabled === false && !initial.pipeline?.bloomShown, - initial, - ); - const normal = await capture(page); - save('normal', normal); - const normalStats = stats(normal); + const initial = await page.evaluate(() => { + const { engine } = window.__godsEyeView; + return { bloom: engine.render.bloom(), pipeline: engine.pipeline() }; + }); + check( + 'Navara blooms itself and starts with bloom off', + initial.bloom.enabled === false && !initial.pipeline?.bloomShown, + initial, + ); + const normal = await capture(page); + save('normal', normal); + const normalStats = stats(normal); - const full = await setBloom(page, true, 200); - check( - 'the slider at 200 reaches the engine at full strength', - full.engine.enabled === true && - full.engine.strength === 1 && - full.pipeline.bloomShown === true, - full, - ); - const order = full.pipeline.passes ?? []; - const at = order.indexOf('gevBloom'); - check( - 'bloom runs after the scene and overlays, just before the final copy', - at >= 0 && - order[at + 1] === 'final' && - ['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every( - (key) => !order.includes(key) || order.indexOf(key) < at, - ), - order, - ); - const glowFull = await capture(page); - save('bloom-200', glowFull); - const fullChange = diff(normal, glowFull); - const fullStats = stats(glowFull); - check( - 'bloom at 200 glows the frame brighter', - fullChange.mean > 3 && luma(fullStats) > luma(normalStats) + 2, - { - ...fullChange, - normalLuma: luma(normalStats), - bloomLuma: luma(fullStats), - }, - ); + const full = await setBloom(page, true, 200); + check( + 'the slider at 200 reaches the engine at full strength', + full.engine.enabled === true && + full.engine.strength === 1 && + full.pipeline.bloomShown === true, + full, + ); + const order = full.pipeline.passes ?? []; + const at = order.indexOf('gevBloom'); + check( + 'bloom runs after the scene and overlays, just before the final copy', + at >= 0 && + order[at + 1] === 'final' && + ['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every( + (key) => !order.includes(key) || order.indexOf(key) < at, + ), + order, + ); + const glowFull = await capture(page); + save('bloom-200', glowFull); + const fullChange = diff(normal, glowFull); + const fullStats = stats(glowFull); + check( + 'bloom at 200 glows the frame brighter', + fullChange.mean > 3 && luma(fullStats) > luma(normalStats) + 2, + { + ...fullChange, + normalLuma: luma(normalStats), + bloomLuma: luma(fullStats), + }, + ); - await setBloom(page, true, 100); - const glowHalf = await capture(page); - save('bloom-100', glowHalf); - const halfChange = diff(normal, glowHalf); - check( - 'a lower slider glows less', - halfChange.mean > 0.5 && halfChange.mean < fullChange.mean, - { half: halfChange.mean, full: fullChange.mean }, - ); + await setBloom(page, true, 100); + const glowHalf = await capture(page); + save('bloom-100', glowHalf); + const halfChange = diff(normal, glowHalf); + check( + 'a lower slider glows less', + halfChange.mean > 0.5 && halfChange.mean < fullChange.mean, + { half: halfChange.mean, full: fullChange.mean }, + ); - const dead = await setBloom(page, true, 10); - const deadFrame = await capture(page); - check( - "Cesium's dead zone: the toggle on, nothing glows", - dead.engine.enabled === false && - dead.pipeline.bloomShown === false && - diff(normal, deadFrame).mean === 0, - { engine: dead.engine, diff: diff(normal, deadFrame) }, - ); + const dead = await setBloom(page, true, 10); + const deadFrame = await capture(page); + check( + "Cesium's dead zone: the toggle on, nothing glows", + dead.engine.enabled === false && + dead.pipeline.bloomShown === false && + diff(normal, deadFrame).mean === 0, + { engine: dead.engine, diff: diff(normal, deadFrame) }, + ); - // With a style: bloom first, then the style, then the final copy. - await setBloom(page, true, 200); - const styled = await pickStyle(page, 'noir', 'normal'); - const styledOrder = styled.pipeline?.passes ?? []; - check( - 'bloom runs before the visual styles', - styledOrder.indexOf('gevBloom') >= 0 && - styledOrder.indexOf('gevBloom') + 1 === - styledOrder.indexOf('gevStyles') && - styledOrder.indexOf('gevStyles') + 1 === styledOrder.indexOf('final'), - styledOrder, - ); - await pickStyle(page, 'normal', 'noir'); + // With a style: bloom first, then the style, then the final copy. + await setBloom(page, true, 200); + const styled = await pickStyle(page, 'noir', 'normal'); + const styledOrder = styled.pipeline?.passes ?? []; + check( + 'bloom runs before the visual styles', + styledOrder.indexOf('gevBloom') >= 0 && + styledOrder.indexOf('gevBloom') + 1 === + styledOrder.indexOf('gevStyles') && + styledOrder.indexOf('gevStyles') + 1 === styledOrder.indexOf('final'), + styledOrder, + ); + await pickStyle(page, 'normal', 'noir'); - const off = await setBloom(page, false, 200); - const offFrame = await capture(page); - check( - 'turning bloom off restores the frame', - off.engine.enabled === false && - off.pipeline.bloomShown === false && - diff(normal, offFrame).mean === 0, - { engine: off.engine, diff: diff(normal, offFrame) }, - ); + const off = await setBloom(page, false, 200); + const offFrame = await capture(page); + check( + 'turning bloom off restores the frame', + off.engine.enabled === false && + off.pipeline.bloomShown === false && + diff(normal, offFrame).mean === 0, + { engine: off.engine, diff: diff(normal, offFrame) }, + ); - // Cesium keeps its scene bloom stage; its engine has no bloom. - await strict.switchTo(page, 'cesium'); - await freeze(page); - await setBloom(page, false, 200); - const cesiumNormal = await capture(page); - const cesium = await setBloom(page, true, 200); - const cesiumStage = await page.evaluate(() => { - const a = window.__godsEyeView; - const stage = a.viewer?.scene?.postProcessStages?.bloom; - return { - setBloom: typeof a.engine.render.setBloom, - enabled: stage?.enabled ?? null, - contrast: stage?.uniforms?.contrast ?? null, - }; - }); - const cesiumGlow = await capture(page); - save('cesium-bloom-200', cesiumGlow); - check( - "Cesium: its scene's bloom stage glows, the engine draws none", - cesium.engine === null && - cesiumStage.setBloom === 'undefined' && - cesiumStage.enabled !== false && - diff(cesiumNormal, cesiumGlow).mean > 1, - { stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) }, - ); - await setBloom(page, false, 200); - const cesiumBack = diff(cesiumNormal, await capture(page)); - check( - 'Cesium: bloom off restores its frame', - cesiumBack.mean < 0.5, - cesiumBack, - ); + // Cesium keeps its scene bloom stage; its engine has no bloom. + await strict.switchTo(page, 'cesium'); + await freeze(page); + await setBloom(page, false, 200); + const cesiumNormal = await capture(page); + const cesium = await setBloom(page, true, 200); + const cesiumStage = await page.evaluate(() => { + const a = window.__godsEyeView; + const stage = a.viewer?.scene?.postProcessStages?.bloom; + return { + setBloom: typeof a.engine.render.setBloom, + enabled: stage?.enabled ?? null, + contrast: stage?.uniforms?.contrast ?? null, + }; + }); + const cesiumGlow = await capture(page); + save('cesium-bloom-200', cesiumGlow); + check( + "Cesium: its scene's bloom stage glows, the engine draws none", + cesium.engine === null && + cesiumStage.setBloom === 'undefined' && + cesiumStage.enabled !== false && + diff(cesiumNormal, cesiumGlow).mean > 1, + { stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) }, + ); + await setBloom(page, false, 200); + const cesiumBack = diff(cesiumNormal, await capture(page)); + check( + 'Cesium: bloom off restores its frame', + cesiumBack.mean < 0.5, + cesiumBack, + ); - check('no page errors', errors.length === 0, errors); - if (outDir) - fs.writeFileSync( - path.join(outDir, 'bloom.json'), - JSON.stringify(report, null, 2), - ); - }, -); + check('no page errors', errors.length === 0, errors); + if (outDir) + fs.writeFileSync( + path.join(outDir, 'bloom.json'), + JSON.stringify(report, null, 2), + ); +}); diff --git a/scripts/qa-navara-cockpit-clouds.mjs b/scripts/qa-navara-cockpit-clouds.mjs index 974fe6f84..5dffd909f 100644 --- a/scripts/qa-navara-cockpit-clouds.mjs +++ b/scripts/qa-navara-cockpit-clouds.mjs @@ -1,4 +1,4 @@ -// The cockpit weather clouds on Navara (Stage 6 C3): in cockpit mode with +// The cockpit weather clouds on Navara: in cockpit mode with // the cockpit weather on, Navara's own volumetric clouds take their cover // from the weather under the camera (clear: none; overcast: a heavy deck), // with no second canvas or WebGL context; leaving the cockpit gives the look @@ -101,152 +101,147 @@ const coverage = (page) => () => window.__godsEyeView.engine.render.cloudCover?.().coverage, ); -await strict.run( - { args: ['--use-angle=metal', '--enable-gpu'] }, - async (browser) => { - const page = await browser.newPage(); - const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); - await page.evaluateOnNewDocument((utc) => { - localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); - const offset = Date.parse(utc) - Date.now(); - const RealDate = Date; - class ShiftedDate extends RealDate { - constructor(...args) { - if (args.length === 0) super(RealDate.now() + offset); - else super(...args); - } - static now() { - return RealDate.now() + offset; - } +await strict.run({}, async (browser) => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.evaluateOnNewDocument((utc) => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + const offset = Date.parse(utc) - Date.now(); + const RealDate = Date; + class ShiftedDate extends RealDate { + constructor(...args) { + if (args.length === 0) super(RealDate.now() + offset); + else super(...args); } - globalThis.Date = ShiftedDate; - }, MIDDAY_UTC); - await page.setViewport({ width: 1280, height: 800 }); - await page.goto(visualPageUrl(base.origin, 'navara', '', LOW_VIEW), { - waitUntil: 'domcontentloaded', - }); - await waitForEngine(page, 'navara'); - await freeze(page); + static now() { + return RealDate.now() + offset; + } + } + globalThis.Date = ShiftedDate; + }, MIDDAY_UTC); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(visualPageUrl(base.origin, 'navara', '', LOW_VIEW), { + waitUntil: 'domcontentloaded', + }); + await waitForEngine(page, 'navara'); + await freeze(page); - const setup = await page.evaluate(() => { - const a = window.__godsEyeView; - return { - cover: a.engine.render.cloudCover?.() ?? null, - canvas: Boolean(document.getElementById('cockpit-cloud-effects')), - canvases: document.querySelectorAll('canvas').length, - }; - }); - check( - 'Navara takes the cockpit cloud cover itself, no cloud canvas', - setup.cover?.coverage === null && !setup.canvas, - setup, - ); - const lookOwn = await converged(page, 'look-own'); - - await weatherToggle(page, true); - await cockpit(page, true); - await page.waitForFunction( - () => window.__godsEyeView.engine.render.cloudCover().coverage === 0, - { timeout: 10000, polling: 100 }, - ); - check('clear weather in the cockpit: no cloud cover', asked.length >= 1, { - requests: asked.length, - }); - const clear = await converged(page, 'cockpit-clear'); - - // Overcast 30 km east: the movement makes the weather stale. - weather = WEATHER.overcast; - await page.evaluate(() => { - const { engine } = window.__godsEyeView; - const place = engine.camera.pose().cartographic; - const east = - place.longitude + 30000 / (6378137 * Math.cos(place.latitude)); - const { sin, cos } = Math; - // WGS84 Earth-fixed position of (east, latitude, height). - const a = 6378137; - const e2 = 0.00669437999014; - const n = a / Math.sqrt(1 - e2 * sin(place.latitude) ** 2); - const h = place.height; - engine.camera.setView({ - destination: { - x: (n + h) * cos(place.latitude) * cos(east), - y: (n + h) * cos(place.latitude) * sin(east), - z: (n * (1 - e2) + h) * sin(place.latitude), - }, - orientation: { heading: Math.PI / 2, pitch: -0.14, roll: 0 }, - }); - }); - await page.waitForFunction( - () => window.__godsEyeView.engine.render.cloudCover().coverage > 0.4, - { timeout: 15000, polling: 100 }, - ); - const overcastCover = await coverage(page); - const overcast = await converged(page, 'cockpit-overcast'); - const greyed = { - lookOwn: saturation(lookOwn), - clear: saturation(clear), - overcast: saturation(overcast), - clearToOvercast: diff(clear, overcast).mean, - }; - check( - 'overcast weather covers the view with Navara clouds, clear leaves it open', - overcastCover > 0.4 && - greyed.overcast < greyed.clear * 0.6 && - greyed.lookOwn < greyed.clear && - greyed.clearToOvercast > 15, - { overcastCover, greyed, requests: asked.length }, - ); - const contexts = await page.evaluate(() => ({ + const setup = await page.evaluate(() => { + const a = window.__godsEyeView; + return { + cover: a.engine.render.cloudCover?.() ?? null, canvas: Boolean(document.getElementById('cockpit-cloud-effects')), canvases: document.querySelectorAll('canvas').length, - })); - check( - 'no second canvas or WebGL context in the cockpit', - !contexts.canvas && contexts.canvases === setup.canvases, - { before: setup.canvases, after: contexts }, - ); + }; + }); + check( + 'Navara takes the cockpit cloud cover itself, no cloud canvas', + setup.cover?.coverage === null && !setup.canvas, + setup, + ); + const lookOwn = await converged(page, 'look-own'); - await cockpit(page, false); - check( - 'leaving the cockpit gives the look its own clouds back', - (await coverage(page)) === null, - ); + await weatherToggle(page, true); + await cockpit(page, true); + await page.waitForFunction( + () => window.__godsEyeView.engine.render.cloudCover().coverage === 0, + { timeout: 10000, polling: 100 }, + ); + check('clear weather in the cockpit: no cloud cover', asked.length >= 1, { + requests: asked.length, + }); + const clear = await converged(page, 'cockpit-clear'); - // Cesium keeps its own cockpit cloud canvas; its engine draws none. - await strict.switchTo(page, 'cesium'); - await freeze(page); - weather = WEATHER.overcast; - await cockpit(page, true); - await page.waitForFunction( - () => - Number( - document.getElementById('cockpit-cloud-effects')?.dataset - .cloudStrength, - ) > 0.3, - { timeout: 15000, polling: 100 }, - ); - const cesium = await page.evaluate(() => { - const canvas = document.getElementById('cockpit-cloud-effects'); - return { - setCloudCover: typeof window.__godsEyeView.engine.render.setCloudCover, - strength: Number(canvas.dataset.cloudStrength), - cockpit: canvas.dataset.cockpit, - }; + // Overcast 30 km east: the movement makes the weather stale. + weather = WEATHER.overcast; + await page.evaluate(() => { + const { engine } = window.__godsEyeView; + const place = engine.camera.pose().cartographic; + const east = place.longitude + 30000 / (6378137 * Math.cos(place.latitude)); + const { sin, cos } = Math; + // WGS84 Earth-fixed position of (east, latitude, height). + const a = 6378137; + const e2 = 0.00669437999014; + const n = a / Math.sqrt(1 - e2 * sin(place.latitude) ** 2); + const h = place.height; + engine.camera.setView({ + destination: { + x: (n + h) * cos(place.latitude) * cos(east), + y: (n + h) * cos(place.latitude) * sin(east), + z: (n * (1 - e2) + h) * sin(place.latitude), + }, + orientation: { heading: Math.PI / 2, pitch: -0.14, roll: 0 }, }); - check( - 'Cesium: the cockpit draws its own cloud canvas, the engine none', - cesium.setCloudCover === 'undefined' && cesium.cockpit === 'true', - cesium, - ); - await cockpit(page, false); - await weatherToggle(page, false); + }); + await page.waitForFunction( + () => window.__godsEyeView.engine.render.cloudCover().coverage > 0.4, + { timeout: 15000, polling: 100 }, + ); + const overcastCover = await coverage(page); + const overcast = await converged(page, 'cockpit-overcast'); + const greyed = { + lookOwn: saturation(lookOwn), + clear: saturation(clear), + overcast: saturation(overcast), + clearToOvercast: diff(clear, overcast).mean, + }; + check( + 'overcast weather covers the view with Navara clouds, clear leaves it open', + overcastCover > 0.4 && + greyed.overcast < greyed.clear * 0.6 && + greyed.lookOwn < greyed.clear && + greyed.clearToOvercast > 15, + { overcastCover, greyed, requests: asked.length }, + ); + const contexts = await page.evaluate(() => ({ + canvas: Boolean(document.getElementById('cockpit-cloud-effects')), + canvases: document.querySelectorAll('canvas').length, + })); + check( + 'no second canvas or WebGL context in the cockpit', + !contexts.canvas && contexts.canvases === setup.canvases, + { before: setup.canvases, after: contexts }, + ); - check('no page errors', errors.length === 0, errors); - if (outDir) - fs.writeFileSync( - path.join(outDir, 'cockpit-clouds.json'), - JSON.stringify(report, null, 2), - ); - }, -); + await cockpit(page, false); + check( + 'leaving the cockpit gives the look its own clouds back', + (await coverage(page)) === null, + ); + + // Cesium keeps its own cockpit cloud canvas; its engine draws none. + await strict.switchTo(page, 'cesium'); + await freeze(page); + weather = WEATHER.overcast; + await cockpit(page, true); + await page.waitForFunction( + () => + Number( + document.getElementById('cockpit-cloud-effects')?.dataset.cloudStrength, + ) > 0.3, + { timeout: 15000, polling: 100 }, + ); + const cesium = await page.evaluate(() => { + const canvas = document.getElementById('cockpit-cloud-effects'); + return { + setCloudCover: typeof window.__godsEyeView.engine.render.setCloudCover, + strength: Number(canvas.dataset.cloudStrength), + cockpit: canvas.dataset.cockpit, + }; + }); + check( + 'Cesium: the cockpit draws its own cloud canvas, the engine none', + cesium.setCloudCover === 'undefined' && cesium.cockpit === 'true', + cesium, + ); + await cockpit(page, false); + await weatherToggle(page, false); + + check('no page errors', errors.length === 0, errors); + if (outDir) + fs.writeFileSync( + path.join(outDir, 'cockpit-clouds.json'), + JSON.stringify(report, null, 2), + ); +}); diff --git a/scripts/qa-navara-draw.mjs b/scripts/qa-navara-draw.mjs index b1133cf9a..a1cf4382a 100644 --- a/scripts/qa-navara-draw.mjs +++ b/scripts/qa-navara-draw.mjs @@ -32,8 +32,6 @@ const AREA = [ await strict.run( { args: [ - '--use-angle=metal', - '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ], diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs index e13bd729f..222bc8e7c 100644 --- a/scripts/qa-navara-geo-import.mjs +++ b/scripts/qa-navara-geo-import.mjs @@ -136,8 +136,6 @@ const FILES = [ await strict.run( { args: [ - '--use-angle=metal', - '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ], diff --git a/scripts/qa-navara-scope.mjs b/scripts/qa-navara-scope.mjs index f5c2b1561..e87d960b9 100644 --- a/scripts/qa-navara-scope.mjs +++ b/scripts/qa-navara-scope.mjs @@ -10,7 +10,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import sharp from 'sharp'; import { fromDegrees } from '../src/geo/cartesian3.js'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0'; @@ -31,8 +35,6 @@ const EDGE = { x: 1250, y: 560, width: 26, height: 90 }; await strict.run( { args: [ - '--use-angle=metal', - '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ], @@ -43,21 +45,7 @@ await strict.run( const errors = []; page.on('pageerror', (error) => errors.push(error.message)); await page.goto(base.href); - const ready = async (kind) => { - await page.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 120000 }, - kind, - ); - await page.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(page, 'navara'); assert.equal( await page.evaluate(() => Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), @@ -92,8 +80,7 @@ await strict.run( if ( (await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind ) { - await strict.pick(page, kind); - await ready(kind); + await strict.switchTo(page, kind); } await wait(2500); const mask = await page.evaluate(() => { diff --git a/scripts/qa-navara-sonar.mjs b/scripts/qa-navara-sonar.mjs index a4cea62ca..3c60851d6 100644 --- a/scripts/qa-navara-sonar.mjs +++ b/scripts/qa-navara-sonar.mjs @@ -1,4 +1,4 @@ -// The Cyber HUD sonar on Navara (Stage 6 C2): with the Cyber HUD on, the +// The Cyber HUD sonar on Navara: with the Cyber HUD on, the // sweep lights the contacts it passes and dims the rest to the opacity // floor, by each contact's place on screen exactly as the CPU sampler of // src/cyberSonar.js rates it; the sweep turns; with the sweep off every @@ -98,193 +98,189 @@ const framesIn = (page, ms) => const mean = (values) => values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length); -await strict.run( - { args: ['--use-angle=metal', '--enable-gpu'] }, - async (browser) => { - const page = await browser.newPage(); - const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); - await page.evaluateOnNewDocument(() => { - localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); +await strict.run({}, async (browser) => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + }); + await page.setViewport({ width: WIDTH, height: HEIGHT }); + await page.goto(visualPageUrl(base.origin, 'navara'), { + waitUntil: 'domcontentloaded', + }); + await waitForEngine(page, 'navara'); + await freeze(page); + + const places = contactPlaces(); + const placed = await page.evaluate((places) => { + const { engine } = window.__godsEyeView; + const set = engine.movers.createSet({ + id: 'qa-sonar-contacts', + kind: 'point', + capacity: places.length, + depthTest: 'always', + point: { sizePx: 14, outlinePx: 0 }, }); - await page.setViewport({ width: WIDTH, height: HEIGHT }); - await page.goto(visualPageUrl(base.origin, 'navara'), { - waitUntil: 'domcontentloaded', + window.__qaSonarSet = set; + let missing = 0; + places.forEach((place, index) => { + const position = engine.camera.pickEllipsoid(place); + if (!position) missing++; + set.set(index, { position, color: [1, 1, 1, 1] }); }); - await waitForEngine(page, 'navara'); - await freeze(page); + set.setCount(places.length); + set.commit(); + return { missing, sonar: engine.render.contactSonar?.() ?? null }; + }, places); + check( + 'Navara treats contacts itself; the sonar starts off', + placed.missing === 0 && placed.sonar?.enabled === false, + placed, + ); + const baseline = await read(page, places); - const places = contactPlaces(); - const placed = await page.evaluate((places) => { - const { engine } = window.__godsEyeView; - const set = engine.movers.createSet({ - id: 'qa-sonar-contacts', - kind: 'point', - capacity: places.length, - depthTest: 'always', - point: { sizePx: 14, outlinePx: 0 }, - }); - window.__qaSonarSet = set; - let missing = 0; - places.forEach((place, index) => { - const position = engine.camera.pickEllipsoid(place); - if (!position) missing++; - set.set(index, { position, color: [1, 1, 1, 1] }); - }); - set.setCount(places.length); - set.commit(); - return { missing, sonar: engine.render.contactSonar?.() ?? null }; - }, places); - check( - 'Navara treats contacts itself; the sonar starts off', - placed.missing === 0 && placed.sonar?.enabled === false, - placed, + await page.evaluate(() => { + const { styleManager } = window.__godsEyeView; + styleManager._setHudVariant('cyber'); + // Normal keeps the contacts' own colours; the HUD stays on (in Normal + // it would hide itself, and the sweep with it). + styleManager.setStyle('normal'); + styleManager.hud.setMode('on'); + styleManager._setCyberSonarEnabled(true); + styleManager._setCyberSonarSetting('opacity', 35); + }); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + ); + const theme = await page.evaluate(() => ({ + theme: document.documentElement.dataset.uiTheme, + gpu: document.documentElement.dataset.cyberSonarGpu, + hud: document.getElementById('intel-hud')?.classList.contains('active'), + })); + check( + 'the Cyber HUD runs with the contact adapter', + theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud, + theme, + ); + + const sweeps = []; + for (const wait of [0, 1300, 1300]) { + await new Promise((resolve) => setTimeout(resolve, wait)); + const { luma, sonar } = await read(page, places); + const expected = places.map(({ x, y }) => + sonarFactorAt(x, y, WIDTH, HEIGHT, sonar), ); - const baseline = await read(page, places); - - await page.evaluate(() => { - const { styleManager } = window.__godsEyeView; - styleManager._setHudVariant('cyber'); - // Normal keeps the contacts' own colours; the HUD stays on (in Normal - // it would hide itself, and the sweep with it). - styleManager.setStyle('normal'); - styleManager.hud.setMode('on'); - styleManager._setCyberSonarEnabled(true); - styleManager._setCyberSonarSetting('opacity', 35); + const lit = luma.filter((_, i) => expected[i] > 0.9); + const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100); + sweeps.push({ + spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)), + angle: sonar.angleDeg, + lit: lit.length, + litLuma: mean(lit), + dimLuma: mean(dim), + brightest: luma.indexOf(Math.max(...luma)), + expectedBrightest: expected.indexOf(Math.max(...expected)), + sonar, }); - await page.waitForFunction( - () => window.__godsEyeView.styleManager.transitions.size === 0, - ); - const theme = await page.evaluate(() => ({ - theme: document.documentElement.dataset.uiTheme, - gpu: document.documentElement.dataset.cyberSonarGpu, - hud: document.getElementById('intel-hud')?.classList.contains('active'), - })); check( - 'the Cyber HUD runs with the contact adapter', - theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud, - theme, + `the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`, + sonar.enabled && + sonar.active && + sonar.opacity === 35 && + lit.length >= 1 && + dim.length >= 8 && + mean(lit) > mean(dim) + 20 && + expected[places.length - 1] === 0.35, + sweeps.at(-1), ); + } + check( + 'the sweep turns: another contact is lit', + new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 && + new Set(sweeps.map((s) => s.brightest)).size > 1, + sweeps.map(({ angle, brightest, expectedBrightest }) => ({ + angle, + brightest, + expectedBrightest, + })), + ); + const cadence = await framesIn(page, 1500); + check( + 'a parked scene draws at the fleet cadence while the sweep runs over contacts', + cadence >= 8, + { frames: cadence }, + ); - const sweeps = []; - for (const wait of [0, 1300, 1300]) { - await new Promise((resolve) => setTimeout(resolve, wait)); - const { luma, sonar } = await read(page, places); - const expected = places.map(({ x, y }) => - sonarFactorAt(x, y, WIDTH, HEIGHT, sonar), - ); - const lit = luma.filter((_, i) => expected[i] > 0.9); - const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100); - sweeps.push({ - spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)), - angle: sonar.angleDeg, - lit: lit.length, - litLuma: mean(lit), - dimLuma: mean(dim), - brightest: luma.indexOf(Math.max(...luma)), - expectedBrightest: expected.indexOf(Math.max(...expected)), - sonar, - }); - check( - `the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`, - sonar.enabled && - sonar.active && - sonar.opacity === 35 && - lit.length >= 1 && - dim.length >= 8 && - mean(lit) > mean(dim) + 20 && - expected[places.length - 1] === 0.35, - sweeps.at(-1), - ); - } - check( - 'the sweep turns: another contact is lit', - new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 && - new Set(sweeps.map((s) => s.brightest)).size > 1, - sweeps.map(({ angle, brightest, expectedBrightest }) => ({ - angle, - brightest, - expectedBrightest, - })), - ); - const cadence = await framesIn(page, 1500); - check( - 'a parked scene draws at the fleet cadence while the sweep runs over contacts', - cadence >= 8, - { frames: cadence }, - ); + await page.evaluate(() => + window.__godsEyeView.styleManager._setCyberSonarEnabled(false), + ); + const neutral = await read(page, places); + const spread = + Math.max(...neutral.luma.slice(0, 32)) - + Math.min(...neutral.luma.slice(0, 32)); + await new Promise((resolve) => setTimeout(resolve, 300)); + const idle = await framesIn(page, 1500); + check( + 'with the sweep off every contact takes the neutral factor and the scene rests', + neutral.sonar.enabled && + !neutral.sonar.active && + // What still differs is the map under the translucent contacts. + spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 && + idle <= 3, + { spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle }, + ); - await page.evaluate(() => - window.__godsEyeView.styleManager._setCyberSonarEnabled(false), - ); - const neutral = await read(page, places); - const spread = - Math.max(...neutral.luma.slice(0, 32)) - - Math.min(...neutral.luma.slice(0, 32)); - await new Promise((resolve) => setTimeout(resolve, 300)); - const idle = await framesIn(page, 1500); - check( - 'with the sweep off every contact takes the neutral factor and the scene rests', - neutral.sonar.enabled && - !neutral.sonar.active && - // What still differs is the map under the translucent contacts. - spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 && - idle <= 3, - { spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle }, - ); + await page.evaluate(() => { + const { styleManager } = window.__godsEyeView; + styleManager._setCyberSonarEnabled(true); + styleManager._setHudVariant('tactical'); + styleManager.setStyle('normal'); + styleManager.hud.setMode('auto'); + }); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + ); + const after = await read(page, places); + const drift = Math.max( + ...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])), + ); + check( + 'leaving the Cyber HUD gives the contacts back unchanged', + after.sonar.enabled === false && drift < 3, + { drift }, + ); + await page.evaluate(() => window.__qaSonarSet.destroy()); - await page.evaluate(() => { - const { styleManager } = window.__godsEyeView; - styleManager._setCyberSonarEnabled(true); - styleManager._setHudVariant('tactical'); - styleManager.setStyle('normal'); - styleManager.hud.setMode('auto'); - }); - await page.waitForFunction( - () => window.__godsEyeView.styleManager.transitions.size === 0, - ); - const after = await read(page, places); - const drift = Math.max( - ...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])), - ); - check( - 'leaving the Cyber HUD gives the contacts back unchanged', - after.sonar.enabled === false && drift < 3, - { drift }, - ); - await page.evaluate(() => window.__qaSonarSet.destroy()); + // Cesium keeps its command adapter; its engine draws no sonar. + await strict.switchTo(page, 'cesium'); + await page.evaluate(() => { + const { styleManager } = window.__godsEyeView; + styleManager._setHudVariant('cyber'); + }); + await page.waitForFunction( + () => document.documentElement.dataset.cyberSonarGpu, + { timeout: 10000 }, + ); + const cesium = await page.evaluate(() => ({ + setContactSonar: typeof window.__godsEyeView.engine.render.setContactSonar, + gpu: document.documentElement.dataset.cyberSonarGpu, + })); + check( + 'Cesium: the command adapter treats contacts, the engine none', + cesium.setContactSonar === 'undefined' && cesium.gpu === 'supported', + cesium, + ); + await page.evaluate(() => + window.__godsEyeView.styleManager._setHudVariant('tactical'), + ); - // Cesium keeps its command adapter; its engine draws no sonar. - await strict.switchTo(page, 'cesium'); - await page.evaluate(() => { - const { styleManager } = window.__godsEyeView; - styleManager._setHudVariant('cyber'); - }); - await page.waitForFunction( - () => document.documentElement.dataset.cyberSonarGpu, - { timeout: 10000 }, + check('no page errors', errors.length === 0, errors); + if (outDir) { + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + path.join(outDir, 'sonar.json'), + JSON.stringify(report, null, 2), ); - const cesium = await page.evaluate(() => ({ - setContactSonar: - typeof window.__godsEyeView.engine.render.setContactSonar, - gpu: document.documentElement.dataset.cyberSonarGpu, - })); - check( - 'Cesium: the command adapter treats contacts, the engine none', - cesium.setContactSonar === 'undefined' && cesium.gpu === 'supported', - cesium, - ); - await page.evaluate(() => - window.__godsEyeView.styleManager._setHudVariant('tactical'), - ); - - check('no page errors', errors.length === 0, errors); - if (outDir) { - fs.mkdirSync(outDir, { recursive: true }); - fs.writeFileSync( - path.join(outDir, 'sonar.json'), - JSON.stringify(report, null, 2), - ); - } - }, -); + } +}); diff --git a/scripts/qa-navara-visual-styles.mjs b/scripts/qa-navara-visual-styles.mjs index 213fd71fd..c60bb36a2 100644 --- a/scripts/qa-navara-visual-styles.mjs +++ b/scripts/qa-navara-visual-styles.mjs @@ -1,4 +1,4 @@ -// The app's visual styles on Navara (Stage 6 B1): each of CRT, NVG, FLIR, +// The app's visual styles on Navara: each of CRT, NVG, FLIR, // Anime and Noir filters the Navara frame (pixels and a style-specific // signature against Normal), switching styles crossfades (both styles drawn // at once mid-way, the fade completes), Normal restores the frame, the @@ -45,291 +45,274 @@ const check = checker(report, assert); const save = (name, pixels) => saveCapture(outDir, name, pixels); -await strict.run( - { args: ['--use-angle=metal', '--enable-gpu'] }, - async (browser) => { - const page = await browser.newPage(); - const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); - // The same sun on every run: the frame's brightness decides what blooms - // and what each style's signature reads. - await pinDaytime(page); - await page.evaluateOnNewDocument(() => { - localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); - }); - await page.setViewport({ width: 1280, height: 800 }); - await page.goto(pageUrl('navara'), { waitUntil: 'domcontentloaded' }); - await waitForEngine(page, 'navara'); - await freeze(page); +await strict.run({}, async (browser) => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + // The same sun on every run: the frame's brightness decides what blooms + // and what each style's signature reads. + await pinDaytime(page); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + }); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(pageUrl('navara'), { waitUntil: 'domcontentloaded' }); + await waitForEngine(page, 'navara'); + await freeze(page); - const capabilities = await page.evaluate(() => { - const { engine } = window.__godsEyeView; - return { - styles: engine.render.visualStyles, - pipeline: engine.pipeline(), - }; - }); + const capabilities = await page.evaluate(() => { + const { engine } = window.__godsEyeView; + return { + styles: engine.render.visualStyles, + pipeline: engine.pipeline(), + }; + }); + check( + 'Navara draws every app style itself', + JSON.stringify(capabilities.styles) === JSON.stringify(STYLES), + capabilities, + ); + check( + 'no style pass shows on Normal', + capabilities.pipeline?.shown === false, + capabilities.pipeline, + ); + + // The military styles' presets set sharpening and detection (as on + // Cesium); Normal keeps them, so they are put back before comparing. + const display = await page.evaluate( + () => window.__godsEyeView.styleManager.getControlState().sharpen, + ); + const normal = await capture(page); + const normalAgain = await capture(page); + const noise = diff(normal, normalAgain); + check('Normal is a still frame to compare against', noise.mean < 0.5, noise); + save('normal', normal); + const normalStats = stats(normal); + + let previous = 'normal'; + for (const name of STYLES) { + const fade = await pickStyle(page, name, previous); check( - 'Navara draws every app style itself', - JSON.stringify(capabilities.styles) === JSON.stringify(STYLES), - capabilities, + `${name}: the crossfade completes`, + fade.pending === 0 && fade.ms < 1500 && fade.active === name, + { ms: fade.ms, pending: fade.pending }, + ); + const between = fade.samples.filter( + ([, next]) => next > 0.02 && next < 0.98, ); check( - 'no style pass shows on Normal', - capabilities.pipeline?.shown === false, - capabilities.pipeline, + `${name}: the engine fades it in over several frames`, + between.length >= 1, + { frames: fade.samples.length, between: between.length }, ); - - // The military styles' presets set sharpening and detection (as on - // Cesium); Normal keeps them, so they are put back before comparing. - const display = await page.evaluate( - () => window.__godsEyeView.styleManager.getControlState().sharpen, - ); - const normal = await capture(page); - const normalAgain = await capture(page); - const noise = diff(normal, normalAgain); - check( - 'Normal is a still frame to compare against', - noise.mean < 0.5, - noise, - ); - save('normal', normal); - const normalStats = stats(normal); - - let previous = 'normal'; - for (const name of STYLES) { - const fade = await pickStyle(page, name, previous); - check( - `${name}: the crossfade completes`, - fade.pending === 0 && fade.ms < 1500 && fade.active === name, - { ms: fade.ms, pending: fade.pending }, - ); - const between = fade.samples.filter( - ([, next]) => next > 0.02 && next < 0.98, + if (previous !== 'normal') { + const both = fade.samples.filter( + ([out, next]) => out > 0.05 && out < 0.95 && next > 0.05 && next < 0.95, ); check( - `${name}: the engine fades it in over several frames`, - between.length >= 1, - { frames: fade.samples.length, between: between.length }, + `${name}: ${previous} and ${name} draw together mid-crossfade`, + both.length >= 1, + { both: both.length }, ); - if (previous !== 'normal') { - const both = fade.samples.filter( - ([out, next]) => - out > 0.05 && out < 0.95 && next > 0.05 && next < 0.95, - ); - check( - `${name}: ${previous} and ${name} draw together mid-crossfade`, - both.length >= 1, - { both: both.length }, - ); - } - const others = Object.entries(fade.intensities).filter( - ([style, value]) => style !== name && value !== 0, - ); - check( - `${name}: alone at full intensity once settled`, - fade.intensities[name] === 1 && others.length === 0, - fade.intensities, - ); - const order = fade.pipeline?.passes ?? []; - const at = order.indexOf('gevStyles'); - check( - `${name}: the style pass shows, last before the final copy and after the overlays`, - fade.pipeline?.shown === true && - at >= 0 && - order[at + 1] === 'final' && - ['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every( - (key) => !order.includes(key) || order.indexOf(key) < at, - ), - fade.pipeline, - ); - const pixels = await capture(page); - save(name, pixels); - const styled = stats(pixels); - const change = diff(normal, pixels); - const shift = histogramShift(normalStats, styled); - check( - `${name}: the frame changes against Normal`, - change.mean > 6 && change.changedShare > 0.25, - { ...change, histogramShift: shift }, - ); - const signature = { - // Night vision: phosphor green over red and blue. - surveillance: styled.g > styled.r * 1.3 && styled.g > styled.b * 1.3, - // FLIR white-hot (palette 0): grey. - thermal: styled.saturation < normalStats.saturation * 0.5, - // Noir: greyer, with a slight sepia, and vignetted corners. - noir: - styled.saturation < normalStats.saturation && - styled.vignette < normalStats.vignette * 0.85, - // Anime: cel bands, more saturated. - anime: - styled.saturation > normalStats.saturation * 1.05 || shift > 0.05, - // CRT: pixelated, dithered, scanlined. - retro: shift > 0.05 || change.mean > 12, - }[name]; - check(`${name}: the style's own look`, signature, { - normal: { - r: normalStats.r, - g: normalStats.g, - b: normalStats.b, - saturation: normalStats.saturation, - vignette: normalStats.vignette, - }, - styled: { - r: styled.r, - g: styled.g, - b: styled.b, - saturation: styled.saturation, - vignette: styled.vignette, - }, - histogramShift: shift, - }); - previous = name; } - - // A style parameter reaches the Navara pass (FLIR's Ironbow palette). - await pickStyle(page, 'thermal', previous); - previous = 'thermal'; - const grey = await capture(page); - const ironbow = await page.evaluate(() => { - const a = window.__godsEyeView; - a.styleManager.stages.thermal.uniforms.palette = 1; - return a.engine.render.style('thermal').uniforms.palette; - }); - const coloured = await capture(page); - check( - 'FLIR Ironbow reaches the engine and colours the frame', - ironbow === 1 && stats(coloured).saturation > stats(grey).saturation + 10, - { - grey: stats(grey).saturation, - ironbow: stats(coloured).saturation, - }, + const others = Object.entries(fade.intensities).filter( + ([style, value]) => style !== name && value !== 0, ); - await page.evaluate(() => { - window.__godsEyeView.styleManager.stages.thermal.uniforms.palette = 0; - }); - - const back = await pickStyle(page, 'normal', previous); check( - 'Normal: every style back at 0 and the pass hidden', - Object.values(back.intensities).every((v) => v === 0) && - back.pipeline?.shown === false, - back, + `${name}: alone at full intensity once settled`, + fade.intensities[name] === 1 && others.length === 0, + fade.intensities, ); - await page.evaluate((sharpen) => { - const { styleManager } = window.__godsEyeView; - styleManager._setDetectionMode?.('OFF'); - styleManager.setSharpen(sharpen); - }, display); - const restored = await capture(page); - save('normal-restored', restored); - const residue = diff(normal, restored); + const order = fade.pipeline?.passes ?? []; + const at = order.indexOf('gevStyles'); check( - 'Normal restores the frame', - residue.mean < 1 && residue.changedShare < 0.01, - residue, - ); - - // The retired Snow opens on Normal; a link's style opens on Navara. - await page.goto(pageUrl('navara', '&style=snow'), { - waitUntil: 'domcontentloaded', - }); - await waitForEngine(page, 'navara'); - const snow = await page.evaluate(() => { - const a = window.__godsEyeView; - return { - active: a.styleManager.activeStyle, - intensities: a.engine.render.visualStyles.map( - (s) => a.engine.render.style(s).intensity, + `${name}: the style pass shows, last before the final copy and after the overlays`, + fade.pipeline?.shown === true && + at >= 0 && + order[at + 1] === 'final' && + ['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every( + (key) => !order.includes(key) || order.indexOf(key) < at, ), - }; - }); + fade.pipeline, + ); + const pixels = await capture(page); + save(name, pixels); + const styled = stats(pixels); + const change = diff(normal, pixels); + const shift = histogramShift(normalStats, styled); check( - 'style=snow opens on Normal', - snow.active === 'normal' && snow.intensities.every((v) => v === 0), - snow, + `${name}: the frame changes against Normal`, + change.mean > 6 && change.changedShare > 0.25, + { ...change, histogramShift: shift }, ); - await page.goto(pageUrl('navara', `&style=${URL_NAMES.thermal}`), { - waitUntil: 'domcontentloaded', + const signature = { + // Night vision: phosphor green over red and blue. + surveillance: styled.g > styled.r * 1.3 && styled.g > styled.b * 1.3, + // FLIR white-hot (palette 0): grey. + thermal: styled.saturation < normalStats.saturation * 0.5, + // Noir: greyer, with a slight sepia, and vignetted corners. + noir: + styled.saturation < normalStats.saturation && + styled.vignette < normalStats.vignette * 0.85, + // Anime: cel bands, more saturated. + anime: styled.saturation > normalStats.saturation * 1.05 || shift > 0.05, + // CRT: pixelated, dithered, scanlined. + retro: shift > 0.05 || change.mean > 12, + }[name]; + check(`${name}: the style's own look`, signature, { + normal: { + r: normalStats.r, + g: normalStats.g, + b: normalStats.b, + saturation: normalStats.saturation, + vignette: normalStats.vignette, + }, + styled: { + r: styled.r, + g: styled.g, + b: styled.b, + saturation: styled.saturation, + vignette: styled.vignette, + }, + histogramShift: shift, }); - await waitForEngine(page, 'navara'); - await page.waitForFunction( - () => window.__godsEyeView.engine.render.style('thermal').intensity === 1, - { timeout: 10000, polling: 50 }, - ); - check('a FLIR link opens filtered on Navara', true); + previous = name; + } - // Cesium keeps its own post stages; its engine draws no styles. The - // switch is real input on the picker, in the same document. - await page.evaluate(() => - window.__godsEyeView.styleManager.setStyle('normal'), - ); - await page.waitForFunction( - () => window.__godsEyeView.styleManager.transitions.size === 0, - ); - await strict.switchTo(page, 'cesium'); - await freeze(page); - // The address may still carry the FLIR link: Normal first. - await pickStyle(page, 'normal', 'thermal'); - await page.evaluate((sharpen) => { - const { styleManager } = window.__godsEyeView; - styleManager._setDetectionMode?.('OFF'); - styleManager.setSharpen(sharpen); - }, display); - const cesiumNormal = await capture(page); - const cesium = await pickStyle(page, 'thermal', 'normal'); - const cesiumState = await page.evaluate(() => { - const a = window.__godsEyeView; - const stage = a.styleManager.stages.thermal; - return { - setStyle: typeof a.engine.render.setStyle, - stageName: stage.name, - enabled: stage.enabled, - intensity: stage.uniforms.intensity, - inScene: a.viewer?.scene?.postProcessStages?.contains?.(stage) ?? null, - }; - }); - const cesiumThermal = await capture(page); - save('cesium-normal', cesiumNormal); - save('cesium-thermal', cesiumThermal); - if (outDir) - await page.screenshot({ - path: path.join(outDir, 'cesium-thermal-page.png'), - }); - check( - 'Cesium: FLIR is its own post stage, the engine draws none', - cesium.pending === 0 && - cesiumState.setStyle === 'undefined' && - cesiumState.enabled === true && - cesiumState.intensity === 1 && - cesiumState.inScene !== false, - cesiumState, - ); - const cesiumChange = diff(cesiumNormal, cesiumThermal); - check( - 'Cesium: FLIR changes its frame', - cesiumChange.mean > 6, - cesiumChange, - ); - await pickStyle(page, 'normal', 'thermal'); - await page.evaluate((sharpen) => { - const { styleManager } = window.__godsEyeView; - styleManager._setDetectionMode?.('OFF'); - styleManager.setSharpen(sharpen); - }, display); - const cesiumBack = diff(cesiumNormal, await capture(page)); - check( - 'Cesium: Normal restores its frame', - cesiumBack.mean < 1.5, - cesiumBack, - ); + // A style parameter reaches the Navara pass (FLIR's Ironbow palette). + await pickStyle(page, 'thermal', previous); + previous = 'thermal'; + const grey = await capture(page); + const ironbow = await page.evaluate(() => { + const a = window.__godsEyeView; + a.styleManager.stages.thermal.uniforms.palette = 1; + return a.engine.render.style('thermal').uniforms.palette; + }); + const coloured = await capture(page); + check( + 'FLIR Ironbow reaches the engine and colours the frame', + ironbow === 1 && stats(coloured).saturation > stats(grey).saturation + 10, + { + grey: stats(grey).saturation, + ironbow: stats(coloured).saturation, + }, + ); + await page.evaluate(() => { + window.__godsEyeView.styleManager.stages.thermal.uniforms.palette = 0; + }); - check('no page errors', errors.length === 0, errors); - if (outDir) - fs.writeFileSync( - path.join(outDir, 'visual-styles.json'), - JSON.stringify(report, null, 2), - ); - }, -); + const back = await pickStyle(page, 'normal', previous); + check( + 'Normal: every style back at 0 and the pass hidden', + Object.values(back.intensities).every((v) => v === 0) && + back.pipeline?.shown === false, + back, + ); + await page.evaluate((sharpen) => { + const { styleManager } = window.__godsEyeView; + styleManager._setDetectionMode?.('OFF'); + styleManager.setSharpen(sharpen); + }, display); + const restored = await capture(page); + save('normal-restored', restored); + const residue = diff(normal, restored); + check( + 'Normal restores the frame', + residue.mean < 1 && residue.changedShare < 0.01, + residue, + ); + + // The retired Snow opens on Normal; a link's style opens on Navara. + await page.goto(pageUrl('navara', '&style=snow'), { + waitUntil: 'domcontentloaded', + }); + await waitForEngine(page, 'navara'); + const snow = await page.evaluate(() => { + const a = window.__godsEyeView; + return { + active: a.styleManager.activeStyle, + intensities: a.engine.render.visualStyles.map( + (s) => a.engine.render.style(s).intensity, + ), + }; + }); + check( + 'style=snow opens on Normal', + snow.active === 'normal' && snow.intensities.every((v) => v === 0), + snow, + ); + await page.goto(pageUrl('navara', `&style=${URL_NAMES.thermal}`), { + waitUntil: 'domcontentloaded', + }); + await waitForEngine(page, 'navara'); + await page.waitForFunction( + () => window.__godsEyeView.engine.render.style('thermal').intensity === 1, + { timeout: 10000, polling: 50 }, + ); + check('a FLIR link opens filtered on Navara', true); + + // Cesium keeps its own post stages; its engine draws no styles. The + // switch is real input on the picker, in the same document. + await page.evaluate(() => + window.__godsEyeView.styleManager.setStyle('normal'), + ); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + ); + await strict.switchTo(page, 'cesium'); + await freeze(page); + // The address may still carry the FLIR link: Normal first. + await pickStyle(page, 'normal', 'thermal'); + await page.evaluate((sharpen) => { + const { styleManager } = window.__godsEyeView; + styleManager._setDetectionMode?.('OFF'); + styleManager.setSharpen(sharpen); + }, display); + const cesiumNormal = await capture(page); + const cesium = await pickStyle(page, 'thermal', 'normal'); + const cesiumState = await page.evaluate(() => { + const a = window.__godsEyeView; + const stage = a.styleManager.stages.thermal; + return { + setStyle: typeof a.engine.render.setStyle, + stageName: stage.name, + enabled: stage.enabled, + intensity: stage.uniforms.intensity, + inScene: a.viewer?.scene?.postProcessStages?.contains?.(stage) ?? null, + }; + }); + const cesiumThermal = await capture(page); + save('cesium-normal', cesiumNormal); + save('cesium-thermal', cesiumThermal); + if (outDir) + await page.screenshot({ + path: path.join(outDir, 'cesium-thermal-page.png'), + }); + check( + 'Cesium: FLIR is its own post stage, the engine draws none', + cesium.pending === 0 && + cesiumState.setStyle === 'undefined' && + cesiumState.enabled === true && + cesiumState.intensity === 1 && + cesiumState.inScene !== false, + cesiumState, + ); + const cesiumChange = diff(cesiumNormal, cesiumThermal); + check('Cesium: FLIR changes its frame', cesiumChange.mean > 6, cesiumChange); + await pickStyle(page, 'normal', 'thermal'); + await page.evaluate((sharpen) => { + const { styleManager } = window.__godsEyeView; + styleManager._setDetectionMode?.('OFF'); + styleManager.setSharpen(sharpen); + }, display); + const cesiumBack = diff(cesiumNormal, await capture(page)); + check('Cesium: Normal restores its frame', cesiumBack.mean < 1.5, cesiumBack); + + check('no page errors', errors.length === 0, errors); + if (outDir) + fs.writeFileSync( + path.join(outDir, 'visual-styles.json'), + JSON.stringify(report, null, 2), + ); +}); diff --git a/scripts/qa-nepal-event-switch.mjs b/scripts/qa-nepal-event-switch.mjs index 4765aba43..48648db65 100644 --- a/scripts/qa-nepal-event-switch.mjs +++ b/scripts/qa-nepal-event-switch.mjs @@ -1,6 +1,11 @@ import sharp from 'sharp'; import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; @@ -54,18 +59,8 @@ const strict = createStrict({ }, ], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); - p.on('console', (msg) => { - if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) - errors.push(msg.text()); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b, { shaderErrors: true }); const assets = []; p.on('request', (req) => { const path = new URL(req.url()).pathname; @@ -77,21 +72,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { assets.push(path); }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { tileset, dataManager, sceneDirector } = window.__godsEyeView; if (!sceneDirector?.listScenes().length) @@ -118,8 +99,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }; for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); } const state = await p.evaluate(() => { const { dataManager } = window.__godsEyeView; @@ -184,8 +164,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { await p.waitForFunction( () => window.qaEvent.getPlaybackState().progress > 0.53, ); - await strict.pick(p, 'cesium'); - await ready('cesium'); + await strict.switchTo(p, 'cesium'); const live = await p.evaluate(() => window.qaEvent.getPlaybackState()); await strict.check( p, diff --git a/scripts/qa-nepal-locator-switch.mjs b/scripts/qa-nepal-locator-switch.mjs index 30416b2e8..e236130e8 100644 --- a/scripts/qa-nepal-locator-switch.mjs +++ b/scripts/qa-nepal-locator-switch.mjs @@ -1,7 +1,12 @@ import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; @@ -11,34 +16,10 @@ const strict = createStrict({ origin: base.origin, layers: ['bhote-koshi-locator'], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); - p.on('console', (msg) => { - if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) - errors.push(msg.text()); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b, { shaderErrors: true }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { tileset, dataManager } = window.__godsEyeView; if (tileset || window.__GOOGLE_MAPS_API_KEY__) @@ -51,8 +32,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }); for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); } const { same, stats, params, at } = await p.evaluate( (world) => { diff --git a/scripts/qa-open-layers-switch.mjs b/scripts/qa-open-layers-switch.mjs index 335ca173c..3a8ff7d3e 100644 --- a/scripts/qa-open-layers-switch.mjs +++ b/scripts/qa-open-layers-switch.mjs @@ -1,6 +1,11 @@ import sharp from 'sharp'; import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; // Drone airspace, OSM places, sea marks and ski pistes on both engines, over // Navara → Cesium → Navara: each draws over the view centre, and the switch @@ -57,14 +62,8 @@ const strict = createStrict({ tileRoute('https://www.opensnowmap.org/*', 'ski-pistes'), ], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); await p.evaluateOnNewDocument((ring) => { const fetch = window.fetch.bind(window); window.qaZoneReads = 0; @@ -101,20 +100,6 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }; }, RING); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; const setEnabled = (id, on) => p.evaluate( (layerId, show) => @@ -177,7 +162,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { const greener = (on, off) => 2 * on[1] - on[0] - on[2] > 2 * off[1] - off[0] - off[2] + 10; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(() => { const { dataManager } = window.__godsEyeView; window.qaModels = Object.fromEntries( @@ -190,8 +175,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { for (const [leg, kind] of ['navara', 'cesium', 'navara'].entries()) { const readsBeforeSwitch = await p.evaluate(() => window.qaZoneReads); if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); const same = await p.evaluate(() => Object.entries(window.qaModels).every( ([id, model]) => diff --git a/scripts/qa-radio-engine-switch.mjs b/scripts/qa-radio-engine-switch.mjs index 427de7712..6e8b688e4 100644 --- a/scripts/qa-radio-engine-switch.mjs +++ b/scripts/qa-radio-engine-switch.mjs @@ -6,86 +6,83 @@ const url = localPreview(process.argv[2]); url.search = '?engine=navara&welcome=0'; url.hash = 'v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=sentinel2&l='; const strict = createStrict({ origin: url.origin, layers: ['radio'] }); -await strict.run( - { args: ['--use-angle=metal', '--enable-gpu'] }, - async (browser) => { - const page = await browser.newPage(); - const errors = []; - page.on('pageerror', (e) => errors.push(e.message)); - await installRadioFixture(page); - await page.goto(url.href); - await page.waitForFunction( - () => - window.__godsEyeView?.engine?.kind === 'navara' && - document.querySelector('#loading-screen.hidden'), - ); - await page.evaluate(async (id) => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; - await styleManager.initialRestorePromise; - if (window.__GOOGLE_MAPS_API_KEY__ || tileset) - throw Error('Expected keyless preview'); - window.qaLayer = dataManager.layers.get('radio').module; - await dataManager.setEnabled('radio', true, { origin: 'user' }); - }, stations[0].id); - await page.waitForFunction(() => window.qaLayer?.getStats().count === 750); - await page.evaluate((id) => { - window.qaLayer.selectStation(id, { - autoplay: true, - focus: false, - origin: 'user', - }); - window.qaLayer.setRadioVolume(0.36); - }, stations[10].id); - await page.waitForFunction( - () => - window.__qaRadio.plays.length === 1 && - window.qaLayer.getUIState().audioState === 'playing', - ); - const first = await page.evaluate(() => { - window.qaAudio = window.qaPlayer; - window.qaSnapshot = window.qaLayer.getAcceptedCatalogSnapshot(); - return window.__qaRadio.reads; +await strict.run({}, async (browser) => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await installRadioFixture(page); + await page.goto(url.href); + await page.waitForFunction( + () => + window.__godsEyeView?.engine?.kind === 'navara' && + document.querySelector('#loading-screen.hidden'), + ); + await page.evaluate(async (id) => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + await styleManager.initialRestorePromise; + if (window.__GOOGLE_MAPS_API_KEY__ || tileset) + throw Error('Expected keyless preview'); + window.qaLayer = dataManager.layers.get('radio').module; + await dataManager.setEnabled('radio', true, { origin: 'user' }); + }, stations[0].id); + await page.waitForFunction(() => window.qaLayer?.getStats().count === 750); + await page.evaluate((id) => { + window.qaLayer.selectStation(id, { + autoplay: true, + focus: false, + origin: 'user', }); - for (const kind of ['cesium', 'navara']) { - await strict.switchTo(page, kind); - const state = await page.evaluate(() => ({ - same: - window.qaLayer === - window.__godsEyeView.dataManager.layers.get('radio').module, - player: window.qaAudio === window.qaPlayer, - snapshot: - window.qaSnapshot === window.qaLayer.getAcceptedCatalogSnapshot(), - plays: window.__qaRadio.plays.length, - reads: window.__qaRadio.reads, - state: window.qaLayer.getUIState(), - count: window.qaLayer.getStats().count, - })); - console.log( - kind, - state.count, - state.state.selected?.id, - state.state.audioState, - state.plays, - state.reads, - ); - assert.ok(state.same && state.player && state.snapshot); - assert.equal(state.plays, 1); - assert.equal(state.reads, first); - assert.equal(state.count, 750); - assert.equal(state.state.audioState, 'playing'); - assert.equal(state.state.selected.id, stations[10].id); - assert.equal(state.state.volume, 0.36); - // The fixture's HTMLMediaElement.prototype.play counts play() calls: the - // switch must not start the stream again. - await strict.passed( - page, - 'radio', - 'same model, player element and catalog snapshot; 750 stations, one catalog read, no second play(), same station playing at volume 0.36', - ); - } - assert.deepEqual(errors, []); + window.qaLayer.setRadioVolume(0.36); + }, stations[10].id); + await page.waitForFunction( + () => + window.__qaRadio.plays.length === 1 && + window.qaLayer.getUIState().audioState === 'playing', + ); + const first = await page.evaluate(() => { + window.qaAudio = window.qaPlayer; + window.qaSnapshot = window.qaLayer.getAcceptedCatalogSnapshot(); + return window.__qaRadio.reads; + }); + for (const kind of ['cesium', 'navara']) { + await strict.switchTo(page, kind); + const state = await page.evaluate(() => ({ + same: + window.qaLayer === + window.__godsEyeView.dataManager.layers.get('radio').module, + player: window.qaAudio === window.qaPlayer, + snapshot: + window.qaSnapshot === window.qaLayer.getAcceptedCatalogSnapshot(), + plays: window.__qaRadio.plays.length, + reads: window.__qaRadio.reads, + state: window.qaLayer.getUIState(), + count: window.qaLayer.getStats().count, + })); console.log( - 'PASS one live radio player, one station directory and the same selection', + kind, + state.count, + state.state.selected?.id, + state.state.audioState, + state.plays, + state.reads, ); - }, -); + assert.ok(state.same && state.player && state.snapshot); + assert.equal(state.plays, 1); + assert.equal(state.reads, first); + assert.equal(state.count, 750); + assert.equal(state.state.audioState, 'playing'); + assert.equal(state.state.selected.id, stations[10].id); + assert.equal(state.state.volume, 0.36); + // The fixture's HTMLMediaElement.prototype.play counts play() calls: the + // switch must not start the stream again. + await strict.passed( + page, + 'radio', + 'same model, player element and catalog snapshot; 750 stations, one catalog read, no second play(), same station playing at volume 0.36', + ); + } + assert.deepEqual(errors, []); + console.log( + 'PASS one live radio player, one station directory and the same selection', + ); +}); diff --git a/scripts/qa-space-engine-switch.mjs b/scripts/qa-space-engine-switch.mjs index a7a6248c2..dba39241b 100644 --- a/scripts/qa-space-engine-switch.mjs +++ b/scripts/qa-space-engine-switch.mjs @@ -14,7 +14,7 @@ const strict = createStrict({ origin: url.origin, layers: [missions ? 'rocket-launches' : 'satellites'], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { const p = await b.newPage(); const errors = []; p.on('pageerror', (e) => { diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index d640c3cf4..a0d6632c3 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -54,7 +54,7 @@ const getOpt = (name, dflt) => { }; const BASE_URL = getOpt('--url', 'http://localhost:4173'); -// --engine=cesium|navara (phase 3): without a viewer the layer is read through +// --engine=cesium|navara: without a viewer the layer is read through // the QA probe (window.__godsEyeView.layers.drawn) and the military checks are // skipped where the engine has no military layer. const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9); diff --git a/scripts/qa-street-level-engine-switch.mjs b/scripts/qa-street-level-engine-switch.mjs index 46b63d71b..f43221e29 100644 --- a/scripts/qa-street-level-engine-switch.mjs +++ b/scripts/qa-street-level-engine-switch.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + waitForEngine, +} from './qa-strict-harness.mjs'; import { serveFixtures, newFixture } from './qa-street-level.mjs'; import { KV_LINE, KV_SEQUENCE_ID } from './fixtures/street-level/kartaView.mjs'; import { @@ -28,186 +32,166 @@ const strict = createStrict({ 'https://panoramax.openstreetmap.fr/*', ], }); -await strict.run( - { args: ['--use-angle=metal', '--enable-gpu'] }, - async (browser) => { - const p = await browser.newPage(); - await p.setViewport({ width: 1280, height: 900 }); - const errors = [], - fixture = newFixture({ configured: true }); - p.on('pageerror', (e) => { - errors.push(e.message); - console.error(e.message.split('?')[0]); - }); - p.on('console', (m) => { - if (/shader|registerMeshPicker/i.test(m.text()) && m.type() === 'error') - errors.push(m.text()); - }); - await serveFixtures(p, fixture); - let photoRequests = 0; - p.on('request', (req) => { - if ( - /\.jpg(?:$|\?)/i.test(new URL(req.url()).pathname) && - /openstreetcam|kartaview|panoramax/.test(new URL(req.url()).hostname) - ) - photoRequests++; - }); - await p.goto(base.href); - const ready = async (k) => { - await p.waitForFunction( - (kind) => - window.__godsEyeView?.engine?.kind === kind && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - k, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); - await p.evaluate(async (provider) => { - const { dataManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Expected keyless preview'); - window.qaStreet = dataManager.layers.get('street-level').module; - await dataManager.setEnabled('street-level', true, { origin: 'user' }); - window.qaStreet.setProviderEnabled(provider, true); - }, provider); - const until = async (fn) => { - try { - await p.waitForFunction(fn, { timeout: 20000 }); - } catch (e) { - console.log(await p.evaluate(() => window.qaStreet.getUIState())); - throw e; - } - }; - await until(() => window.qaStreet.getUIState().coverage.count > 0); - // The fixture drive passes under the middle of the canvas. Click its actual picked line. - const pickLine = async () => { - const xy = await p.evaluate( - (prefix) => { - const { engine } = window.__godsEyeView, - canvas = engine.host.canvas, - rect = canvas.getBoundingClientRect(); - for (let dy = -30; dy <= 30; dy += 3) - for (let dx = -40; dx <= 40; dx++) { - const at = { x: rect.width / 2 + dx, y: rect.height / 2 + dy }, - pick = engine.picker.pick(at), - id = pick?.id?.id ?? pick?.id; - if (typeof id === 'string' && id.startsWith(prefix)) - return { x: at.x + rect.x, y: at.y + rect.y, id }; - } - return null; - }, - panorama ? 'pnx:seq:' : 'kv:line:', - ); - assert.ok(xy, 'native ground line was pickable'); - console.log('pick', xy); - await p.mouse.click(xy.x, xy.y); - await until( - () => - window.qaStreet.getUIState().street.open && - window.qaStreet.getUIState().sequence.images > 0 && - (document.querySelector('#sl-viewer img')?.naturalWidth > 0 || - document.querySelector('#sl-viewer canvas:not([hidden])')?.width > - 0), - ); - await strict.passed( - p, - 'street-level', - 'a click on the coverage line opens its sequence and photo', - xy, - ); - return xy; - }; - const xy = await pickLine(); - if (panorama) { - const expected = panoramaxPictures().filter( - (p) => p.sequenceId === xy.id.slice('pnx:seq:'.length), - ).length; - await p.waitForFunction( - (n) => window.qaStreet.getUIState().sequence.images === n, - { timeout: 20000 }, - expected, - ); +await strict.run({}, async (browser) => { + const p = await browser.newPage(); + await p.setViewport({ width: 1280, height: 900 }); + const errors = [], + fixture = newFixture({ configured: true }); + p.on('pageerror', (e) => { + errors.push(e.message); + console.error(e.message.split('?')[0]); + }); + p.on('console', (m) => { + if (/shader|registerMeshPicker/i.test(m.text()) && m.type() === 'error') + errors.push(m.text()); + }); + await serveFixtures(p, fixture); + let photoRequests = 0; + p.on('request', (req) => { + if ( + /\.jpg(?:$|\?)/i.test(new URL(req.url()).pathname) && + /openstreetcam|kartaview|panoramax/.test(new URL(req.url()).hostname) + ) + photoRequests++; + }); + await p.goto(base.href); + await waitForEngine(p, 'navara'); + await p.evaluate(async (provider) => { + const { dataManager, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Expected keyless preview'); + window.qaStreet = dataManager.layers.get('street-level').module; + await dataManager.setEnabled('street-level', true, { origin: 'user' }); + window.qaStreet.setProviderEnabled(provider, true); + }, provider); + const until = async (fn) => { + try { + await p.waitForFunction(fn, { timeout: 20000 }); + } catch (e) { + console.log(await p.evaluate(() => window.qaStreet.getUIState())); + throw e; } - const first = await p.evaluate(() => { - window.streetPhoto = - document.querySelector('#sl-viewer canvas:not([hidden])') ?? - document.querySelector('#sl-viewer img'); - return window.qaStreet.getUIState(); - }); - assert.ok( - (panorama ? PNX_SEQUENCES : [KV_SEQUENCE_ID]).includes( - first.street.sequenceId, - ), + }; + await until(() => window.qaStreet.getUIState().coverage.count > 0); + // The fixture drive passes under the middle of the canvas. Click its actual picked line. + const pickLine = async () => { + const xy = await p.evaluate( + (prefix) => { + const { engine } = window.__godsEyeView, + canvas = engine.host.canvas, + rect = canvas.getBoundingClientRect(); + for (let dy = -30; dy <= 30; dy += 3) + for (let dx = -40; dx <= 40; dx++) { + const at = { x: rect.width / 2 + dx, y: rect.height / 2 + dy }, + pick = engine.picker.pick(at), + id = pick?.id?.id ?? pick?.id; + if (typeof id === 'string' && id.startsWith(prefix)) + return { x: at.x + rect.x, y: at.y + rect.y, id }; + } + return null; + }, + panorama ? 'pnx:seq:' : 'kv:line:', + ); + assert.ok(xy, 'native ground line was pickable'); + console.log('pick', xy); + await p.mouse.click(xy.x, xy.y); + await until( + () => + window.qaStreet.getUIState().street.open && + window.qaStreet.getUIState().sequence.images > 0 && + (document.querySelector('#sl-viewer img')?.naturalWidth > 0 || + document.querySelector('#sl-viewer canvas:not([hidden])')?.width > 0), ); - const reads = photoRequests; - for (const kind of ['cesium', 'navara']) { - await strict.pick(p, kind); - await ready(kind); - // The new globe reloads coverage right after it attaches: compare once - // that load is over. - await until( - () => - !window.qaStreet.getUIState().coverage.loading && - window.qaStreet.getUIState().coverage.count > 0, - ); - const state = await p.evaluate(() => ({ - model: - window.__godsEyeView.dataManager.layers.get('street-level').module === - window.qaStreet, - samePhoto: - document.querySelector('#sl-viewer')?.contains(window.streetPhoto) && - window.streetPhoto.isConnected, - ui: window.qaStreet.getUIState(), - })); - console.log( - kind, - state.model, - state.samePhoto, - state.ui.street.imageId, - state.ui.sequence.images, - state.ui.coverage.count, - ); - assert.equal(state.model, true); - assert.equal(state.samePhoto, true); - assert.ok(state.ui.street.open); - assert.equal(state.ui.street.imageId, first.street.imageId); - assert.equal(state.ui.sequence.images, first.sequence.images); - assert.deepEqual(state.ui.filter, first.filter); - assert.ok(state.ui.coverage.count > 0); - await strict.passed( - p, - 'street-level', - 'same model, the same mounted photo, image and sequence; coverage kept', - ); - } - assert.equal(photoRequests, reads); await strict.passed( p, 'street-level', - 'no photo requested again by the switches', + 'a click on the coverage line opens its sequence and photo', + xy, ); - // Click-picking on Cesium: close the viewer, switch, click the line again. - await p.evaluate(() => window.qaStreet.closeViewer()); - await until(() => !window.qaStreet.getUIState().street.open); - await strict.pick(p, 'cesium'); - await ready('cesium'); + return xy; + }; + const xy = await pickLine(); + if (panorama) { + const expected = panoramaxPictures().filter( + (p) => p.sequenceId === xy.id.slice('pnx:seq:'.length), + ).length; + await p.waitForFunction( + (n) => window.qaStreet.getUIState().sequence.images === n, + { timeout: 20000 }, + expected, + ); + } + const first = await p.evaluate(() => { + window.streetPhoto = + document.querySelector('#sl-viewer canvas:not([hidden])') ?? + document.querySelector('#sl-viewer img'); + return window.qaStreet.getUIState(); + }); + assert.ok( + (panorama ? PNX_SEQUENCES : [KV_SEQUENCE_ID]).includes( + first.street.sequenceId, + ), + ); + const reads = photoRequests; + for (const kind of ['cesium', 'navara']) { + await strict.switchTo(p, kind); + // The new globe reloads coverage right after it attaches: compare once + // that load is over. await until( () => !window.qaStreet.getUIState().coverage.loading && window.qaStreet.getUIState().coverage.count > 0, ); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await pickLine(); - assert.deepEqual(errors, []); - assert.deepEqual(fixture.unknown, []); + const state = await p.evaluate(() => ({ + model: + window.__godsEyeView.dataManager.layers.get('street-level').module === + window.qaStreet, + samePhoto: + document.querySelector('#sl-viewer')?.contains(window.streetPhoto) && + window.streetPhoto.isConnected, + ui: window.qaStreet.getUIState(), + })); console.log( - 'PASS native line pick, same mounted photo and sequence across two switches', + kind, + state.model, + state.samePhoto, + state.ui.street.imageId, + state.ui.sequence.images, + state.ui.coverage.count, ); - }, -); + assert.equal(state.model, true); + assert.equal(state.samePhoto, true); + assert.ok(state.ui.street.open); + assert.equal(state.ui.street.imageId, first.street.imageId); + assert.equal(state.ui.sequence.images, first.sequence.images); + assert.deepEqual(state.ui.filter, first.filter); + assert.ok(state.ui.coverage.count > 0); + await strict.passed( + p, + 'street-level', + 'same model, the same mounted photo, image and sequence; coverage kept', + ); + } + assert.equal(photoRequests, reads); + await strict.passed( + p, + 'street-level', + 'no photo requested again by the switches', + ); + // Click-picking on Cesium: close the viewer, switch, click the line again. + await p.evaluate(() => window.qaStreet.closeViewer()); + await until(() => !window.qaStreet.getUIState().street.open); + await strict.switchTo(p, 'cesium'); + await until( + () => + !window.qaStreet.getUIState().coverage.loading && + window.qaStreet.getUIState().coverage.count > 0, + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await pickLine(); + assert.deepEqual(errors, []); + assert.deepEqual(fixture.unknown, []); + console.log( + 'PASS native line pick, same mounted photo and sequence across two switches', + ); +}); diff --git a/scripts/qa-strict-cold-chunks.mjs b/scripts/qa-strict-cold-chunks.mjs index 37f79e157..e5e1b59f6 100644 --- a/scripts/qa-strict-cold-chunks.mjs +++ b/scripts/qa-strict-cold-chunks.mjs @@ -41,7 +41,7 @@ const navaraCode = async (url) => { }; const results = []; strict.extra.contracts = results; -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { for (const start of ['navara', 'cesium']) { const p = await b.newPage(); await p.setViewport({ width: 1280, height: 800 }); diff --git a/scripts/qa-strict-context-loss.mjs b/scripts/qa-strict-context-loss.mjs index fcc78151c..64a9c9dff 100644 --- a/scripts/qa-strict-context-loss.mjs +++ b/scripts/qa-strict-context-loss.mjs @@ -32,7 +32,7 @@ const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara'); const strict = createStrict({ origin: base.origin, layers: [] }); const results = []; strict.extra.contracts = results; -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { let p = null; const errors = []; // A fresh page on `from`; warm visits the other engine first. diff --git a/scripts/qa-strict-failure-matrix.mjs b/scripts/qa-strict-failure-matrix.mjs index c3d2a5eec..e70ed6fa3 100644 --- a/scripts/qa-strict-failure-matrix.mjs +++ b/scripts/qa-strict-failure-matrix.mjs @@ -37,7 +37,7 @@ const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara'); const strict = createStrict({ origin: base.origin, layers: [] }); const results = []; strict.extra.contracts = results; -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { // A fresh page on `start` with the session; `warm` visits the other // engine first so it is parked. const open = async (start, warm) => { diff --git a/scripts/qa-strict-harness.mjs b/scripts/qa-strict-harness.mjs index f6a7c1798..42cebbcdf 100644 --- a/scripts/qa-strict-harness.mjs +++ b/scripts/qa-strict-harness.mjs @@ -29,8 +29,21 @@ import { import { createMissLoader, tileCacheFile } from './qa/tileCache.mjs'; export const ENGINES = Object.freeze(['cesium', 'navara']); -const RESERVED_PORTS = ['', '4173', '4185', '4186', '4187', '4188', '4189']; -const RESERVED = [...RESERVED_PORTS, '4194', '4196']; +/** Local ports that belong to the user's own servers: never a QA preview. */ +export const RESERVED_PORTS = Object.freeze([ + '', + '4173', + '4174', + '4185', + '4186', + '4187', + '4188', + '4189', + '4194', + '4196', +]); +/** Chrome flags every scenario's browser starts with (the GPU path). */ +const CHROME_ARGS = Object.freeze(['--use-angle=metal', '--enable-gpu']); /** The values of --layers=a,b and --evidence=FILE (null when absent). */ export function strictArguments(argv = process.argv) { @@ -48,7 +61,7 @@ export function localPreview(href) { const url = new URL(href); if ( !['localhost', '127.0.0.1'].includes(url.hostname) || - RESERVED.includes(url.port) + RESERVED_PORTS.includes(url.port) ) throw Error('Supply an unreserved local keyless preview URL'); return url; @@ -89,11 +102,18 @@ export async function installStrictNetwork( // workers' own requests are never held. const intercept = async (session, type) => { session.on('Fetch.requestPaused', async ({ requestId, request }) => { - const fulfil = (status, headers, body) => + // Every answer allows any origin; `noStore` keeps it out of the cache. + const fulfil = (status, contentType, body, { noStore = false } = {}) => session.send('Fetch.fulfillRequest', { requestId, responseCode: status, - responseHeaders: headers, + responseHeaders: [ + ...(contentType + ? [{ name: 'Content-Type', value: contentType }] + : []), + ...(noStore ? [{ name: 'Cache-Control', value: 'no-store' }] : []), + { name: 'Access-Control-Allow-Origin', value: '*' }, + ], ...(body == null ? {} : { body: Buffer.from(body).toString('base64') }), @@ -106,12 +126,7 @@ export async function installStrictNetwork( const answer = await route.respond(request); return await fulfil( answer.status ?? 200, - [ - ...(answer.contentType - ? [{ name: 'Content-Type', value: answer.contentType }] - : []), - { name: 'Access-Control-Allow-Origin', value: '*' }, - ], + answer.contentType, answer.body ?? '', ); } @@ -128,14 +143,9 @@ export async function installStrictNetwork( (ledger.probes[probe.pattern] ?? 0) + 1; return await fulfil( probe.status, - [ - ...(probe.contentType - ? [{ name: 'Content-Type', value: probe.contentType }] - : []), - { name: 'Cache-Control', value: 'no-store' }, - { name: 'Access-Control-Allow-Origin', value: '*' }, - ], + probe.contentType, probe.body ?? null, + { noStore: true }, ); } const accept = Object.entries(request.headers ?? {}).find( @@ -172,16 +182,7 @@ export async function installStrictNetwork( const host = new URL(request.url).host; tiles.fetchedByHost[host] = (tiles.fetchedByHost[host] ?? 0) + 1; } - await fulfil( - entry.status, - [ - ...(entry.contentType - ? [{ name: 'Content-Type', value: entry.contentType }] - : []), - { name: 'Access-Control-Allow-Origin', value: '*' }, - ], - entry.body, - ); + await fulfil(entry.status, entry.contentType, entry.body); } catch (error) { if ( /Invalid InterceptionId|closed|detached/i.test(String(error?.message)) @@ -326,21 +327,15 @@ export async function installStrictNetwork( * each page gets an accessor in place of the global: reads before the script * has assigned it are logged with their stack in `ledger.cesiumBeforeLoad` * and answer `undefined`, as production would. - * - * GEV_QA_CESIUM_TRAP=record (a survey, not a check) answers such a read with - * the SDK instead, loaded synchronously on the first one, and logs every - * distinct read site until the page itself loads Cesium.js, so one run lists - * all the Navara paths that still need Cesium. */ async function trapCesiumGlobal(session, ledger) { const binding = '__gevStrictCesiumRead'; - const record = process.env.GEV_QA_CESIUM_TRAP === 'record'; session.on('Runtime.bindingCalled', (event) => { if (event.name !== binding) return; const stack = String(event.payload).slice(0, 1500); const known = ledger.cesiumBeforeLoad.find((read) => read.stack === stack); if (known) known.count++; - else if (ledger.cesiumBeforeLoad.length < (record ? 2000 : 50)) + else if (ledger.cesiumBeforeLoad.length < 50) ledger.cesiumBeforeLoad.push({ stack, count: 1 }); }); await session.send('Runtime.enable'); @@ -349,10 +344,8 @@ async function trapCesiumGlobal(session, ledger) { await session.send('Page.addScriptToEvaluateOnNewDocument', { source: `(() => { if (window.top !== window) return; - const record = ${record}; const seen = new Set(); let sdk; - let pageLoaded = false; const report = () => { const limit = Error.stackTraceLimit; Error.stackTraceLimit = 12; @@ -366,20 +359,7 @@ async function trapCesiumGlobal(session, ledger) { Object.defineProperty(window, 'Cesium', { configurable: true, get() { - if (!record) { - if (sdk === undefined) report(); - return sdk; - } - if (!pageLoaded) { - pageLoaded = !!document.querySelector('script[src$="/Cesium.js"]'); - if (!pageLoaded) report(); - } - if (sdk === undefined) { - const request = new XMLHttpRequest(); - request.open('GET', '/cesium/Cesium.js', false); - request.send(); - (0, eval)(request.responseText); - } + if (sdk === undefined) report(); return sdk; }, set(value) { @@ -434,30 +414,8 @@ export async function pickEngine( ); const opened = !(await expanded()); if (opened) { - await page.click('#control-panel-toggle').catch(async (error) => { - const state = await page.evaluate(() => { - const el = document.querySelector('#control-panel-toggle'); - const box = el?.getBoundingClientRect(); - const style = el && getComputedStyle(el); - const hit = - box && - document.elementFromPoint( - box.x + box.width / 2, - box.y + box.height / 2, - ); - return { - box: box && [box.x, box.y, box.width, box.height], - display: style?.display, - visibility: style?.visibility, - hidden: el?.closest('[hidden]')?.id ?? null, - hit: hit?.id || hit?.className || null, - body: document.body.className, - }; - }); - throw Error( - `cannot click the control panel toggle (${error.message}): ` + - JSON.stringify(state), - ); + await page.click('#control-panel-toggle').catch((error) => { + throw Error(`cannot click the control panel toggle: ${error.message}`); }); await page.waitForFunction( () => @@ -592,6 +550,27 @@ export async function waitForEngine(page, kind, { timeout = 120000 } = {}) { ); } +/** + * A new 1024×768 page of `browser` whose uncaught errors (and, with + * `shaderErrors`, shader and WebGL program errors on the console) land in + * `errors`; each uncaught error is also logged without its query string. + */ +export async function openPage(browser, { shaderErrors = false } = {}) { + const page = await browser.newPage(); + await page.setViewport({ width: 1024, height: 768 }); + const errors = []; + page.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + if (shaderErrors) + page.on('console', (msg) => { + if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) + errors.push(msg.text()); + }); + return { page, errors }; +} + /** * The strict context of one scenario run. * @@ -653,9 +632,16 @@ export function createStrict({ ledger, extra, timings, - /** Launch Chrome with the policy on every target. */ - async launch(options = {}) { - const browser = await puppeteer.launch({ headless: true, ...options }); + /** + * Launch headless Chrome with the policy on every target; `args` adds + * flags to CHROME_ARGS. + */ + async launch({ args = [], ...options } = {}) { + const browser = await puppeteer.launch({ + headless: true, + ...options, + args: [...CHROME_ARGS, ...args], + }); await installStrictNetwork(browser, { origin, fixtures, @@ -665,7 +651,7 @@ export function createStrict({ }); return browser; }, - /** Launch, run `body(browser)`, close, then finish(). */ + /** Launch with `options`, run `body(browser)`, close, then finish(). */ async run(options, body) { const browser = await strict.launch(options); let failed = null; diff --git a/scripts/qa-strict-hidden-switch.mjs b/scripts/qa-strict-hidden-switch.mjs index be0ff338d..deef7ca24 100644 --- a/scripts/qa-strict-hidden-switch.mjs +++ b/scripts/qa-strict-hidden-switch.mjs @@ -20,7 +20,7 @@ import { const base = localPreview(process.argv[2]); const strict = createStrict({ origin: base.origin, layers: [] }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { const p = await b.newPage(); await p.setViewport({ width: 1280, height: 800 }); const errors = []; diff --git a/scripts/qa-strict-prod-smoke.mjs b/scripts/qa-strict-prod-smoke.mjs index 8d497bbce..93e275a5b 100644 --- a/scripts/qa-strict-prod-smoke.mjs +++ b/scripts/qa-strict-prod-smoke.mjs @@ -20,7 +20,7 @@ const base = localPreview(process.argv[2]); const strict = createStrict({ origin: base.origin, layers: [] }); const results = []; strict.extra.contracts = results; -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { for (const start of ENGINES) { const page = await b.newPage(); await page.setViewport({ width: 1280, height: 800 }); diff --git a/scripts/qa-strict-rapid-switch.mjs b/scripts/qa-strict-rapid-switch.mjs index 8bebe7356..fedc95c7d 100644 --- a/scripts/qa-strict-rapid-switch.mjs +++ b/scripts/qa-strict-rapid-switch.mjs @@ -24,7 +24,7 @@ import { const base = localPreview(process.argv[2]); const strict = createStrict({ origin: base.origin, layers: [] }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { const p = await b.newPage(); await p.setViewport({ width: 1280, height: 800 }); const errors = []; diff --git a/scripts/qa-strict-soak.mjs b/scripts/qa-strict-soak.mjs index 9f2bdc207..8a498c7e0 100644 --- a/scripts/qa-strict-soak.mjs +++ b/scripts/qa-strict-soak.mjs @@ -156,9 +156,7 @@ strict.extra.soak = report; for (let run = 1; run <= runs; run++) { const start = run % 2 ? 'navara' : 'cesium'; - const browser = await strict.launch({ - args: ['--use-angle=metal', '--enable-gpu'], - }); + const browser = await strict.launch(); const row = { run, start, swaps: [], checkpoints: [], errors: [] }; report.runs.push(row); try { diff --git a/scripts/qa-submarine-cables-engine-switch.mjs b/scripts/qa-submarine-cables-engine-switch.mjs index ceb4079ab..a85198fe0 100644 --- a/scripts/qa-submarine-cables-engine-switch.mjs +++ b/scripts/qa-submarine-cables-engine-switch.mjs @@ -1,7 +1,12 @@ import { fromDegrees } from '../src/geo/cartesian3.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; @@ -27,39 +32,15 @@ const strict = createStrict({ origin: base.origin, layers: ['telegeography-submarine-cables'], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b, { shaderErrors: true }); const reads = []; p.on('request', (req) => { const path = new URL(req.url()).pathname; if (/\/(cable-geo|landing-point-geo).*\.json$/.test(path)) reads.push(path); }); - p.on('console', (msg) => { - if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) - errors.push(msg.text()); - }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { dataManager, tileset } = window.__godsEyeView; if (tileset || window.__GOOGLE_MAPS_API_KEY__) @@ -71,8 +52,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { }); for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); } await p.waitForFunction( () => window.qaCables.getStats().referenceLabelCount > 0, diff --git a/scripts/qa-traffic-engine-switch.mjs b/scripts/qa-traffic-engine-switch.mjs index f98fb4598..243c5a566 100644 --- a/scripts/qa-traffic-engine-switch.mjs +++ b/scripts/qa-traffic-engine-switch.mjs @@ -1,7 +1,12 @@ import { trafficFixtureResponse } from './traffic-fixtures.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; import { STRICT_TILE_HOSTS } from './qa-map-policy.mjs'; const base = localPreview(process.argv[2]); @@ -22,14 +27,8 @@ const strict = createStrict({ fixtures: ['https://tiles.maps.eox.at/*', 'https://tiles.openfreemap.org/*'], tileHosts: STRICT_TILE_HOSTS.filter((host) => host !== 'tiles.maps.eox.at'), }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b, { shaderErrors: true }); const keyless = process.argv.includes('--simulation'); const reads = new Map(); @@ -73,26 +72,8 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { } return req.continue(); }); - p.on('console', (msg) => { - if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) - errors.push(msg.text()); - }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { tileset, dataManager } = window.__godsEyeView; if (tileset || window.__GOOGLE_MAPS_API_KEY__) @@ -199,8 +180,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { ); for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); } await waitTraffic(); const check = await p.evaluate(() => { diff --git a/scripts/qa-urban-engine-switch.mjs b/scripts/qa-urban-engine-switch.mjs index 597ef41c7..432068653 100644 --- a/scripts/qa-urban-engine-switch.mjs +++ b/scripts/qa-urban-engine-switch.mjs @@ -31,7 +31,7 @@ const strict = createStrict({ '*://*.earthdata.nasa.gov/*', ], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { +await strict.run({}, async (b) => { const p = await b.newPage(); await p.setViewport({ width: 1280, height: 900 }); const errors = []; diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index f9f121b7d..4aafaf22c 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -38,7 +38,7 @@ const getOpt = (name, dflt) => { }; const APP_URL = getOpt('--url', 'http://localhost:4173'); -// --engine cesium|navara (phase 3): frame, settle and read the renderer +// --engine cesium|navara: frame, settle and read the renderer // through window.__godsEyeView.engine (either engine) instead of the Cesium // viewer; the URL gets ?engine=. Without it, as before. const ENGINE = getOpt('--engine', null); diff --git a/scripts/qa-vessel-datum.mjs b/scripts/qa-vessel-datum.mjs index 940b457bf..0a1673c47 100644 --- a/scripts/qa-vessel-datum.mjs +++ b/scripts/qa-vessel-datum.mjs @@ -19,7 +19,7 @@ * node scripts/qa-vessel-datum.mjs --ports rotterdam --url http://localhost:4173 * node scripts/qa-vessel-datum.mjs --engine navara --url 'http://localhost:PORT/?qaFeed=vessels:fixture' * - * --engine cesium|navara (phase 3): the engine-neutral path on either engine, + * --engine cesium|navara: the engine-neutral path on either engine, * through window.__godsEyeView.engine and the QA probe (layers.drawn: each * chevron's drawn position and depth-test flag) instead of the Cesium * viewer's primitives; the URL gets ?engine=. Without it, as before. diff --git a/scripts/qa-w2-imagery.mjs b/scripts/qa-w2-imagery.mjs index 3c7015e8e..e839da432 100644 --- a/scripts/qa-w2-imagery.mjs +++ b/scripts/qa-w2-imagery.mjs @@ -1,19 +1,28 @@ +// Recent imagery on a keyless preview, starting on Navara: the NASA catalog +// and imagery tiles are fixtures, and the image, A/B, swap, divider, opacity, +// basemap, toggle, failure, box-drag and zoom-to-fit checks read the rendered +// 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: +// [--layers=recent-imagery] [--evidence=FILE] import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import sharp from 'sharp'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + strictArguments, +} from './qa-strict-harness.mjs'; import { isExpectedPreviewSetupError, redact } from './qa-browserEvidence.mjs'; -const args = process.argv.slice(2); -const option = (name, fallback) => - args.includes(name) ? args[args.indexOf(name) + 1] : fallback; -const url = option('--url', 'http://127.0.0.1:4193'); -const output = option('--report', '/private/tmp/gev-imagery.json'); -if ( - ['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( - new URL(url).port, - ) -) - throw new Error('Reserved QA port'); +const { origin } = localPreview(process.argv[2]); +const url = origin + '/?engine=navara'; +const { evidence } = strictArguments(); +const output = evidence + ? path.join(path.dirname(evidence), 'imagery-report.json') + : path.join(os.tmpdir(), 'gev-imagery.json'); const report = { url, checks: [], @@ -26,7 +35,7 @@ let failLandsat = false, // The engine the page runs, for the strict assertion manifest. let engine = null; const strict = createStrict({ - origin: localPreview(url).origin, + origin, layers: ['recent-imagery'], // The scenario answers the NASA catalog and imagery tiles itself. fixtures: ['*://*.earthdata.nasa.gov/*'], @@ -48,8 +57,6 @@ const browser = await strict.launch({ headless: true, args: [ '--no-sandbox', - '--use-angle=metal', - '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ], @@ -63,7 +70,7 @@ try { const text = redact(message.text()); const expected = isExpectedPreviewSetupError(message, { - preview: args.includes('--preview'), + preview: true, baseUrl: url, }) || (failedTiles > 0 && @@ -231,37 +238,35 @@ try { await sleep(2500); image = await capture('ab'); check('A/B dates on opposite sides', red(image.left) && green(image.right)); - if (args.includes('--switch')) { - const first = await page.evaluate(() => { - window.qaImageryOwner = window.__gevRecentImagery.layer; - return window.qaImageryOwner.diagnostics().pins; - }); - const reads = () => - report.requests.filter((r) => r.host === 'cmr.earthdata.nasa.gov').length; - const expectedReads = reads(); - const initial = await page.evaluate(() => window.__godsEyeView.engine.kind); - for (const kind of [initial === 'navara' ? 'cesium' : 'navara', initial]) { - await strict.switchTo(page, kind); - engine = kind; - const state = await page.evaluate(() => ({ - same: window.__gevRecentImagery.layer === window.qaImageryOwner, - ...window.qaImageryOwner.diagnostics(), - })); - check( - kind + ' holds both original pins', - state.same && - state.mode === 'ab' && - state.pins.a === first.a && - state.pins.b === first.b, - ); - await sleep(1800); - const pixels = await capture('switch-' + kind); - check( - kind + ' renders both pinned dates', - red(pixels.left) && green(pixels.right), - ); - check(kind + ' reuses the STAC search', reads() === expectedReads); - } + const first = await page.evaluate(() => { + window.qaImageryOwner = window.__gevRecentImagery.layer; + return window.qaImageryOwner.diagnostics().pins; + }); + const reads = () => + report.requests.filter((r) => r.host === 'cmr.earthdata.nasa.gov').length; + const expectedReads = reads(); + const initial = await page.evaluate(() => window.__godsEyeView.engine.kind); + for (const kind of [initial === 'navara' ? 'cesium' : 'navara', initial]) { + await strict.switchTo(page, kind); + engine = kind; + const state = await page.evaluate(() => ({ + same: window.__gevRecentImagery.layer === window.qaImageryOwner, + ...window.qaImageryOwner.diagnostics(), + })); + check( + kind + ' holds both original pins', + state.same && + state.mode === 'ab' && + state.pins.a === first.a && + state.pins.b === first.b, + ); + await sleep(1800); + const pixels = await capture('switch-' + kind); + check( + kind + ' renders both pinned dates', + red(pixels.left) && green(pixels.right), + ); + check(kind + ' reuses the STAC search', reads() === expectedReads); } await page.evaluate(() => window.__gevRecentImagery.layer.swapSides()); image = await capture('swap'); diff --git a/scripts/qa-weather-engine-switch.mjs b/scripts/qa-weather-engine-switch.mjs index a6f9802a4..b550c85b5 100644 --- a/scripts/qa-weather-engine-switch.mjs +++ b/scripts/qa-weather-engine-switch.mjs @@ -1,7 +1,12 @@ import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; @@ -16,14 +21,8 @@ const strict = createStrict({ 'weather-cyclones', ], }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); const times = ['2026-11-02T12:00:00.000Z', '2026-11-02T12:05:00.000Z']; const ids = ['weather-radar', 'weather-satellite', 'weather-lightning']; const images = new Map(), @@ -168,21 +167,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { console.log('GPU', msg.text().slice(0, 3000)); }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); const waitTime = (ids, time) => p.waitForFunction( (ids, time) => @@ -251,8 +236,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { const polls = [...reads.entries()]; for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); } await waitTime(ids, times[0]); const state = await p.evaluate( @@ -364,7 +348,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { await p.select('#globe-engine', kind); - await ready(kind); + await waitForEngine(p, kind); } await p.waitForFunction( () => diff --git a/scripts/qa-wind-engine-switch.mjs b/scripts/qa-wind-engine-switch.mjs index 0c31e1bcc..ac155feba 100644 --- a/scripts/qa-wind-engine-switch.mjs +++ b/scripts/qa-wind-engine-switch.mjs @@ -1,6 +1,11 @@ import sharp from 'sharp'; import assert from 'node:assert/strict'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; const base = localPreview(process.argv[2]); base.search = '?engine=navara&welcome=0&look=cinematic'; @@ -9,14 +14,8 @@ base.hash = // The fixture field is uniform, so the GPU seeds a fixed particle count. const WIND_PATHS = 7200; const strict = createStrict({ origin: base.origin, layers: ['wind'] }); -await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { - const p = await b.newPage(); - await p.setViewport({ width: 1024, height: 768 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message.split('?')[0]); - }); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); const reads = []; await p.setRequestInterception(true); p.on('request', (req) => { @@ -70,21 +69,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { } }); await p.goto(base.href); - const ready = async (kind) => { - await p.waitForFunction( - (k) => - window.__godsEyeView?.engine?.kind === k && - !document.querySelector('#globe-engine')?.disabled && - !document.querySelector('.engine-switch-frame') && - document.querySelector('#loading-screen.hidden'), - { timeout: 90000 }, - kind, - ); - await p.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; - await ready('navara'); + await waitForEngine(p, 'navara'); await p.evaluate(async () => { const { dataManager, tileset } = window.__godsEyeView; if (tileset || window.__GOOGLE_MAPS_API_KEY__) @@ -103,8 +88,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { const baseline = reads.length; for (const kind of ['navara', 'cesium', 'navara']) { if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { - await strict.pick(p, kind); - await ready(kind); + await strict.switchTo(p, kind); } await p.waitForFunction( () => diff --git a/scripts/qa/appSessionHarness.mjs b/scripts/qa/appSessionHarness.mjs index 032f45f80..6b7666990 100644 --- a/scripts/qa/appSessionHarness.mjs +++ b/scripts/qa/appSessionHarness.mjs @@ -5,19 +5,7 @@ // the session ids, listener counts and transport counters. import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from '../qa-map-policy.mjs'; - -const RESERVED_PORTS = new Set([ - '', - '4173', - '4174', - '4185', - '4186', - '4187', - '4188', - '4189', - '4194', - '4196', -]); +import { localPreview, waitForEngine } from '../qa-strict-harness.mjs'; /** Paid or live model endpoints no switch gate may reach. */ export const BLOCKED_URLS = Object.freeze([ @@ -36,13 +24,7 @@ export const BLOCKED_URLS = Object.freeze([ /** The preview origin from argv, refusing the ports that belong to the user. */ export function previewBase(argument) { - const base = new URL(argument ?? ''); - if ( - !['localhost', '127.0.0.1'].includes(base.hostname) || - RESERVED_PORTS.has(base.port) - ) - throw new Error('Supply an unreserved local preview URL'); - return base; + return localPreview(argument ?? ''); } /** The page address: Boston on OSM, no welcome, on the given engine. */ @@ -247,20 +229,8 @@ export async function openSessionPage(browser, address) { } /** The engine runs, its picker is idle and the loading screen is gone. */ -export async function waitReady(page, kind) { - await page.waitForFunction( - (kind) => - window.__godsEyeView?.engine?.kind === kind && - document.querySelector('#globe-engine')?.value === kind && - !document.querySelector('#globe-engine').disabled && - document.getElementById('loading-screen')?.classList.contains('hidden') && - !document.querySelector('.engine-switch-frame'), - { timeout: 90_000, polling: 100 }, - kind, - ); - await page.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); +export function waitReady(page, kind) { + return waitForEngine(page, kind, { timeout: 90_000 }); } /** diff --git a/scripts/qa/tileCache.mjs b/scripts/qa/tileCache.mjs index 86070ade0..8b4c53edc 100644 --- a/scripts/qa/tileCache.mjs +++ b/scripts/qa/tileCache.mjs @@ -1,14 +1,12 @@ -// Map tiles for headless QA: a disk cache shared by every run (in the system -// temp directory) and one fetch per missing tile, with backoff on HTTP 429, -// for the free tile hosts the QA pages load (OpenStreetMap imagery, the -// keyless re:earth terrain). Back-to-back QA runs otherwise reload the same -// tiles until those hosts rate-limit them. Only requests to these hosts are -// paused (CDP Fetch with URL patterns); page-wide request interception also -// held the Navara workers' requests and kept workers alive past their globe. +// Map tiles for headless QA: the disk cache entry of a tile request and one +// fetch per missing tile, with backoff on HTTP 429, for the free tile hosts +// the QA pages load (OpenStreetMap imagery, the keyless re:earth terrain). +// Back-to-back QA runs otherwise reload the same tiles until those hosts +// rate-limit them. The strict harness (scripts/qa-strict-harness.mjs) pauses +// only the requests to these hosts (CDP Fetch with URL patterns) and answers +// them from the cache in the system temp directory. import { createHash } from 'node:crypto'; -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; export const CACHED_TILE_HOSTS = Object.freeze([ @@ -119,82 +117,3 @@ export function createMissLoader({ }, }; } - -/** - * Serves the page's requests to CACHED_TILE_HOSTS (or `hosts`) from the - * cache (filling it on a miss). Returns the counts. - */ -export async function installTileCache( - page, - { - dir = path.join(os.tmpdir(), 'gev-qa-tiles'), - hosts = CACHED_TILE_HOSTS, - } = {}, -) { - fs.mkdirSync(dir, { recursive: true }); - const stats = { hits: 0, fetched: 0, cancelled: 0, failed: 0, errors: {} }; - const client = await page.createCDPSession(); - const misses = createMissLoader(); - client.on('Fetch.requestPaused', async ({ requestId, request }) => { - const accept = Object.entries(request.headers ?? {}).find( - ([name]) => name.toLowerCase() === 'accept', - )?.[1]; - const file = - request.method === 'GET' - ? tileCacheFile(request.url, accept, dir, hosts) - : null; - try { - if (!file) - return await client.send('Fetch.continueRequest', { requestId }); - let entry = null; - if (fs.existsSync(`${file}.json`)) { - entry = { - ...JSON.parse(fs.readFileSync(`${file}.json`, 'utf8')), - body: fs.readFileSync(file), - }; - stats.hits++; - } else { - const tile = await misses.load(request.url, accept); - // Only successes are cached; a 404 (no tile there) is passed on. - if (tile.status === 200) { - fs.writeFileSync(file, tile.body); - fs.writeFileSync( - `${file}.json`, - JSON.stringify({ status: 200, contentType: tile.contentType }), - ); - stats.fetched++; - } else if (tile.status === 429) stats.failed++; - entry = tile; - } - await client.send('Fetch.fulfillRequest', { - requestId, - responseCode: entry.status, - responseHeaders: [ - ...(entry.contentType - ? [{ name: 'Content-Type', value: entry.contentType }] - : []), - { name: 'Access-Control-Allow-Origin', value: '*' }, - ], - body: entry.body.toString('base64'), - }); - } catch (error) { - // The page dropped the request meanwhile (a tile no longer in view). - if (/Invalid InterceptionId/.test(String(error?.message))) { - stats.cancelled++; - return; - } - stats.failed++; - const reason = String(error?.message ?? error).slice(0, 80); - stats.errors[reason] = (stats.errors[reason] ?? 0) + 1; - await client - .send('Fetch.failRequest', { requestId, errorReason: 'Failed' }) - .catch(() => {}); - } - }); - await client.send('Fetch.enable', { - patterns: hosts.map((host) => ({ - urlPattern: `https://${host}/*`, - })), - }); - return stats; -} diff --git a/src/annotations/drawInputNavara.js b/src/annotations/drawInputNavara.js index a3241c7f7..46242c826 100644 --- a/src/annotations/drawInputNavara.js +++ b/src/annotations/drawInputNavara.js @@ -14,7 +14,7 @@ const LINE_ID = 'gev-draw-preview'; const DOTS_ID = 'gev-draw-vertices'; /** '#rrggbb' → rgba in 0..1. */ -export function cssRgba(css, alpha = 1) { +function cssRgba(css, alpha = 1) { const hex = Number.parseInt(String(css).slice(1), 16); return [ ((hex >> 16) & 255) / 255, diff --git a/src/app/catalog.js b/src/app/catalog.js index f2915c70f..7cca7996a 100644 --- a/src/app/catalog.js +++ b/src/app/catalog.js @@ -68,14 +68,3 @@ export function catalogControlServices(catalog) { }), ); } - -/** - * Layer roles for controls on a page without data layers: one empty object - * per role, so the shell's optional calls - * (`layer.method?.()`) do nothing. - */ -export function absentLayerControlServices() { - return Object.fromEntries( - Object.keys(CONTROL_LAYER_IDS).map((role) => [role, {}]), - ); -} diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index 91d7e8ce1..0086e790b 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -1,10 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - absentLayerControlServices, - createLayerCatalog, - catalogControlServices, -} from './catalog.js'; +import { createLayerCatalog, catalogControlServices } from './catalog.js'; import { createApplicationData } from './data.js'; const metadata = (layers) => @@ -158,40 +154,6 @@ test('controls bind catalog instances rather than similarly named defaults', () ); }); -test('without data layers the controls get one empty stand-in per layer role', () => { - const ids = [ - 'traffic', - 'flights', - 'military', - 'satellites', - 'cctv', - 'street-level', - 'radio', - 'bikeshare', - 'transit', - 'ais-live-vessels', - 'military-awareness', - 'military-installations', - 'rocket-launches', - 'local-adsb', - ]; - const layers = ids.map((id) => ({ id })); - const roles = Object.keys( - catalogControlServices(createLayerCatalog(layers, metadata(layers))), - ); - const absent = absentLayerControlServices(); - assert.deepEqual(Object.keys(absent), roles); - for (const role of roles) { - assert.deepEqual(Object.keys(absent[role]), [], role); - assert.equal(absent[role].subscribe, undefined); - } - assert.notEqual(absent.flightsLayer, absent.trafficLayer); - assert.notEqual( - absentLayerControlServices().flightsLayer, - absent.flightsLayer, - ); -}); - test('data setup without a viewer registers an empty catalog and no sonar scene', async (t) => { const previous = globalThis.document; globalThis.document = { getElementById: () => null }; diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index f4eb55400..39b418db0 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -6,17 +6,17 @@ import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/c import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js'; import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js'; import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js'; -// Which engine draws which layer: the layer renderer registry (plan, "layer -// renderer interface") and the layer availability API built on it. +// Which engine draws which layer: the layer renderer registry and the layer +// availability API built on it. // // A renderer factory `(engine, options) => renderer` draws one layer on one // engine kind (`render/cesium.js`, `render/navara.js` of a layer). Cesium -// layers still draw themselves until their render/cesium.js is extracted -// (the *a tasks): they are registered as CESIUM_BUILTIN. Cesium entries are -// registered here, statically (main chunk); Navara factories are registered -// by the Navara mode after its import() (src/app/navaraMode.js). +// layers without a render/cesium.js draw themselves: they are registered as +// CESIUM_BUILTIN. Cesium entries are registered here, statically (main +// chunk); Navara factories are registered by the Navara mode after its +// import() (src/app/navaraMode.js). // -// ## Layer availability (stable API, used by phase 4's layer list) +// ## Layer availability (stable API, used by the layer list) // // import { createLayerAvailability } from './app/layers/renderers.js'; // const availability = createLayerAvailability(engine.kind); @@ -28,8 +28,8 @@ import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesiu // The object is frozen; available() and unavailable() return fresh arrays. // It reads the registry when called, so a Navara factory registered later // (after the Navara mode loads) shows up. `name` is the layer's display name -// (src/app/layers/directory.js). Phase 4 injects it as its `availability` -// provider (`{ engine, unavailable() }` is the part it reads). +// (src/app/layers/directory.js). The layer list takes it as its +// `availability` provider (`{ engine, unavailable() }` is the part it reads). import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js'; diff --git a/src/app/qaProbe.js b/src/app/qaProbe.js index 7eb044963..8edf63377 100644 --- a/src/app/qaProbe.js +++ b/src/app/qaProbe.js @@ -14,7 +14,7 @@ // drawn(id) what the layer draws per object (glyph, model), // as plain data, or null (layers with qaDrawn) // following() { label, native } of engine.camera.following() -// movers() engine.movers.list() +// movers() engine.movers.list() ([] without movers: Cesium) // framesDrawn() frames drawn since the probe started const plain = (position) => @@ -83,7 +83,7 @@ export function createQaProbe({ engine, dataManager, availability }) { native: handle.native?.id ?? null, }; }, - movers: () => engine.movers.list(), + movers: () => engine.movers?.list() ?? [], framesDrawn: () => frames, destroy: () => removeFrames(), }; diff --git a/src/app/scene.js b/src/app/scene.js index 1d6664720..944f453fa 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -217,11 +217,11 @@ async function createRenderer({ googleTileset: tileset, cesiumToken, initialStack: tileset ? 'photoreal' : 'esri-imagery', - // Task 5 (height-datum fix): rebroadcast stack changes as a window - // CustomEvent so data layers (CCTV per-regime ground resolution) can - // react without coupling MapStackController to layer modules. Fires on - // 'switching'/'ready'/'error'; listeners derive the surface regime from - // live scene state, so intermediate emissions are harmless. + // Rebroadcast stack changes as a window CustomEvent so data layers (CCTV + // per-regime ground resolution) can react without coupling + // MapStackController to layer modules. Fires on 'switching'/'ready'/ + // 'error'; listeners derive the surface regime from live scene state, so + // intermediate emissions are harmless. onChange: (state) => { window.dispatchEvent( new CustomEvent('gev:map-stack-changed', { detail: state }), diff --git a/src/app/sceneHost.js b/src/app/sceneHost.js index e3d1c65dd..3fe9dd89f 100644 --- a/src/app/sceneHost.js +++ b/src/app/sceneHost.js @@ -9,13 +9,6 @@ // a release that runs, in reverse, when the part detaches (before its // detachScene). -const STATES = Object.freeze([ - 'detached', - 'attaching', - 'attached', - 'detaching', -]); - export class SceneSwitchingError extends Error { constructor(message = 'The globe is switching engines') { super(message); @@ -208,5 +201,3 @@ export function createSceneHost({ onError = null } = {}) { }, }); } - -export const SCENE_HOST_STATES = STATES; diff --git a/src/app/tools.js b/src/app/tools.js index 2a4e7e159..dc7d5cf1d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -327,7 +327,7 @@ function attachViewerTools( ); // Install the explicit scope mask used by the DISPLAY controls. - installScopeMask(viewer); + installScopeMask(engine); defer(() => destroyScopeMask()); // The follow camera recomputes the tracked target's dead-reckon position diff --git a/src/app/zoomOutGuard.js b/src/app/zoomOutGuard.js index 092d368e8..5edb7f8fd 100644 --- a/src/app/zoomOutGuard.js +++ b/src/app/zoomOutGuard.js @@ -9,7 +9,7 @@ export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000; // with the first Cesium globe, after this module (src/app/loadCesium.js). const RADIANS_PER_DEGREE = Math.PI / 180; /** Look this far below the Earth's limb, so the globe stays on screen. */ -export const LIMB_MARGIN_RAD = 8 * RADIANS_PER_DEGREE; +const LIMB_MARGIN_RAD = 8 * RADIANS_PER_DEGREE; const PITCH_EASE = 0.35; const PITCH_SNAP_RAD = 0.1 * RADIANS_PER_DEGREE; const HEIGHT_EPSILON_M = 0.0001; diff --git a/src/bench/launchFixture.js b/src/bench/launchFixture.js index f3e6a4aa3..9f0812fdc 100644 --- a/src/bench/launchFixture.js +++ b/src/bench/launchFixture.js @@ -1,6 +1,6 @@ // Deterministic Launch Library 2 records for phase-3 mission/replay acceptance. import { SATELLITE_FIXTURE_EPOCH } from './satelliteFixture.js'; -export const LAUNCH_FIXTURE_EPOCH = SATELLITE_FIXTURE_EPOCH; +const LAUNCH_FIXTURE_EPOCH = SATELLITE_FIXTURE_EPOCH; export function launchFixture() { const definitions = [ [ diff --git a/src/bench/satelliteFixture.js b/src/bench/satelliteFixture.js index 7e46f3572..ca6fa03ed 100644 --- a/src/bench/satelliteFixture.js +++ b/src/bench/satelliteFixture.js @@ -1,4 +1,4 @@ -// Deterministic TLE catalogs for phase 3 browser QA; no live feed requests. +// Deterministic TLE catalogs for browser QA; no live feed requests. import { W1_FROZEN_MS } from './w1Feeds.js'; const checksum = (line) => { @@ -30,8 +30,6 @@ const GROUPS = { starlink: [50000, 6000, 53, 15.2, 'QA STARLINK'], }; export const SATELLITE_FIXTURE_EPOCH = W1_FROZEN_MS; -export const SATELLITE_FIXTURE_CORE_COUNT = 839; -export const SATELLITE_FIXTURE_DENSE_COUNT = 6839; export function satelliteFixture(group) { const spec = GROUPS[group]; if (!spec) throw new TypeError('Unknown fixture group'); diff --git a/src/cameraGroundGuard.test.mjs b/src/cameraGroundGuard.test.mjs index 04cb0b27e..9ffa794bb 100644 --- a/src/cameraGroundGuard.test.mjs +++ b/src/cameraGroundGuard.test.mjs @@ -57,6 +57,7 @@ function fakeViewer({ cameraHeightM, surfaceM, canvas = null }) { const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM); const viewer = { flights, + canvas, scene: { canvas, sampleHeightSupported: true, diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 86555c3f6..f58beb797 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -400,7 +400,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => assert.doesNotMatch(source, /camera\.flyTo/); } // Vessels' click rule is a function the engine's selection dispatcher - // calls (phase 3, 13a): the same body, out of the old LEFT_CLICK handler. + // calls: the same body, out of the old LEFT_CLICK handler. const vesselClick = body( vessels, /function handleClick\(click\) \{([\s\S]*?)\n \}/, diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 3f7e8efe9..652fa524e 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -2370,17 +2370,3 @@ export function flyRoute( }; return typeof runNavigation === 'function' ? runNavigation(start) : start(); } - -// QA (development builds only; production builds drop it): the verbs and -// their diagnostics, so a browser scenario can drive them on an engine whose -// voice tools do not offer them yet. -if (import.meta.env?.DEV && typeof window !== 'undefined') - window.__gevCameraVerbs = Object.freeze({ - cameraVerbsSupported, - flyRoute, - getActiveCameraMotion, - initCameraVerbs, - interruptCameraMotion, - moveCamera, - releaseCameraVerbs, - }); diff --git a/src/data/aircraftIcons.js b/src/data/aircraftIcons.js index 879a47b78..fbf8098c6 100644 --- a/src/data/aircraftIcons.js +++ b/src/data/aircraftIcons.js @@ -261,10 +261,8 @@ const TRACKED_RASTER_PX = 192; * Default size serves the fleet; pass `aircraftIcon(kind, TRACKED_ICON_PX)` * (re-exported below) for the tracked billboard. */ export const TRACKED_ICON_PX = TRACKED_RASTER_PX; -/** The fleet raster size (aircraftIcon's default). */ -export const FLEET_ICON_PX = FLEET_RASTER_PX; /** Every silhouette kind aircraftIcon draws (an engine's sprite atlas). */ -export const AIRCRAFT_ICON_KINDS = Object.freeze(Object.keys(BODIES)); +const AIRCRAFT_ICON_KINDS = Object.freeze(Object.keys(BODIES)); export function aircraftIcon(kind, px = FLEET_RASTER_PX) { const k = BODIES[kind] ? kind : 'airliner'; const key = `${k}@${px}`; diff --git a/src/data/cameraArrival.js b/src/data/cameraArrival.js index 4ba777170..82b3375a7 100644 --- a/src/data/cameraArrival.js +++ b/src/data/cameraArrival.js @@ -2,8 +2,8 @@ import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; /** * Arrival corrections belong to one globe engine and yield to its next camera - * owner. Callers may name the engine or (layers until phase 1c) the viewer it - * is registered for; both reach the same correction. + * owner. Callers may name the engine or (layers that still hold one) the + * viewer it is registered for; both reach the same correction. */ const arrivals = new WeakMap(); diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 953e0f695..07924b551 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -29,7 +29,7 @@ for (const layer of LAYERS) { test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => { // The aircraft renderer loads through its `loadModel`, Cesium.Model.fromGltfAsync - // unless a test injects a loader (phase 3). + // unless a test injects a loader. assert.match(source, /loadModel = \(options\) => Cesium\.Model\.fromGltfAsync\(options\)/); const calls = [...source.matchAll(/(?:Cesium\.Model\.fromGltfAsync|\bloadModel)\(\{([\s\S]*?)\}\)/g)]; assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index e5bc7fdbd..ae58d8ddb 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -34,7 +34,7 @@ export const OPENMAPTILES_CREDIT = { key: 'openfreemap', html: 'Vector tiles: OpenFreeMap © OpenMapTiles', }; -export const GOOGLE_PLACES_CREDIT = { +const GOOGLE_PLACES_CREDIT = { key: 'google-maps-places', html: 'Google Maps Places: Google Maps', }; @@ -45,7 +45,7 @@ const viewerCredits = new WeakMap(); /** * Credits of a globe engine, or of a Cesium viewer's credit display for - * callers that still hold a viewer (layers until phase 1c); null without one. + * callers that still hold a viewer (Cesium layers); null without one. * @returns {{ addStatic: Function, request: () => void } | null} */ function creditsOf(target) { diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 744da15e8..05197e845 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -143,7 +143,7 @@ test('every scene click handler consults ownership before it picks', () => { 'src/layers/submarineCables/interaction.js', 'viewer.scene.pick(click.position)', ], - // Vessels take the engine dispatcher's pick (phase 3, 13a): the guard + // Vessels take the engine dispatcher's pick: the guard // comes before the click rule reads it. ['src/layers/vessels/selection.js', 'resolvePickId(picked)'], ]; diff --git a/src/data/meshFloorSampler.test.mjs b/src/data/meshFloorSampler.test.mjs index 1f7ad388c..ef876f4b1 100644 --- a/src/data/meshFloorSampler.test.mjs +++ b/src/data/meshFloorSampler.test.mjs @@ -140,7 +140,7 @@ test('sampler on a globe engine: samples engine.surface and latches against the assert.equal(cachedMeshFloor(cell.lat, cell.lon), 137.0); const [call] = engine.fake.callsOf('surface.sampleHeightDetailed'); assert.ok(Math.abs(Cesium.Math.toDegrees(call.args[0].latitude) - cell.lat) < 1e-9); - assert.deepEqual(call.args[1], { exclude: ['own'], lod: 'visible' }); + assert.deepEqual(call.args[1], { exclude: ['own'] }); }); test('sampler: a coarse tile answer is a miss; a fine one latches', async () => { diff --git a/src/data/render/cesiumConstants.js b/src/data/render/cesiumConstants.js index afa1cab2a..ad9e83ada 100644 --- a/src/data/render/cesiumConstants.js +++ b/src/data/render/cesiumConstants.js @@ -1,7 +1,6 @@ // Native constants shared by the W1 adapters; keep the SDK import here. // Read on call: the production build's Cesium global loads with the first // Cesium globe, after this module has loaded on a Navara start. -import { Axis, ScreenSpaceEventType } from 'cesium'; +import { ScreenSpaceEventType } from 'cesium'; -export const modelAxis = () => Axis; export const trackingEventTypes = () => ScreenSpaceEventType; diff --git a/src/data/selectionDispatcher.js b/src/data/selectionDispatcher.js index 6354dbcf2..7e3c3f368 100644 --- a/src/data/selectionDispatcher.js +++ b/src/data/selectionDispatcher.js @@ -1,6 +1,6 @@ import { nearbyContact } from './pickRegistry.js'; -// One selection dispatcher for the layers that have moved onto the globe -// engine (phase 3 Task 4): one click subscription with picks +// 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 @@ -38,7 +38,7 @@ import { isTrackingSelectionGesture, } from './trackingClickGesture.js'; -export const SELECTION_DRILL = 8; +const SELECTION_DRILL = 8; /** * @param {import('../globe/contract.js').GlobeEngine} engine diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index 3d01a645a..002d6c9f1 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -109,7 +109,7 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r assert.deepEqual(calls, [viewer, viewer, viewer]); const firmsLayer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS' }); - // Flights draws through its renderer (phase 3): enable asks the fleet to + // Flights draws through its renderer: enable asks the fleet to // restore its order, which the Cesium aircraft renderer wires to the shared // restorer under the flights' sprite slot. assert.match(flightsLayer.enable.toString(), /fleet\.restoreOrderOnEnable\(\)/); diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index f1220ff9b..f0982e655 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -114,7 +114,7 @@ test('civilian and military click handlers apply duration only at the deselect b readLayerSource(new URL('./flights.js', import.meta.url)), readLayerSource(new URL('./militaryFlights.js', import.meta.url)), ]; - // Flights and military moved to the selection dispatcher (phase 3), whose + // Flights and military use the selection dispatcher, whose // source applies the same two rules: the selection gesture before a // select, the full click classifier before the empty-space deselect, which // each layer answers with a user-origin clear. diff --git a/src/geo/bitExact.test.mjs b/src/geo/bitExact.test.mjs index 3628a3c5a..dc93beeae 100644 --- a/src/geo/bitExact.test.mjs +++ b/src/geo/bitExact.test.mjs @@ -1,12 +1,15 @@ -// Seeded, deterministic bit-for-bit comparison of src/geo against Cesium. +// Seeded, deterministic bit-for-bit comparison of src/geo against Cesium's +// release build (Build/Cesium, debug checks stripped, as production ships it). // Every number is compared with Object.is (so -0 and NaN count), a throw on one // side must be a throw on the other, and `result` aliasing is exercised the // same way on both sides. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; +import { createRequire } from 'node:module'; import * as geo from './index.js'; +const Cesium = createRequire(import.meta.url)('cesium/Build/Cesium/index.cjs'); + const { cartesian3: c3, cartographic, @@ -108,7 +111,7 @@ const cesium = (v) => new C3(v.x, v.y, v.z); test('vector operations, including -0 inputs and aliased results, equal Cesium bit for bit', () => { const { scalar, vector } = inputs(1); - // Directionless vectors first: normalize must throw on both sides. + // Directionless vectors first: normalize returns NaN on both sides. const DEGENERATE = [ { x: 0, y: 0, z: 0 }, { x: -0, y: 0, z: -0 }, @@ -397,12 +400,7 @@ test('matrices and quaternions, including aliased results and every fromRotation ][i % 4]; const angle = i % 8 < 4 ? Math.PI - 0.5 * random() : (random() - 0.5) * 2 * Math.PI; - let m; - try { - m = rotation(axis, angle); - } catch { - continue; // a zero axis: Cesium throws, nothing to compare - } + const m = rotation(axis, angle); const trace = m[0] + m[4] + m[8]; branches.add( trace > 0 diff --git a/src/geo/boundingSphere.js b/src/geo/boundingSphere.js index a7581f20e..09870ba66 100644 --- a/src/geo/boundingSphere.js +++ b/src/geo/boundingSphere.js @@ -1,8 +1,6 @@ // Ported from CesiumJS 1.138 (Apache-2.0) — BoundingSphere.js subset. // Exact mission framing: Ritter/box fit and the union of two spheres. import * as Cartesian3 from './cartesian3.js'; -import { Check } from './check.js'; -import { DEBUG } from './debug.js'; const defined = (value) => value !== null && value !== undefined; export function create(center = Cartesian3.ZERO, radius = 0) { return { center: Cartesian3.clone(center), radius }; @@ -184,11 +182,6 @@ export function fromPoints(positions, result) { return result; } export function union(left, right, result) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - } - if (!defined(result)) { result = create(); } diff --git a/src/geo/cartesian2.js b/src/geo/cartesian2.js index 44cefb563..2f33aa902 100644 --- a/src/geo/cartesian2.js +++ b/src/geo/cartesian2.js @@ -1,17 +1,12 @@ // Ported from CesiumJS 1.138 (Apache-2.0) — Cartesian2.js // -// 2D vectors (screen coordinates) as plain-data {x, y} objects. With DEBUG on -// (development and tests; see debug.js) invalid arguments throw the same -// DeveloperError as Cesium's debug checks. +// 2D vectors (screen coordinates) as plain-data {x, y} objects. // // Geo values are not Object.prototype objects, so src/app/stateChannel.js // copyState and src/director/documentFields.js jsonTree reject them: copy // the fields into a literal before putting one into product state or a // scene document. -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; - // Values are instances of a module-private constructor rather than object // literals: every `{x, y}` literal in the process shares one V8 hidden // class, and code elsewhere that builds such literals would make geo's hot @@ -38,11 +33,6 @@ export function clone(cartesian, result) { /** The distance between two points. */ export function distance(left, right) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - } - const x = left.x - right.x; const y = left.y - right.y; return Math.sqrt(x * x + y * y); diff --git a/src/geo/cartesian3.js b/src/geo/cartesian3.js index aea1a5ba9..d3ec5dfd4 100644 --- a/src/geo/cartesian3.js +++ b/src/geo/cartesian3.js @@ -3,19 +3,14 @@ // 3D vectors as plain-data {x, y, z} objects. Arithmetic takes a required `result` // (as Cesium does) that may be one of the inputs; creators take an optional // one. The operation order matches Cesium so the outputs are bit-identical. -// With DEBUG on (development and tests; see debug.js) invalid arguments throw -// the same DeveloperError as Cesium's debug checks, and `normalize` throws on a -// zero or non-finite vector; production builds skip the checks and return NaN -// components there, as release Cesium does. +// As in release Cesium, arguments are not checked and `normalize` returns NaN +// components for a zero or non-finite vector. // // Geo values are not Object.prototype objects, so src/app/stateChannel.js // copyState and src/director/documentFields.js jsonTree reject them: copy // the fields into a literal before putting one into product state or a // scene document. -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; -import { DeveloperError } from './developerError.js'; import { equalsEpsilon as scalarEqualsEpsilon, toRadians } from './math.js'; // Values are instances of a module-private constructor rather than object @@ -59,10 +54,6 @@ export const UNIT_Y = Object.freeze(create(0.0, 1.0, 0.0)); export const UNIT_Z = Object.freeze(create(0.0, 0.0, 1.0)); export function magnitudeSquared(cartesian) { - if (DEBUG) { - Check.typeOf.object('cartesian', cartesian); - } - return ( cartesian.x * cartesian.x + cartesian.y * cartesian.y + @@ -77,60 +68,29 @@ export function magnitude(cartesian) { const distanceScratch = create(); export function distance(left, right) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - } - subtract(left, right, distanceScratch); return magnitude(distanceScratch); } export function distanceSquared(left, right) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - } - subtract(left, right, distanceScratch); return magnitudeSquared(distanceScratch); } -/** The unit vector; with DEBUG on, throws when the vector has no direction (zero or non-finite). */ +/** The unit vector; NaN components when the vector has no direction (zero or non-finite). */ export function normalize(cartesian, result) { - if (DEBUG) { - Check.typeOf.object('cartesian', cartesian); - Check.typeOf.object('result', result); - } - const length = magnitude(cartesian); result.x = cartesian.x / length; result.y = cartesian.y / length; result.z = cartesian.z / length; - if (DEBUG) { - if (isNaN(result.x) || isNaN(result.y) || isNaN(result.z)) { - throw new DeveloperError('normalized result is not a number'); - } - } return result; } export function dot(left, right) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - } - return left.x * right.x + left.y * right.y + left.z * right.z; } export function multiplyComponents(left, right, result) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - Check.typeOf.object('result', result); - } - result.x = left.x * right.x; result.y = left.y * right.y; result.z = left.z * right.z; @@ -138,12 +98,6 @@ export function multiplyComponents(left, right, result) { } export function add(left, right, result) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - Check.typeOf.object('result', result); - } - result.x = left.x + right.x; result.y = left.y + right.y; result.z = left.z + right.z; @@ -151,12 +105,6 @@ export function add(left, right, result) { } export function subtract(left, right, result) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - Check.typeOf.object('result', result); - } - result.x = left.x - right.x; result.y = left.y - right.y; result.z = left.z - right.z; @@ -164,12 +112,6 @@ export function subtract(left, right, result) { } export function multiplyByScalar(cartesian, scalar, result) { - if (DEBUG) { - Check.typeOf.object('cartesian', cartesian); - Check.typeOf.number('scalar', scalar); - Check.typeOf.object('result', result); - } - result.x = cartesian.x * scalar; result.y = cartesian.y * scalar; result.z = cartesian.z * scalar; @@ -177,12 +119,6 @@ export function multiplyByScalar(cartesian, scalar, result) { } export function divideByScalar(cartesian, scalar, result) { - if (DEBUG) { - Check.typeOf.object('cartesian', cartesian); - Check.typeOf.number('scalar', scalar); - Check.typeOf.object('result', result); - } - result.x = cartesian.x / scalar; result.y = cartesian.y / scalar; result.z = cartesian.z / scalar; @@ -190,11 +126,6 @@ export function divideByScalar(cartesian, scalar, result) { } export function negate(cartesian, result) { - if (DEBUG) { - Check.typeOf.object('cartesian', cartesian); - Check.typeOf.object('result', result); - } - result.x = -cartesian.x; result.y = -cartesian.y; result.z = -cartesian.z; @@ -204,25 +135,12 @@ export function negate(cartesian, result) { const lerpScratch = create(); export function lerp(start, end, t, result) { - if (DEBUG) { - Check.typeOf.object('start', start); - Check.typeOf.object('end', end); - Check.typeOf.number('t', t); - Check.typeOf.object('result', result); - } - multiplyByScalar(end, t, lerpScratch); result = multiplyByScalar(start, 1.0 - t, result); return add(lerpScratch, result, result); } export function cross(left, right, result) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - Check.typeOf.object('result', result); - } - const leftX = left.x; const leftY = left.y; const leftZ = left.z; @@ -241,12 +159,6 @@ export function cross(left, right, result) { } export function midpoint(left, right, result) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - Check.typeOf.object('result', result); - } - result.x = (left.x + right.x) * 0.5; result.y = (left.y + right.y) * 0.5; result.z = (left.z + right.z) * 0.5; @@ -292,11 +204,6 @@ let scratchK = create(); /** The Earth-fixed position of a geodetic one in radians on the ellipsoid (WGS84 by default). */ export function fromRadians(longitude, latitude, height, ellipsoid, result) { - if (DEBUG) { - Check.typeOf.number('longitude', longitude); - Check.typeOf.number('latitude', latitude); - } - height = height ?? 0.0; const radiiSquared = ellipsoid == null ? WGS84_RADII_SQUARED : ellipsoid.radiiSquared; @@ -318,11 +225,6 @@ export function fromRadians(longitude, latitude, height, ellipsoid, result) { /** The Earth-fixed position of a geodetic one in degrees on the ellipsoid (WGS84 by default). */ export function fromDegrees(longitude, latitude, height, ellipsoid, result) { - if (DEBUG) { - Check.typeOf.number('longitude', longitude); - Check.typeOf.number('latitude', latitude); - } - longitude = toRadians(longitude); latitude = toRadians(latitude); return fromRadians(longitude, latitude, height, ellipsoid, result); @@ -330,15 +232,6 @@ export function fromDegrees(longitude, latitude, height, ellipsoid, result) { /** Positions from a flat [lon, lat, lon, lat, …] list in degrees, at height 0. */ export function fromDegreesArray(coordinates, ellipsoid, result) { - if (DEBUG) { - Check.defined('coordinates', coordinates); - if (coordinates.length < 2 || coordinates.length % 2 !== 0) { - throw new DeveloperError( - 'the number of coordinates must be a multiple of 2 and at least 2', - ); - } - } - const length = coordinates.length; if (result == null) { result = new Array(length / 2); @@ -358,43 +251,9 @@ export function fromDegreesArray(coordinates, ellipsoid, result) { return result; } -/** Positions from a flat [lon, lat, height, …] list in degrees and metres. */ -export function fromDegreesArrayHeights(coordinates, ellipsoid, result) { - if (DEBUG) { - Check.defined('coordinates', coordinates); - if (coordinates.length < 3 || coordinates.length % 3 !== 0) { - throw new DeveloperError( - 'the number of coordinates must be a multiple of 3 and at least 3', - ); - } - } - - const length = coordinates.length; - if (result == null) { - result = new Array(length / 3); - } else { - result.length = length / 3; - } - for (let i = 0; i < length; i += 3) { - const index = i / 3; - result[index] = fromDegrees( - coordinates[i], - coordinates[i + 1], - coordinates[i + 2], - ellipsoid, - result[index], - ); - } - return result; -} - const angleBetweenScratch = create(); const angleBetweenScratch2 = create(); export function angleBetween(left, right) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - } normalize(left, angleBetweenScratch); normalize(right, angleBetweenScratch2); const cosine = dot(angleBetweenScratch, angleBetweenScratch2); diff --git a/src/geo/cartesian3.test.mjs b/src/geo/cartesian3.test.mjs index ba661f550..9217b3c99 100644 --- a/src/geo/cartesian3.test.mjs +++ b/src/geo/cartesian3.test.mjs @@ -21,78 +21,16 @@ const same = (ours, theirs, label) => label, ); -test('binary vector ops equal Cesium bit for bit', () => { - for (const a of SAMPLES) - for (const b of SAMPLES) { - for (const op of ['add', 'subtract', 'cross', 'multiplyComponents']) { - same( - c3[op](asPlain(a), asPlain(b), c3.create()), - C[op](asCesium(a), asCesium(b), new C()), - `${op}`, - ); - } - assert.equal( - c3.dot(asPlain(a), asPlain(b)), - C.dot(asCesium(a), asCesium(b)), - ); - assert.equal( - c3.distance(asPlain(a), asPlain(b)), - C.distance(asCesium(a), asCesium(b)), - ); - assert.equal( - c3.distanceSquared(asPlain(a), asPlain(b)), - C.distanceSquared(asCesium(a), asCesium(b)), - ); - same( - c3.lerp(asPlain(a), asPlain(b), 0.3, c3.create()), - C.lerp(asCesium(a), asCesium(b), 0.3, new C()), - 'lerp', - ); - same( - c3.midpoint(asPlain(a), asPlain(b), c3.create()), - C.midpoint(asCesium(a), asCesium(b), new C()), - 'midpoint', - ); - assert.equal( - c3.equalsEpsilon(asPlain(a), asPlain(b), 1e-7), - C.equalsEpsilon(asCesium(a), asCesium(b), 1e-7), - ); - } -}); - -test('unary ops and scalars equal Cesium', () => { +test('magnitudeSquared equals Cesium', () => { for (const a of SAMPLES) { - assert.equal(c3.magnitude(asPlain(a)), C.magnitude(asCesium(a))); assert.equal( c3.magnitudeSquared(asPlain(a)), C.magnitudeSquared(asCesium(a)), ); - same( - c3.negate(asPlain(a), c3.create()), - C.negate(asCesium(a), new C()), - 'negate', - ); - same( - c3.multiplyByScalar(asPlain(a), 2.5, c3.create()), - C.multiplyByScalar(asCesium(a), 2.5, new C()), - 'mul', - ); - same( - c3.divideByScalar(asPlain(a), 4, c3.create()), - C.divideByScalar(asCesium(a), 4, new C()), - 'div', - ); - if (C.magnitude(asCesium(a)) !== 0) { - same( - c3.normalize(asPlain(a), c3.create()), - C.normalize(asCesium(a), new C()), - 'normalize', - ); - } } }); -test('geodetic constructors equal Cesium', () => { +test('fromRadians and fromDegreesArray equal Cesium', () => { for (const [lon, lat, h] of [ [0, 0, 0], [-73.9857, 40.7484, 381], @@ -100,11 +38,6 @@ test('geodetic constructors equal Cesium', () => { [180, -90, 1e4], [12.5, 89.999999, 1], ]) { - same( - c3.fromDegrees(lon, lat, h), - C.fromDegrees(lon, lat, h), - `fromDegrees ${lon},${lat}`, - ); const lr = [Cesium.Math.toRadians(lon), Cesium.Math.toRadians(lat), h]; same(c3.fromRadians(...lr), C.fromRadians(...lr), 'fromRadians'); } @@ -112,10 +45,6 @@ test('geodetic constructors equal Cesium', () => { c3.fromDegreesArray(flat).forEach((p, i) => same(p, C.fromDegreesArray(flat)[i], `array ${i}`), ); - const heights = [-74, 40.7, 10, -73.9, 40.8, 20]; - c3.fromDegreesArrayHeights(heights).forEach((p, i) => - same(p, C.fromDegreesArrayHeights(heights)[i], `heights ${i}`), - ); }); test('result aliasing works like Cesium and creators reuse result', () => { @@ -138,20 +67,3 @@ test('plain results are accepted by Cesium functions (duck typing)', () => { ), ); }); - -test('normalize throws on a zero or NaN vector, as Cesium does when run from source', () => { - for (const [x, y, z] of [ - [0, 0, 0], - [-0, 0, -0], - [Number.NaN, 1, 2], - ]) { - assert.throws( - () => C.normalize(new C(x, y, z), new C()), - /normalized result is not a number/, - ); - assert.throws( - () => c3.normalize({ x, y, z }, c3.create()), - /normalized result is not a number/, - ); - } -}); diff --git a/src/geo/cartographic.js b/src/geo/cartographic.js index 5500a1f94..f123920cf 100644 --- a/src/geo/cartographic.js +++ b/src/geo/cartographic.js @@ -1,17 +1,13 @@ // Ported from CesiumJS 1.138 (Apache-2.0) — Cartographic.js // // Geodetic positions as plain-data {longitude, latitude, height} objects (radians and -// metres). Every function takes an optional `result` and returns it. With -// DEBUG on (development and tests; see debug.js) invalid arguments throw the -// same DeveloperError as Cesium's debug checks. +// metres). Every function takes an optional `result` and returns it. // // Geo values are not Object.prototype objects, so src/app/stateChannel.js // copyState and src/director/documentFields.js jsonTree reject them: copy // the fields into a literal before putting one into product state or a // scene document. -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; import { toRadians } from './math.js'; import * as cartesian3 from './cartesian3.js'; import { scaleToGeodeticSurface } from './scaleToGeodeticSurface.js'; @@ -51,11 +47,6 @@ const WGS84_CENTER_TOLERANCE_SQUARED = 0.1; /** A geodetic position from radians. */ export function fromRadians(longitude, latitude, height, result) { - if (DEBUG) { - Check.typeOf.number('longitude', longitude); - Check.typeOf.number('latitude', latitude); - } - height = height ?? 0.0; if (result == null) return create(longitude, latitude, height); result.longitude = longitude; @@ -66,11 +57,6 @@ export function fromRadians(longitude, latitude, height, result) { /** A geodetic position from degrees; the result is in radians. */ export function fromDegrees(longitude, latitude, height, result) { - if (DEBUG) { - Check.typeOf.number('longitude', longitude); - Check.typeOf.number('latitude', latitude); - } - longitude = toRadians(longitude); latitude = toRadians(latitude); return fromRadians(longitude, latitude, height, result); @@ -133,10 +119,6 @@ export function fromCartesian(cartesian, ellipsoid, result) { * where Cesium uses Ellipsoid.default; gev never changes it). */ export function toCartesian(cartographic, ellipsoid, result) { - if (DEBUG) { - Check.defined('cartographic', cartographic); - } - return cartesian3.fromRadians( cartographic.longitude, cartographic.latitude, diff --git a/src/geo/cartographic.test.mjs b/src/geo/cartographic.test.mjs index 724312821..20a73900b 100644 --- a/src/geo/cartographic.test.mjs +++ b/src/geo/cartographic.test.mjs @@ -4,12 +4,6 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import * as cartographic from './cartographic.js'; -const close = (a, b, tol, label) => - assert.ok( - Math.abs(a - b) <= tol * Math.max(1, Math.abs(b)), - `${label}: ${a} vs ${b}`, - ); - test('fromDegrees and fromRadians equal Cesium', () => { for (const [lon, lat, h] of [ [0, 0, 0], @@ -30,29 +24,6 @@ test('fromDegrees and fromRadians equal Cesium', () => { } }); -test('fromCartesian and toCartesian equal Cesium, including the centre case', () => { - for (const [x, y, z] of [ - [1334762.2, -4654869.1, 4137287.9], - [6378137, 0, 0], - [0.01, 0, 0], - ]) { - const ours = cartographic.fromCartesian({ x, y, z }); - const theirs = Cesium.Cartographic.fromCartesian( - new Cesium.Cartesian3(x, y, z), - ); - if (theirs === undefined) { - assert.equal(ours, undefined); - continue; - } - close(ours.longitude, theirs.longitude, 1e-12, 'lon'); - close(ours.latitude, theirs.latitude, 1e-12, 'lat'); - close(ours.height, theirs.height, 1e-9, 'h'); - const back = cartographic.toCartesian(ours); - const cBack = Cesium.Cartographic.toCartesian(theirs); - for (const k of ['x', 'y', 'z']) close(back[k], cBack[k], 1e-12, k); - } -}); - test('result objects are reused', () => { const out = cartographic.create(); assert.equal(cartographic.fromDegrees(1, 2, 3, out), out); diff --git a/src/geo/check.js b/src/geo/check.js deleted file mode 100644 index 16d83f368..000000000 --- a/src/geo/check.js +++ /dev/null @@ -1,61 +0,0 @@ -// Ported from CesiumJS 1.138 (Apache-2.0) — Check.js -// -// Cesium's argument checks with the same conditions and message text. Only the -// checks src/geo needs are ported. Callers run them inside `if (DEBUG)` blocks -// (see debug.js), as Cesium runs them inside its debug pragmas, so production -// builds drop the calls and this module with them. - -import { DeveloperError } from './developerError.js'; - -const undefinedMessage = (name) => - `${name} is required, actual value was undefined`; -const typeMessage = (actual, expected, name) => - `Expected ${name} to be typeof ${expected}, actual typeof was ${actual}`; - -/** Throws unless `test` is neither undefined nor null. */ -function defined(name, test) { - if (test === undefined || test === null) { - throw new DeveloperError(undefinedMessage(name)); - } -} - -/** Throws unless `typeof test` is 'number' (NaN passes, as in Cesium). */ -function number(name, test) { - if (typeof test !== 'number') { - throw new DeveloperError(typeMessage(typeof test, 'number', name)); - } -} - -// Attached in a function marked pure, not by top-level assignments, so that a -// production build, where no caller runs a check, drops this module entirely. -function withBounds(check) { - check.lessThanOrEquals = function (name, test, limit) { - check(name, test); - if (test > limit) { - throw new DeveloperError( - `Expected ${name} to be less than or equal to ${limit}, actual value was ${test}`, - ); - } - }; - check.greaterThanOrEquals = function (name, test, limit) { - check(name, test); - if (test < limit) { - throw new DeveloperError( - `Expected ${name} to be greater than or equal to ${limit}, actual value was ${test}`, - ); - } - }; - return check; -} - -/** Throws unless `typeof test` is 'object' (null passes, as in Cesium). */ -function object(name, test) { - if (typeof test !== 'object') { - throw new DeveloperError(typeMessage(typeof test, 'object', name)); - } -} - -export const Check = /* @__PURE__ */ Object.freeze({ - defined, - typeOf: Object.freeze({ number: /* @__PURE__ */ withBounds(number), object }), -}); diff --git a/src/geo/checks.test.mjs b/src/geo/checks.test.mjs deleted file mode 100644 index 41f59b0d0..000000000 --- a/src/geo/checks.test.mjs +++ /dev/null @@ -1,486 +0,0 @@ -// Argument-check parity with Cesium. gev runs Cesium from source, where its -// debug checks are live, so for any input both sides must throw an error with -// the same name and message, or neither may throw. The inputs are seeded and -// deterministic: each case builds its arguments twice from the same seed, once -// for geo and once for Cesium, so no object is shared between the two sides. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import * as geo from './index.js'; -import { DeveloperError } from './developerError.js'; - -const TRIALS = 150; - -function generator(seed) { - return () => { - seed = (seed + 0x6d2b79f5) | 0; - let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -// Invalid values every argument may receive. NaN is a number to Check.typeOf.number -// and null an object to Check.typeOf.object, so both reach the arithmetic. -const INVALID = [undefined, null, Number.NaN, 'abc', 5, true, {}]; - -const vectorValues = { x: 1.5, y: -2, z: 3 }; -const VALID = { - num: (side, random) => [0, -0, 0.7, -2.5, 1e6][Math.floor(random() * 5)], - index3: (side, random) => Math.floor(random() * 5) - 1, - index4: (side, random) => Math.floor(random() * 6) - 1, - nonNegative: (side, random) => [0, 3, 6378137, -1][Math.floor(random() * 4)], - vec: (side) => - side === 'geo' ? { ...vectorValues } : new Cesium.Cartesian3(1.5, -2, 3), - zeroVec: (side) => - side === 'geo' ? { x: 0, y: 0, z: 0 } : new Cesium.Cartesian3(), - vec4: (side) => - side === 'geo' ? { x: 0, y: 0, z: 0, w: 0 } : new Cesium.Cartesian4(), - carto: (side) => - side === 'geo' - ? { longitude: 0.3, latitude: -0.2, height: 10 } - : new Cesium.Cartographic(0.3, -0.2, 10), - partialCarto: (side) => - side === 'geo' - ? { longitude: 0.3, latitude: -0.2 } - : { longitude: 0.3, latitude: -0.2 }, - quat: (side) => - side === 'geo' - ? { x: 0.1, y: 0.2, z: 0.3, w: 0.9 } - : new Cesium.Quaternion(0.1, 0.2, 0.3, 0.9), - mat3: (side) => - side === 'geo' - ? geo.matrix3.fromRotationZ(0.4) - : Cesium.Matrix3.fromRotationZ(0.4, new Cesium.Matrix3()), - mat4: (side) => - side === 'geo' - ? geo.transforms.eastNorthUpToFixedFrame({ x: 6378137, y: 1, z: 2 }) - : Cesium.Transforms.eastNorthUpToFixedFrame( - new Cesium.Cartesian3(6378137, 1, 2), - ), - hpr: (side) => - side === 'geo' - ? { heading: 0.3, pitch: -0.2, roll: 0.1 } - : new Cesium.HeadingPitchRoll(0.3, -0.2, 0.1), - partialHpr: () => ({ heading: 0.3 }), - ellipsoid: (side) => - side === 'geo' ? geo.ellipsoid.WGS84 : Cesium.Ellipsoid.WGS84, - coords2: (side, random) => - [[1, 2], [1, 2, 3], [], [1, 2, 3, 4]][Math.floor(random() * 4)], - coords3: (side, random) => - [[1, 2, 3], [1, 2], [], [1, 2, 3, 4, 5, 6], [1, 2, 'h']][ - Math.floor(random() * 5) - ], - nanVec: (side) => - side === 'geo' - ? { x: 1, y: Number.NaN, z: 2 } - : new Cesium.Cartesian3(1, Number.NaN, 2), - partialVec: () => ({ x: 1, y: 2 }), - stringHeight: () => '5', -}; - -/** One argument: usually a valid value of its kind, sometimes an invalid one. */ -function argument(kinds, side, random) { - if (random() < 0.4) return INVALID[Math.floor(random() * INVALID.length)]; - const kind = kinds[Math.floor(random() * kinds.length)]; - return VALID[kind](side, random); -} - -const W = Cesium.Ellipsoid.WGS84; -const C3 = Cesium.Cartesian3; -const V = ['vec', 'zeroVec', 'nanVec', 'partialVec']; -const N = ['num']; -const R = ['vec']; - -// [name, geo function, Cesium function, argument kinds per position] -const CASES = [ - ['math.toRadians', geo.math.toRadians, Cesium.Math.toRadians, [N]], - ['math.toDegrees', geo.math.toDegrees, Cesium.Math.toDegrees, [N]], - ['math.clamp', geo.math.clamp, Cesium.Math.clamp, [N, N, N]], - ['math.lerp', geo.math.lerp, Cesium.Math.lerp, [N, N, N]], - ['math.mod', geo.math.mod, Cesium.Math.mod, [N, ['num', 'nonNegative']]], - ['math.zeroToTwoPi', geo.math.zeroToTwoPi, Cesium.Math.zeroToTwoPi, [N]], - [ - 'math.negativePiToPi', - geo.math.negativePiToPi, - Cesium.Math.negativePiToPi, - [N], - ], - [ - 'math.equalsEpsilon', - geo.math.equalsEpsilon, - Cesium.Math.equalsEpsilon, - [N, N, N, N], - ], - - ['cartesian2.clone', geo.cartesian2.clone, Cesium.Cartesian2.clone, [R, R]], - [ - 'cartesian2.distance', - geo.cartesian2.distance, - Cesium.Cartesian2.distance, - [R, R], - ], - - ['cartesian3.clone', geo.cartesian3.clone, C3.clone, [V, R]], - [ - 'cartesian3.fromElements', - geo.cartesian3.fromElements, - C3.fromElements, - [N, N, N, R], - ], - ['cartesian3.magnitude', geo.cartesian3.magnitude, C3.magnitude, [V]], - [ - 'cartesian3.magnitudeSquared', - geo.cartesian3.magnitudeSquared, - C3.magnitudeSquared, - [V], - ], - ['cartesian3.distance', geo.cartesian3.distance, C3.distance, [V, V]], - [ - 'cartesian3.distanceSquared', - geo.cartesian3.distanceSquared, - C3.distanceSquared, - [V, V], - ], - ['cartesian3.normalize', geo.cartesian3.normalize, C3.normalize, [V, R]], - ['cartesian3.dot', geo.cartesian3.dot, C3.dot, [V, V]], - [ - 'cartesian3.multiplyComponents', - geo.cartesian3.multiplyComponents, - C3.multiplyComponents, - [V, V, R], - ], - ['cartesian3.add', geo.cartesian3.add, C3.add, [V, V, R]], - ['cartesian3.subtract', geo.cartesian3.subtract, C3.subtract, [V, V, R]], - [ - 'cartesian3.multiplyByScalar', - geo.cartesian3.multiplyByScalar, - C3.multiplyByScalar, - [V, N, R], - ], - [ - 'cartesian3.divideByScalar', - geo.cartesian3.divideByScalar, - C3.divideByScalar, - [V, N, R], - ], - ['cartesian3.negate', geo.cartesian3.negate, C3.negate, [V, R]], - ['cartesian3.lerp', geo.cartesian3.lerp, C3.lerp, [V, V, N, R]], - ['cartesian3.cross', geo.cartesian3.cross, C3.cross, [V, V, R]], - ['cartesian3.midpoint', geo.cartesian3.midpoint, C3.midpoint, [V, V, R]], - ['cartesian3.equals', geo.cartesian3.equals, C3.equals, [V, V]], - [ - 'cartesian3.equalsEpsilon', - geo.cartesian3.equalsEpsilon, - C3.equalsEpsilon, - [V, V, N, N], - ], - [ - 'cartesian3.fromRadians', - geo.cartesian3.fromRadians, - C3.fromRadians, - [N, N, ['num', 'stringHeight'], ['ellipsoid'], R], - ], - [ - 'cartesian3.fromDegrees', - geo.cartesian3.fromDegrees, - C3.fromDegrees, - [N, N, ['num', 'stringHeight'], ['ellipsoid'], R], - ], - [ - 'cartesian3.fromDegreesArray', - geo.cartesian3.fromDegreesArray, - C3.fromDegreesArray, - [['coords2'], ['ellipsoid']], - ], - [ - 'cartesian3.fromDegreesArrayHeights', - geo.cartesian3.fromDegreesArrayHeights, - C3.fromDegreesArrayHeights, - [['coords3'], ['ellipsoid']], - ], - - [ - 'cartographic.fromRadians', - geo.cartographic.fromRadians, - Cesium.Cartographic.fromRadians, - [N, N, N, ['carto']], - ], - [ - 'cartographic.fromDegrees', - geo.cartographic.fromDegrees, - Cesium.Cartographic.fromDegrees, - [N, N, N, ['carto']], - ], - [ - 'cartographic.fromCartesian', - geo.cartographic.fromCartesian, - Cesium.Cartographic.fromCartesian, - [V, ['ellipsoid'], ['carto']], - ], - [ - 'cartographic.toCartesian', - geo.cartographic.toCartesian, - Cesium.Cartographic.toCartesian, - [['carto', 'partialCarto'], ['ellipsoid'], R], - ], - [ - 'cartographic.clone', - geo.cartographic.clone, - Cesium.Cartographic.clone, - [['carto'], ['carto']], - ], - - [ - 'ellipsoid.create', - geo.ellipsoid.create, - (x, y, z) => new Cesium.Ellipsoid(x, y, z), - [['nonNegative'], ['nonNegative'], ['nonNegative']], - ], - [ - 'WGS84.geodeticSurfaceNormal', - geo.ellipsoid.WGS84.geodeticSurfaceNormal, - (c, r) => W.geodeticSurfaceNormal(c, r), - [V, R], - ], - [ - 'WGS84.geodeticSurfaceNormalCartographic', - geo.ellipsoid.WGS84.geodeticSurfaceNormalCartographic, - (c, r) => W.geodeticSurfaceNormalCartographic(c, r), - [['carto', 'partialCarto'], R], - ], - [ - 'WGS84.scaleToGeodeticSurface', - geo.ellipsoid.WGS84.scaleToGeodeticSurface, - (c, r) => W.scaleToGeodeticSurface(c, r), - [V, R], - ], - [ - 'WGS84.cartographicToCartesian', - geo.ellipsoid.WGS84.cartographicToCartesian, - (c, r) => W.cartographicToCartesian(c, r), - [['carto', 'partialCarto'], R], - ], - [ - 'WGS84.cartesianToCartographic', - geo.ellipsoid.WGS84.cartesianToCartographic, - (c, r) => W.cartesianToCartographic(c, r), - [V, ['carto']], - ], - - [ - 'matrix3.fromQuaternion', - geo.matrix3.fromQuaternion, - Cesium.Matrix3.fromQuaternion, - [['quat'], ['mat3']], - ], - [ - 'matrix3.fromRotationZ', - geo.matrix3.fromRotationZ, - Cesium.Matrix3.fromRotationZ, - [N, ['mat3']], - ], - [ - 'matrix3.multiplyByVector', - geo.matrix3.multiplyByVector, - Cesium.Matrix3.multiplyByVector, - [['mat3'], V, R], - ], - [ - 'matrix3.getColumn', - geo.matrix3.getColumn, - Cesium.Matrix3.getColumn, - [['mat3'], ['index3'], R], - ], - - [ - 'matrix4.clone', - geo.matrix4.clone, - Cesium.Matrix4.clone, - [['mat4'], ['mat4']], - ], - [ - 'matrix4.equals', - geo.matrix4.equals, - Cesium.Matrix4.equals, - [['mat4'], ['mat4']], - ], - [ - 'matrix4.multiply', - geo.matrix4.multiply, - Cesium.Matrix4.multiply, - [['mat4'], ['mat4'], ['mat4']], - ], - [ - 'matrix4.multiplyTransformation', - geo.matrix4.multiplyTransformation, - Cesium.Matrix4.multiplyTransformation, - [['mat4'], ['mat4'], ['mat4']], - ], - [ - 'matrix4.multiplyByPoint', - geo.matrix4.multiplyByPoint, - Cesium.Matrix4.multiplyByPoint, - [['mat4'], V, R], - ], - [ - 'matrix4.multiplyByPointAsVector', - geo.matrix4.multiplyByPointAsVector, - Cesium.Matrix4.multiplyByPointAsVector, - [['mat4'], V, R], - ], - [ - 'matrix4.getTranslation', - geo.matrix4.getTranslation, - Cesium.Matrix4.getTranslation, - [['mat4'], R], - ], - [ - 'matrix4.getMatrix3', - geo.matrix4.getMatrix3, - Cesium.Matrix4.getMatrix3, - [['mat4'], ['mat3']], - ], - [ - 'matrix4.getColumn', - geo.matrix4.getColumn, - Cesium.Matrix4.getColumn, - [['mat4'], ['index4'], ['vec4']], - ], - [ - 'matrix4.inverseTransformation', - geo.matrix4.inverseTransformation, - Cesium.Matrix4.inverseTransformation, - [['mat4'], ['mat4']], - ], - [ - 'matrix4.fromRotationTranslation', - geo.matrix4.fromRotationTranslation, - Cesium.Matrix4.fromRotationTranslation, - [['mat3'], R, ['mat4']], - ], - [ - 'matrix4.fromUniformScale', - geo.matrix4.fromUniformScale, - Cesium.Matrix4.fromUniformScale, - [N, ['mat4']], - ], - - [ - 'quaternion.fromAxisAngle', - geo.quaternion.fromAxisAngle, - Cesium.Quaternion.fromAxisAngle, - [V, N, ['quat']], - ], - [ - 'quaternion.fromRotationMatrix', - geo.quaternion.fromRotationMatrix, - Cesium.Quaternion.fromRotationMatrix, - [['mat3'], ['quat']], - ], - - [ - 'transforms.eastNorthUpToFixedFrame', - geo.transforms.eastNorthUpToFixedFrame, - Cesium.Transforms.eastNorthUpToFixedFrame, - [V, ['ellipsoid'], ['mat4']], - ], - [ - 'transforms.headingPitchRollToFixedFrame', - geo.transforms.headingPitchRollToFixedFrame, - Cesium.Transforms.headingPitchRollToFixedFrame, - [V, ['hpr', 'partialHpr'], ['ellipsoid'], [], ['mat4']], - ], -]; - -function outcome(fn, args) { - try { - fn(...args); - return 'returned'; - } catch (error) { - return `${error?.name}: ${error?.message}`; - } -} - -test('every ported function throws exactly where Cesium does, with the same name and message', () => { - let thrown = 0; - let checked = 0; - const mismatches = []; - CASES.forEach(([name, ours, theirs, kinds], caseIndex) => { - for (let trial = 0; trial < TRIALS; trial++) { - const seed = caseIndex * 100003 + trial; - // Trial 0 passes no arguments at all; the rest draw from the pools. - const build = (side) => { - if (trial === 0) return []; - const random = generator(seed); - return kinds.map((k) => - k.length ? argument(k, side, random) : undefined, - ); - }; - const a = outcome(ours, build('geo')); - const b = outcome(theirs, build('cesium')); - if (a !== b) - mismatches.push(`${name} (seed ${seed}): geo ${a} | Cesium ${b}`); - checked++; - if (b !== 'returned') thrown++; - } - }); - assert.deepEqual(mismatches, []); - // The pools must actually reach the checks. - assert.ok(thrown > checked / 4, `only ${thrown} of ${checked} cases threw`); -}); - -test('the checks throw a DeveloperError with Cesium message text', () => { - const cases = [ - [() => geo.math.toRadians(undefined), 'degrees is required.'], - [() => geo.math.mod(5, 0), 'divisor cannot be 0.'], - [ - () => geo.math.clamp('1', 0, 1), - 'Expected value to be typeof number, actual typeof was string', - ], - [ - () => geo.cartesian3.fromDegrees(undefined, 1), - 'Expected longitude to be typeof number, actual typeof was undefined', - ], - [ - () => geo.cartesian3.add({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }), - 'Expected result to be typeof object, actual typeof was undefined', - ], - [ - () => geo.cartesian3.fromDegreesArray([1]), - 'the number of coordinates must be a multiple of 2 and at least 2', - ], - [ - () => geo.cartesian3.fromDegreesArray(), - 'coordinates is required, actual value was undefined', - ], - [ - () => geo.ellipsoid.create(-1, 1, 1), - 'Expected x to be greater than or equal to 0, actual value was -1', - ], - [ - () => geo.matrix3.getColumn(geo.matrix3.create(), 3, {}), - 'Expected index to be less than or equal to 2, actual value was 3', - ], - [ - () => - geo.transforms.eastNorthUpToFixedFrame({ x: 1, y: Number.NaN, z: 0 }), - 'origin has a NaN component', - ], - [ - () => geo.cartographic.fromCartesian({ x: 1, y: 2, z: 3 }, {}), - 'oneOverRadii is required.', - ], - ]; - for (const [fn, message] of cases) { - assert.throws(fn, (error) => { - assert.ok(error instanceof DeveloperError); - assert.equal(error.name, 'DeveloperError'); - assert.equal(error.message, message); - return true; - }); - } - // NaN is typeof number, so Check.typeOf.number lets it through (as in Cesium). - assert.ok(Number.isNaN(geo.math.clamp(Number.NaN, 0, 1))); - assert.ok(Number.isNaN(Cesium.Math.clamp(Number.NaN, 0, 1))); -}); diff --git a/src/geo/debug.js b/src/geo/debug.js deleted file mode 100644 index b3eed7c23..000000000 --- a/src/geo/debug.js +++ /dev/null @@ -1,12 +0,0 @@ -// Whether src/geo runs Cesium's debug argument checks. -// -// Cesium wraps its argument checks in `//>>includeStart('debug')` pragmas: they -// are live in its Source modules (Node tests, the Vite dev server) and stripped -// from Build/Cesium, which vite-plugin-cesium copies into production builds. -// src/geo does the same: every Check call and every debug throw sits in an -// `if (DEBUG)` block. DEBUG is true in Node (no import.meta.env) and in Vite -// dev; `vite build` folds it to false and the minifier drops the blocks, so a -// production build returns what release Cesium returns (NaN, not a throw, for -// a degenerate input). - -export const DEBUG = import.meta.env?.PROD !== true; diff --git a/src/geo/developerError.js b/src/geo/developerError.js deleted file mode 100644 index 459a8bacf..000000000 --- a/src/geo/developerError.js +++ /dev/null @@ -1,13 +0,0 @@ -// Ported from CesiumJS 1.138 (Apache-2.0) — DeveloperError.js -// -// The error Cesium throws for invalid arguments. Its debug argument checks are -// live in Cesium's Source modules (Node tests, the Vite dev server) and -// stripped from production builds; src/geo throws the same name and message -// wherever Cesium does while DEBUG is on (see debug.js). - -export class DeveloperError extends Error { - constructor(message) { - super(message); - this.name = 'DeveloperError'; - } -} diff --git a/src/geo/ellipsoid.js b/src/geo/ellipsoid.js index 2da0bcaaf..0ab69d17b 100644 --- a/src/geo/ellipsoid.js +++ b/src/geo/ellipsoid.js @@ -3,14 +3,9 @@ // A quadratic surface x²/a² + y²/b² + z²/c² = 1 as a plain, frozen object. // Every method takes an optional `result` and returns it (a new object when it // is absent); `result` may be the input. The algorithms and the operation order -// match Cesium so the outputs are bit-identical. With DEBUG on (development -// and tests; see debug.js) invalid arguments throw the same DeveloperError as -// Cesium's debug checks. +// match Cesium so the outputs are bit-identical. -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; import { create as createCartographic } from './cartographic.js'; -import { DeveloperError } from './developerError.js'; import { EPSILON14 } from './math.js'; import { scaleToGeodeticSurface } from './scaleToGeodeticSurface.js'; import { @@ -39,11 +34,6 @@ export function create(x, y, z) { x = x ?? 0.0; y = y ?? 0.0; z = z ?? 0.0; - if (DEBUG) { - Check.typeOf.number.greaterThanOrEquals('x', x, 0.0); - Check.typeOf.number.greaterThanOrEquals('y', y, 0.0); - Check.typeOf.number.greaterThanOrEquals('z', z, 0.0); - } const radii = Object.freeze(vec(x, y, z)); const radiiSquared = Object.freeze(vec(x * x, y * y, z * z)); @@ -68,10 +58,6 @@ export function create(x, y, z) { /** The normal to the ellipsoid surface at a geodetic position. */ function geodeticSurfaceNormalCartographic(cartographic, result) { - if (DEBUG) { - Check.typeOf.object('cartographic', cartographic); - } - const longitude = cartographic.longitude; const latitude = cartographic.latitude; const cosLatitude = Math.cos(latitude); @@ -89,13 +75,6 @@ export function create(x, y, z) { /** The normal to the ellipsoid surface at a position, or `undefined` at the centre. */ function geodeticSurfaceNormal(cartesian, result) { - if (DEBUG) { - Check.typeOf.object('cartesian', cartesian); - if (isNaN(cartesian.x) || isNaN(cartesian.y) || isNaN(cartesian.z)) { - throw new DeveloperError('cartesian has a NaN component'); - } - } - if (equalsEpsilon(cartesian, ZERO, EPSILON14)) { return undefined; } diff --git a/src/geo/ellipsoid.test.mjs b/src/geo/ellipsoid.test.mjs index 5389c8a2b..cd3e98ab3 100644 --- a/src/geo/ellipsoid.test.mjs +++ b/src/geo/ellipsoid.test.mjs @@ -12,18 +12,6 @@ const close = (a, b, tol, label) => const closeVec = (a, b, tol, label) => { for (const k of ['x', 'y', 'z']) close(a[k], b[k], tol, `${label}.${k}`); }; -const POINTS = [ - [6378137, 0, 0], - [0, 6378137, 0], - [0, 0, 6356752.314245], - [1334762.2, -4654869.1, 4137287.9], - [-2694045, -4293642, 3857878], - [1e3, 1e3, 1e3], - [0.01, 0.01, 0.01], - [7e6, -2e6, 1e6], - [4510000, 4510000, 1], - [1, 1, 6400000], -]; test('WGS84 constants equal Cesium', () => { const c = Cesium.Ellipsoid.WGS84; @@ -40,39 +28,6 @@ test('WGS84 constants equal Cesium', () => { assert.equal(WGS84.maximumRadius, c.maximumRadius); }); -test('surface normals, scaling and conversions equal Cesium', () => { - const c = Cesium.Ellipsoid.WGS84; - for (const [x, y, z] of POINTS) { - const p = { x, y, z }; - const cp = new Cesium.Cartesian3(x, y, z); - closeVec( - WGS84.geodeticSurfaceNormal(p), - c.geodeticSurfaceNormal(cp), - 1e-12, - 'normal', - ); - const ours = WGS84.scaleToGeodeticSurface(p); - const theirs = c.scaleToGeodeticSurface(cp); - if (theirs === undefined) assert.equal(ours, undefined); - else closeVec(ours, theirs, 1e-12, 'scale'); - const carto = WGS84.cartesianToCartographic(p); - const cCarto = c.cartesianToCartographic(cp); - if (cCarto === undefined) { - assert.equal(carto, undefined); - } else { - close(carto.longitude, cCarto.longitude, 1e-12, 'lon'); - close(carto.latitude, cCarto.latitude, 1e-12, 'lat'); - close(carto.height, cCarto.height, 1e-9, 'height'); - closeVec( - WGS84.cartographicToCartesian(carto), - c.cartographicToCartesian(cCarto), - 1e-12, - 'roundtrip', - ); - } - } -}); - test('result parameters are filled in place and may alias the input', () => { const p = { x: 1334762.2, y: -4654869.1, z: 4137287.9 }; const out = { x: 0, y: 0, z: 0 }; diff --git a/src/geo/ellipsoidGeodesic.js b/src/geo/ellipsoidGeodesic.js index ac87a6102..fa88c76bd 100644 --- a/src/geo/ellipsoidGeodesic.js +++ b/src/geo/ellipsoidGeodesic.js @@ -1,11 +1,8 @@ // Ported from CesiumJS 1.138 (Apache-2.0) — EllipsoidGeodesic.js. // Vincenty inverse and surface interpolation, preserving operation order. -import * as Cartesian3 from './cartesian3.js'; import * as Cartographic from './cartographic.js'; import * as Ellipsoid from './ellipsoid.js'; import * as CesiumMath from './math.js'; -import { Check } from './check.js'; -import { DEBUG } from './debug.js'; const defined = (value) => value !== null && value !== undefined; function setConstants(ellipsoidGeodesic) { const uSquared = ellipsoidGeodesic._uSquared; @@ -230,29 +227,7 @@ function vincentyInverseFormula( ellipsoidGeodesic._uSquared = uSquared; } -const scratchCart1 = Cartesian3.create(); -const scratchCart2 = Cartesian3.create(); function computeProperties(ellipsoidGeodesic, start, end, ellipsoid) { - const firstCartesian = Cartesian3.normalize( - ellipsoid.cartographicToCartesian(start, scratchCart2), - scratchCart1, - ); - const lastCartesian = Cartesian3.normalize( - ellipsoid.cartographicToCartesian(end, scratchCart2), - scratchCart2, - ); - - if (DEBUG) { - Check.typeOf.number.greaterThanOrEquals( - 'value', - Math.abs( - Math.abs(Cartesian3.angleBetween(firstCartesian, lastCartesian)) - - Math.PI, - ), - 0.0125, - ); - } - vincentyInverseFormula( ellipsoidGeodesic, ellipsoid.maximumRadius, @@ -322,10 +297,6 @@ Object.defineProperties(EllipsoidGeodesic.prototype, { */ surfaceDistance: { get: function () { - if (DEBUG) { - Check.defined('distance', this._distance); - } - return this._distance; }, }, @@ -362,10 +333,6 @@ Object.defineProperties(EllipsoidGeodesic.prototype, { */ startHeading: { get: function () { - if (DEBUG) { - Check.defined('distance', this._distance); - } - return this._startHeading; }, }, @@ -378,10 +345,6 @@ Object.defineProperties(EllipsoidGeodesic.prototype, { */ endHeading: { get: function () { - if (DEBUG) { - Check.defined('distance', this._distance); - } - return this._endHeading; }, }, @@ -394,11 +357,6 @@ Object.defineProperties(EllipsoidGeodesic.prototype, { * @param {Cartographic} end The final planetodetic point on the path. */ EllipsoidGeodesic.prototype.setEndPoints = function (start, end) { - if (DEBUG) { - Check.defined('start', start); - Check.defined('end', end); - } - computeProperties(this, start, end, this._ellipsoid); }; @@ -425,17 +383,11 @@ EllipsoidGeodesic.prototype.interpolateUsingFraction = function ( * @param {number} distance The distance from the initial point to the point of interest along the geodesic * @param {Cartographic} [result] The object in which to store the result. * @returns {Cartographic} The location of the point along the geodesic. - * - * @exception {DeveloperError} start and end must be set before calling function interpolateUsingSurfaceDistance */ EllipsoidGeodesic.prototype.interpolateUsingSurfaceDistance = function ( distance, result, ) { - if (DEBUG) { - Check.defined('distance', this._distance); - } - const constants = this._constants; const s = constants.distanceRatio + distance / constants.b; diff --git a/src/geo/ellipsoidGeodesic.test.mjs b/src/geo/ellipsoidGeodesic.test.mjs index 0ab080da1..f9436b694 100644 --- a/src/geo/ellipsoidGeodesic.test.mjs +++ b/src/geo/ellipsoidGeodesic.test.mjs @@ -66,17 +66,3 @@ test('endpoint reset, result reuse, custom sphere and dateline/polar paths retai ); } }); -test('unset and near-antipodal geodesics reject the same debug inputs', () => { - assert.throws(() => geo.ellipsoidGeodesic.create().surfaceDistance, { - name: 'DeveloperError', - }); - const a = geo.cartographic.fromDegrees(0, 0), - b = geo.cartographic.fromDegrees(180, 0); - assert.throws(() => geo.ellipsoidGeodesic.create(a, b), { - name: 'DeveloperError', - }); - assert.throws( - () => geo.ellipsoidGeodesic.create().setEndPoints(undefined, b), - { name: 'DeveloperError' }, - ); -}); diff --git a/src/geo/math.js b/src/geo/math.js index 3e77f5eef..e68b907da 100644 --- a/src/geo/math.js +++ b/src/geo/math.js @@ -1,15 +1,8 @@ // Ported from CesiumJS 1.138 (Apache-2.0) — Math.js // // Engine-neutral angle maths. Formulas and constants match Cesium.Math bit for -// bit. With DEBUG on (development and tests; see debug.js) invalid arguments -// throw the same DeveloperError as Cesium's debug checks; production builds -// skip the checks, as release Cesium does. +// bit. -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; -import { DeveloperError } from './developerError.js'; - -export const EPSILON7 = 0.0000001; export const EPSILON10 = 0.0000000001; export const EPSILON12 = 0.000000000001; export const EPSILON14 = 0.00000000000001; @@ -20,32 +13,16 @@ export const DEGREES_PER_RADIAN = 180.0 / Math.PI; /** Converts degrees to radians. */ export function toRadians(degrees) { - if (DEBUG) { - if (degrees == null) { - throw new DeveloperError('degrees is required.'); - } - } return degrees * RADIANS_PER_DEGREE; } /** Converts radians to degrees. */ export function toDegrees(radians) { - if (DEBUG) { - if (radians == null) { - throw new DeveloperError('radians is required.'); - } - } return radians * DEGREES_PER_RADIAN; } /** Constrains a value to lie between two values. */ export function clamp(value, min, max) { - if (DEBUG) { - Check.typeOf.number('value', value); - Check.typeOf.number('min', min); - Check.typeOf.number('max', max); - } - return value < min ? min : value > max ? max : value; } @@ -56,17 +33,6 @@ export function lerp(p, q, time) { /** The modulo operation that also works for negative dividends. */ export function mod(m, n) { - if (DEBUG) { - if (m == null) { - throw new DeveloperError('m is required.'); - } - if (n == null) { - throw new DeveloperError('n is required.'); - } - if (n === 0.0) { - throw new DeveloperError('divisor cannot be 0.'); - } - } if (Math.sign(m) === Math.sign(n) && Math.abs(m) < Math.abs(n)) { // Early exit if the input does not need to be modded. This avoids // unnecessary math which could introduce floating point error. @@ -77,11 +43,6 @@ export function mod(m, n) { /** Produces an angle in the range 0 <= angle <= 2Pi equivalent to the provided angle. */ export function zeroToTwoPi(angle) { - if (DEBUG) { - if (angle == null) { - throw new DeveloperError('angle is required.'); - } - } if (angle >= 0 && angle <= TWO_PI) { // Early exit if the input is already inside the range. This avoids // unnecessary math which could introduce floating point error. @@ -96,11 +57,6 @@ export function zeroToTwoPi(angle) { /** Produces an angle in the range -Pi <= angle <= Pi equivalent to the provided angle. */ export function negativePiToPi(angle) { - if (DEBUG) { - if (angle == null) { - throw new DeveloperError('angle is required.'); - } - } if (angle >= -Math.PI && angle <= Math.PI) { // Early exit if the input is already inside the range. This avoids // unnecessary math which could introduce floating point error. @@ -114,15 +70,6 @@ export function negativePiToPi(angle) { * (the absolute one defaults to the relative one). */ export function equalsEpsilon(left, right, relativeEpsilon, absoluteEpsilon) { - if (DEBUG) { - if (left == null) { - throw new DeveloperError('left is required.'); - } - if (right == null) { - throw new DeveloperError('right is required.'); - } - } - relativeEpsilon = relativeEpsilon ?? 0.0; absoluteEpsilon = absoluteEpsilon ?? relativeEpsilon; const absDiff = Math.abs(left - right); diff --git a/src/geo/math.test.mjs b/src/geo/math.test.mjs index d2cecc266..5b26a0f10 100644 --- a/src/geo/math.test.mjs +++ b/src/geo/math.test.mjs @@ -31,7 +31,6 @@ test('constants equal Cesium.Math', () => { for (const name of [ 'PI_OVER_TWO', 'TWO_PI', - 'EPSILON7', 'EPSILON10', 'EPSILON12', 'EPSILON14', diff --git a/src/geo/matrix.test.mjs b/src/geo/matrix.test.mjs index b4db952b1..07cc86040 100644 --- a/src/geo/matrix.test.mjs +++ b/src/geo/matrix.test.mjs @@ -4,13 +4,10 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import * as m3 from './matrix3.js'; import * as m4 from './matrix4.js'; -import * as quat from './quaternion.js'; const arr = (m, n) => Array.from({ length: n }, (_, i) => m[i]); const sameArr = (ours, theirs, n, label) => assert.deepEqual(arr(ours, n), arr(theirs, n), label); -const close = (a, b, tol, label) => - assert.ok(Math.abs(a - b) <= tol, `${label}: ${a} vs ${b}`); const T = Cesium.Matrix4.fromTranslationQuaternionRotationScale( new Cesium.Cartesian3(10, -20, 30), @@ -29,41 +26,7 @@ const U = Cesium.Matrix4.fromRotationTranslation( ); const plain4 = (m) => arr(m, 16); -test('matrix4 products, points and inverses equal Cesium', () => { - sameArr( - m4.multiply(plain4(T), plain4(U), m4.create()), - Cesium.Matrix4.multiply(T, U, new Cesium.Matrix4()), - 16, - 'multiply', - ); - sameArr( - m4.multiplyTransformation(plain4(T), plain4(U), m4.create()), - Cesium.Matrix4.multiplyTransformation(T, U, new Cesium.Matrix4()), - 16, - 'multiplyTransformation', - ); - const p = new Cesium.Cartesian3(1.5, -2, 3); - const ours = m4.multiplyByPoint( - plain4(T), - { x: 1.5, y: -2, z: 3 }, - { x: 0, y: 0, z: 0 }, - ); - const theirs = Cesium.Matrix4.multiplyByPoint(T, p, new Cesium.Cartesian3()); - assert.deepEqual({ ...ours }, { x: theirs.x, y: theirs.y, z: theirs.z }); - const v = m4.multiplyByPointAsVector( - plain4(T), - { x: 1.5, y: -2, z: 3 }, - { x: 0, y: 0, z: 0 }, - ); - const cv = Cesium.Matrix4.multiplyByPointAsVector( - T, - p, - new Cesium.Cartesian3(), - ); - assert.deepEqual({ ...v }, { x: cv.x, y: cv.y, z: cv.z }); - const inv = m4.inverseTransformation(plain4(T), m4.create()); - const cinv = Cesium.Matrix4.inverseTransformation(T, new Cesium.Matrix4()); - for (let i = 0; i < 16; i++) close(inv[i], cinv[i], 1e-12, `inv ${i}`); +test('matrix4 getters, equals and IDENTITY equal Cesium', () => { const t = m4.getTranslation(plain4(T), { x: 0, y: 0, z: 0 }); assert.deepEqual({ ...t }, { x: 10, y: -20, z: 30 }); sameArr( @@ -74,49 +37,15 @@ test('matrix4 products, points and inverses equal Cesium', () => { ); assert.ok(m4.equals(m4.clone(plain4(T)), plain4(T))); assert.ok(Object.isFrozen(m4.IDENTITY)); - sameArr( - m4.fromUniformScale(2.5, m4.create()), - Cesium.Matrix4.fromUniformScale(2.5, new Cesium.Matrix4()), - 16, - 'scale', - ); }); -test('matrix3, quaternion and rotation-translation equal Cesium', () => { +test('matrix3 and rotation-translation equal Cesium', () => { sameArr( m3.fromRotationZ(1.1, m3.create()), Cesium.Matrix3.fromRotationZ(1.1, new Cesium.Matrix3()), 9, 'rotZ', ); - const axis = { - x: 0.2672612419124244, - y: 0.5345224838248488, - z: 0.8017837257372732, - }; - const q = quat.fromAxisAngle(axis, 0.7, quat.create()); - const cq = Cesium.Quaternion.fromAxisAngle( - new Cesium.Cartesian3(axis.x, axis.y, axis.z), - 0.7, - new Cesium.Quaternion(), - ); - assert.deepEqual({ ...q }, { x: cq.x, y: cq.y, z: cq.z, w: cq.w }); - sameArr( - m3.fromQuaternion(q, m3.create()), - Cesium.Matrix3.fromQuaternion(cq, new Cesium.Matrix3()), - 9, - 'fromQuaternion', - ); - const back = quat.fromRotationMatrix( - m3.fromQuaternion(q, m3.create()), - quat.create(), - ); - const cback = Cesium.Quaternion.fromRotationMatrix( - Cesium.Matrix3.fromQuaternion(cq, new Cesium.Matrix3()), - new Cesium.Quaternion(), - ); - for (const k of ['x', 'y', 'z', 'w']) - close(back[k], cback[k], 1e-15, `q.${k}`); const mv = m3.multiplyByVector( m3.fromRotationZ(1.1, m3.create()), { x: 1, y: 2, z: 3 }, diff --git a/src/geo/matrix3.js b/src/geo/matrix3.js index 09550e6cc..79ecc176c 100644 --- a/src/geo/matrix3.js +++ b/src/geo/matrix3.js @@ -2,12 +2,7 @@ // // 3x3 matrices as plain arrays of 9 numbers in Cesium's column-major index // order (m[column * 3 + row]), so Cesium accepts them wherever it reads -// m[0]..m[8]. Only the subset gev uses is ported. With DEBUG on (development -// and tests; see debug.js) invalid arguments throw the same DeveloperError as -// Cesium's debug checks. - -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; +// m[0]..m[8]. Only the subset gev uses is ported. /** A matrix of zeros (Cesium's `new Matrix3()`). */ export function create() { @@ -16,10 +11,6 @@ export function create() { /** The rotation matrix of a unit quaternion. */ export function fromQuaternion(quaternion, result) { - if (DEBUG) { - Check.typeOf.object('quaternion', quaternion); - } - const x2 = quaternion.x * quaternion.x; const xy = quaternion.x * quaternion.y; const xz = quaternion.x * quaternion.z; @@ -58,10 +49,6 @@ export function fromQuaternion(quaternion, result) { /** A rotation by `angle` radians around the z-axis. */ export function fromRotationZ(angle, result) { - if (DEBUG) { - Check.typeOf.number('angle', angle); - } - const cosAngle = Math.cos(angle); const sinAngle = Math.sin(angle); @@ -80,12 +67,6 @@ export function fromRotationZ(angle, result) { /** The product of a matrix and a column vector; `result` is required. */ export function multiplyByVector(matrix, cartesian, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - Check.typeOf.object('cartesian', cartesian); - Check.typeOf.object('result', result); - } - const vX = cartesian.x; const vY = cartesian.y; const vZ = cartesian.z; @@ -102,13 +83,6 @@ export function multiplyByVector(matrix, cartesian, result) { /** The column at `index` (0..2) as {x, y, z}; `result` is required. */ export function getColumn(matrix, index, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - Check.typeOf.number.greaterThanOrEquals('index', index, 0); - Check.typeOf.number.lessThanOrEquals('index', index, 2); - Check.typeOf.object('result', result); - } - const startIndex = index * 3; const x = matrix[startIndex]; const y = matrix[startIndex + 1]; diff --git a/src/geo/matrix4.js b/src/geo/matrix4.js index 7a98fd8d9..676b35ce6 100644 --- a/src/geo/matrix4.js +++ b/src/geo/matrix4.js @@ -4,12 +4,8 @@ // order (m[column * 4 + row]), so Cesium accepts them wherever it reads // m[0]..m[15] (e.g. the Model.modelMatrix setter clones by index). Products // and getters take a required `result` that may be an input; constructors -// take an optional one. Only the subset gev uses is ported. With DEBUG on -// (development and tests; see debug.js) invalid arguments throw the same -// DeveloperError as Cesium's debug checks. +// take an optional one. Only the subset gev uses is ported. -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; import { ZERO } from './cartesian3.js'; /** A matrix of zeros (Cesium's `new Matrix4()`). */ @@ -65,12 +61,6 @@ export function equals(left, right) { /** The product of two matrices. */ export function multiply(left, right, result) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - Check.typeOf.object('result', result); - } - const left0 = left[0]; const left1 = left[1]; const left2 = left[2]; @@ -162,12 +152,6 @@ export function multiply(left, right, result) { /** The product of two affine transformations (bottom rows assumed [0, 0, 0, 1]). */ export function multiplyTransformation(left, right, result) { - if (DEBUG) { - Check.typeOf.object('left', left); - Check.typeOf.object('right', right); - Check.typeOf.object('result', result); - } - const left0 = left[0]; const left1 = left[1]; const left2 = left[2]; @@ -234,12 +218,6 @@ export function multiplyTransformation(left, right, result) { /** Transforms a point (w = 1). */ export function multiplyByPoint(matrix, cartesian, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - Check.typeOf.object('cartesian', cartesian); - Check.typeOf.object('result', result); - } - const vX = cartesian.x; const vY = cartesian.y; const vZ = cartesian.z; @@ -256,12 +234,6 @@ export function multiplyByPoint(matrix, cartesian, result) { /** Transforms a direction (w = 0), ignoring the translation. */ export function multiplyByPointAsVector(matrix, cartesian, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - Check.typeOf.object('cartesian', cartesian); - Check.typeOf.object('result', result); - } - const vX = cartesian.x; const vY = cartesian.y; const vZ = cartesian.z; @@ -278,11 +250,6 @@ export function multiplyByPointAsVector(matrix, cartesian, result) { /** The translation part as {x, y, z}. */ export function getTranslation(matrix, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - Check.typeOf.object('result', result); - } - result.x = matrix[12]; result.y = matrix[13]; result.z = matrix[14]; @@ -291,11 +258,6 @@ export function getTranslation(matrix, result) { /** The upper-left 3x3 part. */ export function getMatrix3(matrix, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - Check.typeOf.object('result', result); - } - result[0] = matrix[0]; result[1] = matrix[1]; result[2] = matrix[2]; @@ -310,13 +272,6 @@ export function getMatrix3(matrix, result) { /** The column at `index` (0..3) as {x, y, z, w}. */ export function getColumn(matrix, index, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - Check.typeOf.number.greaterThanOrEquals('index', index, 0); - Check.typeOf.number.lessThanOrEquals('index', index, 3); - Check.typeOf.object('result', result); - } - const startIndex = index * 4; const x = matrix[startIndex]; const y = matrix[startIndex + 1]; @@ -332,11 +287,6 @@ export function getColumn(matrix, index, result) { /** The inverse of a rotation-plus-translation matrix (no scale or shear). */ export function inverseTransformation(matrix, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - Check.typeOf.object('result', result); - } - // This function is an optimized version of the below 4 lines. // const rT = Matrix3.transpose(Matrix4.getMatrix3(matrix)); // const rTN = Matrix3.negate(rT); @@ -382,10 +332,6 @@ export function inverseTransformation(matrix, result) { /** A transformation from a 3x3 rotation and a translation (zero when absent). */ export function fromRotationTranslation(rotation, translation, result) { - if (DEBUG) { - Check.typeOf.object('rotation', rotation); - } - translation = translation ?? ZERO; if (result == null) result = create(); result[0] = rotation[0]; @@ -406,29 +352,3 @@ export function fromRotationTranslation(rotation, translation, result) { result[15] = 1.0; return result; } - -/** A uniform scale. */ -export function fromUniformScale(scale, result) { - if (DEBUG) { - Check.typeOf.number('scale', scale); - } - - if (result == null) result = create(); - result[0] = scale; - result[1] = 0.0; - result[2] = 0.0; - result[3] = 0.0; - result[4] = 0.0; - result[5] = scale; - result[6] = 0.0; - result[7] = 0.0; - result[8] = 0.0; - result[9] = 0.0; - result[10] = scale; - result[11] = 0.0; - result[12] = 0.0; - result[13] = 0.0; - result[14] = 0.0; - result[15] = 1.0; - return result; -} diff --git a/src/geo/quaternion.js b/src/geo/quaternion.js index 00234c1c6..44ffea9f8 100644 --- a/src/geo/quaternion.js +++ b/src/geo/quaternion.js @@ -1,16 +1,12 @@ // Ported from CesiumJS 1.138 (Apache-2.0) — Quaternion.js // // Rotations as plain-data {x, y, z, w} objects. Only the subset gev uses is ported. -// With DEBUG on (development and tests; see debug.js) invalid arguments throw -// the same DeveloperError as Cesium's debug checks. // // Geo values are not Object.prototype objects, so src/app/stateChannel.js // copyState and src/director/documentFields.js jsonTree reject them: copy // the fields into a literal before putting one into product state or a // scene document. -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; import { normalize, create as createCartesian3 } from './cartesian3.js'; // Values are instances of a module-private constructor rather than object @@ -32,13 +28,8 @@ export function create(x, y, z, w) { let fromAxisAngleScratch = createCartesian3(); -/** The rotation by `angle` radians around `axis` (normalised here; with DEBUG on, a zero axis throws). */ +/** The rotation by `angle` radians around `axis` (normalised here; a zero axis gives NaN). */ export function fromAxisAngle(axis, angle, result) { - if (DEBUG) { - Check.typeOf.object('axis', axis); - Check.typeOf.number('angle', angle); - } - const halfAngle = angle / 2.0; const s = Math.sin(halfAngle); fromAxisAngleScratch = normalize(axis, fromAxisAngleScratch); @@ -63,10 +54,6 @@ const elementIndex = (column, row) => column * 3 + row; /** The rotation of a 3x3 rotation matrix. */ export function fromRotationMatrix(matrix, result) { - if (DEBUG) { - Check.typeOf.object('matrix', matrix); - } - let root; let x; let y; diff --git a/src/geo/releaseParity.test.mjs b/src/geo/releaseParity.test.mjs index 21faa39e8..14f8da2cc 100644 --- a/src/geo/releaseParity.test.mjs +++ b/src/geo/releaseParity.test.mjs @@ -1,36 +1,15 @@ -// Release parity: with DEBUG off, src/geo returns what release Cesium returns. +// Release parity: src/geo returns what release Cesium returns. // -// Production builds fold geo's DEBUG to false (see debug.js) and ship Cesium's -// Build/Cesium, whose debug checks are stripped. So for a degenerate input that -// throws in development (a zero vector to normalize, a NaN origin, a divisor -// of 0, an index out of range) both must now return the same values, NaN for -// NaN and -0 for -0, or fail with the same error. This file loads a copy of -// src/geo whose debug.js says `DEBUG = false`, and the stripped Cesium build. -import { test, after } from 'node:test'; +// Production builds ship Cesium's Build/Cesium, whose debug checks are +// stripped, and src/geo has no debug checks either. So for a degenerate input +// (a zero vector to normalize, a NaN origin, a divisor of 0, an index out of +// range) both must return the same values, NaN for NaN and -0 for -0, or fail +// with the same error. +import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; -import { - cpSync, - mkdtempSync, - readdirSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import * as geo from './index.js'; -const here = path.dirname(fileURLToPath(import.meta.url)); -const copy = mkdtempSync(path.join(tmpdir(), 'geo-release-')); -for (const name of readdirSync(here)) { - if (name.endsWith('.js')) - cpSync(path.join(here, name), path.join(copy, name)); -} -writeFileSync(path.join(copy, 'debug.js'), 'export const DEBUG = false;\n'); -after(() => rmSync(copy, { recursive: true, force: true })); - -const geo = await import(pathToFileURL(path.join(copy, 'index.js')).href); -const { DEBUG } = await import(pathToFileURL(path.join(copy, 'debug.js')).href); const Cesium = createRequire(import.meta.url)('cesium/Build/Cesium/index.cjs'); const W = Cesium.Ellipsoid.WGS84; @@ -185,18 +164,6 @@ const CASES = [ C3.fromDegreesArray, [list(1, 2, 3)], ], - [ - 'cartesian3.fromDegreesArrayHeights(4)', - geo.cartesian3.fromDegreesArrayHeights, - C3.fromDegreesArrayHeights, - [list(1, 2, 3, 4)], - ], - [ - 'cartesian3.fromDegreesArrayHeights(str)', - geo.cartesian3.fromDegreesArrayHeights, - C3.fromDegreesArrayHeights, - [list(1, 2, 'h')], - ], [ 'cartographic.fromRadians(NaN)', @@ -314,12 +281,6 @@ const CASES = [ Cesium.Matrix4.multiplyByPoint, [nanMat4, NAN, R], ], - [ - 'matrix4.fromUniformScale(str)', - geo.matrix4.fromUniformScale, - Cesium.Matrix4.fromUniformScale, - [value('2'), mat4], - ], [ 'quaternion.fromAxisAngle(zero)', @@ -403,11 +364,6 @@ function outcome(fn, args) { } } -test('the copy under test has its debug checks off', () => { - assert.equal(DEBUG, false); - assert.ok(!('normalized result is not a number' in Cesium)); -}); - for (const [name, geoFn, cesiumFn, builders] of CASES) { test(`release ${name} matches Build/Cesium`, () => { const g = outcome( @@ -432,7 +388,7 @@ for (const [name, geoFn, cesiumFn, builders] of CASES) { }); } -test('release geo returns NaN where development geo throws', () => { +test('geo returns NaN for degenerate inputs, as release Cesium does', () => { const r = geo.cartesian3.normalize( geo.cartesian3.create(), geo.cartesian3.create(), diff --git a/src/geo/scaleToGeodeticSurface.js b/src/geo/scaleToGeodeticSurface.js index feeed0022..008e6200a 100644 --- a/src/geo/scaleToGeodeticSurface.js +++ b/src/geo/scaleToGeodeticSurface.js @@ -4,8 +4,6 @@ // Internal to src/geo (not re-exported by index.js): used by ellipsoid.js and, // as in Cesium, directly by cartographic.fromCartesian. -import { DEBUG } from './debug.js'; -import { DeveloperError } from './developerError.js'; import { EPSILON12 } from './math.js'; import { clone, @@ -28,21 +26,6 @@ export function scaleToGeodeticSurface( centerToleranceSquared, result, ) { - if (DEBUG) { - if (cartesian == null) { - throw new DeveloperError('cartesian is required.'); - } - if (oneOverRadii == null) { - throw new DeveloperError('oneOverRadii is required.'); - } - if (oneOverRadiiSquared == null) { - throw new DeveloperError('oneOverRadiiSquared is required.'); - } - if (centerToleranceSquared == null) { - throw new DeveloperError('centerToleranceSquared is required.'); - } - } - const positionX = cartesian.x; const positionY = cartesian.y; const positionZ = cartesian.z; diff --git a/src/geo/transforms.js b/src/geo/transforms.js index 4fa6b60c3..cc7f88f56 100644 --- a/src/geo/transforms.js +++ b/src/geo/transforms.js @@ -3,12 +3,7 @@ // Local east-north-up frames and heading-pitch-roll orientations as 4x4 // column-major matrices (see matrix4.js). Outputs match Cesium bit for bit, // including the degenerate frames at the Earth's centre and at the poles. -// With DEBUG on (development and tests; see debug.js) invalid arguments throw -// the same DeveloperError as Cesium's debug checks. -import { DEBUG } from './debug.js'; -import { Check } from './check.js'; -import { DeveloperError } from './developerError.js'; import { EPSILON14, TWO_PI, @@ -52,15 +47,6 @@ function unpack(array, result) { * `Transforms.localFrameToFixedFrameGenerator('east', 'north')`). */ export function eastNorthUpToFixedFrame(origin, ellipsoid, result) { - if (DEBUG) { - if (origin == null) { - throw new DeveloperError('origin is required.'); - } - if (isNaN(origin.x) || isNaN(origin.y) || isNaN(origin.z)) { - throw new DeveloperError('origin has a NaN component'); - } - } - if (result == null) result = createMatrix4(); const east = scratchEast; @@ -228,12 +214,6 @@ export function headingPitchRollToFixedFrame( fixedFrameTransform, result, ) { - // The private helpers below skip Cesium's checks on their internal - // arguments (scratch objects and constants), which can never fail. - if (DEBUG) { - Check.typeOf.object('HeadingPitchRoll', headingPitchRoll); - } - fixedFrameTransform = fixedFrameTransform ?? eastNorthUpToFixedFrame; const hprQuaternion = quaternionFromHeadingPitchRoll( headingPitchRoll, diff --git a/src/geo/transforms.test.mjs b/src/geo/transforms.test.mjs index 2811d48c5..d80579219 100644 --- a/src/geo/transforms.test.mjs +++ b/src/geo/transforms.test.mjs @@ -4,63 +4,6 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import * as transforms from './transforms.js'; -const close = (a, b, tol, label) => - assert.ok(Math.abs(a - b) <= tol, `${label}: ${a} vs ${b}`); -const ORIGINS = [ - [-73.9857, 40.7484, 381], - [0, 0, 0], - [179.9, -45, 10], - [10, 89.9999999, 0], - [10, -90, 0], - [0, 90, 0], -]; - -test('eastNorthUpToFixedFrame equals Cesium, including the poles', () => { - for (const [lon, lat, h] of ORIGINS) { - const origin = Cesium.Cartesian3.fromDegrees(lon, lat, h); - const ours = transforms.eastNorthUpToFixedFrame({ - x: origin.x, - y: origin.y, - z: origin.z, - }); - const theirs = Cesium.Transforms.eastNorthUpToFixedFrame(origin); - for (let i = 0; i < 16; i++) - close( - ours[i], - theirs[i], - 1e-9 * Math.max(1, Math.abs(theirs[i])), - `${lon},${lat} [${i}]`, - ); - } -}); - -test('headingPitchRollToFixedFrame equals Cesium', () => { - for (const [lon, lat, h] of ORIGINS.slice(0, 3)) { - const origin = Cesium.Cartesian3.fromDegrees(lon, lat, h); - for (const [hd, p, r] of [ - [0, 0, 0], - [0.7, -0.2, 0.1], - [-2.5, 1.2, -0.4], - ]) { - const ours = transforms.headingPitchRollToFixedFrame( - { x: origin.x, y: origin.y, z: origin.z }, - { heading: hd, pitch: p, roll: r }, - ); - const theirs = Cesium.Transforms.headingPitchRollToFixedFrame( - origin, - new Cesium.HeadingPitchRoll(hd, p, r), - ); - for (let i = 0; i < 16; i++) - close( - ours[i], - theirs[i], - 1e-9 * Math.max(1, Math.abs(theirs[i])), - `hpr [${i}]`, - ); - } - } -}); - test('headingPitchRollToFixedFrame keeps Cesium argument order (result is the fifth argument)', () => { const origin = Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 381); const hpr = new Cesium.HeadingPitchRoll(0.7, -0.2, 0.1); diff --git a/src/globe/navara/cameraMath.js b/src/globe/cameraMath.js similarity index 93% rename from src/globe/navara/cameraMath.js rename to src/globe/cameraMath.js index 71d6bc367..035b8cf05 100644 --- a/src/globe/navara/cameraMath.js +++ b/src/globe/cameraMath.js @@ -1,13 +1,12 @@ -// Camera math for the Navara engine, on src/geo only: Cesium's heading/pitch/ -// roll convention, the perspective projection and picks of a camera given as -// { position, direction, up, fovy, width, height } (Earth-fixed metres, unit -// vectors, vertical fov in radians, CSS pixels), Cesium's view rectangle and -// horizon test, and the Navara setCamera pose (degrees). Ported from the S4 -// spike (src/bench/cameraConvention.js) and the contract's fake engine; the -// projection matches what Navara draws because the engine keeps Navara's -// camera on the same pose and fov. +// Camera math for the Navara and fake engines, on src/geo only: Cesium's +// heading/pitch/roll convention, the perspective projection and picks of a +// camera given as { position, direction, up, fovy, width, height } +// (Earth-fixed metres, unit vectors, vertical fov in radians, CSS pixels), +// Cesium's view rectangle and horizon test, and the Navara setCamera pose +// (degrees). The projection matches what Navara draws because the engine +// keeps Navara's camera on the same pose and fov. -import * as geo from '../../geo/index.js'; +import * as geo from '../geo/index.js'; const { cartesian2, cartesian3, cartographic, math, matrix4 } = geo; const WGS84 = geo.ellipsoid.WGS84; @@ -109,7 +108,7 @@ export function headingPitchRoll( /** * The Navara setCamera/flyTo pose for a camera pose: the eye's longitude, * latitude and ellipsoid height, and the same heading/pitch/roll in degrees - * (Navara 0.2.1 uses Cesium's convention, S4), heading in [0, 360) and roll + * (Navara 0.2.1 uses Cesium's convention), heading in [0, 360) and roll * in (−180, 180]. */ export function navaraPose(position, direction, up) { @@ -206,14 +205,16 @@ export function pickRay(camera, screen) { return { origin: copy3(camera.position), direction: unit(direction) }; } -/** Nearest intersection of a ray with the WGS84 ellipsoid, if any. */ -export function intersectEllipsoid(origin, direction) { - const o = [origin.x / RADII.x, origin.y / RADII.y, origin.z / RADII.z]; - const d = [ - direction.x / RADII.x, - direction.y / RADII.y, - direction.z / RADII.z, - ]; +/** + * Nearest intersection of a ray with the WGS84 ellipsoid raised by `height` + * metres (each radius grown by it), if any. + */ +export function intersectEllipsoid(origin, direction, height = 0) { + const rx = RADII.x + height; + const ry = RADII.y + height; + const rz = RADII.z + height; + const o = [origin.x / rx, origin.y / ry, origin.z / rz]; + const d = [direction.x / rx, direction.y / ry, direction.z / rz]; const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]); const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1; diff --git a/src/globe/navara/cameraMath.test.mjs b/src/globe/cameraMath.test.mjs similarity index 98% rename from src/globe/navara/cameraMath.test.mjs rename to src/globe/cameraMath.test.mjs index 6f78874fe..2ad72643c 100644 --- a/src/globe/navara/cameraMath.test.mjs +++ b/src/globe/cameraMath.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import * as geo from '../../geo/index.js'; +import * as geo from '../geo/index.js'; import { cesiumFovy, headingPitchRoll, @@ -194,8 +194,8 @@ test('horizonVisible: the nadir is visible and the antipode hidden', () => { assert.equal(horizonVisible(eye, cartesian3.fromDegrees(-70, 10, 2e7)), true); }); -test("viewRectangle matches Cesium's computeViewRectangle (S4 reference poses)", () => { - // S4 measured these on Cesium at 1280×720 with Cesium's fov. +test("viewRectangle matches Cesium's computeViewRectangle (reference poses)", () => { + // Measured on Cesium at 1280×720 with Cesium's fov. const deg = (rectangle) => ['west', 'south', 'east', 'north'].map((side) => math.toDegrees(rectangle[side]), diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index bbc19f40f..9cf678b29 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -3,24 +3,17 @@ import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; -import { createEngineEvent } from '../contract.js'; +import { wrapAngle } from '../cameraMath.js'; import { bridgeEvent } from './events.js'; import { toCesium2, toCesium3, toGeo3 } from './convert.js'; import { createCesiumFollow } from './follow.js'; -const { math } = geo; const easings = () => ({ cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT, sinusoidalInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, linear: Cesium.EasingFunction.LINEAR_NONE, }); -// Cesium reports heading and roll in [0, 2π]; the contract wants [0, 2π). -function wrapAngle(angle) { - const value = math.zeroToTwoPi(angle); - return value < 1e-12 || math.TWO_PI - value < 1e-12 ? 0 : value; -} - function cesiumOrientation(orientation) { if (!orientation) return undefined; if (orientation.direction) @@ -65,70 +58,38 @@ const COMMANDS = Object.freeze([ /** * camera.commanded: raised before any COMMANDS call on the Cesium camera and * when the viewer's tracked entity changes. The hooks exist only while the - * event has listeners and then restore each method exactly (unless something - * newer replaced it), so an idle engine leaves the camera untouched. + * event has listeners (bridgeEvent) and then restore each method exactly + * (unless something newer replaced it), so an idle engine leaves the camera + * untouched. */ function commandedEvent(viewer, cesium) { - const event = createEngineEvent(); - let unhook = null; - const hook = () => { - const removers = []; - for (const name of COMMANDS) { - const original = cesium[name]; - if (typeof original !== 'function') continue; - const descriptor = Object.getOwnPropertyDescriptor(cesium, name); - const wrapped = function (...args) { - event.raiseEvent(); - return original.apply(this, args); + return bridgeEvent({ + addEventListener(raise) { + const removers = []; + for (const name of COMMANDS) { + const original = cesium[name]; + if (typeof original !== 'function') continue; + const descriptor = Object.getOwnPropertyDescriptor(cesium, name); + const wrapped = function (...args) { + raise(); + return original.apply(this, args); + }; + cesium[name] = wrapped; + removers.push(() => { + if (cesium[name] !== wrapped) return; + if (descriptor) Object.defineProperty(cesium, name, descriptor); + else delete cesium[name]; + }); + } + const removeTracking = viewer.trackedEntityChanged?.addEventListener(() => + raise(), + ); + if (removeTracking) removers.push(removeTracking); + return () => { + for (const remove of removers) remove(); }; - cesium[name] = wrapped; - removers.push(() => { - if (cesium[name] !== wrapped) return; - if (descriptor) Object.defineProperty(cesium, name, descriptor); - else delete cesium[name]; - }); - } - const removeTracking = viewer.trackedEntityChanged?.addEventListener(() => - event.raiseEvent(), - ); - if (removeTracking) removers.push(removeTracking); - return () => { - for (const remove of removers) remove(); - }; - }; - let detached = false; - const sync = () => { - const wanted = !detached && event.numberOfListeners > 0; - if (wanted === (unhook !== null)) return; - if (wanted) unhook = hook(); - else { - const remove = unhook; - unhook = null; - remove(); - } - }; - const add = event.addEventListener; - const remove = event.removeEventListener; - event.addEventListener = (listener) => { - const removeListener = add(listener); - sync(); - return () => { - removeListener(); - sync(); - }; - }; - event.removeEventListener = (listener) => { - const removed = remove(listener); - sync(); - return removed; - }; - return { - event, - detach() { - detached = true; - sync(); }, - }; + }); } /** @@ -136,8 +97,7 @@ function commandedEvent(viewer, cesium) { * @returns {{ camera: import('../contract.js').GlobeCamera, destroy: () => void }} */ export function createCesiumCamera(viewer) { - // Viewer.camera is scene.camera; the public alias is what shell callers read. - const cesium = viewer.camera ?? viewer.scene.camera; + const cesium = viewer.camera; const bridges = { changed: bridgeEvent(cesium.changed), moveStart: bridgeEvent(cesium.moveStart), diff --git a/src/globe/cesium/clock.js b/src/globe/cesium/clock.js index 902fbbe32..389082d8f 100644 --- a/src/globe/cesium/clock.js +++ b/src/globe/cesium/clock.js @@ -15,7 +15,6 @@ // preUpdate instead, attached on first use so creating the engine adds no // scene listener. -import { createEngineEvent } from '../contract.js'; import { appClock as pageClock } from '../../time/clock.js'; /** @@ -28,21 +27,16 @@ export function createCesiumClock(viewer, { appClock = pageClock } = {}) { // A viewer-shaped object without a clock (tests, a bare scene): the // scene's preUpdate opens the frame instead. const tickSource = viewer.clock?.onTick ?? viewer.scene.preUpdate; - const ticked = createEngineEvent(); let removeTick = null; let destroyed = false; - const onTick = () => ticked.raiseValue(appClock.beginFrame()); + const onTick = () => { + appClock.beginFrame(); + }; const attach = () => { if (!removeTick && !destroyed) removeTick = tickSource.addEventListener(onTick); }; if (viewer.clock?.onTick) attach(); - const add = ticked.addEventListener; - ticked.addEventListener = (listener) => { - const remove = add(listener); - attach(); - return remove; - }; return { clock: { now: () => appClock.now(), @@ -50,7 +44,6 @@ export function createCesiumClock(viewer, { appClock = pageClock } = {}) { attach(); return appClock.frameTime(); }, - ticked, }, destroy() { destroyed = true; diff --git a/src/globe/cesium/clock.test.mjs b/src/globe/cesium/clock.test.mjs index 5f8e063e4..b904b4dc0 100644 --- a/src/globe/cesium/clock.test.mjs +++ b/src/globe/cesium/clock.test.mjs @@ -1,7 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { isEngineEvent } from '../contract.js'; import { createAppClock } from '../../time/clock.js'; import { createCesiumClock } from './clock.js'; import { createFakeViewer } from './testing/fakeViewer.mjs'; @@ -32,7 +31,6 @@ test('the clock reads the app clock and latches on the viewer clock from creatio const viewer = createFakeViewer(); const wall = manualAppClock(); const { clock } = createCesiumClock(viewer, { appClock: wall.appClock }); - assert.ok(isEngineEvent(clock.ticked)); // Attached at creation, so it runs before onTick listeners added later // (cameraVerbs installs its own after the engine exists). assert.equal(viewer.clock.onTick.numberOfListeners, 1); @@ -51,7 +49,9 @@ test('frameTime is latched on the viewer clock tick, before preUpdate', () => { const wall = manualAppClock(); const { clock } = createCesiumClock(viewer, { appClock: wall.appClock }); const seen = []; - clock.ticked.addEventListener((time) => seen.push(['ticked', time])); + viewer.clock.onTick.addEventListener(() => + seen.push(['tick', clock.frameTime()]), + ); // A preUpdate listener the app added before the engine existed still // reads this frame's time: the latch is on the tick, not on preUpdate. viewer.scene.preUpdate.addEventListener(() => @@ -66,10 +66,10 @@ test('frameTime is latched on the viewer clock tick, before preUpdate', () => { wall.advance(16); cesiumFrame(viewer, { draw: false }); assert.deepEqual(seen, [ - ['ticked', t0], + ['tick', t0], ['preUpdate', t0], ['preRender', t0], - ['ticked', t0 + 19], + ['tick', t0 + 19], ['preUpdate', t0 + 19], ]); }); @@ -99,11 +99,14 @@ test('a viewer without a clock latches on preUpdate', () => { const { clock, destroy } = createCesiumClock(viewer, { appClock: wall.appClock, }); - const seen = []; - clock.ticked.addEventListener((time) => seen.push(time)); + // Attached on first use: creating the clock adds no scene listener. + assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); + const before = clock.frameTime(); + assert.equal(viewer.scene.preUpdate.numberOfListeners, 1); wall.advance(7); viewer.scene.preUpdate.raiseEvent(viewer.scene); - assert.deepEqual(seen, [wall.appClock.now()]); + wall.advance(2); + assert.equal(clock.frameTime(), before + 7); destroy(); assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); }); @@ -116,7 +119,7 @@ test('the viewer clock itself is left alone', () => { const { clock } = createCesiumClock(viewer, { appClock: manualAppClock().appClock, }); - clock.ticked.addEventListener(() => {}); + clock.frameTime(); cesiumFrame(viewer); assert.equal(viewer.clock.shouldAnimate, false); assert.equal(viewer.clock.multiplier, 1); diff --git a/src/globe/cesium/convert.js b/src/globe/cesium/convert.js index ddf101376..04f540d0f 100644 --- a/src/globe/cesium/convert.js +++ b/src/globe/cesium/convert.js @@ -18,6 +18,6 @@ export const toCesium3 = (value) => /** * A new Cesium.Cartesian2 for a window position. Always a new instance: Cesium - * caches pickPosition by windowPosition.toString() (phase 1a rule 9). + * caches pickPosition by windowPosition.toString(). */ export const toCesium2 = (value) => new Cesium.Cartesian2(value.x, value.y); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 3aaf2445e..858e1ac89 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -10,9 +10,7 @@ import { createCesiumClock } from './clock.js'; import { createCesiumCredits } from './credits.js'; import { createCesiumFrames } from './frames.js'; import { createCesiumInput } from './input.js'; -import { createCesiumLines } from './lines.js'; import { createCesiumMaps } from './maps.js'; -import { createCesiumMovers } from './movers.js'; import { createCesiumPicker } from './picker.js'; import { createCesiumProjector } from './projector.js'; import { createCesiumRender } from './render.js'; @@ -37,44 +35,18 @@ import { createCesiumSurface } from './surface.js'; */ export function createCesiumEngine(viewer, options = {}) { if (!viewer?.scene) throw new TypeError('createCesiumEngine needs a viewer'); - // Viewer.canvas is scene.canvas; the engine reads the public alias. - const canvas = viewer.canvas ?? viewer.scene.canvas; + const { canvas } = viewer; const render = createCesiumRender(viewer, options); const credits = createCesiumCredits(viewer); - // Picks name the mover set or line they hit (movers and lines exist - // below; the picker asks them when it picks). - let lines = null; - let movers = null; - let projectorPart = null; - const picker = createCesiumPicker(viewer, { - identify: (picked) => - movers?.identify(picked) ?? lines?.identify(picked) ?? null, - // A mover instance behind the globe is not picked (billboards drawn - // without a depth test reach Cesium's GPU pick through it). - visible: ({ owner, instance }) => { - const position = movers?.instancePosition(owner, instance); - return position ? projectorPart.projector.horizonVisible(position) : true; - }, - }); + const picker = createCesiumPicker(viewer); const parts = { camera: createCesiumCamera(viewer), picker, - projector: (projectorPart = createCesiumProjector(viewer)), + projector: createCesiumProjector(viewer), frames: createCesiumFrames(viewer), clock: createCesiumClock(viewer, options), - lines: (lines = createCesiumLines(viewer, { - requestRender: (reason) => render.render.request(reason), - ...(options.lineFactories ? { factories: options.lineFactories } : {}), - })), - movers: (movers = createCesiumMovers(viewer, { - requestRender: (reason) => render.render.request(reason), - ...(options.moverFactories ? { factories: options.moverFactories } : {}), - })), render, - // Mover sets in a surface `exclude` are hidden for the sample. - surface: createCesiumSurface(viewer, { - engineExclude: (entry) => movers?.primitiveOf(entry) ?? null, - }), + surface: createCesiumSurface(viewer), input: createCesiumInput(viewer, { ...options, picker: picker.picker }), credits, maps: createCesiumMaps(viewer, { diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index a98426081..ccf6f2f24 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -146,57 +146,11 @@ test('the engine clock is the app clock given, latched on the viewer clock tick' const viewer = createFakeViewer(); const engine = createCesiumEngine(viewer, { appClock }); assert.equal(engine.clock.now(), 1_000_000); - const seen = []; - engine.clock.ticked.addEventListener((time) => seen.push(time)); assert.equal(viewer.clock.onTick.numberOfListeners, 1); wall += 16; viewer.clock.onTick.raiseEvent(viewer.clock); - assert.deepEqual(seen, [1_000_016]); + wall += 5; assert.equal(engine.clock.frameTime(), 1_000_016); engine.destroy(); assert.equal(viewer.clock.onTick.numberOfListeners, 0); }); - -test('picks leave out mover instances behind the horizon and the follow proxy', async () => { - const geo = await import('../../geo/index.js'); - const viewer = createFakeViewer(); - const engine = createCesiumEngine(viewer, { requestRender: () => {} }); - try { - 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) }); - set.set(1, { position: geo.cartesian3.fromDegrees(180, 1, 0) }); - set.setCount(2); - set.commit(); - const [front, back] = viewer.primitiveList.flatMap((root) => - collectPoints(root), - ); - viewer.scene.drillPick = () => [ - { primitive: back, id: 'back' }, - { primitive: front, id: 'front' }, - ]; - assert.deepEqual( - engine.picker.drillPick({ x: 1, y: 1 }).map(({ id }) => id), - ['front'], - ); - } finally { - engine.destroy(); - } -}); - -// The PointPrimitives under a primitive collection tree. -function collectPoints(collection) { - const out = []; - for (let i = 0; i < (collection.length ?? 0); i++) { - const item = collection.get(i); - if (item instanceof Cesium.PointPrimitive) out.push(item); - else if (item?.length !== undefined) out.push(...collectPoints(item)); - } - return out; -} diff --git a/src/globe/cesium/engineFromViewer.js b/src/globe/cesium/engineFromViewer.js index c6ffa8640..f9f6fa63f 100644 --- a/src/globe/cesium/engineFromViewer.js +++ b/src/globe/cesium/engineFromViewer.js @@ -1,5 +1,5 @@ -// The bridge for shell callers that still hold only a Cesium viewer (layers -// until phase 1c): the engine registered for that viewer. The app registers +// The bridge for shell callers that hold only a Cesium viewer: the engine +// registered for that viewer. The app registers // its scene engine (src/app/globeEngine.js) and unregisters it before // destroying it. Nothing is ever built here, so no engine can outlive its // viewer without a destroy path: an unregistered or torn-down viewer has none. diff --git a/src/globe/cesium/lines.js b/src/globe/cesium/lines.js deleted file mode 100644 index ea518d31d..000000000 --- a/src/globe/cesium/lines.js +++ /dev/null @@ -1,225 +0,0 @@ -// engine.lines on Cesium: each line is one Primitive of a PolylineGeometry -// (built synchronously, rebuilt when its positions change) with a -// PolylineMaterialAppearance, inside one engine-owned PrimitiveCollection -// added to the scene with the first line. depthFailAlpha adds Cesium's -// depthFailAppearance (the same material, its alpha scaled) for the part -// the scene hides; dashes in pixels are PolylineDash; the tactical dash is -// the launches' selected-orbit pattern along the whole line. - -import * as Cesium from 'cesium'; -import { createLineRegistry } from '../lines.js'; - -const TACTICAL_TYPE = 'GevLineTactical'; -let tacticalRegistered = false; - -// The launches' GevMissionOrbitTactical shader (layers/launches/ -// orbitRendering.js), under the engine's own type until Task 15a moves -// launches onto engine.lines. -function registerTactical() { - if (tacticalRegistered) return; - new Cesium.Material({ - fabric: { - type: TACTICAL_TYPE, - uniforms: { color: Cesium.Color.CYAN, groupCount: 4, dashCount: 100 }, - source: ` - czm_material czm_getMaterial(czm_materialInput materialInput) { - czm_material material = czm_getDefaultMaterial(materialInput); - float groupPosition = fract(materialInput.st.s * groupCount); - float markPosition = groupPosition * (dashCount + 1.0); - float markIndex = floor(markPosition); - float localPosition = fract(markPosition); - float centerDistance = abs(localPosition - 0.5); - float edge = max(fwidth(localPosition) * 1.35, 0.012); - float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); - float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); - float dash = dashAlong * dashAcross; - float dotAlong = (localPosition - 0.5) / 0.32; - float dotAcross = (materialInput.st.t - 0.5) / 0.5; - float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); - float isDot = 1.0 - step(0.5, markIndex); - float visible = mix(dash, dot, isDot); - material.diffuse = color.rgb; - material.emission = color.rgb * mix(0.07, 0.65, isDot); - material.alpha = color.a * visible * mix(0.58, 1.0, isDot); - return material; - }`, - }, - }); - tacticalRegistered = true; -} - -/** PolylineDash's 16-bit pattern: the dash first, then the gap. */ -export function dashPattern(lengthPx, gapPx) { - const on = Math.max( - 1, - Math.min(16, Math.round((16 * lengthPx) / (lengthPx + gapPx))), - ); - return (0xffff << (16 - on)) & 0xffff; -} - -const toColor = ([r, g, b, a], alphaScale = 1) => - new Cesium.Color(r, g, b, a * alphaScale); - -/** The Cesium material type and uniforms of a line (pure, for tests). */ -export function materialSpec(options, color, alphaScale = 1) { - if (options.arrow) - return { - type: 'PolylineArrow', - uniforms: { color: toColor(color, alphaScale) }, - }; - const { dash } = options; - if (dash?.kind === 'pixels') - return { - type: 'PolylineDash', - uniforms: { - color: toColor(color, alphaScale), - gapColor: Cesium.Color.TRANSPARENT, - dashLength: dash.lengthPx + dash.gapPx, - dashPattern: dashPattern(dash.lengthPx, dash.gapPx), - }, - }; - if (dash?.kind === 'tactical') - return { - type: TACTICAL_TYPE, - uniforms: { - color: toColor(color, alphaScale), - groupCount: dash.groups, - dashCount: dash.dashes, - }, - }; - return { type: 'Color', uniforms: { color: toColor(color, alphaScale) } }; -} - -function cesiumMaterial({ type, uniforms }) { - if (type === TACTICAL_TYPE) registerTactical(); - return Cesium.Material.fromType(type, uniforms); -} - -const DEFAULT_FACTORIES = { - primitive: (options) => new Cesium.Primitive(options), - appearance: (spec) => - new Cesium.PolylineMaterialAppearance({ material: cesiumMaterial(spec) }), -}; - -/** - * @param {Cesium.Viewer} viewer - * @param {{ requestRender?: (reason: string) => void, factories?: object }} [options] - * @returns {{ lines: import('../contract.js').GlobeLines, destroy: () => void }} - */ -export function createCesiumLines( - viewer, - { requestRender = () => viewer.scene.requestRender(), factories = {} } = {}, -) { - const make = { ...DEFAULT_FACTORIES, ...factories }; - const { scene } = viewer; - let root = null; - const rootCollection = () => { - if (!root) { - root = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); - scene.primitives.add(root); - } - return root; - }; - const registry = createLineRegistry((options) => { - rootCollection(); - let color = options.color; - let modelMatrix = options.modelMatrix - ? Cesium.Matrix4.fromArray(options.modelMatrix) - : Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); - let shown = true; - let primitive = null; - let appearance = null; - let depthFail = null; - const drop = () => { - if (primitive) root.remove(primitive); - primitive = null; - }; - return { - setPositions(flat) { - drop(); - if (flat.length < 6) { - requestRender('lines'); - return; - } - const positions = []; - for (let index = 0; index < flat.length; index += 3) - positions.push( - new Cesium.Cartesian3( - flat[index], - flat[index + 1], - flat[index + 2], - ), - ); - appearance = make.appearance(materialSpec(options, color)); - depthFail = - options.depthFailAlpha > 0 - ? make.appearance( - materialSpec(options, color, options.depthFailAlpha), - ) - : undefined; - primitive = make.primitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry: new Cesium.PolylineGeometry({ - positions, - width: options.widthPx, - vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, - arcType: - options.arc === 'geodesic' - ? Cesium.ArcType.GEODESIC - : Cesium.ArcType.NONE, - }), - id: options.pickId, - }), - appearance, - depthFailAppearance: depthFail, - modelMatrix: Cesium.Matrix4.clone(modelMatrix), - show: shown, - asynchronous: false, - }); - primitive.gevLine = options.id; - root.add(primitive); - requestRender('lines'); - }, - setColor(rgba) { - color = rgba; - if (appearance) appearance.material.uniforms.color = toColor(rgba); - if (depthFail) - depthFail.material.uniforms.color = toColor( - rgba, - options.depthFailAlpha, - ); - requestRender('lines'); - }, - setModelMatrix(matrix) { - modelMatrix = Cesium.Matrix4.fromArray(matrix); - if (primitive) - primitive.modelMatrix = Cesium.Matrix4.clone(modelMatrix); - requestRender('lines'); - }, - show(visible) { - shown = visible; - if (primitive) primitive.show = visible; - requestRender('lines'); - }, - destroy() { - drop(); - requestRender('lines'); - }, - }; - }); - return { - lines: registry.lines, - /** The line a Cesium pick result belongs to, or null. */ - identify(picked) { - const owner = picked?.primitive?.gevLine; - return owner !== undefined && registry.live.has(owner) ? { owner } : null; - }, - destroy() { - registry.destroyAll(); - if (root) { - scene.primitives.remove(root); - root = null; - } - }, - }; -} diff --git a/src/globe/cesium/lines.test.mjs b/src/globe/cesium/lines.test.mjs deleted file mode 100644 index 229f56813..000000000 --- a/src/globe/cesium/lines.test.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { createCesiumLines, dashPattern } from './lines.js'; -import { createFakeViewer } from './testing/fakeViewer.mjs'; - -function fakePrimitives() { - const made = []; - return { - made, - factories: { - primitive: (options) => { - const primitive = { - ...options, - isDestroyed: () => false, - destroy() { - primitive.destroyed = true; - }, - }; - made.push(primitive); - return primitive; - }, - appearance: (spec) => ({ - material: { type: spec.type, uniforms: { ...spec.uniforms } }, - }), - }, - }; -} - -const points = [ - { x: 6_378_137, y: 0, z: 0 }, - { x: 6_378_137, y: 1000, z: 0 }, - { x: 6_378_137, y: 2000, z: 1000 }, -]; - -test('a dash in pixels becomes PolylineDash with a 16-bit pattern', () => { - assert.equal(dashPattern(16, 16), 0xff00); - assert.equal(dashPattern(12, 4), 0xfff0); - assert.equal(dashPattern(1, 0), 0xffff); -}); - -test('positions build one polyline primitive; a change rebuilds it', () => { - const viewer = createFakeViewer(); - const { factories, made } = fakePrimitives(); - const renders = []; - const { lines, destroy } = createCesiumLines(viewer, { - factories, - requestRender: (reason) => renders.push(reason), - }); - assert.equal(viewer.scene.primitives.length, 0, 'nothing at creation'); - const line = lines.createLine({ - id: 'trail', - widthPx: 3, - color: [1, 0, 0, 1], - depthFailAlpha: 0.4, - arc: 'geodesic', - pickId: 'trail-1', - }); - assert.equal(viewer.scene.primitives.length, 1); - assert.equal(made.length, 0, 'no primitive without positions'); - line.setPositions(points); - assert.equal(made.length, 1); - const [first] = made; - const geometry = first.geometryInstances.geometry; - assert.equal(geometry._width, 3); - assert.equal(geometry._arcType, Cesium.ArcType.GEODESIC); - assert.equal(geometry._positions.length, 3); - assert.equal(first.geometryInstances.id, 'trail-1'); - assert.equal(first.asynchronous, false); - assert.ok(first.depthFailAppearance, 'the dimmer copy behind the scene'); - assert.ok( - Cesium.Color.equals( - first.depthFailAppearance.material.uniforms.color, - new Cesium.Color(1, 0, 0, 0.4), - ), - ); - line.setPositions(points.slice(0, 2)); - assert.equal(made.length, 2); - assert.equal(first.destroyed, true, 'the old primitive goes'); - line.setColor([0, 1, 0, 1]); - assert.ok( - Cesium.Color.equals( - made[1].appearance.material.uniforms.color, - new Cesium.Color(0, 1, 0, 1), - ), - ); - const turn = Cesium.Matrix4.fromRotation(Cesium.Matrix3.fromRotationZ(0.5)); - line.setModelMatrix(Cesium.Matrix4.toArray(turn)); - assert.ok(Cesium.Matrix4.equals(made[1].modelMatrix, turn)); - line.show(false); - assert.equal(made[1].show, false); - line.setPositions(points); - assert.equal(made[2].show, false, 'a rebuild keeps show and matrix'); - assert.ok(Cesium.Matrix4.equals(made[2].modelMatrix, turn)); - assert.ok(renders.length >= 4); - line.destroy(); - assert.equal(made[2].destroyed, true); - destroy(); - assert.equal(viewer.scene.primitives.length, 0); -}); - -test('dash kinds pick their materials', () => { - const viewer = createFakeViewer(); - const { factories, made } = fakePrimitives(); - const { lines } = createCesiumLines(viewer, { factories }); - lines - .createLine({ - id: 'd', - widthPx: 2, - color: [1, 1, 1, 1], - dash: { lengthPx: 12, gapPx: 4 }, - }) - .setPositions(points); - lines - .createLine({ id: 't', widthPx: 3, color: [0, 1, 1, 1], dash: 'tactical' }) - .setPositions(points); - lines - .createLine({ id: 's', widthPx: 1, color: [1, 1, 1, 1] }) - .setPositions(points); - assert.equal(made[0].appearance.material.type, 'PolylineDash'); - assert.equal(made[0].appearance.material.uniforms.dashLength, 16); - assert.equal(made[0].appearance.material.uniforms.dashPattern, 0xfff0); - assert.equal(made[1].appearance.material.type, 'GevLineTactical'); - assert.equal(made[1].appearance.material.uniforms.groupCount, 4); - assert.equal(made[2].appearance.material.type, 'Color'); - assert.equal(made[2].depthFailAppearance, undefined); -}); - -test('a pick of a line primitive names the line', () => { - const viewer = createFakeViewer(); - const { factories, made } = fakePrimitives(); - const { lines, identify } = createCesiumLines(viewer, { factories }); - lines - .createLine({ - id: 'orbit-25544', - widthPx: 2, - color: [1, 1, 1, 1], - pickId: 'o', - }) - .setPositions(points); - assert.deepEqual(identify({ primitive: made[0], id: 'o' }), { - owner: 'orbit-25544', - }); - assert.equal(identify({ primitive: {}, id: 'o' }), null); -}); - -test('arrow lines retain the native arrow material in both depth passes', () => { - const { factories, made } = fakePrimitives(); - const { lines, destroy } = createCesiumLines(createFakeViewer(), { - factories, - }); - lines - .createLine({ - id: 'arrow', - widthPx: 8, - color: [1, 0, 0, 1], - arrow: true, - depthFailAlpha: 0.45, - }) - .setPositions(points); - assert.equal(made[0].appearance.material.type, 'PolylineArrow'); - assert.equal(made[0].depthFailAppearance.material.type, 'PolylineArrow'); - assert.equal(made[0].depthFailAppearance.material.uniforms.color.alpha, 0.45); - destroy(); -}); diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js deleted file mode 100644 index 8d2bfc7bb..000000000 --- a/src/globe/cesium/movers.js +++ /dev/null @@ -1,365 +0,0 @@ -// engine.movers on Cesium: one primitive per set inside one engine-owned -// PrimitiveCollection (added to the scene with the first set, in `order`): -// sprites are a BillboardCollection (screen-aligned, rotation in screen -// space, an image per atlas cell), points a PointPrimitiveCollection, -// models one Model per instance in a PrimitiveCollection. commit() writes -// the changed instances and requests a render; nothing else renders. - -import * as Cesium from 'cesium'; -import { createMoverRegistry } from '../movers.js'; - -let scratchPosition; -let scratchColor; -let scratchQuaternion; -let scratchRotation; -// Model.modelMatrix and Model.color copy what they are given. -let scratchModelMatrix; - -/** The atlas cells as canvases, by key (a DOM canvas or image source). */ -async function cropAtlasCells(atlas, keys) { - let image = atlas.canvas; - if (!image) { - image = new Image(); - image.crossOrigin = 'anonymous'; - image.src = atlas.url; - await image.decode(); - } - const width = image.width; - const height = image.height; - const cells = new Map(); - for (const key of keys) { - const [u0, v0, u1, v1] = atlas.cells[key]; - const canvas = document.createElement('canvas'); - canvas.width = Math.max(1, Math.round((u1 - u0) * width)); - canvas.height = Math.max(1, Math.round((v1 - v0) * height)); - canvas - .getContext('2d') - .drawImage( - image, - u0 * width, - v0 * height, - canvas.width, - canvas.height, - 0, - 0, - canvas.width, - canvas.height, - ); - cells.set(key, canvas); - } - return cells; -} - -const DEFAULT_FACTORIES = { - billboards: (scene) => new Cesium.BillboardCollection({ scene }), - points: () => new Cesium.PointPrimitiveCollection(), - models: () => new Cesium.PrimitiveCollection({ destroyPrimitives: true }), - loadModel: (options) => Cesium.Model.fromGltfAsync(options), - cropCells: cropAtlasCells, -}; - -const depthTestDistance = (depthTest) => - depthTest === 'always' - ? Number.POSITIVE_INFINITY - : depthTest === 'normal' - ? 0 - : depthTest.disableWithinM; - -const nearFar = (value) => - value ? new Cesium.NearFarScalar(...value) : undefined; - -function readPosition(store, index) { - const { positions } = store; - return Cesium.Cartesian3.fromElements( - positions[index * 3], - positions[index * 3 + 1], - positions[index * 3 + 2], - scratchPosition, - ); -} - -function readColor(store, index) { - const { colors } = store; - scratchColor.red = colors[index * 4]; - scratchColor.green = colors[index * 4 + 1]; - scratchColor.blue = colors[index * 4 + 2]; - scratchColor.alpha = colors[index * 4 + 3]; - return scratchColor; -} - -function spritePart(scene, options, store, factories, requestRender, owners) { - const collection = factories.billboards(scene); - const { sprite, depthTest, id } = options; - const style = { - width: sprite.sizePx, - height: sprite.sizePx, - scaleByDistance: nearFar(sprite.scaleByDistance), - disableDepthTestDistance: depthTestDistance(depthTest), - }; - const billboards = []; - const shownCell = []; - let cells = null; - let destroyed = false; - const apply = (from, to) => { - for (let index = from; index < to; index++) { - let billboard = billboards[index]; - if (index >= store.count) { - if (billboard) billboard.show = false; - continue; - } - if (!billboard) { - billboard = collection.add({ ...style }); - billboards[index] = billboard; - owners.set(billboard, { owner: id, instance: index }); - shownCell[index] = -1; - } - billboard.position = readPosition(store, index); - billboard.rotation = store.rotations[index]; - billboard.scale = store.scales[index]; - billboard.color = readColor(store, index); - billboard.show = store.shows[index] === 1; - billboard.id = store.pickIds[index]; - const cell = store.cells[index]; - if (shownCell[index] !== cell) { - const key = store.cellKeys[cell]; - billboard.setImage(`${id}:${key}`, cells.get(key)); - shownCell[index] = cell; - } - } - }; - // Instances wait for the atlas; then everything drawn so far is applied. - factories - .cropCells(sprite.atlas, store.cellKeys) - .then((ready) => { - if (destroyed) return; - cells = ready; - apply(0, Math.max(store.count, billboards.length)); - requestRender('movers'); - }) - .catch((error) => console.warn(`[movers] ${id}: atlas failed`, error)); - return { - primitive: collection, - ready: () => cells !== null, - commit(range) { - if (cells && range) apply(range.from, range.to); - requestRender('movers'); - }, - destroy() { - destroyed = true; - billboards.length = 0; - shownCell.length = 0; - cells = null; - }, - }; -} - -function pointPart(options, store, factories, requestRender, owners) { - const collection = factories.points(); - const { point, depthTest } = options; - const style = { - outlineWidth: point.outlinePx, - outlineColor: new Cesium.Color(...point.outlineColor), - scaleByDistance: nearFar(point.scaleByDistance), - translucencyByDistance: nearFar(point.translucencyByDistance), - disableDepthTestDistance: depthTestDistance(depthTest), - }; - const points = []; - return { - primitive: collection, - commit(range) { - if (range) - for (let index = range.from; index < range.to; index++) { - let item = points[index]; - if (index >= store.count) { - if (item) item.show = false; - continue; - } - if (!item) { - item = collection.add({ ...style }); - points[index] = item; - owners.set(item, { owner: options.id, instance: index }); - } - item.position = readPosition(store, index); - item.pixelSize = point.sizePx * store.scales[index]; - item.color = readColor(store, index); - item.show = store.shows[index] === 1; - item.id = store.pickIds[index]; - } - requestRender('movers'); - }, - destroy() { - points.length = 0; - }, - }; -} - -function modelPart(options, store, factories, requestRender, owners) { - const collection = factories.models(); - const { model } = options; - const blendMode = - model.colorBlend?.mode === 'replace' - ? Cesium.ColorBlendMode.REPLACE - : Cesium.ColorBlendMode.MIX; - const models = []; - const loading = []; - let destroyed = false; - const place = (item, index) => { - const { orientations } = store; - scratchQuaternion.x = orientations[index * 4]; - scratchQuaternion.y = orientations[index * 4 + 1]; - scratchQuaternion.z = orientations[index * 4 + 2]; - scratchQuaternion.w = orientations[index * 4 + 3]; - item.modelMatrix = Cesium.Matrix4.fromRotationTranslation( - Cesium.Matrix3.fromQuaternion(scratchQuaternion, scratchRotation), - readPosition(store, index), - scratchModelMatrix, - ); - item.scale = store.scales[index]; - item.show = index < store.count && store.shows[index] === 1; - item.id = store.pickIds[index]; - if (model.colorBlend) item.color = readColor(store, index); - }; - const ensure = (index) => { - if (models[index] || loading[index]) return; - loading[index] = true; - factories - .loadModel({ - url: model.url, - minimumPixelSize: model.minimumPixelSize, - maximumScale: Number.isFinite(model.maximumScale) - ? model.maximumScale - : undefined, - colorBlendMode: blendMode, - colorBlendAmount: model.colorBlend?.amount ?? 0.5, - ...(model.unlit - ? { - customShader: new Cesium.CustomShader({ - lightingModel: Cesium.LightingModel.UNLIT, - }), - } - : {}), - }) - .then((item) => { - if (destroyed) { - item.destroy?.(); - return; - } - models[index] = item; - owners.set(item, { owner: options.id, instance: index }); - place(item, index); - collection.add(item); - requestRender('movers'); - }) - .catch((error) => - console.warn(`[movers] ${options.id}: model failed`, error), - ); - }; - return { - primitive: collection, - ready() { - for (let index = 0; index < store.count; index++) - if (!models[index]?.ready) return false; - return true; - }, - commit(range) { - if (range) - for (let index = range.from; index < range.to; index++) { - if (index < store.count) ensure(index); - if (models[index]) place(models[index], index); - } - requestRender('movers'); - }, - destroy() { - destroyed = true; - models.length = 0; - loading.length = 0; - }, - }; -} - -/** - * @param {Cesium.Viewer} viewer - * @param {{ requestRender?: (reason: string) => void, factories?: object }} [options] - * `factories` (collections, model loading, atlas cropping) is a test seam. - * @returns {{ movers: import('../contract.js').GlobeMovers, destroy: () => void }} - */ -export function createCesiumMovers( - viewer, - { requestRender = () => viewer.scene.requestRender(), factories = {} } = {}, -) { - scratchPosition ??= new Cesium.Cartesian3(); - scratchColor ??= new Cesium.Color(); - scratchQuaternion ??= new Cesium.Quaternion(); - scratchRotation ??= new Cesium.Matrix3(); - scratchModelMatrix ??= new Cesium.Matrix4(); - const make = { ...DEFAULT_FACTORIES, ...factories }; - const { scene } = viewer; - let root = null; - const ordered = []; - // Billboard, point or model → its set and instance, for picks. - const owners = new WeakMap(); - const rootCollection = () => { - if (!root) { - root = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); - scene.primitives.add(root); - } - return root; - }; - const registry = createMoverRegistry((options, store) => { - const part = - options.kind === 'sprite' - ? spritePart(scene, options, store, make, requestRender, owners) - : options.kind === 'point' - ? pointPart(options, store, make, requestRender, owners) - : modelPart(options, store, make, requestRender, owners); - const { primitive } = part; - primitive.gevMoverSet = options.id; - // Later (higher) order draws later: after every set of lower or equal order. - const at = ordered.filter((entry) => entry.order <= options.order).length; - ordered.splice(at, 0, { order: options.order, primitive }); - rootCollection().add(primitive, at); - return { - primitive, - ready: part.ready, - commit: () => part.commit(store.dirty()), - destroy() { - part.destroy(); - ordered.splice( - ordered.findIndex((entry) => entry.primitive === primitive), - 1, - ); - root.remove(primitive); - requestRender('movers'); - }, - }; - }); - return { - movers: registry.movers, - /** Where an instance of a set is (a geo Cartesian3), or null. */ - instancePosition(owner, instance) { - const entry = registry.sets.get(owner); - if (!entry || !(instance < entry.store.count)) return null; - const view = entry.store.positionsView(); - const at = 3 * instance; - return { x: view[at], y: view[at + 1], z: view[at + 2] }; - }, - /** The primitive drawing a live mover set of this engine, or null. */ - primitiveOf(set) { - const entry = set?.id ? registry.sets.get(set.id) : null; - return entry && entry.set === set ? entry.part.primitive : null; - }, - /** The set and instance a Cesium pick result belongs to, or null. */ - identify(picked) { - const known = picked?.primitive ? owners.get(picked.primitive) : null; - const entry = known && registry.sets.get(known.owner); - return entry ? { ...known, pickable: entry.options.pickable } : null; - }, - destroy() { - registry.destroyAll(); - if (root) { - scene.primitives.remove(root); - root = null; - } - }, - }; -} diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs deleted file mode 100644 index edbf6991c..000000000 --- a/src/globe/cesium/movers.test.mjs +++ /dev/null @@ -1,248 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { createCesiumMovers } from './movers.js'; -import { createFakeViewer } from './testing/fakeViewer.mjs'; - -// Collections that keep what the engine gives them (Cesium's own need a -// WebGL scene to add billboards). -function fakeFactories() { - const made = []; - const collection = (kind) => { - const items = []; - const value = { - kind, - items, - show: true, - add(options) { - // Cesium's billboards and points copy positions and colours. - let position; - let color; - const item = { - ...options, - get position() { - return position; - }, - set position(value) { - position = Cesium.Cartesian3.clone(value); - }, - get color() { - return color; - }, - set color(value) { - color = Cesium.Color.clone(value); - }, - images: [], - setImage(id, image) { - this.images.push(id); - this.image = image; - }, - }; - items.push(item); - return item; - }, - remove(item) { - items.splice(items.indexOf(item), 1); - return true; - }, - get length() { - return items.length; - }, - isDestroyed: () => false, - destroy() { - value.destroyed = true; - }, - }; - made.push(value); - return value; - }; - return { - made, - factories: { - billboards: () => collection('billboards'), - points: () => collection('points'), - models: () => collection('models'), - loadModel: async (options) => ({ ...options, loaded: true }), - cropCells: async (atlas, keys) => - new Map(keys.map((key) => [key, { cell: key }])), - }, - }; -} - -const atlas = { - canvas: { width: 64, height: 32 }, - cells: { jet: [0, 0, 0.5, 1], heli: [0.5, 0, 1, 1] }, -}; -const at = (x) => ({ x, y: 0, z: 6_400_000 }); -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - -test('creating the movers changes nothing; the first set adds one root collection', () => { - const viewer = createFakeViewer(); - const { factories } = fakeFactories(); - const { movers, destroy } = createCesiumMovers(viewer, { factories }); - assert.equal(viewer.scene.primitives.length, 0); - movers.createSet({ - id: 'a', - kind: 'point', - capacity: 2, - point: { sizePx: 4 }, - }); - assert.equal(viewer.scene.primitives.length, 1); - destroy(); - assert.equal(viewer.scene.primitives.length, 0); -}); - -test('sprites become billboards with the set style once the atlas cells are ready', async () => { - const viewer = createFakeViewer(); - const { factories, made } = fakeFactories(); - const requests = []; - const { movers } = createCesiumMovers(viewer, { - factories, - requestRender: (reason) => requests.push(reason), - }); - const set = movers.createSet({ - id: 'flights', - kind: 'sprite', - capacity: 3, - depthTest: 'always', - sprite: { atlas, sizePx: 20, scaleByDistance: [1e3, 1, 1e6, 0.5] }, - }); - set.set(0, { position: at(1), rotation: 0.25, cell: 'heli', pickId: 'x1' }); - set.set(1, { position: at(2), color: [1, 0, 0, 0.5], scale: 2, show: false }); - set.setCount(2); - set.commit(); - const billboards = made.find( - (collection) => collection.kind === 'billboards', - ); - assert.equal(billboards.items.length, 0, 'waits for the atlas'); - await settle(); - assert.equal(billboards.items.length, 2); - const [first, second] = billboards.items; - assert.ok(first.position instanceof Cesium.Cartesian3); - assert.equal(first.position.x, 1); - assert.equal(first.width, 20); - assert.equal(first.height, 20); - assert.equal(first.rotation, 0.25); - assert.equal(first.id, 'x1'); - assert.equal(first.disableDepthTestDistance, Number.POSITIVE_INFINITY); - assert.ok(first.scaleByDistance instanceof Cesium.NearFarScalar); - assert.deepEqual(first.images, ['flights:heli']); - assert.equal(first.show, true); - assert.equal(second.show, false); - assert.equal(second.scale, 2); - assert.ok(Cesium.Color.equals(second.color, new Cesium.Color(1, 0, 0, 0.5))); - assert.ok(requests.length >= 1); - // Lowering the count hides the instances past it. - set.setCount(1); - set.commit(); - assert.equal(second.show, false); - set.set(0, { cell: 'heli' }); - set.commit(); - assert.deepEqual( - first.images, - ['flights:heli'], - 'the same cell is not re-sent', - ); -}); - -test('points take the size, outline and depth test of the set', () => { - const viewer = createFakeViewer(); - const { factories, made } = fakeFactories(); - const { movers } = createCesiumMovers(viewer, { factories }); - const set = movers.createSet({ - id: 'sats', - kind: 'point', - capacity: 2, - depthTest: { disableWithinM: 5000 }, - point: { sizePx: 4, outlinePx: 1 }, - }); - set.set(0, { position: at(3), scale: 1.5, color: [0, 1, 0, 1] }); - set.setCount(1); - set.commit(); - const [point] = made.find((c) => c.kind === 'points').items; - assert.equal(point.pixelSize, 6); - assert.equal(point.outlineWidth, 1); - assert.equal(point.disableDepthTestDistance, 5000); - assert.ok(Cesium.Color.equals(point.color, new Cesium.Color(0, 1, 0, 1))); -}); - -test('sets are ordered in the root by their order', () => { - const viewer = createFakeViewer(); - const { factories } = fakeFactories(); - const { movers } = createCesiumMovers(viewer, { factories }); - const make = (id, order) => - movers.createSet({ - id, - kind: 'point', - capacity: 1, - order, - point: { sizePx: 2 }, - }); - make('top', 5); - make('bottom', -1); - make('middle', 0); - const root = viewer.scene.primitives.get(0); - assert.deepEqual( - [0, 1, 2].map((index) => root.get(index).gevMoverSet), - ['bottom', 'middle', 'top'], - ); -}); - -test('models load per instance and follow position and orientation', async () => { - const viewer = createFakeViewer(); - const { factories, made } = fakeFactories(); - const { movers } = createCesiumMovers(viewer, { factories }); - const set = movers.createSet({ - id: 'models', - kind: 'model', - capacity: 2, - model: { - url: '/models/airplane.glb', - minimumPixelSize: 24, - colorBlend: { mode: 'mix', amount: 0.4 }, - }, - }); - set.set(0, { - position: at(4), - orientation: { x: 0, y: 0, z: 0, w: 1 }, - color: [1, 0.5, 0, 1], - }); - set.setCount(1); - set.commit(); - await settle(); - const models = made.find((c) => c.kind === 'models'); - assert.equal(models.items.length, 1); - const [model] = models.items; - assert.equal(model.url, '/models/airplane.glb'); - assert.equal(model.minimumPixelSize, 24); - assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX); - assert.equal(model.colorBlendAmount, 0.4); - assert.equal(model.modelMatrix[12], 4); - set.destroy(); - assert.equal(models.destroyed, true); -}); - -test('a pick of a billboard or point names its set and instance', async () => { - const viewer = createFakeViewer(); - const { factories, made } = fakeFactories(); - const { movers, identify } = createCesiumMovers(viewer, { factories }); - const set = movers.createSet({ - id: 'sats', - kind: 'point', - capacity: 3, - point: { sizePx: 4 }, - }); - set.set(0, { position: at(1) }); - set.set(1, { position: at(2), pickId: 25544 }); - set.setCount(2); - set.commit(); - const [, second] = made.find((c) => c.kind === 'points').items; - assert.deepEqual(identify({ primitive: second, id: 25544 }), { - owner: 'sats', - instance: 1, - pickable: false, - }); - assert.equal(identify({ primitive: {}, id: 'x' }), null); - set.destroy(); - assert.equal(identify({ primitive: second, id: 25544 }), null); -}); diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js index 20ee5a95c..66f91fffc 100644 --- a/src/globe/cesium/picker.js +++ b/src/globe/cesium/picker.js @@ -6,44 +6,18 @@ import { toCesium2, toGeo3 } from './convert.js'; /** * @param {Cesium.Viewer} viewer - * @param {{ - * identify?: (picked: object) => ({ owner: string, instance?: number, pickable?: boolean } | null), - * visible?: (known: { owner: string, instance?: number }) => boolean, - * }} [options] - * `identify` names the mover set or line (and instance) a pick belongs to; - * `visible` says whether that instance is in front of the horizon - * (Cesium's GPU pick sees billboards drawn without a depth test through - * the globe). * @returns {{ picker: object, destroy: () => void }} */ -export function createCesiumPicker( - viewer, - { identify = () => null, visible = () => true } = {}, -) { +export function createCesiumPicker(viewer) { const normalise = (picked) => { if (picked == null) return undefined; // The follow camera's proxy entity (follow.js) is not an object. if (picked.id?.gevFollowProxy) return undefined; - const known = identify(picked); - // A mover set created with pickable: false is not picked (Cesium's GPU - // pick sees every primitive; Navara's only the pickable ones), nor an - // instance behind the horizon. - if (known?.pickable === false) return undefined; - if (known && known.instance !== undefined && !visible(known)) - return undefined; return { id: picked.id, primitive: picked.primitive, content: picked.content, object: picked, - ...(known - ? { - owner: known.owner, - ...(known.instance !== undefined - ? { instance: known.instance } - : {}), - } - : {}), }; }; const { scene } = viewer; @@ -58,8 +32,8 @@ export function createCesiumPicker( if (top == null) return undefined; const picked = normalise(top); if (picked) return picked; - // The top result is filtered: what lies under it, the first with an - // id (an object), else the first kept (map tiles). + // The top result is the follow proxy: what lies under it, the first + // with an id (an object), else the first kept (map tiles). const under = drill(screen, undefined, width, height); return under.find((entry) => entry.id !== undefined) ?? under[0]; }, @@ -81,8 +55,7 @@ export function createCesiumPicker( pickGlobe(screen) { if (!scene.globe) return undefined; try { - const camera = viewer.camera ?? scene.camera; - const ray = camera.getPickRay(toCesium2(screen)); + const ray = viewer.camera.getPickRay(toCesium2(screen)); return ray ? toGeo3(scene.globe.pick(ray, scene)) : undefined; } catch { return undefined; diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs index ca10efc44..6e40298af 100644 --- a/src/globe/cesium/picker.test.mjs +++ b/src/globe/cesium/picker.test.mjs @@ -114,97 +114,23 @@ test('pickGlobe asks the globe along the camera pick ray and returns geo', () => assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined); }); -test('picks of movers and lines name their owner and instance', () => { +test('a follow-proxy top pick falls through to the first pick under it with an id', () => { const viewer = createFakeViewer(); - const billboard = { id: 'ae01ce' }; - viewer.scene.pick = () => ({ primitive: billboard, id: 'ae01ce' }); - viewer.scene.drillPick = () => [ - { primitive: billboard, id: 'ae01ce' }, - { primitive: {}, id: 'other' }, - ]; - const { picker } = createCesiumPicker(viewer, { - identify: (raw) => - raw.primitive === billboard ? { owner: 'flights', instance: 7 } : null, - }); - const picked = picker.pick({ x: 1, y: 2 }); - assert.equal(picked.id, 'ae01ce'); - assert.equal(picked.owner, 'flights'); - assert.equal(picked.instance, 7); - const drilled = picker.drillPick({ x: 1, y: 2 }, 5); - assert.equal(drilled[0].owner, 'flights'); - assert.equal(drilled[1].owner, undefined); -}); - -test('picks of a mover set made with pickable: false are dropped', () => { - const viewer = createFakeViewer(); - const quiet = { id: 'q' }; - viewer.scene.pick = () => ({ primitive: quiet, id: 'q' }); - viewer.scene.drillPick = () => [ - { primitive: quiet, id: 'q' }, - { primitive: {}, id: 'other' }, - ]; - const { picker } = createCesiumPicker(viewer, { - identify: (raw) => - raw.primitive === quiet - ? { owner: 'quiet', instance: 0, pickable: false } - : null, - }); - // The pick falls through to what is under the quiet set. - assert.equal(picker.pick({ x: 1, y: 2 }).id, 'other'); - assert.deepEqual( - picker.drillPick({ x: 1, y: 2 }, 5).map(({ id }) => id), - ['other'], - ); -}); - -test('a filtered top pick falls through to the first pick under it with an id', () => { - const viewer = createFakeViewer(); - const quiet = { primitive: 'quiet-billboard', id: 'q' }; const proxy = { primitive: 'proxy-point', id: { gevFollowProxy: true } }; const tile = { primitive: 'tileset', content: {} }; const flight = { primitive: 'flight-billboard', id: 'ae01ce' }; - const { picker } = createCesiumPicker(viewer, { - identify: (picked) => - picked === quiet - ? { owner: 'quiet', instance: 0, pickable: false } - : picked === flight - ? { owner: 'flights', instance: 3, pickable: true } - : null, - }); - viewer.scene.pick = () => quiet; - viewer.scene.drillPick = () => [quiet, proxy, tile, flight]; - const picked = picker.pick(geo.cartesian2.create(1, 1)); - assert.equal(picked.id, 'ae01ce'); - assert.equal(picked.owner, 'flights'); + const { picker } = createCesiumPicker(viewer); + viewer.scene.pick = () => proxy; + viewer.scene.drillPick = () => [proxy, tile, flight]; + assert.equal(picker.pick(geo.cartesian2.create(1, 1)).id, 'ae01ce'); assert.deepEqual( picker.drillPick(geo.cartesian2.create(1, 1)).map((p) => p.primitive), ['tileset', 'flight-billboard'], - 'the follow proxy and pickable: false sets are never picked', + 'the follow proxy is never picked', ); // Nothing under it with an id: the first kept result (a tile). - viewer.scene.drillPick = () => [quiet, tile]; + viewer.scene.drillPick = () => [proxy, tile]; assert.equal(picker.pick(geo.cartesian2.create(1, 1)).primitive, 'tileset'); - viewer.scene.pick = () => proxy; viewer.scene.drillPick = () => [proxy]; assert.equal(picker.pick(geo.cartesian2.create(1, 1)), undefined); }); - -test('drills leave out mover instances behind the horizon', () => { - const viewer = createFakeViewer(); - const near = { primitive: 'near', id: 'n' }; - const far = { primitive: 'far', id: 'f' }; - const { picker } = createCesiumPicker(viewer, { - identify: (picked) => ({ - owner: 'flights', - instance: picked === near ? 0 : 1, - }), - visible: (known) => known.instance === 0, - }); - viewer.scene.drillPick = () => [far, near]; - assert.deepEqual( - picker.drillPick(geo.cartesian2.create(1, 1)).map((p) => p.id), - ['n'], - ); - viewer.scene.pick = () => far; - assert.equal(picker.pick(geo.cartesian2.create(1, 1)).id, 'n'); -}); diff --git a/src/globe/cesium/projector.js b/src/globe/cesium/projector.js index dfb0e1b77..3aed5e06c 100644 --- a/src/globe/cesium/projector.js +++ b/src/globe/cesium/projector.js @@ -15,9 +15,7 @@ export function createCesiumProjector( worldToWindowCoordinates = Cesium.SceneTransforms.worldToWindowCoordinates, } = {}, ) { - const { scene } = viewer; - // Viewer.camera is scene.camera; the engine reads the public alias. - const camera = viewer.camera ?? scene.camera; + const { scene, camera } = viewer; const scratchWindow = new Cesium.Cartesian2(); const scratchMatrix = new Cesium.Matrix4(); const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); diff --git a/src/globe/cesium/surface.js b/src/globe/cesium/surface.js index 219699f84..f1b90fa84 100644 --- a/src/globe/cesium/surface.js +++ b/src/globe/cesium/surface.js @@ -1,11 +1,5 @@ // GlobeSurface on a Cesium scene: the rendered-surface queries that // services/groundSnap and services/meshFloorSampler make. -// -// `exclude` takes the engine's own mover sets (engine.movers) as well as -// Cesium objects: Cesium's sampleHeight matches excluded objects by the -// picked primitive, which for a set is each billboard, point or model, so a -// set is hidden for the sample instead (synchronous: nothing draws in -// between) and shown again after it. import { firstVisibleTileset } from '../../services/groundSnap.js'; import { normalizeSurfaceOptions } from '../surfaceDetail.js'; @@ -15,46 +9,16 @@ const finite = (value) => (Number.isFinite(value) ? value : undefined); /** * @param {Cesium.Viewer} viewer - * @param {{ engineExclude?: (entry: any) => ({ show: boolean } | null) }} [options] - * `engineExclude` maps an engine object (a mover set) to the Cesium - * primitive that draws it, or null for a Cesium object. * @returns {{ surface: import('../contract.js').GlobeSurface, destroy: () => void }} */ -export function createCesiumSurface( - viewer, - { engineExclude = () => null } = {}, -) { +export function createCesiumSurface(viewer) { const { scene } = viewer; - // Runs `query(nativeExclude)` with the engine objects in `exclude` hidden. - function excluding(exclude, query) { - if (!exclude?.length) return query(exclude); - let native = exclude; - const hidden = []; - for (const entry of exclude) { - const primitive = engineExclude(entry); - if (!primitive) continue; - if (native === exclude) - native = exclude.filter((other) => !engineExclude(other)); - if (primitive.show) { - primitive.show = false; - hidden.push(primitive); - } - } - try { - return query(native); - } finally { - for (const primitive of hidden) primitive.show = true; - } - } - const sampleHeight = (cartographic, exclude) => { if (!scene.sampleHeightSupported) return undefined; try { // Throws when unsupported (no depth textures); that is a miss. - return excluding(exclude, (native) => - finite(scene.sampleHeight(cartographic, native)), - ); + return finite(scene.sampleHeight(cartographic, exclude)); } catch { return undefined; } @@ -64,8 +28,8 @@ export function createCesiumSurface( const surface = { sampleHeight, - // Cesium samples the tiles it has loaded (lod 'finest' cannot do better - // synchronously) and does not say which tile answered. + // Cesium samples the tiles it has loaded and does not say which tile + // answered. sampleHeightDetailed(cartographic, options) { const { exclude } = normalizeSurfaceOptions(options); const height = sampleHeight(cartographic, exclude); @@ -85,9 +49,7 @@ export function createCesiumSurface( clampToHeight(position, exclude) { if (!scene.clampToHeightSupported) return undefined; try { - return excluding(exclude, (native) => - toGeo3(scene.clampToHeight(position, native)), - ); + return toGeo3(scene.clampToHeight(position, exclude)); } catch { return undefined; } diff --git a/src/globe/cesium/surface.test.mjs b/src/globe/cesium/surface.test.mjs index 91df264c4..063e78329 100644 --- a/src/globe/cesium/surface.test.mjs +++ b/src/globe/cesium/surface.test.mjs @@ -93,35 +93,12 @@ test('sampleHeightDetailed: tiles from sampleHeight, else terrain; no tile size' source: 'tiles', }); scene.sampleHeight = () => undefined; - assert.deepEqual(surface.sampleHeightDetailed(place, { lod: 'finest' }), { + assert.deepEqual(surface.sampleHeightDetailed(place), { height: 12.5, source: 'terrain', }); assert.throws( - () => surface.sampleHeightDetailed(place, { lod: 2 }), + () => surface.sampleHeightDetailed(place, { exclude: 'x' }), TypeError, ); }); - -test('an engine mover set in exclude is hidden for the sample, native objects pass', () => { - const viewer = createFakeViewer(); - const collection = { show: true }; - const set = { id: 'flights' }; - const { surface } = createCesiumSurface(viewer, { - engineExclude: (entry) => (entry === set ? collection : null), - }); - const shownDuring = []; - viewer.scene.sampleHeight = (position, excluded) => { - shownDuring.push(collection.show, excluded); - return 30; - }; - const native = { primitive: 1 }; - const place = geo.cartographic.fromDegrees(20, 10, 0); - assert.equal(surface.sampleHeight(place, [set, native]), 30); - assert.deepEqual(shownDuring, [false, [native]]); - assert.equal(collection.show, true, 'shown again after the sample'); - shownDuring.length = 0; - surface.sampleHeightDetailed(place, { exclude: [set] }); - assert.deepEqual(shownDuring, [false, []]); - assert.equal(collection.show, true); -}); diff --git a/src/globe/contract.js b/src/globe/contract.js index 7a6bd44dd..fb867f47c 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -189,12 +189,12 @@ * @typedef {object} GlobeSurface * @property {(cartographic: Cartographic, exclude?: any[]) => (number | undefined)} sampleHeight * Height of the rendered scene (tiles, models) under a point. - * @property {(cartographic: Cartographic, options?: { exclude?: any[], lod?: 'visible' | 'finest' }) => (SurfaceHeight | undefined)} sampleHeightDetailed + * @property {(cartographic: Cartographic, options?: { exclude?: any[] }) => (SurfaceHeight | undefined)} sampleHeightDetailed * sampleHeight with the answer's quality (src/globe/surfaceDetail.js): - * lod 'visible' (default) answers from the tiles drawn now, 'finest' from - * the finest loaded ones (Navara; Cesium samples what it has loaded). - * Where no tile answers, the terrain. `exclude` takes this engine's mover - * sets as well as engine objects. + * from the tiles drawn now (Navara: where they miss, the finest loaded + * ones; Cesium samples what it has loaded). Where no tile answers, the + * terrain. `exclude` takes this engine's mover sets as well as engine + * objects. * @property {boolean} supported Whether sampleHeight works. * @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight * Height of the terrain/globe surface only (Cesium's globe.getHeight). @@ -203,7 +203,6 @@ * The position moved onto the rendered scene along the geodetic normal. * @property {boolean} clampToHeightSupported * @property {() => boolean} tilesLoaded - * @property {boolean} [raycastSupported] Optional visible-tile obstruction probe. * @property {(ray: { origin: Cartesian3, direction: Cartesian3, far: number, exclude?: any[] }) => ({ position: Cartesian3, distance: number, object: any } | undefined)} [raycast] * Nearest visible tile triangle within finite positive `far` metres. Direction * is normalized by the adapter. Excluded roots/meshes and hidden objects do @@ -339,28 +338,23 @@ * epoch: the one time source for layers and the engine's own time-driven * drawing (Navara's sun). By default it is the wall clock (Date.now); QA * and tests may freeze, offset or speed it up on the app clock: now() - * follows at once, frameTime() and ticked from the engine's next frame, and - * in request-render mode nothing draws until a render is requested, so a - * QA helper that moves the clock calls render.request() after it. - * Not on this clock yet (each moves when its layer or code moves): + * follows at once, frameTime() from the engine's next frame, and in + * request-render mode nothing draws until a render is requested, so a QA + * helper that moves the clock calls render.request() after it. + * Not on this clock: * - Cesium's viewer.clock: the app never animates it (shouldAnimate false, * its time frozen at boot); Cesium's sun and lighting and every entity * property read at viewer.clock.currentTime stay on that time; - * - wall-clock throttles and feed receipt stamps everywhere (they measure - * real elapsed time, not scene time). - * Launch replay, satellite propagation and orbit GMST now use this clock. + * - wall-clock throttles and feed receipt stamps (they measure real + * elapsed time, not scene time). * @property {() => number} now The current app time. * @property {() => number} frameTime The app time latched at the start of the * engine's current frame: every reader in one frame gets the same value; it * does not decrease from frame to frame while the app clock runs forward. - * Before the first frame, the current time. - * @property {EngineEvent} ticked Raised once per engine frame, with the - * frame time, before that frame's preUpdate (and so before preRender). A - * frame is one turn of the engine's update loop, also when it draws - * nothing: Cesium raises it on the viewer clock's tick, right before - * scene.render (which raises preUpdate on every animation frame, drawn or - * not); Navara on the view's preUpdate, which it emits on every turn of - * its loop. + * Before the first frame, the current time. A frame is one turn of the + * engine's update loop, also when it draws nothing; its time is latched + * before its preUpdate (Cesium: on the viewer clock's tick, right before + * scene.render; Navara: on the view's preUpdate). * * @typedef {object} MoverSetOptions * A set of moving objects drawn as one batch (src/globe/movers.js checks @@ -383,14 +377,14 @@ * @property {{ sizePx: number, outlinePx?: number, outlineColor?: number[], scaleByDistance?: number[], translucencyByDistance?: number[] }} [point] * A filled disc of sizePx CSS px diameter plus outlinePx on each side; * outlineColor is rgba in 0..1 (opaque black by default). - * @property {{ url: string, minimumPixelSize?: number, maximumScale?: number, + * @property {{ url: string, minimumPixelSize?: number, * colorBlend?: { mode: 'mix'|'replace', amount: number }, unlit?: boolean }} [model] * A glTF drawn per instance in metres, at least minimumPixelSize px. * @property {boolean} [pickable] * @property {boolean} [sharpen] Sprites/points: include the glyph in the - * sharpen/AA stage after tone mapping, for translucent fields such as FIRMS. - * Navara defaults to the later display stage used by W1; Cesium retains its - * native billboard stage. Does not enable sharpening or change its amount. + * sharpen/AA stage after tone mapping, for translucent fields such as FIRMS + * (by default Navara draws them in the later display stage). Does not + * enable sharpening or change its amount. * * @typedef {object} MoverFields * One instance; omitted fields keep their value (defaults: white, scale 1, @@ -413,10 +407,8 @@ * @property {() => number} count * @property {() => void} commit Uploads what changed (one buffer write) and * requests a render; nothing else draws a frame. - * @property {() => Float64Array} positionsView x, y, z of the drawn - * instances (a view on the set's own array; read-only). * @property {() => boolean} ready Whether what the set draws has loaded: a - * model set's glTF (Cesium: every instance's model), a sprite set's atlas. + * model set's glTF, a sprite set's atlas. * A layer keeps its fallback visual until then. * @property {() => void} destroy * @@ -475,8 +467,11 @@ * @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits * @property {GlobeMaps} maps * @property {GlobeClock} clock - * @property {GlobeMovers} movers - * @property {GlobeLines} lines + * @property {GlobeMovers} [movers] Optional, with lines (capabilities + * `movers` and `lines`): the engine draws the layers' moving objects and + * polylines (Navara). Cesium has none: in Cesium mode the layers draw + * their own Cesium primitives. + * @property {GlobeLines} [lines] * @property {() => void} destroy * @property {() => boolean} isDestroyed True once destroy() has run. */ @@ -491,7 +486,7 @@ export const INPUT_TYPES = Object.freeze([ ]); export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']); /** Input types whose listeners may ask for picks. */ -export const PICK_INPUT_TYPES = Object.freeze(['click', 'doubleClick', 'move']); +const PICK_INPUT_TYPES = Object.freeze(['click', 'doubleClick', 'move']); /** * input.on options checked and defaulted: { modifier, pick, drill } (drill @@ -512,11 +507,6 @@ export function normalizeInputOptions(type, options = {}) { if (drill && type === 'move') throw new TypeError('A move cannot drill'); return { modifier, pick: pick || drill > 0, drill }; } -export const FLIGHT_EASINGS = Object.freeze([ - 'cubicInOut', - 'sinusoidalInOut', - 'linear', -]); const ENGINE_EVENT = Symbol('gev.globe.engineEvent'); @@ -681,9 +671,7 @@ const SHAPE = { }, input: { on: isFunction, setCameraInputs: isFunction }, credits: { addStatic: isFunction }, - clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent }, - movers: { createSet: isFunction, list: isFunction }, - lines: { createLine: isFunction, list: isFunction }, + clock: { now: isFunction, frameTime: isFunction }, maps: { setBaseImagery: isFunction, setTerrain: isFunction, @@ -720,21 +708,16 @@ const OPTIONAL_SHAPE = { 'render.setContactSonar': isFunction, 'render.contactSonar': isFunction, }, + movers: { + 'movers.createSet': isFunction, + 'movers.list': isFunction, + }, + lines: { + 'lines.createLine': isFunction, + 'lines.list': isFunction, + }, }; -/** The optional capability flags, e.g. 'visualStyles'. */ -export const ENGINE_CAPABILITIES = Object.freeze(Object.keys(OPTIONAL_SHAPE)); - -/** The member paths of each optional capability. */ -export const OPTIONAL_ENGINE_MEMBERS = Object.freeze( - Object.fromEntries( - Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [ - flag, - Object.freeze(Object.keys(members)), - ]), - ), -); - const memberAt = (engine, path) => path.split('.').reduce((value, key) => value?.[key], engine); @@ -756,16 +739,6 @@ export function engineCapabilities(engine) { ); } -/** The capability paths the contract requires, e.g. 'camera.flyTo'. */ -export const GLOBE_ENGINE_MEMBERS = Object.freeze([ - 'kind', - ...Object.entries(SHAPE).flatMap(([capability, members]) => - Object.keys(members).map((member) => `${capability}.${member}`), - ), - 'destroy', - 'isDestroyed', -]); - /** * Whether a value is a globe engine rather than, say, a Cesium viewer (a * cheap structural test; assertGlobeEngine checks every member). diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index b0dba175f..d847148fc 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -2,14 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { ENGINE_KINDS, - GLOBE_ENGINE_MEMBERS, INPUT_TYPES, MODIFIERS, BLOOM_INITIAL, CLOUD_COVER_INITIAL, CONTACT_SONAR_INITIAL, - ENGINE_CAPABILITIES, - OPTIONAL_ENGINE_MEMBERS, SHARPEN_INITIAL, assertGlobeEngine, createEngineEvent, @@ -198,42 +195,6 @@ test('assertGlobeEngine accepts a complete engine and names every missing capabi assert.throws(() => assertGlobeEngine(null), TypeError); }); -test('the contract carries what the shell infrastructure needs', () => { - for (const member of [ - 'camera.livePosition', - 'camera.height', - 'maps.releasePhotorealTiles', - 'maps.current', - ]) - assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); - const engine = createFakeEngine(); - const broken = { - ...engine, - camera: { ...engine.camera, livePosition: undefined, height: 1 }, - maps: { ...engine.maps, releasePhotorealTiles: undefined }, - }; - assert.throws( - () => assertGlobeEngine(broken), - /camera\.livePosition.*camera\.height.*maps\.releasePhotorealTiles/, - ); -}); - -test('the contract carries what shell navigation needs', () => { - for (const member of ['camera.commanded', 'picker.pickGlobe', 'isDestroyed']) - assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); - const engine = createFakeEngine(); - const broken = { - ...engine, - camera: { ...engine.camera, commanded: {} }, - picker: { ...engine.picker, pickGlobe: undefined }, - isDestroyed: false, - }; - assert.throws( - () => assertGlobeEngine(broken), - /camera\.commanded.*picker\.pickGlobe.*isDestroyed/, - ); -}); - test('the fake engine passes the engine contract suite', async (t) => { const engine = createFakeEngine(); const checks = createContractSuite(engine, { @@ -314,9 +275,7 @@ test('raiseValue raises exactly one argument with the event semantics', () => { assert.equal(event.numberOfListeners, 1); }); -test('the contract carries the sharpening and checks its values', () => { - for (const member of ['render.setSharpen', 'render.sharpen']) - assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); +test('mergeSharpen keeps omitted fields and rejects bad values', () => { assert.deepEqual(SHARPEN_INITIAL, { enabled: false, amount: 1 }); const on = mergeSharpen(SHARPEN_INITIAL, { enabled: true }); assert.deepEqual(on, { enabled: true, amount: 1 }); @@ -334,68 +293,34 @@ test('the contract carries the sharpening and checks its values', () => { assert.throws(() => mergeSharpen(on, bad), TypeError); }); -test('the contract carries the app clock', () => { - for (const member of ['clock.now', 'clock.frameTime', 'clock.ticked']) - assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); +test('movers and lines are optional capabilities, whole or absent', () => { const engine = createFakeEngine(); - assert.equal(assertGlobeEngine(engine), engine); - const broken = { - ...engine, - clock: { ...engine.clock, frameTime: 1, ticked: {} }, - }; + assert.equal(engineCapabilities(engine).movers, true); + assert.equal(engineCapabilities(engine).lines, true); + const without = { ...engine }; + delete without.movers; + delete without.lines; + assert.equal(assertGlobeEngine(without), without); + assert.equal(engineCapabilities(without).movers, false); + const half = { ...engine, movers: { createSet: 1 } }; assert.throws( - () => assertGlobeEngine(broken), - /clock\.frameTime.*clock\.ticked/, - ); - const missing = { ...engine }; - delete missing.clock; - assert.throws(() => assertGlobeEngine(missing), /\bclock\b/); -}); - -test('the contract carries instanced movers', () => { - for (const member of ['movers.createSet', 'movers.list']) - assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); - const engine = createFakeEngine(); - const broken = { ...engine, movers: { createSet: 1 } }; - assert.throws( - () => assertGlobeEngine(broken), + () => assertGlobeEngine(half), /movers\.createSet.*movers\.list/, ); -}); - -test('the contract carries dynamic lines', () => { - for (const member of ['lines.createLine', 'lines.list']) - assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); - const engine = createFakeEngine(); - assert.equal(assertGlobeEngine(engine), engine); - const broken = { ...engine, lines: {} }; - assert.throws( - () => assertGlobeEngine(broken), - /lines\.createLine.*lines\.list/, - ); -}); - -test('the contract carries the follow camera', () => { - for (const member of [ - 'camera.follow', - 'camera.following', - 'camera.followChanged', - ]) - assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); - const engine = createFakeEngine(); - const broken = { ...engine, camera: { ...engine.camera, followChanged: {} } }; - assert.throws(() => assertGlobeEngine(broken), /camera\.followChanged/); + // Without them the suite leaves out the checks that draw movers and lines. + const names = (target) => + createContractSuite(target, { waitFrames: async () => {} }).map( + ({ name }) => name, + ); + assert.ok(names(engine).some((name) => name.startsWith('movers:'))); + const plain = names(without); + assert.ok(!plain.some((name) => /^(movers|lines|colours):/.test(name))); + assert.ok(plain.some((name) => name.startsWith('camera.'))); }); test('engineCapabilities names the optional capabilities an engine has whole', () => { - assert.ok(ENGINE_CAPABILITIES.includes('visualStyles')); - assert.deepEqual(OPTIONAL_ENGINE_MEMBERS.visualStyles, [ - 'render.visualStyles', - 'render.setStyle', - 'render.style', - ]); const plain = createFakeEngine(); - for (const flag of ENGINE_CAPABILITIES) + for (const flag of ['visualStyles', 'bloom', 'cloudCover', 'contactSonar']) assert.equal(engineCapabilities(plain)[flag], false, flag); const styled = createFakeEngine({ visualStyles: { thermal: ['palette', 'time'] }, @@ -457,11 +382,6 @@ test('a fake engine with visual styles passes the optional style check', async ( }); test('the bloom capability: whole or absent, merged and checked', async (t) => { - assert.ok(ENGINE_CAPABILITIES.includes('bloom')); - assert.deepEqual(OPTIONAL_ENGINE_MEMBERS.bloom, [ - 'render.setBloom', - 'render.bloom', - ]); const engine = createFakeEngine({ bloom: true }); assert.equal(engineCapabilities(engine).bloom, true); assert.equal(engineCapabilities(engine).visualStyles, false); @@ -482,7 +402,6 @@ test('the bloom capability: whole or absent, merged and checked', async (t) => { }); test('the cloudCover capability: whole or absent, null for the engine own', async (t) => { - assert.ok(ENGINE_CAPABILITIES.includes('cloudCover')); const engine = createFakeEngine({ cloudCover: true }); assert.equal(engineCapabilities(engine).cloudCover, true); assert.equal(engineCapabilities(createFakeEngine()).cloudCover, false); @@ -506,12 +425,6 @@ test('the cloudCover capability: whole or absent, null for the engine own', asyn }); test('the contactSonar capability: whole or absent, merged and checked', async (t) => { - assert.deepEqual(ENGINE_CAPABILITIES, [ - 'visualStyles', - 'bloom', - 'cloudCover', - 'contactSonar', - ]); const engine = createFakeEngine({ contactSonar: true }); assert.equal(engineCapabilities(engine).contactSonar, true); assert.deepEqual(engine.render.contactSonar(), CONTACT_SONAR_INITIAL); diff --git a/src/globe/cpuPicks.js b/src/globe/cpuPicks.js index 61ab015ad..f4fde1a41 100644 --- a/src/globe/cpuPicks.js +++ b/src/globe/cpuPicks.js @@ -16,9 +16,9 @@ const scratchB = geo.cartesian3.create(); const scratchScreen = { x: 0, y: 0 }; const scratchScreenB = { x: 0, y: 0 }; -export const PICK_RADIUS_PX = 3; +const PICK_RADIUS_PX = 3; /** A line is picked within 8 px of its drawn segments (thin trails too). */ -export const LINE_PICK_PX = 8; +const LINE_PICK_PX = 8; /** Distance from point p to segment ab (2D). */ export function distanceToSegment(p, a, b) { diff --git a/src/globe/follow.js b/src/globe/follow.js index 5ba343b25..c8800ee3c 100644 --- a/src/globe/follow.js +++ b/src/globe/follow.js @@ -193,12 +193,7 @@ export function orbitOffset( } /** The ENU offset scaled by `factor`, never inside `minRange`. */ -export function zoomOffset( - offset, - factor, - minRange, - result = cartesian3.create(), -) { +function zoomOffset(offset, factor, minRange, result = cartesian3.create()) { const range = cartesian3.magnitude(offset); if (!(range > 0) || !(factor > 0)) return cartesian3.clone(offset, result); const next = Math.max(minRange, range * factor); diff --git a/src/globe/lines.js b/src/globe/lines.js index f5998991f..880accba6 100644 --- a/src/globe/lines.js +++ b/src/globe/lines.js @@ -23,7 +23,7 @@ function fail(message) { */ export const TACTICAL_DASH = Object.freeze({ groups: 4, dashes: 100 }); -export const LINE_ARCS = Object.freeze(['none', 'geodesic']); +const LINE_ARCS = Object.freeze(['none', 'geodesic']); const isColor = (value) => (Array.isArray(value) || ArrayBuffer.isView(value)) && diff --git a/src/globe/movers.js b/src/globe/movers.js index 4a429b9b8..4ca357382 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -5,7 +5,7 @@ // metres), colours rgba in 0..1, rotations radians. No engine imports. export const MOVER_KINDS = Object.freeze(['sprite', 'point', 'model']); -export const MOVER_DEPTH_TESTS = Object.freeze(['normal', 'always']); +const MOVER_DEPTH_TESTS = Object.freeze(['normal', 'always']); const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value); @@ -146,10 +146,6 @@ export function normalizeMoverSetOptions(options) { 'model.minimumPixelSize', 0, ), - maximumScale: - model.maximumScale === undefined - ? Infinity - : positive(model.maximumScale, 'model.maximumScale'), colorBlend: blend ? { mode: blend.mode, amount: blend.amount ?? 0.5 } : null, @@ -305,8 +301,10 @@ export function createMoverStore(options) { }); this.clean(); }, - /** Indices changed since clean(), as [from, to), or null. */ - /** The changed range (one object, reused: read it at once), or null. */ + /** + * Indices changed since clean(), as [from, to) (one object, reused: read + * it at once), or null. + */ dirty() { if (dirtyFrom === Infinity) return null; range.from = dirtyFrom; @@ -350,14 +348,13 @@ export function createMoverSet( commit(); store.clean(); }, - positionsView: () => store.positionsView(), // Whether what it draws has loaded (a model's glTF, an atlas image). ready: () => !destroyed && ready() === true, destroy() { if (destroyed) return; destroyed = true; destroy(); - store.release?.(); + store.release(); }, isDestroyed: () => destroyed, }; diff --git a/src/globe/movers.test.mjs b/src/globe/movers.test.mjs index 464b26c04..40cd8f147 100644 --- a/src/globe/movers.test.mjs +++ b/src/globe/movers.test.mjs @@ -43,7 +43,6 @@ test('set options are normalised with the contract defaults', () => { model: { url: '/models/airplane.glb' }, }); assert.equal(model.model.minimumPixelSize, 0); - assert.equal(model.model.maximumScale, Infinity); assert.equal(model.model.colorBlend, null); }); @@ -177,7 +176,7 @@ test('the set facade forwards to the store and an engine commit, once destroyed set.set(0, { position: { x: 1, y: 2, z: 3 } }); set.setCount(1); assert.equal(set.count(), 1); - assert.deepEqual([...set.positionsView()], [1, 2, 3]); + assert.deepEqual([...store.positionsView()], [1, 2, 3]); set.commit(); assert.deepEqual(calls, [['commit', { from: 0, to: 1 }]]); assert.equal(store.dirty(), null, 'commit cleans the store'); @@ -205,7 +204,7 @@ test('a destroyed set releases its instance arrays', () => { assert.equal(store.count, 0); assert.equal(store.positions.length, 0); assert.equal(store.pickIds.length, 0); - assert.equal(set.positionsView().length, 0); + assert.equal(store.positionsView().length, 0); assert.equal(store.dirty(), null); }); diff --git a/src/globe/navara/bloom.js b/src/globe/navara/bloom.js index 44104514c..36f6d3312 100644 --- a/src/globe/navara/bloom.js +++ b/src/globe/navara/bloom.js @@ -11,7 +11,7 @@ import { createBloomPort } from './bloomSettings.js'; import { pipelinePassNames } from './internals.js'; import { withPassDisposal } from './frameDescriptor.js'; -export const BLOOM_EFFECT_KEY = 'gevBloom'; +const BLOOM_EFFECT_KEY = 'gevBloom'; function apply(effect, settings) { effect.luminanceMaterial.threshold = settings.luminanceThreshold; diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index c556e4a84..15ae20296 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -30,7 +30,7 @@ import { sphereRange, viewProjection, viewRectangle, -} from './cameraMath.js'; +} from '../cameraMath.js'; const { cartesian3, cartographic, math, matrix4 } = geo; const WGS84 = geo.ellipsoid.WGS84; diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs index 7386ab21d..425b6fb8b 100644 --- a/src/globe/navara/cameraModel.test.mjs +++ b/src/globe/navara/cameraModel.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../geo/index.js'; import { isEngineEvent } from '../contract.js'; -import { orientationVectors } from './cameraMath.js'; +import { orientationVectors } from '../cameraMath.js'; import { createCameraModel } from './cameraModel.js'; const { cartesian2, cartesian3, math, matrix4 } = geo; diff --git a/src/globe/navara/cascadedLightCopy.js b/src/globe/navara/cascadedLightCopy.js index dce4273de..195e5628d 100644 --- a/src/globe/navara/cascadedLightCopy.js +++ b/src/globe/navara/cascadedLightCopy.js @@ -1,4 +1,4 @@ -// Navara 0.2.1 leak fix (phase 3 review): Navara's transparent pass keeps a +// Navara 0.2.1 leak fix: Navara's transparent pass keeps a // copy of each scene light and refreshes it every frame with // `copy.copy(light, true)`. For the sun's cascaded shadow lights // (@navaramap/three-csm CascadedDirectionalLights) that copy clones every diff --git a/src/globe/navara/clock.js b/src/globe/navara/clock.js index 889455aa7..925f715f2 100644 --- a/src/globe/navara/clock.js +++ b/src/globe/navara/clock.js @@ -5,7 +5,6 @@ // contract's preUpdate; every listener of the frame then reads that time. // The look (look.js) reads the same clock for the sun. -import { createEngineEvent } from '../contract.js'; import { appClock as pageClock } from '../../time/clock.js'; /** @@ -14,15 +13,13 @@ import { appClock as pageClock } from '../../time/clock.js'; * @returns {{ clock: import('../contract.js').GlobeClock, beginFrame: () => void }} */ export function createNavaraClock({ appClock = pageClock } = {}) { - const ticked = createEngineEvent(); return { clock: { now: () => appClock.now(), frameTime: () => appClock.frameTime(), - ticked, }, beginFrame() { - ticked.raiseValue(appClock.beginFrame()); + appClock.beginFrame(); }, }; } diff --git a/src/globe/navara/clock.test.mjs b/src/globe/navara/clock.test.mjs index 9d5158515..e99594c88 100644 --- a/src/globe/navara/clock.test.mjs +++ b/src/globe/navara/clock.test.mjs @@ -1,21 +1,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { isEngineEvent } from '../contract.js'; import { createAppClock } from '../../time/clock.js'; import { createNavaraClock } from './clock.js'; -test('beginFrame latches the app clock and raises ticked with the frame time', () => { +test('beginFrame latches the app clock', () => { let wall = 10_000; const appClock = createAppClock({ now: () => wall }); const { clock, beginFrame } = createNavaraClock({ appClock }); - assert.ok(isEngineEvent(clock.ticked)); - const seen = []; - clock.ticked.addEventListener((time) => seen.push(time)); assert.equal(clock.frameTime(), 10_000); wall = 10_016; beginFrame(); wall = 10_020; - assert.deepEqual(seen, [10_016]); assert.equal(clock.frameTime(), 10_016); assert.equal(clock.now(), 10_020); }); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 2908e55ef..d8d829fe9 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -20,7 +20,7 @@ import { mergeSharpen, mergeStyle, } from '../contract.js'; -import { cesiumFovy } from './cameraMath.js'; +import { cesiumFovy, intersectEllipsoid } from '../cameraMath.js'; import { createCameraModel } from './cameraModel.js'; import { createNavaraClock } from './clock.js'; import { createNavaraCredits } from './credits.js'; @@ -62,31 +62,6 @@ function readCamera(raw) { }; } -// Nearest hit of a ray with the WGS84 ellipsoid raised by `height` metres -// (each radius grown by it), or undefined. -function rayRaisedEllipsoid({ origin, direction }, height) { - const radii = geo.ellipsoid.WGS84.radii; - const rx = radii.x + height; - const ry = radii.y + height; - const rz = radii.z + height; - const o = [origin.x / rx, origin.y / ry, origin.z / rz]; - const d = [direction.x / rx, direction.y / ry, direction.z / rz]; - const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; - const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]); - const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1; - const discriminant = b * b - 4 * a * c; - if (discriminant < 0) return undefined; - const root = Math.sqrt(discriminant); - const near = (-b - root) / (2 * a); - const t = near > 0 ? near : (-b + root) / (2 * a); - if (!(t > 0)) return undefined; - return cartesian3.add( - origin, - cartesian3.multiplyByScalar(direction, t, cartesian3.create()), - cartesian3.create(), - ); -} - /** * @param {object} view An initialised Navara ThreeView. * @param {{ @@ -156,7 +131,7 @@ export function createNavaraEngine( read: () => readCamera(view.camera.raw), fly: (pose, options) => view.flyTo(pose, options), // Navara 0.2.1 has no cancel call: any camera change interrupts a - // flight, and an empty setCamera changes nothing else (S4). + // flight, and an empty setCamera changes nothing else. stop: () => view.setCamera({}), size, requestFrame: () => view.forceUpdate(), @@ -170,8 +145,7 @@ export function createNavaraEngine( }; const engineClock = createNavaraClock(appClock ? { appClock } : {}); // camera.follow (src/globe/follow.js): Navara's own cameraFollow takes - // no user rotation or zoom (phase 3 Task 5 probe), so the engine steps - // its own follow each frame. + // no user rotation or zoom, so the engine steps its own follow each frame. const followStep = createEngineEvent(); const follow = createFollowController({ camera: model.camera, @@ -259,8 +233,6 @@ export function createNavaraEngine( // Clicks with { pick } get Navara's GPU pick of the gesture instead. pick: (screen) => picking.pick(screen), drillPick: (screen, limit) => picking.drillPick(screen, limit), - // Navara-only diagnostics: how clicks with { pick } were answered. - clickStats: () => picking.clickStats(), pickPosition(screen) { const rect = canvas.getBoundingClientRect(); const hit = view.pickDepthPosition( @@ -282,7 +254,7 @@ export function createNavaraEngine( geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84), ); if (!Number.isFinite(height)) break; - const next = rayRaisedEllipsoid(ray, height); + const next = intersectEllipsoid(ray.origin, ray.direction, height); if (!next) break; const moved = cartesian3.distance(next, hit); hit = next; diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 7de9802bb..1e0837039 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../geo/index.js'; import { assertGlobeEngine, engineCapabilities } from '../contract.js'; -import { cesiumFovy, orientationVectors } from './cameraMath.js'; +import { cesiumFovy, orientationVectors } from '../cameraMath.js'; import { createNavaraEngine } from './createNavaraEngine.js'; const { cartesian2, cartesian3, math } = geo; @@ -138,7 +138,6 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr const engine = createNavaraEngine(view, { hooks, appClock }); assert.equal(engine.clock.now(), 2_000_000); const seen = []; - engine.clock.ticked.addEventListener((time) => seen.push(['ticked', time])); for (const name of ['preUpdate', 'preRender', 'postRender']) engine.frames[name].addEventListener(() => seen.push([name, engine.clock.frameTime()]), @@ -149,7 +148,6 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr view.emit('preRender', 2); view.emit('postRender', 3); assert.deepEqual(seen, [ - ['ticked', 2_000_016], ['preUpdate', 2_000_016], ['preRender', 2_000_016], ['postRender', 2_000_016], @@ -158,7 +156,7 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr engine.destroy(); wall += 16; view.emit('preUpdate', 4); - assert.equal(seen.length, 4, 'no tick after destroy'); + assert.equal(seen.length, 3, 'no frame after destroy'); }); test('render requests are forceUpdate and continuous mode is Navara animation', () => { diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index c6ff0d621..7dbcee1aa 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -44,7 +44,7 @@ import { } from './viewOptions.js'; // Navara 0.2.1 has no preserveDrawingBuffer option but takes a canvas whose -// WebGL2 context is already created (S4); three then uses that context. +// WebGL2 context is already created; three then uses that context. function preservedCanvas(container) { const canvas = document.createElement('canvas'); Object.assign(canvas.style, { @@ -107,24 +107,13 @@ function groundInView(engine) { /** * @param {{ * container: HTMLElement, - * effects?: 'minimal' | 'photoreal' | 'cinematic', * search?: string, - * pixelRatio?: number, - * appClock?: import('../../time/clock.js').AppClock, - * }} options `effects` names the look (cinematic by default); `search`, the - * page's query string, picks it from `?look=` instead (the app's way). - * `pixelRatio` only for measurements; the product uses the device's. - * `appClock` (the page's by default) is the engine clock's time and so the - * sun's: the look reads the engine clock's frame time. + * }} options `search`, the page's query string, picks the look from + * `?look=` (cinematic by default). The sun follows the engine clock (the + * page's app clock): the look reads its frame time. * @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>} */ -export async function createNavaraGlobe({ - container, - effects, - search, - pixelRatio, - appClock, -}) { +export async function createNavaraGlobe({ container, search }) { if (!container) throw new TypeError('createNavaraGlobe needs a container'); const canvas = preservedCanvas(container); let view; @@ -136,13 +125,13 @@ export async function createNavaraGlobe({ // Parked for a switch back: its render loop is off (setRenderLoop). let parked = false; try { - view = new ThreeView(navaraViewOptions({ container, canvas, pixelRatio })); + view = new ThreeView(navaraViewOptions({ container, canvas })); const plugin = new DefaultPlugin(); view.addPlugin(plugin); await view.init(); const automaticLandscape = - !effects && new URLSearchParams(search).get('sun') !== 'live'; - const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), { + new URLSearchParams(search).get('sun') !== 'live'; + const plan = navaraScenePlan(resolveNavaraLook(search), { mobile: Boolean(view.isMobileOptimized?.()), }); // The sun follows the engine clock (one time source for layers and the @@ -158,7 +147,6 @@ export async function createNavaraGlobe({ engine = createNavaraEngine(view, { hooks: navaraHooks(view), container, - ...(appClock ? { appClock } : {}), sharpen: createNavaraSharpen(view), bloom: createNavaraBloom(view), clouds: createNavaraCloudCover(look), diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js index 4a295e1ad..58af87244 100644 --- a/src/globe/navara/input.js +++ b/src/globe/navara/input.js @@ -4,7 +4,7 @@ // canvas's top-left corner, and a listener with a modifier fires only while // that key is held (shift over ctrl over alt), one without only while none is. // -// Camera inputs map to Navara's controller flags (view.camera.options, S4). +// Camera inputs map to Navara's controller flags (view.camera.options). // Navara 0.2.1 lets a flyTo run on under user input, where Cesium cancels it; // `onCameraInput` reports user camera input so the engine can do the same. diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index e81db706c..4c116d77f 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -1,8 +1,8 @@ // Every private Navara member the engine relies on, in one place. Written // against @navaramap/three 0.2.1 (package.json pins it exactly; the test in // internals.test.mjs fails if the two drift apart): re-check each access below -// before moving to another Navara version. Each names the parity spike that -// found it and the public API that would replace it upstream. +// before moving to another Navara version. Each names the public API that +// would replace it upstream. import { ModelMesh } from '@navaramap/three'; import { patchCascadedLightCopy } from './cascadedLightCopy.js'; @@ -21,7 +21,7 @@ export function setRenderLoopEnabled(view, enabled) { let restoreRelease = null; /** - * S2: keep 3D-tile positions and indices on the CPU after upload so the + * Keeps 3D-tile positions and indices on the CPU after upload so the * surface ray can hit them. Private: ModelMesh.prototype._releaseGeometryArrays, * which frees every attribute array once the tile is on the GPU. Patched on * the prototype, for all views; idempotent. Upstream ask: an option to retain @@ -56,7 +56,7 @@ export function retainTileGeometry() { } /** - * S2: the loaded 3D-tile roots (ModelMesh objects). Private: view._scenes.mrt, + * The loaded 3D-tile roots (ModelMesh objects). Private: view._scenes.mrt, * the scene Navara renders tiles into. Upstream ask: as for * retainTileGeometry. */ @@ -67,7 +67,7 @@ export function tileRoots(view) { } /** - * S2/S5: whether Navara has stopped updating (tiles loaded, camera still). + * Whether Navara has stopped updating (tiles loaded, camera still). * Private: view._isIdle, which the public `idle` event reports only as an * edge. Upstream ask: a public isIdle/tilesLoaded getter. */ @@ -88,7 +88,7 @@ export function isIdle(view) { * throws in the dev server and warns once in a build. Upstream ask: a sky * luminance or atmosphere exposure that leaves unlit content alone. */ -export const LUMINANCE_UNIFORMS = Object.freeze([ +const LUMINANCE_UNIFORMS = Object.freeze([ 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE', 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE', ]); @@ -141,7 +141,7 @@ export function forEachLuminanceUniform(handles, visit) { } /** - * Phase 3 review (leak): the sun's cascaded shadow lights are re-cloned by + * A leak: the sun's cascaded shadow lights are re-cloned by * the transparent pass every frame (cascadedLightCopy.js). Finds the * CascadedDirectionalLights class among the view's scene lights (private: * view._scenes.light) and patches its copy to update in place. Returns the @@ -189,7 +189,7 @@ export const navaraHooks = (view) => ({ }); /** - * Stage 6 (QA diagnostics only): the names of the passes in the view's + * QA diagnostics only: the names of the passes in the view's * post-processing pipeline, in draw order, or null when the path is gone. * Private: ctx.renderPassOrchestrator.passMap.list ({ name, pass }). * Upstream ask: a public read of the pipeline order. diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index ce8b8e8ad..1e7ec9333 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -242,7 +242,7 @@ export function buildNavaraLook( } /** Navara's own stars intensity, for a look that does not set one. */ -export const NAVARA_STARS_INTENSITY = 10; +const NAVARA_STARS_INTENSITY = 10; const IRRADIANCE_ON = Object.freeze({ aerialPerspective: Object.freeze({ irradiance: true }), diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index ed96b1337..70bbf4cc7 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -9,9 +9,9 @@ // - terrain: the keyless Re:Earth quantized mesh, Cesium World Terrain through // the ion endpoint with an explicit token (Navara sends it as a Bearer // token), or none (the ellipsoid). Navara 0.2.1 panics on a quantized-mesh -// URL without the {z}/{x}/{y}.terrain template (S2), so URLs always carry it. -// - photoreal: Google 3D tiles with a browser key only in this phase (the -// server proxy of S3 comes later), with Google's logo and the tiles' own +// URL without the {z}/{x}/{y}.terrain template, so URLs always carry it. +// - photoreal: Google 3D tiles with a browser key only (no server proxy), +// with Google's logo and the tiles' own // copyright lines (creditLayerId) on screen. Showing them hides the base // imagery, as MapSourceController removes it; clearing them does not bring // it back until setBaseImagery. Hidden, not deleted: deleting the last @@ -42,7 +42,7 @@ const ION_WORLD_TERRAIN_ENDPOINT = const GOOGLE_TILES_ROOT = 'https://tile.googleapis.com/v1/3dtiles/root.json'; // The mark Cesium shows for Google tiles (GoogleMaps.getDefaultCredit). const GOOGLE_LOGO = 'https://assets.ion.cesium.com/google-credit.png'; -// Cesium3DTileset's default maximumScreenSpaceError, as the S1–S5 benches use. +// Cesium3DTileset's default maximumScreenSpaceError. const PHOTOREAL_MAX_SSE = 16; const kindOf = (source) => (source?.descriptor ?? source)?.kind; diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index ce21e5710..dde400127 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -7,7 +7,7 @@ // PickableInstancedMeshWrapper so Navara's pick pass sees each instance; // pickIds.js turns its batch ids back into instances. commit() uploads and // asks for one frame (view.forceUpdate); per-frame work runs only on frames -// the view draws anyway (S5), so an idle view stays idle. +// the view draws anyway, so an idle view stays idle. import { withFrameUpdates } from '../frameDescriptor.js'; import { diff --git a/src/globe/navara/movers/modelMover.js b/src/globe/navara/movers/modelMover.js index 9451d4c39..5e39f2647 100644 --- a/src/globe/navara/movers/modelMover.js +++ b/src/globe/navara/movers/modelMover.js @@ -1,10 +1,10 @@ // The three side of a Navara model set: the glTF loaded once, one // InstancedMesh per mesh in it (all instances share the model's meshes), // each instance placed by position, orientation and scale relative to an -// origin near the camera. Cesium's minimumPixelSize/maximumScale are kept on -// the CPU once per drawn frame (models are few: tracked and proximity -// aircraft), and the instance colour is mixed into the surface colour -// (colorBlend), or the model drawn unlit. +// origin near the camera. Cesium's minimumPixelSize is kept on the CPU once +// per drawn frame (models are few: tracked and proximity aircraft), and the +// instance colour is mixed into the surface colour (colorBlend), or the model +// drawn unlit. import { Box3, @@ -25,7 +25,7 @@ import { createModelTint } from './modelTint.js'; * besides the sun. Navara's sky light alone left it grey-brown at dawn. * The instance colour is applied after lighting, independently of this share. */ -export const MODEL_AMBIENT = 0.5; +const MODEL_AMBIENT = 0.5; /** * The distance Cesium sizes a model's minimumPixelSize at (Camera @@ -159,7 +159,6 @@ export function createModelMover( const pixels = (2 * radius * scale) / (footprint * distance); if (pixels < model.minimumPixelSize) scale *= model.minimumPixelSize / pixels; - scale = Math.min(scale, model.maximumScale); drawnPx[index] = (pixels * scale) / scales[index]; } scratchTranslation.set( diff --git a/src/globe/navara/movers/pack.js b/src/globe/navara/movers/pack.js index 168ad6fd6..7223c53b2 100644 --- a/src/globe/navara/movers/pack.js +++ b/src/globe/navara/movers/pack.js @@ -23,7 +23,7 @@ export function atlasUvRects(cells, keys) { } /** The camera distance from the origin past which the origin moves. */ -export const recentreDistance = (cameraHeight) => +const recentreDistance = (cameraHeight) => Math.max(1_000, 0.1 * Math.abs(cameraHeight)); /** diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index c9e182262..fe30a2361 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -10,7 +10,8 @@ // depth; their lit display colour is resolved here so HDR effects and MSAA // cannot spread a tint into neighbouring pixels. // FIRMS opts into a separate native-stage overlay before sharpening/AA. -// Only that stage owns an encoded-RGBA8 blend target; W1 stays in place. +// Only that stage owns an encoded-RGBA8 blend target; this overlay stays in +// place. import { withFrameUpdates, withPassDisposal } from './frameDescriptor.js'; import { EffectDesc, Pass as NavaraPass } from '@navaramap/three'; @@ -18,7 +19,7 @@ import { GevOverlayPass } from './overlayPass.js'; import { sonarUniformsFor } from './sonar.js'; import { RGBADepthPacking, Scene } from 'three'; -export const OVERLAY_EFFECT_KEY = 'gevOverlay'; +const OVERLAY_EFFECT_KEY = 'gevOverlay'; const PRE_SHARPEN_KEY = 'gevPreSharpenOverlay'; const registeredViews = new WeakMap(); diff --git a/src/globe/navara/overlayPass.test.mjs b/src/globe/navara/overlayPass.test.mjs index b5119f581..5d0fd4b4d 100644 --- a/src/globe/navara/overlayPass.test.mjs +++ b/src/globe/navara/overlayPass.test.mjs @@ -220,7 +220,7 @@ test('empty display overlay does not swap an unwritten frame buffer', () => { h.pass.dispose(); }); -test('ordinary W1 overlays keep the direct pass without a display copy or target', () => { +test('ordinary overlays keep the direct pass without a display copy or target', () => { const h = harness(), input = { width: 1280, height: 800, samples: 4, texture: {} }; h.overlay.displayBlend = false; diff --git a/src/globe/navara/picking.js b/src/globe/navara/picking.js index 8fcc99596..c8c77f6d2 100644 --- a/src/globe/navara/picking.js +++ b/src/globe/navara/picking.js @@ -52,13 +52,6 @@ export function createNavaraPicking({ // Clicks waiting for Navara's GPU answer, oldest first. const waiting = []; - // Diagnostics: how clicks were answered and how long it took (ms). - const stats = { gpu: 0, timeout: 0, stale: 0, latencies: [] }; - const answered = (entry, how) => { - stats[how]++; - stats.latencies.push(now() - entry.startedAt); - if (stats.latencies.length > 200) stats.latencies.shift(); - }; // Navara 0.2.1 answers clicks in quick succession (within a frame or so) // with the first click's pick: a GPU answer whose instance is not drawn // near the click is stale, and the CPU pick answers instead. @@ -70,7 +63,7 @@ export function createNavaraPicking({ // the CPU sees must be drawn under the click (its pick radius, no slack) // and be the one the CPU finds on top. A stale answer from the previous // gesture (the followed aircraft at the centre) lay within twice its - // radius of a neighbour's click and won (10b/11b flake). A model hit is + // radius of a neighbour's click and won. A model hit is // checked against its projected position within its drawn radius // (pickRadiusPx); the CPU's top hit (drawn order first) is not asked of // it, as a model is drawn depth-tested among the glyphs. @@ -83,10 +76,7 @@ export function createNavaraPicking({ (top !== undefined && !(top.owner === hit.set && top.instance === hit.instance) && (!isModelSet(hit.set) || top.instance === undefined)); - if (stale) { - stats.stale++; - return drillPick(at, 1)[0]; - } + if (stale) return drillPick(at, 1)[0]; } return hit ? { id: hit.pickId, owner: hit.set, instance: hit.instance } @@ -103,7 +93,6 @@ export function createNavaraPicking({ return; } cancel(next.timer); - answered(next, 'gpu'); next.resolve(answerFrom(resolveInfo(info), next.at)); }; view.on('featureClick', onFeatureClick); @@ -119,29 +108,22 @@ export function createNavaraPicking({ clickPick(at) { const gesture = early; early = null; - if (gesture && now() - gesture.at <= EARLY_ANSWER_MS) { - const entry = { startedAt: gesture.at }; - answered(entry, 'gpu'); + if (gesture && now() - gesture.at <= EARLY_ANSWER_MS) return Promise.resolve(answerFrom(gesture.hit, at)); - } return new Promise((resolve) => { const entry = { at: { x: at.x, y: at.y }, resolve, timer: null, - startedAt: now(), }; entry.timer = later(() => { const index = waiting.indexOf(entry); if (index >= 0) waiting.splice(index, 1); - answered(entry, 'timeout'); resolve(drillPick(entry.at, 1)[0]); }, timeoutMs); waiting.push(entry); }); }, - /** How clicks were answered: GPU picks, timeouts, recent latencies (ms). */ - clickStats: () => ({ ...stats, latencies: [...stats.latencies] }), destroy() { meshPickers.clear(); view.off('featureClick', onFeatureClick); diff --git a/src/globe/navara/picking.test.mjs b/src/globe/navara/picking.test.mjs index 26865754d..84ecda8aa 100644 --- a/src/globe/navara/picking.test.mjs +++ b/src/globe/navara/picking.test.mjs @@ -162,36 +162,6 @@ test('without a GPU answer in time the click falls back to the CPU pick', async assert.equal(view.handlers.size, 0); }); -test('click stats count GPU answers and timeouts with their latency', async () => { - const view = fakeView(); - const clock = timers(); - let time = 0; - const picking = createNavaraPicking({ - view, - projector, - camera, - movers: () => [], - lines: () => [], - resolveBatch: () => null, - now: () => time, - ...clock, - }); - const first = picking.clickPick({ x: 0, y: 0 }); - time = 30; - view.emit('featureClick', null); - await first; - const second = picking.clickPick({ x: 0, y: 0 }); - time = 280; - clock.pending[0].fn(); - await second; - assert.deepEqual(picking.clickStats(), { - gpu: 1, - timeout: 1, - stale: 0, - latencies: [30, 250], - }); -}); - test("Navara's answer may come before the engine's click of the same gesture", async () => { const view = fakeView(); const clock = timers(); @@ -247,7 +217,6 @@ test('a GPU answer naming an instance drawn far from the click is stale', async const answer = picking.clickPick({ x: 10, y: 10 }); view.emit('featureClick', { batchId: 2 }); assert.equal((await answer).id, 'drawn-here'); - assert.equal(picking.clickStats().stale, 1); }); test('a thin line is picked within 8 px of its segments', () => { @@ -328,7 +297,6 @@ test('a stale GPU answer for a neighbour of the click never wins over the instan view.emit('featureClick', { batchId: 1 }); // stale: A again assert.equal((await onB).id, 'b', `gesture ${gesture}: b, not the stale a`); } - assert.equal(picking.clickStats().stale, 20); }); function modelSet(id, rows, radiusPx) { @@ -406,7 +374,6 @@ test('a model hit is checked against its projected position within its drawn rad const outside = picking.clickPick({ x: 160, y: 100 }); view.emit('featureClick', { batchId: 1 }); assert.equal(await outside, undefined); - assert.equal(picking.clickStats().stale, 1); // The CPU pick sees the model at its drawn radius, not the 24 px minimum. assert.equal(picking.drillPick({ x: 121, y: 100 })[0]?.id, 'jet'); assert.equal(picking.drillPick({ x: 125, y: 100 }).length, 0); diff --git a/src/globe/navara/sharpen.js b/src/globe/navara/sharpen.js index 4c44dfd0a..cba94aad5 100644 --- a/src/globe/navara/sharpen.js +++ b/src/globe/navara/sharpen.js @@ -4,15 +4,16 @@ // center + (center − blur) × amount, clamped to [0, 1]. // // After tone mapping and native-stage fields such as FIRMS, before AA and -// the ordinary W1 display overlay. Native SHARPEN_SHADER filters encoded -// display RGB. Navara buffers stay linear, so use the exact sRGB transfer -// before the box mask and its inverse afterward. A gamma-2.2 approximation -// changes the colors of small, high-contrast glows even at the same amount. +// the ordinary display overlay (overlay.js). Native SHARPEN_SHADER filters +// encoded display RGB. Navara buffers stay linear, so use the exact sRGB +// transfer before the box mask and its inverse afterward. A gamma-2.2 +// approximation changes the colors of small, high-contrast glows even at the +// same amount. // // The sky is left alone: Navara's clouds are dithered (a fine grain at the // `low` quality the look uses), and the mask turned that grain into visible // noise. A pixel whose scene depth (the MRT pass's packed depth buffer, as -// the S1 drape reads it) is the far plane is sky or cloud; it keeps its +// the drape reads it) is the far plane is sky or cloud; it keeps its // colour, and costs one texture read instead of nine. // // Its own pass: a convolution reads the pass input, so merged into the tone @@ -25,7 +26,7 @@ import { Effect as NavaraEffect, EffectDesc } from '@navaramap/three'; import { Effect, EffectAttribute } from 'postprocessing'; import { RGBADepthPacking, Uniform } from 'three'; -export const SHARPEN_EFFECT_KEY = 'gevSharpen'; +const SHARPEN_EFFECT_KEY = 'gevSharpen'; const fragmentShader = /* glsl */ ` uniform float amount; @@ -87,9 +88,7 @@ class UnsharpMaskEffect extends Effect { } /** config.gevSharpen: { amount } (Cesium's `amount` uniform). */ -export class SharpenEffectDesc extends withPassDisposal( - withFrameUpdates(EffectDesc), -) { +class SharpenEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { static key = SHARPEN_EFFECT_KEY; static insertAfter = ['toneMapping']; static insertBefore = ['smaa', 'fxaa', 'gevOverlay', 'final']; diff --git a/src/globe/navara/stylePass.js b/src/globe/navara/stylePass.js index 4076c1434..39e4492b5 100644 --- a/src/globe/navara/stylePass.js +++ b/src/globe/navara/stylePass.js @@ -34,7 +34,7 @@ import { export const STYLES_EFFECT_KEY = 'gevStyles'; /** Intensity under which a style draws nothing (as VisualEffects disables a stage). */ -export const STYLE_VISIBLE_INTENSITY = 0.001; +const STYLE_VISIBLE_INTENSITY = 0.001; const VERTEX_SHADER = /* glsl */ `precision highp float; in vec3 position; diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js index 68253bc98..956435a93 100644 --- a/src/globe/navara/surface.js +++ b/src/globe/navara/surface.js @@ -1,4 +1,4 @@ -// GlobeSurface on Navara (S2): the height of the rendered scene is a CPU ray +// GlobeSurface on Navara: the height of the rendered scene is a CPU ray // straight down the ellipsoid normal against the 3D-tile meshes Navara shows. // Where the shown tiles do not reach (outside the frustum), the loaded // tiles Navara hides answer, the finest first. Tile positions are float32 @@ -187,15 +187,14 @@ const NO_EXCLUDE = new Set(); // the top one among them. Tiles the ray passes are tried finest first, and // coarser ones stop being tried once a hit sets the limit, so the coarse // parents' triangles are never walked. -// With `includeShown` (lod 'finest') the shown tiles take part too. const hiddenTiles = []; -function finestHiddenHit(roots, exclude, ray, includeShown = false) { +function finestHiddenHit(roots, exclude, ray) { worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z); worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z); hiddenTiles.length = 0; for (let i = 0; i < roots.length; i++) { const root = roots[i]; - if ((root.visible && !includeShown) || exclude.has(root)) continue; + if (root.visible || exclude.has(root)) continue; const extent = tileExtent(root); if (extent && worldRay.intersectsSphere(extent)) hiddenTiles.push(root); } @@ -257,33 +256,29 @@ export function createNavaraSurface(view, hooks) { return height === undefined ? undefined : { height, source: 'terrain' }; }; const sampleHeightDetailed = (place, options) => { - const { exclude, lod } = normalizeSurfaceOptions(options); + const { exclude } = normalizeSurfaceOptions(options); if (!supported) return terrainDetail(place); const ray = surfaceRay(place); const roots = hooks.tileRoots(); const excluded = exclude.length ? new Set(exclude) : NO_EXCLUDE; - let hit = { distance: Infinity, root: null }; - if (lod === 'visible') { - // The tiles on screen first; where they do not reach (the ground - // under a camera looking ahead is outside the frustum), the hidden - // ones. - shownMeshes.length = 0; - shownRoots.length = 0; - for (let i = 0; i < roots.length; i++) - collectMeshes( - roots[i], - excluded, - true, - shownMeshes, - shownRoots, - roots[i], - ); - hit = nearestHit(shownMeshes, shownRoots, ray); - shownMeshes.length = 0; - shownRoots.length = 0; - } + // The tiles on screen first; where they do not reach (the ground under + // a camera looking ahead is outside the frustum), the hidden ones. + shownMeshes.length = 0; + shownRoots.length = 0; + for (let i = 0; i < roots.length; i++) + collectMeshes( + roots[i], + excluded, + true, + shownMeshes, + shownRoots, + roots[i], + ); + let hit = nearestHit(shownMeshes, shownRoots, ray); + shownMeshes.length = 0; + shownRoots.length = 0; if (!Number.isFinite(hit.distance)) - hit = finestHiddenHit(roots, excluded, ray, lod === 'finest'); + hit = finestHiddenHit(roots, excluded, ray); const height = heightAlongRay(ray, hit.distance); if (height === undefined) return terrainDetail(place); return { height, source: 'tiles', tileSizeM: tileSizeOf(hit.root) }; @@ -342,7 +337,6 @@ export function createNavaraSurface(view, hooks) { return { surface: { raycast, - raycastSupported: supported, sampleHeight, sampleHeightDetailed, supported, diff --git a/src/globe/navara/surface.test.mjs b/src/globe/navara/surface.test.mjs index 8b40f4f0c..7d900c723 100644 --- a/src/globe/navara/surface.test.mjs +++ b/src/globe/navara/surface.test.mjs @@ -230,15 +230,12 @@ test('sampleHeightDetailed names the source and the size of the answering tile', assert.equal(shown.source, 'tiles'); near(shown.height, 43, 0.05, 'the shown tile'); near(shown.tileSizeM, 600 * Math.SQRT2, 1, 'its size'); - const finest = surface.sampleHeightDetailed(place, { lod: 'finest' }); - near(finest.height, 40, 0.05, 'the finest loaded tile'); - near(finest.tileSizeM, 200 * Math.SQRT2, 1, 'its size'); assert.deepEqual( surface.sampleHeightDetailed(place, { exclude: [coarse.tile, fine.tile] }), { height: 7, source: 'terrain' }, ); assert.throws( - () => surface.sampleHeightDetailed(place, { lod: 'best' }), + () => surface.sampleHeightDetailed(place, { exclude: 'x' }), TypeError, ); const bare = setup({ tiles: [] }).surface; diff --git a/src/globe/navara/surfaceRay.test.mjs b/src/globe/navara/surfaceRay.test.mjs index 0ed1629c2..7afaf8fb5 100644 --- a/src/globe/navara/surfaceRay.test.mjs +++ b/src/globe/navara/surfaceRay.test.mjs @@ -97,7 +97,6 @@ test('invalid and unsupported surface rays never invent terrain intersections', undefined, ); const missing = fixture(t, { supported: false }); - assert.equal(missing.surface.raycastSupported, false); assert.equal(missing.surface.raycast(missing.ray), undefined); assert.equal(missing.terrainQueries(), 0); }); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index 3b5e8d860..b0b5cfaf9 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -1,11 +1,10 @@ -// How the product builds its Navara view (S4, S5): render on demand (the -// engine's render.setContinuous turns animation on), MSAA 4 as Cesium's -// msaaSamples, the device pixel ratio unless a measurement pins one, a canvas -// whose WebGL2 context keeps its drawing buffer (Cesium's -// contextOptions.webgl.preserveDrawingBuffer, for captures), and a look: -// `cinematic` by default (navaraScenePlan below), Navara's own `photoreal` -// scene (aerial perspective, tone mapping and SMAA, about 5 ms a frame, S5) -// with `?look=photoreal`, and `minimal` (sky, stars, sky light and sun +// How the product builds its Navara view: render on demand (the engine's +// render.setContinuous turns animation on), MSAA 4 as Cesium's msaaSamples, +// the device pixel ratio, a canvas whose WebGL2 context keeps its drawing +// buffer (Cesium's contextOptions.webgl.preserveDrawingBuffer, for captures), +// and a look: `cinematic` by default (navaraScenePlan below), Navara's own +// `photoreal` scene (aerial perspective, tone mapping and SMAA, about 5 ms a +// frame) with `?look=photoreal`, and `minimal` (sky, stars, sky light and sun // without post effects) for benches. /** WebGL2 attributes Navara 0.2.1 asks for, plus preserveDrawingBuffer. */ @@ -21,14 +20,13 @@ export const NAVARA_CONTEXT_ATTRIBUTES = Object.freeze({ const MSAA_SAMPLES = 4; -/** ThreeView options; `pixelRatio` only when a measurement pins it. */ -export function navaraViewOptions({ container, canvas, pixelRatio } = {}) { +/** ThreeView options. */ +export function navaraViewOptions({ container, canvas } = {}) { return { container, ...(canvas ? { canvas } : {}), animation: false, multisampling: MSAA_SAMPLES, - ...(pixelRatio !== undefined ? { pixelRatio } : {}), }; } diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs index eb71ca495..9a6d9d87c 100644 --- a/src/globe/navara/viewOptions.test.mjs +++ b/src/globe/navara/viewOptions.test.mjs @@ -32,11 +32,6 @@ test('the product view renders on demand with MSAA 4 at the native pixel ratio', animation: false, multisampling: 4, }); - assert.equal( - navaraViewOptions({ container, pixelRatio: 1 }).pixelRatio, - 1, - 'measurements pin the pixel ratio', - ); }); test('the canvas context keeps its drawing buffer, with the attributes Navara asks for', () => { @@ -51,7 +46,7 @@ test('the canvas context keeps its drawing buffer, with the attributes Navara as }); }); -test('cinematic is the default look; photoreal is the default plugin scene; minimal has no post effects (S5)', () => { +test('cinematic is the default look; photoreal is the default plugin scene; minimal has no post effects', () => { assert.equal(DEFAULT_NAVARA_LOOK, 'cinematic'); assert.deepEqual(navaraScenePlan(), navaraScenePlan('cinematic')); assert.deepEqual(navaraScenePlan('photoreal'), { diff --git a/src/globe/surfaceDetail.js b/src/globe/surfaceDetail.js index 5901e02d9..4546b93ff 100644 --- a/src/globe/surfaceDetail.js +++ b/src/globe/surfaceDetail.js @@ -5,23 +5,15 @@ // signal, since a coarse tile's surface can sit tens of metres off the // ground (a 20 m error measured at Austin while the tiles were streaming). -const LODS = new Set(['visible', 'finest']); - /** - * @param {{ exclude?: any[], lod?: 'visible' | 'finest' }} [options] - * `exclude`: mover sets (or their ids), lines, or engine objects the ray - * passes through. `lod`: 'visible' answers from the tiles drawn now (where - * they miss, the finest loaded ones); 'finest' from the finest loaded - * tiles, drawn or not. + * @param {{ exclude?: any[] }} [options] `exclude`: mover sets (or their + * ids), lines, or engine objects the ray passes through. */ export function normalizeSurfaceOptions(options = {}) { const exclude = options.exclude ?? []; if (!Array.isArray(exclude)) throw new TypeError('surface exclude must be an array'); - const lod = options.lod ?? 'visible'; - if (!LODS.has(lod)) - throw new TypeError(`surface lod must be 'visible' or 'finest': ${lod}`); - return { exclude, lod }; + return { exclude }; } /** diff --git a/src/globe/surfaceDetail.test.mjs b/src/globe/surfaceDetail.test.mjs index 02924af79..79c17e283 100644 --- a/src/globe/surfaceDetail.test.mjs +++ b/src/globe/surfaceDetail.test.mjs @@ -5,14 +5,12 @@ import { normalizeSurfaceOptions, } from './surfaceDetail.js'; -test('surface options: exclude list and lod visible (default) or finest', () => { - assert.deepEqual(normalizeSurfaceOptions(), { exclude: [], lod: 'visible' }); +test('surface options: an exclude list', () => { + assert.deepEqual(normalizeSurfaceOptions(), { exclude: [] }); const set = { id: 'flights' }; - assert.deepEqual(normalizeSurfaceOptions({ exclude: [set], lod: 'finest' }), { + assert.deepEqual(normalizeSurfaceOptions({ exclude: [set] }), { exclude: [set], - lod: 'finest', }); - assert.throws(() => normalizeSurfaceOptions({ lod: 'best' }), TypeError); assert.throws(() => normalizeSurfaceOptions({ exclude: 'x' }), TypeError); }); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 422979cee..4a7e7edc7 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -132,6 +132,9 @@ export function createContractSuite( return cartesian2.create(width / 2, height / 2); }; + const capabilities = engineCapabilities(engine); + // A check may name the optional capabilities it needs (movers, lines) + // after its function; without them it is left out. const checks = [ [ 'shape: the engine has every contract capability', @@ -382,19 +385,15 @@ export function createContractSuite( 'clock: one frame time per frame, latched before preUpdate, never decreasing', async () => { const records = []; - const read = (name, value) => + const read = (name) => records.push({ name, - value, frame: engine.clock.frameTime(), now: engine.clock.now(), }); - const removers = [ - engine.clock.ticked.addEventListener((time) => read('ticked', time)), - ...['preUpdate', 'preRender', 'postRender'].map((name) => - engine.frames[name].addEventListener(() => read(name)), - ), - ]; + const removers = ['preUpdate', 'preRender', 'postRender'].map((name) => + engine.frames[name].addEventListener(() => read(name)), + ); try { await until( () => @@ -409,43 +408,28 @@ export function createContractSuite( Number.isFinite(engine.clock.frameTime()), 'clock.now or clock.frameTime is not a number', ); - // From the first tick on: every frame event belongs to the frame the - // last tick opened, and reads that tick's frame time. - const start = records.findIndex(({ name }) => name === 'ticked'); - ensure(start >= 0, 'clock.ticked never fired'); - let tick = null; - let ticksSincePreUpdate = 0; - let previousFrame = -Infinity; + // From the first preUpdate on: every frame event reads the frame time + // its frame's preUpdate read, and that time never goes back. + const start = records.findIndex(({ name }) => name === 'preUpdate'); + ensure(start >= 0, 'frames.preUpdate never fired'); + let frame = null; for (const record of records.slice(start)) { ensure( record.frame <= record.now, `${record.name}: frameTime ${record.frame} is after now ${record.now}`, ); - if (record.name === 'ticked') { + if (record.name === 'preUpdate') { ensure( - record.value === record.frame, - `ticked passed ${record.value}, frameTime read ${record.frame}`, + frame === null || record.frame >= frame.frame, + `frame time went back from ${frame?.frame} to ${record.frame}`, ); - ensure( - record.frame >= previousFrame, - `frame time went back from ${previousFrame} to ${record.frame}`, - ); - previousFrame = record.frame; - tick = record; - ticksSincePreUpdate++; + frame = record; continue; } ensure( - record.frame === tick.frame, - `${record.name} read frame time ${record.frame}, its frame's is ${tick.frame}`, + record.frame === frame.frame, + `${record.name} read frame time ${record.frame}, its frame's is ${frame.frame}`, ); - if (record.name === 'preUpdate') { - ensure( - ticksSincePreUpdate >= 1, - 'preUpdate ran without a tick before it', - ); - ticksSincePreUpdate = 0; - } } }, ], @@ -496,11 +480,6 @@ export function createContractSuite( set.setCount(3); set.commit(); ensure(set.count() === 3, `count ${set.count()}`); - const view = set.positionsView(); - ensure( - view.length === 9 && view[3] === places[1].x, - 'positionsView does not hold the positions', - ); ensure( engine.movers .list() @@ -595,6 +574,7 @@ export function createContractSuite( 'a destroyed set is still listed', ); }, + ['movers'], ], [ 'colours: mid-tone sprite, point and line colours draw as asked (within 3 per channel)', @@ -693,6 +673,7 @@ export function createContractSuite( for (const line of lines) line.destroy(); } }, + ['movers', 'lines'], ], [ 'lines: a line draws between its projections, turns with its model matrix and dims where hidden', @@ -803,6 +784,7 @@ export function createContractSuite( 'a destroyed line is still listed', ); }, + ['lines'], ], [ 'input: picks on click and move payloads, drill, lines', @@ -942,6 +924,7 @@ export function createContractSuite( line.destroy(); } }, + ['movers', 'lines'], ], [ 'lines: pixel dashes are regular, in CSS pixels', @@ -1011,6 +994,7 @@ export function createContractSuite( line.destroy(); } }, + ['lines'], ], [ 'movers: depth tests, rotation, cells, scale, count, points and models', @@ -1239,6 +1223,7 @@ export function createContractSuite( for (const set of sets) set.destroy(); } }, + ['movers'], ], [ 'render: continuous mode toggles', @@ -1599,7 +1584,7 @@ export function createContractSuite( }, ], [ - 'surface.sampleHeightDetailed: source, tile size, lod and mover-set exclusion', + 'surface.sampleHeightDetailed: source, tile size and mover-set exclusion', async () => { const place = cartographic.fromDegrees(20, 10, 0); const shape = (detail, label) => { @@ -1617,10 +1602,6 @@ export function createContractSuite( }; const visible = engine.surface.sampleHeightDetailed(place); shape(visible, 'visible'); - shape( - engine.surface.sampleHeightDetailed(place, { lod: 'finest' }), - 'finest', - ); if (visible?.source === 'tiles') near( engine.surface.sampleHeight(place), @@ -1628,13 +1609,7 @@ export function createContractSuite( 1e-6, 'sampleHeight and the detailed tiles answer', ); - let rejected = null; - try { - engine.surface.sampleHeightDetailed(place, { lod: 'best' }); - } catch (error) { - rejected = error; - } - ensure(rejected instanceof TypeError, 'an unknown lod was accepted'); + if (!capabilities.movers) return; // A mover set in exclude: the sample works and the set stays. const set = engine.movers.createSet({ id: 'contract-surface-exclude', @@ -2765,10 +2740,9 @@ export function createContractSuite( } }, ], - ]; + ].filter(([, , needs = []]) => needs.every((flag) => capabilities[flag])); if (mapSources?.photoreal && mapSources.imagery?.length >= 2) checks.push(...mapChecks(engine.maps, mapSources)); - const capabilities = engineCapabilities(engine); if (capabilities.visualStyles) checks.push(...styleChecks(engine.render, settle)); if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle)); diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 12da11d67..906bcb14a 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -35,6 +35,20 @@ import { mergeStyle, } from '../contract.js'; import { createAppClock } from '../../time/clock.js'; +import { + cesiumFovy, + headingPitchRoll, + horizonVisible, + lookAtFrame, + orientationVectors, + pickEllipsoid, + pickRay, + projectToWindow, + rectangleViewPosition, + sphereRange, + viewProjection, + viewRectangle, +} from '../cameraMath.js'; import { createLineRegistry } from '../lines.js'; import { createMoverRegistry } from '../movers.js'; import { createCpuPicker } from '../cpuPicks.js'; @@ -45,17 +59,10 @@ const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const WGS84 = ellipsoid.WGS84; // Where the fake wall clock starts: 2026-01-01T00:00:00Z. const FAKE_EPOCH_MS = Date.UTC(2026, 0, 1); -const RADII = WGS84.radii; -const MAXIMUM_RADIUS = Math.max(RADII.x, RADII.y, RADII.z); // The terrain kinds the engines build themselves (src/maps/registry.js). const TERRAIN_KINDS = Object.freeze(['ellipsoid', 'keyless', 'world']); const DEFAULT_FLIGHT_SECONDS = 3; const DEFAULT_ZOOM_AMOUNT = 100000; // Cesium's camera.defaultZoomAmount -const ZERO_RADIUS_SPHERE_RANGE = 100; // Cesium's MINIMUM_ZOOM -const MINIMUM_ZOOM_DISTANCE = 1; // Cesium's screenSpaceCameraController default -const MAXIMUM_ZOOM_DISTANCE = Infinity; -const NEAR = 1; -const FAR = 1e9; const SCRIPTABLE = new Set([ 'pick', 'drillPick', @@ -89,152 +96,6 @@ const unit = (vector) => cartesian3.normalize(vector, cartesian3.create()); const cross = (left, right) => cartesian3.cross(left, right, cartesian3.create()); -// An angle in [0, 2π), as Cesium's camera reports it. -function wrapAngle(angle) { - const value = math.zeroToTwoPi(angle); - return value < 1e-12 || math.TWO_PI - value < 1e-12 ? 0 : value; -} - -// The local east/north/up axes at a position, in Earth-fixed coordinates. -function localAxes(position) { - const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84); - return { - east: cartesian3.create(frame[0], frame[1], frame[2]), - north: cartesian3.create(frame[4], frame[5], frame[6]), - up: cartesian3.create(frame[8], frame[9], frame[10]), - }; -} - -const combine = (axes, e, n, u) => - sum(scaled(axes.east, e), scaled(axes.north, n), scaled(axes.up, u)); -const toLocal = (axes, vector) => ({ - e: cartesian3.dot(vector, axes.east), - n: cartesian3.dot(vector, axes.north), - u: cartesian3.dot(vector, axes.up), -}); - -// Direction and up for a heading/pitch/roll in the local frame at position, -// as Cesium's setView builds them: heading 0 looks north, positive pitch looks -// up, positive roll tilts `up` toward the camera's right. -function orientationVectors(position, heading, pitch, roll) { - const axes = localAxes(position); - const cosPitch = Math.cos(pitch); - const direction = combine( - axes, - Math.sin(heading) * cosPitch, - Math.cos(heading) * cosPitch, - Math.sin(pitch), - ); - const levelRight = combine(axes, Math.cos(heading), -Math.sin(heading), 0); - const levelUp = cross(levelRight, direction); - const up = sum( - scaled(levelUp, Math.cos(roll)), - scaled(levelRight, Math.sin(roll)), - ); - return { direction: unit(direction), up: unit(up) }; -} - -// Cesium's Camera heading/pitch/roll getters, in the local frame at position. -function headingPitchRoll(position, direction, up) { - const axes = localAxes(position); - const d = toLocal(axes, direction); - const u = toLocal(axes, up); - const r = toLocal(axes, cross(direction, up)); - const vertical = Math.abs(Math.abs(d.u) - 1) <= 1e-3; - const raw = vertical - ? Math.atan2(u.n, u.e) - math.PI_OVER_TWO - : Math.atan2(d.n, d.e) - math.PI_OVER_TWO; - return { - heading: wrapAngle(math.TWO_PI - math.zeroToTwoPi(raw)), - pitch: math.PI_OVER_TWO - Math.acos(math.clamp(d.u, -1, 1)), - roll: vertical ? 0 : wrapAngle(Math.atan2(-r.u, u.u)), - }; -} - -// Camera offset from a target for a HeadingPitchRange, in the target's frame. -function offsetFromHeadingPitchRange(heading, pitch, range) { - const p = math.clamp(pitch, -math.PI_OVER_TWO, math.PI_OVER_TWO); - const cosPitch = Math.cos(p); - return cartesian3.create( - -Math.sin(heading) * cosPitch * range, - -Math.cos(heading) * cosPitch * range, - -Math.sin(p) * range, - ); -} - -// Nearest intersection of a ray with the WGS84 ellipsoid, if any. -function intersectEllipsoid(origin, direction) { - const o = [origin.x / RADII.x, origin.y / RADII.y, origin.z / RADII.z]; - const d = [ - direction.x / RADII.x, - direction.y / RADII.y, - direction.z / RADII.z, - ]; - const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; - const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]); - const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1; - const discriminant = b * b - 4 * a * c; - if (discriminant < 0) return undefined; - const root = Math.sqrt(discriminant); - const near = (-b - root) / (2 * a); - const t = near > 0 ? near : (-b + root) / (2 * a); - if (!(t > 0)) return undefined; - return sum(origin, scaled(direction, t)); -} - -// Cesium's computeHorizonQuad: four limb points around the camera's nadir. -function horizonQuad(position) { - const q = cartesian3.create( - position.x / RADII.x, - position.y / RADII.y, - position.z / RADII.z, - ); - const qMagnitude = cartesian3.magnitude(q); - const qUnit = unit(q); - let east; - let north; - if (cartesian3.equalsEpsilon(qUnit, cartesian3.UNIT_Z, math.EPSILON10)) { - east = cartesian3.create(0, 1, 0); - north = cartesian3.create(0, 0, 1); - } else { - east = unit(cross(cartesian3.UNIT_Z, qUnit)); - north = unit(cross(qUnit, east)); - } - const center = scaled(qUnit, 1 / qMagnitude); - const offset = - Math.sqrt(Math.max(0, cartesian3.magnitudeSquared(q) - 1)) / qMagnitude; - const point = (n, e) => { - const p = sum(center, scaled(north, n * offset), scaled(east, e * offset)); - return cartesian3.create(p.x * RADII.x, p.y * RADII.y, p.z * RADII.z); - }; - // Same order as the corner picks: upper-left, lower-left, lower-right, upper-right. - return [point(1, -1), point(-1, -1), point(-1, 1), point(1, 1)]; -} - -// Cesium's Rectangle.fromCartographicArray, including the antimeridian case. -function rectangleFromCartographics(points) { - let west = Infinity; - let east = -Infinity; - let westOverIDL = Infinity; - let eastOverIDL = -Infinity; - let south = Infinity; - let north = -Infinity; - for (const { longitude, latitude } of points) { - west = Math.min(west, longitude); - east = Math.max(east, longitude); - south = Math.min(south, latitude); - north = Math.max(north, latitude); - const adjusted = longitude >= 0 ? longitude : longitude + math.TWO_PI; - westOverIDL = Math.min(westOverIDL, adjusted); - eastOverIDL = Math.max(eastOverIDL, adjusted); - } - if (east - west > eastOverIDL - westOverIDL) { - west = westOverIDL > Math.PI ? westOverIDL - math.TWO_PI : westOverIDL; - east = eastOverIDL > Math.PI ? eastOverIDL - math.TWO_PI : eastOverIDL; - } - return { west, south, east, north }; -} - // render.setContactSonar/contactSonar when asked; nothing otherwise. function fakeContactSonar(enabled, record) { if (!enabled) return {}; @@ -328,11 +189,7 @@ export function createFakeEngine(options = {}) { let width = options.width ?? 1024; let height = options.height ?? 768; // Cesium's default: 60 degrees across the wider canvas dimension. - const fovy = - options.fovy ?? - (width > height - ? 2 * Math.atan(Math.tan(Math.PI / 6) * (height / width)) - : Math.PI / 3); + const fovy = options.fovy ?? cesiumFovy(width, height); const state = { position: cartesian3.create(), direction: cartesian3.create(), @@ -346,7 +203,6 @@ export function createFakeEngine(options = {}) { 'frames.preUpdate': createEngineEvent(), 'frames.preRender': createEngineEvent(), 'frames.postRender': createEngineEvent(), - 'clock.ticked': createEngineEvent(), // The follow steps here, before preRender (as on Navara). followStep: createEngineEvent(), }; @@ -435,32 +291,22 @@ export function createFakeEngine(options = {}) { const right = () => unit(cross(state.direction, state.up)); const poseAngles = () => headingPitchRoll(state.position, state.direction, state.up); - - // Where a {west, south, east, north} destination is seen from. - function rectanglePosition({ west, south, east, north }) { - const lonSpan = east >= west ? east - west : east + math.TWO_PI - west; - const longitude = math.negativePiToPi(west + lonSpan / 2); - const latitude = (south + north) / 2; - const tanY = Math.tan(fovy / 2); - const radius = WGS84.maximumRadius; - // The widest parallel of the rectangle, and a small margin for curvature. - const widest = - south * north <= 0 - ? 1 - : Math.cos(Math.min(Math.abs(south), Math.abs(north))); - const across = - 1.05 * - Math.max( - (radius * widest * lonSpan) / 2 / (tanY * aspect()), - (radius * (north - south)) / 2 / tanY, - ); - return cartesian3.fromRadians(longitude, latitude, across, WGS84); - } + // The camera as cameraMath takes it. + const cameraView = () => ({ + position: state.position, + direction: state.direction, + up: state.up, + fovy, + width, + height, + }); // The pose for a destination and orientation; missing angles use `fallback`. function viewFor(destination, orientation, fallback) { const position = - 'west' in destination ? rectanglePosition(destination) : destination; + 'west' in destination + ? rectangleViewPosition(destination, fovy, aspect()) + : destination; if (orientation && 'direction' in orientation) return { position: copy3(position), @@ -489,30 +335,6 @@ export function createFakeEngine(options = {}) { lookAtTarget = target ? copy3(target) : null; } - function lookAtFrame(frame, offset) { - const local = - offset && 'heading' in offset - ? offsetFromHeadingPitchRange( - offset.heading, - offset.pitch, - offset.range, - ) - : copy3(offset); - const localDirection = unit(cartesian3.negate(local, cartesian3.create())); - let localRight = cross(localDirection, cartesian3.UNIT_Z); - if (cartesian3.magnitudeSquared(localRight) < math.EPSILON10) - localRight = copy3(cartesian3.UNIT_X); - localRight = unit(localRight); - const localUp = unit(cross(localRight, localDirection)); - const vector = (value) => - unit(matrix4.multiplyByPointAsVector(frame, value, cartesian3.create())); - apply({ - position: matrix4.multiplyByPoint(frame, local, cartesian3.create()), - direction: vector(localDirection), - up: vector(localUp), - }); - } - function cancelCurrentFlight() { const current = flight; flight = null; @@ -608,151 +430,6 @@ export function createFakeEngine(options = {}) { } } - function project(position, result) { - const offset = cartesian3.subtract( - position, - state.position, - cartesian3.create(), - ); - const depth = cartesian3.dot(offset, state.direction); - if (!(depth > 0)) return undefined; - const tangent = Math.tan(fovy / 2); - const ndcX = cartesian3.dot(offset, right()) / (depth * tangent * aspect()); - const ndcY = cartesian3.dot(offset, state.up) / (depth * tangent); - const x = ((ndcX + 1) / 2) * width; - const y = ((1 - ndcY) / 2) * height; - if (!result) return cartesian2.create(x, y); - result.x = x; - result.y = y; - return result; - } - - function viewProjection(result = matrix4.create()) { - const f = 1 / Math.tan(fovy / 2); - const rows = [ - [right(), f / aspect()], - [state.up, f], - ]; - const D = state.direction; - const P = state.position; - for (const [row, [axis, scale]] of rows.entries()) { - result[row] = axis.x * scale; - result[4 + row] = axis.y * scale; - result[8 + row] = axis.z * scale; - result[12 + row] = -cartesian3.dot(axis, P) * scale; - } - // Eye z is −direction·(p − position); clip.w = −z_eye. - const A = (FAR + NEAR) / (NEAR - FAR); - const B = (2 * FAR * NEAR) / (NEAR - FAR); - result[2] = -D.x * A; - result[6] = -D.y * A; - result[10] = -D.z * A; - result[14] = cartesian3.dot(D, P) * A + B; - result[3] = D.x; - result[7] = D.y; - result[11] = D.z; - result[15] = -cartesian3.dot(D, P); - return result; - } - - // Cesium's EllipsoidalOccluder.isPointVisible. - function horizonVisible(position) { - const camera = cartesian3.create( - state.position.x / RADII.x, - state.position.y / RADII.y, - state.position.z / RADII.z, - ); - const limbSquared = cartesian3.magnitudeSquared(camera) - 1; - const target = cartesian3.create( - position.x / RADII.x, - position.y / RADII.y, - position.z / RADII.z, - ); - const vt = cartesian3.subtract(target, camera, cartesian3.create()); - const vtDotVc = -cartesian3.dot(vt, camera); - const occluded = - limbSquared < 0 - ? vtDotVc > 0 - : vtDotVc > limbSquared && - (vtDotVc * vtDotVc) / cartesian3.magnitudeSquared(vt) > limbSquared; - return !occluded; - } - - function ray(screen) { - const tangent = Math.tan(fovy / 2); - const ndcX = (2 * screen.x) / width - 1; - const ndcY = 1 - (2 * screen.y) / height; - const direction = sum( - state.direction, - scaled(right(), ndcX * tangent * aspect()), - scaled(state.up, ndcY * tangent), - ); - return { origin: copy3(state.position), direction: unit(direction) }; - } - - // Whether the sphere around the Earth is fully outside the view frustum. - function globeOutsideFrustum() { - const toCentre = cartesian3.negate(state.position, cartesian3.create()); - const D = state.direction; - const tanY = Math.tan(fovy / 2); - const tanX = tanY * aspect(); - const R = right(); - const U = state.up; - const planes = [ - [D, NEAR], - [unit(sum(scaled(R, -1), scaled(D, tanX))), 0], - [unit(sum(R, scaled(D, tanX))), 0], - [unit(sum(scaled(U, -1), scaled(D, tanY))), 0], - [unit(sum(U, scaled(D, tanY))), 0], - ]; - return planes.some( - ([normal, offset]) => - cartesian3.dot(normal, toCentre) - offset < -MAXIMUM_RADIUS, - ); - } - - // Cesium's Camera.computeViewRectangle. - function computedViewRectangle() { - if (globeOutsideFrustum()) return undefined; - const horizon = horizonQuad(state.position); - let hits = 0; - const points = [ - [0, 0], - [0, height], - [width, height], - [width, 0], - ].map(([x, y], index) => { - const hit = intersectEllipsoid( - state.position, - ray(cartesian2.create(x, y)).direction, - ); - if (hit) hits++; - return cartographic.fromCartesian(hit ?? horizon[index], WGS84); - }); - if (hits < 2) - return { - west: -Math.PI, - south: -math.PI_OVER_TWO, - east: Math.PI, - north: math.PI_OVER_TWO, - }; - const rectangle = rectangleFromCartographics(points); - let travelled = 0; - let last = points[3].longitude; - for (const { longitude } of points) { - const step = Math.abs(longitude - last); - travelled += step > Math.PI ? math.TWO_PI - step : step; - last = longitude; - } - if (math.equalsEpsilon(Math.abs(travelled), math.TWO_PI, 1e-9)) { - rectangle.west = -Math.PI; - rectangle.east = Math.PI; - if (points[0].latitude >= 0) rectangle.north = math.PI_OVER_TWO; - else rectangle.south = -math.PI_OVER_TWO; - } - return rectangle; - } - function scripted(name, args, fallback) { if (!scripts.has(name)) return fallback(); const value = scripts.get(name); @@ -801,52 +478,32 @@ export function createFakeEngine(options = {}) { flyToSphere(sphere, sphereOptions = {}) { command('camera.flyToSphere', [sphere, sphereOptions]); setTransform(matrix4.IDENTITY); - let range = sphereOptions.range; - if (!range) { - const tanPhi = Math.tan(fovy / 2); - const tanTheta = aspect() * tanPhi; - range = - sphere.radius === 0 - ? ZERO_RADIUS_SPHERE_RANGE - : math.clamp( - Math.max(sphere.radius / tanTheta, sphere.radius / tanPhi), - MINIMUM_ZOOM_DISTANCE, - MAXIMUM_ZOOM_DISTANCE, - ); - } + const range = + sphereOptions.range || sphereRange(sphere.radius, fovy, aspect()); const frame = geo.transforms.eastNorthUpToFixedFrame( sphere.center, WGS84, ); - const saved = { - position: state.position, - direction: state.direction, - up: state.up, - }; - lookAtFrame(frame, { - heading: sphereOptions.heading ?? 0, - pitch: sphereOptions.pitch ?? -Math.PI / 4, - range, - }); - const target = { - position: state.position, - direction: state.direction, - up: state.up, - }; - apply(saved); - startFlight(sphereOptions, target); + startFlight( + sphereOptions, + lookAtFrame(frame, { + heading: sphereOptions.heading ?? 0, + pitch: sphereOptions.pitch ?? -Math.PI / 4, + range, + }), + ); }, flightActive: () => flight != null, lookAt(target, offset) { command('camera.lookAt', [target, offset]); const frame = geo.transforms.eastNorthUpToFixedFrame(target, WGS84); setTransform(frame, target); - lookAtFrame(frame, offset); + apply(lookAtFrame(frame, offset)); }, lookAtTransform(frame, offset) { record('camera.lookAtTransform', [frame, offset]); setTransform(frame, cartesian3.create(frame[12], frame[13], frame[14])); - if (offset) lookAtFrame(frame, offset); + if (offset) apply(lookAtFrame(frame, offset)); }, transform: () => [...transform], release() { @@ -867,15 +524,15 @@ export function createFakeEngine(options = {}) { }, viewRectangle() { record('camera.viewRectangle', []); - return scripted('viewRectangle', [], computedViewRectangle); + return scripted('viewRectangle', [], () => viewRectangle(cameraView())); }, pickEllipsoid(screen) { return scripted('pickEllipsoid', [screen], () => - intersectEllipsoid(state.position, ray(screen).direction), + pickEllipsoid(cameraView(), screen), ); }, pickRay(screen) { - return ray(screen); + return pickRay(cameraView(), screen); }, changed: events['camera.changed'], moveStart: events['camera.moveStart'], @@ -971,21 +628,20 @@ export function createFakeEngine(options = {}) { projector: { worldToWindow(position, result) { const value = scripted('worldToWindow', [position, result], () => - project(position, result), + projectToWindow(cameraView(), position, result), ); if (value && result && value !== result) return cartesian2.clone(value, result); return value; }, - viewProjection, - horizonVisible, + viewProjection: (result) => viewProjection(cameraView(), result), + horizonVisible: (position) => horizonVisible(state.position, position), }, movers: moverRegistry.movers, lines: lineRegistry.lines, clock: { now: () => appClock.now(), frameTime: () => appClock.frameTime(), - ticked: events['clock.ticked'], }, frames: { preUpdate: events['frames.preUpdate'], @@ -1020,24 +676,20 @@ export function createFakeEngine(options = {}) { // Scripted as 'sampleHeightDetailed', else from the scripted // sampleHeight (tiles, no size) and terrainHeight. sampleHeightDetailed(position, surfaceOptions) { - const { exclude, lod } = normalizeSurfaceOptions(surfaceOptions); - record('surface.sampleHeightDetailed', [position, { exclude, lod }]); - return scripted( - 'sampleHeightDetailed', - [position, { exclude, lod }], - () => { - const height = scripted( - 'sampleHeight', - [position, exclude], - () => undefined, - ); - if (Number.isFinite(height)) return { height, source: 'tiles' }; - const terrain = scripted('terrainHeight', [position], () => 0); - return Number.isFinite(terrain) - ? { height: terrain, source: 'terrain' } - : undefined; - }, - ); + const { exclude } = normalizeSurfaceOptions(surfaceOptions); + record('surface.sampleHeightDetailed', [position, { exclude }]); + return scripted('sampleHeightDetailed', [position, { exclude }], () => { + const height = scripted( + 'sampleHeight', + [position, exclude], + () => undefined, + ); + if (Number.isFinite(height)) return { height, source: 'tiles' }; + const terrain = scripted('terrainHeight', [position], () => 0); + return Number.isFinite(terrain) + ? { height: terrain, source: 'terrain' } + : undefined; + }); }, supported: true, terrainHeight(position) { @@ -1217,11 +869,11 @@ export function createFakeEngine(options = {}) { advanceTime(ms) { if (ownsWall) wallMs += ms; }, - /** Runs one frame: clock.ticked, preUpdate, flight progress, preRender, camera events, postRender. */ + /** Runs one frame: the clock's frame time, preUpdate, flight progress, preRender, camera events, postRender. */ frame(dt = 16) { clock += dt; if (ownsWall) wallMs += dt; - events['clock.ticked'].raiseValue(appClock.beginFrame()); + appClock.beginFrame(); events['frames.preUpdate'].raiseEvent(clock); advanceFlight(dt); events.followStep.raiseEvent(); diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index f84a0f2d1..1c84da87b 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -185,45 +185,6 @@ test('fake credits, maps and destroy keep inspectable state', () => { assert.equal(engine.fake.destroyed, true); }); -test('the fake camera follows Cesium conventions for roll, angle ranges and zoom', () => { - const engine = createFakeEngine(); - const destination = geo.cartesian3.fromDegrees(0, 0, 1000); - engine.camera.setView({ - destination, - orientation: { heading: 0, pitch: 0, roll: 0.3 }, - }); - const pose = engine.camera.pose(); - // Facing north at (0, 0), east is +Y: positive roll tilts up toward east. - close(pose.up.y, Math.sin(0.3), 1e-9, 'up east component'); - close(pose.roll, 0.3, 1e-9, 'roll'); - engine.camera.setView({ - destination, - orientation: { heading: -0.5, pitch: 0, roll: -0.2 }, - }); - const wrapped = engine.camera.pose(); - close(wrapped.heading, 2 * Math.PI - 0.5, 1e-9, 'heading in [0, 2π)'); - close(wrapped.roll, 2 * Math.PI - 0.2, 1e-9, 'roll in [0, 2π)'); - engine.camera.setView({ - destination, - orientation: { heading: 0.4, pitch: -Math.PI / 2, roll: 0.3 }, - }); - assert.equal(engine.camera.pose().roll, 0); - engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(0, 0, 5000), - }); - const before = engine.camera.pose(); - engine.camera.zoomIn(1000); - const after = engine.camera.pose(); - close( - geo.cartesian3.distance(before.position, after.position), - 1000, - 1e-6, - 'zoomIn', - ); - engine.camera.zoomOut(400); - close(engine.camera.pose().cartographic.height, 5000 - 600, 1e-3, 'zoomOut'); -}); - test('fake lookAtTransform keeps the transform until release', () => { const engine = createFakeEngine(); const target = geo.cartesian3.fromDegrees(10, 10, 0); @@ -279,64 +240,6 @@ test('fake terrain height and clamp results default to the bare ellipsoid and ca assert.equal(engine.surface.clampToHeightSupported, true); }); -test('fake flights honour endTransform when they land', () => { - const engine = createFakeEngine(); - const target = geo.cartesian3.fromDegrees(10, 20, 0); - engine.camera.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 }); - engine.camera.flyTo({ - destination: geo.cartesian3.fromDegrees(10, 20, 5000), - duration: 0.1, - endTransform: geo.matrix4.IDENTITY, - }); - assert.notDeepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]); - engine.fake.frames(10, 16); - assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]); -}); - -test('a fake rectangle destination looks down on the rectangle centre', () => { - const engine = createFakeEngine(); - const rectangle = { - west: geo.math.toRadians(2.2), - south: geo.math.toRadians(48.8), - east: geo.math.toRadians(2.5), - north: geo.math.toRadians(48.9), - }; - engine.camera.flyTo({ destination: rectangle, duration: 0 }); - const pose = engine.camera.pose(); - close(pose.pitch, -Math.PI / 2, 1e-9, 'pitch'); - close(pose.cartographic.longitude, geo.math.toRadians(2.35), 1e-9, 'lon'); - const { width, height } = engine.host.size(); - for (const [lon, lat] of [ - [rectangle.west, rectangle.south], - [rectangle.east, rectangle.north], - ]) { - const corner = engine.projector.worldToWindow( - geo.cartesian3.fromRadians(lon, lat, 0), - ); - assert.ok(corner.x >= -1 && corner.x <= width + 1, `x ${corner.x}`); - assert.ok(corner.y >= -1 && corner.y <= height + 1, `y ${corner.y}`); - } -}); - -test('fake sphere range follows Cesium: 100 m only for radius 0, no floor otherwise', () => { - const engine = createFakeEngine({ width: 1000, height: 500 }); - const center = geo.cartesian3.fromDegrees(0, 0, 0); - engine.camera.flyToSphere({ center, radius: 10 }, { duration: 0 }); - const tanPhi = Math.tan(engine.camera.pose().fovy / 2); - close( - geo.cartesian3.distance(engine.camera.pose().position, center), - Math.max(10 / (2 * tanPhi), 10 / tanPhi), - 1e-6, - 'range (m)', - ); - engine.camera.flyToSphere({ center, radius: 0 }, { duration: 0 }); - close( - geo.cartesian3.distance(engine.camera.pose().position, center), - 100, - 1e-6, - ); -}); - test('fake drillPick records width and height; maps return promises', async () => { const engine = createFakeEngine(); engine.picker.drillPick(geo.cartesian2.create(1, 2), 3, 4, 5); @@ -354,20 +257,6 @@ test('fake drillPick records width and height; maps return promises', async () = } }); -test('fake livePosition is one object that follows the camera; height is the pose height', () => { - const engine = createFakeEngine({ altitude: 2_000_000 }); - const live = engine.camera.livePosition; - assert.ok( - geo.cartesian3.distance(live, engine.camera.pose().position) < 1e-6, - ); - assert.ok(Math.abs(engine.camera.height() - 2_000_000) < 0.01); - engine.fake.setPose({ longitude: 0.3, latitude: 0.2, height: 500 }); - assert.equal(engine.camera.livePosition, live); - assert.ok( - geo.cartesian3.distance(live, engine.camera.pose().position) < 1e-6, - ); -}); - test('fake host elements can be supplied', () => { const container = { id: 'globe' }; const canvas = { id: 'canvas' }; @@ -402,9 +291,6 @@ 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 = []; - engine.clock.ticked.addEventListener((time) => - seen.push(['ticked', time, engine.clock.frameTime()]), - ); for (const name of ['preUpdate', 'preRender', 'postRender']) engine.frames[name].addEventListener(() => seen.push([name, engine.clock.frameTime()]), @@ -412,7 +298,6 @@ test('the fake clock is the app clock the test controls, latched per frame', () assert.equal(engine.clock.now(), start); engine.fake.frame(16); assert.deepEqual(seen, [ - ['ticked', start + 16, start + 16], ['preUpdate', start + 16], ['preRender', start + 16], ['postRender', start + 16], diff --git a/src/hud.js b/src/hud.js index 9dd381f15..b398348c6 100644 --- a/src/hud.js +++ b/src/hud.js @@ -20,7 +20,6 @@ import { applicationServices } from './services/application.js'; */ import * as geo from './geo/index.js'; -import { isGlobeEngine } from './globe/contract.js'; import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; @@ -229,17 +228,15 @@ const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) => */ export class IntelHUD { /** - * @param {import('./globe/contract.js').GlobeEngine | object} engine The - * globe engine whose camera drives the telemetry, or a viewer registered - * for one. + * @param {import('./globe/contract.js').GlobeEngine} engine The globe + * engine whose camera drives the telemetry. * @param {object} [options] `viewer`: the Cesium viewer the basemap label - * context (voice, until phase 1c) reads; without one the summary has no - * place labels. Defaults to the first argument when that is a viewer. + * context (voice) reads; without one the summary has no place labels. */ constructor( engine, { - viewer = isGlobeEngine(engine) ? null : engine, + viewer = null, placeSearch, summaryPolicy = {}, basemapContext = {}, @@ -252,7 +249,7 @@ export class IntelHUD { this.summaryPolicy = summaryPolicy; this.basemapContext = basemapContext; this.placeSearch = placeSearch; - this.engine = engineFromViewer(engine); + this.engine = engine; this.viewer = viewer; this._visible = false; this._autoMode = true; // auto show/hide based on style diff --git a/src/layers/aircraft/render/cesium.js b/src/layers/aircraft/render/cesium.js index 4c52876b1..de8e0927a 100644 --- a/src/layers/aircraft/render/cesium.js +++ b/src/layers/aircraft/render/cesium.js @@ -1,7 +1,6 @@ import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../../data/aircraftModelLighting.js'; // The aircraft renderer on Cesium (contract: ./contract.js): the Cesium -// objects the aircraft layers used to build themselves, moved here one for -// one — the fleet BillboardCollection and its sprite-order slot, the model +// objects the aircraft layers draw with — the fleet BillboardCollection and its sprite-order slot, the model // PrimitiveCollection and its glTF loads, the tracked Entity (billboard with // per-frame CallbackProperties, followed through viewer.trackedEntity and // the one exact tracked frame), the trail entity and its live head @@ -19,7 +18,7 @@ import { horizonOccluder } from '../../../data/iconOrientation.js'; import { engineFromViewer } from '../../../globe/cesium/engineFromViewer.js'; import { applyTrackedCameraFrame } from '../../../globe/cesium/follow.js'; -// The model shader of the IR boost (owner playtest 2026-08-16): boosted +// The model shader of the IR boost: boosted // models render UNLIT so a side-lit airframe does not go black in FLIR/NVG. // Assigning a customShader to a ready model is a silent no-op, so the layer // reloads boosted models and the shader is set at creation. One stateless diff --git a/src/layers/aircraft/render/cesium.test.mjs b/src/layers/aircraft/render/cesium.test.mjs index 042406529..adb333137 100644 --- a/src/layers/aircraft/render/cesium.test.mjs +++ b/src/layers/aircraft/render/cesium.test.mjs @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import * as geo from '../../../geo/index.js'; import { createAircraftCesiumRenderer } from './cesium.js'; -import { missingAircraftRendererMembers } from './contract.js'; import { CYAN, nearFarScale, rgbaFromCss } from '../../shared/color.js'; /** A viewer with what the renderer touches: primitives, entities, camera. */ @@ -60,9 +59,8 @@ const renderer = (viewer, options = {}) => ...options, }); -test('the Cesium aircraft renderer has every member of the contract', () => { +test('the Cesium aircraft renderer reports its kind', () => { const { viewer } = stubViewer(); - assert.deepEqual(missingAircraftRendererMembers(renderer(viewer)), []); assert.equal(renderer(viewer).kind, 'cesium'); }); diff --git a/src/layers/aircraft/render/contract.js b/src/layers/aircraft/render/contract.js index d0ce7d791..381dc4a07 100644 --- a/src/layers/aircraft/render/contract.js +++ b/src/layers/aircraft/render/contract.js @@ -88,73 +88,3 @@ * What a vertical surface probe must not hit: the layer's own drawings. * @property {() => void} destroy */ - -/** The members every aircraft renderer has (shape checks, tests). */ -export const AIRCRAFT_RENDERER_SHAPE = Object.freeze({ - view: [ - 'frameNumber', - 'viewportHeightPx', - 'frustumTester', - 'horizon', - 'worldToWindow', - 'requestRender', - 'on', - 'holdFrames', - 'closeFrameLatch', - 'releaseFrames', - ], - fleet: [ - 'attach', - 'detach', - 'attached', - 'visible', - 'setVisible', - 'add', - 'remove', - 'restoreOrder', - 'restoreOrderOnEnable', - ], - models: [ - 'live', - 'setVisible', - 'loadFleet', - 'loadTracked', - 'preload', - 'add', - 'remove', - 'destroy', - 'setPose', - ], - tracked: [ - 'create', - 'remove', - 'isLive', - 'setImage', - 'setViewFrom', - 'billboardSize', - ], - follow: [ - 'start', - 'refocus', - 'release', - 'isFollowing', - 'current', - 'onChanged', - ], - trails: ['create', 'createHead', 'removeHead'], -}); - -/** - * The members of AIRCRAFT_RENDERER_SHAPE that `renderer` lacks - * (['group.member', …]); empty for a complete renderer. - */ -export function missingAircraftRendererMembers(renderer) { - const missing = []; - for (const key of ['kind', 'view', 'surfaceExclusions', 'destroy']) - if (renderer?.[key] === undefined) missing.push(key); - for (const [group, members] of Object.entries(AIRCRAFT_RENDERER_SHAPE)) - for (const member of members) - if (typeof renderer?.[group]?.[member] !== 'function') - missing.push(`${group}.${member}`); - return missing; -} diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index d9771f45b..1bf46f3d0 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -23,39 +23,33 @@ import { drawAircraftIconAtlas, } from '../../../data/aircraftIcons.js'; import { createEngineLayerView } from '../../shared/render/engineView.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; +import { + createMoverBank, + StoredColor, + writeFields, +} from '../../shared/render/navaraMoverBank.js'; const BASE_SIZE_PX = 20; /** * The model frame → glTF scene axes on Navara: Cesium's own glTF axis * correction, Y_UP_TO_Z_UP × Z_UP_TO_X_UP (data/modelVisualAnchor.js). - * Navara's glTF loader is three's GLTFLoader, which keeps the scene Y-up. - * (10b used Z_UP_TO_X_UP alone, on the belief that the loader turned the - * scene Z-up: every fleet model drew 90° off its heading — side by side - * with Cesium at the near pose, 10b review item 3.) + * Navara's glTF loader is three's GLTFLoader, which keeps the scene Y-up, + * so Z_UP_TO_X_UP alone would draw every model 90° off its heading. */ // Cesium's Axis.Y_UP_TO_Z_UP × Axis.Z_UP_TO_X_UP, column-major. const NAVARA_GLTF_AXIS = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1]; -const FIRST_CAPACITY = 1024; const ATLAS_PAD = 2; const TRAIL_ALPHA = 0.85; const TRAIL_OCCLUDED_ALPHA = 0.4; const MIN_SEGMENT_DISTANCE_SQ = 0.01; // --------------------------------------------------------------------------- -// Banks of mover sets +// Sprite fields // --------------------------------------------------------------------------- -// Reused set() arguments (set() copies them; no object per write). -const fieldsNoPick = { - position: undefined, - orientation: undefined, - color: undefined, - rotation: undefined, - scale: undefined, - cell: undefined, - show: undefined, -}; const scratchColor = new Float64Array(4); // Sprite setters write synchronously into the mover store; reuse their // field records rather than allocating one per contact and assignment. @@ -64,102 +58,6 @@ const spriteShowField = { show: true }; const spriteRotationField = { rotation: 0 }; const spriteScaleField = { scale: 1 }; const spriteColorField = { color: scratchColor }; -function writeFields(set, index, fields) { - const scratch = fieldsNoPick; - for (const key in fields) scratch[key] = fields[key]; - set.set(index, scratch); - for (const key in fields) scratch[key] = undefined; -} - -/** - * A growing bank of one kind of mover: handles hold an index; removal moves - * the last handle into the hole. `options(capacity, generation)` gives the - * createSet options; `write(handle, set, index)` writes a handle whole. - */ -export function createMoverBank(engine, { options, write, onCommit }) { - const handles = []; - let set = null; - let generation = 0; - let capacity = 0; - let visible = true; - let dirty = false; - const build = (wanted) => { - const next = engine.movers.createSet(options(wanted, ++generation)); - set?.destroy(); - set = next; - capacity = wanted; - handles.forEach((handle, index) => write(handle, set, index)); - set.setCount(visible ? handles.length : 0); - }; - const bank = { - handles, - get set() { - return set; - }, - ready: () => Boolean(set?.ready()), - add(handle) { - if (handle.gevBank === bank) return; - handle.gevBank?.remove(handle); - handle.gevBank = bank; - handle.gevIndex = handles.length; - handles.push(handle); - if (!set || handles.length > capacity) - build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length)); - else { - write(handle, set, handle.gevIndex); - if (visible) set.setCount(handles.length); - } - bank.touch(); - }, - remove(handle) { - if (handle.gevBank !== bank) return; - const index = handle.gevIndex; - const last = handles.pop(); - if (last !== handle) { - handles[index] = last; - last.gevIndex = index; - write(last, set, index); - } - handle.gevBank = null; - handle.gevIndex = -1; - set?.setCount(visible ? handles.length : 0); - bank.touch(); - }, - /** Writes `fields` of one handle (when it is in this bank). */ - update(handle, fields) { - if (handle.gevBank !== bank || !set) return; - writeFields(set, handle.gevIndex, fields); - bank.touch(); - }, - setVisible(next) { - visible = next; - set?.setCount(visible ? handles.length : 0); - bank.touch(); - }, - visible: () => visible, - touch() { - if (!dirty) { - dirty = true; - onCommit(bank); - } - }, - commit() { - if (!dirty) return; - dirty = false; - set?.commit(); - }, - destroy() { - for (const handle of handles) { - handle.gevBank = null; - handle.gevIndex = -1; - } - handles.length = 0; - set?.destroy(); - set = null; - }, - }; - return bank; -} const rgbaOf = (color, result = scratchColor) => { result[0] = color?.red ?? 1; @@ -169,20 +67,6 @@ const rgbaOf = (color, result = scratchColor) => { return result; }; -/** Plain colour copy (the stored value a handle's colour getter returns). */ -class StoredColor { - constructor(color) { - this.red = color?.red ?? 1; - this.green = color?.green ?? 1; - this.blue = color?.blue ?? 1; - this.alpha = color?.alpha ?? 1; - } - - withAlpha(alpha) { - return new StoredColor({ ...this, alpha }); - } -} - const depthKey = (distance) => distance === Number.POSITIVE_INFINITY ? 'always' @@ -826,13 +710,9 @@ export function createAircraftNavaraRenderer({ // --- trails ---------------------------------------------------------------- let lineSeq = 0; - const rgbaFromCss = (css) => { - const hex = String(css).replace('#', ''); - return [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255); - }; const trails = { create({ color, width = 2.5 }) { - const [r, g, b] = rgbaFromCss(color); + const [r, g, b] = rgbaFromCss(color).rgba(); const id = `gev-trail:${layerId}-${++lineSeq}`; let line = null; let visible = true; @@ -880,7 +760,7 @@ export function createAircraftNavaraRenderer({ }; }, createHead({ id, show, positions, color, width = 2.5 }) { - const [r, g, b] = rgbaFromCss(color); + const [r, g, b] = rgbaFromCss(color).rgba(); const line = engine.lines.createLine({ id: `${id}`, widthPx: width, @@ -946,5 +826,23 @@ export function createAircraftNavaraRenderer({ }; } +/** + * The Navara registry factory (src/app/layers/renderers.js) of one aircraft + * layer: its sprite slot (data/spriteOrder's order on Cesium) and its + * model's nose offset. + * @param {string} layerId + * @param {number} headingOffsetDeg + */ +export function aircraftLayerNavaraRenderer(layerId, headingOffsetDeg) { + /** @param {import('../../../globe/contract.js').GlobeEngine} engine */ + return (engine) => + createAircraftNavaraRenderer({ + engine, + layerId, + order: SPRITE_LAYER_ORDER.indexOf(layerId), + headingOffsetDeg, + }); +} + // The glyph atlas helpers, re-exported for tests. export { aircraftIconAtlasLayout as aircraftAtlasLayout, aircraftIconCells }; diff --git a/src/layers/aircraft/render/navara.test.mjs b/src/layers/aircraft/render/navara.test.mjs index c6fa8e891..0ef9e33b7 100644 --- a/src/layers/aircraft/render/navara.test.mjs +++ b/src/layers/aircraft/render/navara.test.mjs @@ -10,7 +10,6 @@ import * as geo from '../../../geo/index.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js'; import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js'; -import { missingAircraftRendererMembers } from './contract.js'; import { aircraftAtlasLayout, aircraftIconCells, @@ -39,9 +38,8 @@ async function setup() { const setsOf = (engine, prefix) => engine.movers.list().filter(({ id }) => id.startsWith(prefix)); -test('the Navara aircraft renderer has every member of the contract', async () => { +test('the Navara aircraft renderer reports its kind', async () => { const { engine, renderer } = await setup(); - assert.deepEqual(missingAircraftRendererMembers(renderer), []); assert.equal(renderer.kind, 'navara'); renderer.destroy(); engine.destroy(); diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/overlay.js new file mode 100644 index 000000000..1c690a607 --- /dev/null +++ b/src/layers/alpr/overlay.js @@ -0,0 +1,386 @@ +import * as geo from '../../geo/index.js'; +import { + MAX_CANVAS_FRUSTUMS, + MARKER_ICON_SIZE, + SELECTED_MARKER_ICON_SIZE, + MARKER_FLOOR_LIFT_M, + ANCHOR_SAMPLES_PER_PAINT, + SURFACE_BELOW_FLOOR_M, + SURFACE_ABOVE_FLOOR_M, + SURFACE_SAMPLES_PER_PAINT, +} from './policy.js'; +import { + MARKER_IMAGE, + SELECTED_IMAGE, + BRACKETS_IMAGE, + directionWedgePositions, + markerScale, + paintDirectionWedge, + validAlprGroundHeight, +} from './visuals.js'; + +/** + * Canvas artwork for the nearest ALPR cameras over each engine's native + * markers, with bounded surface refinement. The engine adapter owns marker + * lookup and appearance, surface sampling, the camera pose and projection. + * @param {object} options + * @param {object} options.state Layer state. + * @param {object} options.services Layer services. + * @param {function(): void} [options.onSurfaceChange] Map stack changed. + * @param {object} options.engine Engine adapter. + */ +export function createAlprOverlayCore({ + state, + services, + onSurfaceChange = () => {}, + engine, +}) { + let lane, + removeMapListener, + retryTimer, + retryUntil = 0; + let records = [], + hits = [], + images = [], + destroyed = false, + lastCamera = null; + const requestPaint = () => { + if (!destroyed && state.enabled) lane?.requestPaint(); + }; + + function nativeVisible(marker, visible) { + if (!marker) return; + engine.canvasOwned(marker, !visible); + engine.setShown(marker, visible); + } + + function resetAnchors() { + for (const marker of engine.markers()) { + marker.gevAlprCanvasPosition = null; + marker.gevAlprWedge = null; + engine.setDisplayPosition(marker, null); + nativeVisible(marker, true); + } + state.lastAnchorSampleAt = 0; + retryUntil = Date.now() + 15000; + requestPaint(); + } + + function anchorFor(record, marker, budget) { + if (marker.gevAlprCanvasPosition) return marker.gevAlprCanvasPosition; + // A floor-placed marker anchors where its native badge sits; only a + // marker still waiting for its floor samples the rendered surface, and + // each paint samples a few at most. + let height; + const floor = services.groundFloor.cachedGroundFloor( + record.latitude, + record.longitude, + ); + if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M; + else if (engine.surfaceSupported()) { + if (budget.samples <= 0) return null; + budget.samples -= 1; + try { + height = engine.sampleHeight(record, marker); + } catch { + /* streaming tiles */ + } + } + if (!validAlprGroundHeight(height)) height = engine.terrainHeight(record); + if (!validAlprGroundHeight(height)) return null; + setAnchor(record, marker, height); + return marker.gevAlprCanvasPosition; + } + + function floorHeightAt(latitude, longitude) { + const floor = services.groundFloor.cachedGroundFloor(latitude, longitude); + return validAlprGroundHeight(floor) ? floor + MARKER_FLOOR_LIFT_M : null; + } + + function setAnchor(record, marker, height) { + engine.placeAnchor(marker, record, height); + marker.gevAlprWedge = directionWedgePositions( + record, + height, + floorHeightAt, + ); + } + + /** + * Replace floor anchors with the rendered surface under each overlay camera. + * Floors come from ~111 m cells, so on a slope a badge could float or sink + * by tens of metres. Runs only while the camera is still and tiles have + * settled, a few surface samples per paint, and each anchor at most once; + * samples far outside the floor prior (a roof or a streaming tile) are + * rejected. Returns whether anchors remain to check. + */ + function refineAnchors(budget) { + const camera = engine.cameraPose(); + const still = + lastCamera && + camera.position && + geo.cartesian3.equalsEpsilon( + camera.position, + lastCamera.position, + 0, + 0.05, + ) && + geo.cartesian3.equalsEpsilon( + camera.direction, + lastCamera.direction, + 0, + 1e-6, + ); + if (camera.position && camera.direction) + lastCamera = { + position: geo.cartesian3.clone(camera.position), + direction: geo.cartesian3.clone(camera.direction), + }; + if (!engine.surfaceSupported()) return false; + let pending = false; + for (const record of records) { + const marker = engine.marker(record.id); + const anchor = marker?.gevAlprCanvasPosition; + if (!anchor || marker.gevAlprSurfaceCheckedFor === anchor) continue; + if (!still || !engine.surfaceSettled() || budget.samples <= 0) { + pending = true; + continue; + } + budget.samples -= 1; + let sample; + try { + sample = engine.sampleHeight(record, marker); + } catch { + /* streaming tiles */ + } + const current = geo.cartographic.fromCartesian(anchor).height; + const floor = services.groundFloor.cachedGroundFloor( + record.latitude, + record.longitude, + ); + const accepted = + validAlprGroundHeight(sample) && + (!validAlprGroundHeight(floor) || + (sample >= floor - SURFACE_BELOW_FLOOR_M && + sample <= floor + SURFACE_ABOVE_FLOOR_M)); + if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1) + setAnchor(record, marker, sample + MARKER_FLOOR_LIFT_M); + marker.gevAlprSurfaceCheckedFor = marker.gevAlprCanvasPosition; + if (marker.id === state.selectedId) + engine.setDisplayPosition(marker, marker.gevAlprCanvasPosition); + } + return pending; + } + + function paint(frame) { + const { ctx, width, height, keyhole } = frame; + hits = []; + if (!state.enabled || destroyed) return; + const { horizonVisible, project } = engine.projector(frame); + let unresolved = false; + const painted = []; + const budget = { samples: ANCHOR_SAMPLES_PER_PAINT }; + for (const record of records) { + const marker = engine.marker(record.id); + if (!marker) continue; + const selected = record.id === state.selectedId; + const image = images[selected ? 1 : 0]; + if ( + !image?.complete || + !image.naturalWidth || + (selected && !images[2]?.naturalWidth) + ) { + nativeVisible(marker, true); + continue; + } + const anchor = anchorFor(record, marker, budget); + if (!anchor) { + nativeVisible(marker, true); + unresolved = true; + continue; + } + engine.canvasOwned(marker, true); + if (!horizonVisible(anchor)) { + engine.setShown(marker, false); + continue; + } + const origin = project(anchor); + if (!origin) { + engine.setShown(marker, false); + continue; + } + const alpha = + services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1; + if (!(alpha > 0)) { + engine.setShown(marker, false); + continue; + } + const wedge = marker.gevAlprWedge; + if (wedge) { + const left = project(wedge[1]); + const right = project(wedge[2]); + if ( + left && + right && + (!engine.cullOffscreenWedges || + (Math.max(origin.x, left.x, right.x) >= -3 && + Math.min(origin.x, left.x, right.x) <= width + 3 && + Math.max(origin.y, left.y, right.y) >= -3 && + Math.min(origin.y, left.y, right.y) <= height + 3)) + ) { + ctx.save(); + ctx.globalAlpha *= alpha; + paintDirectionWedge(ctx, origin, left, right, selected); + ctx.restore(); + } + } + if ( + origin.x < -60 || + origin.x > width + 60 || + origin.y < -60 || + origin.y > height + 60 + ) { + engine.setShown(marker, false); + continue; + } + engine.setShown(marker, true); + painted.push({ + record, + origin, + selected, + image, + alpha, + scale: markerScale( + geo.cartesian3.distance(engine.cameraPosition(), anchor), + ), + }); + if (selected) engine.setDisplayPosition(marker, anchor); + } + // Paint all glyphs after the wedges so one camera's cone cannot wash out another. + for (const { record, origin, selected, image, alpha, scale } of painted) { + const size = + (selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale; + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.drawImage( + image, + origin.x - size / 2, + origin.y - size / 2, + size, + size, + ); + if (selected) + ctx.drawImage( + images[2], + origin.x - size / 2, + origin.y - size / 2, + size, + size, + ); + ctx.restore(); + hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 }); + } + const refining = refineAnchors({ + samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT), + }); + if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) { + retryTimer = setTimeout(() => { + retryTimer = null; + requestPaint(); + }, 250); + } + } + + return { + init() { + destroyed = false; + removeMapListener = services.overlays?.subscribeMapStack?.((event) => { + if (event.detail?.status !== 'ready' || !state.enabled) return; + // Same-regime provider changes also invalidate absolute placements. + onSurfaceChange(); + resetAnchors(); + }); + if (!services.overlays?.registerPaintLane || typeof Image === 'undefined') + return; + lane = services.overlays.registerPaintLane('selected', paint, { + id: `alpr-${engine.createId()}`, + active: false, + }); + images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => { + const image = new Image(); + image.onload = requestPaint; + image.onerror = requestPaint; + image.src = src; + return image; + }); + }, + sync(visible) { + if (!lane) return; + for (const record of records) + nativeVisible(engine.marker(record.id), true); + const position = engine.cameraPosition(); + records = visible + .filter( + (record) => + Number.isFinite(record.directionDeg) && + record.id !== state.selectedId, + ) + .map((record) => ({ + record, + distance: position + ? geo.cartesian3.distanceSquared( + position, + geo.cartesian3.fromDegrees(record.longitude, record.latitude), + ) + : 0, + })) + .sort((a, b) => a.distance - b.distance) + .slice(0, MAX_CANVAS_FRUSTUMS) + .map((item) => item.record); + const selected = visible.find((record) => record.id === state.selectedId); + if (selected) { + if (records.length >= MAX_CANVAS_FRUSTUMS) records.pop(); + records.push(selected); + } + hits = []; + retryUntil = Date.now() + 15000; + lane.setActive(state.enabled && records.length > 0); + requestPaint(); + }, + pick(position) { + if (!state.enabled || !position) return null; + let closest, + distance = Infinity; + for (const hit of hits) { + const next = (hit.x - position.x) ** 2 + (hit.y - position.y) ** 2; + if (next <= hit.radius ** 2 && next <= distance) { + closest = hit.id; + distance = next; + } + } + return closest || null; + }, + clear() { + clearTimeout(retryTimer); + retryTimer = null; + for (const record of records) + nativeVisible(engine.marker(record.id), true); + records = []; + hits = []; + lane?.setActive(false); + }, + destroy() { + this.clear(); + destroyed = true; + removeMapListener?.(); + removeMapListener = null; + lane?.unregister(); + lane = null; + for (const image of images) { + image.onload = null; + image.onerror = null; + } + images = []; + }, + }; +} diff --git a/src/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs index 0b52a402e..16db7a8dc 100644 --- a/src/layers/alpr/overlay.test.mjs +++ b/src/layers/alpr/overlay.test.mjs @@ -197,8 +197,8 @@ test('an unresolved surface preserves the native marker; the horizon removes ove }); test('repeated paints of a still view never rewrite marker properties', (t) => { - // Each Entity write raises definitionChanged; paint used to hide then show - // every overlay camera's native marker on every frame. + // Each Entity write raises definitionChanged, so a still view must not hide + // and re-show every overlay camera's native marker on every frame. const h = harness(t, 3); h.overlay.sync(h.records); h.paint(); diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js new file mode 100644 index 000000000..bf0b91a3f --- /dev/null +++ b/src/layers/alpr/presentation.js @@ -0,0 +1,113 @@ +import * as geo from '../../geo/index.js'; +import { msg } from '../../i18n/index.js'; +import { boxContains } from './model.js'; +import { + LAYER_ID, + MAX_RENDERED, + MAX_VIEWPORT_DEGREES, + SELECTED_MARKER_ICON_SIZE, +} from './policy.js'; +import { alprDisplayId, alprLabelDetails } from './visuals.js'; + +/** + * Bounded nearby coverage around the ground point at the centre of the view. + * The horizon rectangle is unstable during low-angle orbits and can exclude + * that point; the camera-to-ground range keeps zoom-out queries capped. + * @param {{latitude:number, longitude:number}} location Focus, radians. + * @param {number} range Camera-to-focus distance in metres. + * @returns {{south:number, west:number, north:number, east:number}|null} + */ +export function alprFocusBox(location, range) { + const radius = Math.max(1000, 2 * range); + const latitude = geo.math.toDegrees(location.latitude); + const longitude = geo.math.toDegrees(location.longitude); + const latSpan = radius / 111000; + const lonSpan = latSpan / Math.cos(location.latitude); + if ( + !Number.isFinite(latSpan + lonSpan) || + 2 * Math.max(latSpan, lonSpan) > MAX_VIEWPORT_DEGREES || + Math.abs(latitude) + latSpan > 90 || + Math.abs(longitude) + lonSpan > 180 + ) + return null; + return { + south: latitude - latSpan, + west: longitude - lonSpan, + north: latitude + latSpan, + east: longitude + lonSpan, + }; +} + +/** + * Records a view box would display, nearest the box centre first when over + * the render cap. + * @param {Array} records Candidate records. + * @param {object|null} box Viewport box in degrees. + * @returns {{visible: Array, capped: boolean}} + */ +export function alprViewRecords(records, box) { + if (!box) return { visible: [], capped: false }; + let visible = records.filter((record) => + boxContains(box, { + south: record.latitude, + north: record.latitude, + west: record.longitude, + east: record.longitude, + }), + ); + const capped = visible.length > MAX_RENDERED; + if (capped) { + const lat = (box.south + box.north) / 2; + const lon = (box.west + box.east) / 2; + const scale = Math.cos((lat * Math.PI) / 180); + const distance = (record) => + (record.latitude - lat) ** 2 + ((record.longitude - lon) * scale) ** 2; + visible = visible + .map((record) => ({ record, d: distance(record) })) + .sort((a, b) => a.d - b.d) + .slice(0, MAX_RENDERED) + .map((item) => item.record); + } + return { visible, capped }; +} + +/** The selected-camera card shown beside a marker. */ +export function alprLabelModel(record, source, { accent, startedAt }) { + return { + title: alprDisplayId(record), + details: alprLabelDetails(record, source), + accent, + cardStyle: 'tactical', + selected: true, + leaderStyle: 'elbow', + leaderAnimationMs: 440, + leaderAnimationStartedAt: startedAt, + leaderDrawRatio: 0.68, + anchorRadiusPx: SELECTED_MARKER_ICON_SIZE / 2, + anchorRadiusScale: null, + }; +} + +/** Entity-context fields for one camera record. */ +export function alprEntityContext(record, source) { + return { + id: record.id, + layerId: LAYER_ID, + layerName: 'ALPR Cameras', + source: source.attribution?.description || source.label || 'Camera source', + label: msg('ALPR camera'), + latitude: record.latitude, + longitude: record.longitude, + properties: { + operator: record.operator, + manufacturer: record.manufacturer, + cameraType: record.cameraType, + zone: record.zone, + directionDeg: record.directionDeg, + ref: record.ref, + lastVerified: record.lastVerified, + osmId: record.osmId, + sourceTag: record.source, + }, + }; +} diff --git a/src/layers/alpr/render/cesiumOverlay.js b/src/layers/alpr/render/cesiumOverlay.js index 811fa8cdb..74ac3e842 100644 --- a/src/layers/alpr/render/cesiumOverlay.js +++ b/src/layers/alpr/render/cesiumOverlay.js @@ -1,23 +1,5 @@ import * as Cesium from 'cesium'; -import { - MAX_CANVAS_FRUSTUMS, - MARKER_ICON_SIZE, - SELECTED_MARKER_ICON_SIZE, - MARKER_FLOOR_LIFT_M, - ANCHOR_SAMPLES_PER_PAINT, - SURFACE_BELOW_FLOOR_M, - SURFACE_ABOVE_FLOOR_M, - SURFACE_SAMPLES_PER_PAINT, -} from '../policy.js'; -import { - MARKER_IMAGE, - SELECTED_IMAGE, - BRACKETS_IMAGE, - directionWedgePositions, - markerScale, - paintDirectionWedge, - validAlprGroundHeight, -} from '../visuals.js'; +import { createAlprOverlayCore } from '../overlay.js'; /** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */ export function createAlprOverlay({ @@ -26,24 +8,9 @@ export function createAlprOverlay({ services, onSurfaceChange = () => {}, }) { - let lane, - removeMapListener, - retryTimer, - retryUntil = 0; - let records = [], - hits = [], - images = [], - destroyed = false, - lastCamera = null; - const requestPaint = () => { - if (!destroyed && state.enabled) lane?.requestPaint(); - }; - - function nativeVisible(entity, visible) { - if (!entity) return; - canvasOwned(entity, !visible); - setShown(entity, visible); - } + const scene = () => native.viewer.scene; + const location = (record) => + Cesium.Cartographic.fromDegrees(record.longitude, record.latitude); /** Swap a marker between its native look and a faint canvas pick target. */ function canvasOwned(entity, owned) { @@ -76,8 +43,7 @@ export function createAlprOverlay({ } // Assign only on change: each Entity property write mints a new property - // and raises definitionChanged, which paint used to do for every overlay - // camera on every frame. + // and raises definitionChanged. function setShown(entity, visible) { if (entity.gevAlprShown === visible) return; entity.gevAlprShown = visible; @@ -86,360 +52,57 @@ export function createAlprOverlay({ if (entity.polygon) entity.polygon.show = visible; } - function resetAnchors() { - for (const entity of native.dataSource.entities.values) { - entity.gevAlprCanvasPosition = null; - entity.gevAlprWedge = null; - entity.gevAlprDisplayPosition = null; - nativeVisible(entity, true); - } - state.lastAnchorSampleAt = 0; - retryUntil = Date.now() + 15000; - requestPaint(); - } - - function anchorFor(record, entity, budget) { - if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition; - const scene = native.viewer.scene; - const location = Cesium.Cartographic.fromDegrees( - record.longitude, - record.latitude, - ); - // A floor-placed marker anchors where its native badge sits; only a - // marker still waiting for its floor samples the rendered surface, and - // each paint samples a few at most (each sample is a depth render). - let height; - const floor = services.groundFloor.cachedGroundFloor( - record.latitude, - record.longitude, - ); - if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M; - else if (scene.sampleHeightSupported) { - if (budget.samples <= 0) return null; - budget.samples -= 1; - try { - height = scene.sampleHeight(location, [entity]); - } catch { - /* streaming tiles */ - } - } - if (!validAlprGroundHeight(height) && scene.globe.show) - height = scene.globe.getHeight?.(location); - if (!validAlprGroundHeight(height)) return null; - setAnchor(record, entity, height); - return entity.gevAlprCanvasPosition; - } - - function floorHeightAt(latitude, longitude) { - const floor = services.groundFloor.cachedGroundFloor(latitude, longitude); - return validAlprGroundHeight(floor) ? floor + MARKER_FLOOR_LIFT_M : null; - } - - function setAnchor(record, entity, height) { - entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - height, - ); - entity.gevAlprWedge = directionWedgePositions( - record, - height, - floorHeightAt, - ); - } - /** Whether the visible photoreal tiles (or globe) have finished streaming. */ - function surfaceSettled(scene) { - if (scene.globe?.show) return scene.globe.tilesLoaded !== false; - for (let i = 0; i < (scene.primitives?.length || 0); i++) { - const primitive = scene.primitives.get(i); + function surfaceSettled() { + const { globe, primitives } = scene(); + if (globe?.show) return globe.tilesLoaded !== false; + for (let i = 0; i < (primitives?.length || 0); i++) { + const primitive = primitives.get(i); if (primitive?.show && primitive.tilesLoaded === false) return false; } return true; } - /** - * Replace floor anchors with the rendered surface under each overlay camera. - * Floors come from ~111 m cells, so on a slope a badge could float or sink - * by tens of metres. Runs only while the camera is still and tiles have - * settled, a few depth samples per paint, and each anchor at most once; - * samples far outside the floor prior (a roof or a streaming tile) are - * rejected. Returns whether anchors remain to check. - */ - function refineAnchors(budget) { - const camera = native.viewer.camera; - const scene = native.viewer.scene; - const still = - lastCamera && - camera.positionWC && - Cesium.Cartesian3.equalsEpsilon( - camera.positionWC, - lastCamera.position, - 0, - 0.05, - ) && - Cesium.Cartesian3.equalsEpsilon( - camera.directionWC, - lastCamera.direction, - 0, - 1e-6, - ); - if (camera.positionWC && camera.directionWC) - lastCamera = { - position: Cesium.Cartesian3.clone(camera.positionWC), - direction: Cesium.Cartesian3.clone(camera.directionWC), - }; - if (!scene.sampleHeightSupported) return false; - let pending = false; - for (const record of records) { - const entity = native.dataSource.entities.getById(record.id); - const anchor = entity?.gevAlprCanvasPosition; - if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue; - if (!still || !surfaceSettled(scene) || budget.samples <= 0) { - pending = true; - continue; - } - budget.samples -= 1; - let sample; - try { - sample = scene.sampleHeight( - Cesium.Cartographic.fromDegrees(record.longitude, record.latitude), - [entity], + return createAlprOverlayCore({ + state, + services, + onSurfaceChange, + engine: { + marker: (id) => native.dataSource?.entities.getById(id), + markers: () => native.dataSource.entities.values, + canvasOwned, + setShown, + setDisplayPosition(entity, position) { + entity.gevAlprDisplayPosition = position; + }, + placeAnchor(entity, record, height) { + entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + height, ); - } catch { - /* streaming tiles */ - } - const current = Cesium.Cartographic.fromCartesian(anchor).height; - const floor = services.groundFloor.cachedGroundFloor( - record.latitude, - record.longitude, - ); - const accepted = - validAlprGroundHeight(sample) && - (!validAlprGroundHeight(floor) || - (sample >= floor - SURFACE_BELOW_FLOOR_M && - sample <= floor + SURFACE_ABOVE_FLOOR_M)); - if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1) - setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M); - entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition; - if (entity.id === state.selectedId) - entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition; - } - return pending; - } - - function paint({ ctx, width, height, keyhole, occluder }) { - hits = []; - if (!state.enabled || destroyed) return; - let unresolved = false; - const painted = []; - const budget = { samples: ANCHOR_SAMPLES_PER_PAINT }; - for (const record of records) { - const entity = native.dataSource.entities.getById(record.id); - if (!entity) continue; - const selected = record.id === state.selectedId; - const image = images[selected ? 1 : 0]; - if ( - !image?.complete || - !image.naturalWidth || - (selected && !images[2]?.naturalWidth) - ) { - nativeVisible(entity, true); - continue; - } - const anchor = anchorFor(record, entity, budget); - if (!anchor) { - nativeVisible(entity, true); - unresolved = true; - continue; - } - canvasOwned(entity, true); - if (occluder && !occluder.isPointVisible(anchor)) { - setShown(entity, false); - continue; - } - const scene = native.viewer.scene; - const origin = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - anchor, - ); - if (!origin) { - setShown(entity, false); - continue; - } - const alpha = - services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1; - if (!(alpha > 0)) { - setShown(entity, false); - continue; - } - const wedge = entity.gevAlprWedge; - if (wedge) { - const left = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - wedge[1], - ); - const right = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - wedge[2], - ); - if (left && right) { - ctx.save(); - ctx.globalAlpha *= alpha; - paintDirectionWedge(ctx, origin, left, right, selected); - ctx.restore(); - } - } - if ( - origin.x < -60 || - origin.x > width + 60 || - origin.y < -60 || - origin.y > height + 60 - ) { - setShown(entity, false); - continue; - } - setShown(entity, true); - painted.push({ - record, - origin, - selected, - image, - alpha, - scale: markerScale( - Cesium.Cartesian3.distance(native.viewer.camera.positionWC, anchor), - ), - }); - if (selected) entity.gevAlprDisplayPosition = anchor; - } - // Paint all glyphs after the wedges so one camera's cone cannot wash out another. - for (const { record, origin, selected, image, alpha, scale } of painted) { - const size = - (selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale; - ctx.save(); - ctx.globalAlpha *= alpha; - ctx.drawImage( - image, - origin.x - size / 2, - origin.y - size / 2, - size, - size, - ); - if (selected) - ctx.drawImage( - images[2], - origin.x - size / 2, - origin.y - size / 2, - size, - size, - ); - ctx.restore(); - hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 }); - } - const refining = refineAnchors({ - samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT), - }); - if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) { - retryTimer = setTimeout(() => { - retryTimer = null; - requestPaint(); - }, 250); - } - } - - return { - init() { - destroyed = false; - removeMapListener = services.overlays?.subscribeMapStack?.((event) => { - if (event.detail?.status !== 'ready' || !state.enabled) return; - // Same-regime provider changes also invalidate absolute placements. - onSurfaceChange(); - resetAnchors(); - }); - if (!services.overlays?.registerPaintLane || typeof Image === 'undefined') - return; - lane = services.overlays.registerPaintLane('selected', paint, { - id: `alpr-${Cesium.createGuid()}`, - active: false, - }); - images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => { - const image = new Image(); - image.onload = requestPaint; - image.onerror = requestPaint; - image.src = src; - return image; - }); + }, + surfaceSupported: () => scene().sampleHeightSupported, + // Each sample is a depth render. + sampleHeight: (record, entity) => + scene().sampleHeight(location(record), [entity]), + terrainHeight: (record) => + scene().globe.show + ? scene().globe.getHeight?.(location(record)) + : undefined, + surfaceSettled, + cameraPose: () => ({ + position: native.viewer.camera.positionWC, + direction: native.viewer.camera.directionWC, + }), + cameraPosition: () => native.viewer.camera.positionWC, + projector: ({ occluder }) => ({ + horizonVisible: (point) => !occluder || occluder.isPointVisible(point), + project: (point) => + Cesium.SceneTransforms.worldToWindowCoordinates(scene(), point), + }), + cullOffscreenWedges: false, + createId: () => Cesium.createGuid(), }, - sync(visible) { - if (!lane) return; - for (const record of records) - nativeVisible(native.dataSource.entities.getById(record.id), true); - const position = native.viewer.camera.positionWC; - records = visible - .filter( - (record) => - Number.isFinite(record.directionDeg) && - record.id !== state.selectedId, - ) - .map((record) => ({ - record, - distance: position - ? Cesium.Cartesian3.distanceSquared( - position, - Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - ), - ) - : 0, - })) - .sort((a, b) => a.distance - b.distance) - .slice(0, MAX_CANVAS_FRUSTUMS) - .map((item) => item.record); - const selected = visible.find((record) => record.id === state.selectedId); - if (selected) { - if (records.length >= MAX_CANVAS_FRUSTUMS) records.pop(); - records.push(selected); - } - hits = []; - retryUntil = Date.now() + 15000; - lane.setActive(state.enabled && records.length > 0); - requestPaint(); - }, - pick(position) { - if (!state.enabled || !position) return null; - let closest, - distance = Infinity; - for (const hit of hits) { - const next = (hit.x - position.x) ** 2 + (hit.y - position.y) ** 2; - if (next <= hit.radius ** 2 && next <= distance) { - closest = hit.id; - distance = next; - } - } - return closest || null; - }, - clear() { - clearTimeout(retryTimer); - retryTimer = null; - for (const record of records) - nativeVisible(native.dataSource?.entities.getById(record.id), true); - records = []; - hits = []; - lane?.setActive(false); - }, - destroy() { - this.clear(); - destroyed = true; - removeMapListener?.(); - removeMapListener = null; - lane?.unregister(); - lane = null; - for (const image of images) { - image.onload = null; - image.onerror = null; - } - images = []; - }, - }; + }); } diff --git a/src/layers/alpr/render/cesiumPresentation.js b/src/layers/alpr/render/cesiumPresentation.js index 39965190e..1e26886c9 100644 --- a/src/layers/alpr/render/cesiumPresentation.js +++ b/src/layers/alpr/render/cesiumPresentation.js @@ -3,7 +3,6 @@ import { isPointerFree } from '../../../data/inputOwnership.js'; import { LAYER_ID, MAX_VIEWPORT_DEGREES, - MAX_RENDERED, ALPR_COLOR, ALPR_SELECTED_COLOR, MARKER_ICON_SIZE, @@ -12,15 +11,17 @@ import { MARKER_FLOOR_LIFT_M, MARKER_SCALE_BY_DISTANCE, } from '../policy.js'; -import { boxContains } from '../model.js'; +import { + alprEntityContext, + alprFocusBox, + alprLabelModel, + alprViewRecords, +} from '../presentation.js'; import { createAlprFloorResolver } from '../floors.js'; import { createAlprOverlay } from './cesiumOverlay.js'; -import { msg } from '../../../i18n/index.js'; import { MARKER_IMAGE, SELECTED_IMAGE, - alprDisplayId, - alprLabelDetails, validAlprGroundHeight, } from '../visuals.js'; @@ -71,32 +72,8 @@ export function createAlprPresentation({ state, native, services, source }) { } /** Records the current view would display (viewport box, nearest first when capped). */ - function viewRecords(records) { - const box = viewportBox(native.viewer); - if (!box) return { box, visible: [], capped: false }; - let visible = records.filter((record) => - boxContains(box, { - south: record.latitude, - north: record.latitude, - west: record.longitude, - east: record.longitude, - }), - ); - const capped = visible.length > MAX_RENDERED; - if (capped) { - const lat = (box.south + box.north) / 2; - const lon = (box.west + box.east) / 2; - const scale = Math.cos((lat * Math.PI) / 180); - const distance = (record) => - (record.latitude - lat) ** 2 + ((record.longitude - lon) * scale) ** 2; - visible = visible - .map((record) => ({ record, d: distance(record) })) - .sort((a, b) => a.d - b.d) - .slice(0, MAX_RENDERED) - .map((item) => item.record); - } - return { box, visible, capped }; - } + const viewRecords = (records) => + alprViewRecords(records, viewportBox(native.viewer)); /** * Resolve the floor cells of the records this view will display, waiting at @@ -249,9 +226,6 @@ export function createAlprPresentation({ state, native, services, source }) { return Cesium.Color.fromCssColorString(ALPR_COLOR); } - // The horizon rectangle is unstable during low-angle orbits and can exclude - // the ground point in the center of the screen. Bound nearby coverage around - // that point instead; the camera-to-ground range keeps zoom-out queries capped. function viewportBox(viewer) { const camera = viewer?.camera; const canvas = viewer?.scene.canvas; @@ -264,26 +238,10 @@ export function createAlprPresentation({ state, native, services, source }) { viewer.scene.globe.ellipsoid, ); if (!focus) return null; - const location = Cesium.Cartographic.fromCartesian(focus); - const range = Cesium.Cartesian3.distance(camera.positionWC, focus); - const radius = Math.max(1000, 2 * range); - const latitude = Cesium.Math.toDegrees(location.latitude); - const longitude = Cesium.Math.toDegrees(location.longitude); - const latSpan = radius / 111000; - const lonSpan = latSpan / Math.cos(location.latitude); - if ( - !Number.isFinite(latSpan + lonSpan) || - 2 * Math.max(latSpan, lonSpan) > MAX_VIEWPORT_DEGREES || - Math.abs(latitude) + latSpan > 90 || - Math.abs(longitude) + lonSpan > 180 - ) - return null; - return { - south: latitude - latSpan, - west: longitude - lonSpan, - north: latitude + latSpan, - east: longitude + lonSpan, - }; + return alprFocusBox( + Cesium.Cartographic.fromCartesian(focus), + Cesium.Cartesian3.distance(camera.positionWC, focus), + ); } // Rectangle-only viewers retain the same bounded-area contract. const rectangle = viewer?.camera?.computeViewRectangle( @@ -432,41 +390,14 @@ export function createAlprPresentation({ state, native, services, source }) { // at most once per second, through Cesium's public height APIs. entity.gevAlprDisplayPosition ??= null; entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition; - entity.gevLabelModel = { - title: alprDisplayId(record), - details: alprLabelDetails(record, source), + entity.gevLabelModel = alprLabelModel(record, source, { accent: color.toCssColorString(), - cardStyle: 'tactical', - selected: true, - leaderStyle: 'elbow', - leaderAnimationMs: 440, - leaderAnimationStartedAt: selected ? selectionStartedAt : 0, - leaderDrawRatio: 0.68, - anchorRadiusPx: SELECTED_MARKER_ICON_SIZE / 2, - anchorRadiusScale: null, - }; + startedAt: selected ? selectionStartedAt : 0, + }); updateAppearance(entity, selected); registerEntityContext(entity, { - id: record.id, - layerId: LAYER_ID, + ...alprEntityContext(record, source), dataSource: native.dataSource, - layerName: 'ALPR Cameras', - source: - source.attribution?.description || source.label || 'Camera source', - label: msg('ALPR camera'), - latitude: record.latitude, - longitude: record.longitude, - properties: { - operator: record.operator, - manufacturer: record.manufacturer, - cameraType: record.cameraType, - zone: record.zone, - directionDeg: record.directionDeg, - ref: record.ref, - lastVerified: record.lastVerified, - osmId: record.osmId, - sourceTag: record.source, - }, }); } native.dataSource.entities.resumeEvents(); diff --git a/src/layers/alpr/render/navara.js b/src/layers/alpr/render/navara.js index 534413527..3e277e937 100644 --- a/src/layers/alpr/render/navara.js +++ b/src/layers/alpr/render/navara.js @@ -1,21 +1,23 @@ -import { msg } from '../../../i18n/index.js'; import * as geo from '../../../geo/index.js'; import { distanceScale } from '../../../globe/cpuPicks.js'; import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; import { resolvePickId } from '../../../data/pickRegistry.js'; -import { boxContains, alprCreditMarkup } from '../model.js'; +import { alprCreditMarkup } from '../model.js'; +import { + alprEntityContext, + alprFocusBox, + alprLabelModel, + alprViewRecords, +} from '../presentation.js'; import { createAlprFloorResolver } from '../floors.js'; import { createAlprNavaraOverlay } from './navaraOverlay.js'; import { MARKER_IMAGE, SELECTED_IMAGE, - alprDisplayId, - alprLabelDetails, validAlprGroundHeight, } from '../visuals.js'; import { LAYER_ID, - MAX_VIEWPORT_DEGREES, MAX_RENDERED, ALPR_COLOR, ALPR_SELECTED_COLOR, @@ -80,58 +82,12 @@ export function createAlprNavaraRenderer(engine, { state, services, source }) { if (!width || !height) return null; const focus = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 }); if (!focus) return null; - const location = geo.cartographic.fromCartesian(focus); - const radius = Math.max( - 1000, - 2 * geo.cartesian3.distance(engine.camera.livePosition, focus), + return alprFocusBox( + geo.cartographic.fromCartesian(focus), + geo.cartesian3.distance(engine.camera.livePosition, focus), ); - const latitude = geo.math.toDegrees(location.latitude); - const longitude = geo.math.toDegrees(location.longitude); - const latSpan = radius / 111000; - const lonSpan = latSpan / Math.cos(location.latitude); - if ( - !Number.isFinite(latSpan + lonSpan) || - 2 * Math.max(latSpan, lonSpan) > MAX_VIEWPORT_DEGREES || - Math.abs(latitude) + latSpan > 90 || - Math.abs(longitude) + lonSpan > 180 - ) - return null; - return { - south: latitude - latSpan, - west: longitude - lonSpan, - north: latitude + latSpan, - east: longitude + lonSpan, - }; - } - function viewRecords(records) { - const box = viewportBox(); - if (!box) return { visible: [], capped: false }; - let visible = records.filter((r) => - boxContains(box, { - south: r.latitude, - north: r.latitude, - west: r.longitude, - east: r.longitude, - }), - ); - const capped = visible.length > MAX_RENDERED; - if (capped) { - const lat = (box.south + box.north) / 2, - lon = (box.west + box.east) / 2; - const scale = Math.cos((lat * Math.PI) / 180); - visible = visible - .map((record) => ({ - record, - d: - (record.latitude - lat) ** 2 + - ((record.longitude - lon) * scale) ** 2, - })) - .sort((a, b) => a.d - b.d) - .slice(0, MAX_RENDERED) - .map(({ record }) => record); - } - return { visible, capped }; } + const viewRecords = (records) => alprViewRecords(records, viewportBox()); function syncMarker(marker) { if (!initialized) return; const position = @@ -321,41 +277,14 @@ export function createAlprNavaraRenderer(engine, { state, services, source }) { const record = marker.record; marker.gevTrackedId = record.id; marker.gevDisplayPosition = () => marker.displayPosition; - marker.gevLabelModel = { - title: alprDisplayId(record), - details: alprLabelDetails(record, source), + marker.gevLabelModel = alprLabelModel(record, source, { accent: ALPR_COLOR, - cardStyle: 'tactical', - selected: true, - leaderStyle: 'elbow', - leaderAnimationMs: 440, - leaderAnimationStartedAt: - record.id === state.selectedId ? selectionStartedAt : 0, - leaderDrawRatio: 0.68, - anchorRadiusPx: SELECTED_MARKER_ICON_SIZE / 2, - anchorRadiusScale: null, - }; - services.context.registerEntityContext(marker, { - id: record.id, - layerId: LAYER_ID, - layerName: 'ALPR Cameras', - source: - source.attribution?.description || source.label || 'Camera source', - label: msg('ALPR camera'), - latitude: record.latitude, - longitude: record.longitude, - properties: { - operator: record.operator, - manufacturer: record.manufacturer, - cameraType: record.cameraType, - zone: record.zone, - directionDeg: record.directionDeg, - ref: record.ref, - lastVerified: record.lastVerified, - osmId: record.osmId, - sourceTag: record.source, - }, + startedAt: record.id === state.selectedId ? selectionStartedAt : 0, }); + services.context.registerEntityContext( + marker, + alprEntityContext(record, source), + ); } function countOnScreen() { const { width, height } = engine.host.size(); diff --git a/src/layers/alpr/render/navaraOverlay.js b/src/layers/alpr/render/navaraOverlay.js index befe15f6d..e706d0a56 100644 --- a/src/layers/alpr/render/navaraOverlay.js +++ b/src/layers/alpr/render/navaraOverlay.js @@ -1,23 +1,5 @@ import * as geo from '../../../geo/index.js'; -import { - MAX_CANVAS_FRUSTUMS, - MARKER_ICON_SIZE, - SELECTED_MARKER_ICON_SIZE, - MARKER_FLOOR_LIFT_M, - ANCHOR_SAMPLES_PER_PAINT, - SURFACE_BELOW_FLOOR_M, - SURFACE_ABOVE_FLOOR_M, - SURFACE_SAMPLES_PER_PAINT, -} from '../policy.js'; -import { - MARKER_IMAGE, - SELECTED_IMAGE, - BRACKETS_IMAGE, - directionWedgePositions, - markerScale, - paintDirectionWedge, - validAlprGroundHeight, -} from '../visuals.js'; +import { createAlprOverlayCore } from '../overlay.js'; /** Canvas artwork over Navara pick sprites, with bounded surface refinement. */ export function createAlprNavaraOverlay({ @@ -29,396 +11,73 @@ export function createAlprNavaraOverlay({ services, onSurfaceChange = () => {}, }) { - let lane, - removeMapListener, - retryTimer, - retryUntil = 0; - let records = [], - hits = [], - images = [], - destroyed = false, - lastCamera = null; - const requestPaint = () => { - if (!destroyed && state.enabled) lane?.requestPaint(); - }; - - function nativeVisible(entity, visible) { - if (!entity) return; - canvasOwned(entity, !visible); - setShown(entity, visible); - } - - function canvasOwned(marker, owned) { - if (marker.canvasOwned === owned) return; - marker.canvasOwned = owned; - syncMarker(marker); - } - - function setShown(marker, visible) { - if (marker.shown === visible) return; - marker.shown = visible; - syncMarker(marker); - } - - function resetAnchors() { - for (const entity of markers.values()) { - entity.gevAlprCanvasPosition = null; - entity.gevAlprWedge = null; - entity.displayPosition = null; - nativeVisible(entity, true); - } - state.lastAnchorSampleAt = 0; - retryUntil = Date.now() + 15000; - requestPaint(); - } - - function anchorFor(record, entity, budget) { - if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition; - const location = geo.cartographic.fromDegrees( - record.longitude, - record.latitude, - ); - // A floor-placed marker anchors where its native badge sits; only a - // marker still waiting for its floor samples the rendered surface, and - // each paint samples a few at most (Navara raycasts the loaded surface). - let height; - const floor = services.groundFloor.cachedGroundFloor( - record.latitude, - record.longitude, - ); - if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M; - else if (engine.surface.supported) { - if (budget.samples <= 0) return null; - budget.samples -= 1; - try { - height = sampleHeight(location); - } catch { - /* streaming tiles */ - } - } - if (!validAlprGroundHeight(height) && engine.surface.terrainHeightSupported) - height = engine.surface.terrainHeight(location); - if (!validAlprGroundHeight(height)) return null; - setAnchor(record, entity, height); - return entity.gevAlprCanvasPosition; - } - - function floorHeightAt(latitude, longitude) { - const floor = services.groundFloor.cachedGroundFloor(latitude, longitude); - return validAlprGroundHeight(floor) ? floor + MARKER_FLOOR_LIFT_M : null; - } - - function setAnchor(record, entity, height) { - entity.gevAlprCanvasPosition = geo.cartesian3.fromDegrees( - record.longitude, - record.latitude, - height, - ); - entity.resolved = true; - syncMarker(entity); - entity.gevAlprWedge = directionWedgePositions( - record, - height, - floorHeightAt, - ); - } - - /** - * Replace floor anchors with the rendered surface under each overlay camera. - * Floors come from ~111 m cells, so on a slope a badge could float or sink - * by tens of metres. Runs only while the camera is still and tiles have - * settled, a few surface samples per paint, and each anchor at most once; - * samples far outside the floor prior (a roof or a streaming tile) are - * rejected. Returns whether anchors remain to check. - */ - function refineAnchors(budget) { - const camera = engine.camera.pose(); - const still = - lastCamera && - camera.position && - geo.cartesian3.equalsEpsilon( - camera.position, - lastCamera.position, - 0, - 0.05, - ) && - geo.cartesian3.equalsEpsilon( - camera.direction, - lastCamera.direction, - 0, - 1e-6, - ); - if (camera.position && camera.direction) - lastCamera = { - position: geo.cartesian3.clone(camera.position), - direction: geo.cartesian3.clone(camera.direction), - }; - if (!engine.surface.supported) return false; - let pending = false; - for (const record of records) { - const entity = markers.get(record.id); - const anchor = entity?.gevAlprCanvasPosition; - if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue; - if (!still || !engine.surface.tilesLoaded() || budget.samples <= 0) { - pending = true; - continue; - } - budget.samples -= 1; - let sample; - try { - sample = sampleHeight( - geo.cartographic.fromDegrees(record.longitude, record.latitude), + const location = (record) => + geo.cartographic.fromDegrees(record.longitude, record.latitude); + return createAlprOverlayCore({ + state, + services, + onSurfaceChange, + engine: { + marker: (id) => markers.get(id), + markers: () => markers.values(), + canvasOwned(marker, owned) { + if (marker.canvasOwned === owned) return; + marker.canvasOwned = owned; + syncMarker(marker); + }, + setShown(marker, visible) { + if (marker.shown === visible) return; + marker.shown = visible; + syncMarker(marker); + }, + setDisplayPosition(marker, position) { + marker.displayPosition = position; + }, + placeAnchor(marker, record, height) { + marker.gevAlprCanvasPosition = geo.cartesian3.fromDegrees( + record.longitude, + record.latitude, + height, ); - } catch { - /* streaming tiles */ - } - const current = geo.cartographic.fromCartesian(anchor).height; - const floor = services.groundFloor.cachedGroundFloor( - record.latitude, - record.longitude, - ); - const accepted = - validAlprGroundHeight(sample) && - (!validAlprGroundHeight(floor) || - (sample >= floor - SURFACE_BELOW_FLOOR_M && - sample <= floor + SURFACE_ABOVE_FLOOR_M)); - if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1) - setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M); - entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition; - if (entity.id === state.selectedId) - entity.displayPosition = entity.gevAlprCanvasPosition; - } - return pending; - } - - function paint({ - ctx, - width, - height, - keyhole, - viewProjectionMatrix: matrix, - }) { - hits = []; - if (!state.enabled || destroyed) return; - // The host already captured the camera and CSS viewport for this frame. - // Reuse it for every wedge vertex instead of rereading DOM geometry. - const project = ({ x, y, z }) => { - const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; - if (!(w > 0)) return null; - return { - x: - (((matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) / w + - 1) * - width) / - 2, - y: - ((1 - - (matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) / w) * - height) / - 2, - }; - }; - let unresolved = false; - const painted = []; - const budget = { samples: ANCHOR_SAMPLES_PER_PAINT }; - for (const record of records) { - const entity = markers.get(record.id); - if (!entity) continue; - const selected = record.id === state.selectedId; - const image = images[selected ? 1 : 0]; - if ( - !image?.complete || - !image.naturalWidth || - (selected && !images[2]?.naturalWidth) - ) { - nativeVisible(entity, true); - continue; - } - const anchor = anchorFor(record, entity, budget); - if (!anchor) { - nativeVisible(entity, true); - unresolved = true; - continue; - } - canvasOwned(entity, true); - if (!engine.projector.horizonVisible(anchor)) { - setShown(entity, false); - continue; - } - const origin = project(anchor); - if (!origin) { - setShown(entity, false); - continue; - } - const alpha = - services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1; - if (!(alpha > 0)) { - setShown(entity, false); - continue; - } - const wedge = entity.gevAlprWedge; - if (wedge) { - const left = project(wedge[1]); - const right = project(wedge[2]); - if ( - left && - right && - Math.max(origin.x, left.x, right.x) >= -3 && - Math.min(origin.x, left.x, right.x) <= width + 3 && - Math.max(origin.y, left.y, right.y) >= -3 && - Math.min(origin.y, left.y, right.y) <= height + 3 - ) { - ctx.save(); - ctx.globalAlpha *= alpha; - paintDirectionWedge(ctx, origin, left, right, selected); - ctx.restore(); - } - } - if ( - origin.x < -60 || - origin.x > width + 60 || - origin.y < -60 || - origin.y > height + 60 - ) { - setShown(entity, false); - continue; - } - setShown(entity, true); - painted.push({ - record, - origin, - selected, - image, - alpha, - scale: markerScale( - geo.cartesian3.distance(engine.camera.livePosition, anchor), - ), - }); - if (selected) entity.displayPosition = anchor; - } - // Paint all glyphs after the wedges so one camera's cone cannot wash out another. - for (const { record, origin, selected, image, alpha, scale } of painted) { - const size = - (selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale; - ctx.save(); - ctx.globalAlpha *= alpha; - ctx.drawImage( - image, - origin.x - size / 2, - origin.y - size / 2, - size, - size, - ); - if (selected) - ctx.drawImage( - images[2], - origin.x - size / 2, - origin.y - size / 2, - size, - size, - ); - ctx.restore(); - hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 }); - } - const refining = refineAnchors({ - samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT), - }); - if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) { - retryTimer = setTimeout(() => { - retryTimer = null; - requestPaint(); - }, 250); - } - } - - return { - init() { - destroyed = false; - removeMapListener = services.overlays?.subscribeMapStack?.((event) => { - if (event.detail?.status !== 'ready' || !state.enabled) return; - // Same-regime provider changes also invalidate absolute placements. - onSurfaceChange(); - resetAnchors(); - }); - if (!services.overlays?.registerPaintLane || typeof Image === 'undefined') - return; - lane = services.overlays.registerPaintLane('selected', paint, { - id: `alpr-${crypto.randomUUID()}`, - active: false, - }); - images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => { - const image = new Image(); - image.onload = requestPaint; - image.onerror = requestPaint; - image.src = src; - return image; - }); + marker.resolved = true; + syncMarker(marker); + }, + surfaceSupported: () => engine.surface.supported, + // Navara raycasts the loaded surface. + sampleHeight: (record) => sampleHeight(location(record)), + terrainHeight: (record) => + engine.surface.terrainHeightSupported + ? engine.surface.terrainHeight(location(record)) + : undefined, + surfaceSettled: () => engine.surface.tilesLoaded(), + cameraPose: () => engine.camera.pose(), + cameraPosition: () => engine.camera.livePosition, + projector: ({ width, height, viewProjectionMatrix: matrix }) => ({ + horizonVisible: (point) => engine.projector.horizonVisible(point), + // The host already captured the camera and CSS viewport for this + // frame; reuse it for every wedge vertex instead of rereading DOM + // geometry. + project({ x, y, z }) { + const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; + if (!(w > 0)) return null; + return { + x: + (((matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) / + w + + 1) * + width) / + 2, + y: + ((1 - + (matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) / + w) * + height) / + 2, + }; + }, + }), + cullOffscreenWedges: true, + createId: () => crypto.randomUUID(), }, - sync(visible) { - if (!lane) return; - for (const record of records) nativeVisible(markers.get(record.id), true); - const position = engine.camera.livePosition; - records = visible - .filter( - (record) => - Number.isFinite(record.directionDeg) && - record.id !== state.selectedId, - ) - .map((record) => ({ - record, - distance: position - ? geo.cartesian3.distanceSquared( - position, - geo.cartesian3.fromDegrees(record.longitude, record.latitude), - ) - : 0, - })) - .sort((a, b) => a.distance - b.distance) - .slice(0, MAX_CANVAS_FRUSTUMS) - .map((item) => item.record); - const selected = visible.find((record) => record.id === state.selectedId); - if (selected) { - if (records.length >= MAX_CANVAS_FRUSTUMS) records.pop(); - records.push(selected); - } - hits = []; - retryUntil = Date.now() + 15000; - lane.setActive(state.enabled && records.length > 0); - requestPaint(); - }, - pick(position) { - if (!state.enabled || !position) return null; - let closest, - distance = Infinity; - for (const hit of hits) { - const next = (hit.x - position.x) ** 2 + (hit.y - position.y) ** 2; - if (next <= hit.radius ** 2 && next <= distance) { - closest = hit.id; - distance = next; - } - } - return closest || null; - }, - clear() { - clearTimeout(retryTimer); - retryTimer = null; - for (const record of records) nativeVisible(markers.get(record.id), true); - records = []; - hits = []; - lane?.setActive(false); - }, - destroy() { - this.clear(); - destroyed = true; - removeMapListener?.(); - removeMapListener = null; - lane?.unregister(); - lane = null; - for (const image of images) { - image.onload = null; - image.onerror = null; - } - images = []; - }, - }; + }); } diff --git a/src/layers/awareness/render/navaraPulses.js b/src/layers/awareness/render/navaraPulses.js index 50d797eb8..f37f24d1e 100644 --- a/src/layers/awareness/render/navaraPulses.js +++ b/src/layers/awareness/render/navaraPulses.js @@ -4,15 +4,14 @@ // Each surface pixel's distance from the subject along the Earth sets where // it lies within the range, and the same pulse lines, halos and inward band // as Cesium's material are drawn there. Nothing above the surface is touched. -import { Effect, EffectAttribute } from 'postprocessing'; import { Matrix4, Uniform, Vector3, Vector4 } from 'three'; import { groundSurfaceUniforms, updateGroundSurface, } from '../../shared/render/navaraGroundSurface.js'; +import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; const KEY = 'gevAwarenessPulses'; -const registeredViews = new WeakSet(); const MEAN_EARTH_RADIUS_M = 6_371_000; const fragmentShader = /* glsl */ ` @@ -84,82 +83,68 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { } `; -function pulseEffectClass(EffectDesc, NavaraEffect) { - return class AwarenessPulseEffectDesc extends EffectDesc { - static key = KEY; - static insertAfter = [ - 'gevEarthquakeGround', - 'gevObservedWeather', - 'gevNepalEvent', - 'gevRecentImagery', - 'gevFirmsHeat', - 'gevFirmsGround', - 'toneMapping', - ]; - static insertBefore = [ - 'gevPreSharpenOverlay', - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevOverlay', - 'final', - ]; - constructor(view, context, config) { - super(view, context, config); - this.owner = config[KEY].owner; - } - createPass() { - this.effect = new Effect('GevAwarenessPulses', fragmentShader, { - attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map([ - ...groundSurfaceUniforms(), - ['uInverseView', new Uniform(new Matrix4())], - ['uCenter', new Uniform(new Vector3(1, 0, 0))], - ['uRadiusAngle', new Uniform(1)], - ['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; - updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); - const u = this.effect.uniforms; - u.get('uInverseView').value.copy(camera.matrixWorld); - u.get('uCenter').value.copy(owner.center); - u.get('uRadiusAngle').value = owner.radiusAngle; - u.get('uColor').value.fromArray(owner.color); - u.get('uLineColor').value.fromArray(owner.lineColor); - u.get('uHaloColor').value.fromArray(owner.haloColor); - u.get('uRadii').value.fromArray(owner.radii); - u.get('uAlphas').value.fromArray(owner.alphas); - u.get('uDepth').value = owner.depth; - u.get('uLineWidth').value = owner.lineWidth; - u.get('uHaloWidth').value = owner.haloWidth; - // Buffer pixels per CSS pixel: the pass can run above the canvas's - // CSS size, and the pulse widths are in CSS pixels. - const cssWidth = owner.cssWidth(); - u.get('uPixelRatio').value = - cssWidth > 0 - ? this.ctx.getInputBuffer().width / cssWidth - : globalThis.devicePixelRatio || 1; - } - onDestroy() { - this.effect?.dispose(); - super.onDestroy(); - } - }; -} +const pulseEffect = defineGroundEffect({ + key: KEY, + name: 'GevAwarenessPulses', + fragmentShader, + insertAfter: [ + 'gevEarthquakeGround', + 'gevObservedWeather', + 'gevNepalEvent', + 'gevRecentImagery', + 'gevFirmsHeat', + 'gevFirmsGround', + 'toneMapping', + ], + 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)], + ['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() { + const owner = this.owner, + camera = this.view.camera.raw; + if (!this.effect || !camera) return; + updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); + const u = this.effect.uniforms; + u.get('uInverseView').value.copy(camera.matrixWorld); + u.get('uCenter').value.copy(owner.center); + u.get('uRadiusAngle').value = owner.radiusAngle; + u.get('uColor').value.fromArray(owner.color); + u.get('uLineColor').value.fromArray(owner.lineColor); + u.get('uHaloColor').value.fromArray(owner.haloColor); + u.get('uRadii').value.fromArray(owner.radii); + u.get('uAlphas').value.fromArray(owner.alphas); + u.get('uDepth').value = owner.depth; + u.get('uLineWidth').value = owner.lineWidth; + u.get('uHaloWidth').value = owner.haloWidth; + // Buffer pixels per CSS pixel: the pass can run above the canvas's + // CSS size, and the pulse widths are in CSS pixels. + const cssWidth = owner.cssWidth(); + u.get('uPixelRatio').value = + cssWidth > 0 + ? this.ctx.getInputBuffer().width / cssWidth + : globalThis.devicePixelRatio || 1; + }, +}); /** * The pulse disc on one Navara view around `center` (Earth-fixed metres): @@ -172,10 +157,7 @@ export function createAwarenessPulseDisc( { view, EffectDesc, NavaraEffect }, { center, radiusM, color, lineColor, haloColor, depth, lineWidth, haloWidth }, ) { - if (!registeredViews.has(view)) { - view.registerEffect(KEY, pulseEffectClass(EffectDesc, NavaraEffect)); - registeredViews.add(view); - } + pulseEffect.register(view, { EffectDesc, NavaraEffect }); const owner = { cssWidth: () => engine.host?.canvas?.clientWidth ?? 0, center: new Vector3(), diff --git a/src/layers/bikeshare/render/cesium/rendering.js b/src/layers/bikeshare/render/cesium/rendering.js index 6b6100a66..328cef2c8 100644 --- a/src/layers/bikeshare/render/cesium/rendering.js +++ b/src/layers/bikeshare/render/cesium/rendering.js @@ -2,22 +2,12 @@ import * as Cesium from 'cesium'; import { COLOR_NEUTRAL, POINT_HEIGHT_OFFSET_M, - MAX_TOTAL_POINTS, COLOR_OUTLINE, } from '../../policy.js'; -import { - capacityToPixelSize, - resolveCapacity, - statusToColor, -} from '../../presentation.js'; +import { createStationPoints } from '../../stationPoints.js'; -export function createRendering({ - state: layerState, - services, - parts, - source, -}) { - const { governorRequestRender } = services.render; +export function createRendering(context) { + const { state: layerState } = context; /** * Create a Cartesian3 position for a station, terrain-clamped when possible. @@ -44,59 +34,13 @@ export function createRendering({ return Cesium.Cartesian3.fromDegrees(lon, lat, height); } - /** - * Get or create the runtime tracking object for a city. - * @param {string} cityId - City identifier. - * @returns {{ stationKeys: Set }} City runtime object. - */ - - function ensureCityRuntime(cityId) { - if (layerState._cityRuntime.has(cityId)) - return layerState._cityRuntime.get(cityId); - const runtime = { stationKeys: new Set() }; - layerState._cityRuntime.set(cityId, runtime); - return runtime; - } - - /** - * Ensure point primitives exist for all stations in a city. - * Creates new points for stations not yet rendered; skips existing ones. - * Respects the MAX_TOTAL_POINTS global cap to prevent GPU overload. - * @param {string} cityId - City identifier. - * @param {Map} stationMap - Parsed station info map for the city. - */ - - function ensureCityPoints(cityId, stationMap) { - // Deferred debounce/fetch completions mutate point primitives after the - // camera settled — each commit needs one frame in idle mode. (perf wave 2 fix) - governorRequestRender('bikeshare-points'); - const runtime = ensureCityRuntime(cityId); - - for (const station of stationMap.values()) { - const key = parts.model.stationKey(cityId, station.stationId); - if (layerState._stationRenderMap.has(key)) { - runtime.stationKeys.add(key); - continue; - } - - // Enforce global point cap - if (layerState._stationRenderMap.size >= MAX_TOTAL_POINTS) { - if (!layerState._limitWarned) { - layerState._limitWarned = true; - console.warn( - `[Data:Bikeshare] Point cap reached (${MAX_TOTAL_POINTS}).`, - ); - } - break; - } - - const position = createStationPosition(station); - if (!position) continue; - - // Add a point primitive with distance-based scale and translucency falloff - const point = layerState._pointCollection.add({ + return createStationPoints(context, { + position: createStationPosition, + // A point primitive with distance-based scale and translucency falloff + create: (record, { position, pixelSize }) => + layerState._pointCollection.add({ position, - pixelSize: capacityToPixelSize(station.capacity), + pixelSize, color: COLOR_NEUTRAL, outlineColor: COLOR_OUTLINE, outlineWidth: 1, @@ -108,113 +52,8 @@ export function createRendering({ 0.15, ), disableDepthTestDistance: 2500, - id: key, - }); - - layerState._stationRenderMap.set(key, { - key, - cityId, - stationId: station.stationId, - stationName: station.name, - point, - capacity: parts.model.toNonNegativeInteger(station.capacity), - publishedCapacity: parts.model.toNonNegativeInteger(station.capacity), - bikesAvailable: null, - docksAvailable: null, - isInstalled: station.isInstalled, - isRenting: station.isRenting, - isReturning: station.isReturning, - }); - - runtime.stationKeys.add(key); - } - - layerState._count = layerState._stationRenderMap.size; - } - - /** - * Remove all rendered point primitives for a city and clean up runtime state. - * Also clears any active selection if it belongs to this city. - * @param {string} cityId - City identifier to remove. - */ - - function removeCityPoints(cityId) { - const runtime = layerState._cityRuntime.get(cityId); - if (!runtime) return; - - // Clear selection if it belongs to the city being removed - if ( - layerState._selectedKey && - runtime.stationKeys.has(layerState._selectedKey) - ) { - parts.selection._clearSelection(); - } - - for (const key of runtime.stationKeys) { - const record = layerState._stationRenderMap.get(key); - if (!record) continue; - layerState._pointCollection.remove(record.point); - layerState._stationRenderMap.delete(key); - } - - layerState._cityRuntime.delete(cityId); - layerState._count = layerState._stationRenderMap.size; - } - - /** - * Apply real-time status data to rendered station points for a city. - * Updates each point's color (availability ratio) and pixel size (capacity), - * and refreshes the render record's cached availability fields. - * @param {string} cityId - City identifier. - * @param {Map} statusMap - Parsed station status map. - */ - - function applyStatusToPoints(cityId, statusMap) { - governorRequestRender('bikeshare-status'); - const runtime = layerState._cityRuntime.get(cityId); - if (!runtime) return; - - for (const key of runtime.stationKeys) { - const record = layerState._stationRenderMap.get(key); - if (!record) continue; - - const status = statusMap.get(record.stationId) || null; - // Resolve from the published capacity, not last poll's derived value. - const capacity = resolveCapacity( - parts.model, - record.publishedCapacity, - status, - ); - record.capacity = capacity; - - record.bikesAvailable = parts.model.toNonNegativeInteger( - status?.bikesAvailable, - ); - record.docksAvailable = parts.model.toNonNegativeInteger( - status?.docksAvailable, - ); - record.isInstalled = parts.model.normalizeGbfsBool( - status?.isInstalled, - true, - ); - record.isRenting = parts.model.normalizeGbfsBool(status?.isRenting, true); - record.isReturning = parts.model.normalizeGbfsBool( - status?.isReturning, - true, - ); - - // Update visual properties based on current status - record.point.pixelSize = capacityToPixelSize(capacity); - record.point.color = statusToColor(parts.model, status, capacity); - } - parts.selection.refreshSelectedCard(); - } - return { - capacityToPixelSize, - createStationPosition, - ensureCityRuntime, - ensureCityPoints, - removeCityPoints, - applyStatusToPoints, - }; + id: record.key, + }), + remove: (record) => layerState._pointCollection.remove(record.point), + }); } diff --git a/src/layers/bikeshare/render/cesium/selection.js b/src/layers/bikeshare/render/cesium/selection.js index 3c3bdeb8a..b32a93951 100644 --- a/src/layers/bikeshare/render/cesium/selection.js +++ b/src/layers/bikeshare/render/cesium/selection.js @@ -1,9 +1,6 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../../../data/inputOwnership.js'; -import { - buildBikeshareSelectionLabel, - createBikeshareSelectedOverlayEntry, -} from '../../selectionModel.js'; +import { createBikeshareSelectedOverlayEntry } from '../../selectionModel.js'; import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, @@ -140,8 +137,6 @@ export function createSelection({ } return { refreshSelectedCard, - buildSelectionLabel: buildBikeshareSelectionLabel, - createBikeshareSelectedOverlayEntry, _clearSelection, _selectStation, _installClickHandler, diff --git a/src/layers/bikeshare/render/navara.js b/src/layers/bikeshare/render/navara.js index 752d4c96e..8847805dd 100644 --- a/src/layers/bikeshare/render/navara.js +++ b/src/layers/bikeshare/render/navara.js @@ -1,6 +1,7 @@ import * as geo from '../../../geo/index.js'; import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; import { CYAN } from '../../shared/color.js'; +import { viewRectangleCenter } from '../../shared/viewRectangle.js'; import { createViewport } from '../viewport.js'; import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, @@ -10,15 +11,9 @@ import { MAX_TOTAL_POINTS, POINT_HEIGHT_OFFSET_M, } from '../policy.js'; -import { - capacityToPixelSize, - resolveCapacity, - statusToColor, -} from '../presentation.js'; -import { - buildBikeshareSelectionLabel, - createBikeshareSelectedOverlayEntry, -} from '../selectionModel.js'; +import { capacityToPixelSize } from '../presentation.js'; +import { createBikeshareSelectedOverlayEntry } from '../selectionModel.js'; +import { createStationPoints } from '../stationPoints.js'; const LAYER_ID = 'bikeshare'; @@ -36,8 +31,6 @@ function pointSet(engine, { id, capacity, order, depthTest, point }) { } export function createBikeshareNavaraRenderer(engine, context) { - if (!engine?.movers || !engine?.camera?.viewRectangle) - throw new TypeError('Bikeshare requires a Navara globe engine'); const { state, services, parts } = context; const normalSet = pointSet(engine, { id: LAYER_ID, @@ -85,13 +78,8 @@ export function createBikeshareNavaraRenderer(engine, context) { }; const request = (reason) => services.render.governorRequestRender(reason); - const rendering = { - resolveCapacity: (capacity, status) => - resolveCapacity(parts.model, capacity, status), - capacityToPixelSize, - statusToColor: (status, capacity) => - statusToColor(parts.model, status, capacity), - createStationPosition(station) { + const rendering = createStationPoints(context, { + position(station) { const lon = Number(station?.lon); const lat = Number(station?.lat); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; @@ -105,122 +93,22 @@ export function createBikeshareNavaraRenderer(engine, context) { (Number.isFinite(sampled) ? sampled : 0) + POINT_HEIGHT_OFFSET_M; return geo.cartesian3.fromDegrees(lon, lat, height); }, - ensureCityRuntime(cityId) { - let runtime = state._cityRuntime.get(cityId); - if (!runtime) { - runtime = { stationKeys: new Set() }; - state._cityRuntime.set(cityId, runtime); - } - return runtime; + create(record, { position, pixelSize }) { + record.index = state._stationRenderMap.size; + record.point = { position, pixelSize, color: COLOR_NEUTRAL, show: true }; + syncPoint(record); + return record.point; }, - ensureCityPoints(cityId, stationMap) { - request('bikeshare-points'); - const runtime = this.ensureCityRuntime(cityId); - for (const station of stationMap.values()) { - const key = parts.model.stationKey(cityId, station.stationId); - if (state._stationRenderMap.has(key)) { - runtime.stationKeys.add(key); - continue; - } - if (state._stationRenderMap.size >= MAX_TOTAL_POINTS) { - if (!state._limitWarned) { - state._limitWarned = true; - console.warn( - `[Data:Bikeshare] Point cap reached (${MAX_TOTAL_POINTS}).`, - ); - } - break; - } - const position = this.createStationPosition(station); - if (!position) continue; - const record = { - key, - cityId, - stationId: station.stationId, - stationName: station.name, - index: state._stationRenderMap.size, - point: { - position, - pixelSize: capacityToPixelSize(station.capacity), - color: COLOR_NEUTRAL, - show: true, - }, - capacity: parts.model.toNonNegativeInteger(station.capacity), - publishedCapacity: parts.model.toNonNegativeInteger(station.capacity), - bikesAvailable: null, - docksAvailable: null, - isInstalled: station.isInstalled, - isRenting: station.isRenting, - isReturning: station.isReturning, - }; - state._stationRenderMap.set(key, record); - runtime.stationKeys.add(key); - syncPoint(record); - } + afterAdd() { normalSet.setCount(state._stationRenderMap.size); normalSet.commit(); - state._count = state._stationRenderMap.size; }, - removeCityPoints(cityId) { - const runtime = state._cityRuntime.get(cityId); - if (!runtime) return; - if (state._selectedKey && runtime.stationKeys.has(state._selectedKey)) - selection._clearSelection(); - for (const key of runtime.stationKeys) - state._stationRenderMap.delete(key); - state._cityRuntime.delete(cityId); - resetNormalSet(); - }, - applyStatusToPoints(cityId, statusMap) { - request('bikeshare-status'); - const runtime = state._cityRuntime.get(cityId); - if (!runtime) return; - for (const key of runtime.stationKeys) { - const record = state._stationRenderMap.get(key); - if (!record) continue; - const status = statusMap.get(record.stationId) || null; - // Resolve from the published capacity, not last poll's derived value. - record.capacity = resolveCapacity( - parts.model, - record.publishedCapacity, - status, - ); - record.bikesAvailable = parts.model.toNonNegativeInteger( - status?.bikesAvailable, - ); - record.docksAvailable = parts.model.toNonNegativeInteger( - status?.docksAvailable, - ); - record.isInstalled = parts.model.normalizeGbfsBool( - status?.isInstalled, - true, - ); - record.isRenting = parts.model.normalizeGbfsBool( - status?.isRenting, - true, - ); - record.isReturning = parts.model.normalizeGbfsBool( - status?.isReturning, - true, - ); - record.point.pixelSize = capacityToPixelSize(record.capacity); - record.point.color = statusToColor( - parts.model, - status, - record.capacity, - ); - syncPoint(record); - } - normalSet.commit(); - selection.refreshSelectedCard(); - }, - }; + afterRemove: resetNormalSet, + update: syncPoint, + afterUpdate: () => normalSet.commit(), + }); const selection = { - buildSelectionLabel(record) { - return buildBikeshareSelectionLabel(record); - }, - createBikeshareSelectedOverlayEntry, refreshSelectedCard() { const key = state._selectedKey; const record = state._stationRenderMap.get(key); @@ -278,10 +166,6 @@ export function createBikeshareNavaraRenderer(engine, context) { } request('bikeshare-selection'); }, - _installClickHandler() {}, - _onKeyDown(event) { - if (event.key === 'Escape' && state._selectedKey) this._clearSelection(); - }, }; const viewport = createViewport({ @@ -291,12 +175,9 @@ export function createBikeshareNavaraRenderer(engine, context) { getCameraCenterLatLon() { const rect = engine.camera.viewRectangle(); if (rect) { - let east = rect.east; - if (east < rect.west) east += Math.PI * 2; - let longitude = (rect.west + east) / 2; - if (longitude > Math.PI) longitude -= Math.PI * 2; + const { latitude, longitude } = viewRectangleCenter(rect); return { - lat: geo.math.toDegrees((rect.south + rect.north) / 2), + lat: geo.math.toDegrees(latitude), lon: geo.math.toDegrees(longitude), }; } diff --git a/src/layers/bikeshare/selectionModel.js b/src/layers/bikeshare/selectionModel.js index 7e4a003b1..84e36d8d1 100644 --- a/src/layers/bikeshare/selectionModel.js +++ b/src/layers/bikeshare/selectionModel.js @@ -4,7 +4,7 @@ import { BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, } from './policy.js'; -export function buildBikeshareSelectionLabel(record) { +function buildBikeshareSelectionLabel(record) { const stationName = String(record?.stationName || '').trim(); const stationLabel = stationName || diff --git a/src/layers/bikeshare/stationPoints.js b/src/layers/bikeshare/stationPoints.js new file mode 100644 index 000000000..63d68decf --- /dev/null +++ b/src/layers/bikeshare/stationPoints.js @@ -0,0 +1,166 @@ +import { MAX_TOTAL_POINTS } from './policy.js'; +import { + capacityToPixelSize, + resolveCapacity, + statusToColor, +} from './presentation.js'; + +/** + * The station records behind the rendered points, per city, on either + * engine. `points` draws them: + * - `position(station)`: the station's world position, or null; + * - `create(record, { position, pixelSize })`: the record's point; + * - `remove(record)`, `update(record)`: one point removed or restyled; + * - `afterAdd()`, `afterRemove()`, `afterUpdate()`: a batch is done. + * Every member but `position` and `create` is optional. + */ +export function createStationPoints({ state, services, parts }, points) { + /** + * Get or create the runtime tracking object for a city. + * @param {string} cityId - City identifier. + * @returns {{ stationKeys: Set }} City runtime object. + */ + function ensureCityRuntime(cityId) { + if (state._cityRuntime.has(cityId)) return state._cityRuntime.get(cityId); + const runtime = { stationKeys: new Set() }; + state._cityRuntime.set(cityId, runtime); + return runtime; + } + + /** + * Ensure points exist for all stations in a city. + * Creates new points for stations not yet rendered; skips existing ones. + * Respects the MAX_TOTAL_POINTS global cap to prevent GPU overload. + * @param {string} cityId - City identifier. + * @param {Map} stationMap - Parsed station info map for the city. + */ + function ensureCityPoints(cityId, stationMap) { + // Deferred debounce/fetch completions change points after the camera + // settled: each commit needs one frame in idle mode. + services.render.governorRequestRender('bikeshare-points'); + const runtime = ensureCityRuntime(cityId); + + for (const station of stationMap.values()) { + const key = parts.model.stationKey(cityId, station.stationId); + if (state._stationRenderMap.has(key)) { + runtime.stationKeys.add(key); + continue; + } + + // Enforce global point cap + if (state._stationRenderMap.size >= MAX_TOTAL_POINTS) { + if (!state._limitWarned) { + state._limitWarned = true; + console.warn( + `[Data:Bikeshare] Point cap reached (${MAX_TOTAL_POINTS}).`, + ); + } + break; + } + + const position = points.position(station); + if (!position) continue; + + const record = { + key, + cityId, + stationId: station.stationId, + stationName: station.name, + point: null, + capacity: parts.model.toNonNegativeInteger(station.capacity), + publishedCapacity: parts.model.toNonNegativeInteger(station.capacity), + bikesAvailable: null, + docksAvailable: null, + isInstalled: station.isInstalled, + isRenting: station.isRenting, + isReturning: station.isReturning, + }; + record.point = points.create(record, { + position, + pixelSize: capacityToPixelSize(station.capacity), + }); + state._stationRenderMap.set(key, record); + runtime.stationKeys.add(key); + } + + points.afterAdd?.(); + state._count = state._stationRenderMap.size; + } + + /** + * Remove all rendered points for a city and clean up runtime state. + * Also clears any active selection if it belongs to this city. + * @param {string} cityId - City identifier to remove. + */ + function removeCityPoints(cityId) { + const runtime = state._cityRuntime.get(cityId); + if (!runtime) return; + + // Clear selection if it belongs to the city being removed + if (state._selectedKey && runtime.stationKeys.has(state._selectedKey)) + parts.selection._clearSelection(); + + for (const key of runtime.stationKeys) { + const record = state._stationRenderMap.get(key); + if (!record) continue; + points.remove?.(record); + state._stationRenderMap.delete(key); + } + + state._cityRuntime.delete(cityId); + points.afterRemove?.(); + state._count = state._stationRenderMap.size; + } + + /** + * Apply real-time status data to rendered station points for a city. + * Updates each point's color (availability ratio) and pixel size (capacity), + * and refreshes the render record's cached availability fields. + * @param {string} cityId - City identifier. + * @param {Map} statusMap - Parsed station status map. + */ + function applyStatusToPoints(cityId, statusMap) { + services.render.governorRequestRender('bikeshare-status'); + const runtime = state._cityRuntime.get(cityId); + if (!runtime) return; + + for (const key of runtime.stationKeys) { + const record = state._stationRenderMap.get(key); + if (!record) continue; + + const status = statusMap.get(record.stationId) || null; + // Resolve from the published capacity, not last poll's derived value. + const capacity = resolveCapacity( + parts.model, + record.publishedCapacity, + status, + ); + record.capacity = capacity; + + record.bikesAvailable = parts.model.toNonNegativeInteger( + status?.bikesAvailable, + ); + record.docksAvailable = parts.model.toNonNegativeInteger( + status?.docksAvailable, + ); + record.isInstalled = parts.model.normalizeGbfsBool( + status?.isInstalled, + true, + ); + record.isRenting = parts.model.normalizeGbfsBool(status?.isRenting, true); + record.isReturning = parts.model.normalizeGbfsBool( + status?.isReturning, + true, + ); + + // Update visual properties based on current status + record.point.pixelSize = capacityToPixelSize(capacity); + record.point.color = statusToColor(parts.model, status, capacity); + points.update?.(record); + } + points.afterUpdate?.(); + parts.selection.refreshSelectedCard(); + } + + return { ensureCityPoints, removeCityPoints, applyStatusToPoints }; +} diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 2c47dc762..e76f426ff 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -126,7 +126,7 @@ export function createLifecycle({ layerState._lastAppliedRegime = null; } function bindScene(viewer, options) { - // Task 5: track the surface regime the initial geometry was computed for + // Track the surface regime the initial geometry was computed for // and listen for map-stack changes (main.js re-dispatches // MapStackController.onChange as this CustomEvent). The handler compares // regimes itself, so 'switching'/'error' emissions and same-regime stack @@ -140,7 +140,7 @@ export function createLifecycle({ ); } - // Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with + // Horizon-cull on camera settle (pairs with // the billboards' always-on-top depth setting) + one initial pass so the // first paint is already culled. if (!layerState._horizonCullListener) { @@ -187,7 +187,7 @@ export function createLifecycle({ // remain eligible for true empty-space deselection. const pickedId = resolvePickId(picked); if (pickedId !== null) return; - // Item A (owner round 2): the scene pick found no camera — try the + // The scene pick found no camera — try the // painted ambient cards. The cards canvas is pointer-events:none (this // handler owns the events), so a click landing on a card's rect selects // its camera exactly like a click on the icon. Cesium click positions @@ -309,7 +309,7 @@ export function createLifecycle({ parts.model.ensureCameraPose(camera); } - // Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth + // Batch ALL camera coords through the Re:Earth // ellipsoidal ground-prior resolver (network-cached — NOT a scene query; // the catalog's orthometric groundElevationM feeds the geoid fallback // chain). Bounded wait: a warm proxy cache resolves in milliseconds, so @@ -352,7 +352,7 @@ export function createLifecycle({ billboard, coverageEntities: [], projection: null, - // Task 5 (height-datum fix): regime-aware ground resolution state. + // Regime-aware ground resolution state. // groundPrior — { ellipsoid, source } from the Re:Earth batch // (null until a late batch lands). The prior applies in EVERY // regime and is the terrain-globe resolution outright. @@ -397,7 +397,7 @@ export function createLifecycle({ layerState._activeCameraId = layerState._records[0].camera.id; } - // Task 5: if the prior batch lost init's bounded race, apply it post-hoc + // If the prior batch lost init's bounded race, apply it post-hoc // when it lands (pure recomputes — applyLateGroundPriors guards against // a torn-down/re-inited catalog). if (!priors) { diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index 073db2456..835ef1a23 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { msg, t } from '../../i18n/index.js'; import { createZonesCesiumRenderer } from '../shared/render/cesiumZones.js'; @@ -12,11 +11,6 @@ export { normalizeDroneAirspaceSnapshot } from './records.js'; export { createDroneAirspaceSource } from './source.js'; export * from './cards.js'; -/** Fill/line color for a zone by TYPE_CODE. */ -export function droneAirspaceColor(typeCode) { - return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode)); -} - /** The zones on a Cesium globe (the default renderer). */ export function createDroneAirspaceCesiumRenderer({ viewer, diff --git a/src/layers/earthquakes/render/cesium.js b/src/layers/earthquakes/render/cesium.js index ff8a683cc..66bf9c0f5 100644 --- a/src/layers/earthquakes/render/cesium.js +++ b/src/layers/earthquakes/render/cesium.js @@ -1,5 +1,5 @@ -// Native earthquake entities, moved from the layer without changing its -// static CLAMP_TO_GROUND ellipses, properties or analyst snapshot semantics. +// Native earthquake entities: static CLAMP_TO_GROUND ellipses with the +// layer's properties and analyst snapshot semantics. import * as Cesium from 'cesium'; export function createEarthquakesCesiumRenderer(_engine, { viewer }) { diff --git a/src/layers/earthquakes/render/navara.js b/src/layers/earthquakes/render/navara.js index 3e28ddc82..ab0c752ed 100644 --- a/src/layers/earthquakes/render/navara.js +++ b/src/layers/earthquakes/render/navara.js @@ -1,7 +1,6 @@ // Classify the real visible terrain/Google-tiles surface in a public // Navara post-effect. Native ground colour blends in display space; doing // it explicitly preserves translucent colours and overlap order. -import { Effect, EffectAttribute } from 'postprocessing'; import { Uniform } from 'three'; import { createGroundTexture as texture, @@ -9,6 +8,7 @@ import { groundSurfaceUniforms, updateGroundSurface, } from '../../shared/render/navaraGroundSurface.js'; +import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; import * as geo from '../../../geo/index.js'; import { DISC_EDGES, @@ -18,7 +18,6 @@ import { } from './navaraGeometry.js'; import { createDiscGrid, GRID_COLS, GRID_ROWS } from './navaraGrid.js'; const KEY = 'gevEarthquakeGround'; -const registeredViews = new WeakSet(); const fragmentShader = /* glsl */ ` uniform sampler2D tAllDepth; uniform sampler2D tSurfaceDepth; @@ -196,81 +195,61 @@ function buildState(discs) { return state; } -function groundEffectClass(EffectDesc, NavaraEffect) { - return class GroundEffectDesc extends EffectDesc { - static key = KEY; - static insertAfter = [ - 'gevObservedWeather', - 'gevNepalEvent', - 'gevRecentImagery', - 'toneMapping', +const groundEffect = defineGroundEffect({ + key: KEY, + name: 'GevEarthquakeGround', + fragmentShader, + insertAfter: [ + 'gevObservedWeather', + 'gevNepalEvent', + 'gevRecentImagery', + 'toneMapping', + ], + insertBefore: [ + 'gevFirmsGround', + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ], + uniforms: () => [ + ...groundSurfaceUniforms(), + ['tEdges', new Uniform(null)], + ['tFrames', new Uniform(null)], + ['tGrid', new Uniform(null)], + ['tCandidates', new Uniform(null)], + ['uEdgeSize', new Uniform([1, 1])], + ['uFrameSize', new Uniform([1, 1])], + ['uCandidateSize', new Uniform([1, 1])], + ], + update() { + const state = this.owner.state, + camera = this.view.camera.raw, + mrt = this.find('mrt'); + if (!this.effect || !camera || !state) return; + updateGroundSurface(this.effect, camera, this.ctx, mrt); + state.update(camera, this.ctx); + const u = this.effect.uniforms; + u.get('tEdges').value = state.edges.texture; + u.get('uEdgeSize').value = [state.edges.width, state.edges.height]; + u.get('tFrames').value = state.packed.texture; + u.get('uFrameSize').value = [state.packed.width, state.packed.height]; + u.get('tGrid').value = state.gridTexture; + u.get('tCandidates').value = state.candidates.texture; + u.get('uCandidateSize').value = [ + state.candidates.width, + state.candidates.height, ]; - static insertBefore = [ - 'gevFirmsGround', - 'gevPreSharpenOverlay', - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevOverlay', - 'final', - ]; - constructor(view, ctx, config) { - super(view, ctx, config); - this.owner = config[KEY].owner; - } - createPass() { - this.effect = new Effect('GevEarthquakeGround', fragmentShader, { - // Keep a separate pass: inputColor is the already tone-mapped frame. - attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map([ - ...groundSurfaceUniforms(), - ['tEdges', new Uniform(null)], - ['tFrames', new Uniform(null)], - ['tGrid', new Uniform(null)], - ['tCandidates', new Uniform(null)], - ['uEdgeSize', new Uniform([1, 1])], - ['uFrameSize', new Uniform([1, 1])], - ['uCandidateSize', new Uniform([1, 1])], - ]), - }); - return new NavaraEffect(this.view.camera.raw, this.effect); - } - updateFrame() { - const state = this.owner.state, - camera = this.view.camera.raw, - mrt = this.find('mrt'); - if (!this.effect || !camera || !state) return; - updateGroundSurface(this.effect, camera, this.ctx, mrt); - state.update(camera, this.ctx); - const u = this.effect.uniforms; - u.get('tEdges').value = state.edges.texture; - u.get('uEdgeSize').value = [state.edges.width, state.edges.height]; - u.get('tFrames').value = state.packed.texture; - u.get('uFrameSize').value = [state.packed.width, state.packed.height]; - u.get('tGrid').value = state.gridTexture; - u.get('tCandidates').value = state.candidates.texture; - u.get('uCandidateSize').value = [ - state.candidates.width, - state.candidates.height, - ]; - } - onDestroy() { - this.effect?.dispose(); - super.onDestroy(); - } - }; -} + }, +}); export function createEarthquakesNavaraRenderer( engine, { view, EffectDesc, NavaraEffect } = {}, ) { - if (!view?.camera || !engine?.render || !EffectDesc || !NavaraEffect) - throw new TypeError('Earthquakes require a Navara view'); - if (!registeredViews.has(view)) { - view.registerEffect(KEY, groundEffectClass(EffectDesc, NavaraEffect)); - registeredViews.add(view); - } + groundEffect.register(view, { EffectDesc, NavaraEffect }); let destroyed = false, visible = false, rows = [], diff --git a/src/layers/firms/render/navara.js b/src/layers/firms/render/navara.js index 94238bc1f..c495294da 100644 --- a/src/layers/firms/render/navara.js +++ b/src/layers/firms/render/navara.js @@ -9,8 +9,6 @@ export function createFirmsNavaraRenderer( engine, { view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {}, ) { - if (!engine?.movers || !nativeView?.camera || !EffectDesc || !NavaraEffect) - throw new TypeError('FIRMS requires a Navara view'); const display = createGroundCellDisplay( engine, { view: nativeView, EffectDesc, NavaraEffect }, diff --git a/src/layers/firms/render/navaraHeat.js b/src/layers/firms/render/navaraHeat.js index 8cfc0cc79..579c67afc 100644 --- a/src/layers/firms/render/navaraHeat.js +++ b/src/layers/firms/render/navaraHeat.js @@ -3,7 +3,6 @@ // visible terrain and 3D tiles by one non-pickable classification pass. Only // surface pixels take it, so the fire markers above stay untouched. The // adapter has heatImagery.js's interface, so the layer drives both alike. -import { Effect, EffectAttribute } from 'postprocessing'; import { DataTexture, LinearFilter, @@ -18,9 +17,9 @@ import { groundSurfaceUniforms, updateGroundSurface, } from '../../shared/render/navaraGroundSurface.js'; +import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; const KEY = 'gevFirmsHeat'; -const registeredViews = new WeakSet(); // Row 0 of the raster is the north edge and column 0 is 180°W; the texture // keeps that order (no flip), so v = 0 is north. @@ -63,59 +62,45 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { } `; -function heatEffectClass(EffectDesc, NavaraEffect) { - return class FirmsHeatEffectDesc extends EffectDesc { - static key = KEY; - // Under the heat cells' and markers' place in the chain: after the other - // surface drapes, before sharpening, anti-aliasing and overlays. - static insertAfter = [ - 'gevEarthquakeGround', - 'gevObservedWeather', - 'gevNepalEvent', - 'gevRecentImagery', - 'toneMapping', - ]; - static insertBefore = [ - 'gevFirmsGround', - 'gevPreSharpenOverlay', - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevOverlay', - 'final', - ]; - constructor(view, context, config) { - super(view, context, config); - this.owner = config[KEY].owner; - } - createPass() { - this.effect = new Effect('GevFirmsHeat', fragmentShader, { - attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map([ - ...groundSurfaceUniforms(), - ['tHeat', new Uniform(null)], - ['uOpacity', new Uniform(1)], - ['uInverseView', new Uniform(new Matrix4())], - ]), - }); - return new NavaraEffect(this.view.camera.raw, this.effect); - } - updateFrame() { - const owner = this.owner, - camera = this.view.camera.raw; - if (!this.effect || !owner.texture || !camera) return; - updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); - const u = this.effect.uniforms; - u.get('tHeat').value = owner.texture; - u.get('uOpacity').value = owner.opacity; - u.get('uInverseView').value.copy(camera.matrixWorld); - } - onDestroy() { - this.effect?.dispose(); - super.onDestroy(); - } - }; -} +const heatEffect = defineGroundEffect({ + key: KEY, + name: 'GevFirmsHeat', + fragmentShader, + // Under the heat cells' and markers' place in the chain: after the other + // surface drapes, before sharpening, anti-aliasing and overlays. + insertAfter: [ + 'gevEarthquakeGround', + 'gevObservedWeather', + 'gevNepalEvent', + 'gevRecentImagery', + 'toneMapping', + ], + insertBefore: [ + 'gevFirmsGround', + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ], + uniforms: () => [ + ...groundSurfaceUniforms(), + ['tHeat', new Uniform(null)], + ['uOpacity', new Uniform(1)], + ['uInverseView', new Uniform(new Matrix4())], + ], + update() { + const owner = this.owner, + camera = this.view.camera.raw; + if (!this.effect || !owner.texture || !camera) return; + updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); + const u = this.effect.uniforms; + u.get('tHeat').value = owner.texture; + u.get('uOpacity').value = owner.opacity; + u.get('uInverseView').value.copy(camera.matrixWorld); + }, +}); /** * The raster as a texture: linear between pixels, wrapping across 180°. Its @@ -148,10 +133,7 @@ export function createFirmsHeatDrape( engine, { view, EffectDesc, NavaraEffect }, ) { - if (!registeredViews.has(view)) { - view.registerEffect(KEY, heatEffectClass(EffectDesc, NavaraEffect)); - registeredViews.add(view); - } + heatEffect.register(view, { EffectDesc, NavaraEffect }); const owner = { texture: null, opacity: 1 }; let raster = null, show = true, diff --git a/src/layers/firms/render/navaraSprites.js b/src/layers/firms/render/navaraSprites.js index aa9ecffd1..80e3ddb3f 100644 --- a/src/layers/firms/render/navaraSprites.js +++ b/src/layers/firms/render/navaraSprites.js @@ -1,4 +1,6 @@ // The real FIRMS PNGs in one engine mover atlas. +import { packImageAtlas } from '../../shared/render/imageAtlas.js'; + const WHITE = Object.freeze([1, 1, 1, 1]); const CELL = 60, PAD = 2, @@ -61,33 +63,22 @@ export function createFirmsSpriteBank( } }; function buildAtlas() { - const entries = [...loaded], - side = Math.ceil(Math.sqrt(entries.length)); - const canvas = createCanvas(); - canvas.width = side * CELL; - canvas.height = Math.ceil(entries.length / side) * CELL; - const context = canvas.getContext('2d'); - if (!context) throw new Error('FIRMS atlas requires a canvas'); - const cells = {}, - lookup = new Map(); - entries.forEach(([url, image], index) => { + const lookup = new Map(); + const entries = [...loaded].map(([url, image], index) => { const width = image.naturalWidth ?? image.width, height = image.naturalHeight ?? image.height; if (!(width > 0 && width <= BASE_SIZE && height === width)) throw new Error('FIRMS sprite dimensions are invalid'); - const x = (index % side) * CELL + PAD, - y = Math.floor(index / side) * CELL + PAD; - context.drawImage(image, x, y); const key = String(index); - cells[key] = [ - x / canvas.width, - y / canvas.height, - (x + width) / canvas.width, - (y + height) / canvas.height, - ]; lookup.set(url, { key, scale: width / BASE_SIZE }); + return { key, image, width, height }; }); - return { canvas, cells, lookup }; + const atlas = packImageAtlas(entries, { + canvas: createCanvas(), + cell: CELL, + pad: PAD, + }); + return { ...atlas, lookup }; } async function flush() { if (destroyed) return; diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 56a157c65..6671e8451 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -115,7 +115,7 @@ export function createLifecycle({ const renderer = flightState._renderer; flightState.enabled = true; renderer?.fleet.setVisible(true); - if (renderer) holdContinuousRender('flights'); // per-frame animator (perf wave 2) + if (renderer) holdContinuousRender('flights'); // per-frame animator renderer?.models.setVisible(true); parts.tracking._setCockpitContactMode( globalThis.document?.body.classList.contains('cockpit-mode') === true, diff --git a/src/layers/flights/render/navara.js b/src/layers/flights/render/navara.js index 5c9294814..0183eea96 100644 --- a/src/layers/flights/render/navara.js +++ b/src/layers/flights/render/navara.js @@ -1,22 +1,11 @@ // The civil flights layer's renderer on the Navara engine: the shared -// aircraft renderer (src/layers/aircraft/render/navara.js) with the flights' -// sprite slot (drawn over the other contact layers, as data/spriteOrder -// orders them on Cesium) and airplane.glb's nose offset. Registered for -// 'navara' by the Navara mode after its import() (src/app/navaraMode.js). +// aircraft renderer with airplane.glb's nose offset. Registered for 'navara' +// by the Navara mode after its import() (src/app/navaraMode.js). -import { createAircraftNavaraRenderer } from '../../aircraft/render/navara.js'; -import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; +import { aircraftLayerNavaraRenderer } from '../../aircraft/render/navara.js'; import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; -/** - * Registry factory (src/app/layers/renderers.js): `(engine, options)`. - * @param {import('../../../globe/contract.js').GlobeEngine} engine - */ -export function createFlightsNavaraRenderer(engine) { - return createAircraftNavaraRenderer({ - engine, - layerId: 'flights', - order: SPRITE_LAYER_ORDER.indexOf('flights'), - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, - }); -} +export const createFlightsNavaraRenderer = aircraftLayerNavaraRenderer( + 'flights', + MODEL_HEADING_OFFSET_DEG, +); diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs index 32f3f4780..f2d74e16d 100644 --- a/src/layers/flights/renderer.test.mjs +++ b/src/layers/flights/renderer.test.mjs @@ -1,8 +1,7 @@ -// The flights logic draws only through its renderer (phase 3 Task 10a): with -// a renderer that is plain data (no Cesium), the layer builds its fleet, -// selects a contact on a click through the engine's selection dispatcher, -// tracks and follows it, and releases it on an empty click. This is the seam -// the Navara renderer (Task 10b) plugs into. +// The flights logic draws only through its renderer: with a renderer that +// is plain data (no Cesium), the layer builds its fleet, selects a contact +// on a click through the engine's selection dispatcher, tracks and follows +// it, and releases it on an empty click. import test from 'node:test'; import assert from 'node:assert/strict'; @@ -12,7 +11,6 @@ import { createApplicationFlights } from '../../app/layers/flights.js'; import { defaultSurface } from '../../data/surfaceServices.js'; import * as militaryRegistry from '../../data/militaryRegistry.js'; import { openSkySnapshot } from '../../sources/live/aircraft.js'; -import { missingAircraftRendererMembers } from '../aircraft/render/contract.js'; import { Rgba } from '../shared/color.js'; import { recordingAircraftRenderer as recordingRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs'; @@ -78,7 +76,6 @@ test('the flights logic draws, selects and follows only through its renderer', a }, }); layer.init(null, { engine }); - assert.deepEqual(missingAircraftRendererMembers(renderer), []); layer.enable(); try { await layer.update(null); diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index e7cb9b186..f7bffaf3f 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -32,7 +32,7 @@ export function createFlightSnapshotRenderer({ // Reclassify if the category resolved/changed (first extended poll); // a ground flip (landing/takeoff) re-scales the SAME billboard in // place — the transition is a restyle, never a removal. - // Round 5: depth policy is uniform (always depth-test-free, see + // The depth policy is uniform (always depth-test-free, see // _groundDepthDistance) — nothing to flip on landing/takeoff. // Position AND rotation are owned by the fleet pass (_fleetTick); // course changes land on the next rotation pass (forced below). diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index a58feeabe..2327fe02b 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -399,7 +399,7 @@ export function createTracking({ // 12 Hz icon instead of lagging ~1 s behind it. if (!flightState._trailHeadEntity && flightState._renderer) { flightState._trailHeadEntity = flightState._renderer.trails.createHead({ - // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick + // 'gev-trail' namespace: claimed by trailRenderer's pick // owner so a click on the head segment never reads as empty space. id: `gev-trail:fl-head-${++flightState._trailHeadSeq}`, show: !flightState._cockpitContactMode, @@ -441,9 +441,9 @@ export function createTracking({ return [from, geo.cartesian3.clone(head)]; }, width: 2.5, - // Round 4: the head must never vanish into the mesh either (dimmed - // when occluded so depth still reads); round 8: geodesic, consistent - // with the trail body (no chords). + // The head must never vanish into the mesh either (dimmed when + // occluded so depth still reads); geodesic, consistent with the + // trail body (no chords). color: TRAIL_COLOR, }); } diff --git a/src/layers/gnss/render/navara.js b/src/layers/gnss/render/navara.js index 1ed85349e..7e5d3bc20 100644 --- a/src/layers/gnss/render/navara.js +++ b/src/layers/gnss/render/navara.js @@ -1,6 +1,7 @@ import * as geo from '../../../geo/index.js'; import { GNSS_LEVEL_COLORS } from '../records.js'; import { rgbaFromCss } from '../../shared/color.js'; +import { viewRectangleCenter } from '../../shared/viewRectangle.js'; import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js'; export function createGnssNavaraRenderer(engine, options) { @@ -13,15 +14,7 @@ export function createGnssNavaraRenderer(engine, options) { let position = centre && geo.cartographic.fromCartesian(centre); if (!position) { const rect = camera.viewRectangle(); - if (rect) { - const east = - rect.east < rect.west ? rect.east + 2 * Math.PI : rect.east; - const longitude = (rect.west + east) / 2; - position = { - latitude: (rect.south + rect.north) / 2, - longitude: longitude > Math.PI ? longitude - 2 * Math.PI : longitude, - }; - } else position = camera.pose().cartographic; + position = rect ? viewRectangleCenter(rect) : camera.pose().cartographic; } return { latitude: geo.math.toDegrees(position.latitude), diff --git a/src/layers/installations/render/navara.js b/src/layers/installations/render/navara.js index a6a82aa02..dd61762d4 100644 --- a/src/layers/installations/render/navara.js +++ b/src/layers/installations/render/navara.js @@ -1,5 +1,4 @@ import { t } from '../../../i18n/index.js'; -import { Effect, EffectAttribute } from 'postprocessing'; import * as geo from '../../../geo/index.js'; import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; import { createInfrastructureOverlayEntry } from '../../../data/infrastructureOverlayEntry.js'; @@ -14,8 +13,8 @@ import { createInstallationOutlineDrapedMesh, } from './navaraGeometry.js'; import { batchInstallationOutlines } from './navaraOutlineBatch.js'; +import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; -const registeredViews = new WeakSet(); const KEY = 'gevInstallationsDrape'; const BLACK = [0, 0, 0, 0.8]; const IDLE_SHADER = /* glsl */ ` @@ -24,46 +23,30 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { } `; -function createDrapeEffectClass(EffectDesc, NavaraEffect) { - return class InstallationDrapeDesc extends EffectDesc { - static key = KEY; - static insertAfter = ['mrt']; - - constructor(view, ctx, config) { - super(view, ctx, config); - this.owner = config[KEY].owner; +const drapeEffect = defineGroundEffect({ + key: KEY, + name: 'GevInstallationDrape', + fragmentShader: IDLE_SHADER, + insertAfter: ['mrt'], + update() { + const mrt = this.find('mrt'); + this.owner.globeNormal.value = mrt?.globeNormalBuffer ?? null; + this.owner.globeDepth.value = mrt?.globeDepthBuffer ?? null; + const scene = this.ctx.scenes.draped; + this.owner.scene = scene; + for (const mesh of this.owner.meshes) + if (mesh.parent !== scene) scene.add(mesh); + }, + destroy() { + for (const mesh of this.owner.meshes) { + if (mesh.parent) mesh.parent.remove(mesh); + mesh.geometry.dispose(); + mesh.material.dispose(); } - - createPass() { - this.effect = new Effect('GevInstallationDrape', IDLE_SHADER, { - attributes: EffectAttribute.CONVOLUTION, - }); - return new NavaraEffect(this.view.camera.raw, this.effect); - } - - updateFrame() { - const mrt = this.find('mrt'); - this.owner.globeNormal.value = mrt?.globeNormalBuffer ?? null; - this.owner.globeDepth.value = mrt?.globeDepthBuffer ?? null; - const scene = this.ctx.scenes.draped; - this.owner.scene = scene; - for (const mesh of this.owner.meshes) - if (mesh.parent !== scene) scene.add(mesh); - } - - onDestroy() { - for (const mesh of this.owner.meshes) { - if (mesh.parent) mesh.parent.remove(mesh); - mesh.geometry.dispose(); - mesh.material.dispose(); - } - this.owner.meshes.clear(); - this.owner.scene = null; - this.effect?.dispose(); - super.onDestroy(); - } - }; -} + this.owner.meshes.clear(); + this.owner.scene = null; + }, +}); function createPointSet(engine, { id, sizePx, capacity, order }) { return engine.movers.createSet({ @@ -92,19 +75,7 @@ export function createInstallationsNavaraRenderer( setupMaterialForDrape, }, ) { - if ( - !engine?.movers || - !view?.registerEffect || - !EffectDesc || - !NavaraEffect || - !DrapedMesh || - !setupMaterialForDrape - ) - throw new TypeError('Installations require a Navara view'); - if (!registeredViews.has(view)) { - view.registerEffect(KEY, createDrapeEffectClass(EffectDesc, NavaraEffect)); - registeredViews.add(view); - } + drapeEffect.register(view, { EffectDesc, NavaraEffect }); const layerState = state; const owner = { diff --git a/src/layers/launches/render/navara.js b/src/layers/launches/render/navara.js index edbaf4126..5660e4ba4 100644 --- a/src/layers/launches/render/navara.js +++ b/src/layers/launches/render/navara.js @@ -1,6 +1,6 @@ // Mission points, paths and the pad zone on Navara. import * as geo from '../../../geo/index.js'; -import { createMoverBank } from '../../aircraft/render/navara.js'; +import { createMoverBank } from '../../shared/render/navaraMoverBank.js'; import { createEngineLayerView } from '../../shared/render/engineView.js'; import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js'; import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; diff --git a/src/layers/launches/render/navaraPadGround.js b/src/layers/launches/render/navaraPadGround.js index 1f5c59c50..e6b28a8f8 100644 --- a/src/layers/launches/render/navaraPadGround.js +++ b/src/layers/launches/render/navaraPadGround.js @@ -5,24 +5,21 @@ // the zone's radius of the pad, with Cesium's fill, rim and emission. Nothing // is drawn at a height, so the zone neither floats over nor sinks under the // ground, and no height is sampled. -import { Effect, EffectAttribute } from 'postprocessing'; import { Matrix3, Uniform, Vector3 } from 'three'; import * as geo from '../../../geo/index.js'; import { groundSurfaceUniforms, updateGroundSurface, } from '../../shared/render/navaraGroundSurface.js'; +import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; +import { rgbaFromCss } from '../../shared/color.js'; import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js'; export const PAD_GROUND_KEY = 'gevLaunchPadGround'; // The surface above or below the pad that the zone still classifies, as // Cesium's classification volume spans the terrain heights there. -export const PAD_GROUND_BAND_M = 9000; -const registered = new WeakSet(); +const PAD_GROUND_BAND_M = 9000; const glslFloat = (value) => Number(value).toFixed(4); -/** A #rrggbb colour as display (sRGB) values, as Cesium's material reads it. */ -const displayRgb = (hex) => - [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255); export const padGroundShader = /* glsl */ ` uniform sampler2D tAllDepth; @@ -66,7 +63,7 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { `; /** The pad's ECEF position and its east, north and up axes. */ -export function padFrame(launch) { +function padFrame(launch) { const m = geo.transforms.eastNorthUpToFixedFrame( geo.cartesian3.fromDegrees(launch.lon, launch.lat, 0), geo.ellipsoid.WGS84, @@ -79,72 +76,59 @@ export function padFrame(launch) { }; } -function padGroundClass(EffectDesc, NavaraEffect) { - return class LaunchPadGroundDesc extends EffectDesc { - static key = PAD_GROUND_KEY; - static insertAfter = [ - 'gevEarthquakeGround', - 'gevObservedWeather', - 'gevNepalEvent', - 'gevRecentImagery', - 'gevFirmsGround', - 'toneMapping', - ]; - static insertBefore = [ - 'gevPreSharpenOverlay', - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevOverlay', - 'final', - ]; - constructor(view, context, config) { - super(view, context, config); - this.owner = config[PAD_GROUND_KEY].owner; - } - createPass() { - this.effect = new Effect('GevLaunchPadGround', padGroundShader, { - attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map([ - ...groundSurfaceUniforms(), - ['uViewToWorld', new Uniform(new Matrix3())], - ['uPadOffset', new Uniform(new Vector3())], - ['uEast', new Uniform(new Vector3())], - ['uNorth', new Uniform(new Vector3())], - ['uUp', new Uniform(new Vector3())], - [ - 'uPadColor', - new Uniform( - new Vector3(...displayRgb(LAUNCH_PAD_ZONE_STYLE.color)), - ), - ], - ]), - }); - return new NavaraEffect(this.view.camera.raw, this.effect); - } - updateFrame() { - const frame = this.owner.frame, - camera = this.view.camera.raw; - if (!this.effect || !frame || !camera) return; - updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); - const u = this.effect.uniforms; - u.get('uViewToWorld').value.setFromMatrix4(camera.matrixWorld); - const e = camera.matrixWorld.elements; - u.get('uPadOffset').value.set( - frame.origin[0] - e[12], - frame.origin[1] - e[13], - frame.origin[2] - e[14], - ); - u.get('uEast').value.fromArray(frame.east); - u.get('uNorth').value.fromArray(frame.north); - u.get('uUp').value.fromArray(frame.up); - } - onDestroy() { - this.effect?.dispose(); - super.onDestroy(); - } - }; -} +const padGroundEffect = defineGroundEffect({ + key: PAD_GROUND_KEY, + name: 'GevLaunchPadGround', + fragmentShader: padGroundShader, + insertAfter: [ + 'gevEarthquakeGround', + 'gevObservedWeather', + 'gevNepalEvent', + 'gevRecentImagery', + 'gevFirmsGround', + 'toneMapping', + ], + insertBefore: [ + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ], + uniforms: () => [ + ...groundSurfaceUniforms(), + ['uViewToWorld', new Uniform(new Matrix3())], + ['uPadOffset', new Uniform(new Vector3())], + ['uEast', new Uniform(new Vector3())], + ['uNorth', new Uniform(new Vector3())], + ['uUp', new Uniform(new Vector3())], + [ + 'uPadColor', + // Display (sRGB) values, as Cesium's material reads the colour. + new Uniform( + new Vector3(...rgbaFromCss(LAUNCH_PAD_ZONE_STYLE.color).rgba()), + ), + ], + ], + update() { + const frame = this.owner.frame, + camera = this.view.camera.raw; + if (!this.effect || !frame || !camera) return; + updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); + const u = this.effect.uniforms; + u.get('uViewToWorld').value.setFromMatrix4(camera.matrixWorld); + const e = camera.matrixWorld.elements; + u.get('uPadOffset').value.set( + frame.origin[0] - e[12], + frame.origin[1] - e[13], + frame.origin[2] - e[14], + ); + u.get('uEast').value.fromArray(frame.east); + u.get('uNorth').value.fromArray(frame.north); + u.get('uUp').value.fromArray(frame.up); + }, +}); /** * Show one pad zone on a Navara view; `show` toggles it, `destroy` removes it. @@ -153,13 +137,7 @@ function padGroundClass(EffectDesc, NavaraEffect) { * @param {{ lon: number, lat: number }} launch */ export function createPadGround(view, { EffectDesc, NavaraEffect }, launch) { - if (!registered.has(view)) { - view.registerEffect( - PAD_GROUND_KEY, - padGroundClass(EffectDesc, NavaraEffect), - ); - registered.add(view); - } + padGroundEffect.register(view, { EffectDesc, NavaraEffect }); const owner = { frame: padFrame(launch) }; let handle = view.addEffect({ [PAD_GROUND_KEY]: { owner }, diff --git a/src/layers/localAdsb/render/cesium.js b/src/layers/localAdsb/render/cesium.js index 6fcc0ff99..1fa81ffae 100644 --- a/src/layers/localAdsb/render/cesium.js +++ b/src/layers/localAdsb/render/cesium.js @@ -1,5 +1,4 @@ -// The Local ADS-B layer's renderer on Cesium (phase 3 Task 12a): the Cesium -// objects the layer used to build itself, moved here one for one — its +// The Local ADS-B layer's renderer on Cesium: its // CustomDataSource of marker entities (billboard with per-frame position, // rotation and visibility callbacks, a 2,000 km display distance, the 978 MHz // UAT ring as a point graphic), its model PrimitiveCollection (models load, diff --git a/src/layers/localAdsb/render/navara.js b/src/layers/localAdsb/render/navara.js index 10e0363c2..8330199cf 100644 --- a/src/layers/localAdsb/render/navara.js +++ b/src/layers/localAdsb/render/navara.js @@ -1,4 +1,4 @@ -// The Local ADS-B layer's renderer on the Navara engine (phase 3 Task 12b), +// The Local ADS-B layer's renderer on the Navara engine, // with render/cesium.js's interface: the shared aircraft renderer // (src/layers/aircraft/render/navara.js) draws the magenta markers (its // fleet glyphs), the Flights models and the trail; the markers' per-frame @@ -9,10 +9,8 @@ // (src/app/navaraMode.js). The camera never follows a local aircraft. import * as geo from '../../../geo/index.js'; -import { - createAircraftNavaraRenderer, - createMoverBank, -} from '../../aircraft/render/navara.js'; +import { createAircraftNavaraRenderer } from '../../aircraft/render/navara.js'; +import { createMoverBank } from '../../shared/render/navaraMoverBank.js'; import { MODEL_HEADING_OFFSET_DEG } from '../../flights/policy.js'; import { nearFarScale, rgbaFromCss } from '../../shared/color.js'; import { @@ -37,7 +35,7 @@ const RING_TOTAL_PX = RING_PIXEL_SIZE + 2 * RING_OUTLINE_PX; const MARKER_ORDER = -1; /** The ring's atlas: a white ring on a 64 px canvas (tinted per instance). */ -export function drawUatRingAtlas(size = 64) { +function drawUatRingAtlas(size = 64) { const canvas = globalThis.document.createElement('canvas'); canvas.width = size; canvas.height = size; diff --git a/src/layers/localAdsb/renderer.test.mjs b/src/layers/localAdsb/renderer.test.mjs index ab469f7ef..64dcffa2e 100644 --- a/src/layers/localAdsb/renderer.test.mjs +++ b/src/layers/localAdsb/renderer.test.mjs @@ -1,4 +1,4 @@ -// The Local ADS-B logic on its Navara renderer (phase 3 Task 12b) over a +// The Local ADS-B logic on its Navara renderer over a // fake engine: magenta markers in the shared aircraft renderer's glyph sets, // the UAT ring as its own set, a click through the engine's selection // dispatcher selects (the camera never follows), empty map releases, and the diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index c5a312ff2..74647f564 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -98,7 +98,7 @@ export function createLifecycle({ const renderer = flightState._renderer; flightState.enabled = true; renderer?.fleet.setVisible(true); - if (renderer) holdContinuousRender('military'); // per-frame animator (perf wave 2) + if (renderer) holdContinuousRender('military'); // per-frame animator renderer?.models.setVisible(true); parts.tracking._setCockpitContactMode( globalThis.document?.body.classList.contains('cockpit-mode') === true, diff --git a/src/layers/military/render/navara.js b/src/layers/military/render/navara.js index a8b4bd1a9..1982d6c74 100644 --- a/src/layers/military/render/navara.js +++ b/src/layers/military/render/navara.js @@ -1,22 +1,11 @@ // The military flights layer's renderer on the Navara engine: the shared -// aircraft renderer (src/layers/aircraft/render/navara.js) with the -// military's sprite slot (data/spriteOrder's order on Cesium) and jet.glb's -// nose offset. Registered for 'navara' by the Navara mode after its -// import() (src/app/navaraMode.js). +// aircraft renderer with jet.glb's nose offset. Registered for 'navara' by +// the Navara mode after its import() (src/app/navaraMode.js). -import { createAircraftNavaraRenderer } from '../../aircraft/render/navara.js'; -import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; +import { aircraftLayerNavaraRenderer } from '../../aircraft/render/navara.js'; import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; -/** - * Registry factory (src/app/layers/renderers.js): `(engine, options)`. - * @param {import('../../../globe/contract.js').GlobeEngine} engine - */ -export function createMilitaryNavaraRenderer(engine) { - return createAircraftNavaraRenderer({ - engine, - layerId: 'military', - order: SPRITE_LAYER_ORDER.indexOf('military'), - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, - }); -} +export const createMilitaryNavaraRenderer = aircraftLayerNavaraRenderer( + 'military', + MODEL_HEADING_OFFSET_DEG, +); diff --git a/src/layers/military/renderer.test.mjs b/src/layers/military/renderer.test.mjs index eedf33f3a..08f3416b8 100644 --- a/src/layers/military/renderer.test.mjs +++ b/src/layers/military/renderer.test.mjs @@ -1,8 +1,7 @@ -// The military logic draws only through its renderer (phase 3 Task 11a): with -// a renderer that is plain data (no Cesium), the layer builds its amber -// fleet, selects a contact on a click through the engine's selection -// dispatcher, tracks and follows it, and releases it on an empty click or -// Escape. This is the seam the Navara renderer (Task 11b) plugs into. +// The military logic draws only through its renderer: with a renderer that +// is plain data (no Cesium), the layer builds its amber fleet, selects a +// contact on a click through the engine's selection dispatcher, tracks and +// follows it, and releases it on an empty click or Escape. import test from 'node:test'; import assert from 'node:assert/strict'; @@ -10,7 +9,6 @@ import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createApplicationMilitary } from '../../app/layers/militaryFlights.js'; import { defaultSurface } from '../../data/surfaceServices.js'; import * as militaryRegistry from '../../data/militaryRegistry.js'; -import { missingAircraftRendererMembers } from '../aircraft/render/contract.js'; import { Rgba } from '../shared/color.js'; import { MIL_ICON_COLOR, TRACKED_ICON_COLOR } from './policy.js'; import { recordingAircraftRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs'; @@ -82,7 +80,6 @@ test('the military logic draws, selects and follows only through its renderer', }, }); layer.init(null, { engine }); - assert.deepEqual(missingAircraftRendererMembers(renderer), []); layer.enable(); try { await layer.update(null); diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 95b91ded9..56f6cb119 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -55,7 +55,7 @@ export function createMilitarySnapshotRenderer({ // resync the live model, any in-flight load, and the tracked // standalone (mirror of flights.js). if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); - // Round 5: depth policy is uniform (always depth-test-free, see + // The depth policy is uniform (always depth-test-free, see // _groundDepthDistance) — nothing to flip on landing/takeoff. } else { // Start where the fleet pass will draw it: the fleet renders behind diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 86db24c8f..a580833f6 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -523,7 +523,7 @@ export function createTracking({ // to the 12 Hz icon instead of lagging ~1 s behind it. if (!flightState._trailHeadEntity && flightState._renderer) { flightState._trailHeadEntity = flightState._renderer.trails.createHead({ - // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick + // 'gev-trail' namespace: claimed by trailRenderer's pick // owner so a click on the head segment never reads as empty space. id: `gev-trail:mil-head-${++flightState._trailHeadSeq}`, show: !flightState._cockpitContactMode, @@ -547,7 +547,7 @@ export function createTracking({ // backward/reversing segment. const start = flightState._trailPositions[flightState._trailPositions.length - 2]; - // Floor the head too (round 2): a grounded tracked contact's DR + // Floor the head too: a grounded tracked contact's DR // display height is the pre-datum default — without this the last // segment dives underground while taxiing. const end = _trailFloorPosition(geo.cartesian3.clone(head)); @@ -569,9 +569,9 @@ export function createTracking({ return [from, end]; }, width: 2.5, - // Round 4: the head must never vanish into the mesh either (dimmed - // when occluded so depth still reads); round 8: geodesic, consistent - // with the trail body (no chords). + // The head must never vanish into the mesh either (dimmed when + // occluded so depth still reads); geodesic, consistent with the + // trail body (no chords). color: TRAIL_COLOR, }); } diff --git a/src/layers/osmPlaces/render/navara.js b/src/layers/osmPlaces/render/navara.js index fc4777a97..f5b12c981 100644 --- a/src/layers/osmPlaces/render/navara.js +++ b/src/layers/osmPlaces/render/navara.js @@ -18,8 +18,6 @@ const RESAMPLE_TRIES = 20; * that found no surface under a place yet. */ export function createOsmPlacesNavaraRenderer(engine) { - if (!engine?.movers) - throw new TypeError('OSM Places requires a globe engine'); const color = rgbaFromCss(OSM_PLACE_COLOR).rgba(); let set = null, capacity = 0, diff --git a/src/layers/recentImagery/render/navaraGround.js b/src/layers/recentImagery/render/navaraGround.js index 46528c3a8..adebf0f13 100644 --- a/src/layers/recentImagery/render/navaraGround.js +++ b/src/layers/recentImagery/render/navaraGround.js @@ -1,12 +1,12 @@ -import { Effect, EffectAttribute } from 'postprocessing'; import { Uniform, Matrix4, DataTexture } from 'three'; import { groundSurfaceUniforms, updateGroundSurface, } from '../../shared/render/navaraGroundSurface.js'; +import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; const KEY = 'gevRecentImagery', - registered = new WeakMap(); + effects = new Map(); const shader = /* glsl */ ` uniform sampler2D tAllDepth,tSurfaceDepth,tA,tB; uniform mat4 uInverseProjection,uWorld; @@ -57,91 +57,75 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { const boxVector = (b) => b ? [b.west, b.south, b.east, b.north] : [0, 0, 0, 0]; +// Another key (the Nepal event imagery) draws over the recent imagery. +const imageryEffect = (key) => + defineGroundEffect({ + key, + name: 'GevRecentImagery', + fragmentShader: shader, + insertAfter: + key === KEY ? ['toneMapping'] : ['gevRecentImagery', 'toneMapping'], + insertBefore: [ + 'gevNepalEvent', + 'gevObservedWeather', + 'gevEarthquakeGround', + 'gevFirmsGround', + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ], + uniforms: () => [ + ...groundSurfaceUniforms(), + ['uGeographicA', new Uniform(false)], + ['uGeographicB', new Uniform(false)], + ['uWorld', new Uniform(new Matrix4())], + ['uSplit', new Uniform(0.5)], + ['uPixelRatio', new Uniform(1)], + ['uPreview', new Uniform([0, 0, 0, 0])], + ['uPreviewVisible', new Uniform(false)], + ...['A', 'B'].flatMap((id) => [ + [`t${id}`, new Uniform(null)], + [`uBounds${id}`, new Uniform([0, 0, 1, 1])], + [`uBox${id}`, new Uniform([0, 0, 0, 0])], + [`uLook${id}`, new Uniform([0, 0])], + ]), + ], + update() { + if (!this.effect) return; + const camera = this.view.camera.raw, + u = this.effect.uniforms, + o = this.owner; + updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); + u.get('uWorld').value.copy(camera.matrixWorld); + u.get('uSplit').value = o.split; + u.get('uPixelRatio').value = o.pixelRatio(); + u.get('uPreview').value = boxVector(o.preview); + u.get('uPreviewVisible').value = Boolean(o.preview); + for (const id of ['A', 'B']) { + const r = o.slots[id.toLowerCase()]; + u.get(`uGeographic${id}`).value = r?.atlas?.plan.geographic === true; + u.get(`t${id}`).value = r?.atlas?.texture || o.empty; + u.get(`uBounds${id}`).value = r?.atlas?.plan.bounds || [0, 0, 1, 1]; + u.get(`uBox${id}`).value = boxVector(r?.box); + u.get(`uLook${id}`).value = [ + r?.atlas ? r.alpha : 0, + r?.split === 'left' ? -1 : r?.split === 'right' ? 1 : 0, + ]; + } + }, + }); + export function createImageryGround( engine, { view, EffectDesc, NavaraEffect }, owner, key = KEY, ) { - if (!view || !EffectDesc || !NavaraEffect) - throw new TypeError('Imagery requires Navara ground effects'); - if (!registered.has(view)) registered.set(view, new Set()); - if (!registered.get(view).has(key)) { - class ImageryEffect extends EffectDesc { - static key = key; - static insertAfter = ['toneMapping']; - static insertBefore = [ - 'gevNepalEvent', - 'gevObservedWeather', - 'gevEarthquakeGround', - 'gevFirmsGround', - 'gevPreSharpenOverlay', - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevOverlay', - 'final', - ]; - constructor(view, context, config) { - super(view, context, config); - this.owner = config[key].owner; - } - createPass() { - this.effect = new Effect('GevRecentImagery', shader, { - attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map([ - ...groundSurfaceUniforms(), - ['uGeographicA', new Uniform(false)], - ['uGeographicB', new Uniform(false)], - ['uWorld', new Uniform(new Matrix4())], - ['uSplit', new Uniform(0.5)], - ['uPixelRatio', new Uniform(1)], - ['uPreview', new Uniform([0, 0, 0, 0])], - ['uPreviewVisible', new Uniform(false)], - ...['A', 'B'].flatMap((id) => [ - [`t${id}`, new Uniform(null)], - [`uBounds${id}`, new Uniform([0, 0, 1, 1])], - [`uBox${id}`, new Uniform([0, 0, 0, 0])], - [`uLook${id}`, new Uniform([0, 0])], - ]), - ]), - }); - return new NavaraEffect(this.view.camera.raw, this.effect); - } - updateFrame() { - if (!this.effect) return; - const camera = this.view.camera.raw, - u = this.effect.uniforms, - o = this.owner; - updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); - u.get('uWorld').value.copy(camera.matrixWorld); - u.get('uSplit').value = o.split; - u.get('uPixelRatio').value = o.pixelRatio(); - u.get('uPreview').value = boxVector(o.preview); - u.get('uPreviewVisible').value = Boolean(o.preview); - for (const id of ['A', 'B']) { - const r = o.slots[id.toLowerCase()]; - u.get(`uGeographic${id}`).value = r?.atlas?.plan.geographic === true; - u.get(`t${id}`).value = r?.atlas?.texture || o.empty; - u.get(`uBounds${id}`).value = r?.atlas?.plan.bounds || [0, 0, 1, 1]; - u.get(`uBox${id}`).value = boxVector(r?.box); - u.get(`uLook${id}`).value = [ - r?.atlas ? r.alpha : 0, - r?.split === 'left' ? -1 : r?.split === 'right' ? 1 : 0, - ]; - } - } - onDestroy() { - this.effect?.dispose(); - super.onDestroy(); - } - } - class NepalEffect extends ImageryEffect { - static insertAfter = ['gevRecentImagery', 'toneMapping']; - } - view.registerEffect(key, key === KEY ? ImageryEffect : NepalEffect); - registered.get(view).add(key); - } + if (!effects.has(key)) effects.set(key, imageryEffect(key)); + effects.get(key).register(view, { EffectDesc, NavaraEffect }); owner.empty = new DataTexture(new Uint8Array(4), 1, 1); owner.empty.needsUpdate = true; owner.pixelRatio = () => diff --git a/src/layers/recentImagery/render/tiles.js b/src/layers/recentImagery/render/tiles.js index f359ecee1..54eaea847 100644 --- a/src/layers/recentImagery/render/tiles.js +++ b/src/layers/recentImagery/render/tiles.js @@ -1,6 +1,6 @@ const LIMIT = 85.05112878; export const TILE_SIZE = 256; -export const MAX_TILE_SIDE = 8; +const MAX_TILE_SIDE = 8; export const mercatorX = (lon) => (lon + 180) / 360; export const mercatorY = (lat) => { const r = (Math.max(-LIMIT, Math.min(LIMIT, lat)) * Math.PI) / 180; diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 301edb998..6c5758d3e 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -95,9 +95,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4; * attribute, so classification costs nothing per frame. */ -export const POINT_OUTLINE = WHITE.withAlpha(0.3); +const POINT_OUTLINE = WHITE.withAlpha(0.3); -export const _classColor = (group) => rgbaFromCss(satelliteClassColor(group)); +const _classColor = (group) => rgbaFromCss(satelliteClassColor(group)); export const POINT_STYLES = { // The ISS keeps its own long-standing red hero styling rather than the diff --git a/src/layers/satellites/render/navara.js b/src/layers/satellites/render/navara.js index 4c761cf3a..4345e6d51 100644 --- a/src/layers/satellites/render/navara.js +++ b/src/layers/satellites/render/navara.js @@ -1,6 +1,6 @@ // Satellite points, rigid orbit rings and follow through the globe contract. import * as geo from '../../../geo/index.js'; -import { createMoverBank } from '../../aircraft/render/navara.js'; +import { createMoverBank } from '../../shared/render/navaraMoverBank.js'; import { createEngineLayerView } from '../../shared/render/engineView.js'; import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; import { WHITE, constantRgba } from '../../shared/color.js'; diff --git a/src/layers/shared/color.js b/src/layers/shared/color.js index a5fa2e410..7c8208f7b 100644 --- a/src/layers/shared/color.js +++ b/src/layers/shared/color.js @@ -1,5 +1,5 @@ -// Engine-neutral layer colours (phase 3): the colour constants the layers -// used to build as Cesium.Color, without Cesium. +// Engine-neutral layer colours: the layers' colour constants, with +// Cesium.Color's values but without Cesium. // // An Rgba has Cesium.Color's fields and the methods the layers call on their // colours (`withAlpha`, `clone`, `equals`), so it is accepted wherever a diff --git a/src/layers/shared/render/imageAtlas.js b/src/layers/shared/render/imageAtlas.js new file mode 100644 index 000000000..00ded2a3f --- /dev/null +++ b/src/layers/shared/render/imageAtlas.js @@ -0,0 +1,67 @@ +// Sprite atlases for the engine's mover sets: images packed into a grid of +// square cells, each `pad` px into its cell, with the [u0, v0, u1, v1] +// rectangle of every image. + +/** + * The atlas layout (pure). Each entry is `{ key, width, height }`, the + * drawn size of its image. + * @param {{ key: string, width: number, height: number }[]} entries + * @param {{ cell: number, pad?: number, columns?: number }} options + * @returns {{ width: number, height: number, + * rects: { key: string, x: number, y: number, width: number, height: number }[], + * cells: Record }} + */ +export function imageAtlasLayout( + entries, + { cell, pad = 2, columns = Math.ceil(Math.sqrt(entries.length)) }, +) { + const width = columns * cell; + const height = Math.ceil(entries.length / columns) * cell; + const cells = {}; + const rects = entries.map((entry, index) => { + const x = (index % columns) * cell + pad; + const y = Math.floor(index / columns) * cell + pad; + cells[entry.key] = [ + x / width, + y / height, + (x + entry.width) / width, + (y + entry.height) / height, + ]; + return { key: entry.key, x, y, width: entry.width, height: entry.height }; + }); + return { width, height, rects, cells }; +} + +/** + * Draws `{ key, image, width, height }` entries into one canvas at + * imageAtlasLayout's cells: at the image's own size, or scaled to + * width × height with `resize`. + * @returns {{ canvas: object, cells: Record }} + */ +export function packImageAtlas( + entries, + { canvas = document.createElement('canvas'), resize = false, ...options }, +) { + const layout = imageAtlasLayout(entries, options); + canvas.width = layout.width; + canvas.height = layout.height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Sprite atlas requires a canvas'); + entries.forEach(({ image }, index) => { + const { x, y, width, height } = layout.rects[index]; + if (resize) context.drawImage(image, x, y, width, height); + else context.drawImage(image, x, y); + }); + return { canvas, cells: layout.cells }; +} + +/** Decodes image URLs (browser). */ +export const decodeImages = (urls) => + Promise.all( + urls.map(async (url) => { + const image = new Image(); + image.src = url; + await image.decode(); + return image; + }), + ); diff --git a/src/layers/shared/render/navaraCellGeometry.js b/src/layers/shared/render/navaraCellGeometry.js index e9a4101b7..56fb0a9cf 100644 --- a/src/layers/shared/render/navaraCellGeometry.js +++ b/src/layers/shared/render/navaraCellGeometry.js @@ -4,7 +4,7 @@ const ellipsoid = geo.ellipsoid.WGS84; const A = ellipsoid.radii.x, B = ellipsoid.radii.z; const E2 = 1 - (B * B) / (A * A); -export function latitudeBoundary(degrees) { +function latitudeBoundary(degrees) { const angle = geo.math.toRadians(degrees), sin = Math.sin(angle), cos = Math.cos(angle); diff --git a/src/layers/shared/render/navaraGroundCells.js b/src/layers/shared/render/navaraGroundCells.js index 005789456..68b59a7a1 100644 --- a/src/layers/shared/render/navaraGroundCells.js +++ b/src/layers/shared/render/navaraGroundCells.js @@ -1,7 +1,7 @@ // Classify visible terrain and 3D tiles. Multiple cell layers share one non-pickable pass. -import { Effect, EffectAttribute } from 'postprocessing'; import { Matrix4, Uniform } from 'three'; import { rectangleFrame } from './navaraCellGeometry.js'; +import { defineGroundEffect } from './navaraGroundEffect.js'; import { viewFrame, groundClipBounds } from './navaraGroundGeometry.js'; import { createGroundGrid, GRID_COLS, GRID_ROWS } from './navaraGroundGrid.js'; import { @@ -11,7 +11,6 @@ import { updateGroundSurface, } from './navaraGroundSurface.js'; const KEY = 'gevFirmsGround'; -const registeredViews = new WeakSet(); const fragmentShader = /* glsl */ ` uniform sampler2D tAllDepth; uniform sampler2D tSurfaceDepth; @@ -200,65 +199,51 @@ function buildState(cells) { }, }; } -function groundEffectClass(EffectDesc, NavaraEffect) { - return class FirmsGroundEffectDesc extends EffectDesc { - static key = KEY; - static insertAfter = [ - 'gevEarthquakeGround', - 'gevObservedWeather', - 'gevNepalEvent', - 'gevRecentImagery', - 'toneMapping', - ]; - static insertBefore = [ - 'gevPreSharpenOverlay', - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevOverlay', - 'final', - ]; - constructor(view, context, config) { - super(view, context, config); - this.owner = config[KEY].owner; - } - createPass() { - this.effect = new Effect('GevFirmsGround', fragmentShader, { - attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map([ - ...groundSurfaceUniforms(), - ['tCells', new Uniform(null)], - ['tGrid', new Uniform(null)], - ['tCandidates', new Uniform(null)], - ['uCellSize', new Uniform([1, 1])], - ['uCandidateSize', new Uniform([1, 1])], - ['uInverseView', new Uniform(new Matrix4())], - ]), - }); - return new NavaraEffect(this.view.camera.raw, this.effect); - } - updateFrame() { - const state = this.owner.state, - camera = this.view.camera.raw; - if (!this.effect || !state || !camera) return; - updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); - state.update(camera, this.ctx); - const u = this.effect.uniforms; - u.get('tCells').value = state.packed.texture; - u.get('uCellSize').value[0] = state.packed.width; - u.get('uCellSize').value[1] = state.packed.height; - u.get('tGrid').value = state.gridTexture; - u.get('tCandidates').value = state.candidates.texture; - u.get('uCandidateSize').value[0] = state.candidates.width; - u.get('uCandidateSize').value[1] = state.candidates.height; - u.get('uInverseView').value.copy(camera.matrixWorld); - } - onDestroy() { - this.effect?.dispose(); - super.onDestroy(); - } - }; -} +const groundEffect = defineGroundEffect({ + key: KEY, + name: 'GevFirmsGround', + fragmentShader, + insertAfter: [ + 'gevEarthquakeGround', + 'gevObservedWeather', + 'gevNepalEvent', + 'gevRecentImagery', + 'toneMapping', + ], + insertBefore: [ + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ], + uniforms: () => [ + ...groundSurfaceUniforms(), + ['tCells', new Uniform(null)], + ['tGrid', new Uniform(null)], + ['tCandidates', new Uniform(null)], + ['uCellSize', new Uniform([1, 1])], + ['uCandidateSize', new Uniform([1, 1])], + ['uInverseView', new Uniform(new Matrix4())], + ], + update() { + const state = this.owner.state, + camera = this.view.camera.raw; + if (!this.effect || !state || !camera) return; + updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); + state.update(camera, this.ctx); + const u = this.effect.uniforms; + u.get('tCells').value = state.packed.texture; + u.get('uCellSize').value[0] = state.packed.width; + u.get('uCellSize').value[1] = state.packed.height; + u.get('tGrid').value = state.gridTexture; + u.get('tCandidates').value = state.candidates.texture; + u.get('uCandidateSize').value[0] = state.candidates.width; + u.get('uCandidateSize').value[1] = state.candidates.height; + u.get('uInverseView').value.copy(camera.matrixWorld); + }, +}); // The SDK allows one active effect per key. Compose enabled datasets in // catalog order so both can be drawn without replacing one another. const views = new WeakMap(); @@ -267,10 +252,7 @@ export function createGroundCellDisplay( { view, EffectDesc, NavaraEffect }, order, ) { - if (!registeredViews.has(view)) { - view.registerEffect(KEY, groundEffectClass(EffectDesc, NavaraEffect)); - registeredViews.add(view); - } + groundEffect.register(view, { EffectDesc, NavaraEffect }); let pass = views.get(view); if (!pass) { pass = { diff --git a/src/layers/shared/render/navaraGroundEffect.js b/src/layers/shared/render/navaraGroundEffect.js new file mode 100644 index 000000000..4861f2224 --- /dev/null +++ b/src/layers/shared/render/navaraGroundEffect.js @@ -0,0 +1,57 @@ +import { Effect, EffectAttribute } from 'postprocessing'; + +/** + * 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 EffectDesc { + 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/layers/shared/render/navaraGroundOrder.test.mjs b/src/layers/shared/render/navaraGroundOrder.test.mjs index 252c953d2..65dea1160 100644 --- a/src/layers/shared/render/navaraGroundOrder.test.mjs +++ b/src/layers/shared/render/navaraGroundOrder.test.mjs @@ -1,36 +1,62 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; +import { createImageryGround } from '../../recentImagery/render/navaraGround.js'; +import { createEarthquakesNavaraRenderer } from '../../earthquakes/render/navara.js'; +import { createGroundCellDisplay } from './navaraGroundCells.js'; // EffectDesc gives insertAfter priority, then tries insertBefore. Check the // actual renderer declarations under every activation order, including the // minimal look, whose pipeline has no toneMapping anchor. const declarations = [ - ['gevRecentImagery', '../../recentImagery/render/navaraGround.js'], - ['gevNepalEvent', '../../recentImagery/render/navaraGround.js'], - ['gevObservedWeather', '../../weather/render/navaraSurface.js'], - ['gevEarthquakeGround', '../../earthquakes/render/navara.js'], - ['gevFirmsGround', './navaraGroundCells.js'], - ['gevPreSharpenOverlay', '../../../globe/navara/overlay.js'], + 'gevRecentImagery', + 'gevNepalEvent', + 'gevObservedWeather', + 'gevEarthquakeGround', + 'gevFirmsGround', + 'gevPreSharpenOverlay', +]; +// The descriptor a renderer registers; its setup stops there. +const stop = new Error('registered'); +function registered(create) { + let spec; + const view = { + camera: {}, + registerEffect(key, Desc) { + spec = { key, after: Desc.insertAfter, before: Desc.insertBefore }; + throw stop; + }, + }; + try { + create({ view, EffectDesc: class {}, NavaraEffect: class {} }); + } catch (error) { + if (error !== stop) throw error; + } + return spec; +} +async function declared(key, path, className) { + let source = await fs.readFile(new URL(path, import.meta.url), 'utf8'); + if (className) source = source.split(`class ${className}`)[1]; + const anchors = (name) => + [ + ...source + .match(new RegExp(`static ${name} = \\[([^\\]]*)\\]`))[1] + .matchAll(/'([^']+)'/g), + ].map((m) => m[1]); + return { key, after: anchors('insertAfter'), before: anchors('insertBefore') }; +} +const specs = [ + registered((sdk) => createImageryGround({}, sdk, {})), + registered((sdk) => createImageryGround({}, sdk, {}, 'gevNepalEvent')), + await declared('gevObservedWeather', '../../weather/render/navaraSurface.js'), + registered((sdk) => createEarthquakesNavaraRenderer({ render: {} }, sdk)), + registered((sdk) => createGroundCellDisplay({}, sdk, 0)), + await declared( + 'gevPreSharpenOverlay', + '../../../globe/navara/overlay.js', + 'GevPreSharpenOverlayEffectDesc', + ), ]; -const specs = await Promise.all( - declarations.map(async ([key, path]) => { - let source = await fs.readFile(new URL(path, import.meta.url), 'utf8'); - if (key === 'gevPreSharpenOverlay') - source = source.split('class GevPreSharpenOverlayEffectDesc')[1]; - const anchors = (name) => - [ - ...(key === 'gevNepalEvent' && name === 'insertAfter' ? source.split('class NepalEffect')[1] : source) - .match(new RegExp(`static ${name} = \\[([^\\]]*)\\]`))[1] - .matchAll(/'([^']+)'/g), - ].map((m) => m[1]); - return { - key, - after: anchors('insertAfter'), - before: anchors('insertBefore'), - }; - }), -); const permutations = (items) => items.length ? items.flatMap((item, i) => @@ -64,7 +90,7 @@ test('all ground/glyph activation orders preserve imagery, classification and ma } assert.deepEqual( order.filter((key) => specs.some((s) => s.key === key)), - declarations.map(([key]) => key), + declarations, `${toneMapping}: ${activation.map((s) => s.key)}`, ); } diff --git a/src/layers/shared/render/navaraMoverBank.js b/src/layers/shared/render/navaraMoverBank.js new file mode 100644 index 000000000..adc9c3f33 --- /dev/null +++ b/src/layers/shared/render/navaraMoverBank.js @@ -0,0 +1,127 @@ +// A growing bank of engine.movers sprites or models that Navara renderers +// share: handles hold their index into one mover set, which is rebuilt at +// double the capacity when it fills. + +const FIRST_CAPACITY = 1024; + +// Reused set() arguments (set() copies them; no object per write). +const fieldsNoPick = { + position: undefined, + orientation: undefined, + color: undefined, + rotation: undefined, + scale: undefined, + cell: undefined, + show: undefined, +}; +/** Writes `fields` of the mover at `index` (set() copies them). */ +export function writeFields(set, index, fields) { + const scratch = fieldsNoPick; + for (const key in fields) scratch[key] = fields[key]; + set.set(index, scratch); + for (const key in fields) scratch[key] = undefined; +} + +/** + * A growing bank of one kind of mover: handles hold an index; removal moves + * the last handle into the hole. `options(capacity, generation)` gives the + * createSet options; `write(handle, set, index)` writes a handle whole. + */ +export function createMoverBank(engine, { options, write, onCommit }) { + const handles = []; + let set = null; + let generation = 0; + let capacity = 0; + let visible = true; + let dirty = false; + const build = (wanted) => { + const next = engine.movers.createSet(options(wanted, ++generation)); + set?.destroy(); + set = next; + capacity = wanted; + handles.forEach((handle, index) => write(handle, set, index)); + set.setCount(visible ? handles.length : 0); + }; + const bank = { + handles, + get set() { + return set; + }, + ready: () => Boolean(set?.ready()), + add(handle) { + if (handle.gevBank === bank) return; + handle.gevBank?.remove(handle); + handle.gevBank = bank; + handle.gevIndex = handles.length; + handles.push(handle); + if (!set || handles.length > capacity) + build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length)); + else { + write(handle, set, handle.gevIndex); + if (visible) set.setCount(handles.length); + } + bank.touch(); + }, + remove(handle) { + if (handle.gevBank !== bank) return; + const index = handle.gevIndex; + const last = handles.pop(); + if (last !== handle) { + handles[index] = last; + last.gevIndex = index; + write(last, set, index); + } + handle.gevBank = null; + handle.gevIndex = -1; + set?.setCount(visible ? handles.length : 0); + bank.touch(); + }, + /** Writes `fields` of one handle (when it is in this bank). */ + update(handle, fields) { + if (handle.gevBank !== bank || !set) return; + writeFields(set, handle.gevIndex, fields); + bank.touch(); + }, + setVisible(next) { + visible = next; + set?.setCount(visible ? handles.length : 0); + bank.touch(); + }, + visible: () => visible, + touch() { + if (!dirty) { + dirty = true; + onCommit(bank); + } + }, + commit() { + if (!dirty) return; + dirty = false; + set?.commit(); + }, + destroy() { + for (const handle of handles) { + handle.gevBank = null; + handle.gevIndex = -1; + } + handles.length = 0; + set?.destroy(); + set = null; + }, + }; + return bank; +} + +/** Plain colour copy (the stored value a handle's colour getter returns). */ +export class StoredColor { + constructor(color) { + this.red = color?.red ?? 1; + this.green = color?.green ?? 1; + this.blue = color?.blue ?? 1; + this.alpha = color?.alpha ?? 1; + } + + withAlpha(alpha) { + return new StoredColor({ ...this, alpha }); + } +} diff --git a/src/layers/shared/viewRectangle.js b/src/layers/shared/viewRectangle.js new file mode 100644 index 000000000..8d56fec7b --- /dev/null +++ b/src/layers/shared/viewRectangle.js @@ -0,0 +1,15 @@ +import * as geo from '../../geo/index.js'; + +/** + * The centre of a view rectangle (radians), across the antimeridian when + * its east edge is west of its west edge. + * @param {{ west: number, south: number, east: number, north: number }} rect + * @returns {{ latitude: number, longitude: number }} + */ +export function viewRectangleCenter(rect) { + const east = rect.east < rect.west ? rect.east + Math.PI * 2 : rect.east; + return { + latitude: (rect.south + rect.north) / 2, + longitude: geo.math.negativePiToPi((rect.west + east) / 2), + }; +} diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js index 4dd9de735..1859a1c83 100644 --- a/src/layers/submarineCables/geometry.js +++ b/src/layers/submarineCables/geometry.js @@ -41,7 +41,7 @@ export function featureReference(feature) { * Split a [lon, lat] path where a segment crosses the antimeridian, so no * drawn segment runs the long way across the whole map. */ -export function splitAtAntimeridian(path) { +function splitAtAntimeridian(path) { const parts = []; let current = []; for (let index = 0; index < path.length; index++) { diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 5d8ab27a9..cdc8eca1b 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -19,7 +19,7 @@ import { nextTask } from '../../sources/vectorTiles.js'; * local ground estimate (a local tangent-plane approximation, metres apart at * the traffic layer's kilometre range). Null without a camera. */ -export function groundDistanceFrom(camera, groundHeight = 0) { +function groundDistanceFrom(camera, groundHeight = 0) { const view = camera && Cesium.Cartographic.fromCartesian(camera); if (!view) return null; const clearance = view.height - (groundHeight || 0) - DOT_HEIGHT_OFFSET; diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 0c288cfbd..5458fe882 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -258,8 +258,8 @@ export function createLifecycle({ // Enabling is explicit intent, not a camera gesture that needs settling. parts.viewport.onCameraChanged({ immediate: true }); - // Boot-order guard (field-test round 1: layer sat empty until the user - // moved): when the persisted layer state re-enables traffic during the + // Boot-order guard (otherwise the layer sits empty until the user + // moves): when the persisted layer state re-enables traffic during the // intro flyTo, the initial check bails at high altitude — and a camera // that then parks never re-fires camera.changed. Retry cheaply until the // first load commits, then self-clear. Also acts as a safety kick if a diff --git a/src/layers/transit/render/navaraSprites.js b/src/layers/transit/render/navaraSprites.js index 37f821373..ee404ca34 100644 --- a/src/layers/transit/render/navaraSprites.js +++ b/src/layers/transit/render/navaraSprites.js @@ -1,11 +1,15 @@ import * as geo from '../../../geo/index.js'; import { Rgba } from '../../shared/color.js'; -import { createMoverBank } from '../../aircraft/render/navara.js'; +import { createMoverBank } from '../../shared/render/navaraMoverBank.js'; +import { + decodeImages, + packImageAtlas, +} from '../../shared/render/imageAtlas.js'; import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; import { TRANSIT_ICON_KINDS, transitIcon } from '../../../data/transitIcons.js'; /** The same bounded mode/selection/style rasters used by native billboards. */ -export async function drawTransitAtlas() { +async function drawTransitAtlas() { const urls = [ ...new Set( TRANSIT_ICON_KINDS.flatMap((mode) => @@ -15,34 +19,17 @@ export async function drawTransitAtlas() { ), ), ]; - const images = await Promise.all( - urls.map(async (url) => { - const image = new Image(); - image.src = url; - await image.decode(); - return image; - }), - ); + const images = await decodeImages(urls); const cell = Math.max(...images.map((image) => image.naturalWidth)) + 4; - const columns = Math.ceil(Math.sqrt(images.length)); - const canvas = document.createElement('canvas'); - canvas.width = columns * cell; - canvas.height = Math.ceil(images.length / columns) * cell; - const context = canvas.getContext('2d'); - if (!context) throw new Error('Transit atlas requires a canvas'); - const cells = {}; - images.forEach((image, i) => { - const x = (i % columns) * cell + 2, - y = Math.floor(i / columns) * cell + 2; - context.drawImage(image, x, y); - cells[urls[i]] = [ - x / canvas.width, - y / canvas.height, - (x + image.naturalWidth) / canvas.width, - (y + image.naturalHeight) / canvas.height, - ]; - }); - return { canvas, cells }; + return packImageAtlas( + images.map((image, i) => ({ + key: urls[i], + image, + width: image.naturalWidth, + height: image.naturalHeight, + })), + { cell, pad: 2 }, + ); } /** Two independently dirty banks: moving contacts never upload the static fleet. */ diff --git a/src/layers/transit/render/navaraTrail.js b/src/layers/transit/render/navaraTrail.js index 239725469..5df59f909 100644 --- a/src/layers/transit/render/navaraTrail.js +++ b/src/layers/transit/render/navaraTrail.js @@ -1,4 +1,3 @@ -import { Effect, EffectAttribute } from 'postprocessing'; import { Uniform } from 'three'; import * as geo from '../../../geo/index.js'; import { rgbaFromCss } from '../../shared/color.js'; @@ -17,9 +16,9 @@ import { viewFrame, groundClipBounds, } from '../../shared/render/navaraGroundGeometry.js'; +import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; -const KEY = 'gevTransitGround', - registered = new WeakSet(); +const KEY = 'gevTransitGround'; const shader = /* glsl */ ` uniform sampler2D tAllDepth,tSurfaceDepth,tSegments,tGrid,tCandidates; uniform vec2 uSegmentSize,uCandidateSize; @@ -198,82 +197,63 @@ function storage(segments) { }; } -function descriptor(EffectDesc, NavaraEffect) { - return class TransitGroundEffect extends EffectDesc { - static key = KEY; - static insertAfter = ['toneMapping']; - static insertBefore = [ - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevPreSharpenOverlay', - 'gevOverlay', - 'final', - ]; - constructor(view, context, config) { - super(view, context, config); - this.owner = config[KEY].owner; +const groundEffect = defineGroundEffect({ + key: KEY, + name: 'GevTransitGround', + fragmentShader: shader, + insertAfter: ['toneMapping'], + insertBefore: [ + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevPreSharpenOverlay', + 'gevOverlay', + 'final', + ], + uniforms: () => [ + ...groundSurfaceUniforms(), + ['tSegments', new Uniform(null)], + ['tGrid', new Uniform(null)], + ['tCandidates', new Uniform(null)], + ['uSegmentSize', new Uniform([1, 1])], + ['uCandidateSize', new Uniform([1, 1])], + ['uDisplayT', new Uniform(0)], + ['uActive', new Uniform(-1)], + ['uColor', new Uniform([1, 1, 1])], + ['uPixelRatio', new Uniform(1)], + ], + update() { + if (!this.effect) return; + const { state, displayT, active, color, pixelRatio } = this.owner; + const camera = this.view.camera.raw; + updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); + state.update(camera, this.ctx); + const u = this.effect.uniforms; + for (const [name, data] of [ + ['Segments', state.packed], + ['Candidates', state.candidates], + ]) { + u.get(`t${name}`).value = data.texture; + const size = u.get( + name === 'Segments' ? 'uSegmentSize' : 'uCandidateSize', + ).value; + size[0] = data.width; + size[1] = data.height; } - createPass() { - this.effect = new Effect('GevTransitGround', shader, { - attributes: EffectAttribute.CONVOLUTION, - uniforms: new Map([ - ...groundSurfaceUniforms(), - ['tSegments', new Uniform(null)], - ['tGrid', new Uniform(null)], - ['tCandidates', new Uniform(null)], - ['uSegmentSize', new Uniform([1, 1])], - ['uCandidateSize', new Uniform([1, 1])], - ['uDisplayT', new Uniform(0)], - ['uActive', new Uniform(-1)], - ['uColor', new Uniform([1, 1, 1])], - ['uPixelRatio', new Uniform(1)], - ]), - }); - return new NavaraEffect(this.view.camera.raw, this.effect); - } - updateFrame() { - if (!this.effect) return; - const { state, displayT, active, color, pixelRatio } = this.owner; - const camera = this.view.camera.raw; - updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); - state.update(camera, this.ctx); - const u = this.effect.uniforms; - for (const [name, data] of [ - ['Segments', state.packed], - ['Candidates', state.candidates], - ]) { - u.get(`t${name}`).value = data.texture; - const size = u.get( - name === 'Segments' ? 'uSegmentSize' : 'uCandidateSize', - ).value; - size[0] = data.width; - size[1] = data.height; - } - u.get('tGrid').value = state.gridTexture; - u.get('uDisplayT').value = displayT - state.epoch; - u.get('uActive').value = active; - u.get('uColor').value = color; - u.get('uPixelRatio').value = pixelRatio(); - } - onDestroy() { - this.effect?.dispose(); - super.onDestroy(); - } - }; -} + u.get('tGrid').value = state.gridTexture; + u.get('uDisplayT').value = displayT - state.epoch; + u.get('uActive').value = active; + u.get('uColor').value = color; + u.get('uPixelRatio').value = pixelRatio(); + }, +}); /** A static corridor texture; playback only updates time and active uniforms. */ export function createTransitNavaraTrail( engine, { view, EffectDesc, NavaraEffect }, ) { - if (!view || !EffectDesc || !NavaraEffect) - throw new TypeError('Transit trails require the Navara ground effect API'); - if (!registered.has(view)) { - view.registerEffect(KEY, descriptor(EffectDesc, NavaraEffect)); - registered.add(view); - } + groundEffect.register(view, { EffectDesc, NavaraEffect }); let segments = [], revision = -1, rebuilds = 0, diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index a9de80238..3d10e463c 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -7,6 +7,7 @@ import { inflateViewBounds, } from './policy.js'; import { transitFeedsInRange } from '../../data/transitFeeds.js'; +import { viewRectangleCenter } from '../shared/viewRectangle.js'; /** * Camera-driven feed activation: only the feeds whose coverage circle contains @@ -25,11 +26,7 @@ export function createViewport({ state, services, parts }) { function getCameraCenterLatLon() { const rect = parts.renderer?.viewRectangle(); if (rect) { - const east = rect.east < rect.west ? rect.east + Math.PI * 2 : rect.east; - const center = { - latitude: (rect.south + rect.north) / 2, - longitude: geo.math.negativePiToPi((rect.west + east) / 2), - }; + const center = viewRectangleCenter(rect); return { lat: geo.math.toDegrees(center.latitude), lon: geo.math.toDegrees(center.longitude), diff --git a/src/layers/vessels/edgeCases.test.mjs b/src/layers/vessels/edgeCases.test.mjs index 40736c156..00a1f9e43 100644 --- a/src/layers/vessels/edgeCases.test.mjs +++ b/src/layers/vessels/edgeCases.test.mjs @@ -4,123 +4,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; -import * as selection from '../../data/selectionDispatcher.js'; -import * as picking from '../../data/pickRegistry.js'; import { createVesselLayer } from './index.js'; +import { recordingRenderer, services } from './testFixtures.mjs'; -const noop = () => {}; const RAD = Math.PI / 180; -function recordingRenderer(engine) { - const sprites = new Set(); - const moveEnd = new Set(); - const camera = { - positionWC: { x: 0, y: 0, z: 6_500_000 }, - // 60 km up: the layer asks for a 60 km area around the camera. - positionCartographic: { - latitude: 40.6 * RAD, - longitude: -73.9 * RAD, - height: 60_000, - }, - rightWC: { x: 1, y: 0, z: 0 }, - upWC: { x: 0, y: 1, z: 0 }, - heading: 0, - pitch: 0, - roll: 0, - }; - let attached = false; - return { - sprites, - camera, - moveTo(lat, lon) { - camera.positionCartographic = { - latitude: lat * RAD, - longitude: lon * RAD, - height: 60_000, - }; - for (const listener of moveEnd) listener(); - }, - renderer: { - kind: 'plain', - engine, - view: { - camera, - scene: { canvas: { clientWidth: 0, clientHeight: 0 } }, - horizon: () => ({ isPointVisible: () => true }), - worldToWindow: () => undefined, - canvasSize: () => ({ width: 1280, height: 800 }), - requestRender: noop, - on(event, listener) { - if (event !== 'moveEnd') return noop; - moveEnd.add(listener); - return () => moveEnd.delete(listener); - }, - }, - fleet: { - collection: null, - attached: () => attached, - attach() { - attached = true; - }, - setVisible: noop, - add(spec) { - const sprite = { ...spec }; - sprites.add(sprite); - return sprite; - }, - remove: (sprite) => sprites.delete(sprite), - detach() { - attached = false; - }, - }, - pickedRecord: (picked) => - picked?.id && typeof picked.id === 'object' ? picked.id : null, - follow: { onChanged: () => noop, active: () => false }, - trails: { create: () => null }, - destroy: noop, - }, - }; -} - -function services(renderer) { - return { - context: { - clearSelectedEntityContextForLayer: noop, - registerEntityContext: () => null, - selectEntityContext: noop, - }, - trails: { createTrail: null }, - labels: {}, - picking, - overlay: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, - hitTestWorldOverlay: () => null, - }, - geoid: { ensureGeoidReady: () => new Promise(noop), geoidHeight: () => 0 }, - sprites: { - registerSpriteCollection: noop, - restoreSpriteOrder: noop, - restoreSpriteOrderOnEnable: noop, - }, - focus: { - focusNowMs: (now) => now, - getFocusTarget: () => null, - focusPassIsNeeded: () => false, - forgetSpriteFocus: noop, - }, - worldFocus: { requestWorldFocus: noop }, - render: { - holdContinuousRender: noop, - releaseContinuousRender: noop, - governorRequestRender: noop, - }, - selection, - renderer: { create: () => renderer }, - }; -} - const row = (mmsi, latitude, longitude, observedAtMs) => ({ id: mmsi, reference: mmsi, @@ -136,7 +24,15 @@ const row = (mmsi, latitude, longitude, observedAtMs) => ({ test('edge:ais-live-vessels: a vessel crossing into the next cell during an AIS gap keeps one request and its card by MMSI', async (t) => { const engine = createFakeEngine(); - const recorder = recordingRenderer(engine); + // 60 km up: the layer asks for a 60 km area around the camera. + const recorder = recordingRenderer(engine, { + positionWC: { x: 0, y: 0, z: 6_500_000 }, + positionCartographic: { + latitude: 40.6 * RAD, + longitude: -73.9 * RAD, + height: 60_000, + }, + }); const previousDocument = globalThis.document; globalThis.document = Object.assign(new EventTarget(), { getElementById: () => null, diff --git a/src/layers/vessels/render/cesium.js b/src/layers/vessels/render/cesium.js index 933797f96..0ba3836d0 100644 --- a/src/layers/vessels/render/cesium.js +++ b/src/layers/vessels/render/cesium.js @@ -1,5 +1,4 @@ -// The vessels layer's renderer on Cesium (phase 3 Task 13a): the Cesium -// objects the layer used to build itself, moved here one for one — its +// The vessels layer's renderer on Cesium: its // BillboardCollection of ship chevrons (translucent blend, screen-aligned // rotation, centred, never depth-tested), the camera reads (horizon // occluder, window projection, canvas size), the "another layer took the @@ -9,8 +8,8 @@ // // The trail stays an entity polyline (createTrail) rather than engine.lines: // Cesium's engine.lines is a Primitive with a depth-fail appearance, the -// path that did not draw segments below the photoreal mesh in round 6 -// (plan, the T13/T15 blocker; flights keeps the same entity path in 10a). +// path that does not draw segments below the photoreal mesh (flights keeps +// the same entity path). // // Interface (shared with render/navara.js): ./contract.js. diff --git a/src/layers/vessels/render/contract.js b/src/layers/vessels/render/contract.js index 8cf979e7d..ff763f3bf 100644 --- a/src/layers/vessels/render/contract.js +++ b/src/layers/vessels/render/contract.js @@ -1,7 +1,6 @@ // The vessel renderer: what the vessels layer's logic asks of an engine to // draw its ship chevrons and the selected vessel's trail, and to read the -// camera. render/cesium.js implements it over Cesium (the objects the layer -// used to build itself, one for one); render/navara.js on the Navara engine +// camera. render/cesium.js implements it over Cesium; render/navara.js on the Navara engine // over engine.movers / engine.lines / engine.projector. Nothing here imports // an engine. // @@ -54,39 +53,3 @@ * @property {{ create(options: { color: string, width: number }): object|null }} trails * @property {() => void} destroy */ - -/** The members every vessel renderer has (both engines). */ -export const VESSEL_RENDERER_SHAPE = Object.freeze({ - top: ['kind', 'view', 'fleet', 'pickedRecord', 'follow', 'trails', 'destroy'], - view: [ - 'camera', - 'scene', - 'horizon', - 'worldToWindow', - 'canvasSize', - 'requestRender', - 'on', - ], - fleet: [ - 'collection', - 'attached', - 'attach', - 'setVisible', - 'add', - 'remove', - 'detach', - ], - follow: ['onChanged', 'active'], - trails: ['create'], -}); - -/** The members of VESSEL_RENDERER_SHAPE a renderer lacks ([] when whole). */ -export function missingVesselRendererMembers(renderer) { - const missing = []; - for (const name of VESSEL_RENDERER_SHAPE.top) - if (!(name in (renderer ?? {}))) missing.push(name); - for (const part of ['view', 'fleet', 'follow', 'trails']) - for (const name of VESSEL_RENDERER_SHAPE[part]) - if (!(name in (renderer?.[part] ?? {}))) missing.push(`${part}.${name}`); - return missing; -} diff --git a/src/layers/vessels/render/navara.js b/src/layers/vessels/render/navara.js index 454984e72..761c02cce 100644 --- a/src/layers/vessels/render/navara.js +++ b/src/layers/vessels/render/navara.js @@ -1,4 +1,4 @@ -// The vessels layer's renderer on the Navara engine (phase 3 Task 13b), +// The vessels layer's renderer on the Navara engine, // with render/cesium.js's interface (./contract.js), built only on the // engine contract: the chevrons are one engine.movers sprite bank (always on // top, as Cesium's depth-test-free billboards) whose atlas holds every @@ -14,8 +14,17 @@ // frame). import * as geo from '../../../geo/index.js'; -import { createMoverBank } from '../../aircraft/render/navara.js'; +import { + createMoverBank, + StoredColor, +} from '../../shared/render/navaraMoverBank.js'; import { createEngineLayerView } from '../../shared/render/engineView.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { + decodeImages, + imageAtlasLayout, + packImageAtlas, +} from '../../shared/render/imageAtlas.js'; import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; import { allVesselChevronIcons, VESSEL_ICON_PX } from '../icons.js'; @@ -25,62 +34,34 @@ const TRAIL_OCCLUDED_ALPHA = 0.4; const MIN_SEGMENT_DISTANCE_SQ = 0.01; const ATLAS_PAD = 2; +const iconEntry = (key, image) => ({ + key, + image, + width: VESSEL_ICON_PX, + height: VESSEL_ICON_PX, +}); +const atlasOptions = (icons) => ({ + cell: VESSEL_ICON_PX + 2 * ATLAS_PAD, + pad: ATLAS_PAD, + columns: icons.length, +}); + /** The chevron atlas layout (pure): one 32 px cell per icon, in a row. */ export function vesselAtlasLayout(icons = allVesselChevronIcons()) { - const cell = VESSEL_ICON_PX + 2 * ATLAS_PAD; - const width = icons.length * cell; - const height = cell; - const rects = new Map(); - const cells = {}; - icons.forEach((icon, column) => { - const x = column * cell + ATLAS_PAD; - const y = ATLAS_PAD; - rects.set(icon, { x, y, size: VESSEL_ICON_PX }); - cells[icon] = [ - x / width, - y / height, - (x + VESSEL_ICON_PX) / width, - (y + VESSEL_ICON_PX) / height, - ]; - }); - return { width, height, rects, cells }; + return imageAtlasLayout( + icons.map((icon) => iconEntry(icon)), + atlasOptions(icons), + ); } /** Draws the atlas (browser): a canvas and its cells, once the SVGs decode. */ -export async function drawVesselAtlas() { - const layout = vesselAtlasLayout(); - const canvas = document.createElement('canvas'); - canvas.width = layout.width; - canvas.height = layout.height; - const context = canvas.getContext('2d'); - await Promise.all( - [...layout.rects].map(async ([uri, { x, y, size }]) => { - const image = new Image(); - image.src = uri; - await image.decode(); - context.drawImage(image, x, y, size, size); - }), +async function drawVesselAtlas() { + const icons = allVesselChevronIcons(); + const images = await decodeImages(icons); + return packImageAtlas( + icons.map((icon, i) => iconEntry(icon, images[i])), + { ...atlasOptions(icons), resize: true }, ); - return { canvas, cells: layout.cells }; -} - -const cssToRgb = (css) => { - const hex = String(css).replace('#', ''); - return [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255); -}; - -/** Plain colour copy (what a handle's colour getter returns). */ -class StoredColor { - constructor(color) { - this.red = color?.red ?? 1; - this.green = color?.green ?? 1; - this.blue = color?.blue ?? 1; - this.alpha = color?.alpha ?? 1; - } - - withAlpha(alpha) { - return new StoredColor({ ...this, alpha }); - } } /** @@ -338,7 +319,7 @@ export function createVesselsNavaraRenderer( let lineSeq = 0; const trails = { create({ color, width = 2.5 }) { - const [r, g, b] = cssToRgb(color); + const [r, g, b] = rgbaFromCss(color).rgba(); const id = `gev-trail:${LAYER_ID}-${++lineSeq}`; let line = null; const ensure = () => diff --git a/src/layers/vessels/renderer.test.mjs b/src/layers/vessels/renderer.test.mjs index 496731a07..dd6df5d3c 100644 --- a/src/layers/vessels/renderer.test.mjs +++ b/src/layers/vessels/renderer.test.mjs @@ -1,11 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; -import * as selection from '../../data/selectionDispatcher.js'; -import * as picking from '../../data/pickRegistry.js'; import { createVesselLayer } from './index.js'; +import { recordingRenderer, services } from './testFixtures.mjs'; import { createVesselsCesiumRenderer } from './render/cesium.js'; -import { missingVesselRendererMembers } from './render/contract.js'; import { allVesselChevronIcons, vesselChevronIcon } from './icons.js'; import { VESSEL_TYPE_CSS_COLORS, @@ -14,109 +12,6 @@ import { const noop = () => {}; -/** A renderer of plain records over the engine: what the logic asks for. */ -function recordingRenderer(engine) { - const sprites = new Set(); - const followListeners = new Set(); - let following = false; - let attached = false; - return { - sprites, - raiseFollow(active) { - following = active; - for (const listener of followListeners) listener(); - }, - renderer: { - kind: 'plain', - engine, - view: { - camera: { - positionWC: { x: 0, y: 0, z: 20_000_000 }, - positionCartographic: { latitude: 0, longitude: 0, height: 1e7 }, - rightWC: { x: 1, y: 0, z: 0 }, - upWC: { x: 0, y: 1, z: 0 }, - heading: 0, - pitch: 0, - roll: 0, - }, - scene: { canvas: { clientWidth: 0, clientHeight: 0 } }, - horizon: () => ({ isPointVisible: () => true }), - worldToWindow: () => undefined, - canvasSize: () => ({ width: 1280, height: 800 }), - requestRender: noop, - on: () => noop, - }, - fleet: { - collection: null, - attached: () => attached, - attach() { - attached = true; - }, - setVisible: noop, - add(spec) { - const sprite = { ...spec }; - sprites.add(sprite); - return sprite; - }, - remove: (sprite) => sprites.delete(sprite), - detach() { - attached = false; - }, - }, - pickedRecord: (picked) => - picked?.id && typeof picked.id === 'object' ? picked.id : null, - follow: { - onChanged(listener) { - followListeners.add(listener); - return () => followListeners.delete(listener); - }, - active: () => following, - }, - trails: { create: () => null }, - destroy: noop, - }, - }; -} - -function services(renderer) { - return { - context: { - clearSelectedEntityContextForLayer: noop, - registerEntityContext: () => null, - selectEntityContext: noop, - }, - trails: { createTrail: null }, - labels: {}, - picking, - overlay: { - setOverlayEntries: noop, - setOverlaySourceVisible: noop, - clearOverlaySource: noop, - hitTestWorldOverlay: () => null, - }, - geoid: { ensureGeoidReady: () => new Promise(noop), geoidHeight: () => 0 }, - sprites: { - registerSpriteCollection: noop, - restoreSpriteOrder: noop, - restoreSpriteOrderOnEnable: noop, - }, - focus: { - focusNowMs: (now) => now, - getFocusTarget: () => null, - focusPassIsNeeded: () => false, - forgetSpriteFocus: noop, - }, - worldFocus: { requestWorldFocus: noop }, - render: { - holdContinuousRender: noop, - releaseContinuousRender: noop, - governorRequestRender: noop, - }, - selection, - renderer: { create: () => renderer }, - }; -} - const row = (mmsi, latitude, longitude) => ({ id: mmsi, reference: mmsi, @@ -130,11 +25,10 @@ const row = (mmsi, latitude, longitude) => ({ courseDeg: 90, }); -test('the vessels Cesium renderer has every member of the contract', () => { +test('the vessels Cesium renderer picks the record a billboard is of', () => { const renderer = createVesselsCesiumRenderer(null, { viewer: { scene: {}, camera: {} }, }); - assert.deepEqual(missingVesselRendererMembers(renderer), []); assert.equal(renderer.kind, 'cesium'); // The record a pick is of: Cesium's billboard id is the record. const record = { mmsi: '111' }; @@ -221,7 +115,6 @@ test('vessels on the Navara renderer: one always-on-top chevron bank, MMSI picks const renderer = createVesselsNavaraRenderer(engine, { drawAtlas: async () => ({ canvas: { kind: 'atlas' }, cells: layout.cells }), }); - assert.deepEqual(missingVesselRendererMembers(renderer), []); const layer = createVesselLayer({ source: { label: 'Fixture vessels', @@ -367,7 +260,8 @@ test('a pending AIS poll and selected trail survive replacement of the planet', }); test('a vessel bank commits rows arriving after its empty visibility commit and after growth', async () => { - const { createMoverBank } = await import('../aircraft/render/navara.js'); + const { createMoverBank } = + await import('../shared/render/navaraMoverBank.js'); let commits = 0; let pending = null; const bank = createMoverBank( diff --git a/src/layers/vessels/testFixtures.mjs b/src/layers/vessels/testFixtures.mjs new file mode 100644 index 000000000..03763a0a4 --- /dev/null +++ b/src/layers/vessels/testFixtures.mjs @@ -0,0 +1,137 @@ +// Test doubles shared by the vessel layer's tests: a renderer that records +// what the logic asks for, and the application services the layer reads. +import * as selection from '../../data/selectionDispatcher.js'; +import * as picking from '../../data/pickRegistry.js'; + +const noop = () => {}; +const RAD = Math.PI / 180; + +/** + * A renderer of plain records over the engine: what the logic asks for. + * `camera` places the view (default: 10,000 km over 0°, 0°); `moveTo` moves + * it 60 km over a point and raises moveEnd; `raiseFollow` raises a follow + * change. + */ +export function recordingRenderer( + engine, + { + positionWC = { x: 0, y: 0, z: 20_000_000 }, + positionCartographic = { latitude: 0, longitude: 0, height: 1e7 }, + } = {}, +) { + const sprites = new Set(); + const moveEnd = new Set(); + const followListeners = new Set(); + const camera = { + positionWC, + positionCartographic, + rightWC: { x: 1, y: 0, z: 0 }, + upWC: { x: 0, y: 1, z: 0 }, + heading: 0, + pitch: 0, + roll: 0, + }; + let following = false; + let attached = false; + return { + sprites, + camera, + moveTo(lat, lon) { + camera.positionCartographic = { + latitude: lat * RAD, + longitude: lon * RAD, + height: 60_000, + }; + for (const listener of moveEnd) listener(); + }, + raiseFollow(active) { + following = active; + for (const listener of followListeners) listener(); + }, + renderer: { + kind: 'plain', + engine, + view: { + camera, + scene: { canvas: { clientWidth: 0, clientHeight: 0 } }, + horizon: () => ({ isPointVisible: () => true }), + worldToWindow: () => undefined, + canvasSize: () => ({ width: 1280, height: 800 }), + requestRender: noop, + on(event, listener) { + if (event !== 'moveEnd') return noop; + moveEnd.add(listener); + return () => moveEnd.delete(listener); + }, + }, + fleet: { + collection: null, + attached: () => attached, + attach() { + attached = true; + }, + setVisible: noop, + add(spec) { + const sprite = { ...spec }; + sprites.add(sprite); + return sprite; + }, + remove: (sprite) => sprites.delete(sprite), + detach() { + attached = false; + }, + }, + pickedRecord: (picked) => + picked?.id && typeof picked.id === 'object' ? picked.id : null, + follow: { + onChanged(listener) { + followListeners.add(listener); + return () => followListeners.delete(listener); + }, + active: () => following, + }, + trails: { create: () => null }, + destroy: noop, + }, + }; +} + +/** The application services the layer reads, creating `renderer`. */ +export function services(renderer) { + return { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + selectEntityContext: noop, + }, + trails: { createTrail: null }, + labels: {}, + picking, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + hitTestWorldOverlay: () => null, + }, + geoid: { ensureGeoidReady: () => new Promise(noop), geoidHeight: () => 0 }, + sprites: { + registerSpriteCollection: noop, + restoreSpriteOrder: noop, + restoreSpriteOrderOnEnable: noop, + }, + focus: { + focusNowMs: (now) => now, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: { requestWorldFocus: noop }, + render: { + holdContinuousRender: noop, + releaseContinuousRender: noop, + governorRequestRender: noop, + }, + selection, + renderer: { create: () => renderer }, + }; +} diff --git a/src/locations.js b/src/locations.js index fdb0e26c2..0aae1f67e 100644 --- a/src/locations.js +++ b/src/locations.js @@ -317,7 +317,7 @@ export const CANCELLED_SEARCH = Object.freeze({ cancelled: true }); */ export async function searchAndFlyTo(target, query, options = {}) { const { placeSearch = unavailablePlaceSearch, signal } = options; - // The globe engine, or (voice, until phase 1c) a viewer registered for one. + // The globe engine, or (voice) a viewer registered for one. const engine = engineFromViewer(target); if (!engine) return null; signal?.throwIfAborted(); diff --git a/src/main.js b/src/main.js index 9e40e5bfc..2a560c38e 100644 --- a/src/main.js +++ b/src/main.js @@ -104,11 +104,7 @@ function liveComponents() { } // The page's application is created once; a switch detaches and attaches -// only its engine-bound parts. `?switch=legacy` keeps the previous route, one -// application per engine, for one release. -const legacySwitch = - new URLSearchParams(globalThis.location?.search ?? '').get('switch') === - 'legacy'; +// only its engine-bound parts. // Hand the session off and release the running engine. Its renderer is // parked for a later switch back. @@ -125,8 +121,7 @@ async function leave(from) { }); renderers.retain(from); } - if (legacySwitch) await application?.destroy(); - else if (attached) await application.detachScene({ handoff: true }); + if (attached) await application.detachScene({ handoff: true }); session.count('detaches'); } @@ -134,10 +129,9 @@ async function leave(from) { // with the session attached and the address's view restored; its first frame // only decides when the cover goes (settle). async function enter(kind, { recovery = false } = {}) { - if (legacySwitch) return enterLegacy(kind); if (!application || application.getState().status !== 'ready') { // The first engine never started: there is no application to attach to. - return enterLegacy(kind); + return enterNewApplication(kind); } layerSession.restoringView = true; try { @@ -180,8 +174,9 @@ async function resume(style) { } } -// The legacy route: a new application on the retained session. -async function enterLegacy(kind) { +// A new application on the retained session, for a page whose first engine +// never started. +async function enterNewApplication(kind) { const app = createApplication(kind, true); try { const { controls } = await app.start(); diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index d2cbd65e9..f02cea38e 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -5,7 +5,7 @@ import { horizonVisible as navaraHorizonVisible, projectToWindow as navaraProjectToWindow, viewProjection as navaraViewProjection, -} from '../globe/navara/cameraMath.js'; +} from '../globe/cameraMath.js'; import { AMBIENT_CARD_COLLISION_CAPACITY, destroyWorldOverlay, @@ -1242,8 +1242,8 @@ function buildPhase6DetectionWorkload(count) { } /** - * The Navara engine's projector (its camera maths, src/globe/navara/ - * cameraMath.js, as createNavaraEngine wires it) over a real WGS84 camera + * The Navara engine's projector (its camera maths, src/globe/cameraMath.js, + * as createNavaraEngine wires it) over a real WGS84 camera * 2 000 km over (0°, 0°), and labels on a lon/lat grid under it with the * horizon test ON: the per-label horizon test and the per-frame matrix of * the engine-neutral host path, not the Cesium mock's. diff --git a/src/scenes/dataPacks/geometry.js b/src/scenes/dataPacks/geometry.js index 00b391c8c..2e8300110 100644 --- a/src/scenes/dataPacks/geometry.js +++ b/src/scenes/dataPacks/geometry.js @@ -1,14 +1,7 @@ import * as Cesium from 'cesium'; -import { decodePackGeoJSON } from '../../director/packs/geojson.js'; const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p); -/** Render a bounded geometry pack in owned batches; no asynchronous entity geometry or update loop. */ -export function renderPackGeometry({ asset, pack }, handle) { - if (!['application/json', 'application/geo+json'].includes(asset.mimeType)) - throw new Error('Expected GeoJSON'); - return renderDecodedGeometry(decodePackGeoJSON(asset.bytes), pack, handle); -} - +/** Render decoded pack geometry in owned batches; no asynchronous entity geometry or update loop. */ export function renderDecodedGeometry(features, pack, handle) { const polygons = []; let lines; diff --git a/src/scenes/director.js b/src/scenes/director.js index e8e980c40..69cf48c4f 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -85,7 +85,7 @@ export class SceneDirector { * @param {Object} dataManager - Manages data layer enable/disable and per-layer params * @param {Object} [options] `engine`: the globe engine whose camera the * scene flights drive (defaults to the one registered for the viewer); the - * viewer still serves tracking, interactions and data packs (phase 1c). + * viewer still serves tracking, interactions and data packs. */ constructor( viewer, diff --git a/src/scopeMask.js b/src/scopeMask.js index 1e61a0c2e..880f9cc64 100644 --- a/src/scopeMask.js +++ b/src/scopeMask.js @@ -1,5 +1,4 @@ import { getKeyholeGeometry } from './celestialRing.js'; -import { isGlobeEngine } from './globe/contract.js'; import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js'; /** @@ -12,7 +11,7 @@ import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js'; * owner ruled: draw it explicitly on a canvas, make the edge featherable * (like the NVG/FLIR tube masks), and free the six shader passes for real. * - * Implementation: one fixed canvas parented into the viewer container + * Implementation: one fixed canvas parented into the globe container * BELOW the detection surface (z-index 2 < 5) so detection's * mix-blend-mode still composites over the masked scene, exactly like the * old in-scene artifact. The mask is a radial gradient — fully transparent @@ -88,8 +87,8 @@ const SCOPE_TERMINUS_SAMPLE_MS = 120; let _canvas = null; let _container = null; /** - * The globe the mask sits over: { height(), preRender, moveEnd } from a - * Cesium viewer or a globe engine (Navara). + * The globe the mask sits over: { height(), preRender, moveEnd } from its + * globe engine. */ let _globe = null; let _enabled = true; @@ -426,34 +425,27 @@ function syncScopeMaskAppearance(enabled = _enabled) { } /** - * The camera height and frame/camera events of a Cesium viewer or a globe - * engine, and the container the mask goes into. + * The camera height and frame/camera events of a globe engine, and the + * container the mask goes into. */ -function globeOf(target) { - if (isGlobeEngine(target)) - return { - container: target.host.container, - height: () => target.camera.height(), - preRender: target.frames.preRender, - moveEnd: target.camera.moveEnd, - }; +function globeOf(engine) { return { - container: target?.container, - height: () => target?.camera?.positionCartographic?.height, - preRender: target?.scene?.preRender, - moveEnd: target?.camera?.moveEnd, + container: engine?.host?.container, + height: () => engine?.camera?.height?.(), + preRender: engine?.frames?.preRender, + moveEnd: engine?.camera?.moveEnd, }; } /** - * Install the scope mask into the globe's container: a Cesium viewer's, or - * a globe engine's host container (Navara). Idempotent. - * @param {Cesium.Viewer | import('./globe/contract.js').GlobeEngine} target + * Install the scope mask into the globe engine's host container. + * Idempotent. + * @param {import('./globe/contract.js').GlobeEngine} engine * @returns {void} */ -export function installScopeMask(target) { +export function installScopeMask(engine) { if (_canvas) return; - const globe = globeOf(target); + const globe = globeOf(engine); // Only a DOM container can hold the mask (an engine double's cannot). if (typeof globe.container?.appendChild !== 'function') return; syncScopeMaskAppearance(); diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs index 0161a9a86..dd05eda11 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -242,7 +242,7 @@ test('scope appearance follows direct state changes and install/destroy lifecycl setScopeMaskEnabled(false); assert.equal(dom.documentElement.dataset.scopeMask, 'off'); delete dom.documentElement.dataset.scopeMask; - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); assert.equal( dom.documentElement.dataset.scopeMask, 'off', @@ -254,7 +254,7 @@ test('scope appearance follows direct state changes and install/destroy lifecycl undefined, 'destroy clears the document marker', ); - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); assert.equal( dom.documentElement.dataset.scopeMask, 'off', @@ -282,7 +282,7 @@ test('scope appearance follows direct state changes and install/destroy lifecycl test('a DPR change with no resize still repaints the backing store', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); // 1x monitor: backing store matches CSS pixels. assert.equal(dom.canvas.width, 1000); assert.equal(dom.canvas.height, 800); @@ -314,7 +314,7 @@ test('a DPR change with no resize still repaints the backing store', () => { test('destroy tears the DPR watch down (no redraw after teardown)', () => { const dom = stubScopeMaskDom({ width: 640, height: 480, dpr: 1 }); try { - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); assert.equal(dom.canvas.width, 640); destroyScopeMask(); dom.setDpr(2); @@ -439,7 +439,7 @@ test('quantization snaps to the repaint grid', () => { test('repaint gate: only a QUANTIZED step repaints; hovering costs nothing', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); const base = getScopeTerminusRepaintCount(); // Parked at true full-globe altitude: the seeded alpha already matches, so @@ -477,7 +477,7 @@ test('repaint gate: only a QUANTIZED step repaints; hovering costs nothing', () test('a full zoom-in gesture costs only a handful of repaints', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); const base = getScopeTerminusRepaintCount(); // 20 Mm → ground in 10 km steps: ~2000 samples across the whole descent, @@ -510,7 +510,7 @@ test('a full zoom-in gesture costs only a handful of repaints', () => { test('an override pins the terminus and null restores the ramp', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); setScopeTerminusOverride(0.97); assert.equal(getScopeTerminusOverride(), 0.97); assert.equal(getScopeTerminusAlpha(), 0.97); @@ -536,7 +536,7 @@ test('an override pins the terminus and null restores the ramp', () => { test('the hard-crop (feather 0) path honors the same terminus alpha', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); updateScopeTerminusForHeight(SCOPE_TERMINUS_FAR_M + 5_000_000); // true full-globe view setScopeMaskFeather(0); // hard crop — the evenodd rect-minus-circle path // The globe-scale seed paint is legitimately in the history, so assert on @@ -574,26 +574,26 @@ test('one quantum is the smallest step that can repaint', () => { // ~8 Hz through the altitude band and draw() performed the full backing-store // resize + clear BEFORE checking whether the scope was even enabled. -/** A viewer stub with the two camera signals the terminus sampler listens on. */ -function stubScopeViewer(container, heightM) { +/** An engine stub with the two camera signals the terminus sampler listens on. */ +function stubScopeEngine(container, heightM) { const preRenderListeners = new Set(); const moveEndListeners = new Set(); - const positionCartographic = { height: heightM }; + let height = heightM; const addTo = (set) => (listener) => { set.add(listener); return () => set.delete(listener); }; return { - viewer: { - container, - scene: { preRender: { addEventListener: addTo(preRenderListeners) } }, + engine: { + host: { container }, + frames: { preRender: { addEventListener: addTo(preRenderListeners) } }, camera: { - positionCartographic, + height: () => height, moveEnd: { addEventListener: addTo(moveEndListeners) }, }, }, setHeight(next) { - positionCartographic.height = next; + height = next; }, raisePreRender(times = 1) { for (let i = 0; i < times; i += 1) @@ -607,9 +607,9 @@ function stubScopeViewer(container, heightM) { test('a disabled scope does no canvas work and samples no camera heights', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); - const rig = stubScopeViewer(dom.container, 14_000_000); // true full-globe view + const rig = stubScopeEngine(dom.container, 14_000_000); // true full-globe view try { - installScopeMask(rig.viewer); + installScopeMask(rig.engine); assert.equal(getScopeTerminusAlpha(), SCOPE_OUTSIDE_ALPHA); // The transition must clear the painted mask EXACTLY once, and must not @@ -675,9 +675,9 @@ test('a disabled scope does no canvas work and samples no camera heights', () => test('a DPR change that also crosses a terminus step paints once, not twice', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); - const rig = stubScopeViewer(dom.container, SCOPE_TERMINUS_FAR_M); + const rig = stubScopeEngine(dom.container, SCOPE_TERMINUS_FAR_M); try { - installScopeMask(rig.viewer); + installScopeMask(rig.engine); assert.equal(getScopeTerminusAlpha(), SCOPE_OUTSIDE_ALPHA); // Descend into the band WITHOUT a frame, then drag the window to a 2x @@ -748,7 +748,7 @@ test('terminus percents clamp into the supported band; junk is absent, not zero' test('a pinned override is floored to the band at every entry point', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ container: dom.container }); + installScopeMask({ host: { container: dom.container } }); setScopeTerminusOverride(0); // an sce=0 link, or any stale caller assert.equal( getScopeTerminusOverride(), diff --git a/src/standalone/application.js b/src/standalone/application.js index f45ce9f65..53ac7cf1c 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -43,8 +43,7 @@ function sessionChrome(layerSession) { * `engineKind`; `attachScene(kind)` and `detachScene()` switch engines by * detaching and attaching only the engine-bound parts (src/app/sceneHost.js) * while the shell, stores, voice, tools and layer models stay. Destroy it - * before creating another (the legacy route, ?switch=legacy, still builds - * one per engine). + * before creating another. */ export function createStandaloneApplication({ googleApiKey, diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js index 742f473ee..2bc25310d 100644 --- a/src/standalone/engineSwitch.js +++ b/src/standalone/engineSwitch.js @@ -15,15 +15,14 @@ const ENGINES = Object.freeze(['cesium', 'navara']); /** A started engine whose first frame takes longer than this is shown anyway. */ -export const FIRST_FRAME_WAIT_MS = 10_000; +const FIRST_FRAME_WAIT_MS = 10_000; /** * Drive engine switches for one page session. * - * Every attempt has a generation. A failed attempt rolls back to the last - * engine that actually started (never to the one that just failed) and, when - * that recovery fails too, leaves the page without an engine but still able - * to take the next request. A page lifetime abort (pagehide) stops the + * A failed attempt rolls back to the last engine that actually started + * (never to the one that just failed) and, when that recovery fails too, + * leaves the page without an engine but still able to take the next request. A page lifetime abort (pagehide) stops the * machine: nothing starts after it. * * @param {object} options @@ -32,7 +31,7 @@ export const FIRST_FRAME_WAIT_MS = 10_000; * @param {AbortSignal} options.signal The page lifetime. * @param {(from: string, to: string, change: {visit: boolean}) => Promise} options.leave * Hand the session off from the running engine and release that engine. - * @param {(kind: string, attempt: {generation: number, recovery: boolean}) => Promise} options.enter + * @param {(kind: string, attempt: {recovery: boolean}) => Promise} options.enter * Start an engine on the retained session. It resolves once the engine runs * with the session attached; when it rejects it has already released what * it started. @@ -64,7 +63,6 @@ export function createEngineSwitch({ let working = kind; let failed = false; let busy = false; - let generation = 0; // Every request (select or address visit) bumps this; a visit also bumps // `visits`. let requests = 0; @@ -81,7 +79,6 @@ export function createEngineSwitch({ engine: live, busy, failed, - generation, }); const publish = () => onChange?.(state()); @@ -163,7 +160,6 @@ export function createEngineSwitch({ } async function attempt(target) { - const id = ++generation; const visitsAtStart = visits; const visit = pending?.to !== target; publish(); @@ -178,7 +174,7 @@ export function createEngineSwitch({ } if (signal.aborted) return; live = target; - await enter(target, { generation: id, recovery: false }); + await enter(target, { recovery: false }); if (signal.aborted) return; current = working = target; failed = false; @@ -202,7 +198,7 @@ export function createEngineSwitch({ } live = working; try { - await enter(working, { generation: id, recovery: true }); + await enter(working, { recovery: true }); if (signal.aborted) return; current = working; failed = false; diff --git a/src/testSupport/geminiIntegrationFixture.mjs b/src/testSupport/geminiIntegrationFixture.mjs index b5f4138dc..3515150b3 100644 --- a/src/testSupport/geminiIntegrationFixture.mjs +++ b/src/testSupport/geminiIntegrationFixture.mjs @@ -101,6 +101,8 @@ export function nativeIntegrationFixture(t) { }, }, }; + // A Viewer's canvas is its scene's. + viewer.canvas = viewer.scene.canvas; const dataManager = { layers: new Map() }; const actualRunner = createGevActionRunner({ engine: createCesiumEngine(viewer), diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index 11daa5e48..82e893719 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -45,7 +45,7 @@ export function readLayerSource(file) { ? '../layers/cctv' : '../layers/vessels', ); - // A layer's code includes its renderers (render/*.js, phase 3) and, for + // A layer's code includes its renderers (render/*.js) and, for // the aircraft layers, the shared aircraft renderer they draw through: // checks on what the layer does keep finding it where it moved. const directories = [directory, join(directory, 'render')]; diff --git a/src/time/clock.js b/src/time/clock.js index dff07526b..0d2eb509a 100644 --- a/src/time/clock.js +++ b/src/time/clock.js @@ -1,8 +1,8 @@ // The app's own time: milliseconds since the Unix epoch, read by layers and // both globe engines instead of Cesium's JulianDate or viewer.clock. By -// default it is the wall clock (Date.now) exactly; QA and tests can freeze, -// offset or speed it up. The globe engine latches one frame time per engine -// frame (beginFrame), so every reader in a frame sees the same value. +// default it is the wall clock (Date.now) exactly; QA and tests can freeze +// it. The globe engine latches one frame time per engine frame +// (beginFrame), so every reader in a frame sees the same value. // No engine, DOM or Node imports: this runs in the browser and in Node tests. const isFiniteNumber = (value) => @@ -22,17 +22,12 @@ function finite(value, label) { * @property {() => number} beginFrame Latches and returns the frame time; the * globe engine calls it once at the start of each of its frames. * @property {(ms?: number) => void} freeze Holds the time at `ms` (default: - * the current time). QA and tests only. Like offset and rate, it changes - * now() at once and frameTime() from the next beginFrame (the engine's - * next frame); request a render after it in request-render mode. + * the current time). QA and tests only. It changes now() at once and + * frameTime() from the next beginFrame (the engine's next frame); request + * a render after it in request-render mode. * @property {() => void} resume Runs on from the frozen time. - * @property {(ms: number) => void} offset Sets the time to the wall clock - * plus `ms`, running on from it at the current rate (it ends a freeze). - * QA and tests only. - * @property {(rate: number) => void} rate App milliseconds per wall - * millisecond (>= 0), without a jump. QA and tests only. * @property {() => void} reset Back to the wall clock. - * @property {() => { frozen: boolean, offsetMs: number, rate: number }} state + * @property {() => { frozen: boolean, offsetMs: number }} state * The controls in force (offsetMs: the current time minus the wall clock). */ @@ -47,33 +42,18 @@ const wallClock = () => Date.now(); export function createAppClock({ now: source = wallClock } = {}) { if (typeof source !== 'function') throw new TypeError('createAppClock needs a now() function'); - // Rate 1: wall + offsetMs, exact. Any other rate: anchorValue plus the wall - // time since anchorSource, scaled. Numbers live in object fields (stored in - // place), not closure variables, so the per-frame latch allocates nothing. + // Running: wall + offsetMs, exact. Numbers live in object fields (stored + // in place), not closure variables, so the per-frame latch allocates + // nothing. const state = { offsetMs: 0, - rate: 1, - anchorValue: 0, - anchorSource: 0, frozen: false, frozenMs: 0, latched: false, latchedMs: 0, }; - const running = () => - state.rate === 1 - ? source() + state.offsetMs - : state.anchorValue + (source() - state.anchorSource) * state.rate; - const now = () => (state.frozen ? state.frozenMs : running()); - // Make the running clock read `value` from this moment on. - const runFrom = (value) => { - if (state.rate === 1) state.offsetMs = value - source(); - else { - state.anchorValue = value; - state.anchorSource = source(); - } - }; + const now = () => (state.frozen ? state.frozenMs : source() + state.offsetMs); return { now, @@ -90,33 +70,16 @@ export function createAppClock({ now: source = wallClock } = {}) { resume() { if (!state.frozen) return; state.frozen = false; - runFrom(state.frozenMs); - }, - offset(ms) { - const value = source() + finite(ms, 'Clock offset'); - state.frozen = false; - runFrom(value); - }, - rate(rate) { - if (!(isFiniteNumber(rate) && rate >= 0)) - throw new TypeError(`Clock rate must be a finite number >= 0: ${rate}`); - if (state.frozen) { - state.rate = rate; - return; - } - const value = running(); - state.rate = rate; - runFrom(value); + // Run on from the frozen time. + state.offsetMs = state.frozenMs - source(); }, reset() { state.offsetMs = 0; - state.rate = 1; state.frozen = false; }, state: () => ({ frozen: state.frozen, offsetMs: now() - source(), - rate: state.rate, }), }; } diff --git a/src/time/clock.test.mjs b/src/time/clock.test.mjs index 195797cdb..18ca6c9b3 100644 --- a/src/time/clock.test.mjs +++ b/src/time/clock.test.mjs @@ -64,55 +64,15 @@ test('freeze without a time holds the current time', () => { assert.equal(clock.now(), at); }); -test('offset shifts the clock from the wall clock', () => { - const wall = manualSource(); - const clock = createAppClock({ now: wall.now }); - clock.offset(-30_000); - assert.equal(clock.now(), wall.now() - 30_000); - wall.advance(10); - assert.equal(clock.now(), wall.now() - 30_000); - clock.offset(0); - assert.equal(clock.now(), wall.now()); -}); - -test('rate runs the clock faster or slower without a jump', () => { - const wall = manualSource(); - const clock = createAppClock({ now: wall.now }); - const start = clock.now(); - clock.rate(4); - assert.equal(clock.now(), start); - wall.advance(1000); - assert.equal(clock.now(), start + 4000); - clock.rate(0); - wall.advance(1000); - assert.equal(clock.now(), start + 4000); - clock.rate(1); - wall.advance(500); - assert.equal(clock.now(), start + 4500); -}); - -test('a rate set while frozen applies from resume', () => { - const wall = manualSource(); - const clock = createAppClock({ now: wall.now }); - clock.freeze(1000); - clock.rate(2); - wall.advance(400); - assert.equal(clock.now(), 1000); - clock.resume(); - wall.advance(400); - assert.equal(clock.now(), 1800); -}); - test('reset returns to the wall clock and reports the state', () => { const wall = manualSource(); const clock = createAppClock({ now: wall.now }); - assert.deepEqual(clock.state(), { frozen: false, offsetMs: 0, rate: 1 }); - clock.rate(3); + assert.deepEqual(clock.state(), { frozen: false, offsetMs: 0 }); clock.freeze(); - assert.equal(clock.state().frozen, true); - assert.equal(clock.state().rate, 3); + wall.advance(300); + assert.deepEqual(clock.state(), { frozen: true, offsetMs: -300 }); clock.reset(); - assert.deepEqual(clock.state(), { frozen: false, offsetMs: 0, rate: 1 }); + assert.deepEqual(clock.state(), { frozen: false, offsetMs: 0 }); assert.equal(clock.now(), wall.now()); }); @@ -120,24 +80,12 @@ test('non-finite controls are refused', () => { const clock = createAppClock({ now: () => 0 }); for (const call of [ () => clock.freeze(Number.NaN), - () => clock.offset(Number.POSITIVE_INFINITY), - () => clock.rate('2'), - () => clock.rate(-1), + () => clock.freeze(Number.POSITIVE_INFINITY), ]) assert.throws(call, TypeError); assert.throws(() => createAppClock({ now: 5 }), TypeError); }); -test('offset ends a freeze and runs on from the wall clock plus the offset', () => { - const wall = manualSource(); - const clock = createAppClock({ now: wall.now }); - clock.freeze(5); - clock.offset(1000); - assert.equal(clock.now(), wall.now() + 1000); - wall.advance(20); - assert.equal(clock.now(), wall.now() + 1000); -}); - test('the default wall clock is read through Date.now at each call', (t) => { const clock = createAppClock(); t.mock.method(Date, 'now', () => 1_800_000_000_000); diff --git a/src/tooling/mainChunk.js b/src/tooling/mainChunk.js index dd2bfe29b..78f3339e1 100644 --- a/src/tooling/mainChunk.js +++ b/src/tooling/mainChunk.js @@ -1,7 +1,8 @@ -// The main chunk of a Vite build and what it must not carry: the Cesium-mode -// page loads the entry chunk and every chunk it imports statically; Navara and -// three belong in the lazy chunk navaraMode.js loads with import(). Pure -// helpers for scripts/check-main-chunk.mjs. +// The main chunk of a Vite build and what the Cesium-mode page must not +// load: Navara and three belong in the lazy chunk navaraMode.js loads with +// import(). Pure helpers for scripts/check-main-chunk.mjs (which entry the +// page loads) and scripts/qa-strict-cold-chunks.mjs (which chunks a cold +// start fetches carry three or Navara code). /** * Markers that only three or Navara code leaves in a bundle (minification @@ -40,37 +41,6 @@ export function entryFromManifest(manifest) { return entry?.file ?? null; } -/** Sibling chunks a chunk imports statically (`from"./x.js"`, `import"./x.js"`). */ -export function staticImportsOf(code) { - const found = []; - const pattern = /(?:\bfrom|\bimport)\s*(["'])\.\/([^"']+\.js)\1/g; - for (const match of String(code).matchAll(pattern)) { - if (!found.includes(match[2])) found.push(match[2]); - } - return found; -} - -/** - * The entry and every chunk reachable from it by static imports, in - * discovery order. `read(file)` returns a chunk's code (file relative to - * dist, in the entry's directory). - */ -export function initialChunks(entry, read) { - const directory = entry.includes('/') - ? entry.slice(0, entry.lastIndexOf('/') + 1) - : ''; - const seen = [entry]; - for (let index = 0; index < seen.length; index++) { - const code = read(seen[index]); - if (code == null) continue; - for (const name of staticImportsOf(code)) { - const file = directory + name; - if (!seen.includes(file)) seen.push(file); - } - } - return seen; -} - /** The names of FORBIDDEN_MARKERS found in code. */ export function forbiddenMarkersIn(code) { const text = String(code); diff --git a/src/tooling/mainChunkComposition.test.mjs b/src/tooling/mainChunkComposition.test.mjs index edd0b2beb..fb09d2711 100644 --- a/src/tooling/mainChunkComposition.test.mjs +++ b/src/tooling/mainChunkComposition.test.mjs @@ -5,8 +5,6 @@ import { entryFromHtml, entryFromManifest, forbiddenMarkersIn, - initialChunks, - staticImportsOf, } from './mainChunk.js'; test('the entry is the module script the built index.html loads', () => { @@ -42,31 +40,6 @@ test('the entry of a Vite manifest is the chunk marked isEntry for index.html', assert.equal(entryFromManifest(manifest), 'assets/index-A.js'); }); -test('static imports are found, dynamic imports are not', () => { - const code = - 'import{a as b}from"./vendor-C.js";import"./side-D.js";' + - 'const x=()=>import("./navaraMode-B.js");' + - "import { y } from './spaced-E.js';"; - assert.deepEqual(staticImportsOf(code), [ - 'vendor-C.js', - 'side-D.js', - 'spaced-E.js', - ]); -}); - -test('the initial chunks are the entry and everything it imports statically', () => { - const files = { - 'assets/index-A.js': 'import"./vendor-C.js";import("./lazy-L.js")', - 'assets/vendor-C.js': 'import{z}from"./deep-D.js"', - 'assets/deep-D.js': 'import"./vendor-C.js"', - 'assets/lazy-L.js': 'THREE.WebGLRenderer', - }; - assert.deepEqual( - initialChunks('assets/index-A.js', (file) => files[file]), - ['assets/index-A.js', 'assets/vendor-C.js', 'assets/deep-D.js'], - ); -}); - test('three and Navara markers are reported, ordinary engine names are not', () => { assert.deepEqual( forbiddenMarkersIn( @@ -85,22 +58,11 @@ test('three and Navara markers are reported, ordinary engine names are not', () assert.ok(FORBIDDEN_MARKERS.length >= 2); }); -test('the script exports the gzip budget check (phase 4 shape) next to the composition check', async () => { - const { mkdirSync, mkdtempSync, writeFileSync } = await import('node:fs'); - const { tmpdir } = await import('node:os'); - const path = await import('node:path'); - const { checkMainChunk, measureMainChunk } = - await import('../../scripts/check-main-chunk.mjs'); +test('the budget allows growth up to the allowance', async () => { + const { checkMainChunk } = await import('../../scripts/check-main-chunk.mjs'); const baseline = { gzipBytes: 1000, allowanceBytes: 100 }; assert.equal(checkMainChunk(1100, baseline).ok, true); assert.equal(checkMainChunk(1101, baseline).ok, false); - const dist = mkdtempSync(path.join(tmpdir(), 'gev-main-chunk-')); - mkdirSync(path.join(dist, 'assets')); - writeFileSync(path.join(dist, 'assets/index-small.js'), 'x'); - writeFileSync(path.join(dist, 'assets/index-main.js'), 'x'.repeat(5000)); - const measured = measureMainChunk(dist); - assert.equal(measured.name, 'index-main.js'); - assert.ok(measured.gzipBytes > 0 && measured.gzipBytes < 5000); }); test('the budget measures the entry the page loads, not the largest index chunk', async () => { diff --git a/src/tooling/packageBoundaries.test.mjs b/src/tooling/packageBoundaries.test.mjs index 548cadbf4..3cee9fd32 100644 --- a/src/tooling/packageBoundaries.test.mjs +++ b/src/tooling/packageBoundaries.test.mjs @@ -126,3 +126,39 @@ test('a lifecycle owner cannot acquire a layer panel even through an unused impo ); await assert.rejects(checkPackageBoundaries(root), /unowned module.*panel/); }); + +test('a module entry ending in a slash owns every file under that directory', async (t) => { + const root = await fixture( + t, + "import { a } from './shared/a.js'; export const value = a;", + ); + await mkdir(path.join(root, 'shared/nested'), { recursive: true }); + await writeFile( + path.join(root, 'shared/a.js'), + "export { b as a } from './nested/b.js';", + ); + await writeFile(path.join(root, 'shared/nested/b.js'), 'export const b = 1;'); + await writeFile( + path.join(root, 'scripts/package-boundaries.json'), + JSON.stringify({ + feature: { + exports: ['./feature'], + modules: ['entry.js', 'shared/'], + external: [], + }, + }), + ); + assert.deepEqual(await checkPackageBoundaries(root), [ + { name: 'feature', exports: 1, modules: 3 }, + ]); + // The prefix is a directory, not a file-name prefix: shared.js stays unowned. + await writeFile( + path.join(root, 'shared/nested/b.js'), + "export { c as b } from '../../shared.js';", + ); + await writeFile(path.join(root, 'shared.js'), 'export const c = 1;'); + await assert.rejects( + checkPackageBoundaries(root), + /unowned module.*shared\.js/, + ); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index d7bf883f7..41cce2b4e 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -666,8 +666,8 @@ export class StyleManager extends ShellFacade { // production source explicitly registers entries. initWorldOverlay(engine); // Initialize detection overlay BEFORE style stages so the composite - // stage is first in the post-process pipeline. Both run on the engine - // (the Navara mode too: its layers arrive with Tasks 10a-13a). + // stage is first in the post-process pipeline. Both run on the engine, + // Navara's too. { initDetection( engine, @@ -2061,8 +2061,8 @@ export class StyleManager extends ShellFacade { const handoff = Boolean(layerSession?.handoffLayers) && !layerSession.lifetime.signal.aborted; - // A Context mode the next shell can resume (the legacy engine route, - // ?switch=legacy, still builds a shell per engine). + // A Context mode the next shell can resume when the session is handed to + // a new application. const retainContext = handoff && this._contextControls.canRetainScene(); this._feedback.clearLoadingStatus(); this._disposed = true; diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 77184869b..a9acddd4f 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -142,7 +142,7 @@ export function readCameraTargetFrame(target, tracker) { * @param {object} frame From {@link readCameraTargetFrame}. * @param {object} [tracker] As in readCameraTargetFrame. */ -export function setCameraTargetFrame(target, frame, tracker) { +function setCameraTargetFrame(target, frame, tracker) { const engine = engineOf(target); if (!engine || !frame?.target || !Number.isFinite(frame.range)) return false; const viewer = trackerOf(target, tracker); @@ -311,7 +311,7 @@ export function createCameraOrientationAnimator( * showToast?: Function, * animationMs?: number, * }} options `engine` defaults to the one registered for `viewer`; the viewer - * is otherwise only read for its tracked entity (phase 1c). + * is otherwise only read for its clock and tracked entity. */ export function bindCameraOrientationControls({ viewer = null, diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index 1302d42cd..12c8f2c9c 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -18,8 +18,10 @@ import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; // The controls run on the globe engine; these viewer doubles get the Cesium -// engine registered for them, as the app's scene engine is. +// engine registered for them, as the app's scene engine is. A Viewer's +// canvas is its scene's. function withEngine(viewer) { + viewer.canvas = viewer.scene.canvas; registerViewerEngine(viewer, createCesiumEngine(viewer)); return viewer; } diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index 5185f9ab5..1377bbad5 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -11,7 +11,7 @@ import DrawControls from './views/panels/DrawControls.vue'; import GeoImport from './views/panels/GeoImport.vue'; import { AircraftDisplay } from './aircraftDisplay.js'; import { VisualSettings } from './visualSettings.js'; -import { initGeoFileImport } from './geoFileImport.js'; +import { createGeoFileImport } from './geoFileImport.js'; import { CockpitDisplayPortal } from './cockpitDisplayPortal.js'; // Core's classes on an element, without the kit's `ui-` classes. const coreClasses = (el) => @@ -413,8 +413,7 @@ test('the imported files render what the file import publishes and keep focus', scene: { requestRender() {} }, dataSources: { add: async (ds) => ds, remove: () => true }, }; - const tool = initGeoFileImport({ - viewer, + const tool = createGeoFileImport({ load: async (file) => ({ dataSource: { show: true }, name: file.name, @@ -430,6 +429,7 @@ test('the imported files render what the file import publishes and keep focus', return () => store.detach(token); }, }); + tool.attachScene({ viewer }); t.after(() => tool.destroy()); await tool.importFiles([{ name: 'a.geojson' }, { name: 'b.kml' }]); await nextTick(); diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index bf30bdaaa..88c6b17a7 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -20,18 +20,6 @@ function localizedValues(values) { ); } -/** - * Run the Import control on one viewer: the session's control (see - * createGeoFileImport) attached to `viewer`. Null without a viewer or the - * Import markup. - */ -export function initGeoFileImport({ viewer, ...options }) { - if (!viewer) return null; - const tool = createGeoFileImport(options); - tool?.attachScene({ viewer }); - return tool; -} - /** * The Import control for the page session. * @param {{load?: Function, document?: Document, publish?: Function, @@ -618,7 +606,7 @@ export function summarize(outcomes) { * What the attached globe leaves out of the given files (each `{ name, * undrawn }`): the first file's name and the total count. */ -export function undrawnHint(items) { +function undrawnHint(items) { return t('Map objects this engine leaves out of {name}: {count}.', { name: items[0].name, count: formatNumber(items.reduce((n, item) => n + item.undrawn, 0)), diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 0300e62e5..806caec86 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -7,7 +7,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { initGeoFileImport, summarize } from './geoFileImport.js'; +import { createGeoFileImport, summarize } from './geoFileImport.js'; import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js'; class FakeClassList { @@ -105,8 +105,7 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { revoke: () => revoked.push(file.name), }; }; - const tool = initGeoFileImport({ - viewer, + const tool = createGeoFileImport({ load: load || defaultLoad, document, ...(navigation ? { navigation } : {}), @@ -120,6 +119,7 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { return () => (actions = null); }, }); + tool?.attachScene({ viewer }); return { tool, els, @@ -148,16 +148,9 @@ const ROW_ACTIONS = { }; const press = (t, row, name) => t.act(ROW_ACTIONS[name], row.id); -test('returns null without its markup or viewer', () => { +test('returns null without its markup', () => { const document = { getElementById: () => null }; - assert.equal(initGeoFileImport({ viewer: {}, document }), null); - assert.equal( - initGeoFileImport({ - viewer: null, - document: { getElementById: () => new FakeElement() }, - }), - null, - ); + assert.equal(createGeoFileImport({ document }), null); }); test('a pick imports each file', async () => { diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 9d1eca686..47446f23f 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -10,7 +10,7 @@ import { /** * Own camera authority generations, pending search UI and tracking handoff. * The camera is the globe `engine`'s. `viewer` is optional and only clears - * Cesium's tracked entity (entity tracking moves to the engine in phase 1c). + * Cesium's tracked entity. */ export class NavigationController { constructor({ diff --git a/src/ui/pendingVisualSettings.js b/src/ui/pendingVisualSettings.js index 86b663490..cb65cf604 100644 --- a/src/ui/pendingVisualSettings.js +++ b/src/ui/pendingVisualSettings.js @@ -6,7 +6,7 @@ // Reads answer from the outgoing settings' last state. /** The visual choices kept for the next engine, by method name. */ -export const PENDING_VISUAL_CHOICES = Object.freeze( +const PENDING_VISUAL_CHOICES = Object.freeze( new Set([ 'setStyle', 'setBloom', diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 67d508805..4b92517bc 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -2,7 +2,7 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { APPLICATION_LAYER_DIRECTORY } from '../app/layers/directory.js'; /** How long the "kept in the link" notice stays up (ms). */ -export const NOT_ON_THIS_ENGINE_DWELL_MS = 15_000; +const NOT_ON_THIS_ENGINE_DWELL_MS = 15_000; /** Names of the layers a link turned on that this engine does not have. */ export function layersNotOnThisEngine(results) { diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 81422710c..ace7173f6 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -18,7 +18,7 @@ import { * reaches the engine. A value the engine refuses (NaN from an old link, * say) stays here and is reported, as a Cesium uniform would just draw it. */ -export function engineStyleStage(render, name, shader) { +function engineStyleStage(render, name, shader) { const values = { intensity: 0 }; if (shader.fragmentShader.includes('uniform float time')) values.time = 0; for (const [uniform, meta] of Object.entries(shader.uniforms || {})) diff --git a/src/voice/geminiNativeRecovery.test.mjs b/src/voice/geminiNativeRecovery.test.mjs index 07e8064e9..634e7dd1f 100644 --- a/src/voice/geminiNativeRecovery.test.mjs +++ b/src/voice/geminiNativeRecovery.test.mjs @@ -78,6 +78,8 @@ function fixture({ holdFirst = false } = {}) { }, }, }; + // A Viewer's canvas is its scene's. + viewer.canvas = viewer.scene.canvas; const runner = createGevActionRunner({ engine: createCesiumEngine(viewer), viewer, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d9ff7666a..8414ebc8d 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -258,7 +258,7 @@ export const UNAVAILABLE_FEATURES = Object.freeze({ }); /** The engine to switch to for a refused feature. */ -export const SWITCH_ENGINE_HINT = '?engine=cesium'; +const SWITCH_ENGINE_HINT = '?engine=cesium'; const engineName = (engine) => engine?.kind === 'navara' ? 'the Navara engine' : 'this engine'; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 62f9260f1..b2a2a9281 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -184,6 +184,8 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { lookAtTransform() {}, }, }; + // A Viewer's canvas is its scene's. + viewer.canvas = viewer.scene.canvas; // Location flights run on the globe engine registered for the viewer, as // the app's scene engine is (src/app/globeEngine.js). registerViewerEngine(viewer, createCesiumEngine(viewer)); @@ -3577,6 +3579,8 @@ function createPrewarmHarness({ pickPosition, positionCartographic } = {}) { }, }, }; + // A Viewer's canvas is its scene's. + viewer.canvas = viewer.scene.canvas; return { viewer, calls, diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index 48e9a757d..b8209cb4a 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -1,4 +1,4 @@ -// The W1 voice actions on the globe engine (phase 3, Task 9): the same +// The W1 voice actions on the globe engine: the same // results as on the Cesium viewer, through engine.camera / picker / // projector only, and a structured refusal when a required capability // is missing from the attached scene. @@ -395,19 +395,21 @@ test('select_nearest_aircraft runs on the engine end to end', async () => { assert.equal(tracked, 'n1'); }); -test('the camera pose key reads the engine as it reads the viewer', () => { +test('the camera pose key reads the engine pose', () => { const engine = engineOverAustin({ kind: 'navara' }); - const pose = engine.camera.pose(); - const viewer = { - camera: { - positionWC: pose.position, - heading: pose.heading, - pitch: pose.pitch, - roll: pose.roll, - }, - }; - assert.equal(typeof cameraPoseKey(engine), 'string'); - assert.equal(cameraPoseKey(engine), cameraPoseKey(viewer)); + const { position, heading, pitch, roll } = engine.camera.pose(); + const angle = (value) => Math.round(value * 1000); + assert.equal( + cameraPoseKey(engine), + [ + Math.round(position.x), + Math.round(position.y), + Math.round(position.z), + angle(heading), + angle(pitch), + angle(roll), + ].join(','), + ); }); test('the pointer picker picks through the engine', () => { diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js index 658e4460d..d649b25a4 100644 --- a/src/voice/gevRealtime.js +++ b/src/voice/gevRealtime.js @@ -38,7 +38,7 @@ export function withToolCatalog(runner, loadCatalog) { * session; the action runner, the pointer's canvas and picker and the * annotation events belong to one engine attachment (`attachScene`), and a * switch only replaces those. Returns `{ commands, runner, attachScene, - * detachScene }`; `commands` is what initGevVoiceCommands returns. + * detachScene }`; `commands` is the voice controls' command handle. * * `isSwitching()` tells the runner that the page has started a switch, so a * call made before the outgoing scene detaches waits for the next one too. @@ -166,14 +166,3 @@ export function createSceneVoice(options) { detachScene, }; } - -/** - * Compose the standalone action runner with the voice controls, on the globe - * `engine` (by default the one registered for `options.viewer`). The runner - * checks each action against the capabilities of the attached scene. - */ -export function initGevVoiceCommands(options) { - const voice = createSceneVoice(options); - voice.attachScene(options); - return voice.commands; -} diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index 5037a9f3e..ca18b21c3 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -2859,13 +2859,13 @@ test('isBenignViewportDeleteError is false for non-error payloads and junk', () test('hidden document yields no fresh frame — capture must not label a stale canvas Current', async () => { const originalDocument = globalThis.document; let requested = 0; - const scene = { - postRender: { addEventListener() { return () => {}; } }, - requestRender() { requested += 1; }, + const engine = { + frames: { postRender: { addEventListener() { return () => {}; } } }, + render: { request() { requested += 1; } }, }; try { globalThis.document = { hidden: true }; - const fresh = await renderFreshCesiumFrame({ scene }); + const fresh = await renderFreshCesiumFrame(engine); assert.equal(fresh, false, 'hidden capture reports non-fresh'); assert.equal(requested, 0, 'no secret render restart while hidden'); } finally { @@ -2878,11 +2878,11 @@ test('visible document with a rendering scene reports a fresh frame', async () = try { globalThis.document = { hidden: false }; let fire = null; - const scene = { - postRender: { addEventListener(listener) { fire = listener; return () => { fire = null; }; } }, - requestRender() { queueMicrotask(() => fire?.()); }, + const engine = { + frames: { postRender: { addEventListener(listener) { fire = listener; return () => { fire = null; }; } } }, + render: { request() { queueMicrotask(() => fire?.()); } }, }; - const fresh = await renderFreshCesiumFrame({ scene }); + const fresh = await renderFreshCesiumFrame(engine); assert.equal(fresh, true); } finally { globalThis.document = originalDocument; @@ -2895,11 +2895,11 @@ test('a tab switch during the bounded render wait invalidates freshness', async const doc = { hidden: false }; globalThis.document = doc; let fire = null; - const scene = { - postRender: { addEventListener(listener) { fire = listener; return () => { fire = null; }; } }, - requestRender() { doc.hidden = true; queueMicrotask(() => fire?.()); }, + const engine = { + frames: { postRender: { addEventListener(listener) { fire = listener; return () => { fire = null; }; } } }, + render: { request() { doc.hidden = true; queueMicrotask(() => fire?.()); } }, }; - const fresh = await renderFreshCesiumFrame({ scene }); + const fresh = await renderFreshCesiumFrame(engine); assert.equal(fresh, false, 'freshness rechecked after the await'); } finally { globalThis.document = originalDocument; diff --git a/src/voice/hudPlaceLabels.test.mjs b/src/voice/hudPlaceLabels.test.mjs index 0a9b0fb1a..9b3e688f9 100644 --- a/src/voice/hudPlaceLabels.test.mjs +++ b/src/voice/hudPlaceLabels.test.mjs @@ -66,7 +66,7 @@ function createViewer({ latitude, longitude, heightM, groundM = 0 }) { ), }; return { - viewer: createCesiumEngine({ camera, scene }), + viewer: createCesiumEngine({ camera, canvas, scene }), state, degreesPerPixel, }; diff --git a/src/voice/pointerContext.js b/src/voice/pointerContext.js index 911a61ef2..2f06ad4c4 100644 --- a/src/voice/pointerContext.js +++ b/src/voice/pointerContext.js @@ -19,8 +19,6 @@ * Provider-neutral and Cesium-free: the pick is injected. */ -import { isGlobeEngine } from '../globe/contract.js'; - /** Movement below this many CSS pixels is still the same dwell. */ const POINTER_DWELL_PX = 12; /** Stillness that makes a dwell point. */ @@ -59,17 +57,12 @@ const finite = finiteOrNull; * Camera pose identity: position to 1 m, orientation to about 0.06 degrees. * A pixel read under one key names nothing under another. Reads plain * numbers only, so it needs no Cesium import. - * @param {object} globe The globe engine (its camera pose), or a Cesium - * viewer. + * @param {object} engine The globe engine (its camera pose). * @returns {string|null} */ -export function cameraPoseKey(globe) { - let camera = globe?.camera; - if (isGlobeEngine(globe)) { - const pose = camera.pose(); - camera = { ...pose, positionWC: pose.position }; - } - const p = camera?.positionWC; +export function cameraPoseKey(engine) { + const camera = engine?.camera?.pose?.(); + const p = camera?.position; if (!p || !Number.isFinite(p.x)) return null; const angle = (value) => Math.round((Number(value) || 0) * 1000); return [ diff --git a/src/voice/pointerCrop.test.mjs b/src/voice/pointerCrop.test.mjs index 57c8a5983..d9a3c21b2 100644 --- a/src/voice/pointerCrop.test.mjs +++ b/src/voice/pointerCrop.test.mjs @@ -96,21 +96,25 @@ test('crop: a slow render gets one bounded fresh-frame recovery request', async let listener = null; try { globalThis.document = { hidden: false }; - const scene = { - postRender: { - addEventListener(next) { - listener = next; - return () => { - if (listener === next) listener = null; - }; + const engine = { + frames: { + postRender: { + addEventListener(next) { + listener = next; + return () => { + if (listener === next) listener = null; + }; + }, }, }, - requestRender() { - requests += 1; - if (requests === 2) queueMicrotask(() => listener?.()); + render: { + request() { + requests += 1; + if (requests === 2) queueMicrotask(() => listener?.()); + }, }, }; - assert.equal(await renderFreshPointerFrame({ scene }), true); + assert.equal(await renderFreshPointerFrame(engine), true); assert.equal(requests, 2, 'only the pointer path retries once'); assert.equal(listener, null, 'the successful listener is removed'); } finally { @@ -123,13 +127,15 @@ test('crop: a hidden document never starts the recovery request', async () => { let requests = 0; try { globalThis.document = { hidden: true }; - const scene = { - postRender: { addEventListener: () => () => {} }, - requestRender() { - requests += 1; + const engine = { + frames: { postRender: { addEventListener: () => () => {} } }, + render: { + request() { + requests += 1; + }, }, }; - assert.equal(await renderFreshPointerFrame({ scene }), false); + assert.equal(await renderFreshPointerFrame(engine), false); assert.equal(requests, 0); } finally { globalThis.document = originalDocument; diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index b2b926629..aa5f91b74 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -179,6 +179,7 @@ function compositionFixture( const position = { x: 6379137, y: 0, z: 0 }; const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, + canvas, camera: { positionWC: position, positionCartographic: { height: state.height }, diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index 3bb504eeb..7678f633b 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -3,21 +3,19 @@ import { hasStructuredViewIdentity, } from './realtimeProtocol.js'; import { cameraPoseKey } from './pointerContext.js'; -import { isGlobeEngine } from '../globe/contract.js'; -/** The page's globe engine (both engines), else its Cesium viewer. */ +/** The page's globe engine (both engines). */ function pageGlobe() { - const debug = globalThis.window?.__godsEyeView; - return debug?.engine ?? debug?.viewer ?? null; + return globalThis.window?.__godsEyeView?.engine ?? null; } -/** The map canvas: the engine's host canvas, else the viewer's. */ +/** The map canvas: the engine's host canvas. */ function pageCanvas(globe) { - return isGlobeEngine(globe) ? globe.host.canvas : globe?.scene?.canvas; + return globe?.host?.canvas; } /** The live camera pose identity (Cesium-free read). */ -export function readViewerCameraKey() { +function readViewerCameraKey() { return cameraPoseKey(pageGlobe()); } // Viewport-screenshot size guards (M13). The old code clamped WIDTH only, so a @@ -142,7 +140,7 @@ export function pointerCropRect({ * @returns {Promise<{dataUrl: string, rect: object}|null>} JPEG data URL and * the crop's normalized bounds in the full canvas, or null. */ -export async function capturePointerCrop(screenPx) { +async function capturePointerCrop(screenPx) { const globe = pageGlobe(); const source = pageCanvas(globe); if (!source?.width || !source?.height || !screenPx) return null; @@ -243,14 +241,12 @@ export function estimateDataUrlBytes(dataUrl) { * canvas as current. (perf wave 2) */ export async function renderFreshFrame(globe) { - // The engine's frame events and render request, else the viewer's scene. - const scene = isGlobeEngine(globe) - ? { - postRender: globe.frames.postRender, - requestRender: () => globe.render.request('voice-capture'), - } - : globe?.scene; - if (!scene) return false; + if (!globe) return false; + // The engine's frame events and render request. + const scene = { + postRender: globe.frames.postRender, + requestRender: () => globe.render.request('voice-capture'), + }; // While the document is hidden the render loop is suspended — don't // secretly restart rendering for an optional screenshot, and don't pass // the stale preserved frame off as current. @@ -509,5 +505,5 @@ export class RealtimeViewport { } } -/** renderFreshFrame for callers that hold a Cesium viewer. */ +/** renderFreshFrame under its earlier name (realtimeController re-exports it). */ export const renderFreshCesiumFrame = renderFreshFrame;