From a6c64713eeaf60909515e34f50b24484f081a7e4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:18:23 +0400 Subject: [PATCH] Stop Navara's memory growth on engine switches, hide polygon zones reliably, and drop the Cesium camera rig - Navara 0.2.1 never frees pick batch ids, and the mover banks rebuilt their instanced meshes on each attach, leaking about 260 KiB of WebAssembly memory per switch. A per-view pool hands released ids back out; the long soak now asserts native growth of at most 32 KiB per switch. - Navara 0.2.1 sometimes drops the first show:false update on a new vector layer, so a zone turned off right after a switch back stayed drawn. Polygon displays hide by deleting their layers and rebuild them when shown. - Camera verbs drive both engines through the globe contract; the Cesium viewer rig and the tests of states it can no longer reach are gone. - Flights and military tracking share src/layers/aircraft/tracking.js. - The geo import scenario reads movers only where the engine has them. - Add the Navara section to docs/CURRENT-STATE.md and a changelog fragment. --- changelog.d/1555-navara-globe-engine.md | 5 + docs/CURRENT-STATE.md | 65 +- scripts/format-scope.json | 3 + scripts/package-boundaries.json | 5 + scripts/qa-navara-geo-import.mjs | 4 +- scripts/qa-strict-soak.mjs | 22 +- src/app/scene.js | 4 +- src/app/tools.js | 1 + src/cameraGlobeRotation.test.mjs | 232 +--- src/cameraVerbs.js | 297 +---- src/cameraVerbsEngine.test.mjs | 90 +- src/data/trackingClickGesture.test.mjs | 5 +- src/globe/cesium/camera.js | 44 +- src/globe/cesium/camera.test.mjs | 90 ++ src/globe/cesium/clock.js | 11 +- src/globe/cesium/clock.test.mjs | 3 +- src/globe/cesium/follow.js | 11 +- src/globe/cesium/follow.test.mjs | 27 + src/globe/cesium/testing/cameraViewer.mjs | 73 + src/globe/cesium/testing/fakeViewer.mjs | 12 +- src/globe/contract.js | 2 + src/globe/navara/movers/batchIds.js | 40 + src/globe/navara/movers/batchIds.test.mjs | 68 + src/globe/navara/movers/index.js | 21 +- src/layers/airQuality/render/navara.js | 3 +- src/layers/aircraft/sceneBinding.test.mjs | 5 +- src/layers/aircraft/tracking.js | 1182 +++++++++++++++++ src/layers/aircraft/tracking.test.mjs | 157 +++ src/layers/cyclones/render/navara.js | 5 +- src/layers/directions/index.js | 3 +- src/layers/flights/tracking.js | 1119 +--------------- src/layers/infrastructure/render/navara.js | 29 +- src/layers/military/tracking.js | 1135 +--------------- src/layers/shared/render/navaraPolygons.js | 67 +- .../shared/render/navaraPolygons.test.mjs | 82 ++ src/layers/shared/render/navaraZones.js | 4 +- src/routeCinematics.test.mjs | 102 +- src/testSupport/readLayerSource.mjs | 29 +- src/ui/cameraOrientationControls.test.mjs | 6 + src/ui/flyToDataSource.js | 70 +- src/ui/flyToDataSource.test.mjs | 71 +- src/ui/geoFileImport.js | 12 +- src/ui/geoFileImport.test.mjs | 15 +- src/voice/gevActions.js | 11 +- src/voice/gevActions.test.mjs | 2 + src/voice/gevActionsCapabilities.test.mjs | 17 +- 46 files changed, 2367 insertions(+), 2894 deletions(-) create mode 100644 changelog.d/1555-navara-globe-engine.md create mode 100644 src/globe/cesium/testing/cameraViewer.mjs create mode 100644 src/globe/navara/movers/batchIds.js create mode 100644 src/globe/navara/movers/batchIds.test.mjs create mode 100644 src/layers/aircraft/tracking.js create mode 100644 src/layers/aircraft/tracking.test.mjs create mode 100644 src/layers/shared/render/navaraPolygons.test.mjs diff --git a/changelog.d/1555-navara-globe-engine.md b/changelog.d/1555-navara-globe-engine.md new file mode 100644 index 000000000..629873f78 --- /dev/null +++ b/changelog.d/1555-navara-globe-engine.md @@ -0,0 +1,5 @@ +- Add Navara, an experimental three.js/WASM globe, as a second engine next to + Cesium, switchable live under Display → Globe Engine or with + `?engine=navara`. Layers keep their data across a switch and draw on both + engines; Cesium loads only when a Cesium globe or a Cesium-only feature + needs it (German, @gekh, #1555). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..04d2b00b7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,6 +98,69 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and `src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own implementations. +## Navara globe engine — November 5, 2026 + +Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs +next to Cesium. It is picked under Display → Globe Engine or with +`?engine=navara`; Cesium stays the default. Switching is live: the page keeps +one application, and only the globe-bound parts move to the next engine. + +- **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and + `src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands + in for tests, and `src/globe/testing/contractSuite.mjs` runs the same checks + on every engine. `movers` and `lines` are optional capabilities: only + Navara has them, and the suite skips their checks elsewhere. +- **The session owns the state.** Catalog, layer models, sources, selection, + camera mode and URL state outlive a switch. A layer that is a session model + gets `attachRenderer(engine)` / `detachRenderer()` instead of being rebuilt, + so its data is not fetched again. The outgoing globe is parked + (`src/standalone/rendererCache.js`), so switching back is warm. +- **Renderers per engine.** Each layer keeps its model and display policy in + engine-neutral files and draws through `render/cesium*.js` or + `render/navara*.js`. Cesium renderers are listed in + `src/app/layers/renderers.js` (`CESIUM_LAYER_RENDERERS`), Navara ones are + loaded by `registerNavaraLayerRenderers` in `src/app/navaraMode.js`. Navara + ground effects (heat, pulses, trails, pads, drapes) are declared with + `defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`); + their insert order is pinned by `navaraGroundOrder.test.mjs`. +- **Cesium only on demand.** The production build loads `/cesium/Cesium.js` + when a Cesium globe starts or a Navara user turns on a feature that still + uses Cesium's maths or entity model (traffic, Street Level, the Bhote Koshi + event and locator, geo import; `src/app/loadCesium.js`). App code does its maths with `src/geo`, a Cesium-free + port of the vector, ellipsoid, transform and time functions that + `bitExact.test.mjs` compares with Cesium bit for bit. + **Invariant: no module reads Cesium while it loads** + (`scripts/check-cesium-load-time.mjs`, run by `npm test`). +- **Boundaries.** Cesium is imported only from `render/cesium*.js` and + `src/globe/cesium/*`; three, postprocessing and Navara only from `navara*` + modules, which load through `import()`. `build/initialChunkGuard.js` fails + the build when one of them reaches an initial chunk, and + `npm run check:main-chunk` holds the main chunk to its gzip budget + (`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js` + touches Navara's private members. +- **Navara 0.2.1 workaround.** The SDK keeps feature-change events of deleted + vector layers in its queue, so the view never reaches idle. They are dropped + each frame (`src/globe/navara/featureQueue.js`). +- **Strict acceptance.** `node scripts/qa-all-layer-switches.mjs + --serve` builds the commit keyless in a clean worktree and runs every layer + and switch scenario under a deny-by-default network policy, with free map + tiles from a disk cache and a trap that fails any read of Cesium before it + has loaded. Every catalog layer must be asserted on both engines. Point + `PUPPETEER_EXECUTABLE_PATH` at an installed Chrome if Puppeteer's pinned + build is missing. With an OSM base map Navara lights the globe by the real + sun, so scenarios over Austin or San Francisco see a dark frame at local + night and fail their pixel checks; run them in local daylight. +- **Navara 0.2.1 workaround.** The SDK never frees pick batch ids, and the + mover banks rebuild their instanced meshes on each attach, so every switch + leaked one id per instance of capacity (about 260 KiB of WebAssembly memory). + A per-view pool hands released ids back out + (`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS + objects and native memory stay flat. +- **Navara 0.2.1 workaround.** The first `layer.update({ show: false })` after + a vector layer is added is sometimes dropped, so a zone turned off right + after a switch stayed drawn. Polygon displays hide by deleting their layers + and rebuild them when shown (`src/layers/shared/render/navaraPolygons.js`). + ## Prohibited & Restricted Airspace — FAA special-use airspace The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`, @@ -5585,7 +5648,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per frame of the globe engine (Cesium or Navara) through the globe contract. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, the unchanged Globe View destination is checked against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry fails closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eb7dcf432..23e3425a6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -368,8 +368,10 @@ "src/layers/military/index.js", "src/layers/aircraft/index.js", "src/layers/aircraft/classification.js", + "src/layers/aircraft/tracking.js", "src/layers/military/ownership.test.mjs", "src/layers/aircraft/classification.test.mjs", + "src/layers/aircraft/tracking.test.mjs", "src/data/aisLiveVessels.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -1297,6 +1299,7 @@ "src/globe/cesium/engineFromViewer.js", "src/globe/cesium/engineFromViewer.test.mjs", "src/globe/cesium/testing/fakeViewer.mjs", + "src/globe/cesium/testing/cameraViewer.mjs", "src/app/globeEngine.js", "src/app/globeEngine.test.mjs", "src/layers/transit/render/cesium.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 973b80426..dd799e7e6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1352,6 +1352,7 @@ "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", "src/layers/aircraft/sceneBinding.js", + "src/layers/aircraft/tracking.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", @@ -1419,6 +1420,7 @@ "src/layers/military/index.js", "src/layers/military/ingestion.js", "src/layers/aircraft/sceneBinding.js", + "src/layers/aircraft/tracking.js", "src/layers/military/lifecycle.js", "src/layers/military/motion.js", "src/layers/military/policy.js", @@ -2650,6 +2652,7 @@ "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", "src/layers/aircraft/sceneBinding.js", + "src/layers/aircraft/tracking.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", @@ -3414,6 +3417,7 @@ "src/globe/follow.js", "src/globe/lines.js", "src/globe/navara/movers/pickIds.js", + "src/globe/navara/movers/batchIds.js", "src/globe/movers.js", "src/globe/cesium/follow.js", "src/globe/cpuPicks.js", @@ -3789,6 +3793,7 @@ "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", "src/layers/aircraft/sceneBinding.js", + "src/layers/aircraft/tracking.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs index 222bc8e7c..e377841fa 100644 --- a/scripts/qa-navara-geo-import.mjs +++ b/scripts/qa-navara-geo-import.mjs @@ -176,8 +176,8 @@ await strict.run( rows: document.querySelectorAll('#geo-import-list .geo-import-row') .length, hint: document.getElementById('geo-import-hint')?.textContent || '', - pins: window.__godsEyeView.engine.movers - .list() + // Movers are a Navara capability; Cesium draws the pins as entities. + pins: (window.__godsEyeView.engine.movers?.list() ?? []) .filter(({ id }) => id.startsWith('geo-import-')) .reduce((n, set) => n + set.count, 0), })); diff --git a/scripts/qa-strict-soak.mjs b/scripts/qa-strict-soak.mjs index 8a498c7e0..75cab7706 100644 --- a/scripts/qa-strict-soak.mjs +++ b/scripts/qa-strict-soak.mjs @@ -18,11 +18,12 @@ // below), then after the warm-up (switch 20): live WebGL // objects per engine context grow by at most 0.1 per switch (least-squares // slope over the checkpoints) and by at most 64 overall; no new WebGL -// context; the JS objects grow by at most 10 KiB per switch (heap snapshots -// at the warm-up and the end, V8's compiled code and native memory left out -// and reported apart, with the used heap's slope); no new document; the -// webglcontextlost/restored listener counts on both engine canvases stay as -// they were (each canvas keeps its webglcontextlost listener). +// context; the JS objects grow by at most 10 KiB per switch and native +// memory by at most 32 KiB (heap snapshots at the warm-up and the end, V8's +// compiled code left out and reported apart, with the used heap's slope); no +// new document; the webglcontextlost/restored listener counts on both engine +// canvases stay as they were (each canvas keeps its webglcontextlost +// listener). // // node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=6 --swaps=50 // node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=1 --swaps=500 --assert @@ -396,21 +397,22 @@ if (strictMode) { const perSwitch = (mib) => Math.round(((mib * 1024) / (to.swap - from.swap)) * 100) / 100; const objectGrowth = perSwitch(objects(to) - objects(from)); + const nativeGrowth = perSwitch( + (to.typesMiB.native ?? 0) - (from.typesMiB.native ?? 0), + ); checks.push({ check: 'JS heap KiB per switch', objectsKiBPerSwitch: objectGrowth, fromMiB: Math.round(objects(from) * 100) / 100, toMiB: Math.round(objects(to) * 100) / 100, // Reported, not asserted: the used heap's slope (compiled code - // included), compiled code's and native memory's growth. + // included) and compiled code's growth. usedSlopePerSwap: Math.round(slope(heap) * 100) / 100, codeKiBPerSwitch: perSwitch( (to.typesMiB.code ?? 0) - (from.typesMiB.code ?? 0), ), - nativeKiBPerSwitch: perSwitch( - (to.typesMiB.native ?? 0) - (from.typesMiB.native ?? 0), - ), - pass: objectGrowth <= 10, + nativeKiBPerSwitch: nativeGrowth, + pass: objectGrowth <= 10 && nativeGrowth <= 32, }); checks.push({ check: 'documents', diff --git a/src/app/scene.js b/src/app/scene.js index 944f453fa..d670dc92e 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -153,7 +153,6 @@ async function createRenderer({ creditContainer, }); defer(() => { - releaseCameraVerbs(viewer); uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); @@ -161,6 +160,8 @@ async function createRenderer({ // once it loads. const tilesetRef = { current: null }; const engine = createSceneEngine(viewer, { tilesetRef, defer }); + // As the Navara scene above: a released engine keeps no camera verb. + defer(() => releaseCameraVerbs(engine)); defer(installTrackpadPinchZoom(viewer)); registerDataCredits(engine, credits); configureCreditKeyboardAccess(document); @@ -243,7 +244,6 @@ async function createRenderer({ suspend() { uninstallRenderGovernor(viewer); engine.camera.cancelFlight(); - releaseCameraVerbs(viewer); releaseCameraVerbs(engine); viewer.useDefaultRenderLoop = false; }, diff --git a/src/app/tools.js b/src/app/tools.js index dc7d5cf1d..6990c14b1 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -270,6 +270,7 @@ function attachViewerTools( }); geoFileImport?.attachScene({ viewer, + engine, navigation: styleManager, subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener), }); diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 814e4de1e..f1fa49940 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -13,6 +13,8 @@ import { moveCamera, } from './cameraVerbs.js'; import { getRenderGovernorDiagnostics } from './renderGovernor.js'; +import { createCesiumEngine } from './globe/cesium/createCesiumEngine.js'; +import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs'; const radians = Cesium.Math.toRadians; const clone = (value) => Cesium.Cartesian3.clone(value); @@ -94,7 +96,10 @@ function projection(camera, target = Cesium.Cartesian3.ZERO) { }; } -/** Real Cesium geometry with no renderer, GPU, provider or wall-clock waits. */ +/** + * Real Cesium geometry with no renderer, GPU, provider or wall-clock waits: + * the Cesium engine over a real Cesium camera, as the app drives it. + */ function harness( t, { @@ -104,7 +109,6 @@ function harness( heading = 0, pitch = -90, roll = 0, - ellipsoid = Cesium.Ellipsoid.WGS84, viewportWidth = 1280, viewportHeight = 800, } = {}, @@ -112,127 +116,33 @@ function harness( const realPerformance = globalThis.performance; let nowMs = 0; globalThis.performance = { now: () => nowMs }; - const canvas = Object.assign(new EventTarget(), { - clientWidth: viewportWidth, - clientHeight: viewportHeight, + const { viewer, scene, camera, canvas, engine, frame } = createCameraViewer({ width: viewportWidth, height: viewportHeight, }); - const scene = { - canvas, - drawingBufferWidth: viewportWidth, - drawingBufferHeight: viewportHeight, - frameState: { mode: Cesium.SceneMode.SCENE3D }, - mapProjection: new Cesium.GeographicProjection(ellipsoid), - globe: { ellipsoid }, - mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, - mode: Cesium.SceneMode.SCENE3D, - pixelRatio: 1, - tweens: [], - requestRender() {}, - }; - const camera = new Cesium.Camera(scene); - scene.camera = camera; camera.setView({ - destination: Cesium.Cartesian3.fromDegrees( - longitudeDeg, - latitude, - height, - ellipsoid, - ), + destination: Cesium.Cartesian3.fromDegrees(longitudeDeg, latitude, height), orientation: { heading: radians(heading), pitch: radians(pitch), roll: radians(roll), }, }); - const viewer = { - camera, - scene, - trackedEntity: undefined, - trackedEntityChanged: new Cesium.Event(), - clock: { onTick: new Cesium.Event() }, - }; - const target = Cesium.Cartesian3.fromDegrees( - longitudeDeg, - latitude, - 0, - ellipsoid, - ); - initCameraVerbs(viewer, () => target); + const target = Cesium.Cartesian3.fromDegrees(longitudeDeg, latitude, 0); + initCameraVerbs(engine, () => target); t.after(() => { interruptCameraMotion('test-cleanup'); globalThis.performance = realPerformance; }); const tick = (seconds = 0.1) => { nowMs += seconds * 1000; - viewer.clock.onTick.raiseEvent(viewer.clock); + frame(); }; const finishOnce = () => { for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick(); assert.equal(getActiveCameraMotion(), null, 'once motion must finish'); }; - return { viewer, camera, canvas, target, tick, finishOnce }; -} - -for (const [name, sceneMode] of [ - ['2D', Cesium.SceneMode.SCENE2D], - ['Columbus', Cesium.SceneMode.COLUMBUS_VIEW], - ['morphing', Cesium.SceneMode.MORPHING], -]) { - test(`globe rotation refuses ${name} before changing the prior owner`, (t) => { - const { viewer, camera } = harness(t); - assert.equal( - moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }) - .ok, - true, - ); - const priorId = activeCameraMotionId(); - const before = pose(camera); - const transform = Cesium.Matrix4.clone(camera.transform); - viewer.scene.mode = sceneMode; - let handoffs = 0; - let cancellations = 0; - camera.cancelFlight = () => { - cancellations += 1; - }; - for (const mode of ['once', 'continuous']) { - const result = moveCamera( - { motion: 'rotate_globe', direction: 'left', mode }, - () => { - handoffs += 1; - return { ok: true }; - }, - ); - assert.equal(result.ok, false); - assert.match(result.error, /3D/); - } - assert.equal(handoffs, 0); - assert.equal(cancellations, 0); - assert.equal(activeCameraMotionId(), priorId); - assert.equal(hasRenderHold(), true); - assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); - samePose(camera, before); - }); - - test(`active globe rotation yields to ${name} without changing the camera`, (t) => { - const { viewer, camera, tick } = harness(t); - assert.equal( - moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, - true, - ); - viewer.scene.mode = sceneMode; - const before = pose(camera); - let rotations = 0; - camera.rotate = () => { - rotations += 1; - }; - tick(); - assert.equal(rotations, 0); - assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); - samePose(camera, before); - }); + return { viewer, scene, camera, canvas, engine, target, tick, finishOnce }; } test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => { @@ -242,16 +152,14 @@ test('globe rotation yields to tracking acquired and cleared before the next tic true, ); const before = pose(camera); - const entity = { id: 'cockpit-aircraft', position: {} }; - viewer.trackedEntity = entity; - viewer.trackedEntityChanged.raiseEvent(entity); + // Assigning the tracked entity raises trackedEntityChanged (the follow). + viewer.trackedEntity = { id: 'cockpit-aircraft', position: {} }; assert.equal( getActiveCameraMotion(), null, 'tracking acquisition must synchronously yield', ); viewer.trackedEntity = undefined; - viewer.trackedEntityChanged.raiseEvent(undefined); tick(); samePose(camera, before); assert.equal(hasRenderHold(), false); @@ -264,8 +172,9 @@ test('tracking event keeps legacy yaw and an untracked globe rotation active', ( true, ); const yawId = activeCameraMotionId(); - viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' }); + viewer.trackedEntity = { id: 'aircraft' }; assert.equal(activeCameraMotionId(), yawId); + viewer.trackedEntity = undefined; assert.equal( moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, true, @@ -275,24 +184,24 @@ test('tracking event keeps legacy yaw and an untracked globe rotation active', ( assert.equal(activeCameraMotionId(), globeId); }); -test('replaced viewer releases the old tracking listener without duplicate subscriptions', (t) => { +test('a replaced engine keeps no listener and its tracking stays out of the new motion', (t) => { const first = harness(t); - assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); - initCameraVerbs(first.viewer); - assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + initCameraVerbs(first.engine); const next = harness(t); - assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0); - assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1); assert.equal( moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, true, ); + // The motion's frame listener is on the engine it runs on, once. + assert.equal(first.scene.preRender.numberOfListeners, 0); + assert.equal(next.scene.preRender.numberOfListeners, 1); const id = activeCameraMotionId(); - first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); + first.viewer.trackedEntity = { id: 'stale-aircraft' }; assert.equal(activeCameraMotionId(), id); - next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); + next.viewer.trackedEntity = { id: 'new-aircraft' }; assert.equal(getActiveCameraMotion(), null); assert.equal(hasRenderHold(), false); + assert.equal(next.scene.preRender.numberOfListeners, 0); }); for (const latitude of [0, 30, -60, 89.5]) { @@ -526,23 +435,22 @@ test('replacement motion rejects a stale globe owner cancellation', (t) => { assert.equal(hasRenderHold(), false); }); -test('replacing the viewer retires globe motion and its old clock listener', (t) => { - const { viewer, camera, tick } = harness(t); +test('replacing the engine retires globe motion and its frame listener', (t) => { + const { viewer, scene, camera, tick } = harness(t); assert.equal( moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, true, ); tick(); const before = pose(camera); - const nextViewer = { - ...viewer, - clock: { onTick: new Cesium.Event() }, - }; - initCameraVerbs(nextViewer); + // Another engine over the same Cesium camera. + const nextEngine = createCesiumEngine(viewer, { requestRender() {} }); + t.after(() => nextEngine.destroy()); + initCameraVerbs(nextEngine); assert.equal(getActiveCameraMotion(), null); assert.equal(hasRenderHold(), false); + assert.equal(scene.preRender.numberOfListeners, 0); tick(); - nextViewer.clock.onTick.raiseEvent(nextViewer.clock); samePose(camera, before); }); @@ -683,8 +591,8 @@ test('navigation callback never releases tracking for globe rotation', (t) => { samePose(camera, before); }); test('direct globe request refuses Cockpit before navigation mutation', (t) => { - const { viewer, camera } = harness(t); - initCameraVerbs(viewer, null, { isCockpitActive: () => true }); + const { engine, camera } = harness(t); + initCameraVerbs(engine, null, { isCockpitActive: () => true }); const before = pose(camera); let calls = 0; assert.equal( @@ -866,7 +774,7 @@ for (const owner of ['tracking', 'cockpit', 'close-up']) test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => { const { createGevActionRunner } = await import('./voice/gevActions.js'); const { runExplicitNavigation } = await import('./navigationPolicy.js'); - const { viewer, camera } = harness(t, { + const { viewer, engine, camera } = harness(t, { height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM, }); const before = pose(camera); @@ -893,6 +801,7 @@ for (const owner of ['tracking', 'cockpit', 'close-up']) }; const runner = createGevActionRunner({ viewer, + engine, styleManager, dataManager: { layers: new Map(), @@ -918,29 +827,6 @@ for (const owner of ['tracking', 'cockpit', 'close-up']) } }); -for (const [label, ellipsoid] of [ - ['nonfinite radii', { minimumRadius: 1, maximumRadius: Infinity }], - ['nonpositive radii', { minimumRadius: 0, maximumRadius: 1 }], - ['non-WGS84 radii', new Cesium.Ellipsoid(30_000_000, 30_000_000, 1_000_000)], -]) - test(`globe rotation refuses ${label} before handoff`, (t) => { - const { viewer, camera } = harness(t, { height: 100_000_000 }); - const before = pose(camera); - viewer.scene.globe.ellipsoid = ellipsoid; - let handedOff = false; - const result = moveCamera({ motion: 'rotate_globe' }, () => { - handedOff = true; - }); - assert.equal(result.ok, false); - assert.equal( - result.reason, - label === 'non-WGS84 radii' ? 'unsupported-view' : 'unsafe-ellipsoid', - ); - assert.equal(handedOff, false); - assert.equal(hasRenderHold(), false); - samePose(camera, before); - }); - test('an external radius change retires globe rotation without another write', (t) => { const { camera, tick } = harness(t); moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); @@ -952,16 +838,6 @@ test('an external radius change retires globe rotation without another write', ( samePose(camera, changed); }); -test('a non-WGS84 ellipsoid fails closed rather than borrowing the WGS84 silhouette', (t) => { - const ellipsoid = new Cesium.Ellipsoid(10_000_000, 10_000_000, 8_000_000); - const { camera } = harness(t, { ellipsoid, latitude: 60 }); - const before = pose(camera); - const result = moveCamera({ motion: 'rotate_globe' }); - assert.equal(result.ok, false); - assert.equal(result.reason, 'unsupported-view'); - samePose(camera, before); -}); - test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => { const { camera, finishOnce, tick } = harness(t, { latitude: 90 }); const radius = Cesium.Cartesian3.magnitude(camera.positionWC); @@ -1064,12 +940,7 @@ test('wide viewport uses vertical fovy rather than horizontal fov', (t) => { ); }); -for (const invalid of [ - 'orthographic', - 'behind', - 'zero-css-width', - 'offset-frustum', -]) +for (const invalid of ['orthographic', 'behind', 'zero-css-width']) test(`unsupported ${invalid} fails without a reset offer`, (t) => { const h = harness(t); if (invalid === 'orthographic') @@ -1079,7 +950,6 @@ for (const invalid of [ }); if (invalid === 'behind') h.camera.lookRight(Math.PI); if (invalid === 'zero-css-width') h.canvas.clientWidth = 0; - if (invalid === 'offset-frustum') h.camera.frustum.xOffset = 0.1; const result = moveCamera({ motion: 'rotate_globe' }); assert.equal(result.ok, false); assert.equal(result.reason, 'unsupported-view'); @@ -1203,22 +1073,18 @@ test('keyhole overlap after resize stops rotation before another pose write', (t samePose(h.camera, before); }); -for (const clipped of ['near', 'far', 'entirely-beyond-far']) - test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => { - const h = harness(t, { latitude: 0 }); - const radius = Cesium.Ellipsoid.WGS84.maximumRadius; - const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); - if (clipped === 'near') h.camera.frustum.near = distance - radius + 100; - if (clipped === 'far') h.camera.frustum.far = distance + radius - 100; - if (clipped === 'entirely-beyond-far') - h.camera.frustum.far = distance - 2 * radius; - const before = pose(h.camera); - const result = moveCamera({ motion: 'rotate_globe' }); - assert.equal(result.ok, false); - assert.equal(result.reason, 'unsupported-view'); - samePose(h.camera, before); - assert.equal(hasRenderHold(), false); - }); +test('a depth-near globe is unsupported without a reset offer', (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + h.camera.frustum.near = distance - radius + 100; + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); +}); test('fully depth-contained globe remains eligible at actual near/far limits', (t) => { const h = harness(t, { latitude: 0 }); @@ -1231,11 +1097,9 @@ test('fully depth-contained globe remains eligible at actual near/far limits', ( for (const [near, far] of [ [0, 1e9], - [-1, 1e9], [NaN, 1e9], [1, Infinity], [10, 10], - [10, 9], ]) test(`invalid depth limits ${near}/${far} are unsupported`, (t) => { const h = harness(t); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 652fa524e..4192959b8 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -18,14 +18,9 @@ * globe motion, opposite to the camera's travel around Earth. */ -import * as Cesium from 'cesium'; import * as geo from './geo/index.js'; import { GLOBE_VIEW } from './locations.js'; -import { - projectEarthDiscToViewport, - earthDiscScreenRadius, - getKeyholeGeometry, -} from './celestialRing.js'; +import { earthDiscScreenRadius, getKeyholeGeometry } from './celestialRing.js'; import { holdContinuousRender, releaseContinuousRender, @@ -955,21 +950,17 @@ export function advanceRouteFlight(state, dt) { }; } -/* ── Camera rigs ──────────────────────────────────────────────────────────── - * A motion drives one camera through a rig, and remembers which one. +/* ── Camera rig ───────────────────────────────────────────────────────────── + * A motion drives one globe engine's camera through its rig, and remembers + * which one. * - * The VIEWER rig is a Cesium viewer's own camera, exactly as the verbs were - * proven on it: exact basis writes (setView's heading/pitch conversion is - * singular at nadir), the scene's tween queue, the frustum's depth planes and - * the scene modes. Its clock and input listeners are installed once per viewer. - * - * The ENGINE rig speaks only the engine-neutral globe contract + * The rig speaks only the engine-neutral globe contract * (src/globe/contract.js): camera.pose/height/setView/lookAt/release/ * transform/flightActive/following/followChanged/cancelFlight, frames.preRender, * host.canvas/size, projector.worldToWindow, surface and render.request. The - * same verbs therefore run on Navara, on the fake engine and on Cesium through - * engineFromViewer. Its listeners exist only while one of its motions runs, so - * an engine that is parked or destroyed holds nothing of ours. + * same verbs therefore run on Navara, on Cesium (src/globe/cesium) and on the + * fake engine. Its listeners exist only while one of its motions runs, so an + * engine that is parked or destroyed holds nothing of ours. * -------------------------------------------------------------------------- */ /** Manual camera input that reclaims control (the cancelFlight reflex). */ @@ -1037,30 +1028,6 @@ function orientationVectors(position, heading, pitch, roll) { }; } -/** The keyhole admission's view of a Cesium camera (live or a probe clone). */ -function viewerKeyholeView(camera) { - const frustum = camera.frustum; - return { - position: camera.positionWC, - direction: camera.directionWC, - right: camera.rightWC, - up: camera.upWC, - perspective: frustum instanceof Cesium.PerspectiveFrustum, - // Read only once the depth planes passed: Cesium's frustum getters throw - // on invalid planes. - get fovy() { - return frustum?.fovy; - }, - get aspectRatio() { - return frustum?.aspectRatio; - }, - near: frustum?.near, - far: frustum?.far, - xOffset: frustum?.xOffset, - yOffset: frustum?.yOffset, - }; -} - /** The keyhole admission's view of a contract camera pose. */ function engineKeyholeView(pose) { return { @@ -1083,134 +1050,6 @@ function engineKeyholeView(pose) { }; } -/** - * Put the horizon back. - * - * The route dolly is the only motion that rolls the camera, and Cesium keeps - * whatever world-space up vector it was last handed — so releasing a flight - * mid-bank leaves the user holding a tilted horizon until some later motion - * happens to level it. Heading, pitch and position are preserved; only the - * roll is taken out. - * @param {Cesium.Viewer} viewer - * @returns {boolean} Whether a roll was actually removed. - */ -function levelViewerRoll(viewer) { - try { - const cam = viewer?.camera; - if (!cam || typeof cam.setView !== 'function') return false; - const roll = geo.math.negativePiToPi(cam.roll || 0); - if (Math.abs(roll) < 1e-5) return false; - cam.setView({ - orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 }, - }); - return true; - } catch { - return false; // teardown race - } -} - -/** A Cesium viewer's own camera (see the block comment above). */ -function createViewerRig(viewer) { - const cam = () => viewer.camera; - const ellipsoid = () => - viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; - return { - kind: 'viewer', - owner: viewer, - viewer, - engine: null, - position: () => cam().positionWC, - pitch: () => cam().pitch, - heightM: () => cam().positionCartographic.height, - orbitPose: () => ({ position: cam().positionWC, heading: cam().heading }), - // Camera flights are scene tweens, including ones started straight on - // the Cesium camera rather than through the engine. - flightBusy: () => viewer.scene.tweens.length > 0, - tracking: () => Boolean(viewer.trackedEntity), - trackedLabel() { - const entity = viewer.trackedEntity; - return entity ? entity.name || entity.id || 'the tracked target' : null; - }, - is3D: () => viewer.scene.mode === Cesium.SceneMode.SCENE3D, - unlocked: () => - Cesium.Matrix4.equals(cam().transform, Cesium.Matrix4.IDENTITY), - lookAt: (target, hpr) => cam().lookAt(target, hpr), - release: () => cam().lookAtTransform(Cesium.Matrix4.IDENTITY), - move(direction, amount) { - const c = cam(); - if (direction === 'left') c.moveLeft(amount); - else if (direction === 'right') c.moveRight(amount); - else if (direction === 'up') c.moveUp(amount); - else c.moveDown(amount); - }, - look(direction, angle) { - const c = cam(); - if (direction === 'up') c.lookUp(angle); - else if (direction === 'down') c.lookDown(angle); - else if (direction === 'left') c.lookLeft(angle); - else c.lookRight(angle); - }, - rotate: (axis, angle) => cam().rotate(axis, angle), - setFrame: (eye, direction, up) => - cam().setView({ destination: eye, orientation: { direction, up } }), - levelRoll: () => levelViewerRoll(viewer), - surface: () => viewer.scene, - ellipsoid, - supportedEllipsoid: () => - Cesium.Cartesian3.equals(ellipsoid().radii, Cesium.Ellipsoid.WGS84.radii), - viewport: () => ({ - width: viewer.scene.canvas.clientWidth, - height: viewer.scene.canvas.clientHeight, - }), - keyholeView: () => viewerKeyholeView(cam()), - earthDiscProjects: (width, height) => - Boolean(projectEarthDiscToViewport(viewer, width, height)), - resetView() { - // The unchanged Globe View destination, on a clone: the live camera - // never moves for the check. - const camera = Cesium.Camera.clone(cam()); - const { longitude, latitude } = cam().positionCartographic; - camera.setView({ - destination: Cesium.Cartesian3.fromRadians( - longitude, - latitude, - GLOBE_VIEW.heightM, - ), - orientation: { - heading: 0, - pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg), - roll: 0, - }, - endTransform: Cesium.Matrix4.IDENTITY, - }); - return viewerKeyholeView(camera); - }, - captureGlobePose() { - const c = cam(); - return { - position: Cesium.Cartesian3.clone(c.positionWC), - direction: Cesium.Cartesian3.clone(c.directionWC), - up: Cesium.Cartesian3.normalize(c.upWC, new Cesium.Cartesian3()), - right: Cesium.Cartesian3.clone(c.rightWC), - }; - }, - takeGlobePose(pose) { - // Convert an inherited ground-target frame without changing the world - // pose. Rotation must pivot about Earth, never the previous local target. - const c = cam(); - c.cancelFlight(); - c.lookAtTransform(Cesium.Matrix4.IDENTITY); - // Handoff may level a route's bank. Keep the caller's exact world pose. - // Copy the basis directly: setView's heading/pitch conversion is singular - // at nadir and can change the captured orientation near the poles. - Cesium.Cartesian3.clone(pose.position, c.position); - Cesium.Cartesian3.clone(pose.direction, c.direction); - Cesium.Cartesian3.clone(pose.up, c.up); - Cesium.Cartesian3.clone(pose.right, c.right); - }, - }; -} - /** The globe contract's camera (see the block comment above). */ function createEngineRig(engine) { const camera = engine.camera; @@ -1220,7 +1059,6 @@ function createEngineRig(engine) { return { kind: 'engine', owner: engine, - viewer: null, engine, position: () => pose().position, pitch: () => pose().pitch, @@ -1230,7 +1068,9 @@ function createEngineRig(engine) { tracking: () => Boolean(camera.following()), trackedLabel() { const handle = camera.following(); - return handle ? handle.label?.id || 'the tracked target' : null; + return handle + ? handle.name || handle.label?.id || 'the tracked target' + : null; }, // Both engines draw only the 3D globe. is3D: () => true, @@ -1292,6 +1132,9 @@ function createEngineRig(engine) { ); }, setFrame: (eye, direction, up) => place(eye, direction, up), + // Put the horizon back: the route dolly is the only motion that rolls + // the camera, and a bank released mid-flight would stay until some later + // motion happened to level it. Heading, pitch and position are kept. levelRoll() { const p = pose(); if (Math.abs(geo.math.negativePiToPi(p.roll)) <= 1e-5) return false; @@ -1390,17 +1233,13 @@ const ENGINE_RIG_MEMBERS = Object.freeze([ /** * Whether the camera verbs (move_camera, and fly_route over a route list) can - * drive this target: a Cesium viewer, or a globe engine with every contract - * member the engine rig reads. The voice gate asks this instead of "is there - * a viewer". - * @param {object|null|undefined} target A Cesium viewer or a globe engine. + * drive this target: a globe engine with every contract member the rig + * reads. The voice gate asks this instead of "is there a viewer". + * @param {object|null|undefined} target A globe engine. * @returns {boolean} */ export function cameraVerbsSupported(target) { - if (!target) return false; - if (!isGlobeEngine(target)) - return Boolean(target.camera && target.scene && target.clock?.onTick); - if (target.isDestroyed?.()) return false; + if (!isGlobeEngine(target) || target.isDestroyed?.()) return false; return ENGINE_RIG_MEMBERS.every( (path) => typeof path @@ -1416,14 +1255,9 @@ export function cameraVerbsSupported(target) { /** The rig new motions start on: the latest initCameraVerbs target. */ let _rig = null; -/** The viewer whose clock and input listeners are installed (viewer rig). */ -let _viewer = null; let _getTarget = null; let _active = null; // { kind, rig, direction, speed, mode, motionId, ...state } -let _tickRemover = null; -let _trackingRemover = null; -let _inputRemovers = []; -/** The engine listeners of the running engine-rig motion, or null. */ +/** The engine listeners of the running motion, or null. */ let _engineBinding = null; function unbindEngineMotion() { @@ -1451,8 +1285,9 @@ function bindEngineMotion(rig) { removers.push(() => canvas.removeEventListener(event, cancel)); } } - // As the viewer rig's trackedEntityChanged: a follow taking the camera - // yields a globe rotation at once, before its next frame. + // A follow taking the camera (Cockpit can acquire and clear one between + // two frames) yields a globe rotation at once, before its next frame. + // Other motions carry on. removers.push( engine.camera.followChanged.addEventListener((handle) => { if (handle && _active?.rig === rig && _active.kind === 'rotate_globe') @@ -1465,8 +1300,8 @@ function bindEngineMotion(rig) { if (_active?.rig === rig) engine.render.request('camera-verb'); }), ); - // Its first frame takes the minimum step, as a viewer rig's first tick - // does (a resumed flight must not jump across the time it was parked). + // Its first frame takes the minimum step (a resumed flight must not jump + // across the time it was parked). if (_active?.rig === rig) _active.lastMs = undefined; _engineBinding = { rig, @@ -1478,14 +1313,13 @@ function bindEngineMotion(rig) { } /** - * Park the Directions route flight `id` (an engine-rig flight) for an engine - * switch: its listeners go, its state is returned for resumeRouteFlight. + * Park the Directions route flight `id` for an engine switch: its listeners + * go, its state is returned for resumeRouteFlight. * @param {number} id The flight's motion id. * @returns {object|null} The parked state, or null when `id` is not running. */ export function suspendRouteFlight(id) { - if (!id || _active?.motionId !== id || _active.rig?.kind !== 'engine') - return null; + if (!id || _active?.motionId !== id) return null; const state = _active; _active = null; unbindEngineMotion(); @@ -1511,7 +1345,7 @@ let _motionSeq = 0; /** * Install a motion and stamp it with a fresh id. The id is what lets a caller * that started a flight stop THAT flight later without stopping whatever has - * replaced it in the meantime. An engine-rig motion binds its listeners here. + * replaced it in the meantime. The motion binds its engine listeners here. * @param {object} state Motion state (with its rig). * @returns {object} The installed state. */ @@ -1519,7 +1353,7 @@ function setActiveMotion(state) { _motionSeq += 1; state.motionId = _motionSeq; _active = state; - if (state.rig?.kind === 'engine') bindEngineMotion(state.rig); + bindEngineMotion(state.rig); return state; } @@ -1564,13 +1398,8 @@ export function interruptCameraMotion(reason = 'interrupt') { } catch { /* teardown race */ } - // An engine dolly only ever sets views; it never locks a transform. - const engineRoute = motion.kind === 'route' && rig.kind === 'engine'; - if ( - !tracking && - !engineRoute && - (motion.kind === 'orbit' || motion.kind === 'route') - ) { + // A route dolly only ever sets views; it never locks a transform. + if (!tracking && motion.kind === 'orbit') { try { rig.release(); } catch { @@ -1579,7 +1408,7 @@ export function interruptCameraMotion(reason = 'interrupt') { } // Instant, not eased: the user grabbed the camera, or the flight is over. // Either way the next thing they hold must be a level horizon. - if (motion.kind === 'route' && (engineRoute || !tracking)) { + if (motion.kind === 'route') { try { leveled = rig.levelRoll(); } catch { @@ -1618,11 +1447,6 @@ export function getActiveCameraMotion() { return info; } -/** The viewer clock's tick: advance a viewer-rig motion. */ -function onTick() { - if (_rig?.kind === 'viewer') tickMotion(_rig); -} - /** Advance the running motion one frame, when it runs on `rig`. */ function tickMotion(rig) { const m = _active; @@ -1894,35 +1718,20 @@ export function interruptGlobeRotation(reason = 'camera-intent') { return { wasActive: false, reason, leveled: false }; } -function detachViewerListeners() { - _tickRemover?.(); - _trackingRemover?.(); - for (const remove of _inputRemovers) remove(); - _inputRemovers = []; - _tickRemover = _trackingRemover = _viewer = null; -} - /** - * Release what the verbs hold on a departing viewer or engine: a motion - * running on it stops, and when it is the one new motions start on, its - * listeners and getters go too. - * @param {object} target The Cesium viewer or globe engine. + * Release what the verbs hold on a departing engine: a motion running on it + * stops, and when it is the one new motions start on, its getters go too. + * @param {object} target The globe engine. */ export function releaseCameraVerbs(target) { if (target == null) return; - if (target === _viewer) { - if (_active) interruptCameraMotion('viewer-released'); - detachViewerListeners(); - } else if (_active?.rig?.owner === target) { - interruptCameraMotion('engine-released'); - } + if (_active?.rig?.owner === target) interruptCameraMotion('engine-released'); if (_rig?.owner === target) _rig = _getTarget = _isCockpitActive = null; } /** - * Wire the module to the camera once (idempotent). `target` is a Cesium - * viewer (its own camera: the viewer rig) or a globe engine (the contract: - * the engine rig; Navara, or Cesium through engineFromViewer). The + * Wire the module to the camera once (idempotent). `target` is a globe + * engine (Navara, or the Cesium engine over the app's viewer). The * view-target getter is optional so a non-voice caller (the Directions FLY * chip) can arm route flights before any voice session exists; a later call * with a real getter installs it, and a call without one, FOR THE SAME @@ -1948,18 +1757,14 @@ export function initCameraVerbs( _isCockpitActive = isCockpitActive; return; } + if (!isGlobeEngine(target)) return; const previous = _rig; - const next = isGlobeEngine(target) - ? engineRigFor(target) - : createViewerRig(target); + const next = engineRigFor(target); if (_active && (_active.rig === previous || previous === null)) { - // A globe motion belongs to the camera whose world pose it captured. Any - // other motion carries on with a new Cesium viewer (its state is world - // geometry), but never crosses to another kind of camera. + // A globe motion belongs to the camera whose world pose it captured, and + // no motion crosses to another camera. if (_active.kind === 'rotate_globe') interruptCameraMotion('viewer-replaced'); - else if (_active.rig?.kind === 'viewer' && next.kind === 'viewer') - _active.rig = next; else if (_active.rig === previous && previous !== null) interruptCameraMotion('camera-replaced'); } @@ -1967,24 +1772,6 @@ export function initCameraVerbs( _isCockpitActive = typeof isCockpitActive === 'function' ? isCockpitActive : null; _rig = next; - detachViewerListeners(); - if (next.kind !== 'viewer') return; - const viewer = target; - _viewer = viewer; - _tickRemover = viewer.clock.onTick.addEventListener(onTick); - // Cockpit can acquire then clear tracking before the next clock tick. - // Yield on acquisition itself without changing legacy camera motions. - _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { - if (entity && _active?.kind === 'rotate_globe') - interruptCameraMotion('globe-camera-owner-changed'); - }); - const canvas = viewer.scene.canvas; - // ANY manual camera input reclaims control — the cancelFlight reflex. - for (const evt of MANUAL_INPUT_EVENTS) { - const h = () => interruptCameraMotion('manual-input'); - canvas.addEventListener(evt, h, { passive: true }); - _inputRemovers.push(() => canvas.removeEventListener(evt, h)); - } } /** diff --git a/src/cameraVerbsEngine.test.mjs b/src/cameraVerbsEngine.test.mjs index 4ce24df7f..84f0d201c 100644 --- a/src/cameraVerbsEngine.test.mjs +++ b/src/cameraVerbsEngine.test.mjs @@ -1,7 +1,7 @@ // The camera verbs on the engine-neutral globe contract (the engine rig), on // the fake engine: pose deltas and durations against a real Cesium camera -// driven through the viewer rig, cancellation, supersession and the release -// an engine switch makes. +// driven through the Cesium engine, cancellation, supersession and the +// release an engine switch makes. import assert from 'node:assert/strict'; import test from 'node:test'; @@ -9,6 +9,7 @@ import * as Cesium from 'cesium'; import * as geo from './geo/index.js'; import { GLOBE_VIEW } from './locations.js'; import { createFakeEngine } from './globe/testing/fakeEngine.js'; +import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs'; import { activeCameraMotionId, cameraVerbsSupported, @@ -75,30 +76,19 @@ function engineHarness(t, pose = START, clock = installClock(t)) { return { engine, clock, frame, run, camera: () => engine.camera.pose() }; } -/** A real Cesium camera on a stub scene (no renderer), driven as a viewer. */ -function viewerHarness(t, pose = START, clock = installClock(t)) { - const canvas = Object.assign(new EventTarget(), { - clientWidth: WIDTH, - clientHeight: HEIGHT, +/** The Cesium engine over a real Cesium camera on a stub scene (no renderer). */ +function cesiumHarness(t, pose = START, clock = installClock(t)) { + const { + viewer, + scene, + camera, + engine, + frame: turn, + } = createCameraViewer({ width: WIDTH, height: HEIGHT, }); - const ellipsoid = Cesium.Ellipsoid.WGS84; - const scene = { - canvas, - drawingBufferWidth: WIDTH, - drawingBufferHeight: HEIGHT, - frameState: { mode: Cesium.SceneMode.SCENE3D }, - mapProjection: new Cesium.GeographicProjection(ellipsoid), - globe: { ellipsoid }, - mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, - mode: Cesium.SceneMode.SCENE3D, - pixelRatio: 1, - tweens: [], - requestRender() {}, - }; - const camera = new Cesium.Camera(scene); - scene.camera = camera; + t.after(() => engine.destroy()); camera.setView({ destination: Cesium.Cartesian3.fromDegrees( pose.lonDeg, @@ -111,16 +101,9 @@ function viewerHarness(t, pose = START, clock = installClock(t)) { roll: 0, }, }); - const viewer = { - camera, - scene, - trackedEntity: undefined, - trackedEntityChanged: new Cesium.Event(), - clock: { onTick: new Cesium.Event() }, - }; const frame = (seconds = 0.1) => { clock.ms += seconds * 1000; - viewer.clock.onTick.raiseEvent(viewer.clock); + turn(); }; const run = (seconds, step = 0.1) => { for (let s = 0; s < seconds - 1e-9; s += step) frame(step); @@ -133,7 +116,15 @@ function viewerHarness(t, pose = START, clock = installClock(t)) { pitch: camera.pitch, roll: camera.roll, }); - return { viewer, camera: cameraPose, frame, run, cesium: camera }; + return { + viewer, + scene, + engine, + camera: cameraPose, + frame, + run, + cesium: camera, + }; } function near(actual, expected, tolerance, label) { @@ -162,20 +153,21 @@ function samePose(actual, expected, label, metres = 1e-3, unit = 1e-9) { const ORBIT_TARGET = geo.cartesian3.fromDegrees(10.02, 30.03, 0); /** - * Run one verb on the Cesium viewer rig and on the engine rig from the same - * pose with the same frames; return both camera poses before and after. + * Run one verb on the Cesium engine (a real Cesium camera) and on the fake + * engine from the same pose with the same frames; return both camera poses + * before and after. */ function runOnBoth(t, args, seconds, { pose = START, target = null } = {}) { const clock = installClock(t); - const cesium = viewerHarness(t, pose, clock); - initCameraVerbs(cesium.viewer, () => target); + const cesium = cesiumHarness(t, pose, clock); + initCameraVerbs(cesium.engine, () => target); const cesiumBefore = cesium.camera(); const cesiumResult = moveCamera(args); assert.equal(cesiumResult.ok, true, JSON.stringify(cesiumResult)); cesium.run(seconds); const cesiumAfter = cesium.camera(); interruptCameraMotion('parity'); - releaseCameraVerbs(cesium.viewer); + releaseCameraVerbs(cesium.engine); const fake = engineHarness(t, pose, clock); initCameraVerbs(fake.engine, () => target); @@ -436,11 +428,11 @@ test('installing another camera stops a motion running on the previous one', (t) initCameraVerbs(first.engine, () => ORBIT_TARGET); moveCamera({ motion: 'orbit', mode: 'continuous' }); first.run(0.3); - const cesium = viewerHarness(t, START, first.clock); - initCameraVerbs(cesium.viewer, () => ORBIT_TARGET); + const cesium = cesiumHarness(t, START, first.clock); + initCameraVerbs(cesium.engine, () => ORBIT_TARGET); assert.equal(getActiveCameraMotion(), null); assert.deepEqual(first.engine.camera.transform(), [...geo.matrix4.IDENTITY]); - releaseCameraVerbs(cesium.viewer); + releaseCameraVerbs(cesium.engine); }); test('globe rotation yields when a follow takes the engine camera', (t) => { @@ -569,8 +561,8 @@ test('cameraVerbsSupported names the targets the verbs can drive', () => { scene: {}, clock: { onTick: new Cesium.Event() }, }), - true, - 'a Cesium viewer', + false, + 'a Cesium viewer is driven through its engine', ); const partial = createFakeEngine(); partial.camera = { ...partial.camera, following: undefined }; @@ -580,7 +572,7 @@ test('cameraVerbsSupported names the targets the verbs can drive', () => { }); test('a once orbit armed through a flight still turns its full nudge', (t) => { - for (const kind of ['viewer', 'engine']) { + for (const kind of ['cesium', 'engine']) { const clock = installClock(t); const target = ORBIT_TARGET; let h; @@ -596,15 +588,17 @@ test('a once orbit armed through a flight still turns its full nudge', (t) => { }); flying = true; } else { - h = viewerHarness(t, START, clock); - initCameraVerbs(h.viewer, () => target); - h.viewer.scene.tweens.push({}); + // A flight a layer started straight on viewer.camera: a scene tween + // the engine did not start. + h = cesiumHarness(t, START, clock); + initCameraVerbs(h.engine, () => target); + h.scene.tweens.push({}); flying = true; } const before = h.camera(); assert.equal(moveCamera({ motion: 'orbit' }).armed, 'waiting-for-arrival'); h.run(1.6); - if (kind === 'viewer' && flying) h.viewer.scene.tweens.length = 0; + if (kind === 'cesium' && flying) h.scene.tweens.length = 0; h.run(1); const start = getActiveCameraMotion(); assert.equal(start?.kind, 'orbit', `${kind}: the arm outlived 0.9 s`); @@ -621,6 +615,6 @@ test('a once orbit armed through a flight still turns its full nudge', (t) => { geo.math.negativePiToPi(after.heading - before.heading), ); near(turn, 30, 0.05, `${kind}: heading (camera frame vs target frame)`); - if (kind === 'viewer') releaseCameraVerbs(h.viewer); + if (kind === 'cesium') releaseCameraVerbs(h.engine); } }); diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index f0982e655..b847749f6 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -122,7 +122,10 @@ test('civilian and military click handlers apply duration only at the deselect b assert.match(dispatcher, /if \(!isTrackingSelectionGesture\(gesture\)\) return;[\s\S]+onSelect/); assert.match(dispatcher, /if \(!isTrackingClickGesture\(gesture\)\) return;\s*for \(const owner of \[\.\.\.owners\.values\(\)\]\.filter\(plain\)\)\s*owner\.onEmptyClick/); for (const [layer, source] of [['flights', sources[0]], ['military', sources[1]]]) { - assert.match(source, new RegExp(`dispatcher\\.register\\('${layer}', \\{[\\s\\S]+onEmptyClick: _onEmptyClick`)); + // Both layers register through the shared aircraft tracking, under their + // own layer id. + assert.match(source, /dispatcher\.register\(layerId, \{[\s\S]+onEmptyClick: _onEmptyClick/); + assert.match(source, new RegExp(`layerId: '${layer}'`)); assert.match(source, /function _onEmptyClick\(\) \{[\s\S]+?_clearTracking\(false, \{ origin: 'user' \}\)/); } assert.doesNotMatch( diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index 9cf678b29..ef8e0a762 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -38,6 +38,41 @@ const cesiumDestination = (destination) => const cesiumEasing = (easing) => (easing ? easings()[easing] : undefined); +let scratchBasis; + +/** + * Write a world direction and up (right = direction × up, up made + * perpendicular) as the camera's basis, in its current reference frame. + * Cesium's setView turns direction/up into heading/pitch/roll, which is + * singular looking straight down: within about 2.6° of nadir it drops the + * roll and reads the heading off `up`. The exact basis is what Cesium's own + * rotate/look/move leave. A degenerate pair (up along direction) keeps + * Cesium's result. + */ +function setBasis(cesium, direction, up) { + scratchBasis ??= { + direction: new Cesium.Cartesian3(), + up: new Cesium.Cartesian3(), + right: new Cesium.Cartesian3(), + }; + const right = Cesium.Cartesian3.cross(direction, up, scratchBasis.right); + const rightLength = Cesium.Cartesian3.magnitude(right); + const directionLength = Cesium.Cartesian3.magnitude(direction); + if (!(rightLength > 1e-12 * directionLength) || !Number.isFinite(rightLength)) + return; + Cesium.Cartesian3.divideByScalar(right, rightLength, right); + const forward = Cesium.Cartesian3.divideByScalar( + direction, + directionLength, + scratchBasis.direction, + ); + const upright = Cesium.Cartesian3.cross(right, forward, scratchBasis.up); + const inverse = cesium.inverseTransform; + Cesium.Matrix4.multiplyByPointAsVector(inverse, forward, cesium.direction); + Cesium.Matrix4.multiplyByPointAsVector(inverse, upright, cesium.up); + Cesium.Matrix4.multiplyByPointAsVector(inverse, right, cesium.right); +} + // A HeadingPitchRange or a Cartesian offset, as Cesium's lookAt takes them. const cesiumOffset = (offset) => 'heading' in offset @@ -167,10 +202,13 @@ export function createCesiumCamera(viewer) { setView(view) { if (!view?.destination) throw new TypeError('setView needs a destination'); + const orientation = cesiumOrientation(view.orientation); cesium.setView({ destination: cesiumDestination(view.destination), - orientation: cesiumOrientation(view.orientation), + orientation, }); + if (orientation?.direction) + setBasis(cesium, orientation.direction, orientation.up); }, flyTo(options) { if (!options?.destination) @@ -209,7 +247,9 @@ export function createCesiumCamera(viewer) { ), ); }, - flightActive: () => flight != null, + // Also a flight started straight on viewer.camera (layers' flyTo and + // flyToBoundingSphere): Cesium runs every camera flight as a scene tween. + flightActive: () => flight != null || viewer.scene.tweens?.length > 0, lookAt(target, offset) { cesium.lookAt(toCesium3(target), cesiumOffset(offset)); }, diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index 7af75097f..d0e5033ae 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -5,6 +5,7 @@ import * as geo from '../../geo/index.js'; import { isEngineEvent } from '../contract.js'; import { createCesiumCamera } from './camera.js'; import { createFakeViewer } from './testing/fakeViewer.mjs'; +import { createCameraViewer } from './testing/cameraViewer.mjs'; const notCesium = (value, label) => { assert.ok(value != null, `${label} is missing`); @@ -115,6 +116,95 @@ test('flyTo maps easing, duration and callbacks and tracks the flight', () => { assert.equal(viewer.callsOf('camera.cancelFlight').length, 1); }); +test('flightActive also reports a flight started straight on the Cesium camera', () => { + const { viewer, camera, cesium } = setup(); + assert.equal(camera.flightActive(), false); + // Cesium runs every camera flight as a scene tween, so a layer's own + // viewer.camera.flyTo counts as much as the engine's. + const layerFlight = {}; + viewer.scene.tweens.push(layerFlight); + assert.equal(camera.flightActive(), true); + viewer.scene.tweens.splice(viewer.scene.tweens.indexOf(layerFlight), 1); + assert.equal(camera.flightActive(), false); + camera.flyTo({ destination: geo.cartesian3.fromDegrees(0, 0, 1e6) }); + assert.equal(camera.flightActive(), true); + cesium.completeFlight(); + assert.equal(camera.flightActive(), false); +}); + +test('setView with direction and up keeps the exact basis next to nadir', () => { + const { camera, engine } = createCameraViewer(); + const unit = (x, y, z) => + Cesium.Cartesian3.normalize( + new Cesium.Cartesian3(x, y, z), + new Cesium.Cartesian3(), + ); + for (const locked of [false, true]) { + // Nearly straight down at 15° N with a 30° roll: inside the band where + // Cesium's heading/pitch/roll conversion drops the roll. + const destination = Cesium.Cartesian3.fromDegrees(0, 15, 18_000_000); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(destination); + const toWorld = (local) => + Cesium.Matrix4.multiplyByPointAsVector( + enu, + local, + new Cesium.Cartesian3(), + ); + const direction = toWorld(unit(0.01, 0, -1)); + const level = toWorld(unit(1, 0, 0.01)); + const right = Cesium.Cartesian3.cross( + direction, + level, + new Cesium.Cartesian3(), + ); + const roll = Cesium.Quaternion.fromAxisAngle( + direction, + Cesium.Math.toRadians(30), + ); + const up = Cesium.Matrix3.multiplyByVector( + Cesium.Matrix3.fromQuaternion(roll), + Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if (locked) + camera.lookAtTransform( + Cesium.Transforms.eastNorthUpToFixedFrame( + Cesium.Cartesian3.fromDegrees(20, 10), + ), + ); + engine.camera.setView({ + destination: geo.cartesian3.clone(destination), + orientation: { + direction: geo.cartesian3.clone(direction), + up: geo.cartesian3.clone(up), + }, + }); + const close = (actual, expected, tolerance, label) => + assert.ok( + Cesium.Cartesian3.distance(actual, expected) < tolerance, + `${label}: ${Cesium.Cartesian3.distance(actual, expected)}`, + ); + close(camera.positionWC, destination, 1e-6, 'position'); + close(camera.directionWC, direction, 1e-12, 'direction'); + close(camera.upWC, up, 1e-12, 'up'); + close( + camera.rightWC, + Cesium.Cartesian3.cross(direction, up, new Cesium.Cartesian3()), + 1e-12, + 'right', + ); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + !locked, + 'the reference frame is kept', + ); + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } +}); + test('flyTo with duration 0 completes before it returns and leaves no flight', () => { const { camera } = setup(); let done = false; diff --git a/src/globe/cesium/clock.js b/src/globe/cesium/clock.js index 389082d8f..8758af166 100644 --- a/src/globe/cesium/clock.js +++ b/src/globe/cesium/clock.js @@ -8,12 +8,11 @@ // drives Cesium's own lighting as before. // // The latch is attached when the engine is created (the app creates it with -// the scene, before cameraVerbs and the layers add their own onTick -// listeners), so it runs first in each tick; an onTick listener added before -// the engine existed would read the previous frame's time. destroy removes -// it. A viewer without a clock (tests, a bare scene) latches on the scene's -// preUpdate instead, attached on first use so creating the engine adds no -// scene listener. +// the scene), so it runs before any onTick listener added later; one added +// before the engine existed would read the previous frame's time. destroy +// removes it. A viewer without a clock (tests, a bare scene) latches on the +// scene's preUpdate instead, attached on first use so creating the engine +// adds no scene listener. import { appClock as pageClock } from '../../time/clock.js'; diff --git a/src/globe/cesium/clock.test.mjs b/src/globe/cesium/clock.test.mjs index b904b4dc0..dd8531b5f 100644 --- a/src/globe/cesium/clock.test.mjs +++ b/src/globe/cesium/clock.test.mjs @@ -31,8 +31,7 @@ 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 }); - // Attached at creation, so it runs before onTick listeners added later - // (cameraVerbs installs its own after the engine exists). + // Attached at creation, so it runs before onTick listeners added later. assert.equal(viewer.clock.onTick.numberOfListeners, 1); const order = []; viewer.clock.onTick.addEventListener(() => order.push(clock.frameTime())); diff --git a/src/globe/cesium/follow.js b/src/globe/cesium/follow.js index 601e134e3..3e6230b3d 100644 --- a/src/globe/cesium/follow.js +++ b/src/globe/cesium/follow.js @@ -5,7 +5,9 @@ // one exact tracked frame on preUpdate (applyTrackedCameraFrame) and the // zoom limits that keep the camera outside the minimum range. // following() also reports a trackedEntity the app set itself, and -// followChanged is viewer.trackedEntityChanged. +// followChanged is viewer.trackedEntityChanged. A handle on an Entity carries +// its `name` (the Entity's name, else its id): what the camera verbs say they +// are following. import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; @@ -18,6 +20,7 @@ import { import { toCesium3 } from './convert.js'; const MAX_FRAME_ATTEMPTS = 120; +const entityName = (entity) => (entity ? entity.name || entity.id : undefined); const ZOOM_INERTIA_STATES = new WeakMap(); function acquireStableTrackedZoom(controller, owner, minRange) { @@ -188,6 +191,9 @@ export function createCesiumFollow(viewer, { commanded }) { if (!handle) { handle = { native: entity, + get name() { + return entityName(entity); + }, target: () => displayPosition(entity, viewer.clock.currentTime, undefined), stop() { @@ -308,6 +314,9 @@ export function createCesiumFollow(viewer, { commanded }) { const state = { entity: null, proxy: null, options, handle: null }; state.handle = { native: options.native, + get name() { + return entityName(state.handle.native); + }, target() { try { return state.options.position(); diff --git a/src/globe/cesium/follow.test.mjs b/src/globe/cesium/follow.test.mjs index 674f92b25..e7c0372c0 100644 --- a/src/globe/cesium/follow.test.mjs +++ b/src/globe/cesium/follow.test.mjs @@ -126,6 +126,33 @@ test('retarget switches the tracked entity without ending the follow', () => { assert.deepEqual(changes, [handle, handle]); }); +test('a follow on an Entity names it, as the tracked camera is named', () => { + const { viewer, camera } = setup(); + // An entity the app tracked itself: its name, else its id. + viewer.trackedEntity = new Cesium.Entity({ id: 'N123AB', name: 'UAL 12' }); + assert.equal(camera.following().name, 'UAL 12'); + viewer.trackedEntity = new Cesium.Entity({ id: 'N456CD' }); + assert.equal(camera.following().name, 'N456CD'); + // The engine's own follow of a native entity; a proxy follow has none. + const native = new Cesium.Entity({ + id: 'ISS', + position: Cesium.Cartesian3.clone(TARGET), + viewFrom: new Cesium.Cartesian3(0, -900, 400), + }); + const handle = camera.follow({ + native, + position: () => TARGET, + viewFrom: { x: 0, y: -900, z: 400 }, + }); + assert.equal(handle.name, 'ISS'); + const proxy = camera.follow({ + position: () => TARGET, + viewFrom: { x: 0, y: -900, z: 400 }, + }); + assert.equal(proxy.name, undefined); + proxy.stop(); +}); + test('destroy ends an engine follow and leaves an app-tracked entity alone', () => { const first = setup(); first.camera.follow({ diff --git a/src/globe/cesium/testing/cameraViewer.mjs b/src/globe/cesium/testing/cameraViewer.mjs new file mode 100644 index 000000000..18b81ea82 --- /dev/null +++ b/src/globe/cesium/testing/cameraViewer.mjs @@ -0,0 +1,73 @@ +// A Cesium viewer around a real Cesium.Camera on a stub scene (no renderer, +// GPU or providers), with the Cesium engine over it: for tests that check +// what the globe contract does to Cesium's own camera geometry. + +import * as Cesium from 'cesium'; +import { createCesiumEngine } from '../createCesiumEngine.js'; + +/** + * @param {{ width?: number, height?: number }} [options] The canvas size. + * @returns {{ viewer: object, scene: object, camera: Cesium.Camera, + * canvas: EventTarget, engine: import('../../contract.js').GlobeEngine, + * frame: () => void }} `frame()` runs one turn of CesiumWidget's loop: the + * viewer clock's tick, then the scene's frame events. + */ +export function createCameraViewer({ width = 1280, height = 800 } = {}) { + const canvas = Object.assign(new EventTarget(), { + clientWidth: width, + clientHeight: height, + width, + height, + }); + const ellipsoid = Cesium.Ellipsoid.WGS84; + const scene = { + canvas, + drawingBufferWidth: width, + drawingBufferHeight: height, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + mapProjection: new Cesium.GeographicProjection(ellipsoid), + globe: { ellipsoid }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + // Cesium's running camera flights. + tweens: [], + preUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() {}, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + let trackedEntity; + const viewer = { + scene, + camera, + canvas, + container: {}, + trackedEntityChanged: new Cesium.Event(), + // Assigning raises trackedEntityChanged, as Cesium's Viewer does. + get trackedEntity() { + return trackedEntity; + }, + set trackedEntity(value) { + if (value === trackedEntity) return; + trackedEntity = value; + viewer.trackedEntityChanged.raiseEvent(value); + }, + clock: { + onTick: new Cesium.Event(), + currentTime: Cesium.JulianDate.fromIso8601('2026-10-16T12:00:00Z'), + }, + isDestroyed: () => false, + }; + const engine = createCesiumEngine(viewer, { requestRender: () => {} }); + const frame = () => { + const time = viewer.clock.currentTime; + viewer.clock.onTick.raiseEvent(viewer.clock); + scene.preUpdate.raiseEvent(scene, time); + scene.preRender.raiseEvent(scene, time); + scene.postRender.raiseEvent(scene, time); + }; + return { viewer, scene, camera, canvas, engine, frame }; +} diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs index 170713fe2..4fd134af0 100644 --- a/src/globe/cesium/testing/fakeViewer.mjs +++ b/src/globe/cesium/testing/fakeViewer.mjs @@ -30,9 +30,11 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) * (height / width)); const camera = { positionWC: new Cesium.Cartesian3(7e6, 1e5, 2e5), - // Position and direction in the camera's transform (identity: world). + // Position and basis in the camera's transform (identity: world). position: new Cesium.Cartesian3(7e6, 1e5, 2e5), direction: new Cesium.Cartesian3(-1, 0, 0), + up: new Cesium.Cartesian3(0, 0, 1), + right: new Cesium.Cartesian3(0, 1, 0), directionWC: new Cesium.Cartesian3(-1, 0, 0), upWC: new Cesium.Cartesian3(0, 0, 1), rightWC: new Cesium.Cartesian3(0, 1, 0), @@ -48,6 +50,12 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { }, viewMatrix: Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(1, 2, 3)), transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + get inverseTransform() { + return Cesium.Matrix4.inverseTransformation( + camera.transform, + new Cesium.Matrix4(), + ); + }, defaultZoomAmount: 100000, percentageChanged: 0.5, changed: new Cesium.Event(), @@ -132,6 +140,8 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { postRender: new Cesium.Event(), requestRenderMode: true, maximumRenderTimeChange: Infinity, + // Cesium's running camera flights (a flight on viewer.camera is one). + tweens: [], requestRender: record('scene.requestRender'), pick: record('scene.pick', () => undefined), drillPick: record('scene.drillPick', () => []), diff --git a/src/globe/contract.js b/src/globe/contract.js index fb867f47c..d5eaac0f1 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -154,6 +154,8 @@ * target is now (what the follow centres). * @property {any} [native] * @property {object} [label] FollowOptions.label. + * @property {string} [name] Cesium only: the followed Entity's name, else its + * id (a follow without an Entity has none). * * @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType * @typedef {'ctrl'|'shift'|'alt'} InputModifier diff --git a/src/globe/navara/movers/batchIds.js b/src/globe/navara/movers/batchIds.js new file mode 100644 index 000000000..3461e1bd9 --- /dev/null +++ b/src/globe/navara/movers/batchIds.js @@ -0,0 +1,40 @@ +// Navara 0.2.1 never takes a global batch id back: its core keeps every id +// ctx.genGlobalBatchId() hands out (about 20 bytes of WASM memory each, +// never freed). A PickableInstancedMeshWrapper takes one id per instance of +// its capacity, and a layer builds its mover sets again each time it +// attaches to the engine, so every switch back to Navara with 10 000 +// flights took some 32 000 new ids (about 650 KiB). This pool gives the ids +// of a wrapper that is gone to the next wrapper on the same view. Upstream +// ask: release a batch id, or a wrapper's ids when it is unregistered. + +/** A pool of one view's released global batch ids. */ +export function createBatchIdPool() { + const free = []; + const released = new WeakSet(); + return { + /** + * The view's `ctx` to build a pickable wrapper with, whose batch ids + * come from the pool first; everything else is the view's own. + */ + source(ctx) { + const take = () => (free.length ? free.pop() : ctx.genGlobalBatchId()); + return new Proxy(ctx, { + get: (target, key, receiver) => + key === 'genGlobalBatchId' + ? take + : Reflect.get(target, key, receiver), + }); + }, + /** + * Takes back the ids of a wrapper no longer drawn or registered for + * picking; a wrapper's ids come back once. + */ + release(wrapper) { + if (!wrapper || released.has(wrapper)) return; + released.add(wrapper); + for (const id of wrapper.batchIds) if (id > 0) free.push(id); + }, + /** The ids waiting for a wrapper. */ + size: () => free.length, + }; +} diff --git a/src/globe/navara/movers/batchIds.test.mjs b/src/globe/navara/movers/batchIds.test.mjs new file mode 100644 index 000000000..39390cc87 --- /dev/null +++ b/src/globe/navara/movers/batchIds.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBatchIdPool } from './batchIds.js'; + +// A view context that never takes an id back, as Navara 0.2.1's core. +function fakeContext() { + let next = 0; + return { + issued: () => next, + genGlobalBatchId: () => ++next, + }; +} + +// The wrapper's part that matters here: one id per instance, from its ctx. +const wrapper = (ctx, count) => ({ + batchIds: Array.from({ length: count }, () => ctx.genGlobalBatchId() ?? 0), +}); + +test('a rebuilt set takes the ids of the set it replaces', () => { + const ctx = fakeContext(); + const pool = createBatchIdPool(); + let set = wrapper(pool.source(ctx), 1000); + for (let attach = 0; attach < 50; attach++) { + pool.release(set); + set = wrapper(pool.source(ctx), 1000); + } + assert.equal(ctx.issued(), 1000); + assert.equal(pool.size(), 0); + assert.equal(new Set(set.batchIds).size, 1000); +}); + +test('live sets never share an id, and a set gives its ids back once', () => { + const ctx = fakeContext(); + const pool = createBatchIdPool(); + const a = wrapper(pool.source(ctx), 3); + pool.release(a); + pool.release(a); + assert.equal(pool.size(), 3); + const b = wrapper(pool.source(ctx), 2); + const c = wrapper(pool.source(ctx), 2); + const ids = [...b.batchIds, ...c.batchIds]; + assert.equal(new Set(ids).size, 4); + assert.equal(ctx.issued(), 4); +}); + +test('ids Navara could not give (0) are not pooled', () => { + const pool = createBatchIdPool(); + pool.release({ batchIds: [0, 0, 7] }); + pool.release(null); + assert.equal(pool.size(), 1); + const none = { genGlobalBatchId: () => undefined }; + assert.equal(pool.source(none).genGlobalBatchId(), 7); + assert.equal(pool.source(none).genGlobalBatchId(), undefined); +}); + +test('everything else reaches the view context itself', () => { + const ctx = { + _meshes: new Map(), + genGlobalBatchId: () => 1, + registerPickableMesh(key, wrapper) { + this._meshes.set(key, wrapper); + }, + }; + const source = createBatchIdPool().source(ctx); + source.registerPickableMesh('a', 'wrapper'); + assert.equal(ctx._meshes.get('a'), 'wrapper'); + assert.equal(source._meshes, ctx._meshes); +}); diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index dde400127..b1da819fa 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -5,7 +5,8 @@ // (modelMover.js). In the cinematic look, depth stays in that scene and // lit model colour resolves in the display pass. Pickable sets register a // PickableInstancedMeshWrapper so Navara's pick pass sees each instance; -// pickIds.js turns its batch ids back into instances. commit() uploads and +// pickIds.js turns its batch ids back into instances, and batchIds.js gives +// a removed set's ids to the next one. commit() uploads and // asks for one frame (view.forceUpdate); per-frame work runs only on frames // the view draws anyway, so an idle view stays idle. @@ -17,6 +18,7 @@ import { } from '@navaramap/three'; import { Group } from 'three'; import { createMoverRegistry } from '../../movers.js'; +import { createBatchIdPool } from './batchIds.js'; import { createModelMover } from './modelMover.js'; import { createMoverMesh } from './moverMesh.js'; import { createPickIndex } from './pickIds.js'; @@ -34,16 +36,16 @@ class GevMoverSetDesc extends withFrameUpdates(MeshDesc) { } createMesh() { - const { mover, pickable, onPickable } = this.gev; + const { mover, pickable, onPickable, batchIds } = this.gev; const register = (mesh, key) => { if (!pickable) return; const wrapper = new PickableInstancedMeshWrapper( mesh, mesh.instanceMatrix.count, - this.ctx, + batchIds.source(this.ctx), ); this.ctx.registerPickableMesh(key, wrapper); - this.wrappers.push(key); + this.wrappers.push({ key, wrapper }); onPickable(wrapper); }; const group = new Group(); @@ -66,8 +68,9 @@ class GevMoverSetDesc extends withFrameUpdates(MeshDesc) { onDestroy() { this.destroyedByGev = true; - for (const key of this.wrappers) this.ctx.unregisterPickableMesh(key); + for (const { key } of this.wrappers) this.ctx.unregisterPickableMesh(key); this.gev.mover.dispose(); + for (const { wrapper } of this.wrappers) this.gev.batchIds.release(wrapper); super.onDestroy(); } } @@ -90,6 +93,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) { registered.add(view); } const pickIndex = createPickIndex(); + const batchIds = createBatchIdPool(); const readFrameCamera = createMoverFrameCamera(view); let loader = null; const loadGltf = (url) => { @@ -111,11 +115,12 @@ export function createNavaraMovers(view, { overlay, aces = false }) { overlayUniforms: targetOverlay.uniforms, }); const key = `gevMovers:${options.id}`; + let wrapper = null; if (options.pickable) { - const wrapper = new PickableInstancedMeshWrapper( + wrapper = new PickableInstancedMeshWrapper( mover.mesh, options.capacity, - targetOverlay.ctx, + batchIds.source(targetOverlay.ctx), ); targetOverlay.registerPickable(key, wrapper); pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]); @@ -134,6 +139,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) { if (options.pickable) targetOverlay.unregisterPickable(key); targetOverlay.remove(mover.mesh); mover.dispose(); + batchIds.release(wrapper); if (options.sharpen && --preSharpenUsers === 0) { preSharpenOverlay.destroy(); preSharpenOverlay = null; @@ -155,6 +161,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) { readFrameCamera, kind: options.kind, pickable: options.pickable, + batchIds, onPickable: (wrapper) => pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]), }, diff --git a/src/layers/airQuality/render/navara.js b/src/layers/airQuality/render/navara.js index 6d598a5b1..29ab1b9e1 100644 --- a/src/layers/airQuality/render/navara.js +++ b/src/layers/airQuality/render/navara.js @@ -34,10 +34,9 @@ function stationFeature(reading, radius) { /** One native GeoJSON layer per AQHI colour, independent of station count. */ export function createAirQualityNavaraRenderer(engine, { view }) { - const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({ + const display = createNavaraPolygonDisplay(engine, view, (color) => ({ polygon: { color, - show, transparent: true, opacity: 0.35, clampToGround: true, diff --git a/src/layers/aircraft/sceneBinding.test.mjs b/src/layers/aircraft/sceneBinding.test.mjs index 936e2498b..91eb2eb06 100644 --- a/src/layers/aircraft/sceneBinding.test.mjs +++ b/src/layers/aircraft/sceneBinding.test.mjs @@ -31,7 +31,7 @@ function observedShell(t) { } // What the tracking modules do around follow.start on a resume or a share -// restore (flights, military and satellites tracking.js). +// restore (the shared aircraft tracking.js and satellites tracking.js). function follow(trackedEntityChanged, entity, restoring) { if (restoring) entity.gevRestoring = true; try { @@ -150,8 +150,7 @@ test('an engine switch resumes the aircraft with the origin it was selected by', test('every follow of a resumed or share-restored subject is marked while it starts', () => { for (const [file, resume] of [ - ['flights/tracking.js', 'resume'], - ['military/tracking.js', 'resume'], + ['aircraft/tracking.js', 'resume'], ['satellites/tracking.js', 'resuming'], ]) { const source = readLayerSource(new URL(`../${file}`, import.meta.url)); diff --git a/src/layers/aircraft/tracking.js b/src/layers/aircraft/tracking.js new file mode 100644 index 000000000..3cca5f8cd --- /dev/null +++ b/src/layers/aircraft/tracking.js @@ -0,0 +1,1182 @@ +import * as geo from '../../geo/index.js'; +import { nearFarScale } from '../shared/color.js'; +import { appClock } from '../../time/clock.js'; +import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; +import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; +import { + screenProjectedRotation, + stabilizeScreenRotation, +} from '../../data/iconOrientation.js'; +import { trailHeadStart } from '../../data/modelVisualAnchor.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; + +/** + * Selection, follow camera and track history shared by the observed aircraft + * layers (civil flights and military). Everything a layer does differently + * arrives as an explicit parameter; the layer's own tracking module adds its + * extra rules (the civil layer's military suppression and route gate, the + * military layer's tracked card) around the returned parts. + * + * @param {object} options + * @param {string} options.layerId Layer id in awareness events, the context + * slot, the tracked entity id and the selection dispatcher. + * @param {string} options.layerName Layer name in the context slot. + * @param {string} options.focusId Focus-target owner id. + * @param {string} options.logTag Console prefix. + * @param {string} options.trailHeadIdPrefix Trail-head entity id prefix. + * @param {object} options.policy The layer's RENDER_DELAY_SEC, TRAIL_COLOR, + * TRAIL_MAX_POINTS, TRACKED_MODEL_MAX_LOAD_FAILS and + * TRACKED_MODEL_RETRY_BACKOFF_MS. + * @param {{ color: object, transparentColor: object, labelColor: string, + * baseScale: number }} options.trackedStyle Tracked billboard and card style. + * @param {(icao24: string, info: object) => string} options.contactLabel + * Display name of a contact (selection event, console). + * @param {(icao24: string) => string} options.trackedLabelText Tracked card + * text. + * @param {(icao24: string, described: object) => { label: string, + * operator: string, type: string, altitude: string, extra: object }} + * options.contextFields The context slot's layer-specific fields. + * @param {(icao24: string, meta: object|undefined) => number} + * options.fleetBillboardScale Fleet billboard scale restored on untrack. + * @param {(info: object) => number|null} options.followAltitudeM Altitude (m) + * the initial follow range scales with; falsy means the 1500 m default. + * @param {(icao24: string) => void} [options.onTrackStart] Runs when a new + * selection (not a resume) starts, before its trail. + * @param {boolean} options.floorTrail Lift every trail position onto the warm + * ground floor (for a layer whose grounded fixes keep the pre-datum height). + * @param {{ leadAltM: number, thinToBudget: boolean }} options.backfill The + * height of leading waypoints with no altitude and no floor, and whether the + * history is stride-thinned to the remaining vertex budget. + */ +export function createAircraftTracking({ + flightState, + services, + parts, + layerId, + layerName, + focusId, + logTag, + trailHeadIdPrefix, + policy, + trackedStyle, + contactLabel, + trackedLabelText, + contextFields, + fleetBillboardScale, + followAltitudeM, + onTrackStart, + floorTrail, + backfill, +}) { + const { + RENDER_DELAY_SEC, + TRAIL_COLOR, + TRAIL_MAX_POINTS, + TRACKED_MODEL_MAX_LOAD_FAILS, + TRACKED_MODEL_RETRY_BACKOFF_MS, + } = policy; + const { + selectTrackedSubjectContext, + clearTrackedSubjectContext, + refreshTrackedSubjectContext, + } = services.context; + const { isTr3b, tr3bConvertedIds } = services.aircraftPresentation; + const { ensureGeoidReady, geoidHeight } = services.geoid; + const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } = + services.groundFloor; + const { clearFocusTarget } = services.focus; + const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; + const { selectionDispatcherFor } = services.selection; + + function _emitAwarenessEvent(type, detail) { + if ( + typeof window === 'undefined' || + !window.dispatchEvent || + typeof CustomEvent === 'undefined' + ) + return; + window.dispatchEvent(new CustomEvent(type, { detail })); + } + + function _publishTrackedSelection(icao24, origin = 'programmatic') { + const bb = flightState._billboards.get(icao24); + const info = flightState.records.data.get(icao24); + if (!bb?.position || !info) return false; + if (flightState._trackedEntity) + flightState._trackedEntity.gevSelectionOrigin = origin; + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId, + id: icao24, + // Canonical display chain (callsign → registration → hex). Publishing a + // bare `callsign || icao24` here resurrected the pre-enrichment behavior: + // a callsign-less contact reached Context as its raw hex even once adsbdb + // had supplied a registration. Identity below stays `icao24`. + label: contactLabel(icao24, info), + position: geo.cartesian3.clone(bb.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); + return true; + } + + /** + * Describe the selected contact for the shared context slot the voice tools + * and Cockpit read. Values are the LIVE descriptor, not a selection-time + * snapshot, so a long follow never narrates a position the plane has left. + * @param {string} icao24 Contact identity. + * @returns {object|null} Context metadata, or null when the contact is gone. + */ + + function _contextSubjectMetadata(icao24) { + const described = parts.queries._describeFlight(icao24); + if (!described) return null; + const { label, operator, type, altitude, extra } = contextFields( + icao24, + described, + ); + return { + id: icao24, + layerId, + layerName, + source: flightState.feed._lastSource, + label, + latitude: described.latitude, + longitude: described.longitude, + // Flat text only: the voice payload compacts properties through a + // string cleaner that drops nested objects. + properties: { + name: label, + operator, + callsign: described.callsign || '', + registration: described.registration || '', + type, + altitude, + speed: Number.isFinite(described.velocityMps) + ? `${Math.round(described.velocityMps * 1.944)} kt` + : '', + heading: Number.isFinite(described.track) + ? `${roundHeadingDegrees(described.track)}°` + : '', + ...extra, + icao24, + // Honesty cue: the contact is coasting on dead reckoning, so the + // narrated position/velocity are last-known rather than live. + // i18n-ignore: voice-context data, English for the model + status: described.stale ? 'stale (missed polls)' : 'live', + }, + }; + } + + function _isExplicitTrackingOrigin(origin) { + return origin === 'user' || origin === 'voice' || origin === 'tool'; + } + + /** + * Refresh the Cockpit AIR near/far band without consulting model state. + * Near contacts keep their 2D aircraft silhouette when 3D is off, loading, or + * capped; only a ready admitted model may take that silhouette over later. + */ + + function _refreshCockpitNearContacts() { + const camera = flightState._renderer?.view.camera; + if (!flightState._cockpitContactMode || !camera?.positionWC) { + if (flightState._cockpitNearContacts.size) + flightState._cockpitNearContacts = new Set(); + return; + } + const previous = flightState._cockpitNearContacts; + const distancesSquared = []; + for (const [icao24, bb] of flightState._billboards) { + if (icao24 === flightState._trackedIcao || !bb?.position) continue; + distancesSquared.push([ + icao24, + geo.cartesian3.distanceSquared(camera.positionWC, bb.position), + ]); + } + const next = nextCockpitNearContacts( + previous, + distancesSquared, + parts.rendering._modelAddDistM(), + parts.rendering._modelKeepDistM(), + ); + flightState._cockpitNearContacts = next; + let presentationChanged = false; + for (const [icao24, bb] of flightState._billboards) { + if (previous.has(icao24) === next.has(icao24)) continue; + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + presentationChanged = true; + } + if (presentationChanged) flightState._lastCamPoseSig = ''; + } + + /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ + + function _setCockpitContactMode(active) { + const next = active === true; + if (flightState._cockpitContactMode === next) return; + flightState._cockpitContactMode = next; + if (next) _refreshCockpitNearContacts(); + else flightState._cockpitNearContacts = new Set(); + // The collection stays visible in cockpit. Near AIR contacts retain their + // aircraft silhouette until a ready model takes over; far contacts are pips. + // Never destroy on entry: tearing down hundreds of live glTF instances + // synchronously blocked Chrome's renderer into Page Unresponsive. + flightState._renderer?.models.setVisible(true); + flightState._trail?.setVisible(!next); + if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; + for (const [icao24, bb] of flightState._billboards) + parts.rendering._applyFleetBillboardPresentation(icao24, bb); + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + } + + function _applyCockpitState(detail = {}) { + const active = detail?.active === true; + flightState._cockpitSubjectId = active + ? String(detail?.subjectId || '') + .trim() + .toLowerCase() || null + : null; + _setCockpitContactMode(active); + } + + /** Write the explicit tracked presentation model and refresh its host entry. */ + + function _updateTrackedLabelModel(icao24) { + if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) + return; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + trackedLabelText(icao24), + trackedStyle.labelColor, + ); + refreshTrackedReadout(flightState._trackedEntity); + // The readout and the context slot describe the same contact — refresh them + // together so voice never narrates a fix the card has already replaced. + refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); + } + + /** Re-image the tracked entity's billboard from the current class/conversion. */ + + function _syncTrackedBillboardImage() { + if (!flightState._trackedIcao || !flightState._trackedEntity) return; + flightState._renderer.tracked.setImage( + flightState._trackedEntity, + aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState.records.data.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ), + ); + } + + /** + * Re-render one contact after its TR-3B conversion (or the active IR style) + * changed. Converting drops any 3D model so the triangle owns the visual; the + * billboard image, tracked entity, and tracked card are all re-derived here. + * @param {string} icao24 - ICAO 24-bit address. + * @returns {boolean} True when the layer owns this contact. + */ + + function _refreshTr3bContact(icao24) { + const id = String(icao24 || '') + .trim() + .toLowerCase(); + if (!id) return false; + if (isTr3b(id)) { + // Drop the 3D handoff for this contact — the fleet tick now skips it, so + // an already-loaded model would otherwise linger with the billboard hidden. + if (flightState._models.has(id) || flightState._modelPending.has(id)) + parts.rendering._releaseModel(id); + const bb = flightState._billboards.get(id); + if (bb && id !== flightState._trackedIcao) bb.show = true; // horizon cull re-asserts next tick + if (id === flightState._trackedIcao) { + parts.rendering._releaseTrackedModel(); + _syncTracked2dRotation(); + } + } + const bb = flightState._billboards.get(id); + if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); + if (id === flightState._trackedIcao) { + _syncTrackedBillboardImage(); + _updateTrackedLabelModel(id); + } + flightState._renderer?.view.requestRender(); + return flightState._billboards.has(id) || id === flightState._trackedIcao; + } + + /** Re-image every converted contact this layer owns (IR style flip). */ + + function _refreshTr3bForStyle() { + for (const id of tr3bConvertedIds()) { + if (flightState._billboards.has(id) || id === flightState._trackedIcao) + _refreshTr3bContact(id); + } + } + + /** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and + * the load-failure bound. Called from each path that changes which contact is + * selected — deselect, re-track, cross-layer handoff, init, destroy. + * + * This must live in the production lifecycle, not only in the predicate's + * icao-change guard: a deselect followed by a same-turn re-track of the SAME + * icao (Contacts re-entry, a cross-layer round trip back to the original + * layer) never makes `_trackedIcao` *observably* change, so the guard never + * fires. Without the reset, a contact dropped inside the hysteresis band comes + * back as a MODEL above the ENTER ceiling, and a contact whose GLB had already + * failed out would never get its retries back. */ + + function _resetTrackedSelectionState() { + flightState._trackedZoomLatched = false; + flightState._trackedZoomLatchIcao = null; + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + } + + /** Whether the driver may start another tracked-model load this frame. */ + + function _trackedModelLoadAllowed(nowMs = Date.now()) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) + return true; // untried selection + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) + return false; + return nowMs >= flightState._trackedModelRetryAtMs; + } + + /** Record a rejected tracked-model load and arm the backoff / give-up latch. */ + + function _noteTrackedModelLoadFailure(url, err) { + if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { + flightState._trackedModelFailIcao = flightState._trackedIcao; + flightState._trackedModelFailCount = 0; + } + flightState._trackedModelFailCount += 1; + flightState._trackedModelRetryAtMs = + Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; + if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { + console.warn( + `${logTag} tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` + + 'this contact stays 2D until another is selected', + err, + ); + } + } + + /** + * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven + * (owner directive 2026-08-19). Unlike the fleet, this does NOT consult the + * DISPLAY-rail `models3d` toggle: the selected contact is a single model, it is + * what the camera is pointed at, and zooming in on a target should resolve it + * into an aircraft without the operator arming anything. The toggle keeps + * owning the FLEET (`_modelRegimeActive`), which is the draw-call budget. + * + * Thresholds + hysteresis live in trackedModelRegime.js: enter at + * TRACKED_MODEL_ENTER_ALT_M (150_000 m — the owner's playtested swap distance, + * deliberately NEARER than the fleet's 800 km ceiling this used to inherit), + * hand back only above TRACKED_MODEL_EXIT_ALT_M, so orbiting AT the boundary + * cannot flap billboard↔model. See that module's header for why the tracked + * contact now goes 3D closer in than the fleet does. + * + * In cockpit you are sitting 7 m behind and 2.6 m above your own aircraft's + * origin, so its ~26 m airframe would fill the visor. First-person means your + * own airframe is not drawn. + */ + + function _trackedModelRegimeActive() { + if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { + flightState._trackedZoomLatchIcao = flightState._trackedIcao; + flightState._trackedZoomLatched = false; + } + // A converted TR-3B has no 3D asset — suppressing the regime keeps its + // tracked billboard fully opaque (the colour callback reads this too), so + // the triangle stays the visual all the way in. + if ( + !flightState._trackedIcao || + flightState._cockpitContactMode || + isTr3b(flightState._trackedIcao) + ) { + flightState._trackedZoomLatched = false; + return false; + } + flightState._trackedZoomLatched = trackedModelZoomActive( + flightState._renderer?.view.camera?.positionCartographic?.height, + flightState._trackedZoomLatched, + ); + return flightState._trackedZoomLatched; + } + + /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ + + function _syncTracked2dRotation() { + if (!flightState._trackedIcao || !flightState._renderer) return; + const pos = + parts.motion._trackedDisplayCached() || + flightState._billboards.get(flightState._trackedIcao)?.position; + if (!pos) return; + const projected = screenProjectedRotation( + flightState._renderer.view.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rotation = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + 0, + ); + if (rotation !== null) flightState._lastTrackedRotation = rotation; + } + + /** + * Field-test round 2 (2026-07-06): floors a trail-bound position at the warm + * coarse ground cell — a pure LIFT (above-floor positions pass through + * untouched, unknown floors change nothing). Grounded military billboards + * deliberately render at the pre-datum default height (T7 groundSnap + * invariant), so with `floorTrail` every position entering the TRAIL + * subsystem (seed, append, per-frame head) goes through this instead — the + * visible taxi history sits on the surface without touching the + * billboard/model machinery. Grounded civil positions are already + * surface-clamped (the surfaceM chain), so that layer skips it. + * @param {object} position - Owned position (Cartesian3; mutated/replaced freely). + * @returns {object} The same or a lifted position. + */ + + function _trailFloorPosition(position) { + const carto = geo.cartographic.fromCartesian( + position, + geo.ellipsoid.WGS84, + flightState._scratchTrailCarto, + ); + if (!carto) return position; + const latDeg = geo.math.toDegrees(carto.latitude); + const lonDeg = geo.math.toDegrees(carto.longitude); + const floored = floorAltitudeM( + carto.height, + cachedGroundFloor(latDeg, lonDeg), + ); + if (floored == null || floored === carto.height) return position; + return geo.cartesian3.fromDegrees(lonDeg, latDeg, floored); + } + + /** + * Append one fix to the tracked aircraft's trail accumulation and refresh + * the rendered trail. Caller passes an owned (cloned) Cartesian3. + * @param {object} position - New fix position (Cartesian3), appended at the head. + */ + + function _appendTrailFix(position) { + flightState._trailPositions.push( + floorTrail ? _trailFloorPosition(position) : position, + ); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) + flightState._trailPositions.shift(); + _refreshTrailDisplay(); + } + + /** + * Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That + * newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at + * now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane. + * The cheap per-frame _trailHeadEntity segment bridges the last body point to the + * delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll + * cadence), never at motion cadence. + */ + + function _refreshTrailDisplay() { + if (!flightState._trail) return; + flightState._trail.setPositions( + flightState._trailPositions.length > 1 + ? flightState._trailPositions.slice(0, -1) + : flightState._trailPositions, + ); + } + + /** + * Start the trail for a newly tracked aircraft: seed it with the short + * dead-reckoning history (chronological), render immediately, then + * fire-and-forget the source's track backfill. + * @param {string} icao24 - ICAO 24-bit transponder address being tracked. + */ + + function _startTrail(icao24) { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + const history = flightState._positionHistory.get(icao24) || []; + // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The + // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them + // would draw the trail in front of the plane. They join the trail via _appendTrailFix + // as they age past the delay. + const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000; + for (const fix of history) { + if (fix.timeMs <= seedRenderTimeMs) { + const seed = geo.cartesian3.clone(fix.position); + flightState._trailPositions.push( + floorTrail ? _trailFloorPosition(seed) : seed, + ); + } + } + _attachTrail(); + const oldestFixEpochSec = history.length + ? history[0].timeMs / 1000 + : Infinity; + _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); + } + + function _attachTrail() { + if (!flightState._trail && flightState._renderer) { + flightState._trail = flightState._renderer.trails.create({ + color: TRAIL_COLOR, + width: 2.5, + }); + } + flightState._trail?.setVisible(!flightState._cockpitContactMode); + // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated + // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, + // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued + // 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: claimed by trailRenderer's pick + // owner so a click on the head segment never reads as empty space. + id: `gev-trail:${trailHeadIdPrefix}-head-${++flightState._trailHeadSeq}`, + show: !flightState._cockpitContactMode, + positions: () => { + // Need ≥2 accumulated points: the body draws all-but-newest, so the head must + // start at the last DISPLAYED body point (index n−2). With a single fix that + // point would be the sole raw fix — which is ~now, AHEAD of the delayed icon — + // so the segment would draw IN FRONT of the plane. Likewise during warm-up the + // icon predates all real history, so there is no valid body point behind it. + if ( + !flightState._trackedIcao || + flightState._trailPositions.length < 2 || + parts.motion._isTrackWarmingUp() + ) + return []; + const head = + parts.motion._trackedTrailCached() || + parts.motion._trackedDisplayPosition(flightState._trackedIcao); + if (!head) return []; + // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a + // backward/reversing segment. + const start = + flightState._trailPositions[flightState._trailPositions.length - 2]; + // With `floorTrail` the head is floored 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 = floorTrail + ? _trailFloorPosition(geo.cartesian3.clone(head)) + : head; + // On a contact that has not moved this segment runs from inside the + // model out to its own anchor — a line through the fuselage. The END + // never gives, so a moving trail still terminates on the tail; the + // START is what slides, from nothing on a parked contact out to the + // whole segment once it has cleared its own envelope. Measured against + // the endpoint actually drawn. See trailHeadStart. An unfloored `head` + // is read in place and cloned only on the draw path — a suppressed + // parked contact runs this every frame. + const from = trailHeadStart( + start, + end, + parts.motion._trackedModelCenterWorld(), + parts.motion._trackedModelEnvelopeM(), + flightState._scratchTrailHead, + ); + if (!from) return []; + return [from, end === head ? geo.cartesian3.clone(head) : end]; + }, + width: 2.5, + // 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, + }); + } + _refreshTrailDisplay(); + } + + /** + * Fire-and-forget source track backfill (PRD F1/F2). On success, splices + * waypoints strictly older than the oldest seeded fix AHEAD of the locally + * accumulated fine segment, capped at TRAIL_MAX_POINTS (newest kept); with + * `backfill.thinToBudget` they are first stride-thinned to the remaining + * vertex budget. Any failure (404/429/timeout/malformed) silently keeps the + * local-only trail. + * @param {string} icao24 - ICAO 24-bit transponder address being tracked. + * @param {number} token - Backfill token captured at request time. + * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. + * @returns {Promise} + */ + + async function _backfillTrail(icao24, token, oldestFixEpochSec) { + let path = null; + try { + const track = await flightState.feed._source.getTrack?.( + flightState.records.data.get(icao24)?.sourceReference ?? icao24, + { + signal: AbortSignal.any([ + flightState.lifetime.signal, + AbortSignal.timeout(8000), + ]), + }, + ); + path = track?.records ?? null; + } catch { + return; // silent fallback to the accumulated trail + } + if ( + !path || + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + // Height-datum fix (Task 6/7): a track waypoint's altitude is barometric + // (OpenSky /tracks has no per-waypoint geo_altitude; a readsb trace point + // carries the same datum as the live alt_baro), so its render height is the + // documented visual FALLBACK baroM + geoidHeight(waypointLat, waypointLon) + // — geometrically approximate, not exact, same honesty caveat as the live + // baro-fallback branch of pickRenderAltitudeM. + await ensureGeoidReady(); + const parsed = []; + for (const waypoint of path) { + const { + observedAtMs, + latitude: lat, + longitude: lon, + baroAltitudeM: baroAlt, + } = waypoint; + const time = observedAtMs / 1000; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue; + parsed.push({ lat, lon, baroAlt }); + } + if (!parsed.length) return; + + // Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse + // ellipsoidal ground along the track and floor every waypoint at it so low + // baro segments never dive below the mesh; a no-baro waypoint (taxi/ground + // segments) sits ON the surface when the floor is known — the old fixed + // 50 m sentinel rendered ~1.5 km underground at Kirtland AFB. + // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the + // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth + // lookup across a long path could stall the paint for seconds-to-timeout. + // Paint with whatever cells are warm; the resolve keeps filling the cache + // in the background for the next paint/select. + await resolveGroundFloorCellsBounded(parsed); + // Re-check the backfill token after the await (same guard as post-fetch): + // tracking may have moved on while the terrain race was in flight. + if ( + token !== flightState._trailBackfillToken || + icao24 !== flightState._trackedIcao + ) + return; + + let older = []; + let lastAltM = null; // carry-forward for no-baro points whose cell isn't warm yet + for (const { lat, lon, baroAlt } of parsed) { + const baroM = Number.isFinite(baroAlt) + ? baroAlt + geoidHeight(lat, lon) + : null; + let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); + // No baro + unresolved floor: hold the previous waypoint's altitude + // (continuity — never a dive/spike to a made-up height). Leading points + // with nothing to carry keep the layer's old placeholder height. + if (altM == null) altM = lastAltM != null ? lastAltM : backfill.leadAltM; + lastAltM = altM; + older.push(geo.cartesian3.fromDegrees(lon, lat, altM)); + } + + // Stride-thin the backfill so it plus the live fine segment fit the cap. + if (backfill.thinToBudget) { + const budget = Math.max( + 1, + TRAIL_MAX_POINTS - flightState._trailPositions.length, + ); + if (older.length > budget) { + const stride = Math.ceil(older.length / budget); + const thinned = []; + for (let i = 0; i < older.length; i += stride) thinned.push(older[i]); + older = thinned; + } + } + + flightState._trailPositions = older.concat(flightState._trailPositions); + if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { + flightState._trailPositions = flightState._trailPositions.slice( + flightState._trailPositions.length - TRAIL_MAX_POINTS, + ); + } + _refreshTrailDisplay(); + } + + /** + * Clear the rendered trail and accumulation; invalidate pending backfills. + */ + + function _clearTrail() { + flightState._trailBackfillToken += 1; + flightState._trailPositions = []; + if (flightState._trail) flightState._trail.clear(); + if (flightState._trailHeadEntity) + flightState._renderer?.trails.removeHead(flightState._trailHeadEntity); + flightState._trailHeadEntity = null; + } + + /** + * Destroy the trail primitive entirely (layer disable/teardown). + */ + + function _destroyTrail() { + _clearTrail(); + _detachTrail(); + } + + function _detachTrail() { + if (flightState._trailHeadEntity) + flightState._renderer?.trails.removeHead(flightState._trailHeadEntity); + flightState._trailHeadEntity = null; + if (flightState._trail) { + flightState._trail.destroy(); + flightState._trail = null; + } + } + + /** + * Stop tracking the currently followed aircraft. + * + * Restores the hidden billboard, removes the tracked Entity, resets lerp + * state, and RELEASES the camera IN PLACE — no flyTo. Deselect used to fly + * an ~80 km pulled-back overview; the owner field-ruled that wrong + * (2026-07-02: "it randomly zooms way up and loses my context"). The camera + * now simply stays at its current position/orientation, immediately free to + * orbit/zoom. Applies to every deselect path: click-empty-space, Escape, + * aged-out plane, layer disable, and voice stopTracking. + * + * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the + * follow-camera: tear down our own state but leave viewer.trackedEntity + * alone (the new owner controls it). + * @param {object} [options] - Clear origin. + * @param {boolean} [options.evicted=false] - The contact aged out of the feed + * rather than being deselected. Consumers that keep a readout on screen + * (the Cockpit Contact panel) hold last-known values for an eviction and + * only tear down on a deliberate clear. + */ + + function _clearTracking( + skipViewerUntrack = false, + { evicted = false, origin = 'programmatic' } = {}, + ) { + flightState._trackedCameraFrameStop?.(); + flightState._trackedCameraFrameStop = null; + if (!flightState._trackedIcao) { + clearFocusTarget(focusId); + return; + } + const clearedIcao = flightState._trackedIcao; + clearFocusTarget(focusId, clearedIcao); + + // Restore the original billboard appearance. The rotation is re-seeded from + // the tracked entity's last rendered rotation and a rotation pass is forced + // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the + // STALE screen rotation it had when tracking began — up to a full + // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. + if (flightState._billboards.has(flightState._trackedIcao)) { + const bb = flightState._billboards.get(flightState._trackedIcao); + const meta = flightState.records.data.get(flightState._trackedIcao); + bb.show = true; + bb.width = 20; + bb.height = 20; + // Ground-aware restore (a plane untracked while taxiing comes back at + // ground scale, in the layer's fleet tint). + bb.color = parts.rendering._fleetBillboardColor( + flightState._trackedIcao, + meta, + ); + bb.scale = fleetBillboardScale(flightState._trackedIcao, meta); + bb.rotation = flightState._lastTrackedRotation; + } + flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick + + // Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the + // follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner + // controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity + // does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where + // the follow left it and the user can immediately orbit/zoom. + if (flightState._renderer && !skipViewerUntrack) { + flightState._renderer.follow.release(); + } + if (flightState._trackedEntity) { + flightState._renderer.tracked.remove(flightState._trackedEntity); + flightState._trackedEntity = null; + } + parts.rendering._releaseTrackedModel(); + _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped + flightState._trackedIcao = null; + parts.rendering._applyFleetBillboardPresentation( + clearedIcao, + flightState._billboards.get(clearedIcao), + ); + clearTrackedSubjectContext(layerId); + _emitAwarenessEvent('gev:awareness-subject-cleared', { + layerId, + id: clearedIcao, + origin, + reason: evicted ? 'evicted' : 'deliberate', + }); + // Invalidate the per-frame DR cache + reconciliation state so a same-frame re-track + // cannot read the previous aircraft's cached/smoothed position. + parts.motion._resetTrackedDisplay(); + _clearTrail(); + } + + function _applyPendingTrackingRestore() { + const pending = flightState._pendingTrackingRestore; + if ( + !pending || + pending.generation !== flightState._trackingIntentGeneration + ) + return false; + if ( + !flightState._renderer?.fleet.visible() || + !flightState._billboards.has(pending.id) + ) + return false; + flightState._pendingTrackingRestore = null; + _trackFlight(pending.id, { origin: pending.origin }); + return true; + } + + function _cancelPendingTrackingRestore() { + flightState._trackingIntentGeneration += 1; + flightState._pendingTrackingRestore = null; + } + + /** + * Begin tracking a specific aircraft by ICAO24 address. + * + * Clears any existing tracked flight, hides its billboard, and creates a + * new Entity with: + * - A CallbackProperty position driven by dead-reckoning (_deadReckon). + * - A CallbackProperty rotation from the aircraft's course. + * - An explicit host presentation model (the layer's tracked card). + * + * The viewer's trackedEntity is set to this entity so the camera follows it. + * + * @param {string} icao24 - ICAO 24-bit transponder address to track. + */ + + function _trackFlight( + icao24, + { origin = 'programmatic', resume = false, follow = true } = {}, + ) { + if (!resume) _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over + + const bb = flightState._billboards.get(icao24); + const info = flightState.records.data.get(icao24); + if (!bb || !info) return; + + flightState._trackedIcao = icao24; + _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget + flightState._cachedDRFrame = -1; + flightState._lastTrackedRotation = bb.rotation || 0; + // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its + // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover + // fleet model would be orphaned + double-rendered. + parts.rendering._releaseModel(icao24); + + // Hide the billboard — the tracked entity replaces it visually + bb.show = false; + + // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — + // one computation shared by the position/rotation/trail-head callbacks, with + // discontinuity reconciliation). Falls back to the last billboard position when the + // aircraft has no fix. + const getTrackedPosition = () => + parts.motion._trackedDisplayPosition(icao24) || bb.position; + + // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the + // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the + // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always + // resolves into an aircraft. + // + // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by + // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from + // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF + // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in + // would stall the centering until the model loaded. A shown-but-transparent billboard always + // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is + // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. + // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the + // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the + // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel). + flightState._trackedEntity = flightState._renderer.tracked.create({ + // Dead-reckoning position — smooth continuous motion between API updates. + position: getTrackedPosition, + image: aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState.records.data.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, + ), + width: 28, + height: 28, + scale: + trackedStyle.baseScale * + (CLASS_SCALE_2D[ + flightState.records.data.get(flightState._trackedIcao)?.klass + ] || 1), + // Solid tracked tint when the billboard is the visual (zoomed out, 3D off, or model still + // loading); transparent once the STANDALONE tracked model is actually up (ready + shown). + color: () => + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? trackedStyle.transparentColor + : trackedStyle.color, + scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), + // The tracked target must never vanish into tile geometry — tracking a + // taxiing plane at street level would otherwise bury the tracked icon inside + // the runway skin exactly like the fleet ground icons (_groundDepthDistance); + // its shared-host tracked card is top-composited separately. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + // Screen-projected rotation, evaluated per frame: exact in tracked-orbit + // mode where camera.heading lives in the entity's reference frame. + rotation: () => { + const tracked = flightState.records.data.get(flightState._trackedIcao); + const pos = getTrackedPosition(); + if (!tracked || !pos || !flightState._renderer) + return flightState._lastTrackedRotation; + const projected = screenProjectedRotation( + flightState._renderer.view.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rot = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + ); + if (rot !== null) flightState._lastTrackedRotation = rot; + return flightState._lastTrackedRotation; + }, + }); + flightState._trackedEntity.gevSelectionOrigin = origin; + flightState._trackedEntity.gevTrackedId = `${layerId}:${icao24}`; + flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( + trackedLabelText(icao24), + trackedStyle.labelColor, + ); + + // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is + // far too tight (the user had to scroll out to read the plane). Give the entity a + // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable + // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) + const followRange = Math.min( + Math.max((followAltitudeM(info) || 1500) * 1.1 + 2500, 3000), + 30000, + ); + flightState._renderer.tracked.setViewFrom(flightState._trackedEntity, { + x: 0, + y: -followRange * 0.8, + z: followRange * 0.55, + }); + + // Cancel any in-progress camera flight first — otherwise Cesium won't apply the + // tracked entity's viewFrom on the first frame, so voice-initiated tracking (which + // often fires mid-fly_to_location) would follow the plane WITHOUT centering/framing it + // the way a click (idle camera) does. + // Expose the camera's already-settled position to cross-module HUD consumers (the tracked-target + // readout) so they draw at the SAME spot the camera framed, without recomputing the dead-reckon in + // postRender (which would jitter the label against the now-stable plane). + flightState._trackedEntity.gevDisplayPosition = + parts.motion._trackedDisplayCached; + // Separate accessor on purpose: `gevDisplayPosition` carries the follow-camera + // anti-jitter contract and must keep returning the cached DR position. Presentation + // that should weld to the AIRCRAFT YOU SEE reads `gevVisualPosition` instead. + flightState._trackedEntity.gevVisualPosition = + parts.motion._trackedVisualCached; + refreshTrackedReadout(flightState._trackedEntity); + // Camera follows the tracked entity (the renderer cancels any flight + // first and applies the one exact tracked frame). + // A resumed binding or a share restore re-follows the session's own + // subject: marked only while the follow starts, so the shell does not + // read it as new navigation (LayerBindings.observeCamera) yet any later + // follow of the same aircraft still is. + if (follow) { + const entity = flightState._trackedEntity; + const restoring = resume || origin === 'share-restore'; + if (restoring) entity.gevRestoring = true; + try { + flightState._trackedCameraFrameStop = + flightState._renderer.follow.start(entity); + } finally { + if (restoring) delete entity.gevRestoring; + } + } + + if (resume) { + _attachTrail(); + return; + } + onTrackStart?.(icao24); + // Track-history trail (PRD F1/F2): seed from local history + async backfill. + // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing + // aircraft's history is retrievable on select; ground legs drape (see + // _trailFloorPosition), never dive. + _startTrail(icao24); + + _publishTrackedSelection(icao24, origin); + + console.log(`${logTag} Tracking ${contactLabel(icao24, info)} (${icao24})`); + } + + /** + * Escape (through the selection dispatcher) deselects the tracked flight. + */ + + function _onEscape() { + if (flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + } + + /** + * A click the selection dispatcher hands this layer: one of its aircraft + * (a fleet sprite or model, or the tracked plane's own visual). The + * dispatcher has already applied the gesture rules (a drag selects + * nothing; isTrackingSelectionGesture) and the pointer claim. + * @param {string} id - The picked id this layer owns. + */ + + function _onSelect(id) { + // Cockpit mode owns the camera and keeps the current aircraft as its + // first-person reference: a globe click selects nothing here. + if (document.body.classList.contains('cockpit-mode')) return; + // Clicking the tracked plane itself — its tracked visual or its + // standalone 3D model (pick id = its icao, H1) — is a no-op, not a + // deselect and not a re-track. + if ( + id === flightState._trackedEntity?.id || + (flightState._trackedIcao && id === flightState._trackedIcao) + ) + return; + if (flightState._billboards.has(id)) { + _cancelPendingTrackingRestore(); + _trackFlight(id, { origin: 'user' }); + } + } + + /** + * Empty space (or a pick no layer owns). The dispatcher calls this only + * for a clean, short click (isTrackingClickGesture): a slow stationary + * press may select, but cannot release existing tracking. A pick another + * layer owns never reaches here. + */ + + function _onEmptyClick() { + if (document.body.classList.contains('cockpit-mode')) return; + if (flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + } + + /** + * Join the engine's selection dispatcher (clicks with picks, Escape) and + * watch the follow camera for another layer taking it. + * + * Idempotent — returns immediately if already joined. + */ + + function _installClickHandler() { + const renderer = flightState._renderer; + if (!renderer) return; + + // Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our + // tracking so its model/entity/update-loop don't orphan — without touching the follow target + // (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch + // (the follow target briefly empty) doesn't self-clear. + if (!flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove = renderer.follow.onChanged( + () => { + const current = flightState._renderer?.follow.current(); + if ( + flightState._trackedIcao && + current && + current !== flightState._trackedEntity + ) { + _clearTracking(true, { + origin: current?.gevSelectionOrigin || 'programmatic', + }); + } + }, + ); + } + + if (flightState._selectionRemove) return; // already joined + const dispatcher = selectionDispatcherFor(renderer.engine); + if (!dispatcher) return; + flightState._selectionRemove = dispatcher.register(layerId, { + // The fleet's icaos, and the tracked visual's own id: clicking it is + // this layer's no-op, never empty space. + owns: (id) => + flightState._billboards.has(id) || + (!!flightState._trackedEntity && + id === String(flightState._trackedEntity.id)), + onSelect: _onSelect, + onEmptyClick: _onEmptyClick, + onEscape: _onEscape, + }); + } + + /** Leave the selection dispatcher and stop watching the follow camera. */ + + function _removeClickHandler() { + flightState._selectionRemove?.(); + flightState._selectionRemove = null; + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } + } + return { + _emitAwarenessEvent, + _publishTrackedSelection, + _contextSubjectMetadata, + _isExplicitTrackingOrigin, + _refreshCockpitNearContacts, + _setCockpitContactMode, + _applyCockpitState, + _updateTrackedLabelModel, + _syncTrackedBillboardImage, + _refreshTr3bContact, + _refreshTr3bForStyle, + _resetTrackedSelectionState, + _trackedModelLoadAllowed, + _noteTrackedModelLoadFailure, + _trackedModelRegimeActive, + _syncTracked2dRotation, + ...(floorTrail ? { _trailFloorPosition } : {}), + _appendTrailFix, + _refreshTrailDisplay, + _startTrail, + _attachTrail, + _detachTrail, + _backfillTrail, + _clearTrail, + _destroyTrail, + _clearTracking, + _applyPendingTrackingRestore, + _cancelPendingTrackingRestore, + _trackFlight, + _onEscape, + _onSelect, + _onEmptyClick, + _installClickHandler, + _removeClickHandler, + }; +} diff --git a/src/layers/aircraft/tracking.test.mjs b/src/layers/aircraft/tracking.test.mjs new file mode 100644 index 000000000..f314369f8 --- /dev/null +++ b/src/layers/aircraft/tracking.test.mjs @@ -0,0 +1,157 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { createTracking as createFlightsTracking } from '../flights/tracking.js'; +import { createTracking as createMilitaryTracking } from '../military/tracking.js'; + +// Both observed aircraft layers build their tracking from the shared aircraft +// tracking; each must still reach it with its own identity and rules. +const LAYERS = [ + { + name: 'flights', + createTracking: createFlightsTracking, + layerId: 'flights', + layerName: 'Live Flights', + focusId: 'flights', + floorsTrail: false, + backfillLeadAltM: 10000, + thinsBackfill: false, + }, + { + name: 'military', + createTracking: createMilitaryTracking, + layerId: 'military', + layerName: 'Military Flights', + focusId: 'militaryFlights', + floorsTrail: true, + backfillLeadAltM: 50, + thinsBackfill: true, + }, +]; + +const GROUND_M = 300; + +function harness(layer, { groundM = null, track = [] } = {}) { + const calls = { registered: [], focusCleared: [] }; + const flightState = { + enabled: true, + feed: { + _lastSource: 'Fixture aircraft', + _source: { getTrack: async () => ({ records: track }) }, + }, + records: { data: new Map(), missingPolls: new Map() }, + lifetime: new AbortController(), + _billboards: new Map(), + _trackedIcao: null, + _trailPositions: [], + _trailBackfillToken: 0, + _renderer: { + engine: {}, + follow: { onChanged: () => () => {} }, + }, + }; + const services = { + context: {}, + aircraftPresentation: { tr3bTypeLabel: (id, type) => type }, + geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 }, + groundFloor: { + resolveGroundFloorCellsBounded: async () => {}, + cachedGroundFloor: () => groundM, + // The production floor rule: lift onto a known floor, else pass through. + floorAltitudeM: (altM, floorM) => { + const hasAlt = Number.isFinite(altM); + const hasFloor = Number.isFinite(floorM); + if (hasAlt && hasFloor) return Math.max(altM, floorM); + if (hasAlt) return altM; + if (hasFloor) return floorM; + return null; + }, + }, + focus: { clearFocusTarget: (id) => calls.focusCleared.push(id) }, + militaryRegistry: {}, + readout: {}, + selection: { + selectionDispatcherFor: () => ({ + register: (id) => { + calls.registered.push(id); + return () => {}; + }, + }), + }, + }; + const parts = { + queries: { + _describeFlight: () => ({ callsign: 'ABC1', track: 90 }), + _contactLabel: () => 'ABC1', + _toCleanText: (value) => value || '', + _formatAltitude: () => '', + }, + }; + const tracking = layer.createTracking({ + flightState, + services, + parts, + layer: {}, + }); + return { tracking, flightState, calls }; +} + +const heightOf = (position) => geo.cartographic.fromCartesian(position).height; + +for (const layer of LAYERS) { + test(`${layer.name} tracking publishes its own layer identity`, () => { + const { tracking, calls } = harness(layer); + const context = tracking._contextSubjectMetadata('abc123'); + assert.equal(context.layerId, layer.layerId); + assert.equal(context.layerName, layer.layerName); + // The route line is the civil layer's: military contexts carry none. + assert.equal('route' in context.properties, layer.name === 'flights'); + tracking._installClickHandler(); + assert.deepEqual(calls.registered, [layer.layerId]); + tracking._clearTracking(); + assert.deepEqual(calls.focusCleared, [layer.focusId]); + }); + + test(`${layer.name} trail ${layer.floorsTrail ? 'lifts' : 'keeps'} a fix below the warm ground`, () => { + const { tracking, flightState } = harness(layer, { groundM: GROUND_M }); + assert.equal('_trailFloorPosition' in tracking, layer.floorsTrail); + tracking._appendTrailFix(geo.cartesian3.fromDegrees(-106.6, 35.05, 10)); + const height = heightOf(flightState._trailPositions[0]); + assert.ok( + Math.abs(height - (layer.floorsTrail ? GROUND_M : 10)) < 0.01, + `trail height ${height}`, + ); + }); + + test(`${layer.name} backfill places and thins history by its own rules`, async () => { + const leading = harness(layer, { + track: [{ observedAtMs: 1000, latitude: 35, longitude: -106 }], + }); + leading.flightState._trackedIcao = 'abc123'; + await leading.tracking._backfillTrail('abc123', 0, Infinity); + assert.equal(leading.flightState._trailPositions.length, 1); + assert.ok( + Math.abs( + heightOf(leading.flightState._trailPositions[0]) - + layer.backfillLeadAltM, + ) < 0.01, + ); + + const long = harness(layer, { + track: Array.from({ length: 1000 }, (_, index) => ({ + observedAtMs: 1000 + index * 1000, + latitude: 35 + index * 0.001, + longitude: -106, + baroAltitudeM: 3000, + })), + }); + long.flightState._trackedIcao = 'abc123'; + await long.tracking._backfillTrail('abc123', 0, Infinity); + // 1000 points into a 400-vertex trail: stride-thinned (every 3rd) or + // only the newest 400 kept. + assert.equal( + long.flightState._trailPositions.length, + layer.thinsBackfill ? 334 : 400, + ); + }); +} diff --git a/src/layers/cyclones/render/navara.js b/src/layers/cyclones/render/navara.js index bd2cc7fe8..649e47302 100644 --- a/src/layers/cyclones/render/navara.js +++ b/src/layers/cyclones/render/navara.js @@ -22,11 +22,10 @@ const zeroCounts = () => ({ const world = ([lon, lat]) => geo.cartesian3.fromDegrees(lon, lat); export function createCyclonesNavaraRenderer(engine, view, { overlayHost }) { - const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({ - polyline: { color, show, width: 2.5, clampToGround: true }, + const display = createNavaraPolygonDisplay(engine, view, (color) => ({ + polyline: { color, width: 2.5, clampToGround: true }, polygon: { color, - show, opacity: 0.16, transparent: true, clampToGround: true, diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index d7debcc79..666d3fc77 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -1401,9 +1401,8 @@ export function createDirectionsLayer({ } function flyCurrentRoute() { - if (!_route || (!_viewer && !_engine)) return false; + if (!_route || !_engine) return false; cancelOwnedFlight('directions-refly'); - if (_viewer) services.camera.initCameraVerbs(_viewer); const seams = _shellSeams; const result = services.camera.flyRoute( [ diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 2327fe02b..10caeea6c 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1,18 +1,9 @@ import * as geo from '../../geo/index.js'; -import { CYAN, nearFarScale } from '../shared/color.js'; -import { appClock } from '../../time/clock.js'; +import { CYAN } from '../shared/color.js'; import { formatNumber, t } from '../../i18n/index.js'; import { unitLabel } from '../../ui/unitLabels.js'; -import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; -import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; -import { - screenProjectedRotation, - stabilizeScreenRotation, -} from '../../data/iconOrientation.js'; -import { trailHeadStart } from '../../data/modelVisualAnchor.js'; -import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { routePlausible } from '../../data/routePlausible.js'; -import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { createAircraftTracking } from '../aircraft/tracking.js'; import { TRACKED_MODEL_MAX_LOAD_FAILS, TRACKED_MODEL_RETRY_BACKOFF_MS, @@ -21,8 +12,9 @@ import { TRAIL_COLOR, CYAN_TRANSPARENT, } from './policy.js'; -import { roundHeadingDegrees } from '../../data/headingDegrees.js'; +/** The civil layer's tracking: the shared aircraft tracking plus the military + * suppression handoff and the route-gated tracked card. */ export function createTracking({ flightState, services, @@ -30,643 +22,68 @@ export function createTracking({ layer, resolveAsset, }) { - const { - selectTrackedSubjectContext, - clearTrackedSubjectContext, - refreshTrackedSubjectContext, - } = services.context; - const { isTr3b, tr3bTypeLabel, tr3bConvertedIds } = - services.aircraftPresentation; - const { ensureGeoidReady, geoidHeight } = services.geoid; - const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } = - services.groundFloor; - const { clearFocusTarget } = services.focus; + const { isTr3b, tr3bTypeLabel } = services.aircraftPresentation; const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; - const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; - const { selectionDispatcherFor } = services.selection; const flightsLayer = layer; - function _emitAwarenessEvent(type, detail) { - if ( - typeof window === 'undefined' || - !window.dispatchEvent || - typeof CustomEvent === 'undefined' - ) - return; - window.dispatchEvent(new CustomEvent(type, { detail })); - } - - function _publishTrackedSelection(icao24, origin = 'programmatic') { - const bb = flightState._billboards.get(icao24); - const info = flightState.records.data.get(icao24); - if (!bb?.position || !info) return false; - if (flightState._trackedEntity) - flightState._trackedEntity.gevSelectionOrigin = origin; - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'flights', - id: icao24, - // Canonical display chain (callsign → registration → hex). Publishing a - // bare `callsign || icao24` here resurrected the pre-enrichment behavior: - // a callsign-less contact reached Context as its raw hex even once adsbdb - // had supplied a registration. Identity below stays `icao24`. - label: parts.queries._contactLabel(icao24, info), - position: geo.cartesian3.clone(bb.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); - return true; - } - - /** - * Describe the selected contact for the shared context slot the voice tools - * and Cockpit read. Values are the LIVE descriptor, not a selection-time - * snapshot, so a long follow never narrates a position the plane has left. - * @param {string} icao24 Contact identity. - * @returns {object|null} Context metadata, or null when the contact is gone. - */ - - function _contextSubjectMetadata(icao24) { - const described = parts.queries._describeFlight(icao24); - if (!described) return null; - const altFt = Math.round((described.altitudeM || 0) * 3.28084); - const route = - described.route && _routeIsPlausible(icao24, described.route) - ? `${described.route.origin.code} → ${described.route.destination.code}` - : null; - return { - id: icao24, - layerId: 'flights', - layerName: 'Live Flights', - source: flightState.feed._lastSource, - label: parts.queries._contactLabel( - icao24, - flightState.records.data.get(icao24), - ), - latitude: described.latitude, - longitude: described.longitude, - // Flat text only: the voice payload compacts properties through a - // string cleaner that drops nested objects. - properties: { - name: parts.queries._contactLabel( + const tracking = createAircraftTracking({ + flightState, + services, + parts, + layerId: 'flights', + layerName: 'Live Flights', + focusId: 'flights', + logTag: '[Data:Flights]', + trailHeadIdPrefix: 'fl', + policy: { + RENDER_DELAY_SEC, + TRAIL_COLOR, + TRAIL_MAX_POINTS, + TRACKED_MODEL_MAX_LOAD_FAILS, + TRACKED_MODEL_RETRY_BACKOFF_MS, + }, + trackedStyle: { + color: CYAN, + transparentColor: CYAN_TRANSPARENT, + labelColor: '#39d0ff', + baseScale: 1, + }, + contactLabel: (icao24, info) => parts.queries._contactLabel(icao24, info), + trackedLabelText: _trackedLabelText, + contextFields(icao24, described) { + const altFt = Math.round((described.altitudeM || 0) * 3.28084); + const route = + described.route && _routeIsPlausible(icao24, described.route) + ? `${described.route.origin.code} → ${described.route.destination.code}` + : null; + return { + label: parts.queries._contactLabel( icao24, flightState.records.data.get(icao24), ), operator: described.airline || '', - callsign: described.callsign || '', - registration: described.registration || '', type: described.typeName || described.typeCode || '', altitude: described.onGround ? 'on ground' : `${altFt.toLocaleString('en-US')} ft`, - speed: Number.isFinite(described.velocityMps) - ? `${Math.round(described.velocityMps * 1.944)} kt` - : '', - heading: Number.isFinite(described.track) - ? `${roundHeadingDegrees(described.track)}°` - : '', - route: route || '', - icao24, - // Honesty cue: the contact is coasting on dead reckoning, so the - // narrated position/velocity are last-known rather than live. - // i18n-ignore: voice-context data, English for the model - status: described.stale ? 'stale (missed polls)' : 'live', - }, - }; - } - - function _isExplicitTrackingOrigin(origin) { - return origin === 'user' || origin === 'voice' || origin === 'tool'; - } - - /** - * Refresh the Cockpit AIR near/far band without consulting model state. - * Near contacts keep their 2D aircraft silhouette when 3D is off, loading, or - * capped; only a ready admitted model may take that silhouette over later. - */ - - function _refreshCockpitNearContacts() { - const camera = flightState._renderer?.view.camera; - if (!flightState._cockpitContactMode || !camera?.positionWC) { - if (flightState._cockpitNearContacts.size) - flightState._cockpitNearContacts = new Set(); - return; - } - const previous = flightState._cockpitNearContacts; - const distancesSquared = []; - for (const [icao24, bb] of flightState._billboards) { - if (icao24 === flightState._trackedIcao || !bb?.position) continue; - distancesSquared.push([ - icao24, - geo.cartesian3.distanceSquared(camera.positionWC, bb.position), - ]); - } - const next = nextCockpitNearContacts( - previous, - distancesSquared, - parts.rendering._modelAddDistM(), - parts.rendering._modelKeepDistM(), - ); - flightState._cockpitNearContacts = next; - let presentationChanged = false; - for (const [icao24, bb] of flightState._billboards) { - if (previous.has(icao24) === next.has(icao24)) continue; - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - presentationChanged = true; - } - if (presentationChanged) flightState._lastCamPoseSig = ''; - } - - /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ - - function _setCockpitContactMode(active) { - const next = active === true; - if (flightState._cockpitContactMode === next) return; - flightState._cockpitContactMode = next; - if (next) _refreshCockpitNearContacts(); - else flightState._cockpitNearContacts = new Set(); - // The collection stays visible in cockpit. Near AIR contacts retain their - // aircraft silhouette until a ready model takes over; far contacts are pips. - // Never destroy on entry: tearing down hundreds of live glTF instances - // synchronously blocked Chrome's renderer into Page Unresponsive. - flightState._renderer?.models.setVisible(true); - flightState._trail?.setVisible(!next); - if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; - for (const [icao24, bb] of flightState._billboards) - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - flightState._lastCamPoseSig = ''; - flightState._lastFleetTickMs = 0; - } - - function _applyCockpitState(detail = {}) { - const active = detail?.active === true; - flightState._cockpitSubjectId = active - ? String(detail?.subjectId || '') - .trim() - .toLowerCase() || null - : null; - _setCockpitContactMode(active); - } - - /** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and - * the load-failure bound. Called from each path that changes which contact is - * selected — deselect, re-track, cross-layer handoff, init, destroy. - * - * This must live in the production lifecycle, not only in the predicate's - * icao-change guard: a deselect followed by a same-turn re-track of the SAME - * icao (Contacts re-entry, a cross-layer round trip back to the original - * layer) never makes `_trackedIcao` *observably* change, so the guard never - * fires. Without the reset, a contact dropped inside the hysteresis band comes - * back as a MODEL above the ENTER ceiling, and a contact whose GLB had already - * failed out would never get its retries back. */ - - function _resetTrackedSelectionState() { - flightState._trackedZoomLatched = false; - flightState._trackedZoomLatchIcao = null; - flightState._trackedModelFailIcao = null; - flightState._trackedModelFailCount = 0; - flightState._trackedModelRetryAtMs = 0; - } - - /** Whether the driver may start another tracked-model load this frame. */ - - function _trackedModelLoadAllowed(nowMs = Date.now()) { - if (flightState._trackedModelFailIcao !== flightState._trackedIcao) - return true; // untried selection - if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) - return false; - return nowMs >= flightState._trackedModelRetryAtMs; - } - - /** Record a rejected tracked-model load and arm the backoff / give-up latch. */ - - function _noteTrackedModelLoadFailure(url, err) { - if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { - flightState._trackedModelFailIcao = flightState._trackedIcao; - flightState._trackedModelFailCount = 0; - } - flightState._trackedModelFailCount += 1; - flightState._trackedModelRetryAtMs = - Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; - if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { - console.warn( - `[Data:Flights] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` + - 'this contact stays 2D until another is selected', - err, - ); - } - } - - /** - * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven - * (owner directive 2026-08-19). Unlike the fleet, this does NOT consult the - * DISPLAY-rail `models3d` toggle: the selected contact is a single model, it is - * what the camera is pointed at, and zooming in on a target should resolve it - * into an aircraft without the operator arming anything. The toggle keeps - * owning the FLEET (`_modelRegimeActive`), which is the draw-call budget. - * - * Thresholds + hysteresis live in trackedModelRegime.js: enter at - * TRACKED_MODEL_ENTER_ALT_M (150_000 m — the owner's playtested swap distance, - * deliberately NEARER than the fleet's 800 km ceiling this used to inherit), - * hand back only above TRACKED_MODEL_EXIT_ALT_M, so orbiting AT the boundary - * cannot flap billboard↔model. See that module's header for why the tracked - * contact now goes 3D closer in than the fleet does. - * - * In cockpit you are sitting 7 m behind and 2.6 m above your own aircraft's - * origin, so its ~26 m airframe would fill the visor. First-person means your - * own airframe is not drawn. - */ - - function _trackedModelRegimeActive() { - if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { - flightState._trackedZoomLatchIcao = flightState._trackedIcao; - flightState._trackedZoomLatched = false; - } - // A converted TR-3B has no 3D asset — suppressing the regime keeps its - // tracked billboard fully opaque (the colour callback reads this too), so - // the triangle stays the visual all the way in. - if ( - !flightState._trackedIcao || - flightState._cockpitContactMode || - isTr3b(flightState._trackedIcao) - ) { - flightState._trackedZoomLatched = false; - return false; - } - flightState._trackedZoomLatched = trackedModelZoomActive( - flightState._renderer?.view.camera?.positionCartographic?.height, - flightState._trackedZoomLatched, - ); - return flightState._trackedZoomLatched; - } - - /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ - - function _syncTracked2dRotation() { - if (!flightState._trackedIcao || !flightState._renderer) return; - const pos = - parts.motion._trackedDisplayCached() || - flightState._billboards.get(flightState._trackedIcao)?.position; - if (!pos) return; - const projected = screenProjectedRotation( - flightState._renderer.view.scene, - pos, - parts.motion._trackedDisplayCourse(), - flightState._lastTrackedRotation, - ); - const rotation = stabilizeScreenRotation( - flightState._lastTrackedRotation, - projected, - 0, - ); - if (rotation !== null) flightState._lastTrackedRotation = rotation; - } - - /** - * Append one fix to the tracked aircraft's trail accumulation and refresh - * the rendered trail. Caller passes an owned (cloned) Cartesian3. - * @param {object} position - New fix position (Cartesian3), appended at the head. - */ - - function _appendTrailFix(position) { - flightState._trailPositions.push(position); - if (flightState._trailPositions.length > TRAIL_MAX_POINTS) - flightState._trailPositions.shift(); - _refreshTrailDisplay(); - } - - /** - * Renders the trail with its head clamped to the render-behind display - * position. Raw newest fixes run up to RENDER_DELAY_SEC ahead of the - * displayed aircraft (PRD C2 delayed clock) — drawing them verbatim makes - * the trail extend in FRONT of the icon. The head is refreshed ~1Hz from - * the fleet tick so it stays glued to the moving aircraft. - */ - - function _refreshTrailDisplay() { - // The trail BODY is the accumulated fixes EXCLUDING the newest raw one — that newest - // fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at - // now − RENDER_DELAY_SEC), so drawing it would push the trail in front of the plane. - // The cheap per-frame _trailHeadEntity segment bridges the last body point to the - // delayed dead-reckoned head, so the body primitive only rebuilds on a real fix - // (poll cadence), never at motion cadence. - if (!flightState._trail) return; - flightState._trail.setPositions( - flightState._trailPositions.length > 1 - ? flightState._trailPositions.slice(0, -1) - : flightState._trailPositions, - ); - } - - /** - * Start the trail for a newly tracked aircraft: seed it with the short - * dead-reckoning history (chronological), render immediately, then - * fire-and-forget an OpenSky track backfill. - * @param {string} icao24 - ICAO 24-bit transponder address being tracked. - */ - - function _startTrail(icao24) { - flightState._trailBackfillToken += 1; - flightState._trailPositions = []; - const history = flightState._positionHistory.get(icao24) || []; - // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The - // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them - // would draw the trail in front of the plane. They join the trail via _appendTrailFix - // as they age past the delay. - const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000; - for (const fix of history) { - if (fix.timeMs <= seedRenderTimeMs) { - flightState._trailPositions.push(geo.cartesian3.clone(fix.position)); - } - } - _attachTrail(); - const oldestFixEpochSec = history.length - ? history[0].timeMs / 1000 - : Infinity; - _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); - } - - function _attachTrail() { - if (!flightState._trail && flightState._renderer) { - flightState._trail = flightState._renderer.trails.create({ - color: TRAIL_COLOR, - width: 2.5, - }); - } - flightState._trail?.setVisible(!flightState._cockpitContactMode); - // Live head segment: last fix → current dead-reckoned icon, updated every frame via - // a CallbackProperty (Cesium updates entity-polyline positions cheaply, unlike the - // trail primitive which fully rebuilds on setPositions). Keeps the head glued 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: 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, - positions: () => { - // Need ≥2 accumulated points: the body draws all-but-newest, so the head must - // start at the last DISPLAYED body point (index n−2). With a single fix that - // point would be the sole raw fix — which is ~now, AHEAD of the delayed icon — - // so the segment would draw IN FRONT of the plane. Likewise during warm-up the - // icon predates all real history, so there is no valid body point behind it. - if ( - !flightState._trackedIcao || - flightState._trailPositions.length < 2 || - parts.motion._isTrackWarmingUp() - ) - return []; - const head = - parts.motion._trackedTrailCached() || - parts.motion._trackedDisplayPosition(flightState._trackedIcao); - if (!head) return []; - // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a - // backward/reversing segment. - const start = - flightState._trailPositions[flightState._trailPositions.length - 2]; - // On a contact that has not moved this segment runs from inside the - // model out to its own anchor — a line through the fuselage. The END - // never gives, so a moving trail still terminates on the tail; the - // START is what slides, from nothing on a parked contact out to the - // whole segment once it has cleared its own envelope. - // See trailHeadStart. Read `head` in place and clone only on the draw - // path — a suppressed parked contact runs this every frame. - const from = trailHeadStart( - start, - head, - parts.motion._trackedModelCenterWorld(), - parts.motion._trackedModelEnvelopeM(), - flightState._scratchTrailHead, - ); - if (!from) return []; - return [from, geo.cartesian3.clone(head)]; - }, - width: 2.5, - // 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, - }); - } - _refreshTrailDisplay(); - } - - /** - * Fire-and-forget OpenSky /tracks backfill (PRD F1). On success, splices - * waypoints strictly older than the oldest seeded fix AHEAD of the locally - * accumulated fine segment, capped at TRAIL_MAX_POINTS (newest kept). Any - * failure (404/429/timeout/malformed) silently keeps the local-only trail. - * @param {string} icao24 - ICAO 24-bit transponder address being tracked. - * @param {number} token - Backfill token captured at request time. - * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. - * @returns {Promise} - */ - - async function _backfillTrail(icao24, token, oldestFixEpochSec) { - let path = null; - try { - const track = await flightState.feed._source.getTrack?.( - flightState.records.data.get(icao24)?.sourceReference ?? icao24, - { - signal: AbortSignal.any([ - flightState.lifetime.signal, - AbortSignal.timeout(8000), - ]), - }, - ); - path = track?.records ?? null; - } catch { - return; // silent fallback to the accumulated trail - } - if ( - !path || - token !== flightState._trailBackfillToken || - icao24 !== flightState._trackedIcao - ) - return; - - // OpenSky track waypoints: [time, latitude, longitude, baro_altitude, true_track, on_ground] - // Height-datum fix (Task 6): /tracks only ever reports barometric/MSL altitude - // (no per-waypoint geo_altitude in this endpoint), so waypoint render height is - // the documented visual FALLBACK baroM + geoidHeight(waypointLat, waypointLon) - // — geometrically approximate, not exact, same honesty caveat as the live - // baro-fallback branch of pickRenderAltitudeM. - await ensureGeoidReady(); - const parsed = []; - for (const waypoint of path) { - const { - observedAtMs, - latitude: lat, - longitude: lon, - baroAltitudeM: baroAlt, - } = waypoint; - const time = observedAtMs / 1000; - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue; - parsed.push({ lat, lon, baroAlt }); - } - if (!parsed.length) return; - - // Field-test fix (WAKE01 trail-underground, 2026-07-06 — mirror of - // militaryFlights.js): resolve the coarse ellipsoidal ground along the track - // and floor every waypoint at it so low baro segments never dive below the - // mesh; a no-baro waypoint (predominantly taxi/ground segments in /tracks) - // sits ON the surface when the floor is known. - // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the - // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth - // lookup across a long path could stall the paint for seconds-to-timeout. - // Paint with whatever cells are warm; the resolve keeps filling the cache - // in the background for the next paint/select. - await resolveGroundFloorCellsBounded(parsed); - // Re-check the backfill token after the await (same guard as post-fetch): - // tracking may have moved on while the terrain race was in flight. - if ( - token !== flightState._trailBackfillToken || - icao24 !== flightState._trackedIcao - ) - return; - - const older = []; - let lastAltM = null; // carry-forward for no-baro points whose cell isn't warm yet - for (const { lat, lon, baroAlt } of parsed) { - const baroM = Number.isFinite(baroAlt) - ? baroAlt + geoidHeight(lat, lon) - : null; - let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); - // No baro + unresolved floor: hold the previous waypoint's altitude - // (continuity — never a dive/spike to a made-up height). Leading points - // with nothing to carry keep the old 10 km airborne default. - if (altM == null) altM = lastAltM != null ? lastAltM : 10000; - lastAltM = altM; - older.push(geo.cartesian3.fromDegrees(lon, lat, altM)); - } - - flightState._trailPositions = older.concat(flightState._trailPositions); - if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { - flightState._trailPositions = flightState._trailPositions.slice( - flightState._trailPositions.length - TRAIL_MAX_POINTS, - ); - } - _refreshTrailDisplay(); - } - - /** - * Clear the rendered trail and accumulation; invalidate pending backfills. - */ - - function _clearTrail() { - flightState._trailBackfillToken += 1; - flightState._trailPositions = []; - if (flightState._trail) flightState._trail.clear(); - if (flightState._trailHeadEntity) - flightState._renderer?.trails.removeHead(flightState._trailHeadEntity); - flightState._trailHeadEntity = null; - } - - /** - * Destroy the trail primitive entirely (layer disable/teardown). - */ - - function _destroyTrail() { - _clearTrail(); - _detachTrail(); - } - - function _detachTrail() { - if (flightState._trailHeadEntity) - flightState._renderer?.trails.removeHead(flightState._trailHeadEntity); - flightState._trailHeadEntity = null; - if (flightState._trail) { - flightState._trail.destroy(); - flightState._trail = null; - } - } - - /** - * Stop tracking the currently followed aircraft. - * - * Restores the hidden billboard, removes the tracked Entity, resets lerp - * state, and RELEASES the camera IN PLACE — no flyTo. Deselect used to fly - * an ~80 km pulled-back overview; the owner field-ruled that wrong - * (2026-07-02: "it randomly zooms way up and loses my context"). The camera - * now simply stays at its current position/orientation, immediately free to - * orbit/zoom. Applies to every deselect path: click-empty-space, Escape, - * aged-out plane, layer disable, and voice stopTracking. - * - * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the - * follow-camera: tear down our own state but leave viewer.trackedEntity - * alone (the new owner controls it). - * @param {object} [options] - Clear origin. - * @param {boolean} [options.evicted=false] - The contact aged out of the feed - * rather than being deselected. Consumers that keep a readout on screen - * (the Cockpit Contact panel) hold last-known values for an eviction and - * only tear down on a deliberate clear. - */ - - function _clearTracking( - skipViewerUntrack = false, - { evicted = false, origin = 'programmatic' } = {}, - ) { - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = null; - if (!flightState._trackedIcao) { - clearFocusTarget('flights'); - return; - } - const clearedIcao = flightState._trackedIcao; - clearFocusTarget('flights', clearedIcao); - - // Restore the original billboard appearance. The rotation is re-seeded from - // the tracked entity's last rendered rotation and a rotation pass is forced - // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the - // STALE screen rotation it had when tracking began — up to a full - // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. - if (flightState._billboards.has(flightState._trackedIcao)) { - const bb = flightState._billboards.get(flightState._trackedIcao); - bb.show = true; - bb.width = 20; - bb.height = 20; - // Ground/military-aware restore (a plane untracked while taxiing must come - // back muted-gray at ground scale, not white at full scale). - bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao); - bb.scale = parts.rendering._fleetBillboardScale( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, - ); - bb.rotation = flightState._lastTrackedRotation; - } - flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick - - // Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the - // follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner - // controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity - // does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where - // the follow left it and the user can immediately orbit/zoom. - if (flightState._renderer && !skipViewerUntrack) { - flightState._renderer.follow.release(); - } - if (flightState._trackedEntity) { - flightState._renderer.tracked.remove(flightState._trackedEntity); - flightState._trackedEntity = null; - } - parts.rendering._releaseTrackedModel(); - _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped - flightState._trackedIcao = null; - parts.rendering._applyFleetBillboardPresentation( - clearedIcao, - flightState._billboards.get(clearedIcao), - ); - clearTrackedSubjectContext('flights'); - _emitAwarenessEvent('gev:awareness-subject-cleared', { - layerId: 'flights', - id: clearedIcao, - origin, - reason: evicted ? 'evicted' : 'deliberate', - }); - // Invalidate the per-frame DR cache + reconciliation state so a same-frame re-track - // cannot read the previous aircraft's cached/smoothed position. - parts.motion._resetTrackedDisplay(); - _clearTrail(); - } + extra: { route: route || '' }, + }; + }, + // Ground/military-aware restore (a plane untracked while taxiing must come + // back at ground scale, not full scale). + fleetBillboardScale: (icao24, meta) => + parts.rendering._fleetBillboardScale(icao24, meta?.klass), + followAltitudeM: (info) => info.altitude, + onTrackStart(icao24) { + parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue + parts.enrichment._requestRouteEnrichment(icao24); + }, + // Grounded flights positions are already surface-clamped (the surfaceM + // chain), so trail seeds/appends drape without a floor of their own. + floorTrail: false, + // Leading no-baro waypoints keep the old 10 km airborne default. + backfill: { leadAltM: 10000, thinToBudget: false }, + }); /** * Whether the Military layer suppresses this civil duplicate right now. @@ -695,28 +112,6 @@ export function createTracking({ return true; } - function _applyPendingTrackingRestore() { - const pending = flightState._pendingTrackingRestore; - if ( - !pending || - pending.generation !== flightState._trackingIntentGeneration - ) - return false; - if ( - !flightState._renderer?.fleet.visible() || - !flightState._billboards.has(pending.id) - ) - return false; - flightState._pendingTrackingRestore = null; - _trackFlight(pending.id, { origin: pending.origin }); - return true; - } - - function _cancelPendingTrackingRestore() { - flightState._trackingIntentGeneration += 1; - flightState._pendingTrackingRestore = null; - } - /** Multi-line tracked presentation text: "CS · FL · kts" + "Airline · Type" + * "ORIG → DEST". The route line is gated by * routePlausible so a wrong-leg adsbdb route is hidden, not displayed. @@ -764,81 +159,6 @@ export function createTracking({ return lines.join('\n'); } - /** Write the explicit tracked presentation model and refresh its host entry. */ - - function _updateTrackedLabelModel(icao24) { - if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) - return; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _trackedLabelText(icao24), - '#39d0ff', - ); - refreshTrackedReadout(flightState._trackedEntity); - // The readout and the context slot describe the same contact — refresh them - // together so voice never narrates a fix the card has already replaced. - refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); - } - - /** Re-image the tracked entity's billboard from the current class/conversion. */ - - function _syncTrackedBillboardImage() { - if (!flightState._trackedIcao || !flightState._trackedEntity) return; - flightState._renderer.tracked.setImage( - flightState._trackedEntity, - aircraftIcon( - parts.rendering._iconKind( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, - ), - TRACKED_ICON_PX, - ), - ); - } - - /** - * Re-render one contact after its TR-3B conversion (or the active IR style) - * changed. Converting drops any 3D model so the triangle owns the visual; the - * billboard image, tracked entity, and tracked card are all re-derived here. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when the layer owns this contact. - */ - - function _refreshTr3bContact(icao24) { - const id = String(icao24 || '') - .trim() - .toLowerCase(); - if (!id) return false; - if (isTr3b(id)) { - // Drop the 3D handoff for this contact — the fleet tick now skips it, so - // an already-loaded model would otherwise linger with the billboard hidden. - if (flightState._models.has(id) || flightState._modelPending.has(id)) - parts.rendering._releaseModel(id); - const bb = flightState._billboards.get(id); - if (bb && id !== flightState._trackedIcao) bb.show = true; // horizon cull re-asserts next tick - if (id === flightState._trackedIcao) { - parts.rendering._releaseTrackedModel(); - _syncTracked2dRotation(); - } - } - const bb = flightState._billboards.get(id); - if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); - if (id === flightState._trackedIcao) { - _syncTrackedBillboardImage(); - _updateTrackedLabelModel(id); - } - flightState._renderer?.view.requestRender(); - return flightState._billboards.has(id) || id === flightState._trackedIcao; - } - - /** Re-image every converted contact this layer owns (IR style flip). */ - - function _refreshTr3bForStyle() { - for (const id of tr3bConvertedIds()) { - if (flightState._billboards.has(id) || id === flightState._trackedIcao) - _refreshTr3bContact(id); - } - } - /** Plausibility check anchored to the plane's billboard position (coarse is * fine here — this gates a LABEL, and it must not touch the tracked frame * cache). Missing data → true (never hide what we can't judge). */ @@ -863,192 +183,6 @@ export function createTracking({ }); } - /** - * Begin tracking a specific aircraft by ICAO24 address. - * - * Clears any existing tracked flight, hides its billboard, and creates a - * new Entity with: - * - A CallbackProperty position driven by dead-reckoning (_deadReckon). - * - A CallbackProperty alignedAxis set to the surface normal at the - * dead-reckoned position (keeps the icon tangent to the earth). - * - A CallbackProperty rotation from the aircraft's true_track heading. - * - An explicit host presentation model with callsign, flight level, speed, - * identity, and plausible route text. - * - * The viewer's trackedEntity is set to this entity so the camera follows it. - * - * @param {string} icao24 - ICAO 24-bit transponder address to track. - */ - - function _trackFlight( - icao24, - { origin = 'programmatic', resume = false, follow = true } = {}, - ) { - if (!resume) _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over - - const bb = flightState._billboards.get(icao24); - const info = flightState.records.data.get(icao24); - if (!bb || !info) return; - - flightState._trackedIcao = icao24; - _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget - flightState._cachedDRFrame = -1; - flightState._lastTrackedRotation = bb.rotation || 0; - // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its - // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover - // fleet model would be orphaned + double-rendered. - parts.rendering._releaseModel(icao24); - - // Hide the billboard — the tracked entity replaces it visually - bb.show = false; - - // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — - // one computation shared by the position/alignedAxis/rotation/trail-head callbacks, - // with discontinuity reconciliation). Falls back to the last billboard position when - // the aircraft has no fix. - const getTrackedPosition = () => - parts.motion._trackedDisplayPosition(icao24) || bb.position; - - // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the - // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the - // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always - // resolves into an aircraft. - // - // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. - // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the - // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the - // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel). - flightState._trackedEntity = flightState._renderer.tracked.create({ - // Dead-reckoning position — smooth continuous motion between API updates. - position: getTrackedPosition, - image: aircraftIcon( - parts.rendering._iconKind( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, - ), - TRACKED_ICON_PX, - ), - width: 28, - height: 28, - scale: - CLASS_SCALE_2D[ - flightState.records.data.get(flightState._trackedIcao)?.klass - ] || 1, - // Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading); - // transparent once the STANDALONE tracked model is actually up (ready + shown). - color: () => - parts.rendering._modelOwnsVisual(flightState._trackedIcao) - ? CYAN_TRANSPARENT - : CYAN, - scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), - // The tracked target must never vanish into tile geometry — tracking a - // taxiing plane at street level would otherwise bury the cyan icon inside - // the runway skin exactly like the fleet ground icons (_groundDepthDistance); - // its shared-host tracked card is top-composited separately. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - // Screen-projected rotation, evaluated per frame: exact in tracked-orbit - // mode where camera.heading lives in the entity's reference frame. - rotation: () => { - const tracked = flightState.records.data.get(flightState._trackedIcao); - const pos = getTrackedPosition(); - if (!tracked || !pos || !flightState._renderer) - return flightState._lastTrackedRotation; - const projected = screenProjectedRotation( - flightState._renderer.view.scene, - pos, - parts.motion._trackedDisplayCourse(), - flightState._lastTrackedRotation, - ); - const rot = stabilizeScreenRotation( - flightState._lastTrackedRotation, - projected, - ); - if (rot !== null) flightState._lastTrackedRotation = rot; - return flightState._lastTrackedRotation; - }, - }); - flightState._trackedEntity.gevSelectionOrigin = origin; - flightState._trackedEntity.gevTrackedId = `flights:${icao24}`; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _trackedLabelText(icao24), - '#39d0ff', - ); - - // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is - // far too tight (the user had to scroll out to read the plane). Give the entity a - // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable - // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) - const followRange = Math.min( - Math.max((info.altitude || 1500) * 1.1 + 2500, 3000), - 30000, - ); - flightState._renderer.tracked.setViewFrom(flightState._trackedEntity, { - x: 0, - y: -followRange * 0.8, - z: followRange * 0.55, - }); - - // Cancel any in-progress camera flight first — otherwise Cesium won't apply the - // tracked entity's viewFrom on the first frame, so voice-initiated tracking (which - // often fires mid-fly_to_location) would follow the plane WITHOUT centering/framing it - // the way a click (idle camera) does. - // Expose the camera's already-settled position to cross-module HUD consumers (the tracked-target - // readout) so they draw at the SAME spot the camera framed, without recomputing the dead-reckon in - // postRender (which would jitter the label against the now-stable plane). - flightState._trackedEntity.gevDisplayPosition = - parts.motion._trackedDisplayCached; - // Separate accessor on purpose: `gevDisplayPosition` carries the follow-camera - // anti-jitter contract and must keep returning the cached DR position. Presentation - // that should weld to the AIRCRAFT YOU SEE reads `gevVisualPosition` instead. - flightState._trackedEntity.gevVisualPosition = - parts.motion._trackedVisualCached; - refreshTrackedReadout(flightState._trackedEntity); - // Camera follows the tracked entity (the renderer cancels any flight - // first and applies the one exact tracked frame). - // A resumed binding or a share restore re-follows the session's own - // subject: marked only while the follow starts, so the shell does not - // read it as new navigation (LayerBindings.observeCamera) yet any later - // follow of the same aircraft still is. - if (follow) { - const entity = flightState._trackedEntity; - const restoring = resume || origin === 'share-restore'; - if (restoring) entity.gevRestoring = true; - try { - flightState._trackedCameraFrameStop = - flightState._renderer.follow.start(entity); - } finally { - if (restoring) delete entity.gevRestoring; - } - } - - // Track-history trail (PRD F1): seed from local history + async backfill. - // Ground traffic draws NO trail (a taxi path is noise, not a track) — if the - // plane takes off while tracked, the on_ground→false transition in update() - // starts one. - if (resume) { - _attachTrail(); - return; - } - parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue - parts.enrichment._requestRouteEnrichment(icao24); - // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing - // aircraft's history is retrievable on select. Grounded flights positions - // are already surface-clamped (the surfaceM chain), so seeds/appends drape. - _startTrail(icao24); - - _publishTrackedSelection(icao24, origin); - - console.log( - `[Data:Flights] Tracking ${parts.queries._contactLabel(icao24, info)} (${icao24})`, - ); - } - /** * Immediate military-suppression handoff sweep (pre-ship audit M2). * @@ -1097,152 +231,11 @@ export function createTracking({ } } - /** - * Escape (through the selection dispatcher) deselects the tracked flight. - */ - - function _onEscape() { - if (flightState._trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - } - - /** - * A click the selection dispatcher hands this layer: one of its aircraft - * (a fleet sprite or model, or the tracked plane's own visual). The - * dispatcher has already applied the gesture rules (a drag selects - * nothing; isTrackingSelectionGesture) and the pointer claim. - * @param {string} id - The picked id this layer owns. - */ - - function _onSelect(id) { - // Cockpit mode owns the camera and keeps the current aircraft as its - // first-person reference: a globe click selects nothing here. - if (document.body.classList.contains('cockpit-mode')) return; - // Clicking the tracked plane itself — its tracked visual or its - // standalone 3D model (pick id = its icao, H1) — is a no-op, not a - // deselect and not a re-track. - if ( - id === flightState._trackedEntity?.id || - (flightState._trackedIcao && id === flightState._trackedIcao) - ) - return; - if (flightState._billboards.has(id)) { - _cancelPendingTrackingRestore(); - _trackFlight(id, { origin: 'user' }); - } - } - - /** - * Empty space (or a pick no layer owns). The dispatcher calls this only - * for a clean, short click (isTrackingClickGesture): a slow stationary - * press may select, but cannot release existing tracking. A pick another - * layer owns never reaches here. - */ - - function _onEmptyClick() { - if (document.body.classList.contains('cockpit-mode')) return; - if (flightState._trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - } - - /** - * Join the engine's selection dispatcher (clicks with picks, Escape) and - * watch the follow camera for another layer taking it. - * - * Idempotent — returns immediately if already joined. - */ - - function _installClickHandler() { - const renderer = flightState._renderer; - if (!renderer) return; - - // Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our - // tracking so its model/entity/update-loop don't orphan — without touching the follow target - // (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch - // (the follow target briefly empty) doesn't self-clear. - if (!flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove = renderer.follow.onChanged( - () => { - const current = flightState._renderer?.follow.current(); - if ( - flightState._trackedIcao && - current && - current !== flightState._trackedEntity - ) { - _clearTracking(true, { - origin: current?.gevSelectionOrigin || 'programmatic', - }); - } - }, - ); - } - - if (flightState._selectionRemove) return; // already joined - const dispatcher = selectionDispatcherFor(renderer.engine); - if (!dispatcher) return; - flightState._selectionRemove = dispatcher.register('flights', { - // The fleet's icaos, and the tracked visual's own id: clicking it is - // this layer's no-op, never empty space. - owns: (id) => - flightState._billboards.has(id) || - (!!flightState._trackedEntity && - id === String(flightState._trackedEntity.id)), - onSelect: _onSelect, - onEmptyClick: _onEmptyClick, - onEscape: _onEscape, - }); - } - - /** Leave the selection dispatcher and stop watching the follow camera. */ - - function _removeClickHandler() { - flightState._selectionRemove?.(); - flightState._selectionRemove = null; - if (flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove(); - flightState._trackedEntityChangedRemove = null; - } - } return { - _emitAwarenessEvent, - _publishTrackedSelection, - _contextSubjectMetadata, - _isExplicitTrackingOrigin, - _refreshCockpitNearContacts, - _setCockpitContactMode, - _applyCockpitState, - _resetTrackedSelectionState, - _trackedModelLoadAllowed, - _noteTrackedModelLoadFailure, - _trackedModelRegimeActive, - _syncTracked2dRotation, - _appendTrailFix, - _refreshTrailDisplay, - _startTrail, - _backfillTrail, - _clearTrail, - _destroyTrail, - _detachTrail, - _clearTracking, + ...tracking, _militaryLayerSuppresses, - _applyPendingTrackingRestore, - _cancelPendingTrackingRestore, _trackedLabelText, - _updateTrackedLabelModel, - _syncTrackedBillboardImage, - _refreshTr3bContact, - _refreshTr3bForStyle, _routeIsPlausible, - _trackFlight, _onMilitaryActiveChange, - _onEscape, - _onSelect, - _onEmptyClick, - _installClickHandler, - _removeClickHandler, }; } diff --git a/src/layers/infrastructure/render/navara.js b/src/layers/infrastructure/render/navara.js index b6a8f4757..c3caf6181 100644 --- a/src/layers/infrastructure/render/navara.js +++ b/src/layers/infrastructure/render/navara.js @@ -31,23 +31,18 @@ export function createInfrastructureNavaraRenderer( sourceId: id, host: services.overlayHost, }); - const display = createNavaraPolygonDisplay( - engine, - native.view, - (tint, show) => ({ - polyline: { color: tint, show, width: 2, clampToGround: true }, - polygon: { - color: tint, - show, - opacity: 0.3, - transparent: true, - clampToGround: true, - outline: true, - outlineColor: tint, - outlineWidth: 2, - }, - }), - ); + const display = createNavaraPolygonDisplay(engine, native.view, (tint) => ({ + polyline: { color: tint, width: 2, clampToGround: true }, + polygon: { + color: tint, + opacity: 0.3, + transparent: true, + clampToGround: true, + outline: true, + outlineColor: tint, + outlineWidth: 2, + }, + })); let dataset = null, records = new Map(), active = new Set(), diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index a580833f6..f7289a026 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1,16 +1,6 @@ -import * as geo from '../../geo/index.js'; -import { nearFarScale } from '../shared/color.js'; -import { appClock } from '../../time/clock.js'; import { t } from '../../i18n/index.js'; -import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; -import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; -import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; -import { - screenProjectedRotation, - stabilizeScreenRotation, -} from '../../data/iconOrientation.js'; -import { trailHeadStart } from '../../data/modelVisualAnchor.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { createAircraftTracking } from '../aircraft/tracking.js'; import { TRACKED_MODEL_MAX_LOAD_FAILS, TRACKED_MODEL_RETRY_BACKOFF_MS, @@ -22,8 +12,9 @@ import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR, } from './policy.js'; -import { roundHeadingDegrees } from '../../data/headingDegrees.js'; +/** The military layer's tracking: the shared aircraft tracking with the amber + * tracked card and trails floored at the warm ground. */ export function createTracking({ flightState, services, @@ -31,78 +22,41 @@ export function createTracking({ layer, resolveAsset, }) { - const { - selectTrackedSubjectContext, - refreshTrackedSubjectContext, - clearTrackedSubjectContext, - } = services.context; - const { tr3bTypeLabel, isTr3b, tr3bConvertedIds } = - services.aircraftPresentation; - const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; - const { floorAltitudeM, cachedGroundFloor, resolveGroundFloorCellsBounded } = - services.groundFloor; - const { ensureGeoidReady, geoidHeight } = services.geoid; - const { clearFocusTarget } = services.focus; - const { selectionDispatcherFor } = services.selection; + const { tr3bTypeLabel } = services.aircraftPresentation; - function _emitAwarenessEvent(type, detail) { - if ( - typeof window === 'undefined' || - !window.dispatchEvent || - typeof CustomEvent === 'undefined' - ) - return; - window.dispatchEvent(new CustomEvent(type, { detail })); - } - - function _publishTrackedSelection(icao24, origin = 'programmatic') { - const bb = flightState._billboards.get(icao24); - const info = flightState.records.data.get(icao24); - if (!bb?.position || !info) return false; - if (flightState._trackedEntity) - flightState._trackedEntity.gevSelectionOrigin = origin; - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'military', - id: icao24, - label: - parts.queries._toCleanText(info.callsign) || - parts.queries._toCleanText(info.registration) || - icao24, - position: geo.cartesian3.clone(bb.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); - return true; - } - - /** - * Describe the selected contact for the shared context slot the voice tools - * and Cockpit read. Values are the LIVE descriptor, not a selection-time - * snapshot, so a long follow never narrates a position the plane has left. - * @param {string} icao24 Contact identity. - * @returns {object|null} Context metadata, or null when the contact is gone. - */ - - function _contextSubjectMetadata(icao24) { - const described = parts.queries._describeFlight(icao24); - if (!described) return null; - const info = flightState.records.data.get(icao24); - const label = described.callsign || described.registration || icao24; - return { - id: icao24, - layerId: 'military', - layerName: 'Military Flights', - source: flightState.feed._lastSource, - label, - latitude: described.latitude, - longitude: described.longitude, - // Flat text only: the voice payload compacts properties through a - // string cleaner that drops nested objects. - properties: { - name: label, + const tracking = createAircraftTracking({ + flightState, + services, + parts, + layerId: 'military', + layerName: 'Military Flights', + focusId: 'militaryFlights', + logTag: '[Data:Military]', + trailHeadIdPrefix: 'mil', + policy: { + RENDER_DELAY_SEC, + TRAIL_COLOR, + TRAIL_MAX_POINTS, + TRACKED_MODEL_MAX_LOAD_FAILS, + TRACKED_MODEL_RETRY_BACKOFF_MS, + }, + trackedStyle: { + color: TRACKED_ICON_COLOR, + transparentColor: AMBER_TRANSPARENT, + labelColor: '#ffd166', + baseScale: BILLBOARD_SCALE, + }, + contactLabel: (icao24, info) => + parts.queries._toCleanText(info.callsign) || + parts.queries._toCleanText(info.registration) || + icao24, + trackedLabelText: (icao24) => + _buildTrackedLabel(flightState.records.data.get(icao24), icao24), + contextFields(icao24, described) { + const info = flightState.records.data.get(icao24); + return { + label: described.callsign || described.registration || icao24, operator: parts.queries._toCleanText(info?.operator) || '', - callsign: described.callsign || '', - registration: described.registration || '', // Converted contacts report their class as TR-3B — the same override // the readout and Contacts card show. type: tr3bTypeLabel( @@ -112,88 +66,25 @@ export function createTracking({ altitude: described.onGround ? 'on ground' : parts.queries._formatAltitude(info?.altitudeFt), - speed: Number.isFinite(described.velocityMps) - ? `${Math.round(described.velocityMps * 1.944)} kt` - : '', - heading: Number.isFinite(described.track) - ? `${roundHeadingDegrees(described.track)}°` - : '', - icao24, - // Honesty cue: the contact is coasting on dead reckoning, so the - // narrated position/velocity are last-known rather than live. - // i18n-ignore: voice-context data, English for the model - status: described.stale ? 'stale (missed polls)' : 'live', - }, - }; - } - - function _isExplicitTrackingOrigin(origin) { - return origin === 'user' || origin === 'voice' || origin === 'tool'; - } - - /** Refresh the AIR-only Cockpit near band independently from model admission. */ - - function _refreshCockpitNearContacts() { - const camera = flightState._renderer?.view.camera; - if (!flightState._cockpitContactMode || !camera?.positionWC) { - if (flightState._cockpitNearContacts.size) - flightState._cockpitNearContacts = new Set(); - return; - } - const previous = flightState._cockpitNearContacts; - const distancesSquared = []; - for (const [icao24, bb] of flightState._billboards) { - if (icao24 === flightState._trackedIcao || !bb?.position) continue; - distancesSquared.push([ - icao24, - geo.cartesian3.distanceSquared(camera.positionWC, bb.position), - ]); - } - const next = nextCockpitNearContacts( - previous, - distancesSquared, - parts.rendering._modelAddDistM(), - parts.rendering._modelKeepDistM(), - ); - flightState._cockpitNearContacts = next; - let presentationChanged = false; - for (const [icao24, bb] of flightState._billboards) { - if (previous.has(icao24) === next.has(icao24)) continue; - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - presentationChanged = true; - } - if (presentationChanged) flightState._lastCamPoseSig = ''; - } - - /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ - - function _setCockpitContactMode(active) { - const next = active === true; - if (flightState._cockpitContactMode === next) return; - flightState._cockpitContactMode = next; - if (next) _refreshCockpitNearContacts(); - else flightState._cockpitNearContacts = new Set(); - // The collection stays visible in cockpit. Near AIR contacts retain their - // aircraft silhouette until a ready model takes over; far contacts are pips. - // Never bulk-destroy here: that stalls the renderer exactly as cockpit begins. - flightState._renderer?.models.setVisible(true); - flightState._trail?.setVisible(!next); - if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; - for (const [icao24, bb] of flightState._billboards) - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - flightState._lastCamPoseSig = ''; - flightState._lastFleetTickMs = 0; - } - - function _applyCockpitState(detail = {}) { - const active = detail?.active === true; - flightState._cockpitSubjectId = active - ? String(detail?.subjectId || '') - .trim() - .toLowerCase() || null - : null; - _setCockpitContactMode(active); - } + extra: {}, + }; + }, + // Ground-aware restore (a plane untracked while taxiing comes back at + // ground scale). + fleetBillboardScale: (icao24, meta) => + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta?.klass] || 1) * + (meta?.onGround ? GROUND_SCALE : 1), + followAltitudeM: (info) => + info?.altitudeFt ? info.altitudeFt * 0.3048 : null, + // Grounded military billboards keep the pre-datum default height (T7 + // groundSnap invariant), so the trail lifts its own positions instead. + floorTrail: true, + // Leading 'ground'/null trace points keep the old low breadcrumb sentinel + // (an arbitrary placeholder, never a reported altitude); the ~24 h trace + // is thinned to the vertex budget. + backfill: { leadAltM: 50, thinToBudget: true }, + }); /** * Build the multi-line presentation text for the protected tracked host card. @@ -237,919 +128,5 @@ export function createTracking({ ].join('\n'); } - /** Write the explicit tracked presentation model and refresh its host entry. */ - - function _updateTrackedLabelModel(icao24) { - if (!flightState._trackedEntity || icao24 !== flightState._trackedIcao) - return; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(flightState.records.data.get(icao24), icao24), - '#ffd166', - ); - refreshTrackedReadout(flightState._trackedEntity); - // The readout and the context slot describe the same contact — refresh them - // together so voice never narrates a fix the card has already replaced. - refreshTrackedSubjectContext(_contextSubjectMetadata(icao24)); - } - - /** Re-image the tracked entity's billboard from the current class/conversion. */ - - function _syncTrackedBillboardImage() { - if (!flightState._trackedIcao || !flightState._trackedEntity) return; - flightState._renderer.tracked.setImage( - flightState._trackedEntity, - aircraftIcon( - parts.rendering._iconKind( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, - ), - TRACKED_ICON_PX, - ), - ); - } - - /** - * Re-render one contact after its TR-3B conversion (or the active IR style) - * changed. Mirror of flights.js: converting drops any 3D model so the triangle - * owns the visual, then the billboard, tracked entity, and card re-derive. - * @param {string} icao24 - ICAO 24-bit address. - * @returns {boolean} True when the layer owns this contact. - */ - - function _refreshTr3bContact(icao24) { - const id = String(icao24 || '') - .trim() - .toLowerCase(); - if (!id) return false; - if (isTr3b(id)) { - if (flightState._models.has(id) || flightState._modelPending.has(id)) - parts.rendering._releaseModel(id); - const modelled = flightState._billboards.get(id); - if (modelled && id !== flightState._trackedIcao) modelled.show = true; // horizon cull re-asserts next tick - if (id === flightState._trackedIcao) { - parts.rendering._releaseTrackedModel(); - _syncTracked2dRotation(); - } - } - const bb = flightState._billboards.get(id); - if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); - if (id === flightState._trackedIcao) { - _syncTrackedBillboardImage(); - _updateTrackedLabelModel(id); - } - flightState._renderer?.view.requestRender(); - return flightState._billboards.has(id) || id === flightState._trackedIcao; - } - - /** Re-image every converted contact this layer owns (IR style flip). */ - - function _refreshTr3bForStyle() { - for (const id of tr3bConvertedIds()) { - if (flightState._billboards.has(id) || id === flightState._trackedIcao) - _refreshTr3bContact(id); - } - } - - /** Clear every per-SELECTION tracked-model latch: the zoom hysteresis band and - * the load-failure bound. Called from each path that changes which contact is - * selected — deselect, re-track, cross-layer handoff, init, destroy. - * - * This must live in the production lifecycle, not only in the predicate's - * icao-change guard: a deselect followed by a same-turn re-track of the SAME - * icao (Contacts re-entry, a cross-layer round trip back to this layer) never - * makes `_trackedIcao` *observably* change, so the guard never fires. Without - * the reset, a contact dropped inside the hysteresis band comes back as a - * MODEL above the ENTER ceiling, and a contact whose GLB had already failed - * out would never get its retries back. Mirror of flights.js. */ - - function _resetTrackedSelectionState() { - flightState._trackedZoomLatched = false; - flightState._trackedZoomLatchIcao = null; - flightState._trackedModelFailIcao = null; - flightState._trackedModelFailCount = 0; - flightState._trackedModelRetryAtMs = 0; - } - - /** Whether the driver may start another tracked-model load this frame. */ - - function _trackedModelLoadAllowed(nowMs = Date.now()) { - if (flightState._trackedModelFailIcao !== flightState._trackedIcao) - return true; // untried selection - if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) - return false; - return nowMs >= flightState._trackedModelRetryAtMs; - } - - /** Record a rejected tracked-model load and arm the backoff / give-up latch. */ - - function _noteTrackedModelLoadFailure(url, err) { - if (flightState._trackedModelFailIcao !== flightState._trackedIcao) { - flightState._trackedModelFailIcao = flightState._trackedIcao; - flightState._trackedModelFailCount = 0; - } - flightState._trackedModelFailCount += 1; - flightState._trackedModelRetryAtMs = - Date.now() + TRACKED_MODEL_RETRY_BACKOFF_MS; - if (flightState._trackedModelFailCount >= TRACKED_MODEL_MAX_LOAD_FAILS) { - console.warn( - `[Data:Military] tracked 3D model gave up after ${flightState._trackedModelFailCount} failed loads of ${url} — ` + - 'this contact stays 2D until another is selected', - err, - ); - } - } - - /** - * The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven - * (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the - * DISPLAY-rail `models3d` toggle, which keeps owning the FLEET - * (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live - * in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the - * owner's playtested swap distance, deliberately NEARER than the fleet's 800 km - * ceiling this used to inherit), hand back only above - * TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model. - * First-person means your own airframe is not drawn in cockpit — the eye sits - * metres from its origin. - */ - - function _trackedModelRegimeActive() { - if (flightState._trackedZoomLatchIcao !== flightState._trackedIcao) { - flightState._trackedZoomLatchIcao = flightState._trackedIcao; - flightState._trackedZoomLatched = false; - } - // A converted TR-3B has no 3D asset — suppressing the regime keeps its - // tracked billboard fully opaque (the colour callback reads this too), so - // the triangle stays the visual all the way in. Mirror of flights.js. - if ( - !flightState._trackedIcao || - flightState._cockpitContactMode || - isTr3b(flightState._trackedIcao) - ) { - flightState._trackedZoomLatched = false; - return false; - } - flightState._trackedZoomLatched = trackedModelZoomActive( - flightState._renderer?.view.camera?.positionCartographic?.height, - flightState._trackedZoomLatched, - ); - return flightState._trackedZoomLatched; - } - - /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ - - function _syncTracked2dRotation() { - if (!flightState._trackedIcao || !flightState._renderer) return; - const pos = - parts.motion._trackedDisplayCached() || - flightState._billboards.get(flightState._trackedIcao)?.position; - if (!pos) return; - const projected = screenProjectedRotation( - flightState._renderer.view.scene, - pos, - parts.motion._trackedDisplayCourse(), - flightState._lastTrackedRotation, - ); - const rotation = stabilizeScreenRotation( - flightState._lastTrackedRotation, - projected, - 0, - ); - if (rotation !== null) flightState._lastTrackedRotation = rotation; - } - - /** - * Field-test round 2 (2026-07-06): floors a trail-bound position at the warm - * coarse ground cell — a pure LIFT (above-floor positions pass through - * untouched, unknown floors change nothing). Grounded military billboards - * deliberately render at the pre-datum default height (T7 groundSnap - * invariant), so every position entering the TRAIL subsystem (seed, append, - * per-frame head) goes through this instead — the visible taxi history sits - * on the surface without touching the billboard/model machinery. - * @param {object} position - Owned position (Cartesian3; mutated/replaced freely). - * @returns {object} The same or a lifted position. - */ - - function _trailFloorPosition(position) { - const carto = geo.cartographic.fromCartesian( - position, - geo.ellipsoid.WGS84, - flightState._scratchTrailCarto, - ); - if (!carto) return position; - const latDeg = geo.math.toDegrees(carto.latitude); - const lonDeg = geo.math.toDegrees(carto.longitude); - const floored = floorAltitudeM( - carto.height, - cachedGroundFloor(latDeg, lonDeg), - ); - if (floored == null || floored === carto.height) return position; - return geo.cartesian3.fromDegrees(lonDeg, latDeg, floored); - } - - /** - * Append one fix to the tracked aircraft's trail accumulation and refresh - * the rendered trail. Caller passes an owned (cloned) Cartesian3. - * @param {object} position - New fix position (Cartesian3), appended at the head. - */ - - function _appendTrailFix(position) { - flightState._trailPositions.push(_trailFloorPosition(position)); - if (flightState._trailPositions.length > TRAIL_MAX_POINTS) - flightState._trailPositions.shift(); - _refreshTrailDisplay(); - } - - /** - * Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That - * newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at - * now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane. - * The cheap per-frame _trailHeadEntity segment bridges the last body point to the - * delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll - * cadence), never at motion cadence. - */ - - function _refreshTrailDisplay() { - if (!flightState._trail) return; - flightState._trail.setPositions( - flightState._trailPositions.length > 1 - ? flightState._trailPositions.slice(0, -1) - : flightState._trailPositions, - ); - } - - /** - * Start the trail for a newly tracked aircraft: seed it with the short - * dead-reckoning history (chronological), render immediately, then - * fire-and-forget an adsb.lol trace backfill (~24 h of real history). - * @param {string} icao24 - ICAO hex identifier being tracked. - */ - - function _startTrail(icao24) { - flightState._trailBackfillToken += 1; - flightState._trailPositions = []; - const history = flightState._positionHistory.get(icao24) || []; - // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The - // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them - // would draw the trail in front of the plane. They join via _appendTrailFix as they age. - const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000; - for (const fix of history) { - if (fix.timeMs <= seedRenderTimeMs) { - // Floor grounded-history seeds (round 2): a grounded contact's stored - // fixes carry the pre-datum default height — the trail copy sits on - // the surface instead (pure lift; airborne fixes pass through). - flightState._trailPositions.push( - _trailFloorPosition(geo.cartesian3.clone(fix.position)), - ); - } - } - _attachTrail(); - const oldestFixEpochSec = history.length - ? history[0].timeMs / 1000 - : Infinity; - _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); - } - - function _attachTrail() { - if (!flightState._trail && flightState._renderer) { - flightState._trail = flightState._renderer.trails.create({ - color: TRAIL_COLOR, - width: 2.5, - }); - } - flightState._trail?.setVisible(!flightState._cockpitContactMode); - // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated - // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, - // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued - // 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: 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, - positions: () => { - // Need ≥2 accumulated points: the body draws all-but-newest, so the head must - // start at the last DISPLAYED body point (index n−2). With a single fix that - // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the - // segment would draw IN FRONT of the plane. Likewise during warm-up the icon - // predates all real history, so there is no valid body point behind it. - if ( - !flightState._trackedIcao || - flightState._trailPositions.length < 2 || - parts.motion._isTrackWarmingUp() - ) - return []; - const head = - parts.motion._trackedTrailCached() || - parts.motion._trackedDisplayPosition(flightState._trackedIcao); - if (!head) return []; - // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a - // backward/reversing segment. - const start = - flightState._trailPositions[flightState._trailPositions.length - 2]; - // 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)); - // On a contact that has not moved this segment runs from inside the - // model out to its own anchor — a line through the fuselage. The END - // never gives, so a moving trail still terminates on the tail; the - // START is what slides, from nothing on a parked contact out to the - // whole segment once it has cleared its own envelope. Measured against - // the FLOORED endpoint, which is the one actually drawn. - // See trailHeadStart. - const from = trailHeadStart( - start, - end, - parts.motion._trackedModelCenterWorld(), - parts.motion._trackedModelEnvelopeM(), - flightState._scratchTrailHead, - ); - if (!from) return []; - return [from, end]; - }, - width: 2.5, - // 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, - }); - } - _refreshTrailDisplay(); - } - - /** - * Fire-and-forget optional source history backfill (PRD F2). Trace points are - * stride-thinned to the remaining vertex budget, then spliced strictly older - * than the oldest seeded fix AHEAD of the locally accumulated fine segment, - * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ - * malformed) silently keeps the local-only trail. - * @param {string} icao24 - ICAO hex identifier being tracked. - * @param {number} token - Backfill token captured at request time. - * @param {number} oldestFixEpochSec - Epoch seconds of the oldest seeded fix. - * @returns {Promise} - */ - - async function _backfillTrail(icao24, token, oldestFixEpochSec) { - let trace = null; - try { - const track = await flightState.feed._source.getTrack?.( - flightState.records.data.get(icao24)?.sourceReference ?? icao24, - { - signal: AbortSignal.any([ - flightState.lifetime.signal, - AbortSignal.timeout(8000), - ]), - }, - ); - trace = track?.records ?? null; - } catch { - return; // silent fallback to the accumulated trail - } - if (!trace) return; - if ( - token !== flightState._trailBackfillToken || - icao24 !== flightState._trackedIcao - ) - return; - - // Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a - // readsb trace point's altitude is barometric feet (same datum as the live - // alt_baro, NOT geometric), so a trail waypoint's render height is the same - // documented visual FALLBACK the live path uses — baroM + geoidHeight(lat,lon) - // — geometrically approximate, not exact. - await ensureGeoidReady(); - - // readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...] - const parsed = []; - for (const point of trace) { - const t = point.observedAtMs / 1000; - const lat = point.latitude, - lon = point.longitude; - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue; - parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM }); - } - if (!parsed.length) return; - - // Field-test fix (WAKE01 trail-underground, 2026-07-06): resolve the coarse - // ellipsoidal ground along the trace and clamp every waypoint at it. A - // 'ground'/null point sits ON the local surface — the old fixed 50 m - // sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m - // ellipsoidal) and dragged the whole pattern-work loop with it. - // Round-2 fix (owner: "trails suddenly much shorter / not loading"): the - // resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth - // lookup across a long path could stall the paint for seconds-to-timeout. - // Paint with whatever cells are warm; the resolve keeps filling the cache - // in the background for the next paint/select. - await resolveGroundFloorCellsBounded(parsed); - // Re-check the backfill token after the await (same guard as post-fetch): - // tracking may have moved on while the terrain race was in flight. - if ( - token !== flightState._trailBackfillToken || - icao24 !== flightState._trackedIcao - ) - return; - - let older = []; - let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet - for (const { lat, lon, baroAltitudeM } of parsed) { - const baroM = - baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon); - let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon)); - // Ground/no-alt point with an unresolved floor: hold the previous - // waypoint's altitude (continuity — never a dive to a made-up depth). - // Leading points with nothing to carry keep the old low breadcrumb - // sentinel (an arbitrary placeholder, never a reported altitude). - if (altM == null) altM = lastAltM != null ? lastAltM : 50; - lastAltM = altM; - older.push(geo.cartesian3.fromDegrees(lon, lat, altM)); - } - - // Stride-thin the backfill so it plus the live fine segment fit the cap. - const budget = Math.max( - 1, - TRAIL_MAX_POINTS - flightState._trailPositions.length, - ); - if (older.length > budget) { - const stride = Math.ceil(older.length / budget); - const thinned = []; - for (let i = 0; i < older.length; i += stride) thinned.push(older[i]); - older = thinned; - } - - flightState._trailPositions = older.concat(flightState._trailPositions); - if (flightState._trailPositions.length > TRAIL_MAX_POINTS) { - flightState._trailPositions = flightState._trailPositions.slice( - flightState._trailPositions.length - TRAIL_MAX_POINTS, - ); - } - _refreshTrailDisplay(); - } - - /** - * Clear the rendered trail and accumulation; invalidate pending backfills. - */ - - function _clearTrail() { - flightState._trailBackfillToken += 1; - flightState._trailPositions = []; - if (flightState._trail) flightState._trail.clear(); - if (flightState._trailHeadEntity) - flightState._renderer?.trails.removeHead(flightState._trailHeadEntity); - flightState._trailHeadEntity = null; - } - - /** - * Destroy the trail primitive entirely (layer disable/teardown). - */ - - function _destroyTrail() { - _clearTrail(); - _detachTrail(); - } - - function _detachTrail() { - if (flightState._trailHeadEntity) - flightState._renderer?.trails.removeHead(flightState._trailHeadEntity); - flightState._trailHeadEntity = null; - if (flightState._trail) { - flightState._trail.destroy(); - flightState._trail = null; - } - } - - /** - * Stop tracking the current aircraft and clean up all tracking state. - * Restores the original billboard, removes the tracked Entity, invalidates - * the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo - * (mirror of flights.js). Deselect used to fly an ~80 km pulled-back - * overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms - * way up and loses my context"). The camera stays at its current - * position/orientation, immediately free to orbit/zoom. Applies to every - * deselect path: click-empty-space, Escape, aged-out plane, layer disable, - * and voice stopTracking. - * - * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the - * follow-camera: tear down our own state but leave viewer.trackedEntity - * alone (the new owner controls it). - * @param {object} [options] - Clear origin. - * @param {boolean} [options.evicted=false] - The contact aged out of the feed - * rather than being deselected. Consumers that keep a readout on screen - * (the Cockpit Contact panel) hold last-known values for an eviction and - * only tear down on a deliberate clear. - */ - - function _clearTracking( - skipViewerUntrack = false, - { evicted = false, origin = 'programmatic' } = {}, - ) { - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = null; - if (!flightState._trackedIcao) { - clearFocusTarget('militaryFlights'); - return; - } - const clearedIcao = flightState._trackedIcao; - clearFocusTarget('militaryFlights', clearedIcao); - - // Restore the original billboard appearance. The rotation is re-seeded from - // the tracked entity's last rendered rotation and a rotation pass is forced - // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the - // STALE screen rotation it had when tracking began — up to a full - // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. - if (flightState._billboards.has(flightState._trackedIcao)) { - const bb = flightState._billboards.get(flightState._trackedIcao); - const meta = flightState.records.data.get(flightState._trackedIcao); - bb.show = true; - bb.width = 20; - bb.height = 20; - // Ground-aware restore (a plane untracked while taxiing comes back at - // ground scale; the fleet tint is amber, or red while broadcasting an - // emergency, on the ground and in the air). - bb.scale = - BILLBOARD_SCALE * - (CLASS_SCALE_2D[meta?.klass] || 1) * - (meta?.onGround ? GROUND_SCALE : 1); - bb.color = parts.rendering._fleetBillboardColor( - flightState._trackedIcao, - meta, - ); - bb.rotation = flightState._lastTrackedRotation; - } - flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick - - // Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the - // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). - // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in - // place, so the view stays where the follow left it and the user can immediately orbit/zoom. - if (flightState._renderer && !skipViewerUntrack) { - flightState._renderer.follow.release(); - } - if (flightState._trackedEntity) { - flightState._renderer.tracked.remove(flightState._trackedEntity); - flightState._trackedEntity = null; - } - parts.rendering._releaseTrackedModel(); - _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped - flightState._trackedIcao = null; - parts.rendering._applyFleetBillboardPresentation( - clearedIcao, - flightState._billboards.get(clearedIcao), - ); - clearTrackedSubjectContext('military'); - _emitAwarenessEvent('gev:awareness-subject-cleared', { - layerId: 'military', - id: clearedIcao, - origin, - reason: evicted ? 'evicted' : 'deliberate', - }); - // Invalidate the per-frame DR cache so a same-frame re-track cannot read - // the previous aircraft's cached position. - parts.motion._resetTrackedDisplay(); - _clearTrail(); - } - - function _applyPendingTrackingRestore() { - const pending = flightState._pendingTrackingRestore; - if ( - !pending || - pending.generation !== flightState._trackingIntentGeneration - ) - return false; - if ( - !flightState._renderer?.fleet.visible() || - !flightState._billboards.has(pending.id) - ) - return false; - flightState._pendingTrackingRestore = null; - _trackFlight(pending.id, { origin: pending.origin }); - return true; - } - - function _cancelPendingTrackingRestore() { - flightState._trackingIntentGeneration += 1; - flightState._pendingTrackingRestore = null; - } - - /** - * Begin tracking a specific aircraft. Clears any previous tracking, hides the - * billboard, creates a new Entity with dead-reckoning CallbackProperties for - * smooth continuous motion, and sets the viewer's trackedEntity so the camera - * follows. - * @param {string} icao24 - ICAO hex identifier of the aircraft to track - */ - - function _trackFlight( - icao24, - { origin = 'programmatic', resume = false, follow = true } = {}, - ) { - if (!resume) _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over - - const bb = flightState._billboards.get(icao24); - const info = flightState.records.data.get(icao24); - if (!bb || !info) return; - - flightState._trackedIcao = icao24; - _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget - flightState._cachedDRFrame = -1; - flightState._lastTrackedRotation = bb.rotation || 0; - // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its - // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover - // fleet model would be orphaned + double-rendered. - parts.rendering._releaseModel(icao24); - - // Hide the billboard -- the tracked entity replaces it visually - bb.show = false; - - // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — - // one computation shared by the position/rotation/trail-head callbacks, with - // discontinuity reconciliation). Falls back to the last billboard position when the - // aircraft has no fix. - const getTrackedPosition = () => - parts.motion._trackedDisplayPosition(icao24) || bb.position; - - // World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0). - // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the - // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the - // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always - // resolves into an aircraft. - // - // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. - // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D - // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is - // what makes the follow-camera's bounding sphere always ready (see _trackedModel). - // The renderer keeps Cesium's EntityView in the same ENU frame as the - // close-range camera guard (AUTO may otherwise alternate with a velocity frame). - flightState._trackedEntity = flightState._renderer.tracked.create({ - // Dead-reckoning position, evaluated every render frame. - position: getTrackedPosition, - image: aircraftIcon( - parts.rendering._iconKind( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, - ), - TRACKED_ICON_PX, - ), - width: 28, - height: 28, - scale: - BILLBOARD_SCALE * - (CLASS_SCALE_2D[ - flightState.records.data.get(flightState._trackedIcao)?.klass - ] || 1), - // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); - // transparent once the STANDALONE tracked model is actually up (ready + shown). - color: () => - parts.rendering._modelOwnsVisual(flightState._trackedIcao) - ? AMBER_TRANSPARENT - : TRACKED_ICON_COLOR, - scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), - // The tracked target must never vanish into tile geometry — tracking a - // taxiing plane at street level would otherwise bury the amber icon inside - // the runway skin exactly like the fleet ground icons (_groundDepthDistance); - // its shared-host tracked card is top-composited separately. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - // Screen-projected rotation, evaluated per frame: exact in tracked-orbit - // mode where camera.heading lives in the entity's reference frame. - rotation: () => { - const tracked = flightState.records.data.get(flightState._trackedIcao); - const pos = getTrackedPosition(); - if (!tracked || !pos || !flightState._renderer) - return flightState._lastTrackedRotation; - const projected = screenProjectedRotation( - flightState._renderer.view.scene, - pos, - parts.motion._trackedDisplayCourse(), - flightState._lastTrackedRotation, - ); - const rot = stabilizeScreenRotation( - flightState._lastTrackedRotation, - projected, - ); - if (rot !== null) flightState._lastTrackedRotation = rot; - return flightState._lastTrackedRotation; - }, - }); - flightState._trackedEntity.gevSelectionOrigin = origin; - flightState._trackedEntity.gevTrackedId = `military:${icao24}`; - flightState._trackedEntity.gevLabelModel = trackedLabelModelFromText( - _buildTrackedLabel(info, icao24), - '#ffd166', - ); - - // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is - // far too tight (the user had to scroll out to read the plane). Give the entity a - // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable - // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) - const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; - const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); - flightState._renderer.tracked.setViewFrom(flightState._trackedEntity, { - x: 0, - y: -followRange * 0.8, - z: followRange * 0.55, - }); - - // Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked - // entity's viewFrom on the first frame, so voice-initiated tracking (which often fires - // mid-fly_to_location) would follow the plane WITHOUT centering it like a click does. - // Cross-module HUD consumers (tracked-target readout) read the camera's settled position, not a - // postRender recompute, so the label doesn't jitter against the now-stable plane (mirror of flights). - flightState._trackedEntity.gevDisplayPosition = - parts.motion._trackedDisplayCached; - // Separate accessor on purpose (mirror of flights.js): `gevDisplayPosition` keeps the - // follow-camera anti-jitter contract; presentation that must weld to the aircraft you - // can see reads `gevVisualPosition`. - flightState._trackedEntity.gevVisualPosition = - parts.motion._trackedVisualCached; - refreshTrackedReadout(flightState._trackedEntity); - // Camera follows the tracked entity (the renderer cancels any flight - // first and applies the one exact tracked frame). - // A resumed binding or a share restore re-follows the session's own - // subject: marked only while the follow starts, so the shell does not - // read it as new navigation (LayerBindings.observeCamera) yet any later - // follow of the same aircraft still is. - if (follow) { - const entity = flightState._trackedEntity; - const restoring = resume || origin === 'share-restore'; - if (restoring) entity.gevRestoring = true; - try { - flightState._trackedCameraFrameStop = - flightState._renderer.follow.start(entity); - } finally { - if (restoring) delete entity.gevRestoring; - } - } - if (resume) { - _attachTrail(); - return; - } - - // Track-history trail (PRD F2): seed from local history + async backfill. - // Ground traffic draws NO trail — if the plane takes off while tracked, the - // ground→air transition in update() starts one. - // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing - // aircraft's history is retrievable on select. Trail positions are floored - // at the surface (_trailFloorPosition), so ground legs drape, never dive. - _startTrail(icao24); - - const callsign = - parts.queries._toCleanText(info.callsign) || - parts.queries._toCleanText(info.registration) || - icao24; - _publishTrackedSelection(icao24, origin); - console.log(`[Data:Military] Tracking ${callsign} (${icao24})`); - } - - /** - * Escape (through the selection dispatcher) clears the active aircraft tracking. - */ - - function _onEscape() { - if (flightState._trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - } - - /** - * A click the selection dispatcher hands this layer: one of its aircraft - * (a fleet sprite or model, or the tracked plane's own visual). The - * dispatcher has already applied the gesture rules (a drag selects - * nothing; isTrackingSelectionGesture) and the pointer claim. - * @param {string} id - The picked id this layer owns. - */ - - function _onSelect(id) { - // Cockpit mode owns the camera and keeps the current aircraft as its - // first-person reference: a globe click selects nothing here. - if (document.body.classList.contains('cockpit-mode')) return; - // Clicking the tracked plane itself — its tracked visual or its - // standalone 3D model (pick id = its icao, H1) — is a no-op, not a - // deselect and not a re-track. - if ( - id === flightState._trackedEntity?.id || - (flightState._trackedIcao && id === flightState._trackedIcao) - ) - return; - if (flightState._billboards.has(id)) { - _cancelPendingTrackingRestore(); - _trackFlight(id, { origin: 'user' }); - } - } - - /** - * Empty space (or a pick no layer owns). The dispatcher calls this only - * for a clean, short click (isTrackingClickGesture): a slow stationary - * press may select, but cannot release existing tracking. A pick another - * layer owns (commercial flight, satellite, vessel, station, CCTV - * camera…) never reaches here. - */ - - function _onEmptyClick() { - if (document.body.classList.contains('cockpit-mode')) return; - if (flightState._trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - } - - /** - * Join the engine's selection dispatcher (clicks with picks, Escape) and - * watch the follow camera for another layer taking it. - * Idempotent -- no-ops if already joined. - */ - - function _installClickHandler() { - const renderer = flightState._renderer; - if (!renderer) return; - - // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our - // tracking without touching the follow target (the new owner controls it). - if (!flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove = renderer.follow.onChanged( - () => { - const current = flightState._renderer?.follow.current(); - if ( - flightState._trackedIcao && - current && - current !== flightState._trackedEntity - ) { - _clearTracking(true, { - origin: current?.gevSelectionOrigin || 'programmatic', - }); - } - }, - ); - } - - if (flightState._selectionRemove) return; // already joined - const dispatcher = selectionDispatcherFor(renderer.engine); - if (!dispatcher) return; - flightState._selectionRemove = dispatcher.register('military', { - // The fleet's icaos, and the tracked visual's own id: clicking it is - // this layer's no-op, never empty space. - owns: (id) => - flightState._billboards.has(id) || - (!!flightState._trackedEntity && - id === String(flightState._trackedEntity.id)), - onSelect: _onSelect, - onEmptyClick: _onEmptyClick, - onEscape: _onEscape, - }); - } - - /** Leave the selection dispatcher and stop watching the follow camera. */ - - function _removeClickHandler() { - flightState._selectionRemove?.(); - flightState._selectionRemove = null; - if (flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove(); - flightState._trackedEntityChangedRemove = null; - } - } - return { - _emitAwarenessEvent, - _publishTrackedSelection, - _contextSubjectMetadata, - _isExplicitTrackingOrigin, - _refreshCockpitNearContacts, - _setCockpitContactMode, - _applyCockpitState, - _buildTrackedLabel, - _updateTrackedLabelModel, - _syncTrackedBillboardImage, - _refreshTr3bContact, - _refreshTr3bForStyle, - _resetTrackedSelectionState, - _trackedModelLoadAllowed, - _noteTrackedModelLoadFailure, - _trackedModelRegimeActive, - _syncTracked2dRotation, - _trailFloorPosition, - _appendTrailFix, - _refreshTrailDisplay, - _startTrail, - _attachTrail, - _detachTrail, - _backfillTrail, - _clearTrail, - _destroyTrail, - _clearTracking, - _applyPendingTrackingRestore, - _cancelPendingTrackingRestore, - _trackFlight, - _onEscape, - _onSelect, - _onEmptyClick, - _installClickHandler, - _removeClickHandler, - }; + return { ...tracking, _buildTrackedLabel }; } diff --git a/src/layers/shared/render/navaraPolygons.js b/src/layers/shared/render/navaraPolygons.js index 916b733a9..aa91e6fa4 100644 --- a/src/layers/shared/render/navaraPolygons.js +++ b/src/layers/shared/render/navaraPolygons.js @@ -1,7 +1,13 @@ /** GeoJSON features grouped by paint. Swap complete batches so a failed - * source or layer allocation cannot wipe the last rendered result. */ + * source or layer allocation cannot wipe the last rendered result. + * + * Hidden means no layers at all: Navara 0.2.1 sometimes drops the first + * `layer.update({ show: false })` after a layer is added (a zone turned off + * right after an engine switch stayed drawn), while a deleted layer always + * goes. Showing again rebuilds the batches from the last groups. */ export function createNavaraPolygonDisplay(engine, view, style) { let visible = false; + let groups = new Map(); let batches = []; function release(items) { for (const { layer, source } of items) { @@ -9,43 +15,50 @@ export function createNavaraPolygonDisplay(engine, view, style) { source.delete(); } } + function build(next) { + const built = []; + try { + for (const [css, features] of next) { + const color = Number.parseInt(css.slice(1), 16); + const source = view.addSource({ + type: 'geojson', + data: { type: 'FeatureCollection', features }, + }); + const batch = { source }; + built.push(batch); + batch.layer = view.addLayer({ + type: 'vector', + source, + ...style(color), + }); + } + } catch (error) { + release(built); + throw error; + } + release(batches); + batches = built; + } return { setVisible(show) { if (visible === show) return; - visible = show; - for (const { color, source, layer } of batches) { - layer.update({ type: 'vector', source, ...style(color, show) }); + if (show) build(groups); + else { + release(batches); + batches = []; } + visible = show; engine.render.request('polygon-visibility'); }, - replace(groups) { - const next = []; - try { - for (const [css, features] of groups) { - const color = Number.parseInt(css.slice(1), 16); - const source = view.addSource({ - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }); - const batch = { source, color }; - next.push(batch); - batch.layer = view.addLayer({ - type: 'vector', - source, - ...style(color, visible), - }); - } - } catch (error) { - release(next); - throw error; - } - release(batches); - batches = next; + replace(next) { + if (visible) build(next); + groups = next; engine.render.request('polygon-data'); }, destroy() { release(batches); batches = []; + groups = new Map(); engine.render.request('polygon-destroy'); }, }; diff --git a/src/layers/shared/render/navaraPolygons.test.mjs b/src/layers/shared/render/navaraPolygons.test.mjs new file mode 100644 index 000000000..6557fb1bc --- /dev/null +++ b/src/layers/shared/render/navaraPolygons.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNavaraPolygonDisplay } from './navaraPolygons.js'; + +function fakeView({ failOn = null } = {}) { + const live = new Set(); + let added = 0; + const handle = (kind, description) => { + const item = { + kind, + description, + delete: () => live.delete(item), + update: () => assert.fail('the display never updates a layer in place'), + }; + live.add(item); + return item; + }; + return { + live, + layers: () => [...live].filter((item) => item.kind === 'layer'), + addSource: (description) => handle('source', description), + addLayer: (description) => { + if (++added === failOn) throw new Error('layer allocation failed'); + return handle('layer', description); + }, + }; +} + +const engine = { render: { request() {} } }; +const groups = (...colors) => + new Map(colors.map((css) => [css, [{ type: 'Feature', id: css }]])); + +test('a hidden display holds no Navara layers, and showing it rebuilds them', () => { + const view = fakeView(); + const display = createNavaraPolygonDisplay(engine, view, (color) => ({ + polygon: { color }, + })); + display.replace(groups('#ff0000', '#00ff00')); + assert.equal(view.live.size, 0, 'nothing is built while hidden'); + display.setVisible(true); + assert.deepEqual( + view.layers().map((layer) => layer.description.polygon.color), + [0xff0000, 0x00ff00], + ); + // Navara 0.2.1 may drop a show:false update on a new layer: hiding deletes. + display.setVisible(false); + assert.equal(view.live.size, 0); + display.setVisible(true); + assert.equal(view.layers().length, 2); + display.destroy(); + assert.equal(view.live.size, 0); +}); + +test('a visible display swaps whole batches and keeps the last on a failure', () => { + const view = fakeView({ failOn: 3 }); + const display = createNavaraPolygonDisplay(engine, view, (color) => ({ + polygon: { color }, + })); + display.setVisible(true); + display.replace(groups('#ff0000')); + display.replace(groups('#0000ff')); + assert.deepEqual( + view.layers().map((layer) => layer.description.polygon.color), + [0x0000ff], + ); + assert.throws( + () => display.replace(groups('#00ff00')), + /layer allocation failed/, + ); + assert.deepEqual( + view.layers().map((layer) => layer.description.polygon.color), + [0x0000ff], + ); + assert.equal(view.live.size, 2, 'the failed batch released its source'); + // The failed rows were not adopted: hiding and showing redraws the last. + display.setVisible(false); + display.setVisible(true); + assert.deepEqual( + view.layers().map((layer) => layer.description.polygon.color), + [0x0000ff], + ); +}); diff --git a/src/layers/shared/render/navaraZones.js b/src/layers/shared/render/navaraZones.js index fa43e3104..c93840c63 100644 --- a/src/layers/shared/render/navaraZones.js +++ b/src/layers/shared/render/navaraZones.js @@ -14,17 +14,15 @@ export function createZonesNavaraRenderer( { idPrefix, colorOf }, ) { let polygons = []; - const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({ + const display = createNavaraPolygonDisplay(engine, view, (color) => ({ polygon: { color, - show, clampToGround: true, transparent: true, opacity: 0.25, }, polyline: { color, - show, clampToGround: true, width: 2, transparent: true, diff --git a/src/routeCinematics.test.mjs b/src/routeCinematics.test.mjs index 622c8ab63..6edc52726 100644 --- a/src/routeCinematics.test.mjs +++ b/src/routeCinematics.test.mjs @@ -34,6 +34,7 @@ import { routeSpeedProfile, signedTurnRad, } from './cameraVerbs.js'; +import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs'; const FRAME_S = 1 / 60; const CRUISE_M_S = { slow: 20, normal: 40, fast: 90 }; @@ -58,68 +59,43 @@ function rollDegOf(eye, direction, up) { } /** - * A viewer whose camera actually MODELS orientation: setView with a - * direction/up pair updates heading/pitch/roll the way Cesium's own - * decomposition does, and setView with an explicit roll writes it back. Without - * that, a pin can only prove the code called setView — not that the horizon - * the user is left holding is level. + * The Cesium engine over a real Cesium camera, whose setView calls are + * recorded. Its heading/pitch/roll are Cesium's own decomposition of the + * camera it is left with, so a pin proves the horizon the user is left + * holding is level, not only that the code called setView. */ function createTickableViewer({ heightM = 700 } = {}) { - const listeners = []; - const setViews = []; // The tick derives dt from the wall clock, so a synchronous test loop would // advance the flight by ~0 no matter how many times it ticked. Stub the // clock and step it explicitly. const realPerformance = globalThis.performance; let nowMs = 0; globalThis.performance = { now: () => nowMs }; - const position = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, heightM); - const camera = { - positionWC: position, - positionCartographic: Cesium.Cartographic.fromCartesian(position), - heading: 0, - pitch: Cesium.Math.toRadians(-45), - roll: 0, - cancelFlight() {}, - lookAtTransform() {}, - setView(options) { - setViews.push(options); - const orientation = options?.orientation; - // A destination ALWAYS moves the camera, whatever orientation form came - // with it. Branching on the orientation shape instead would let a - // levelling call that also passed a destination slip past the pose pin. - if (options?.destination) camera.positionWC = options.destination; - if (orientation?.direction) { - camera.roll = Cesium.Math.toRadians(rollDegOf( - options?.destination || camera.positionWC, orientation.direction, orientation.up, - )); - } - if (orientation && Number.isFinite(orientation.roll)) camera.roll = orientation.roll; - if (orientation && Number.isFinite(orientation.heading)) camera.heading = orientation.heading; - if (orientation && Number.isFinite(orientation.pitch)) camera.pitch = orientation.pitch; - }, - }; - const viewer = { - trackedEntity: undefined, - clock: { onTick: { addEventListener: (fn) => { listeners.push(fn); return () => {}; } } }, - scene: { - canvas: { addEventListener() {}, removeEventListener() {} }, - tweens: [], - requestRender() {}, - // A live app has a rendered surface under the route; the mesh probe reads - // it, so these flights start immediately rather than arming. - sampleHeight: () => 0, - }, - camera, + const { viewer, scene, camera, engine, frame } = createCameraViewer(); + // A live app has a rendered surface under the route; the mesh probe reads + // it, so these flights start immediately rather than arming. + scene.sampleHeightSupported = true; + scene.sampleHeight = () => 0; + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(-97.76, 30.26, heightM), + orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 }, + }); + const setViews = []; + const setView = camera.setView; + camera.setView = function (options) { + setViews.push(options); + return setView.call(this, options); }; return { viewer, + engine, setViews, tick: (dtS = FRAME_S) => { nowMs += dtS * 1000; - for (const fn of listeners) fn(); + frame(); }, - rollDeg: () => Cesium.Math.toDegrees(camera.roll), + rollDeg: () => + Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(camera.roll)), restore: () => { globalThis.performance = realPerformance; }, }; } @@ -789,7 +765,7 @@ test('the reduced-motion PREFERENCE reaches a real flight, not just the flag', ( assert.ok(queries.some((q) => /prefers-reduced-motion:\s*reduce/.test(q)), `queried ${queries}`); const viewer = createTickableViewer(); - initCameraVerbs(viewer.viewer, () => null); + initCameraVerbs(viewer.engine, () => null); const started = flyRoute([{ type: 'route', label: 'reduced', path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })), }]); @@ -886,7 +862,7 @@ test('approachValue is frame-rate independent and adopts a cold value', () => { test('an interrupt MID-BANK stops the dolly and puts the horizon back level', () => { const viewer = createTickableViewer(); - initCameraVerbs(viewer.viewer, () => null); + initCameraVerbs(viewer.engine, () => null); const started = flyRoute([{ type: 'route', @@ -923,17 +899,23 @@ test('an interrupt MID-BANK stops the dolly and puts the horizon back level', () `the horizon was left tilted at ${viewer.rollDeg().toFixed(3)}° after the cut`, ); // Levelling preserves where the camera was looking — it is not a re-frame. - // Asserted NUMERICALLY, not just by the omitted destination: position, - // heading and pitch must come out the far side unchanged. + // Asserted NUMERICALLY: position, heading and pitch must come out the far + // side unchanged. const levelling = viewer.setViews.at(-1); assert.equal(levelling.orientation.roll, 0); - assert.equal(levelling.destination, undefined, 'levelling must not move the camera'); assert.equal( Cesium.Cartesian3.distance(viewer.viewer.camera.positionWC, poseAtCut.position), 0, 'the camera moved while being levelled', ); - assert.equal(viewer.viewer.camera.heading, poseAtCut.heading, 'heading changed while levelling'); - assert.equal(viewer.viewer.camera.pitch, poseAtCut.pitch, 'pitch changed while levelling'); + // Cesium re-derives heading and pitch from the basis: equal to rounding. + assert.ok( + Math.abs(viewer.viewer.camera.heading - poseAtCut.heading) < 1e-12, + 'heading changed while levelling', + ); + assert.ok( + Math.abs(viewer.viewer.camera.pitch - poseAtCut.pitch) < 1e-12, + 'pitch changed while levelling', + ); const writesAtCut = viewer.setViews.length; viewer.tick(); @@ -948,7 +930,7 @@ test('an interrupt MID-BANK stops the dolly and puts the horizon back level', () test('a completed dolly lands wings level and releases the slot', () => { const viewer = createTickableViewer(); - initCameraVerbs(viewer.viewer, () => null); + initCameraVerbs(viewer.engine, () => null); // A route that is still turning as it ends — the case where a bank would // otherwise be frozen into the final frame. const started = flyRoute([{ @@ -1010,7 +992,7 @@ test('a flight can be stopped by its owner, and only while it is still theirs', // somewhere, a tracked aircraft took over — and stopping that would be a // feature reaching across the app to cancel a stranger's motion. const viewer = createTickableViewer(); - initCameraVerbs(viewer.viewer, () => null); + initCameraVerbs(viewer.engine, () => null); const path = TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })); assert.equal(activeCameraMotionId(), 0, 'an idle camera has no motion id'); @@ -1053,7 +1035,7 @@ test('a new viewer lifetime drops the previous view-target getter', () => { const first = createTickableViewer(); const target = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, 0); let asked = 0; - initCameraVerbs(first.viewer, () => { asked += 1; return target; }); + initCameraVerbs(first.engine, () => { asked += 1; return target; }); const orbit = moveCamera({ motion: 'orbit', mode: 'continuous' }); assert.equal(orbit.ok, true); assert.equal(orbit.armed, undefined, 'the installed getter supplied a target'); @@ -1061,7 +1043,7 @@ test('a new viewer lifetime drops the previous view-target getter', () => { interruptCameraMotion('test-cleanup'); // Same viewer, no getter: the installed one stays. - initCameraVerbs(first.viewer); + initCameraVerbs(first.engine); const again = moveCamera({ motion: 'orbit', mode: 'continuous' }); assert.equal(again.armed, undefined, 'the getter survives a call without one'); interruptCameraMotion('test-cleanup'); @@ -1070,7 +1052,7 @@ test('a new viewer lifetime drops the previous view-target getter', () => { // New viewer, no getter: the old one is gone, so the orbit arms instead of // capturing a target from a viewer that no longer exists. const second = createTickableViewer(); - initCameraVerbs(second.viewer); + initCameraVerbs(second.engine); const askedBefore = asked; const armed = moveCamera({ motion: 'orbit', mode: 'continuous' }); assert.equal(armed.armed, 'waiting-for-arrival'); @@ -1081,7 +1063,7 @@ test('a new viewer lifetime drops the previous view-target getter', () => { test('a route hidden from Draw is not flown; the newest visible one is', () => { const viewer = createTickableViewer(); - initCameraVerbs(viewer.viewer, () => null); + initCameraVerbs(viewer.engine, () => null); const route = (label, hidden) => ({ type: 'route', label, diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index 82e893719..6a82c9961 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -46,18 +46,23 @@ export function readLayerSource(file) { : '../layers/vessels', ); // 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. + // the aircraft layers, the shared aircraft renderer and tracking they draw + // through: checks on what the layer does keep finding it where it moved. const directories = [directory, join(directory, 'render')]; - if (['flights.js', 'militaryFlights.js'].includes(basename(path))) + const shared = []; + if (['flights.js', 'militaryFlights.js'].includes(basename(path))) { directories.push(join(directory, '../aircraft/render')); - return directories - .filter((dir) => existsSync(dir)) - .flatMap((dir) => - readdirSync(dir) - .filter((name) => name.endsWith('.js')) - .sort() - .map((name) => readFileSync(join(dir, name), 'utf8')), - ) - .join('\n'); + shared.push(join(directory, '../aircraft/tracking.js')); + } + return [ + ...directories + .filter((dir) => existsSync(dir)) + .flatMap((dir) => + readdirSync(dir) + .filter((name) => name.endsWith('.js')) + .sort() + .map((name) => readFileSync(join(dir, name), 'utf8')), + ), + ...shared.map((file) => readFileSync(file, 'utf8')), + ].join('\n'); } diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index 12c8f2c9c..6c4ce3bf1 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -43,6 +43,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) { positionWC: position, directionWC: new Cesium.Cartesian3(-1, 0, 0), upWC: new Cesium.Cartesian3(0, 0, 1), + // The basis in the camera's reference frame (the world: identity), which + // the engine's setView writes. + direction: new Cesium.Cartesian3(-1, 0, 0), + up: new Cesium.Cartesian3(0, 0, 1), + right: new Cesium.Cartesian3(0, 1, 0), + inverseTransform: Cesium.Matrix4.IDENTITY, heading: Cesium.Math.toRadians(90), pitch: Cesium.Math.toRadians(-90), roll: 0, diff --git a/src/ui/flyToDataSource.js b/src/ui/flyToDataSource.js index 1a18c4c0d..7afab6300 100644 --- a/src/ui/flyToDataSource.js +++ b/src/ui/flyToDataSource.js @@ -3,7 +3,6 @@ import * as Cesium from 'cesium'; import { guardCameraAboveGround } from '../cameraGroundGuard.js'; import { rangeForBoundingSphere } from '../data/cameraFraming.js'; import { cancelCameraArrival } from '../data/cameraArrival.js'; -import { isGlobeEngine } from '../globe/contract.js'; /** All visible positions, path vertices, polygon rings, walls and overlay bounds. */ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) { @@ -72,17 +71,17 @@ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) { /** * Fly synchronously to the footprint; later navigation always owns its - * camera. `viewer` is a Cesium viewer or a globe engine (Navara), which flies - * the same sphere through the contract's flyToSphere. + * camera. `engine` is the globe engine (Cesium or Navara), which flies the + * sphere through the contract's flyToSphere. */ export function flyToDataSource( - viewer, + engine, dataSource, { duration = 1.5, ground = null } = {}, ) { const points = dataSourcePoints(dataSource); if (!points.length) return false; - cancelCameraArrival(viewer); + cancelCameraArrival(engine); const positions = points.map(([lon, lat, height]) => Cesium.Cartesian3.fromDegrees(lon, lat, height), ); @@ -92,12 +91,9 @@ export function flyToDataSource( Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]); const lat = Cesium.Math.toDegrees(center.latitude), lon = Cesium.Math.toDegrees(center.longitude); - const engine = isGlobeEngine(viewer) ? viewer : null; - const terrain = engine - ? engine.surface.terrainHeightSupported - ? engine.surface.terrainHeight(center) - : undefined - : viewer.scene?.globe?.getHeight?.(center); + const terrain = engine.surface.terrainHeightSupported + ? engine.surface.terrainHeight(center) + : undefined; const cached = ground?.cachedGroundFloor?.(lat, lon); const height = Number.isFinite(terrain) && terrain > 0 @@ -119,38 +115,26 @@ export function flyToDataSource( sphere.radius, Cesium.Cartesian3.distance(sphere.center, position), ); - if (engine) { - const pose = engine.camera.pose(); - const aspect = pose.aspectRatio ?? 1; - // Cesium's frustum fov: the wider side's angle. - const fov = - aspect > 1 ? 2 * Math.atan(Math.tan(pose.fovy / 2) * aspect) : pose.fovy; - engine.camera.flyToSphere( - { center: sphere.center, radius: sphere.radius }, - { - heading: pose.heading || 0, - pitch: -Math.PI / 2, - range: Math.max( - 800, - rangeForBoundingSphere( - { camera: { frustum: { fov, aspectRatio: aspect } } }, - sphere.radius, - ), + const pose = engine.camera.pose(); + const aspect = pose.aspectRatio ?? 1; + // Cesium's frustum fov: the wider side's angle. + const fov = + aspect > 1 ? 2 * Math.atan(Math.tan(pose.fovy / 2) * aspect) : pose.fovy; + engine.camera.flyToSphere( + { center: sphere.center, radius: sphere.radius }, + { + heading: pose.heading || 0, + pitch: -Math.PI / 2, + range: Math.max( + 800, + rangeForBoundingSphere( + { camera: { frustum: { fov, aspectRatio: aspect } } }, + sphere.radius, ), - duration, - complete: () => guardCameraAboveGround(engine, { lat, lon }), - }, - ); - return true; - } - viewer.camera?.flyToBoundingSphere?.(sphere, { - offset: new Cesium.HeadingPitchRange( - viewer.camera.heading || 0, - -Math.PI / 2, - Math.max(800, rangeForBoundingSphere(viewer, sphere.radius)), - ), - duration, - complete: () => guardCameraAboveGround(viewer, { lat, lon }), - }); + ), + duration, + complete: () => guardCameraAboveGround(engine, { lat, lon }), + }, + ); return true; } diff --git a/src/ui/flyToDataSource.test.mjs b/src/ui/flyToDataSource.test.mjs index 6129b9fb9..cee852ae5 100644 --- a/src/ui/flyToDataSource.test.mjs +++ b/src/ui/flyToDataSource.test.mjs @@ -2,8 +2,21 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { cancelCameraArrival } from '../data/cameraArrival.js'; +import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; +import { createFakeViewer } from '../globe/cesium/testing/fakeViewer.mjs'; import { dataSourcePoints, flyToDataSource } from './flyToDataSource.js'; +/** The Cesium engine over a recording viewer, and the sphere flights it made. */ +function cesiumEngine() { + const viewer = createFakeViewer(); + const engine = createCesiumEngine(viewer, { requestRender() {} }); + const calls = () => + viewer + .callsOf('camera.flyToBoundingSphere') + .map(({ args: [sphere, options] }) => ({ sphere, options })); + return { viewer, engine, calls }; +} + function source() { const ds = new Cesium.CustomDataSource('t'); ds.entities.add({ @@ -35,29 +48,23 @@ test('points come from positions, lines and polygons, as lon/lat only', () => { }); test('the camera frames the footprint at the surface, never a buried center', () => { - const calls = []; - const viewer = { - camera: { - heading: 0, - flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }), - }, - flyTo: () => calls.push('viewer.flyTo'), - }; - assert.equal(flyToDataSource(viewer, source()), true); - const center = Cesium.Cartographic.fromCartesian(calls[0].sphere.center); + const { engine, calls } = cesiumEngine(); + assert.equal(flyToDataSource(engine, source()), true); + const [flight] = calls(); + const center = Cesium.Cartographic.fromCartesian(flight.sphere.center); assert.ok(Math.abs(center.height) < 50, `center height ${center.height}`); - assert.ok(calls[0].options.offset.range >= 800); + assert.ok(flight.options.offset.range >= 800); assert.equal( - typeof calls[0].options.complete, + typeof flight.options.complete, 'function', 'the ground guard runs on landing', ); assert.equal( - flyToDataSource(viewer, new Cesium.CustomDataSource('empty')), + flyToDataSource(engine, new Cesium.CustomDataSource('empty')), false, ); assert.equal( - calls.length, + calls().length, 1, 'empty sources never start deferred navigation', ); @@ -112,24 +119,24 @@ test('portrait framing accounts for FOV, high ground and symmetric extents', () positions: Cesium.Cartesian3.fromDegreesArray([-105, 39, -104.9, 39]), }, }); - const calls = []; - const viewer = { - scene: { globe: { getHeight: () => 2600 } }, - camera: { - frustum: { fov: Math.PI / 3, aspectRatio: 390 / 844 }, - flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }), - }, - }; - flyToDataSource(viewer, ds); + const { viewer, engine, calls } = cesiumEngine(); + viewer.scene.globe.getHeight = () => 2600; + // A 60° field of view on the narrower side, as Cesium's frustum keeps it: + // vertical in portrait, horizontal in landscape. + const { frustum } = viewer.camera; + frustum.aspectRatio = 390 / 844; + frustum.fovy = Math.PI / 3; + flyToDataSource(engine, ds); assert.ok( Math.abs( - Cesium.Cartographic.fromCartesian(calls[0].sphere.center).height - 2600, + Cesium.Cartographic.fromCartesian(calls()[0].sphere.center).height - 2600, ) < 1e-6, ); - assert.equal(calls[0].options.offset.pitch, -Math.PI / 2); - viewer.camera.frustum.aspectRatio = 16 / 9; - flyToDataSource(viewer, ds); - assert.ok(calls[0].options.offset.range > calls[1].options.offset.range); + assert.equal(calls()[0].options.offset.pitch, -Math.PI / 2); + frustum.aspectRatio = 16 / 9; + frustum.fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / frustum.aspectRatio); + flyToDataSource(engine, ds); + assert.ok(calls()[0].options.offset.range > calls()[1].options.offset.range); const symmetric = { entities: { values: [ @@ -138,10 +145,10 @@ test('portrait framing accounts for FOV, high ground and symmetric extents', () ], }, }; - assert.doesNotThrow(() => flyToDataSource(viewer, symmetric)); - assert.ok(Number.isFinite(calls.at(-1).options.offset.range)); - assert.doesNotThrow(() => calls.at(-1).options.complete()); - cancelCameraArrival(viewer); + assert.doesNotThrow(() => flyToDataSource(engine, symmetric)); + assert.ok(Number.isFinite(calls().at(-1).options.offset.range)); + assert.doesNotThrow(() => calls().at(-1).options.complete()); + cancelCameraArrival(engine); }); test('non-finite positions and hidden features never enter the camera footprint', () => { diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index 88c6b17a7..f577727b9 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -30,7 +30,8 @@ function localizedValues(values) { * and returns their disconnect. * * The imported files belong to the session; their drawing belongs to the - * attached globe. `attachScene({ viewer })` draws them on a Cesium viewer; + * attached globe. `attachScene({ viewer, engine })` draws them on a Cesium + * viewer (its engine flies the camera to them); * `attachScene({ engine, draw })` on a globe engine without one (Navara), * where `draw(dataSource)` resolves with that engine's drawing of a file * ({ report, setShow, refresh, destroy }: src/layers/shared/render/navaraStory.js). @@ -67,7 +68,8 @@ export function createGeoFileImport({ let hint = ''; const lifetime = new AbortController(); // The attached globe ({ viewer, engine, draw, controller }): a Cesium - // viewer, or an engine and its drawing; null while none is attached. + // viewer and its engine, or an engine and its drawing; null while none is + // attached. let scene = null; const listen = (target, type, listener, options) => { @@ -186,9 +188,9 @@ export function createGeoFileImport({ } const fly = (item) => { - if (!scene || !item.dataSource) return false; + if (!scene?.engine || !item.dataSource) return false; if (!item.visible) setVisible(item.id, true); - flyToSource(scene.viewer ?? scene.engine, item.dataSource, { ground }); + flyToSource(scene.engine, item.dataSource, { ground }); return true; }; @@ -471,7 +473,7 @@ export function createGeoFileImport({ if (typeof offMapSource === 'function') groundRemovers.push(offMapSource); const attached = (scene = { viewer, - engine: viewer ? null : engine, + engine, draw: viewer ? null : drawOnEngine, controller: new AbortController(), }); diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 806caec86..38c7a2f7b 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -90,6 +90,9 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { }, flyTo: (target) => flights.push(target), }; + // The viewer's globe engine, which flies the camera to a file. + const engine = { kind: 'cesium' }; + const flownOn = []; const revoked = []; const loads = []; const defaultLoad = async (file, opts) => { @@ -110,7 +113,12 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { document, ...(navigation ? { navigation } : {}), ...(subscribeMapSource ? { subscribeMapSource } : {}), - flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)), + flyToSource: + flyToSource || + ((target, ds) => { + flownOn.push(target); + flights.push(ds); + }), publish: (view) => { shown = view; }, @@ -119,15 +127,17 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { return () => (actions = null); }, }); - tool?.attachScene({ viewer }); + tool?.attachScene({ viewer, engine }); return { tool, els, document, viewer, + engine, added, removed, flights, + flownOn, revoked, loads, renders: () => renders, @@ -166,6 +176,7 @@ test('a pick imports each file', async () => { ); assert.equal(rows(t).length, 2); assert.equal(t.flights.length, 1, 'the first file of a batch is flown to'); + assert.equal(t.flownOn[0], t.engine, "the viewer's engine flies there"); assert.equal(t.shown().hint, 'Added 2 files.'); assert.deepEqual( t.tool.list().map((f) => f.name), diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 8414ebc8d..535de75ec 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -375,9 +375,8 @@ export function createGevActionRunner({ { memory: analystMemory }, )); installViewTargetPrewarm(engine); - // Keep Cesium's viewer rig; viewerless engines use the contract rig. - if (cameraVerbsSupported(viewer ?? engine)) - initCameraVerbs(viewer ?? engine, () => getViewTargetCartesian(engine), { + if (cameraVerbsSupported(engine)) + initCameraVerbs(engine, () => getViewTargetCartesian(engine), { isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active), }); const annotationsAvailable = (method) => @@ -398,11 +397,9 @@ export function createGevActionRunner({ case 'clear_annotations': return annotationsAvailable('clear'); case 'move_camera': - return cameraVerbsSupported(viewer ?? engine); + return cameraVerbsSupported(engine); case 'fly_route': - return ( - cameraVerbsSupported(viewer ?? engine) && annotationsAvailable('list') - ); + return cameraVerbsSupported(engine) && annotationsAvailable('list'); case 'set_visual_style': return renderSupported('visualStyles', 'setStyle'); case 'set_cyber_sonar': diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index b2a2a9281..87549542a 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -163,6 +163,8 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { }, globe: { getHeight: () => 0 }, tweens: [], + // The frame event a camera verb advances on (through the engine). + preRender: new Cesium.Event(), }, camera: { moveEnd: { addEventListener() {} }, diff --git a/src/voice/gevActionsCapabilities.test.mjs b/src/voice/gevActionsCapabilities.test.mjs index dbbde9a7f..23bda3e2d 100644 --- a/src/voice/gevActionsCapabilities.test.mjs +++ b/src/voice/gevActionsCapabilities.test.mjs @@ -93,7 +93,7 @@ function fixture(t, kind, { native = kind === 'cesium' } = {}) { ...options, floorServices: { cachedGroundFloor: () => 0, warmGroundFloor: () => {} }, }); - t.after(() => releaseCameraVerbs(viewer ?? engine)); + t.after(() => releaseCameraVerbs(engine)); return { engine, viewer, annotations, styleManager, calls, build }; } @@ -129,12 +129,9 @@ for (const kind of ['cesium', 'navara']) { const result = await f.build()(action, args); assert.equal(result.ok, true, JSON.stringify(result)); assert.equal(result.engine, kind); + // The camera verbs drive the engine on both. if (call) assert.equal(f.calls[0][0], call); - else - assert.equal( - getActiveCameraMotion().rig, - f.viewer ? 'viewer' : 'engine', - ); + else assert.equal(getActiveCameraMotion().rig, 'engine'); if (action === 'set_post_processing') { assert.deepEqual( f.calls.map(([name]) => name), @@ -154,10 +151,7 @@ for (const kind of ['cesium', 'navara']) { if (action === 'annotate_map') delete f.annotations.annotate; if (action === 'clear_annotations') delete f.annotations.clear; if (action === 'fly_route') delete f.annotations.list; - if (action === 'move_camera') { - if (f.viewer) delete f.viewer.clock.onTick; - else delete f.engine.camera.lookAt; - } + if (action === 'move_camera') delete f.engine.camera.lookAt; if (action === 'set_visual_style') { if (f.viewer) delete f.styleManager.setStyle; else delete f.engine.render.setStyle; @@ -203,8 +197,7 @@ for (const kind of ['cesium', 'navara']) { test(`${kind}: fly_route also requires camera verbs`, async (t) => { const f = fixture(t, kind); - if (f.viewer) delete f.viewer.clock.onTick; - else delete f.engine.camera.lookAt; + delete f.engine.camera.lookAt; assert.equal((await f.build()('fly_route')).code, UNAVAILABLE_ON_ENGINE); });