diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index b331b3c87..b20f49a36 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -1,13 +1,13 @@ # Data Sources & Attribution -God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources; bundled 3D-model provenance is recorded in [`public/models/README.md`](public/models/README.md). +God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources. The bundled 3D models are the project's own procedural fleet models, MIT-licensed with the code ([`public/models/README.md`](public/models/README.md)). How to read this: - **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms. - **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. - **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes. -- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status. +- **The bundled 3D models are the project's own.** Every model under `public/models/` is a procedural fleet model generated by the project maintainers and covered by the repository's MIT license; [`public/models/README.md`](public/models/README.md) describes the set. --- diff --git a/LICENSE b/LICENSE index 4f01699a2..86483f2d6 100644 --- a/LICENSE +++ b/LICENSE @@ -56,7 +56,6 @@ In short: the MIT grant does not extend to any third-party data. See DATA_SOURCES.md for the full per-source license + attribution summary, and remove any dataset whose terms don't fit your use. -The 3D models under public/models/ are also third-party works and are NOT -licensed under MIT. They remain under their individual licenses. See -public/models/README.md for the author, source, license, modification notice, -and attribution covering each model file. +The 3D models under public/models/ are the project's procedural fleet +models, generated from code by the project maintainers, and are covered by +the MIT license above like the source code. See public/models/README.md. diff --git a/README.md b/README.md index 74479d046..1d7cb46d3 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat - **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit. - **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. - **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. -- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in. +- **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in. - **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir. - **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. - **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. diff --git a/changelog.d/1579-render-failure-notice.md b/changelog.d/1579-render-failure-notice.md new file mode 100644 index 000000000..2045ae823 --- /dev/null +++ b/changelog.d/1579-render-failure-notice.md @@ -0,0 +1,6 @@ +- When the globe stops drawing after a render error or a lost WebGL context, + an alert dialog says so instead of leaving a frozen or black globe behind + working controls. Retry restarts drawing (Reload after a lost context), and + Reset view reopens the app without the view restored from the address bar. + Cesium's "Rendering has stopped" panel no longer appears in the app + (German, @gekh, #1579). diff --git a/changelog.d/1686-cockpit-subjects.md b/changelog.d/1686-cockpit-subjects.md new file mode 100644 index 000000000..715561e8e --- /dev/null +++ b/changelog.d/1686-cockpit-subjects.md @@ -0,0 +1,12 @@ +- Let an application add its own Cockpit subjects through + `services.cockpitSubjects`. A provider supplies the subject's position, + heading and HUD (in the page's language, via `getLocale` and + `onLocaleChange` from `./i18n`); the camera can follow it without the + ground clamp, anchored exactly on it, and a generic HUD adds source rows, a + banner, a veil (off under reduced motion) and a bottom progress line. The + Rail and Transit layers expose read and hold APIs so a selected train or + vehicle can be ridden, and `./layers/rail/paths` is exported; that export + and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal + provider. GEV itself registers none, and entry to the built-in aircraft, + satellite and vessel views is unchanged; a provider's subject takes entry + when it was selected after them (Denis Groshev, @ZloyS, #1686). diff --git a/changelog.d/1714-fleet-models.md b/changelog.d/1714-fleet-models.md new file mode 100644 index 000000000..adf11676d --- /dev/null +++ b/changelog.d/1714-fleet-models.md @@ -0,0 +1,8 @@ +- Draw every 3D model from the project's procedural fleet set: 121 models + (about 2 MB, MIT like the code) chosen per vehicle by type and drawn life + size, replacing the eight third-party aircraft models. Flights, Military + Flights and Local ADS-B pick a model by ICAO type, class and emitter + category; Vessels, Transit and Rail now draw models too, under the same 3D + toggle and distances, a ship at its AIS length and beam and a tram, bus or + train in its mode or category colour. The whole set loads once when a layer + starts (samehkhamis, #1714). diff --git a/changelog.d/1715-vessel-typical-length.md b/changelog.d/1715-vessel-typical-length.md new file mode 100644 index 000000000..9cb589974 --- /dev/null +++ b/changelog.d/1715-vessel-typical-length.md @@ -0,0 +1,3 @@ +- Draw a vessel whose feed sends no length at its ship type's typical size, so + a cruise ship or container ship no longer shows as its type's smallest model + (samehkhamis, #1715). diff --git a/changelog.d/1723-cctv-panel-collapse-on-disable.md b/changelog.d/1723-cctv-panel-collapse-on-disable.md new file mode 100644 index 000000000..ae5a49445 --- /dev/null +++ b/changelog.d/1723-cctv-panel-collapse-on-disable.md @@ -0,0 +1,5 @@ +- Collapse the CCTV panel when the layer is switched off. It stayed open with + an empty frame and a meta line that still described the last camera; the + meta line of a disabled layer now reads the catalog count. Contacts, Space + Missions and Clear selected data layers leave the panel as it is + (asturksever, #1723). diff --git a/changelog.d/1729-fleet-ground-vehicles.md b/changelog.d/1729-fleet-ground-vehicles.md new file mode 100644 index 000000000..ed198f27a --- /dev/null +++ b/changelog.d/1729-fleet-ground-vehicles.md @@ -0,0 +1,5 @@ +- Draw airport ground vehicles and ground stations as what they are instead of + the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback + tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m + radio mast, and one with no altitude stands on the ground instead of at the + 10 km airborne default (samehkhamis, #1729). diff --git a/changelog.d/1730-airport-vehicle-models.md b/changelog.d/1730-airport-vehicle-models.md new file mode 100644 index 000000000..c491effa4 --- /dev/null +++ b/changelog.d/1730-airport-vehicle-models.md @@ -0,0 +1,4 @@ +- Draw airport ground vehicles as real shapes instead of stacked boxes: the + car is an operations SUV, the van a high-roof panel van, the fire truck an + airport crash tender with turrets and the pushback a low tug with a glazed + cab, all on the ground at their built size (samehkhamis, #1730). diff --git a/changelog.d/1731-aircraft-mesh-colours.md b/changelog.d/1731-aircraft-mesh-colours.md new file mode 100644 index 000000000..5443c3a1d --- /dev/null +++ b/changelog.d/1731-aircraft-mesh-colours.md @@ -0,0 +1,3 @@ +- Draw aircraft models in their own mesh colours instead of a 94% flat + white (amber for military) over the model; the tracked and emergency + colours now tint the mesh without hiding its paint (samehkhamis, #1731). diff --git a/changelog.d/1732-no-track-true-heading.md b/changelog.d/1732-no-track-true-heading.md new file mode 100644 index 000000000..1f01a1097 --- /dev/null +++ b/changelog.d/1732-no-track-true-heading.md @@ -0,0 +1,4 @@ +- An aircraft that reports no ground track (parked or taxiing, from adsb.lol + or a local receiver) now faces its reported true heading instead of north, + so one taxiing south no longer looks like it is moving backwards + (samehkhamis, #1732). diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 7c2206467..3aa85f72c 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -230,3 +230,125 @@ starts and switches the language with the `./i18n` entry (`startPageLocale`, `localePicker: false` to `createApplicationControls` instead of editing the markup. Node tests that import application modules register the component loader with `node --import=/build/register-vue`. + +### Cockpit subjects + +Core's Cockpit rides a tracked aircraft, the tracked satellite and the +selected AIS vessel. An application adds kinds of its own with +`services.cockpitSubjects` on `createApplicationControls`: a list of +providers, checked once when the controls are built +(`src/cockpitSubjectProviders.js`). A malformed provider, or one with a member +that throws when read (a getter), is left out with a warning and the rest are +kept. A provider's subject takes entry when it was selected after every +built-in subject present (a tracked aircraft or satellite, a selected vessel), +whose own fixed order is unchanged, and needs the provider's `layerId` on; the +provider then holds its subject, hands the camera a frame and the generic HUD +a view model on every update, and releases it on exit. Provider methods are +synchronous and are called on the provider, so a class instance's methods keep +their `this`; one that throws or returns a promise is treated as having +failed. What a method returns may carry getters: one that throws when read +makes a candidate none, a frame malformed (the view ends) and a HUD view keep +the last; a worded member (labels, entry face, look) that throws keeps its +last good words. + +```js +import { getLocale, onLocaleChange } from 'gods-eye-view/i18n'; +import { pointAtS } from 'gods-eye-view/layers/rail/paths'; + +const words = { + en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' }, + de: { + view: 'Fahrtansicht', + exit: 'Fahrtansicht verlassen', + exitText: 'VERLASSEN', + }, +}; + +/** Ride the Rail layer's selected train; `rail` is its layer instance. */ +const ride = { + kind: 'example-ride', + layerId: 'rail', + entryFace: { icon: 'train', label: 'RIDE' }, + // A function is read again when the page's language changes. + labels: () => words[getLocale()] ?? words.en, + candidate() { + const train = rail.getSelectedTrain(); + return ( + train && { + id: train.key, + name: train.label, + selectedAt: train.selectedAtMs, + } + ); + }, + // Only a train bound to its line can be ridden: read() needs its path. + isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path), + hold: (id) => rail.setCockpitSubject(id), + read(nowMs, ctx) { + const train = rail.getCockpitSubject(); + if (!train?.path) return null; // ends the view + const point = pointAtS(train.path.line, train.path.s); + return { + id: train.key, + latitude: point.lat, + longitude: point.lon, + heightM: train.position.railHeightM, // null stands it on the shared floor + name: train.label, + headingDeg: + train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg, + speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps, + // The layer already moves the train smoothly: sit on it. + anchor: 'exact', + eye: { upM: 3.5 }, + }; + }, + hud: (frame) => ({ title: frame.name }), + release(id, { restore }) { + rail.setCockpitSubject(null); + if (restore) rail.focusTrain(id); + }, +}; + +createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } }); +``` + +The `./i18n` entry gives `getLocale()` and `onLocaleChange()`: +Core translates only its own strings, so a provider words its labels, entry +face and HUD for the page's language (labels may be functions, as above; the +HUD is read again on a change). `gods-eye-view/layers/rail/paths` is the +Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`) +for a provider placing a train along its line. The frame, HUD view and +candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail +and Transit read APIs in `src/layers/rail/cockpitSubject.js` and +`src/layers/transit/cockpitSubject.js`. A snapshot read again while nothing in +it changed is the one read before, so `read` may take one on every update; a +train's `path.line` is a frozen copy of its line, for `pointAtS` and +`projectOnPath`. The `layers/rail/paths` export and the +Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`, +`getCockpitSubject` and the snapshots they return, `path.line` among them) +are experimental and may change without notice. + +A candidate's `selectedAt` is a token that changes each time the user selects +the subject, even the same one again, so entry counts it as selected then; a +new `id` under the same token is the same selection (a train's selection +moving to another feed's copy of it). The Rail and Transit snapshots give one, +`selectedAtMs`: when the user last selected the train or vehicle by a click or +`focusTrain`/`focusVehicle`. It is null after a deselection and for one +selected only by a hold, since a hold is not the user's selection. + +A frame with `heightM: null` stands on the shared floor, or where it last +stood while that floor is cold. If no height has been known since entry, the +view waits, showing ACQUIRING SURFACE, for at most five seconds +(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject. + +`hold(id)` returns `true` once it holds the subject, or `false` to refuse +entry; any other answer, `undefined` included, refuses entry too, with one +warning. A refused hold, and one that throws or returns a promise, is followed +by `release(id, { restore: false })`, again once a returned promise settles, +so `release` must be harmless for a subject it does not hold and when called +more than once. + +Leaving the view does not track again an aircraft that was tracked before +entry, as with the bridge and orbital views: when the user leaves by the exit +control, the map goes back to the ridden subject through +`release(id, { restore: true })`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6073b5c84..59077f153 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1706,7 +1706,7 @@ Applications supply the existing floor/snap, geoid, picking, sprite, camera, trail, focus, readout, context, aircraft presentation and render services. The factory never constructs a second application registry. Configure a source before initialization; replacing it while initialized is rejected. Model loads -use the supplied asset resolver, including the preload path. Enrichment receives +use the supplied asset resolver, including the up-front fleet set load. Enrichment receives an abort signal and cannot update a later lifecycle after destruction. Existing camera, terrain floor, trail, selection and measured model-size policies remain. @@ -2208,6 +2208,83 @@ the subject (tracking stopped, Satellites off) or tracking another target ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs` covers entry, camera, zoom, look, HUD and exit. +### Application subjects + +An application can add its own first-person subjects beside the aircraft, +vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`, +a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by +the coordinator; a malformed one, or one with a member that throws when read, +is left out with a warning and the rest are kept). A provider names +its layer, an entry face and labels in the page's language, and supplies four +operations: `candidate()` (what entry would ride now), `hold(id)`, +`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD +view model), with `release(id, {restore})` on exit. Entry needs the provider's +layer on, no Context change in flight and the provider's own +`isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with +the reason, while a provider's view is in use. + +The built-in kinds keep their fixed entry order (a tracked aircraft, then the +tracked satellite, then the selected vessel), so without providers entry is +unchanged. A provider's candidate takes entry only when it was selected after +every built-in subject present, by when the Cockpit first saw it or saw it +selected again (its `selectedAt` changing): a train picked after an aircraft +was tracked takes entry, and an aircraft tracked after it takes it back. +Providers' candidates are ordered most recently selected first, then by +priority; one first seen together with a built-in subject comes after it. The +first in that order decides alone: when its gate is shut there is no entry +rather than the next subject's. + +A frame places the eye `eye.upM` above `heightM` (the shared floor where the +frame gives none, or where it last stood while that floor is cold; with +neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for +at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the +subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may +turn off the floor clamps (`groundClamp: false`, for a subject inside a +structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a +layer that already moves it smoothly; a frame with no `speedMps` is always +exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the +camera every rendered frame. A frame that is null, malformed or for another +subject ends the view (`restoreTracking: false`), as an aircraft tracked +meanwhile and the provider's layer turning off do; the exit control asks the +provider to restore its subject. The generic HUD shows the provider's kicker, +title, meta line, speed with its unit and tag, readout cards, always-visible +source rows, banner, route card and Live Signals rows, a veil that darkens the +picture under the HUD (with passing light streaks, which stop under reduced +motion), arrow-key look-around when the provider asks for it, and a progress +line along the bottom edge (`src/cockpitProgress.js`: close ticks merge, +labels never overlap). Core translates none of the provider's text: a host +words it for the page's language (`gods-eye-view/i18n`: +`getLocale`, `onLocaleChange`). Its entry face, labels and look may be +functions, read again when they are shown and on a change of language, as the +HUD view is. + +The Rail layer offers a read API for such a view: `getSelectedTrain()` and +`getCockpitSubject()` (a snapshot of the train: where it is along its line, +which way it runs (`pathBinding.js` learns it), its rail height, speed as +reported or derived, position source, feed credit and stops, and +`selectedAtMs`, when the user last selected it by a click or `focusTrain`, +even again; null after a deselection and for a train only a hold selected), +`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its +feed held, clicks and Escape left to the view; the held copy stays the one +drawn of its train while held, another feed's copy hidden, and it keeps +moving, still selected, when its category is turned off) and +`focusTrain(key)` (false for a train the layer does not draw); its pure path +module is exported as `gods-eye-view/layers/rail/paths`. That export +and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is +the one read before, and its `path.line` is a frozen copy. Transit +offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`, +`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is +out of the camera's view, and its sprite, 3D model, card, trail and detection +box are hidden; released, it is out of play until a visibility sweep finds it in +view. Its snapshot's `position.groundHeightM` is the floor where playback draws +it (the drawn marker's height while it is placed, else its segment's two +reports' floors at the playback fraction), not the newest report's, and null while that floor is unknown. Rail's `path.direction` is the +way the drawn train runs: once playback has placed it, a report that runs back +turns it only when playback reaches that report. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`, +`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`, +`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests +cover them. + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, @@ -2229,6 +2306,17 @@ shown without a fade once it is decoded (or after one second if its decode never settles), so a new camera's preview is blank only while its frame loads. Disposal releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. +The panel opens when a camera becomes active and collapses when the layer is +switched off, from its own switch or anywhere else. The panel chrome applies +the collapse, so a panel the desktop accordion only folded has its saved and +shared state closed too. Clear selected data layers and Context isolation +(Contacts, Space Missions) switch the layer off without the user asking: the +panel and its share preference stay as they are, and the layer comes back to an +open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true +while `_clearLayersOutsideContextMode` switches the layer off): the user +switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its +meta line reads the catalog count, not the last active camera. + ## UI disposal UI disposal releases the CCTV subscription, command-dock observer, legacy drag @@ -2420,8 +2508,9 @@ is validated against Cesium 1.138.0 and must be revalidated on upgrades. `scripts/qa-anime-sky.mjs` checks asset readiness, visible Normal sky and Anime band coverage against a Vite dev server. -`scripts/qa-sky-model-lighting.mjs` compares a lit `airplane.glb` against -black and white environment maps in isolated scenes. +`scripts/qa-sky-model-lighting.mjs` compares a lit fleet airliner +(`public/models/fleet/aircraft/a320.glb`) against black and white environment +maps in isolated scenes. ## Model atmosphere on Apple Metal @@ -2442,6 +2531,53 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform detection is the backstop for when no probe context can be created. Applied during viewer construction, before any tile builds a draw command. +## Render failure notice + +When the globe stops drawing, the scene chrome says so instead of leaving a +black globe behind working controls. `installRenderFailureNotice()` +(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas' +`webglcontextlost`, logs the error, and publishes a notice that +`RenderFailure.vue` shows as an alert dialog with focus on its first action. +The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end +"Rendering has stopped" panel no longer appears; `applicationViewerOptions()` +keeps it by default for other callers. + +- **Retry** restarts the render loop. The notice stays until a frame starts + and ends without an error: Cesium raises `postRender` after a failed frame + too. +- **Reload**, after a lost context, reloads the page; the address keeps the + view. +- **Reset view** opens `appRootUrl()`, without the hash or `/share/` + path; the query, such as `?lang=`, is kept. + +Pitfalls: + +- An error thrown outside Scene's error handling (a frame listener, `onTick`, + a resize) stops the loop without a `renderError`. The notice wraps the + widget's `render()` and `resize()` and raises it as one, once even with + `rethrowRenderErrors`. +- A throwing listener leaves its Cesium event mid-raise, with listener changes + only queued. The wrapper finishes those raises first (`finishRaise()` over + `frameEvents()`), so a layer turned off or on meanwhile behaves. +- A render error marks the loop stopped while its next frame is still queued. + The wrapper keeps the loop marked running, so a Retry or a shown tab + (`src/app/tools.js`) before that frame starts no second loop. +- After a lost context, later render errors are only logged, so the notice + keeps saying why. The listener neither prevents nor restores the loss. +- When the globe draws again, focus goes back where it was, or to the globe's + canvas, which the map's keys read as the map; the canvas is focusable only + until focus leaves or the globe is pressed. Focus the user moved stays put. +- The notice sits above floating panels such as the Québec camera viewer, and + its buttons are 44 px tall on touch screens. + +An inline conversation panel never reloads or navigates its host's page: it +offers Retry after a render error only, and no notice after a lost context, +where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`). +Its timer (`keepPanelRendering()`) draws only while the render loop is on and +logs nothing itself, so each render error is logged once, by the notice. An +error a timer frame throws is caught there: it has already been raised as a +render error. + ## Map Source control ownership Map Source controls own chip listeners, source-state subscriptions and selection @@ -3606,20 +3742,22 @@ This is the current runtime/source-of-truth snapshot for the project. > 24.09 m aft (70 % of the model's rendered envelope, so inside it), 2.22 m > below centre (6 %), and 2e-9 m off the centreline. > - **The tracked trail attaches to REAL HULL, aft and below (2026-08-23):** -> `MODEL_TRAIL_ANCHOR_NATIVE` (`src/data/modelVisualAnchor.js`) holds, per GLB -> and in RAW glTF coordinates (see the transform-chain entry below), the point -> of the hull's CENTRELINE PROFILE (its y = 0 slice) closest to the aft-belly -> AABB corner. It is NOT the corner: a bounding-box corner is empty -> space, 4.80 m off the nearest triangle on `airplane.glb` and 6.14 m on -> `jet.glb`, so the trail ended in mid air beside the aircraft. The head segment +> `FLEET_TRAIL_ANCHORS` (`src/data/fleetTrailAnchors.js`, generated by +> `scripts/measure-fleet-trail-anchors.mjs`) holds, per fleet aircraft and +> helicopter model and in RAW glTF coordinates (see the transform-chain entry +> below), the point of the hull's CENTRELINE PROFILE (its centreline slice) +> closest to the aft-belly AABB corner. It is NOT the corner: a bounding-box +> corner is empty space (4.80 m off the nearest triangle on the airliner +> asset this was first measured on, 6.14 m on the jet), so the trail ended in +> mid air beside the aircraft. The head segment > is drawn from a point behind the aircraft to this anchor, so the anchor must > stay aft (83–96 % of each aft extreme) or the segment enters at the tail and -> stops inside the fuselage. `modelScale.test.mjs` reads the POSITION BUFFERS — +> stops inside the fuselage. `fleetModels.test.mjs` reads the POSITION BUFFERS — > real vertices and triangles, because accessor min/max cannot tell a corner > from a surface — and re-derives on-hull, aft, lowest-at-its-station, > on-centreline, and the construction itself. Re-measure, never re-guess. > - **The trail anchor rides CESIUM'S transform chain, not a hand-rolled one -> (2026-08-23 regression fix):** `MODEL_TRAIL_ANCHOR_NATIVE` stores RAW +> (2026-08-23 regression fix):** `FLEET_TRAIL_ANCHORS` stores RAW > glTF coordinates and `modelAnchorWorld()` assembles > `modelMatrix × components.transform × axisCorrection` — the same chain > `ModelSceneGraph` renders with, built from Cesium's own exported @@ -3654,7 +3792,7 @@ This is the current runtime/source-of-truth snapshot for the project. > ENVELOPE, and between them the drawn start slides along the segment so the > visible length grows CONTINUOUSLY from zero. Three earlier cuts are recorded > because their shape matters: testing segment LENGTH against the radius HID -> real trail (58 m aft of `airplane.glb`'s 34.41 m envelope gives a 33.98 m +> real trail (58 m aft of an airliner's 34.41 m envelope gives a 33.98 m > segment, suppressed, though ~23.6 m of it is open air); CLIPPING at the > envelope and keeping only what lay outside stopped a moving trail visibly > SHORT of the aircraft, since a bounding sphere encloses a lot of empty space @@ -4830,8 +4968,9 @@ activeCameraCardEnabled: true })` may publish it through the retained > - **Flights 3D/cockpit hardening:** the destination-direction cue is an > inline SVG rather than a remotely loaded Material Icons ligature, so a > blocked or late font can no longer expand the literal word `navigation` -> across the cockpit. The grounded `airplane.glb` belly offset is calibrated -> to the current asset's measured Y-up bounds (`0.063` native units). +> across the cockpit. A grounded aircraft stands its fleet model's scaled +> clearance (the catalog's `clearance`, measured below the model's origin) +> above the ground. > Contact Previous/Next and Live Signals aircraft handoffs now > re-seed the first-person camera anchor at the selected flight immediately; > bounded feed-correction smoothing remains scoped to the same aircraft. @@ -7290,8 +7429,9 @@ pins and share links already supply complete boxes. normal four-samples-per-250-ms budget. Retry timestamps more than the maximum 30-second backoff ahead of the current clock are ignored. A smaller rollback can retain a bounded retry delay of up to 30 seconds. -- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. -- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; the two preload calls only warm the glTF cache and are never drawn. +- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. +- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay. +- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. - **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. diff --git a/package.json b/package.json index c1476342d..7559b1ddd 100644 --- a/package.json +++ b/package.json @@ -298,6 +298,7 @@ }, "./sources/rail-service": "./src/sources/railService.js", "./layers/rail/source": "./src/layers/rail/source.js", + "./layers/rail/paths": "./src/data/railPaths.js", "./sources/map-features": "./src/sources/overpassFeatures.js", "./server/providers/mapillary": { "node": "./server/providers/mapillary.js" diff --git a/public/models/README.md b/public/models/README.md index aa7f40fbd..49d22b4c9 100644 --- a/public/models/README.md +++ b/public/models/README.md @@ -1,20 +1,33 @@ -# Bundled 3D Model Attribution +# Bundled 3D Models -The model files in this directory are third-party visual assets. They are not -covered by the repository's MIT source-code license; each remains available -under the license listed below. +Every 3D model shipped in this directory is one of the project's procedural +fleet models under [`fleet/`](fleet/): aircraft, helicopters, ships, buoys, +transit and rail vehicles, and a few other airborne and ground objects. They +are generated from code by the project maintainers, contain no third-party +artwork, and are covered by the repository's [MIT license](../../LICENSE) +like the source code. -| File | Original work and creator | Source | License | Project modifications | -|---|---|---|---|---| -| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. | -| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. | -| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `mq9.glb` | “MQ-9” by [IProZenoN](https://sketchfab.com/IProZenoN) | [Sketchfab model](https://sketchfab.com/3d-models/mq-9-fabe963feb354c5584b51f9c470c3f7e) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `b789.glb` | “Boeing 787-9” by [Nobilis 2](https://sketchfab.com/nobilishornet2) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-787-9-b6711e2e698e4e469675c1154a50b7a3) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `atr72.glb` | “ATR 72 - 600” by [Oyan3D](https://sketchfab.com/oyan3D) | [Sketchfab model](https://sketchfab.com/3d-models/atr-72-600-1e1a7186f7444d288675262fcee44744) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures removed with dominant material colors baked into PBR factors (flat “abstracted” style), orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | +| Path | What it holds | +|---|---| +| `fleet/catalog.json` | Every model by name: its file, category, built length, beam and bounds (metres, in Cesium's Z-up model frame: x along the length with the nose toward −X, y across the beam, z up), the clearance below its origin, and its 2D icon class. | +| `fleet/types.json` | Which model draws which vehicle, and at what length: ICAO aircraft and helicopter type codes, ADS-B emitter categories, AIS ship-type and length ranges, GTFS route types, transit modes and rail categories, with the defaults. | +| `fleet/aircraft/`, `fleet/helicopters/`, `fleet/ships/`, `fleet/buoys/`, `fleet/transit/`, `fleet/other/` | The models, one GLB each (EXT_meshopt_compression and KHR_mesh_quantization, vertex colours, no textures). | -CC BY 4.0 permits sharing and adaptation, including commercial use, provided -appropriate credit is retained, the license is linked, and modifications are -identified. These credits do not imply endorsement by the original creators. +How the layers use them: + +- Each layer draws a vehicle with the model its type selects, scaled to the + type's published length (a vessel to its reported AIS length and beam), so + every model is drawn life size. Ground objects, balloons and obstacles in + the `other` category are drawn at the size they are built. +- An aircraft model's origin is its axis; it stands that model's scaled + clearance above the ground. A ship's origin is its waterline, and a transit + or rail vehicle's the road or rail top (a monorail or gondola is raised by + its `transit.elevation` entry, for a source that names those modes). +- Transit and rail models carry a `_TINT` vertex attribute that marks where + the route or category colour is painted. +- The whole set loads once, up front, when a layer that draws it starts; no + model is fetched while a session runs. + +`src/data/fleetModels.js` reads the set and chooses each vehicle's model; +`src/data/fleetModels.test.mjs` checks that every model `types.json` names is +in the catalog and on disk, and that no GLB exceeds 3 MB. diff --git a/public/models/airplane.glb b/public/models/airplane.glb deleted file mode 100644 index 016a56f1d..000000000 Binary files a/public/models/airplane.glb and /dev/null differ diff --git a/public/models/atr72.glb b/public/models/atr72.glb deleted file mode 100644 index 8946dc39c..000000000 Binary files a/public/models/atr72.glb and /dev/null differ diff --git a/public/models/b789.glb b/public/models/b789.glb deleted file mode 100644 index be9849d74..000000000 Binary files a/public/models/b789.glb and /dev/null differ diff --git a/public/models/bell206.glb b/public/models/bell206.glb deleted file mode 100644 index 029fe1339..000000000 Binary files a/public/models/bell206.glb and /dev/null differ diff --git a/public/models/c172.glb b/public/models/c172.glb deleted file mode 100644 index 3c5155433..000000000 Binary files a/public/models/c172.glb and /dev/null differ diff --git a/public/models/citation2.glb b/public/models/citation2.glb deleted file mode 100644 index 2ec47d6dc..000000000 Binary files a/public/models/citation2.glb and /dev/null differ diff --git a/public/models/fleet/aircraft/a320.glb b/public/models/fleet/aircraft/a320.glb new file mode 100644 index 000000000..017d8ed2c Binary files /dev/null and b/public/models/fleet/aircraft/a320.glb differ diff --git a/public/models/fleet/aircraft/a333.glb b/public/models/fleet/aircraft/a333.glb new file mode 100644 index 000000000..7daeb8319 Binary files /dev/null and b/public/models/fleet/aircraft/a333.glb differ diff --git a/public/models/fleet/aircraft/a343.glb b/public/models/fleet/aircraft/a343.glb new file mode 100644 index 000000000..a850cea4a Binary files /dev/null and b/public/models/fleet/aircraft/a343.glb differ diff --git a/public/models/fleet/aircraft/a359.glb b/public/models/fleet/aircraft/a359.glb new file mode 100644 index 000000000..745681b27 Binary files /dev/null and b/public/models/fleet/aircraft/a359.glb differ diff --git a/public/models/fleet/aircraft/a388.glb b/public/models/fleet/aircraft/a388.glb new file mode 100644 index 000000000..58e04f2d0 Binary files /dev/null and b/public/models/fleet/aircraft/a388.glb differ diff --git a/public/models/fleet/aircraft/at72.glb b/public/models/fleet/aircraft/at72.glb new file mode 100644 index 000000000..bbe129d7e Binary files /dev/null and b/public/models/fleet/aircraft/at72.glb differ diff --git a/public/models/fleet/aircraft/b738.glb b/public/models/fleet/aircraft/b738.glb new file mode 100644 index 000000000..6e4a94a38 Binary files /dev/null and b/public/models/fleet/aircraft/b738.glb differ diff --git a/public/models/fleet/aircraft/b744.glb b/public/models/fleet/aircraft/b744.glb new file mode 100644 index 000000000..f5f9e421d Binary files /dev/null and b/public/models/fleet/aircraft/b744.glb differ diff --git a/public/models/fleet/aircraft/b763.glb b/public/models/fleet/aircraft/b763.glb new file mode 100644 index 000000000..34c59a912 Binary files /dev/null and b/public/models/fleet/aircraft/b763.glb differ diff --git a/public/models/fleet/aircraft/b77w.glb b/public/models/fleet/aircraft/b77w.glb new file mode 100644 index 000000000..a19051300 Binary files /dev/null and b/public/models/fleet/aircraft/b77w.glb differ diff --git a/public/models/fleet/aircraft/b789.glb b/public/models/fleet/aircraft/b789.glb new file mode 100644 index 000000000..5474a55a4 Binary files /dev/null and b/public/models/fleet/aircraft/b789.glb differ diff --git a/public/models/fleet/aircraft/bcs3.glb b/public/models/fleet/aircraft/bcs3.glb new file mode 100644 index 000000000..422ebe700 Binary files /dev/null and b/public/models/fleet/aircraft/bcs3.glb differ diff --git a/public/models/fleet/aircraft/be20.glb b/public/models/fleet/aircraft/be20.glb new file mode 100644 index 000000000..d1a2c4dfd Binary files /dev/null and b/public/models/fleet/aircraft/be20.glb differ diff --git a/public/models/fleet/aircraft/c17.glb b/public/models/fleet/aircraft/c17.glb new file mode 100644 index 000000000..19c51a523 Binary files /dev/null and b/public/models/fleet/aircraft/c17.glb differ diff --git a/public/models/fleet/aircraft/c172.glb b/public/models/fleet/aircraft/c172.glb new file mode 100644 index 000000000..9a5b7d73f Binary files /dev/null and b/public/models/fleet/aircraft/c172.glb differ diff --git a/public/models/fleet/aircraft/c30j.glb b/public/models/fleet/aircraft/c30j.glb new file mode 100644 index 000000000..b5d4a7122 Binary files /dev/null and b/public/models/fleet/aircraft/c30j.glb differ diff --git a/public/models/fleet/aircraft/c56x.glb b/public/models/fleet/aircraft/c56x.glb new file mode 100644 index 000000000..2185dfe79 Binary files /dev/null and b/public/models/fleet/aircraft/c56x.glb differ diff --git a/public/models/fleet/aircraft/cl35.glb b/public/models/fleet/aircraft/cl35.glb new file mode 100644 index 000000000..f2df5f86b Binary files /dev/null and b/public/models/fleet/aircraft/cl35.glb differ diff --git a/public/models/fleet/aircraft/crj9.glb b/public/models/fleet/aircraft/crj9.glb new file mode 100644 index 000000000..24e6448b6 Binary files /dev/null and b/public/models/fleet/aircraft/crj9.glb differ diff --git a/public/models/fleet/aircraft/dh8d.glb b/public/models/fleet/aircraft/dh8d.glb new file mode 100644 index 000000000..f763fec21 Binary files /dev/null and b/public/models/fleet/aircraft/dh8d.glb differ diff --git a/public/models/fleet/aircraft/e175.glb b/public/models/fleet/aircraft/e175.glb new file mode 100644 index 000000000..acdc5230e Binary files /dev/null and b/public/models/fleet/aircraft/e175.glb differ diff --git a/public/models/fleet/aircraft/e55p.glb b/public/models/fleet/aircraft/e55p.glb new file mode 100644 index 000000000..b130d2a9e Binary files /dev/null and b/public/models/fleet/aircraft/e55p.glb differ diff --git a/public/models/fleet/aircraft/generic_plane.glb b/public/models/fleet/aircraft/generic_plane.glb new file mode 100644 index 000000000..a126c8b26 Binary files /dev/null and b/public/models/fleet/aircraft/generic_plane.glb differ diff --git a/public/models/fleet/aircraft/glf4.glb b/public/models/fleet/aircraft/glf4.glb new file mode 100644 index 000000000..31ddd2c74 Binary files /dev/null and b/public/models/fleet/aircraft/glf4.glb differ diff --git a/public/models/fleet/aircraft/glider.glb b/public/models/fleet/aircraft/glider.glb new file mode 100644 index 000000000..60b722726 Binary files /dev/null and b/public/models/fleet/aircraft/glider.glb differ diff --git a/public/models/fleet/aircraft/k35r.glb b/public/models/fleet/aircraft/k35r.glb new file mode 100644 index 000000000..7a7cfbe73 Binary files /dev/null and b/public/models/fleet/aircraft/k35r.glb differ diff --git a/public/models/fleet/aircraft/md11.glb b/public/models/fleet/aircraft/md11.glb new file mode 100644 index 000000000..885a81624 Binary files /dev/null and b/public/models/fleet/aircraft/md11.glb differ diff --git a/public/models/fleet/aircraft/mq9.glb b/public/models/fleet/aircraft/mq9.glb new file mode 100644 index 000000000..737912fc3 Binary files /dev/null and b/public/models/fleet/aircraft/mq9.glb differ diff --git a/public/models/fleet/aircraft/p28a.glb b/public/models/fleet/aircraft/p28a.glb new file mode 100644 index 000000000..ca47aec1a Binary files /dev/null and b/public/models/fleet/aircraft/p28a.glb differ diff --git a/public/models/fleet/aircraft/pc12.glb b/public/models/fleet/aircraft/pc12.glb new file mode 100644 index 000000000..2b2666a0a Binary files /dev/null and b/public/models/fleet/aircraft/pc12.glb differ diff --git a/public/models/fleet/aircraft/sr22.glb b/public/models/fleet/aircraft/sr22.glb new file mode 100644 index 000000000..7c2d66fe6 Binary files /dev/null and b/public/models/fleet/aircraft/sr22.glb differ diff --git a/public/models/fleet/aircraft/t38.glb b/public/models/fleet/aircraft/t38.glb new file mode 100644 index 000000000..b9c8cd9ee Binary files /dev/null and b/public/models/fleet/aircraft/t38.glb differ diff --git a/public/models/fleet/aircraft/tex2.glb b/public/models/fleet/aircraft/tex2.glb new file mode 100644 index 000000000..8a3ec3e00 Binary files /dev/null and b/public/models/fleet/aircraft/tex2.glb differ diff --git a/public/models/fleet/aircraft/ultralight.glb b/public/models/fleet/aircraft/ultralight.glb new file mode 100644 index 000000000..965d9cddd Binary files /dev/null and b/public/models/fleet/aircraft/ultralight.glb differ diff --git a/public/models/fleet/aircraft/v22.glb b/public/models/fleet/aircraft/v22.glb new file mode 100644 index 000000000..01021ca2d Binary files /dev/null and b/public/models/fleet/aircraft/v22.glb differ diff --git a/public/models/fleet/buoys/barrel_yellow.glb b/public/models/fleet/buoys/barrel_yellow.glb new file mode 100644 index 000000000..d2fb0e017 Binary files /dev/null and b/public/models/fleet/buoys/barrel_yellow.glb differ diff --git a/public/models/fleet/buoys/buoy_cardinal_east.glb b/public/models/fleet/buoys/buoy_cardinal_east.glb new file mode 100644 index 000000000..a0053edb0 Binary files /dev/null and b/public/models/fleet/buoys/buoy_cardinal_east.glb differ diff --git a/public/models/fleet/buoys/buoy_cardinal_north.glb b/public/models/fleet/buoys/buoy_cardinal_north.glb new file mode 100644 index 000000000..adac99ccb Binary files /dev/null and b/public/models/fleet/buoys/buoy_cardinal_north.glb differ diff --git a/public/models/fleet/buoys/buoy_cardinal_south.glb b/public/models/fleet/buoys/buoy_cardinal_south.glb new file mode 100644 index 000000000..b84b392f2 Binary files /dev/null and b/public/models/fleet/buoys/buoy_cardinal_south.glb differ diff --git a/public/models/fleet/buoys/buoy_cardinal_west.glb b/public/models/fleet/buoys/buoy_cardinal_west.glb new file mode 100644 index 000000000..d9891b253 Binary files /dev/null and b/public/models/fleet/buoys/buoy_cardinal_west.glb differ diff --git a/public/models/fleet/buoys/buoy_isolated_danger.glb b/public/models/fleet/buoys/buoy_isolated_danger.glb new file mode 100644 index 000000000..e27187ac1 Binary files /dev/null and b/public/models/fleet/buoys/buoy_isolated_danger.glb differ diff --git a/public/models/fleet/buoys/buoy_lateral_port.glb b/public/models/fleet/buoys/buoy_lateral_port.glb new file mode 100644 index 000000000..dcc6bf1c1 Binary files /dev/null and b/public/models/fleet/buoys/buoy_lateral_port.glb differ diff --git a/public/models/fleet/buoys/buoy_lateral_starboard.glb b/public/models/fleet/buoys/buoy_lateral_starboard.glb new file mode 100644 index 000000000..c025c8cb3 Binary files /dev/null and b/public/models/fleet/buoys/buoy_lateral_starboard.glb differ diff --git a/public/models/fleet/buoys/buoy_safe_water.glb b/public/models/fleet/buoys/buoy_safe_water.glb new file mode 100644 index 000000000..47fbc5602 Binary files /dev/null and b/public/models/fleet/buoys/buoy_safe_water.glb differ diff --git a/public/models/fleet/buoys/buoy_special.glb b/public/models/fleet/buoys/buoy_special.glb new file mode 100644 index 000000000..faee37740 Binary files /dev/null and b/public/models/fleet/buoys/buoy_special.glb differ diff --git a/public/models/fleet/buoys/can_red.glb b/public/models/fleet/buoys/can_red.glb new file mode 100644 index 000000000..97beeffa7 Binary files /dev/null and b/public/models/fleet/buoys/can_red.glb differ diff --git a/public/models/fleet/buoys/can_red_cylinder.glb b/public/models/fleet/buoys/can_red_cylinder.glb new file mode 100644 index 000000000..bd87b55f4 Binary files /dev/null and b/public/models/fleet/buoys/can_red_cylinder.glb differ diff --git a/public/models/fleet/buoys/conical_green.glb b/public/models/fleet/buoys/conical_green.glb new file mode 100644 index 000000000..36eb6729c Binary files /dev/null and b/public/models/fleet/buoys/conical_green.glb differ diff --git a/public/models/fleet/buoys/conical_green_cone.glb b/public/models/fleet/buoys/conical_green_cone.glb new file mode 100644 index 000000000..c5a6b4476 Binary files /dev/null and b/public/models/fleet/buoys/conical_green_cone.glb differ diff --git a/public/models/fleet/buoys/pile_green_cone.glb b/public/models/fleet/buoys/pile_green_cone.glb new file mode 100644 index 000000000..6960071c9 Binary files /dev/null and b/public/models/fleet/buoys/pile_green_cone.glb differ diff --git a/public/models/fleet/buoys/pile_red_cylinder.glb b/public/models/fleet/buoys/pile_red_cylinder.glb new file mode 100644 index 000000000..a61336602 Binary files /dev/null and b/public/models/fleet/buoys/pile_red_cylinder.glb differ diff --git a/public/models/fleet/buoys/spar_lateral_port.glb b/public/models/fleet/buoys/spar_lateral_port.glb new file mode 100644 index 000000000..1152dd928 Binary files /dev/null and b/public/models/fleet/buoys/spar_lateral_port.glb differ diff --git a/public/models/fleet/buoys/spar_lateral_starboard.glb b/public/models/fleet/buoys/spar_lateral_starboard.glb new file mode 100644 index 000000000..193882ac8 Binary files /dev/null and b/public/models/fleet/buoys/spar_lateral_starboard.glb differ diff --git a/public/models/fleet/buoys/spherical_green.glb b/public/models/fleet/buoys/spherical_green.glb new file mode 100644 index 000000000..fc06b7920 Binary files /dev/null and b/public/models/fleet/buoys/spherical_green.glb differ diff --git a/public/models/fleet/buoys/spherical_yellow.glb b/public/models/fleet/buoys/spherical_yellow.glb new file mode 100644 index 000000000..d8b660b7c Binary files /dev/null and b/public/models/fleet/buoys/spherical_yellow.glb differ diff --git a/public/models/fleet/buoys/spherical_yellow_x.glb b/public/models/fleet/buoys/spherical_yellow_x.glb new file mode 100644 index 000000000..2ebbee87c Binary files /dev/null and b/public/models/fleet/buoys/spherical_yellow_x.glb differ diff --git a/public/models/fleet/buoys/stake_green_cone.glb b/public/models/fleet/buoys/stake_green_cone.glb new file mode 100644 index 000000000..25fc6758b Binary files /dev/null and b/public/models/fleet/buoys/stake_green_cone.glb differ diff --git a/public/models/fleet/buoys/stake_red_cylinder.glb b/public/models/fleet/buoys/stake_red_cylinder.glb new file mode 100644 index 000000000..31164848d Binary files /dev/null and b/public/models/fleet/buoys/stake_red_cylinder.glb differ diff --git a/public/models/fleet/buoys/stake_yellow_x.glb b/public/models/fleet/buoys/stake_yellow_x.glb new file mode 100644 index 000000000..cdd756643 Binary files /dev/null and b/public/models/fleet/buoys/stake_yellow_x.glb differ diff --git a/public/models/fleet/catalog.json b/public/models/fleet/catalog.json new file mode 100644 index 000000000..91fbc5acd --- /dev/null +++ b/public/models/fleet/catalog.json @@ -0,0 +1,2320 @@ +{ + "a139": { + "category": "helicopters", + "file": "helicopters/a139.glb", + "min": [ + -8.045, + -6.626, + -1.575 + ], + "max": [ + 7.638, + 6.626, + 2.979 + ], + "length": 15.683, + "beam": 13.253, + "clearance": 1.575, + "tris": 1464, + "icon": "heli" + }, + "a320": { + "category": "aircraft", + "file": "aircraft/a320.glb", + "min": [ + -18.785, + -17.806, + -3.063 + ], + "max": [ + 18.785, + 17.806, + 7.56 + ], + "length": 37.57, + "beam": 35.611, + "clearance": 3.063, + "tris": 2338, + "icon": "jet" + }, + "a333": { + "category": "aircraft", + "file": "aircraft/a333.glb", + "min": [ + -31.845, + -29.842, + -4.251 + ], + "max": [ + 31.845, + 29.842, + 10.854 + ], + "length": 63.69, + "beam": 59.684, + "clearance": 4.251, + "tris": 2338, + "icon": "widebody" + }, + "a343": { + "category": "aircraft", + "file": "aircraft/a343.glb", + "min": [ + -31.845, + -29.842, + -3.343 + ], + "max": [ + 31.845, + 29.842, + 10.854 + ], + "length": 63.69, + "beam": 59.684, + "clearance": 3.343, + "tris": 2638, + "icon": "quad" + }, + "a359": { + "category": "aircraft", + "file": "aircraft/a359.glb", + "min": [ + -33.4, + -32.106, + -4.83 + ], + "max": [ + 33.4, + 32.106, + 11.233 + ], + "length": 66.8, + "beam": 64.212, + "clearance": 4.83, + "tris": 2338, + "icon": "widebody" + }, + "a388": { + "category": "aircraft", + "file": "aircraft/a388.glb", + "min": [ + -36.36, + -39.291, + -4.827 + ], + "max": [ + 37.527, + 39.291, + 14.899 + ], + "length": 73.887, + "beam": 78.582, + "clearance": 4.827, + "tris": 2658, + "icon": "quad" + }, + "articulated_bus": { + "category": "transit", + "file": "transit/articulated_bus.glb", + "min": [ + -9.05, + -1.32, + -0.0 + ], + "max": [ + 9.05, + 1.355, + 3.33 + ], + "length": 18.1, + "beam": 2.675, + "clearance": 0.0, + "tris": 604, + "icon": "bus" + }, + "as50": { + "category": "helicopters", + "file": "helicopters/as50.glb", + "min": [ + -5.465, + -4.709, + -1.365 + ], + "max": [ + 6.023, + 4.709, + 2.267 + ], + "length": 11.488, + "beam": 9.418, + "clearance": 1.365, + "tris": 1488, + "icon": "heli" + }, + "at72": { + "category": "aircraft", + "file": "aircraft/at72.glb", + "min": [ + -13.585, + -13.255, + -1.402 + ], + "max": [ + 13.585, + 13.255, + 5.503 + ], + "length": 27.17, + "beam": 26.509, + "clearance": 1.402, + "tris": 2300, + "icon": "turboprop" + }, + "b06": { + "category": "helicopters", + "file": "helicopters/b06.glb", + "min": [ + -6.695, + -1.029, + -1.265 + ], + "max": [ + 5.294, + 1.029, + 1.91 + ], + "length": 11.989, + "beam": 2.057, + "clearance": 1.265, + "tris": 1476, + "icon": "heli" + }, + "b407": { + "category": "helicopters", + "file": "helicopters/b407.glb", + "min": [ + -7.137, + -5.335, + -1.315 + ], + "max": [ + 5.83, + 5.335, + 2.128 + ], + "length": 12.967, + "beam": 10.67, + "clearance": 1.315, + "tris": 1500, + "icon": "heli" + }, + "b738": { + "category": "aircraft", + "file": "aircraft/b738.glb", + "min": [ + -19.735, + -17.802, + -2.528 + ], + "max": [ + 19.735, + 17.802, + 8.122 + ], + "length": 39.47, + "beam": 35.603, + "clearance": 2.528, + "tris": 2350, + "icon": "jet" + }, + "b744": { + "category": "aircraft", + "file": "aircraft/b744.glb", + "min": [ + -35.33, + -31.777, + -3.786 + ], + "max": [ + 35.809, + 31.777, + 12.595 + ], + "length": 71.139, + "beam": 63.554, + "clearance": 3.786, + "tris": 2910, + "icon": "quad" + }, + "b763": { + "category": "aircraft", + "file": "aircraft/b763.glb", + "min": [ + -27.45, + -23.692, + -3.852 + ], + "max": [ + 27.45, + 23.692, + 10.008 + ], + "length": 54.9, + "beam": 47.385, + "clearance": 3.852, + "tris": 2338, + "icon": "widebody" + }, + "b77w": { + "category": "aircraft", + "file": "aircraft/b77w.glb", + "min": [ + -36.95, + -31.752, + -5.444 + ], + "max": [ + 36.95, + 31.752, + 11.469 + ], + "length": 73.9, + "beam": 63.504, + "clearance": 5.444, + "tris": 2186, + "icon": "widebody" + }, + "b789": { + "category": "aircraft", + "file": "aircraft/b789.glb", + "min": [ + -31.4, + -29.449, + -4.769 + ], + "max": [ + 31.4, + 29.449, + 10.973 + ], + "length": 62.8, + "beam": 58.898, + "clearance": 4.769, + "tris": 2186, + "icon": "widebody" + }, + "balloon": { + "category": "other", + "file": "other/balloon.glb", + "min": [ + -8.5, + -8.5, + -5.95 + ], + "max": [ + 8.5, + 8.5, + 19.55 + ], + "length": 16.999, + "beam": 16.999, + "clearance": 5.95, + "tris": 504, + "icon": "balloon" + }, + "barrel_yellow": { + "category": "buoys", + "file": "buoys/barrel_yellow.glb", + "min": [ + -0.85, + -0.5, + -0.3 + ], + "max": [ + 0.85, + 0.5, + 0.85 + ], + "length": 1.7, + "beam": 1.0, + "clearance": 0.3, + "tris": 80, + "icon": "buoy" + }, + "bcs3": { + "category": "aircraft", + "file": "aircraft/bcs3.glb", + "min": [ + -19.35, + -17.439, + -3.196 + ], + "max": [ + 19.35, + 17.439, + 7.086 + ], + "length": 38.7, + "beam": 34.879, + "clearance": 3.196, + "tris": 2338, + "icon": "jet" + }, + "be20": { + "category": "aircraft", + "file": "aircraft/be20.glb", + "min": [ + -6.67, + -8.139, + -0.871 + ], + "max": [ + 7.162, + 8.139, + 3.351 + ], + "length": 13.832, + "beam": 16.278, + "clearance": 0.871, + "tris": 2386, + "icon": "turboprop" + }, + "blimp": { + "category": "other", + "file": "other/blimp.glb", + "min": [ + -37.5, + -8.427, + -10.6 + ], + "max": [ + 37.5, + 8.427, + 8.427 + ], + "length": 75.0, + "beam": 16.855, + "clearance": 10.6, + "tris": 1044, + "icon": "balloon" + }, + "bulk_carrier": { + "category": "ships", + "file": "ships/bulk_carrier.glb", + "min": [ + -98.8, + -16.0, + -10.88 + ], + "max": [ + 95.0, + 16.0, + 30.76 + ], + "length": 193.8, + "beam": 32.0, + "clearance": 10.88, + "tris": 3121, + "icon": "ship" + }, + "buoy_cardinal_east": { + "category": "buoys", + "file": "buoys/buoy_cardinal_east.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.7 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 654, + "icon": "buoy" + }, + "buoy_cardinal_north": { + "category": "buoys", + "file": "buoys/buoy_cardinal_north.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.7 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 606, + "icon": "buoy" + }, + "buoy_cardinal_south": { + "category": "buoys", + "file": "buoys/buoy_cardinal_south.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.7 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 606, + "icon": "buoy" + }, + "buoy_cardinal_west": { + "category": "buoys", + "file": "buoys/buoy_cardinal_west.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.7 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 654, + "icon": "buoy" + }, + "buoy_isolated_danger": { + "category": "buoys", + "file": "buoys/buoy_isolated_danger.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.65 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 986, + "icon": "buoy" + }, + "buoy_lateral_port": { + "category": "buoys", + "file": "buoys/buoy_lateral_port.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 4.95 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 558, + "icon": "buoy" + }, + "buoy_lateral_starboard": { + "category": "buoys", + "file": "buoys/buoy_lateral_starboard.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 4.95 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 528, + "icon": "buoy" + }, + "buoy_safe_water": { + "category": "buoys", + "file": "buoys/buoy_safe_water.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 4.95 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 694, + "icon": "buoy" + }, + "buoy_special": { + "category": "buoys", + "file": "buoys/buoy_special.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.202 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 514, + "icon": "buoy" + }, + "c17": { + "category": "aircraft", + "file": "aircraft/c17.glb", + "min": [ + -26.52, + -25.688, + -3.467 + ], + "max": [ + 28.899, + 25.688, + 11.893 + ], + "length": 55.419, + "beam": 51.377, + "clearance": 3.467, + "tris": 2768, + "icon": "widebody" + }, + "c172": { + "category": "aircraft", + "file": "aircraft/c172.glb", + "min": [ + -4.416, + -5.39, + -1.49 + ], + "max": [ + 4.548, + 5.39, + 1.97 + ], + "length": 8.964, + "beam": 10.78, + "clearance": 1.49, + "tris": 2162, + "icon": "light" + }, + "c30j": { + "category": "aircraft", + "file": "aircraft/c30j.glb", + "min": [ + -14.895, + -19.801, + -2.328 + ], + "max": [ + 15.001, + 19.801, + 9.051 + ], + "length": 29.896, + "beam": 39.602, + "clearance": 2.328, + "tris": 2800, + "icon": "turboprop" + }, + "c56x": { + "category": "aircraft", + "file": "aircraft/c56x.glb", + "min": [ + -7.895, + -8.413, + -0.911 + ], + "max": [ + 8.028, + 8.413, + 3.458 + ], + "length": 15.923, + "beam": 16.827, + "clearance": 0.911, + "tris": 2198, + "icon": "rear_jet" + }, + "cable_tram": { + "category": "transit", + "file": "transit/cable_tram.glb", + "min": [ + -4.3, + -1.445, + -0.0 + ], + "max": [ + 4.3, + 1.445, + 3.13 + ], + "length": 8.6, + "beam": 2.89, + "clearance": 0.0, + "tris": 384, + "icon": "tram" + }, + "can_red": { + "category": "buoys", + "file": "buoys/can_red.glb", + "min": [ + -0.8, + -0.8, + -0.6 + ], + "max": [ + 0.8, + 0.8, + 1.18 + ], + "length": 1.6, + "beam": 1.6, + "clearance": 0.6, + "tris": 228, + "icon": "buoy" + }, + "can_red_cylinder": { + "category": "buoys", + "file": "buoys/can_red_cylinder.glb", + "min": [ + -0.8, + -0.8, + -0.6 + ], + "max": [ + 0.8, + 0.8, + 1.9 + ], + "length": 1.6, + "beam": 1.6, + "clearance": 0.6, + "tris": 308, + "icon": "buoy" + }, + "car": { + "category": "other", + "file": "other/car.glb", + "min": [ + -2.31, + -1.075, + 0.018 + ], + "max": [ + 2.31, + 1.075, + 1.84 + ], + "length": 4.62, + "beam": 2.15, + "clearance": 0.0, + "tris": 736, + "icon": "vehicle" + }, + "car_carrier": { + "category": "ships", + "file": "ships/car_carrier.glb", + "min": [ + -101.0, + -16.0, + -8.96 + ], + "max": [ + 100.0, + 18.315, + 38.525 + ], + "length": 201.0, + "beam": 34.315, + "clearance": 8.96, + "tris": 3949, + "icon": "ship" + }, + "city_bus": { + "category": "transit", + "file": "transit/city_bus.glb", + "min": [ + -6.05, + -1.32, + -0.0 + ], + "max": [ + 6.05, + 1.355, + 3.33 + ], + "length": 12.1, + "beam": 2.675, + "clearance": 0.0, + "tris": 432, + "icon": "bus" + }, + "cl35": { + "category": "aircraft", + "file": "aircraft/cl35.glb", + "min": [ + -10.46, + -10.446, + -1.189 + ], + "max": [ + 12.27, + 10.446, + 4.351 + ], + "length": 22.73, + "beam": 20.892, + "clearance": 1.189, + "tris": 2434, + "icon": "rear_jet" + }, + "coach": { + "category": "transit", + "file": "transit/coach.glb", + "min": [ + -6.51, + -1.32, + -0.0 + ], + "max": [ + 6.5, + 1.355, + 3.739 + ], + "length": 13.01, + "beam": 2.675, + "clearance": 0.0, + "tris": 474, + "icon": "bus" + }, + "commuter_emu": { + "category": "transit", + "file": "transit/commuter_emu.glb", + "min": [ + -49.05, + -1.53, + -0.0 + ], + "max": [ + 49.05, + 1.53, + 5.733 + ], + "length": 98.1, + "beam": 3.06, + "clearance": 0.0, + "tris": 1570, + "icon": "rail" + }, + "conical_green": { + "category": "buoys", + "file": "buoys/conical_green.glb", + "min": [ + -0.85, + -0.85, + -0.6 + ], + "max": [ + 0.85, + 0.85, + 1.7 + ], + "length": 1.7, + "beam": 1.7, + "clearance": 0.6, + "tris": 172, + "icon": "buoy" + }, + "conical_green_cone": { + "category": "buoys", + "file": "buoys/conical_green_cone.glb", + "min": [ + -0.85, + -0.85, + -0.6 + ], + "max": [ + 0.85, + 0.85, + 2.42 + ], + "length": 1.7, + "beam": 1.7, + "clearance": 0.6, + "tris": 222, + "icon": "buoy" + }, + "container_ship": { + "category": "ships", + "file": "ships/container_ship.glb", + "min": [ + -159.9, + -24.0, + -13.92 + ], + "max": [ + 150.0, + 24.0, + 47.94 + ], + "length": 309.9, + "beam": 48.0, + "clearance": 13.92, + "tris": 14337, + "icon": "ship" + }, + "crj9": { + "category": "aircraft", + "file": "aircraft/crj9.glb", + "min": [ + -18.1, + -12.347, + -1.339 + ], + "max": [ + 18.396, + 12.347, + 5.684 + ], + "length": 36.496, + "beam": 24.694, + "clearance": 1.339, + "tris": 2334, + "icon": "rear_jet" + }, + "cruise_ship": { + "category": "ships", + "file": "ships/cruise_ship.glb", + "min": [ + -168.0, + -22.5, + -8.4 + ], + "max": [ + 150.0, + 22.5, + 67.55 + ], + "length": 318.0, + "beam": 45.0, + "clearance": 8.4, + "tris": 3759, + "icon": "passenger" + }, + "dh8d": { + "category": "aircraft", + "file": "aircraft/dh8d.glb", + "min": [ + -16.4, + -13.916, + -1.339 + ], + "max": [ + 16.4, + 13.916, + 5.709 + ], + "length": 32.8, + "beam": 27.832, + "clearance": 1.339, + "tris": 2364, + "icon": "turboprop" + }, + "double_decker": { + "category": "transit", + "file": "transit/double_decker.glb", + "min": [ + -5.55, + -1.32, + -0.0 + ], + "max": [ + 5.55, + 1.355, + 4.387 + ], + "length": 11.1, + "beam": 2.675, + "clearance": 0.0, + "tris": 442, + "icon": "bus" + }, + "dredger": { + "category": "ships", + "file": "ships/dredger.glb", + "min": [ + -53.0, + -10.06, + -7.0 + ], + "max": [ + 50.0, + 11.0, + 17.955 + ], + "length": 103.0, + "beam": 21.06, + "clearance": 7.0, + "tris": 2597, + "icon": "ship" + }, + "e175": { + "category": "aircraft", + "file": "aircraft/e175.glb", + "min": [ + -15.84, + -12.917, + -2.427 + ], + "max": [ + 15.84, + 12.917, + 5.818 + ], + "length": 31.68, + "beam": 25.835, + "clearance": 2.427, + "tris": 2338, + "icon": "jet" + }, + "e55p": { + "category": "aircraft", + "file": "aircraft/e55p.glb", + "min": [ + -7.95, + -8.042, + -0.896 + ], + "max": [ + 8.222, + 8.042, + 3.469 + ], + "length": 16.172, + "beam": 16.084, + "clearance": 0.896, + "tris": 2414, + "icon": "rear_jet" + }, + "ec35": { + "category": "helicopters", + "file": "helicopters/ec35.glb", + "min": [ + -6.834, + -5.1, + -1.365 + ], + "max": [ + 5.304, + 5.1, + 2.0 + ], + "length": 12.138, + "beam": 10.2, + "clearance": 1.365, + "tris": 1420, + "icon": "heli" + }, + "ec45": { + "category": "helicopters", + "file": "helicopters/ec45.glb", + "min": [ + -7.472, + -5.5, + -1.54 + ], + "max": [ + 6.032, + 5.5, + 2.65 + ], + "length": 13.504, + "beam": 11.0, + "clearance": 1.54, + "tris": 1420, + "icon": "heli" + }, + "fast_ferry": { + "category": "ships", + "file": "ships/fast_ferry.glb", + "min": [ + -22.4, + -5.56, + -1.32 + ], + "max": [ + 20.0, + 5.56, + 8.3 + ], + "length": 42.4, + "beam": 11.12, + "clearance": 1.32, + "tris": 4754, + "icon": "passenger" + }, + "feeder": { + "category": "ships", + "file": "ships/feeder.glb", + "min": [ + -79.95, + -11.5, + -6.67 + ], + "max": [ + 75.0, + 11.5, + 28.09 + ], + "length": 154.95, + "beam": 23.0, + "clearance": 6.67, + "tris": 3519, + "icon": "ship" + }, + "ferry": { + "category": "ships", + "file": "ships/ferry.glb", + "min": [ + -70.2, + -12.9, + -5.28 + ], + "max": [ + 65.05, + 12.9, + 31.575 + ], + "length": 135.25, + "beam": 25.8, + "clearance": 5.28, + "tris": 3157, + "icon": "passenger" + }, + "fire_truck": { + "category": "other", + "file": "other/fire_truck.glb", + "min": [ + -6.408, + -1.507, + 0.029 + ], + "max": [ + 5.51, + 1.507, + 3.64 + ], + "length": 11.918, + "beam": 3.014, + "clearance": 0.0, + "tris": 1016, + "icon": "vehicle" + }, + "fishing_boat": { + "category": "ships", + "file": "ships/fishing_boat.glb", + "min": [ + -7.2, + -2.0, + -1.2 + ], + "max": [ + 6.0, + 2.0, + 5.753 + ], + "length": 13.2, + "beam": 4.0, + "clearance": 1.2, + "tris": 2839, + "icon": "boat" + }, + "funicular": { + "category": "transit", + "file": "transit/funicular.glb", + "min": [ + -7.05, + -1.345, + -0.0 + ], + "max": [ + 7.05, + 1.345, + 5.43 + ], + "length": 14.1, + "beam": 2.69, + "clearance": 0.0, + "tris": 308, + "icon": "rail" + }, + "general_cargo": { + "category": "ships", + "file": "ships/general_cargo.glb", + "min": [ + -47.7, + -7.0, + -5.32 + ], + "max": [ + 45.0, + 7.0, + 18.846 + ], + "length": 92.7, + "beam": 14.0, + "clearance": 5.32, + "tris": 2797, + "icon": "ship" + }, + "generic_heli": { + "category": "helicopters", + "file": "helicopters/generic_heli.glb", + "min": [ + -7.37, + -5.5, + -1.365 + ], + "max": [ + 5.927, + 5.5, + 2.28 + ], + "length": 13.297, + "beam": 11.0, + "clearance": 1.365, + "tris": 1272, + "icon": "heli" + }, + "generic_plane": { + "category": "aircraft", + "file": "aircraft/generic_plane.glb", + "min": [ + -18.0, + -16.66, + -2.906 + ], + "max": [ + 18.0, + 16.66, + 7.373 + ], + "length": 36.0, + "beam": 33.32, + "clearance": 2.906, + "tris": 2166, + "icon": "jet" + }, + "generic_vessel": { + "category": "ships", + "file": "ships/generic_vessel.glb", + "min": [ + -21.6, + -4.5, + -2.7 + ], + "max": [ + 20.0, + 4.5, + 11.6 + ], + "length": 41.6, + "beam": 9.0, + "clearance": 2.7, + "tris": 2389, + "icon": "ship" + }, + "glf4": { + "category": "aircraft", + "file": "aircraft/glf4.glb", + "min": [ + -13.46, + -11.79, + -1.189 + ], + "max": [ + 15.73, + 11.79, + 5.022 + ], + "length": 29.19, + "beam": 23.58, + "clearance": 1.189, + "tris": 2474, + "icon": "rear_jet" + }, + "glider": { + "category": "aircraft", + "file": "aircraft/glider.glb", + "min": [ + -3.4, + -7.396, + -0.398 + ], + "max": [ + 3.561, + 7.396, + 1.384 + ], + "length": 6.961, + "beam": 14.793, + "clearance": 0.398, + "tris": 2314, + "icon": "light" + }, + "gondola": { + "category": "transit", + "file": "transit/gondola.glb", + "min": [ + -1.05, + -0.938, + 0.014 + ], + "max": [ + 1.05, + 0.938, + 4.6 + ], + "length": 2.1, + "beam": 1.876, + "clearance": 0.0, + "tris": 282, + "icon": "unknown" + }, + "h47": { + "category": "helicopters", + "file": "helicopters/h47.glb", + "min": [ + -10.3, + -9.151, + -2.588 + ], + "max": [ + 13.921, + 8.103, + 4.78 + ], + "length": 24.221, + "beam": 17.254, + "clearance": 2.588, + "tris": 1430, + "icon": "heli_tandem" + }, + "h60": { + "category": "helicopters", + "file": "helicopters/h60.glb", + "min": [ + -10.774, + -8.18, + -1.765 + ], + "max": [ + 9.13, + 8.18, + 3.265 + ], + "length": 19.904, + "beam": 16.36, + "clearance": 1.765, + "tris": 1560, + "icon": "heli" + }, + "h64": { + "category": "helicopters", + "file": "helicopters/h64.glb", + "min": [ + -9.875, + -7.315, + -1.475 + ], + "max": [ + 8.545, + 7.315, + 2.742 + ], + "length": 18.42, + "beam": 14.63, + "clearance": 1.475, + "tris": 2040, + "icon": "heli" + }, + "high_speed": { + "category": "transit", + "file": "transit/high_speed.glb", + "min": [ + -103.1, + -1.555, + -0.0 + ], + "max": [ + 103.1, + 1.555, + 5.285 + ], + "length": 206.2, + "beam": 3.11, + "clearance": 0.0, + "tris": 2844, + "icon": "rail" + }, + "inland_cargo": { + "category": "ships", + "file": "ships/inland_cargo.glb", + "min": [ + -56.32, + -5.725, + -3.0 + ], + "max": [ + 55.0, + 5.725, + 9.995 + ], + "length": 111.32, + "beam": 11.45, + "clearance": 3.0, + "tris": 3277, + "icon": "ship" + }, + "inland_tanker": { + "category": "ships", + "file": "ships/inland_tanker.glb", + "min": [ + -56.32, + -5.725, + -3.2 + ], + "max": [ + 55.0, + 5.725, + 7.5 + ], + "length": 111.32, + "beam": 11.45, + "clearance": 3.2, + "tris": 2709, + "icon": "tanker" + }, + "intercity": { + "category": "transit", + "file": "transit/intercity.glb", + "min": [ + -91.4, + -1.545, + -0.0 + ], + "max": [ + 91.45, + 1.545, + 5.933 + ], + "length": 182.85, + "beam": 3.09, + "clearance": 0.0, + "tris": 3168, + "icon": "rail" + }, + "k35r": { + "category": "aircraft", + "file": "aircraft/k35r.glb", + "min": [ + -20.765, + -19.541, + -2.478 + ], + "max": [ + 28.186, + 19.541, + 8.482 + ], + "length": 48.951, + "beam": 39.082, + "clearance": 2.478, + "tris": 2626, + "icon": "jet" + }, + "light_rail": { + "category": "transit", + "file": "transit/light_rail.glb", + "min": [ + -14.1, + -1.405, + -0.0 + ], + "max": [ + 14.1, + 1.405, + 5.641 + ], + "length": 28.2, + "beam": 2.81, + "clearance": 0.0, + "tris": 760, + "icon": "tram" + }, + "lng_carrier": { + "category": "ships", + "file": "ships/lng_carrier.glb", + "min": [ + -152.25, + -23.0, + -11.5 + ], + "max": [ + 145.0, + 23.0, + 45.38 + ], + "length": 297.25, + "beam": 46.0, + "clearance": 11.5, + "tris": 2951, + "icon": "tanker" + }, + "md11": { + "category": "aircraft", + "file": "aircraft/md11.glb", + "min": [ + -30.8, + -25.74, + -4.162 + ], + "max": [ + 33.345, + 25.74, + 10.88 + ], + "length": 64.145, + "beam": 51.48, + "clearance": 4.162, + "tris": 2476, + "icon": "widebody" + }, + "metro": { + "category": "transit", + "file": "transit/metro.glb", + "min": [ + -57.55, + -1.53, + -0.0 + ], + "max": [ + 57.55, + 1.53, + 3.691 + ], + "length": 115.1, + "beam": 3.06, + "clearance": 0.0, + "tris": 2226, + "icon": "subway" + }, + "monorail": { + "category": "transit", + "file": "transit/monorail.glb", + "min": [ + -27.75, + -1.53, + 0.0 + ], + "max": [ + 27.75, + 1.53, + 3.587 + ], + "length": 55.5, + "beam": 3.06, + "clearance": 0.0, + "tris": 814, + "icon": "subway" + }, + "motor_yacht": { + "category": "ships", + "file": "ships/motor_yacht.glb", + "min": [ + -9.92, + -2.3, + -1.15 + ], + "max": [ + 9.2, + 2.3, + 5.46 + ], + "length": 19.12, + "beam": 4.6, + "clearance": 1.15, + "tris": 2477, + "icon": "boat" + }, + "mq9": { + "category": "aircraft", + "file": "aircraft/mq9.glb", + "min": [ + -5.5, + -9.8, + -1.616 + ], + "max": [ + 5.85, + 9.8, + 1.582 + ], + "length": 11.35, + "beam": 19.6, + "clearance": 1.616, + "tris": 1936, + "icon": "light" + }, + "p28a": { + "category": "aircraft", + "file": "aircraft/p28a.glb", + "min": [ + -3.901, + -5.228, + -1.341 + ], + "max": [ + 3.877, + 5.228, + 1.801 + ], + "length": 7.778, + "beam": 10.457, + "clearance": 1.341, + "tris": 2136, + "icon": "light" + }, + "patrol": { + "category": "ships", + "file": "ships/patrol.glb", + "min": [ + -36.0, + -4.5, + -2.7 + ], + "max": [ + 30.0, + 4.5, + 19.6 + ], + "length": 66.0, + "beam": 9.0, + "clearance": 2.7, + "tris": 2587, + "icon": "ship" + }, + "pc12": { + "category": "aircraft", + "file": "aircraft/pc12.glb", + "min": [ + -7.62, + -8.053, + -1.435 + ], + "max": [ + 7.3, + 8.053, + 3.396 + ], + "length": 14.92, + "beam": 16.106, + "clearance": 1.435, + "tris": 2292, + "icon": "turboprop" + }, + "pile_green_cone": { + "category": "buoys", + "file": "buoys/pile_green_cone.glb", + "min": [ + -0.35, + -0.35, + -1.5 + ], + "max": [ + 0.35, + 0.35, + 4.72 + ], + "length": 0.7, + "beam": 0.7, + "clearance": 1.5, + "tris": 138, + "icon": "buoy" + }, + "pile_red_cylinder": { + "category": "buoys", + "file": "buoys/pile_red_cylinder.glb", + "min": [ + -0.35, + -0.35, + -1.5 + ], + "max": [ + 0.35, + 0.35, + 4.72 + ], + "length": 0.7, + "beam": 0.7, + "clearance": 1.5, + "tris": 168, + "icon": "buoy" + }, + "pilot_boat": { + "category": "ships", + "file": "ships/pilot_boat.glb", + "min": [ + -10.54, + -2.88, + -1.3 + ], + "max": [ + 8.5, + 2.88, + 8.716 + ], + "length": 19.04, + "beam": 5.76, + "clearance": 1.3, + "tris": 2985, + "icon": "boat" + }, + "product_tanker": { + "category": "ships", + "file": "ships/product_tanker.glb", + "min": [ + -58.76, + -9.5, + -6.46 + ], + "max": [ + 56.5, + 9.5, + 25.672 + ], + "length": 115.26, + "beam": 19.0, + "clearance": 6.46, + "tris": 2995, + "icon": "tanker" + }, + "psv": { + "category": "ships", + "file": "ships/psv.glb", + "min": [ + -45.05, + -9.5, + -6.27 + ], + "max": [ + 42.5, + 9.5, + 24.119 + ], + "length": 87.55, + "beam": 19.0, + "clearance": 6.27, + "tris": 3059, + "icon": "ship" + }, + "pushback": { + "category": "other", + "file": "other/pushback.glb", + "min": [ + -3.45, + -1.398, + 0.025 + ], + "max": [ + 3.05, + 1.398, + 2.14 + ], + "length": 6.5, + "beam": 2.795, + "clearance": 0.0, + "tris": 490, + "icon": "vehicle" + }, + "r44": { + "category": "helicopters", + "file": "helicopters/r44.glb", + "min": [ + -7.116, + -0.744, + -1.14 + ], + "max": [ + 5.079, + 0.744, + 2.03 + ], + "length": 12.195, + "beam": 1.488, + "clearance": 1.14, + "tris": 1172, + "icon": "heli" + }, + "radio_mast": { + "category": "other", + "file": "other/radio_mast.glb", + "min": [ + -0.7, + -0.815, + 0.0 + ], + "max": [ + 1.2, + 0.815, + 11.8 + ], + "length": 1.9, + "beam": 1.63, + "clearance": 0.0, + "tris": 940, + "icon": "vehicle" + }, + "regional_dmu": { + "category": "transit", + "file": "transit/regional_dmu.glb", + "min": [ + -24.75, + -1.53, + -0.0 + ], + "max": [ + 24.75, + 1.53, + 4.36 + ], + "length": 49.5, + "beam": 3.06, + "clearance": 0.0, + "tris": 770, + "icon": "rail" + }, + "river_cruise": { + "category": "ships", + "file": "ships/river_cruise.glb", + "min": [ + -69.12, + -5.7, + -1.8 + ], + "max": [ + 67.5, + 5.7, + 9.9 + ], + "length": 136.62, + "beam": 11.4, + "clearance": 1.8, + "tris": 2675, + "icon": "passenger" + }, + "s76": { + "category": "helicopters", + "file": "helicopters/s76.glb", + "min": [ + -8.949, + -6.705, + -1.525 + ], + "max": [ + 7.428, + 6.705, + 2.776 + ], + "length": 16.377, + "beam": 13.41, + "clearance": 1.525, + "tris": 1356, + "icon": "heli" + }, + "sailboat": { + "category": "ships", + "file": "ships/sailboat.glb", + "min": [ + -7.8, + -2.05, + -2.695 + ], + "max": [ + 6.5, + 2.05, + 18.698 + ], + "length": 14.3, + "beam": 4.1, + "clearance": 2.695, + "tris": 2421, + "icon": "sail" + }, + "spar_lateral_port": { + "category": "buoys", + "file": "buoys/spar_lateral_port.glb", + "min": [ + -0.47, + -0.47, + -1.5 + ], + "max": [ + 0.47, + 0.47, + 3.36 + ], + "length": 0.94, + "beam": 0.94, + "clearance": 1.5, + "tris": 290, + "icon": "buoy" + }, + "spar_lateral_starboard": { + "category": "buoys", + "file": "buoys/spar_lateral_starboard.glb", + "min": [ + -0.47, + -0.47, + -1.5 + ], + "max": [ + 0.47, + 0.47, + 3.36 + ], + "length": 0.94, + "beam": 0.94, + "clearance": 1.5, + "tris": 262, + "icon": "buoy" + }, + "speedboat": { + "category": "ships", + "file": "ships/speedboat.glb", + "min": [ + -4.96, + -1.3, + -0.55 + ], + "max": [ + 4.45, + 1.3, + 2.03 + ], + "length": 9.41, + "beam": 2.6, + "clearance": 0.55, + "tris": 2369, + "icon": "boat" + }, + "spherical_green": { + "category": "buoys", + "file": "buoys/spherical_green.glb", + "min": [ + -0.75, + -0.739, + -0.4 + ], + "max": [ + 0.75, + 0.739, + 1.25 + ], + "length": 1.5, + "beam": 1.477, + "clearance": 0.4, + "tris": 344, + "icon": "buoy" + }, + "spherical_yellow": { + "category": "buoys", + "file": "buoys/spherical_yellow.glb", + "min": [ + -0.75, + -0.739, + -0.4 + ], + "max": [ + 0.75, + 0.739, + 1.25 + ], + "length": 1.5, + "beam": 1.477, + "clearance": 0.4, + "tris": 344, + "icon": "buoy" + }, + "spherical_yellow_x": { + "category": "buoys", + "file": "buoys/spherical_yellow_x.glb", + "min": [ + -0.75, + -0.739, + -0.4 + ], + "max": [ + 0.75, + 0.739, + 2.019 + ], + "length": 1.5, + "beam": 1.477, + "clearance": 0.4, + "tris": 380, + "icon": "buoy" + }, + "sr22": { + "category": "aircraft", + "file": "aircraft/sr22.glb", + "min": [ + -4.3, + -5.723, + -1.325 + ], + "max": [ + 4.124, + 5.723, + 1.765 + ], + "length": 8.424, + "beam": 11.446, + "clearance": 1.325, + "tris": 2428, + "icon": "light" + }, + "stake_green_cone": { + "category": "buoys", + "file": "buoys/stake_green_cone.glb", + "min": [ + -0.198, + -0.198, + -1.5 + ], + "max": [ + 0.198, + 0.198, + 3.63 + ], + "length": 0.396, + "beam": 0.396, + "clearance": 1.5, + "tris": 78, + "icon": "buoy" + }, + "stake_red_cylinder": { + "category": "buoys", + "file": "buoys/stake_red_cylinder.glb", + "min": [ + -0.154, + -0.154, + -1.5 + ], + "max": [ + 0.154, + 0.154, + 3.63 + ], + "length": 0.308, + "beam": 0.308, + "clearance": 1.5, + "tris": 108, + "icon": "buoy" + }, + "stake_yellow_x": { + "category": "buoys", + "file": "buoys/stake_yellow_x.glb", + "min": [ + -0.08, + -0.232, + -1.5 + ], + "max": [ + 0.08, + 0.232, + 3.769 + ], + "length": 0.16, + "beam": 0.463, + "clearance": 1.5, + "tris": 64, + "icon": "buoy" + }, + "superyacht": { + "category": "ships", + "file": "ships/superyacht.glb", + "min": [ + -36.0, + -5.25, + -3.15 + ], + "max": [ + 30.0, + 5.25, + 17.565 + ], + "length": 66.0, + "beam": 10.5, + "clearance": 3.15, + "tris": 2689, + "icon": "passenger" + }, + "t38": { + "category": "aircraft", + "file": "aircraft/t38.glb", + "min": [ + -7.07, + -3.773, + -0.746 + ], + "max": [ + 7.074, + 3.773, + 2.592 + ], + "length": 14.144, + "beam": 7.546, + "clearance": 0.746, + "tris": 2246, + "icon": "fighter" + }, + "tanker": { + "category": "ships", + "file": "ships/tanker.glb", + "min": [ + -130.0, + -22.0, + -14.96 + ], + "max": [ + 125.0, + 22.0, + 35.608 + ], + "length": 255.0, + "beam": 44.0, + "clearance": 14.96, + "tris": 3393, + "icon": "tanker" + }, + "tex2": { + "category": "aircraft", + "file": "aircraft/tex2.glb", + "min": [ + -5.452, + -4.993, + -1.293 + ], + "max": [ + 5.134, + 4.993, + 2.168 + ], + "length": 10.586, + "beam": 9.986, + "clearance": 1.293, + "tris": 2380, + "icon": "turboprop" + }, + "tour_boat": { + "category": "ships", + "file": "ships/tour_boat.glb", + "min": [ + -14.5, + -3.0, + -1.68 + ], + "max": [ + 12.5, + 3.0, + 5.57 + ], + "length": 27.0, + "beam": 6.0, + "clearance": 1.68, + "tris": 2449, + "icon": "boat" + }, + "tower": { + "category": "other", + "file": "other/tower.glb", + "min": [ + -0.696, + -1.135, + -0.016 + ], + "max": [ + 1.296, + 1.135, + 60.6 + ], + "length": 1.992, + "beam": 2.27, + "clearance": 0.016, + "tris": 568, + "icon": "vehicle" + }, + "tram": { + "category": "transit", + "file": "transit/tram.glb", + "min": [ + -15.0, + -1.28, + -0.0 + ], + "max": [ + 15.0, + 1.28, + 5.241 + ], + "length": 30.0, + "beam": 2.56, + "clearance": 0.0, + "tris": 1004, + "icon": "tram" + }, + "trawler": { + "category": "ships", + "file": "ships/trawler.glb", + "min": [ + -12.1, + -6.338, + -2.8 + ], + "max": [ + 11.0, + 6.338, + 12.529 + ], + "length": 23.1, + "beam": 12.676, + "clearance": 2.8, + "tris": 3121, + "icon": "ship" + }, + "trolleybus": { + "category": "transit", + "file": "transit/trolleybus.glb", + "min": [ + -6.05, + -1.32, + -0.0 + ], + "max": [ + 6.05, + 1.355, + 5.656 + ], + "length": 12.1, + "beam": 2.675, + "clearance": 0.0, + "tris": 458, + "icon": "bus" + }, + "tug": { + "category": "ships", + "file": "ships/tug.glb", + "min": [ + -15.8, + -5.35, + -3.7 + ], + "max": [ + 14.4, + 5.35, + 14.1 + ], + "length": 30.2, + "beam": 10.7, + "clearance": 3.7, + "tris": 3661, + "icon": "ship" + }, + "ultralight": { + "category": "aircraft", + "file": "aircraft/ultralight.glb", + "min": [ + -3.51, + -5.243, + -1.217 + ], + "max": [ + 3.6, + 5.243, + 1.644 + ], + "length": 7.11, + "beam": 10.486, + "clearance": 1.217, + "tris": 2068, + "icon": "light" + }, + "v22": { + "category": "aircraft", + "file": "aircraft/v22.glb", + "min": [ + -8.74, + -11.647, + -1.468 + ], + "max": [ + 8.74, + 11.647, + 4.569 + ], + "length": 17.48, + "beam": 23.294, + "clearance": 1.468, + "tris": 2250, + "icon": "turboprop" + }, + "van": { + "category": "other", + "file": "other/van.glb", + "min": [ + -2.76, + -1.22, + 0.018 + ], + "max": [ + 2.76, + 1.22, + 2.56 + ], + "length": 5.52, + "beam": 2.44, + "clearance": 0.0, + "tris": 736, + "icon": "vehicle" + } +} diff --git a/public/models/fleet/helicopters/a139.glb b/public/models/fleet/helicopters/a139.glb new file mode 100644 index 000000000..f61975dfb Binary files /dev/null and b/public/models/fleet/helicopters/a139.glb differ diff --git a/public/models/fleet/helicopters/as50.glb b/public/models/fleet/helicopters/as50.glb new file mode 100644 index 000000000..597d916e9 Binary files /dev/null and b/public/models/fleet/helicopters/as50.glb differ diff --git a/public/models/fleet/helicopters/b06.glb b/public/models/fleet/helicopters/b06.glb new file mode 100644 index 000000000..25baa7fa4 Binary files /dev/null and b/public/models/fleet/helicopters/b06.glb differ diff --git a/public/models/fleet/helicopters/b407.glb b/public/models/fleet/helicopters/b407.glb new file mode 100644 index 000000000..09d67e67e Binary files /dev/null and b/public/models/fleet/helicopters/b407.glb differ diff --git a/public/models/fleet/helicopters/ec35.glb b/public/models/fleet/helicopters/ec35.glb new file mode 100644 index 000000000..c52f3d156 Binary files /dev/null and b/public/models/fleet/helicopters/ec35.glb differ diff --git a/public/models/fleet/helicopters/ec45.glb b/public/models/fleet/helicopters/ec45.glb new file mode 100644 index 000000000..b9e5cb27e Binary files /dev/null and b/public/models/fleet/helicopters/ec45.glb differ diff --git a/public/models/fleet/helicopters/generic_heli.glb b/public/models/fleet/helicopters/generic_heli.glb new file mode 100644 index 000000000..bc8f1a239 Binary files /dev/null and b/public/models/fleet/helicopters/generic_heli.glb differ diff --git a/public/models/fleet/helicopters/h47.glb b/public/models/fleet/helicopters/h47.glb new file mode 100644 index 000000000..bb2f71426 Binary files /dev/null and b/public/models/fleet/helicopters/h47.glb differ diff --git a/public/models/fleet/helicopters/h60.glb b/public/models/fleet/helicopters/h60.glb new file mode 100644 index 000000000..45f79180d Binary files /dev/null and b/public/models/fleet/helicopters/h60.glb differ diff --git a/public/models/fleet/helicopters/h64.glb b/public/models/fleet/helicopters/h64.glb new file mode 100644 index 000000000..3a44d8e50 Binary files /dev/null and b/public/models/fleet/helicopters/h64.glb differ diff --git a/public/models/fleet/helicopters/r44.glb b/public/models/fleet/helicopters/r44.glb new file mode 100644 index 000000000..845727936 Binary files /dev/null and b/public/models/fleet/helicopters/r44.glb differ diff --git a/public/models/fleet/helicopters/s76.glb b/public/models/fleet/helicopters/s76.glb new file mode 100644 index 000000000..a773aba73 Binary files /dev/null and b/public/models/fleet/helicopters/s76.glb differ diff --git a/public/models/fleet/other/balloon.glb b/public/models/fleet/other/balloon.glb new file mode 100644 index 000000000..016ad0719 Binary files /dev/null and b/public/models/fleet/other/balloon.glb differ diff --git a/public/models/fleet/other/blimp.glb b/public/models/fleet/other/blimp.glb new file mode 100644 index 000000000..3aba8e86b Binary files /dev/null and b/public/models/fleet/other/blimp.glb differ diff --git a/public/models/fleet/other/car.glb b/public/models/fleet/other/car.glb new file mode 100644 index 000000000..80f50d967 Binary files /dev/null and b/public/models/fleet/other/car.glb differ diff --git a/public/models/fleet/other/fire_truck.glb b/public/models/fleet/other/fire_truck.glb new file mode 100644 index 000000000..47cb6d245 Binary files /dev/null and b/public/models/fleet/other/fire_truck.glb differ diff --git a/public/models/fleet/other/pushback.glb b/public/models/fleet/other/pushback.glb new file mode 100644 index 000000000..565ce426c Binary files /dev/null and b/public/models/fleet/other/pushback.glb differ diff --git a/public/models/fleet/other/radio_mast.glb b/public/models/fleet/other/radio_mast.glb new file mode 100644 index 000000000..1242c7d41 Binary files /dev/null and b/public/models/fleet/other/radio_mast.glb differ diff --git a/public/models/fleet/other/tower.glb b/public/models/fleet/other/tower.glb new file mode 100644 index 000000000..39ff5c990 Binary files /dev/null and b/public/models/fleet/other/tower.glb differ diff --git a/public/models/fleet/other/van.glb b/public/models/fleet/other/van.glb new file mode 100644 index 000000000..0b79e1db6 Binary files /dev/null and b/public/models/fleet/other/van.glb differ diff --git a/public/models/fleet/ships/bulk_carrier.glb b/public/models/fleet/ships/bulk_carrier.glb new file mode 100644 index 000000000..3fe73d3bf Binary files /dev/null and b/public/models/fleet/ships/bulk_carrier.glb differ diff --git a/public/models/fleet/ships/car_carrier.glb b/public/models/fleet/ships/car_carrier.glb new file mode 100644 index 000000000..04ad3e779 Binary files /dev/null and b/public/models/fleet/ships/car_carrier.glb differ diff --git a/public/models/fleet/ships/container_ship.glb b/public/models/fleet/ships/container_ship.glb new file mode 100644 index 000000000..f8686176b Binary files /dev/null and b/public/models/fleet/ships/container_ship.glb differ diff --git a/public/models/fleet/ships/cruise_ship.glb b/public/models/fleet/ships/cruise_ship.glb new file mode 100644 index 000000000..d2dcfd88e Binary files /dev/null and b/public/models/fleet/ships/cruise_ship.glb differ diff --git a/public/models/fleet/ships/dredger.glb b/public/models/fleet/ships/dredger.glb new file mode 100644 index 000000000..4d9ab5264 Binary files /dev/null and b/public/models/fleet/ships/dredger.glb differ diff --git a/public/models/fleet/ships/fast_ferry.glb b/public/models/fleet/ships/fast_ferry.glb new file mode 100644 index 000000000..65b008200 Binary files /dev/null and b/public/models/fleet/ships/fast_ferry.glb differ diff --git a/public/models/fleet/ships/feeder.glb b/public/models/fleet/ships/feeder.glb new file mode 100644 index 000000000..1c40203bf Binary files /dev/null and b/public/models/fleet/ships/feeder.glb differ diff --git a/public/models/fleet/ships/ferry.glb b/public/models/fleet/ships/ferry.glb new file mode 100644 index 000000000..fcaa48abc Binary files /dev/null and b/public/models/fleet/ships/ferry.glb differ diff --git a/public/models/fleet/ships/fishing_boat.glb b/public/models/fleet/ships/fishing_boat.glb new file mode 100644 index 000000000..084f5dd3b Binary files /dev/null and b/public/models/fleet/ships/fishing_boat.glb differ diff --git a/public/models/fleet/ships/general_cargo.glb b/public/models/fleet/ships/general_cargo.glb new file mode 100644 index 000000000..0cd1cd4cd Binary files /dev/null and b/public/models/fleet/ships/general_cargo.glb differ diff --git a/public/models/fleet/ships/generic_vessel.glb b/public/models/fleet/ships/generic_vessel.glb new file mode 100644 index 000000000..e8c748544 Binary files /dev/null and b/public/models/fleet/ships/generic_vessel.glb differ diff --git a/public/models/fleet/ships/inland_cargo.glb b/public/models/fleet/ships/inland_cargo.glb new file mode 100644 index 000000000..9f9facc4a Binary files /dev/null and b/public/models/fleet/ships/inland_cargo.glb differ diff --git a/public/models/fleet/ships/inland_tanker.glb b/public/models/fleet/ships/inland_tanker.glb new file mode 100644 index 000000000..db46c2476 Binary files /dev/null and b/public/models/fleet/ships/inland_tanker.glb differ diff --git a/public/models/fleet/ships/lng_carrier.glb b/public/models/fleet/ships/lng_carrier.glb new file mode 100644 index 000000000..d06330645 Binary files /dev/null and b/public/models/fleet/ships/lng_carrier.glb differ diff --git a/public/models/fleet/ships/motor_yacht.glb b/public/models/fleet/ships/motor_yacht.glb new file mode 100644 index 000000000..d7283750a Binary files /dev/null and b/public/models/fleet/ships/motor_yacht.glb differ diff --git a/public/models/fleet/ships/patrol.glb b/public/models/fleet/ships/patrol.glb new file mode 100644 index 000000000..6b3352e11 Binary files /dev/null and b/public/models/fleet/ships/patrol.glb differ diff --git a/public/models/fleet/ships/pilot_boat.glb b/public/models/fleet/ships/pilot_boat.glb new file mode 100644 index 000000000..f4197ab0f Binary files /dev/null and b/public/models/fleet/ships/pilot_boat.glb differ diff --git a/public/models/fleet/ships/product_tanker.glb b/public/models/fleet/ships/product_tanker.glb new file mode 100644 index 000000000..6e31083d3 Binary files /dev/null and b/public/models/fleet/ships/product_tanker.glb differ diff --git a/public/models/fleet/ships/psv.glb b/public/models/fleet/ships/psv.glb new file mode 100644 index 000000000..d943978ec Binary files /dev/null and b/public/models/fleet/ships/psv.glb differ diff --git a/public/models/fleet/ships/river_cruise.glb b/public/models/fleet/ships/river_cruise.glb new file mode 100644 index 000000000..2da8589f0 Binary files /dev/null and b/public/models/fleet/ships/river_cruise.glb differ diff --git a/public/models/fleet/ships/sailboat.glb b/public/models/fleet/ships/sailboat.glb new file mode 100644 index 000000000..51ae8818f Binary files /dev/null and b/public/models/fleet/ships/sailboat.glb differ diff --git a/public/models/fleet/ships/speedboat.glb b/public/models/fleet/ships/speedboat.glb new file mode 100644 index 000000000..eda10fb84 Binary files /dev/null and b/public/models/fleet/ships/speedboat.glb differ diff --git a/public/models/fleet/ships/superyacht.glb b/public/models/fleet/ships/superyacht.glb new file mode 100644 index 000000000..012e69dd3 Binary files /dev/null and b/public/models/fleet/ships/superyacht.glb differ diff --git a/public/models/fleet/ships/tanker.glb b/public/models/fleet/ships/tanker.glb new file mode 100644 index 000000000..68de5a001 Binary files /dev/null and b/public/models/fleet/ships/tanker.glb differ diff --git a/public/models/fleet/ships/tour_boat.glb b/public/models/fleet/ships/tour_boat.glb new file mode 100644 index 000000000..22ee9cb43 Binary files /dev/null and b/public/models/fleet/ships/tour_boat.glb differ diff --git a/public/models/fleet/ships/trawler.glb b/public/models/fleet/ships/trawler.glb new file mode 100644 index 000000000..bb8651f2c Binary files /dev/null and b/public/models/fleet/ships/trawler.glb differ diff --git a/public/models/fleet/ships/tug.glb b/public/models/fleet/ships/tug.glb new file mode 100644 index 000000000..f20e72b6b Binary files /dev/null and b/public/models/fleet/ships/tug.glb differ diff --git a/public/models/fleet/transit/articulated_bus.glb b/public/models/fleet/transit/articulated_bus.glb new file mode 100644 index 000000000..4b10d2ef0 Binary files /dev/null and b/public/models/fleet/transit/articulated_bus.glb differ diff --git a/public/models/fleet/transit/cable_tram.glb b/public/models/fleet/transit/cable_tram.glb new file mode 100644 index 000000000..10b12a3ee Binary files /dev/null and b/public/models/fleet/transit/cable_tram.glb differ diff --git a/public/models/fleet/transit/city_bus.glb b/public/models/fleet/transit/city_bus.glb new file mode 100644 index 000000000..4e2b1dc8a Binary files /dev/null and b/public/models/fleet/transit/city_bus.glb differ diff --git a/public/models/fleet/transit/coach.glb b/public/models/fleet/transit/coach.glb new file mode 100644 index 000000000..00e23f7f5 Binary files /dev/null and b/public/models/fleet/transit/coach.glb differ diff --git a/public/models/fleet/transit/commuter_emu.glb b/public/models/fleet/transit/commuter_emu.glb new file mode 100644 index 000000000..e53f0a1be Binary files /dev/null and b/public/models/fleet/transit/commuter_emu.glb differ diff --git a/public/models/fleet/transit/double_decker.glb b/public/models/fleet/transit/double_decker.glb new file mode 100644 index 000000000..416bd2cbc Binary files /dev/null and b/public/models/fleet/transit/double_decker.glb differ diff --git a/public/models/fleet/transit/funicular.glb b/public/models/fleet/transit/funicular.glb new file mode 100644 index 000000000..4b14d6622 Binary files /dev/null and b/public/models/fleet/transit/funicular.glb differ diff --git a/public/models/fleet/transit/gondola.glb b/public/models/fleet/transit/gondola.glb new file mode 100644 index 000000000..530ccb448 Binary files /dev/null and b/public/models/fleet/transit/gondola.glb differ diff --git a/public/models/fleet/transit/high_speed.glb b/public/models/fleet/transit/high_speed.glb new file mode 100644 index 000000000..c3f09d094 Binary files /dev/null and b/public/models/fleet/transit/high_speed.glb differ diff --git a/public/models/fleet/transit/intercity.glb b/public/models/fleet/transit/intercity.glb new file mode 100644 index 000000000..2845594ec Binary files /dev/null and b/public/models/fleet/transit/intercity.glb differ diff --git a/public/models/fleet/transit/light_rail.glb b/public/models/fleet/transit/light_rail.glb new file mode 100644 index 000000000..22f80d197 Binary files /dev/null and b/public/models/fleet/transit/light_rail.glb differ diff --git a/public/models/fleet/transit/metro.glb b/public/models/fleet/transit/metro.glb new file mode 100644 index 000000000..6154049b9 Binary files /dev/null and b/public/models/fleet/transit/metro.glb differ diff --git a/public/models/fleet/transit/monorail.glb b/public/models/fleet/transit/monorail.glb new file mode 100644 index 000000000..a8dab0f07 Binary files /dev/null and b/public/models/fleet/transit/monorail.glb differ diff --git a/public/models/fleet/transit/regional_dmu.glb b/public/models/fleet/transit/regional_dmu.glb new file mode 100644 index 000000000..1a091e2d6 Binary files /dev/null and b/public/models/fleet/transit/regional_dmu.glb differ diff --git a/public/models/fleet/transit/tram.glb b/public/models/fleet/transit/tram.glb new file mode 100644 index 000000000..514cff436 Binary files /dev/null and b/public/models/fleet/transit/tram.glb differ diff --git a/public/models/fleet/transit/trolleybus.glb b/public/models/fleet/transit/trolleybus.glb new file mode 100644 index 000000000..a7691b71e Binary files /dev/null and b/public/models/fleet/transit/trolleybus.glb differ diff --git a/public/models/fleet/types.json b/public/models/fleet/types.json new file mode 100644 index 000000000..2b2388bb1 --- /dev/null +++ b/public/models/fleet/types.json @@ -0,0 +1,2037 @@ +{ + "aircraft": { + "A320": [ + "a320", + 37.57 + ], + "A20N": [ + "a320", + 37.57 + ], + "A319": [ + "a320", + 33.84 + ], + "A19N": [ + "a320", + 33.84 + ], + "A321": [ + "a320", + 44.51 + ], + "A21N": [ + "a320", + 44.51 + ], + "A318": [ + "a320", + 31.44 + ], + "B738": [ + "b738", + 39.47 + ], + "B38M": [ + "b738", + 39.52 + ], + "B737": [ + "b738", + 33.63 + ], + "B37M": [ + "b738", + 35.56 + ], + "B739": [ + "b738", + 42.11 + ], + "B39M": [ + "b738", + 42.16 + ], + "B736": [ + "b738", + 31.24 + ], + "B733": [ + "b738", + 33.4 + ], + "B734": [ + "b738", + 36.4 + ], + "B735": [ + "b738", + 31.0 + ], + "B752": [ + "b738", + 47.32 + ], + "B753": [ + "b738", + 54.4 + ], + "P8": [ + "b738", + 39.47 + ], + "BCS3": [ + "bcs3", + 38.7 + ], + "BCS1": [ + "bcs3", + 35.0 + ], + "E75L": [ + "e175", + 31.68 + ], + "E75S": [ + "e175", + 31.68 + ], + "E170": [ + "e175", + 29.9 + ], + "E190": [ + "e175", + 36.24 + ], + "E195": [ + "e175", + 38.65 + ], + "E290": [ + "e175", + 36.2 + ], + "E295": [ + "e175", + 41.5 + ], + "CRJ9": [ + "crj9", + 36.2 + ], + "CRJ7": [ + "crj9", + 32.5 + ], + "CRJ2": [ + "crj9", + 26.77 + ], + "CRJX": [ + "crj9", + 39.1 + ], + "E145": [ + "crj9", + 29.87 + ], + "E135": [ + "crj9", + 26.33 + ], + "E35L": [ + "crj9", + 26.33 + ], + "B789": [ + "b789", + 62.8 + ], + "B788": [ + "b789", + 56.7 + ], + "B78X": [ + "b789", + 68.3 + ], + "A359": [ + "a359", + 66.8 + ], + "A35K": [ + "a359", + 73.8 + ], + "B77W": [ + "b77w", + 73.9 + ], + "B77L": [ + "b77w", + 63.7 + ], + "B772": [ + "b77w", + 63.7 + ], + "B773": [ + "b77w", + 73.9 + ], + "B778": [ + "b77w", + 70.9 + ], + "B779": [ + "b77w", + 76.7 + ], + "B763": [ + "b763", + 54.9 + ], + "B762": [ + "b763", + 48.5 + ], + "B764": [ + "b763", + 61.4 + ], + "A333": [ + "a333", + 63.69 + ], + "A332": [ + "a333", + 58.82 + ], + "A339": [ + "a333", + 63.66 + ], + "A338": [ + "a333", + 58.82 + ], + "DH8D": [ + "dh8d", + 32.8 + ], + "DH8C": [ + "dh8d", + 25.7 + ], + "DH8A": [ + "dh8d", + 22.25 + ], + "DH8B": [ + "dh8d", + 22.25 + ], + "AT72": [ + "at72", + 27.17 + ], + "AT76": [ + "at72", + 27.17 + ], + "AT75": [ + "at72", + 27.17 + ], + "AT45": [ + "at72", + 22.67 + ], + "AT43": [ + "at72", + 22.67 + ], + "AT46": [ + "at72", + 22.67 + ], + "E55P": [ + "e55p", + 15.9 + ], + "E545": [ + "e55p", + 19.7 + ], + "E550": [ + "e55p", + 20.7 + ], + "E50P": [ + "e55p", + 12.8 + ], + "LJ45": [ + "e55p", + 17.7 + ], + "LJ75": [ + "e55p", + 17.7 + ], + "LJ35": [ + "e55p", + 14.83 + ], + "LJ60": [ + "e55p", + 17.9 + ], + "C25B": [ + "e55p", + 15.6 + ], + "C25A": [ + "e55p", + 14.5 + ], + "C525": [ + "e55p", + 13.0 + ], + "C25C": [ + "e55p", + 16.3 + ], + "C680": [ + "e55p", + 19.4 + ], + "C68A": [ + "e55p", + 19.7 + ], + "C700": [ + "e55p", + 22.3 + ], + "HDJT": [ + "e55p", + 13.0 + ], + "PC24": [ + "e55p", + 16.85 + ], + "C56X": [ + "c56x", + 15.79 + ], + "C560": [ + "c56x", + 14.9 + ], + "C550": [ + "c56x", + 14.4 + ], + "C510": [ + "c56x", + 12.4 + ], + "C750": [ + "c56x", + 22.0 + ], + "C501": [ + "c56x", + 13.3 + ], + "CL35": [ + "cl35", + 20.92 + ], + "CL30": [ + "cl35", + 20.92 + ], + "CL60": [ + "cl35", + 20.85 + ], + "F2TH": [ + "cl35", + 20.2 + ], + "FA50": [ + "cl35", + 18.5 + ], + "F900": [ + "cl35", + 20.2 + ], + "FA7X": [ + "cl35", + 23.2 + ], + "FA8X": [ + "cl35", + 24.5 + ], + "G280": [ + "cl35", + 20.3 + ], + "H25B": [ + "cl35", + 15.6 + ], + "GLF4": [ + "glf4", + 26.92 + ], + "GLF5": [ + "glf4", + 29.4 + ], + "GLF6": [ + "glf4", + 30.4 + ], + "GLEX": [ + "glf4", + 30.3 + ], + "GL5T": [ + "glf4", + 29.5 + ], + "GL7T": [ + "glf4", + 33.8 + ], + "GA6C": [ + "glf4", + 30.4 + ], + "C172": [ + "c172", + 8.28 + ], + "C182": [ + "c172", + 8.84 + ], + "C152": [ + "c172", + 7.34 + ], + "C150": [ + "c172", + 7.29 + ], + "C72R": [ + "c172", + 8.28 + ], + "C206": [ + "c172", + 8.61 + ], + "C210": [ + "c172", + 8.59 + ], + "C208": [ + "c172", + 11.46 + ], + "C170": [ + "c172", + 7.6 + ], + "C180": [ + "c172", + 7.8 + ], + "C177": [ + "c172", + 8.4 + ], + "P28A": [ + "p28a", + 7.25 + ], + "P28R": [ + "p28a", + 7.5 + ], + "P28B": [ + "p28a", + 7.5 + ], + "PA46": [ + "p28a", + 8.8 + ], + "DA40": [ + "p28a", + 8.06 + ], + "BE36": [ + "p28a", + 8.38 + ], + "BE35": [ + "p28a", + 8.0 + ], + "M20P": [ + "p28a", + 7.52 + ], + "M20T": [ + "p28a", + 8.15 + ], + "P32R": [ + "p28a", + 8.4 + ], + "BE33": [ + "p28a", + 7.7 + ], + "RV7": [ + "p28a", + 6.2 + ], + "RV8": [ + "p28a", + 6.3 + ], + "GLAS": [ + "p28a", + 6.0 + ], + "PA18": [ + "p28a", + 6.9 + ], + "SR22": [ + "sr22", + 7.92 + ], + "SR20": [ + "sr22", + 7.92 + ], + "S22T": [ + "sr22", + 7.92 + ], + "COL4": [ + "sr22", + 7.7 + ], + "DA20": [ + "sr22", + 7.2 + ], + "PC12": [ + "pc12", + 14.4 + ], + "TBM7": [ + "pc12", + 10.7 + ], + "TBM8": [ + "pc12", + 10.7 + ], + "TBM9": [ + "pc12", + 10.7 + ], + "P46T": [ + "pc12", + 9.0 + ], + "BE20": [ + "be20", + 13.34 + ], + "BE9L": [ + "be20", + 10.82 + ], + "B350": [ + "be20", + 14.22 + ], + "BE30": [ + "be20", + 13.34 + ], + "PA31": [ + "be20", + 10.55 + ], + "BE58": [ + "be20", + 9.09 + ], + "PA44": [ + "be20", + 8.41 + ], + "DA42": [ + "be20", + 8.56 + ], + "PA34": [ + "be20", + 8.72 + ], + "C414": [ + "be20", + 11.1 + ], + "C421": [ + "be20", + 11.1 + ], + "C310": [ + "be20", + 9.7 + ], + "BE55": [ + "be20", + 8.5 + ], + "P180": [ + "be20", + 14.4 + ], + "TEX2": [ + "tex2", + 10.16 + ], + "PC21": [ + "tex2", + 11.2 + ], + "PC9": [ + "tex2", + 10.1 + ], + "PC7": [ + "tex2", + 9.8 + ], + "T6": [ + "tex2", + 8.8 + ], + "T38": [ + "t38", + 14.14 + ], + "HAWK": [ + "t38", + 12.43 + ], + "F16": [ + "t38", + 15.06 + ], + "F18": [ + "t38", + 17.1 + ], + "F18S": [ + "t38", + 18.31 + ], + "F35": [ + "t38", + 15.7 + ], + "F15": [ + "t38", + 19.43 + ], + "EUFI": [ + "t38", + 15.96 + ], + "F22": [ + "t38", + 18.9 + ], + "T45": [ + "t38", + 11.99 + ], + "M346": [ + "t38", + 11.49 + ], + "C30J": [ + "c30j", + 34.36 + ], + "C130": [ + "c30j", + 29.79 + ], + "A400": [ + "c30j", + 45.1 + ], + "C17": [ + "c17", + 53.04 + ], + "K35R": [ + "k35r", + 41.53 + ], + "E3TF": [ + "k35r", + 46.61 + ], + "E3CF": [ + "k35r", + 46.61 + ], + "B744": [ + "b744", + 70.66 + ], + "B748": [ + "b744", + 76.3 + ], + "B742": [ + "b744", + 70.66 + ], + "B74S": [ + "b744", + 56.3 + ], + "A388": [ + "a388", + 72.72 + ], + "B712": [ + "crj9", + 37.8 + ], + "MD82": [ + "crj9", + 45.0 + ], + "MD83": [ + "crj9", + 45.0 + ], + "MD88": [ + "crj9", + 45.0 + ], + "MD87": [ + "crj9", + 39.75 + ], + "A306": [ + "a333", + 54.08 + ], + "A310": [ + "a333", + 46.66 + ], + "T206": [ + "c172", + 8.61 + ], + "C82T": [ + "c172", + 8.84 + ], + "C82S": [ + "c172", + 8.84 + ], + "C82R": [ + "c172", + 8.84 + ], + "DV20": [ + "sr22", + 7.3 + ], + "PA32": [ + "p28a", + 8.2 + ], + "PA24": [ + "p28a", + 7.6 + ], + "AA5": [ + "p28a", + 6.7 + ], + "RV12": [ + "p28a", + 6.2 + ], + "EPIC": [ + "pc12", + 10.8 + ], + "C402": [ + "be20", + 11.1 + ], + "BE95": [ + "be20", + 7.8 + ], + "PA30": [ + "be20", + 7.7 + ], + "SF34": [ + "at72", + 19.73 + ], + "BE40": [ + "e55p", + 14.8 + ], + "GALX": [ + "cl35", + 18.97 + ], + "GA5C": [ + "p28a", + 6.7 + ], + "MD11": [ + "md11", + 61.6 + ], + "DC10": [ + "md11", + 55.5 + ], + "L101": [ + "md11", + 54.2 + ], + "A343": [ + "a343", + 63.69 + ], + "A346": [ + "a343", + 75.36 + ], + "A342": [ + "a343", + 59.4 + ], + "A345": [ + "a343", + 67.9 + ], + "GLID": [ + "glider", + 6.8 + ], + "GLIM": [ + "glider", + 7.0 + ], + "DRON": [ + "mq9", + 11.0 + ], + "Q9": [ + "mq9", + 11.0 + ], + "Q4": [ + "mq9", + 14.5 + ], + "Q1": [ + "mq9", + 8.2 + ], + "PIAT": [ + "ultralight", + 6.5 + ], + "PIVI": [ + "ultralight", + 6.5 + ], + "ULAC": [ + "ultralight", + 6.0 + ], + "E45X": [ + "crj9", + 29.87 + ], + "SB20": [ + "at72", + 27.28 + ], + "C500": [ + "c56x", + 13.26 + ], + "BE50": [ + "be20", + 9.6 + ], + "AC68": [ + "be20", + 13.1 + ], + "B190": [ + "be20", + 17.63 + ], + "FA6X": [ + "cl35", + 25.7 + ], + "FA20": [ + "cl35", + 17.15 + ], + "T204": [ + "b738", + 46.1 + ], + "GA7": [ + "be20", + 9.1 + ], + "C320": [ + "be20", + 10.0 + ], + "SLG4": [ + "glider", + 6.5 + ], + "SLG2": [ + "glider", + 6.5 + ], + "SF50": [ + "sr22", + 9.4 + ], + "T210": [ + "c172", + 8.6 + ], + "PA23": [ + "be20", + 8.8 + ], + "BE23": [ + "p28a", + 7.8 + ], + "RV6": [ + "p28a", + 6.1 + ], + "CH7B": [ + "ultralight", + 6.6 + ], + "BT7": [ + "tex2", + 9.6 + ], + "TB30": [ + "tex2", + 7.6 + ], + "IL62": [ + "md11", + 53.1 + ], + "BALL": [ + "balloon", + 17.0 + ], + "SHIP": [ + "blimp", + 75.0 + ], + "ZVEH": [ + "car", + 4.61 + ], + "GRND": [ + "radio_mast", + 1.9 + ] + }, + "helicopters": { + "R44": [ + "r44", + 9.07 + ], + "R22": [ + "r44", + 6.3 + ], + "R66": [ + "r44", + 9.2 + ], + "H500": [ + "r44", + 7.0 + ], + "H269": [ + "r44", + 6.8 + ], + "B06": [ + "b06", + 9.5 + ], + "B505": [ + "b06", + 9.6 + ], + "B206": [ + "b06", + 9.5 + ], + "B407": [ + "b407", + 10.6 + ], + "B212": [ + "b407", + 12.9 + ], + "B412": [ + "b407", + 12.9 + ], + "UH1": [ + "b407", + 12.8 + ], + "AS50": [ + "as50", + 10.93 + ], + "EC30": [ + "as50", + 10.68 + ], + "A119": [ + "as50", + 11.1 + ], + "AS55": [ + "as50", + 10.93 + ], + "EC35": [ + "ec35", + 10.2 + ], + "B429": [ + "ec35", + 11.3 + ], + "EC45": [ + "ec45", + 11.6 + ], + "B105": [ + "ec45", + 8.8 + ], + "A139": [ + "a139", + 13.77 + ], + "A169": [ + "a139", + 12.1 + ], + "A109": [ + "a139", + 11.4 + ], + "A189": [ + "a139", + 14.6 + ], + "S76": [ + "s76", + 13.2 + ], + "AS65": [ + "s76", + 11.6 + ], + "EC55": [ + "s76", + 12.1 + ], + "LYNX": [ + "s76", + 12.0 + ], + "EC75": [ + "s76", + 13.7 + ], + "H60": [ + "h60", + 15.26 + ], + "S92": [ + "h60", + 17.1 + ], + "EH10": [ + "h60", + 19.5 + ], + "NH90": [ + "h60", + 16.1 + ], + "H47": [ + "h47", + 15.46 + ], + "H64": [ + "h64", + 15.06 + ], + "H1": [ + "h64", + 13.6 + ], + "TIGR": [ + "h64", + 14.0 + ], + "V22": [ + "v22", + 17.48 + ], + "GYRO": [ + "r44", + 5.0 + ] + }, + "aircraft_callsign": { + "ZVEH": [ + { + "prefixes": [ + "FIRE" + ], + "model": "fire_truck", + "length": 11.01 + }, + { + "prefixes": [ + "PUSH", + "TUG" + ], + "model": "pushback", + "length": 6.0 + }, + { + "prefixes": [ + "DEICER", + "SQUID", + "SQB" + ], + "model": "van", + "length": 5.51 + } + ] + }, + "vessels": [ + { + "types": [ + 70, + 79 + ], + "typical_len": 300, + "rules": [ + { + "min_len": 70, + "max_len": 140, + "max_bl": 0.12, + "model": "inland_cargo" + }, + { + "name_re": "\\b(LEADER|HIGHWAY|ACE|GLOVIS|HOEGH|SPIRIT OF)\\b", + "min_len": 150, + "model": "car_carrier" + }, + { + "min_len": 250, + "model": "container_ship" + }, + { + "min_len": 160, + "max_bl": 0.145, + "model": "container_ship" + }, + { + "min_len": 160, + "model": "bulk_carrier" + }, + { + "min_len": 120, + "model": "feeder" + }, + { + "model": "general_cargo" + } + ] + }, + { + "types": [ + 80, + 89 + ], + "typical_len": 250, + "rules": [ + { + "name_re": "\\bLNG\\b|\\bGAS\\b", + "min_len": 150, + "model": "lng_carrier" + }, + { + "min_len": 70, + "max_len": 140, + "max_bl": 0.12, + "model": "inland_tanker" + }, + { + "min_len": 180, + "model": "tanker" + }, + { + "model": "product_tanker" + } + ] + }, + { + "types": [ + 60, + 69 + ], + "typical_len": 140, + "rules": [ + { + "min_len": 70, + "max_len": 140, + "max_bl": 0.12, + "model": "river_cruise" + }, + { + "min_len": 200, + "model": "cruise_ship" + }, + { + "min_len": 40, + "model": "ferry" + }, + { + "min_len": 15, + "model": "tour_boat" + }, + { + "model": "speedboat" + } + ] + }, + { + "types": [ + 37, + 37 + ], + "typical_len": 19, + "rules": [ + { + "min_len": 35, + "model": "superyacht" + }, + { + "min_len": 12, + "model": "motor_yacht" + }, + { + "model": "speedboat" + } + ] + }, + { + "types": [ + 36, + 36 + ], + "rules": [ + { + "model": "sailboat" + } + ] + }, + { + "types": [ + 30, + 30 + ], + "typical_len": 24, + "rules": [ + { + "min_len": 15, + "model": "trawler" + }, + { + "model": "fishing_boat" + } + ] + }, + { + "types": [ + 31, + 32 + ], + "rules": [ + { + "model": "tug" + } + ] + }, + { + "types": [ + 52, + 52 + ], + "rules": [ + { + "model": "tug" + } + ] + }, + { + "types": [ + 50, + 51 + ], + "rules": [ + { + "model": "pilot_boat" + } + ] + }, + { + "types": [ + 53, + 54 + ], + "rules": [ + { + "model": "pilot_boat" + } + ] + }, + { + "types": [ + 35, + 35 + ], + "rules": [ + { + "model": "patrol" + } + ] + }, + { + "types": [ + 55, + 55 + ], + "typical_len": 40, + "rules": [ + { + "min_len": 25, + "model": "patrol" + }, + { + "model": "pilot_boat" + } + ] + }, + { + "types": [ + 40, + 49 + ], + "typical_len": 40, + "rules": [ + { + "min_len": 20, + "model": "fast_ferry" + }, + { + "model": "speedboat" + } + ] + }, + { + "types": [ + 33, + 33 + ], + "typical_len": 100, + "rules": [ + { + "min_len": 50, + "model": "dredger" + }, + { + "model": "tug" + } + ] + }, + { + "types": [ + 0, + 99 + ], + "rules": [ + { + "min_len": 70, + "max_len": 140, + "max_bl": 0.12, + "model": "inland_cargo" + }, + { + "model": "generic_vessel" + } + ] + } + ], + "categories": { + "A1": [ + "c172", + 8.3 + ], + "A2": [ + "be20", + 14.0 + ], + "A3": [ + "a320", + 37.6 + ], + "A4": [ + "b738", + 47.3 + ], + "A5": [ + "b77w", + 70.0 + ], + "A6": [ + "t38", + 15.0 + ], + "A7": [ + "generic_heli", + 11.0 + ], + "B1": [ + "glider", + 6.8 + ], + "B2": [ + "balloon", + 17.0 + ], + "B3": [ + "ultralight", + 5.0 + ], + "B4": [ + "ultralight", + 6.5 + ], + "B6": [ + "mq9", + 11.0 + ], + "B7": [ + "ultralight", + 4.0 + ], + "C1": [ + "fire_truck", + 11.0 + ], + "C2": [ + "van", + 5.5 + ], + "C3": [ + "tower", + 60.0 + ], + "C4": [ + "tower", + 60.0 + ], + "C5": [ + "tower", + 60.0 + ], + "A0": [ + "generic_plane", + 36.0 + ] + }, + "default_aircraft": [ + "generic_plane", + 36.0 + ], + "default_vessel": "generic_vessel", + "transit": { + "modes": { + "bus": [ + "city_bus", + 12.0 + ], + "articulated_bus": [ + "articulated_bus", + 18.0 + ], + "double_decker": [ + "double_decker", + 11.0 + ], + "trolleybus": [ + "trolleybus", + 12.0 + ], + "coach": [ + "coach", + 13.0 + ], + "tram": [ + "tram", + 30.0 + ], + "light_rail": [ + "light_rail", + 28.2 + ], + "cable_tram": [ + "cable_tram", + 8.4 + ], + "subway": [ + "metro", + 115.0 + ], + "metro": [ + "metro", + 115.0 + ], + "monorail": [ + "monorail", + 55.5 + ], + "rail": [ + "commuter_emu", + 98.1 + ], + "commuter": [ + "commuter_emu", + 98.1 + ], + "regional": [ + "regional_dmu", + 49.5 + ], + "intercity": [ + "intercity", + 182.8 + ], + "high_speed": [ + "high_speed", + 206.2 + ], + "ferry": [ + "fast_ferry", + 40.0 + ], + "aerial_lift": [ + "gondola", + 2.1 + ], + "gondola": [ + "gondola", + 2.1 + ], + "funicular": [ + "funicular", + 14.0 + ] + }, + "route_types": { + "0": [ + "tram", + 30.0 + ], + "1": [ + "metro", + 115.0 + ], + "2": [ + "commuter_emu", + 98.1 + ], + "3": [ + "city_bus", + 12.0 + ], + "4": [ + "fast_ferry", + 40.0 + ], + "5": [ + "cable_tram", + 8.4 + ], + "6": [ + "gondola", + 2.1 + ], + "7": [ + "funicular", + 14.0 + ], + "11": [ + "trolleybus", + 12.0 + ], + "12": [ + "monorail", + 55.5 + ], + "100": [ + "intercity", + 182.8 + ], + "101": [ + "high_speed", + 206.2 + ], + "102": [ + "intercity", + 182.8 + ], + "103": [ + "intercity", + 182.8 + ], + "104": [ + "intercity", + 182.8 + ], + "105": [ + "intercity", + 182.8 + ], + "106": [ + "regional_dmu", + 49.5 + ], + "107": [ + "regional_dmu", + 49.5 + ], + "108": [ + "metro", + 115.0 + ], + "109": [ + "commuter_emu", + 98.1 + ], + "110": [ + "regional_dmu", + 49.5 + ], + "111": [ + "intercity", + 182.8 + ], + "112": [ + "intercity", + 182.8 + ], + "113": [ + "intercity", + 182.8 + ], + "114": [ + "intercity", + 182.8 + ], + "115": [ + "intercity", + 182.8 + ], + "116": [ + "regional_dmu", + 49.5 + ], + "117": [ + "intercity", + 182.8 + ], + "200": [ + "coach", + 13.0 + ], + "201": [ + "coach", + 13.0 + ], + "202": [ + "coach", + 13.0 + ], + "203": [ + "coach", + 13.0 + ], + "204": [ + "coach", + 13.0 + ], + "205": [ + "coach", + 13.0 + ], + "206": [ + "coach", + 13.0 + ], + "207": [ + "coach", + 13.0 + ], + "208": [ + "coach", + 13.0 + ], + "209": [ + "coach", + 13.0 + ], + "400": [ + "metro", + 115.0 + ], + "401": [ + "metro", + 115.0 + ], + "402": [ + "metro", + 115.0 + ], + "403": [ + "metro", + 115.0 + ], + "404": [ + "metro", + 115.0 + ], + "405": [ + "monorail", + 55.5 + ], + "700": [ + "city_bus", + 12.0 + ], + "701": [ + "coach", + 13.0 + ], + "702": [ + "coach", + 13.0 + ], + "703": [ + "city_bus", + 12.0 + ], + "704": [ + "city_bus", + 12.0 + ], + "705": [ + "city_bus", + 12.0 + ], + "706": [ + "coach", + 13.0 + ], + "707": [ + "city_bus", + 12.0 + ], + "708": [ + "city_bus", + 12.0 + ], + "709": [ + "city_bus", + 12.0 + ], + "710": [ + "double_decker", + 11.0 + ], + "711": [ + "city_bus", + 12.0 + ], + "712": [ + "city_bus", + 12.0 + ], + "713": [ + "city_bus", + 12.0 + ], + "714": [ + "coach", + 13.0 + ], + "715": [ + "van", + 5.5 + ], + "716": [ + "city_bus", + 12.0 + ], + "800": [ + "trolleybus", + 12.0 + ], + "900": [ + "tram", + 30.0 + ], + "901": [ + "tram", + 30.0 + ], + "902": [ + "tram", + 30.0 + ], + "903": [ + "light_rail", + 28.2 + ], + "904": [ + "tram", + 30.0 + ], + "905": [ + "tram", + 30.0 + ], + "906": [ + "tram", + 30.0 + ], + "1000": [ + "fast_ferry", + 40.0 + ], + "1200": [ + "fast_ferry", + 40.0 + ], + "1300": [ + "gondola", + 2.1 + ], + "1301": [ + "gondola", + 2.1 + ], + "1302": [ + "gondola", + 2.1 + ], + "1306": [ + "gondola", + 2.1 + ], + "1307": [ + "gondola", + 2.1 + ], + "1400": [ + "funicular", + 14.0 + ], + "1500": [ + "car", + 4.6 + ], + "1501": [ + "van", + 5.5 + ], + "1502": [ + "car", + 4.6 + ], + "1503": [ + "car", + 4.6 + ], + "1504": [ + "car", + 4.6 + ], + "1505": [ + "car", + 4.6 + ], + "1506": [ + "car", + 4.6 + ], + "1507": [ + "car", + 4.6 + ] + }, + "rail": { + "transit": { + "rail": [ + "commuter_emu", + 98.1 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "tram", + 30.0 + ] + }, + "local": { + "rail": [ + "commuter_emu", + 98.1 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "tram", + 30.0 + ] + }, + "regional": { + "rail": [ + "regional_dmu", + 49.5 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "light_rail", + 28.2 + ] + }, + "national": { + "rail": [ + "intercity", + 182.8 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "light_rail", + 28.2 + ] + }, + "international": { + "rail": [ + "high_speed", + 206.2 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "light_rail", + 28.2 + ] + } + }, + "default": [ + "city_bus", + 12.0 + ], + "elevation": { + "monorail": 6.0, + "gondola": 20.0 + } + } +} \ No newline at end of file diff --git a/public/models/jet.glb b/public/models/jet.glb deleted file mode 100644 index bb2f99f9f..000000000 Binary files a/public/models/jet.glb and /dev/null differ diff --git a/public/models/mq9.glb b/public/models/mq9.glb deleted file mode 100644 index 47bc21f91..000000000 Binary files a/public/models/mq9.glb and /dev/null differ diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 310e38473..edc29cf9d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -557,6 +557,13 @@ "src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.test.mjs", "src/ui/frameRateView.test.mjs", + "src/ui/renderFailure.test.mjs", + "src/ui/renderFailureView.test.mjs", + "src/ui/renderFailureLoopErrors.test.mjs", + "src/ui/renderFailureEventRetry.test.mjs", + "src/app/renderFailureInlineRetry.test.mjs", + "src/ui/renderFailureSecondLoop.test.mjs", + "src/ui/renderLoopFixture.mjs", "src/layers/cctv/lifecycle.js", "src/layers/cctv/controls.js", "src/layers/cctv/geometry.js", @@ -1438,5 +1445,19 @@ "src/ui/ownerFlagSheet.js", "src/data/placePacksLoading.test.mjs", "src/ui/placePacksLoading.js", - "src/ui/placePacksLoading.test.mjs" + "src/ui/placePacksLoading.test.mjs", + "src/cockpitSubjectProviders.js", + "src/cockpitSubjectProviders.test.mjs", + "src/cockpitProgress.js", + "src/cockpitProgress.test.mjs", + "src/ui/cockpitSubjectView.test.mjs", + "src/ui/styles/cockpitSubject.css", + "src/layers/rail/cockpitSubject.js", + "src/layers/rail/cockpitSubject.test.mjs", + "src/layers/transit/cockpitSubject.js", + "src/data/fleetModels.test.mjs", + "src/data/fleetModelInstances.test.mjs", + "src/testSupport/fleetGlb.mjs", + "src/testSupport/fleetModelFakes.mjs", + "scripts/measure-fleet-trail-anchors.mjs" ] diff --git a/scripts/measure-fleet-trail-anchors.mjs b/scripts/measure-fleet-trail-anchors.mjs new file mode 100644 index 000000000..c26874dc7 --- /dev/null +++ b/scripts/measure-fleet-trail-anchors.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * Re-measure the trail attachment point of every aircraft-layer fleet model + * and print `src/data/fleetTrailAnchors.js`. + * + * node scripts/measure-fleet-trail-anchors.mjs > src/data/fleetTrailAnchors.js + * + * Run it whenever a model under `public/models/fleet/` changes; the unit + * suite (`src/data/fleetModels.test.mjs`) re-derives each value and fails + * on a table that no longer matches its GLB. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { projectRoot } from './project-root.mjs'; +import { + fleetMesh, + measuredTrailAnchor, +} from '../src/testSupport/fleetGlb.mjs'; + +/** Catalog categories the aircraft layers draw (ADS-B ground vehicles and balloons included). */ +export const TRAIL_ANCHOR_CATEGORIES = ['aircraft', 'helicopters', 'other']; + +const root = projectRoot(import.meta.url); +const dir = path.join(root, 'public', 'models', 'fleet'); +const catalog = JSON.parse( + fs.readFileSync(path.join(dir, 'catalog.json'), 'utf8'), +); +const rows = []; +for (const [name, model] of Object.entries(catalog)) { + if (!TRAIL_ANCHOR_CATEGORIES.includes(model.category)) continue; + const anchor = measuredTrailAnchor( + await fleetMesh(path.join(dir, model.file)), + ); + if (!anchor) continue; + const [x, y] = anchor.map((value) => Number(value.toFixed(4)) || 0); + rows.push( + ` ${/^[a-z_]\w*$/.test(name) ? name : `'${name}'`}: Object.freeze([${x}, ${y}, 0]),`, + ); +} +process.stdout.write(`/** + * Aft-belly trail attachment points of the fleet models the aircraft layers + * draw, in raw glTF axes (X along the body, nose toward -X; Y up), metres + * before scale, node transforms applied. Each is the point of the model's + * centreline profile (its z = 0 slice) nearest its aft-belly bounding-box + * corner: on the hull, aft, and on the centreline (see modelVisualAnchor.js). + * + * Written by scripts/measure-fleet-trail-anchors.mjs; re-measured against the + * GLBs by fleetModels.test.mjs. + */ +export const FLEET_TRAIL_ANCHORS = Object.freeze({ +${rows.join('\n')} +}); +`); diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 57e1dd0a5..d3555c40c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -22,6 +22,9 @@ "src/ui/vueRuntime.js", "src/ui/views/index.js", "src/ui/views/CockpitView.vue", + "src/ui/views/panels/CockpitProgress.vue", + "src/cockpitProgress.js", + "src/cockpitSubjectProviders.js", "src/ui/views/CommandDockView.vue", "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", @@ -163,6 +166,7 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", "src/ui/views/panels/GlobeActions.vue", + "src/ui/views/panels/RenderFailure.vue", "src/ui/views/panels/GlobeCompass.vue", "src/ui/stores/recording.js", "src/ui/styleParameters.js", @@ -1009,6 +1013,8 @@ "src/ui/cockpitInput.js", "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -1174,6 +1180,8 @@ "src/ui/cockpitInput.js", "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -1356,6 +1364,7 @@ "src/ui/styles/status.css", "src/ui/styles/cockpit.css", "src/ui/styles/cockpitBridge.css", + "src/ui/styles/cockpitSubject.css", "src/ui/styles/cockpitRadar.css", "src/ui/styles/overlays.css", "src/ui/styles/layers.css", @@ -1504,6 +1513,9 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", @@ -1578,6 +1590,11 @@ "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/layers/flights/modelSpec.js", + "src/layers/flights/policy.js", + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", @@ -1677,6 +1694,13 @@ "vessel-layer": { "exports": ["./layers/vessels"], "modules": [ + "src/data/fleetModels.js", + "src/data/aircraftClass.js", + "src/i18n/marker.js", + "src/data/fleetModelInstances.js", + "src/data/aircraftModelLighting.js", + "src/data/modelEligibility.js", + "src/data/fleetTrailAnchors.js", "src/data/headingDegrees.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -2545,6 +2569,10 @@ "./standalone/catalog" ], "modules": [ + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", + "src/app/layers/fleetModels.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -3096,6 +3124,8 @@ "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -3113,6 +3143,7 @@ "src/ui/displayControls.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", + "src/ui/renderFailure.js", "src/ui/layerPanel.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", @@ -3283,6 +3314,7 @@ "src/app/layers/rail.js", "src/data/railFeeds.js", "src/layers/rail/index.js", + "src/layers/rail/cockpitSubject.js", "src/layers/rail/source.js", "src/layers/rail/policy.js", "src/layers/rail/ingestion.js", @@ -3313,6 +3345,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -3817,6 +3850,10 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", + "src/app/layers/fleetModels.js", "src/data/feedState.js", "src/loadingFeedback.js", "src/sources/adsbFields.js", @@ -4273,6 +4310,7 @@ "src/app/layers/rail.js", "src/data/railFeeds.js", "src/layers/rail/index.js", + "src/layers/rail/cockpitSubject.js", "src/layers/rail/source.js", "src/layers/rail/policy.js", "src/layers/rail/ingestion.js", @@ -4302,6 +4340,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -4788,7 +4827,9 @@ "src/data/approxDistance.js", "src/data/renderAltitude.js", "src/data/aircraftClass.js", - "src/data/aircraftEmergency.js" + "src/data/aircraftEmergency.js", + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js" ], "external": [] }, @@ -5185,6 +5226,13 @@ "transit-standalone": { "exports": ["./layers/transit/standalone"], "modules": [ + "src/data/fleetModels.js", + "src/data/aircraftClass.js", + "src/i18n/marker.js", + "src/data/fleetModelInstances.js", + "src/data/modelEligibility.js", + "src/data/fleetTrailAnchors.js", + "src/data/aircraftModelLighting.js", "src/data/headingDegrees.js", "src/cyberSonar.js", "src/celestialRing.js", @@ -5220,6 +5268,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/movement.js", @@ -5274,6 +5323,13 @@ "transit-layer": { "exports": ["./layers/transit"], "modules": [ + "src/i18n/marker.js", + "src/data/fleetModels.js", + "src/data/fleetModelInstances.js", + "src/data/aircraftClass.js", + "src/data/aircraftModelLighting.js", + "src/data/fleetTrailAnchors.js", + "src/data/modelEligibility.js", "src/data/headingDegrees.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -5289,6 +5345,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -5929,5 +5986,10 @@ "src/layers/wildlifeSightings/performance.js" ], "external": [] + }, + "rail-paths": { + "exports": ["./layers/rail/paths"], + "modules": ["src/data/railPaths.js"], + "external": [] } } diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index c2d2b5646..b16a793df 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -339,6 +339,45 @@ try { '#cctv-frame', (image) => !image.getAttribute('src') && image.dataset.cameraId === '', ); + // The address bar follows the share state after a short delay: wait for the + // CCTV panel token (1 collapsed, 0 open) the step expects. + const panelState = async (expectedToken) => { + const settled = await page + .waitForFunction( + (token) => + new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test( + new URLSearchParams(location.hash.slice(1)).get('ui') || '', + ), + { timeout: 5000 }, + expectedToken, + ) + .then( + () => true, + () => false, + ); + return page.evaluate( + (shared) => ({ + collapsed: document + .getElementById('cctv-panel') + .classList.contains('collapsed'), + shared, + ui: new URLSearchParams(location.hash.slice(1)).get('ui'), + }), + settled ? expectedToken : null, + ); + }; + const closedOnDisable = await panelState('1'); + check( + 'disable collapses the panel and its share preference', + closedOnDisable.collapsed && closedOnDisable.shared === '1', + JSON.stringify(closedOnDisable), + ); + // The switch sits inside the collapsed panel: reopen it from the right rail. + await page.click('[data-rail-item="cctv-panel"]'); + await page.waitForFunction( + () => + !document.getElementById('cctv-panel').classList.contains('collapsed'), + ); await page.click('#cctv-enable-btn'); await page.waitForFunction( () => @@ -351,6 +390,42 @@ try { 'disable clears the preview and re-enable reacquires the camera frame', disabledCleanly, ); + // Contacts switches CCTV off to isolate itself and restores it on the way + // out: the open panel and its share preference survive the round trip. + const cameraBeforeContacts = await page.evaluate( + () => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId, + ); + const entered = await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode('flights'), + ); + await page.waitForFunction( + () => !window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const duringContacts = await panelState('0'); + await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode(null), + ); + await page.waitForFunction(() => + window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const afterContacts = await panelState('0'); + check( + 'Contacts isolation keeps the open CCTV panel and restores its camera', + entered?.ok === true && + !duringContacts.collapsed && + duringContacts.shared === '0' && + !afterContacts.collapsed && + afterContacts.shared === '0' && + (await page.evaluate( + (cameraId) => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId === cameraId, + cameraBeforeContacts, + )), + JSON.stringify({ entered, duringContacts, afterContacts }), + ); await page.$eval('#cctv-frame', (image) => image.scrollIntoView({ block: 'nearest' }), ); diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index fb33654b1..b9151bbbd 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -40,7 +40,7 @@ * the DISPLAYED (render-delayed) time within 35° — catches * sign/offset inversion in the whole chain (chord lags the * tangent by ≤ half a segment ≈ 22.5°) - * Both layers: flights (airplane.glb, offset 180°) then military (jet.glb, + * Both layers: flights (fleet models, offset 180°) then military (fleet models, * normalized nose -X, offset 180°). * * VISUAL — with the turning flights plane tracked in 3D, capture screenshots @@ -721,7 +721,7 @@ async function main() { // ======================================================================== // Phase 1 — FLIGHTS layer: track TRN001, sample displayed course 30 s // ======================================================================== - console.log('\nPhase 1 — flights layer (airplane.glb, heading offset 180°)'); + console.log('\nPhase 1 — flights layer (fleet models, heading offset 180°)'); await page.evaluate((icao) => { window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao); }, TURN.flights[0].icao); @@ -807,7 +807,7 @@ async function main() { // ======================================================================== // Phase 2 — MILITARY layer: track TRNMIL1 (flights layer auto-clears) // ======================================================================== - console.log('\nPhase 2 — military layer (jet.glb, heading offset 180°)'); + console.log('\nPhase 2 — military layer (fleet models, heading offset 180°)'); await page.evaluate((hex) => { window.__godsEyeView.dataManager.layers.get('military').module.trackById(hex); }, TURN.military[0].hex); diff --git a/scripts/qa-sky-model-lighting.mjs b/scripts/qa-sky-model-lighting.mjs index 2f401506b..206d19d78 100644 --- a/scripts/qa-sky-model-lighting.mjs +++ b/scripts/qa-sky-model-lighting.mjs @@ -75,7 +75,7 @@ try { } const position = C.Cartesian3.fromDegrees(0, 0, 10000); const model = await C.Model.fromGltfAsync({ - url: '/models/airplane.glb', + url: '/models/fleet/aircraft/a320.glb', modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position), }); scene.primitives.add(model); diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index 7e5b824c0..190a0c651 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -15,9 +15,9 @@ * per-class glyph (image data-URI === aircraftIcon(klass), * imported from src) and per-class scale (flights: * CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D) - * B3 models : with 3D on, fleet Cesium.Model.scale === the layer's - * MODEL_SCALE (both 1; legacy scale is baked into the GLB) - * × CLASS_SCALE_3D[klass] per icao; tracked too + * B3 models : with 3D on, fleet Cesium.Model.scale === the life-size + * scale of the fleet model the aircraft's type or category + * selects (aircraftModelSpec) per icao; tracked too * B4 clean : no console errors * * Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows, @@ -33,15 +33,18 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; import { stubPlanespotters } from './qa-photo-stub.mjs'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; -// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military -// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only -// fastjet/unknown keep the per-layer jet/airplane MODEL_SCALE formula. -const flightsWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 : FL_MODEL_SCALE * CLASS_SCALE_3D[klass]); -const MIL_PLANE_CLASSES = new Set(['airliner', 'quadjet', 'glider']); -const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 - : MIL_PLANE_CLASSES.has(klass) ? MIL_PLANE_MODEL_SCALE * CLASS_SCALE_3D[klass] - : MIL_MODEL_SCALE * CLASS_SCALE_3D[klass]); +import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js'; +import { aircraftModelSpec } from '../src/data/fleetModels.js'; +// Every aircraft draws its fleet model life size: its type's length over the +// model's built length, chosen by type code (military) or category (flights). +const FLEET_SET = Object.freeze({ + catalog: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/catalog.json', import.meta.url), 'utf8')), + types: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/types.json', import.meta.url), 'utf8')), +}); +const flightsWantScale = (icao) => + aircraftModelSpec(FLEET_SET, { category: SPRITES.flights.find((p) => p.icao === icao)?.category }).scale; +const militaryWantScale = (hex) => + aircraftModelSpec(FLEET_SET, { typeCode: SPRITES.military.find((p) => p.hex === hex)?.t }).scale; import { aircraftIcon } from '../src/data/aircraftIcons.js'; // --------------------------------------------------------------------------- @@ -58,9 +61,6 @@ const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = getFlag('--headful'); const SHOT_DIR = path.resolve('qa-shots/b5'); -const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale) -const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb -const MIL_MODEL_SCALE = 1; // militaryFlights.js (jet.glb is already real-world scale, native radius ~29.8 m) const MIL_BILLBOARD_SCALE = 0.7; // militaryFlights BILLBOARD_SCALE const CHROME_EXECUTABLE_CANDIDATES = [ @@ -134,10 +134,9 @@ const SPRITES = { // course 0 like the rest — keeps the row a clean size-comparison line (2D // rotation is demoed by caa004 and gated by qa-heading-b3 anyway). { hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 }, - // 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a - // real-world-scale asset (native bounding radius ~29.8) rendered at - // MODEL_SCALE 1, so models are ~22–43 m radius — same 150 m spacing and - // 450–900 m shot ranges as the flights cluster. + // 3D cluster: C17 vs F16 (tracked) vs B744 vs C130, each drawn life size + // (~8–36 m radius) — same 150 m spacing and 450–900 m shot ranges as the + // flights cluster. { hex: 'ddb001', flight: '3DMWID', t: 'C17', lon0: CLUSTER_LON0 - mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, { hex: 'ddb002', flight: '3DMFJ', t: 'F16', lon0: CLUSTER_LON0, lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, { hex: 'ddb003', flight: '3DMQUA', t: 'B744', lon0: CLUSTER_LON0 + mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, @@ -464,7 +463,7 @@ async function main() { // ======================================================================== // B3 + 3D screenshots — flights cluster // ======================================================================== - console.log('\n3D — flights cluster (airplane.glb, per-class scale)'); + console.log('\n3D — flights cluster (fleet models, life size)'); await page.evaluate(() => { const dm = window.__godsEyeView.dataManager; dm.layers.get('flights').module.setParams({ models3d: true }); @@ -481,20 +480,20 @@ async function main() { const flModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready)); const modelScaleBad = []; for (const [id, want] of [ - ['cbb001', flightsWantScale(expectedFlights.get('cbb001'))], - ['cbb003', flightsWantScale(expectedFlights.get('cbb003'))], + ['cbb001', flightsWantScale('cbb001')], + ['cbb003', flightsWantScale('cbb003')], ]) { const m = flModels.find((x) => x.id === id); if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`); } const trackedM = flModels.find((x) => x.id === 'cbb002'); - const trackedWant = flightsWantScale(expectedFlights.get('cbb002')); + const trackedWant = flightsWantScale('cbb002'); if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) { modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`); } - record('B3a flights: model scales match the per-class registry (all GLBs meter-scale; shared @1×class)', + record('B3a flights: model scales match each type's life-size fleet model', modelScaleBad.length === 0, - modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry'); + modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact'); console.log(` world bounding radii (m): ${flModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`); await sleep(1500); @@ -509,7 +508,7 @@ async function main() { // ======================================================================== // B3 + 3D screenshots — military cluster // ======================================================================== - console.log('\n3D — military cluster (jet.glb, per-class scale)'); + console.log('\n3D — military cluster (fleet models, life size)'); await page.evaluate(() => { const dm = window.__godsEyeView.dataManager; dm.layers.get('military').module.setParams({ models3d: true }); @@ -526,25 +525,25 @@ async function main() { const milModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready)); const modelScaleBad = []; for (const [id, want] of [ - ['ddb001', militaryWantScale(expectedMilitary.get('ddb001'))], - ['ddb003', militaryWantScale(expectedMilitary.get('ddb003'))], - ['ddb004', militaryWantScale(expectedMilitary.get('ddb004'))], + ['ddb001', militaryWantScale('ddb001')], + ['ddb003', militaryWantScale('ddb003')], + ['ddb004', militaryWantScale('ddb004')], ]) { const m = milModels.find((x) => x.id === id); if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`); } const trackedM = milModels.find((x) => x.id === 'ddb002'); - const trackedWant = militaryWantScale(expectedMilitary.get('ddb002')); + const trackedWant = militaryWantScale('ddb002'); if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) { modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`); } - record('B3b military: model scales match the weight-class registry (real/heavy/jet assets @1×class)', + record('B3b military: model scales match each type's life-size fleet model', modelScaleBad.length === 0, - modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry'); + modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact'); console.log(` world bounding radii (m): ${milModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`); await sleep(1500); - // jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the + // Fleet models are drawn life size — same shot ranges as the // flights cluster (close zoom no longer puts the camera inside a model). await zoomToRange(900); await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') }); diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index f87250430..10d3e3979 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -97,9 +97,15 @@ import fs from 'node:fs'; import puppeteer from 'puppeteer'; import { respondToPlanespotters } from './qa-photo-stub.mjs'; import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs'; -import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; +import { aircraftModelSpec } from '../src/data/fleetModels.js'; import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js'; +/** The fleet model set the aircraft layers draw from, read from disk. */ +const FLEET_SET = Object.freeze({ + catalog: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/catalog.json', import.meta.url), 'utf8')), + types: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/types.json', import.meta.url), 'utf8')), +}); + // --------------------------------------------------------------------------- // Args // --------------------------------------------------------------------------- @@ -547,13 +553,13 @@ async function main() { // Independent GLB capability control. Product model absence cannot prove // that the browser backend is incapable: that is the condition this - // harness is meant to catch. Load the already-approved airplane asset + // harness is meant to catch. Load one of the shipped fleet models // directly through Cesium, outside the Flights/Military model pipelines, // then remove the control before exercising product behavior. const glbControlStarted = await evalPage(async () => { window.__qaGlbControl = null; window.__qaGlbControlError = null; - const asset = await fetch('/models/airplane.glb'); + const asset = await fetch('/models/fleet/aircraft/a320.glb'); const assetBytes = asset.ok ? (await asset.arrayBuffer()).byteLength : 0; if (!asset.ok || assetBytes === 0) { throw new Error(`GLB control asset unavailable: HTTP ${asset.status}`); @@ -564,7 +570,7 @@ async function main() { Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 9000), ); void Cesium.Model.fromGltfAsync({ - url: '/models/airplane.glb', + url: '/models/fleet/aircraft/a320.glb', modelMatrix, scale: 1, }).then((model) => { @@ -1454,8 +1460,8 @@ async function main() { }, trackedIcao); console.log(' (3D model did not render under SwiftShader; testing the shared display-position contract directly)'); } else { - // Full check: independently transform airplane.glb's measured AABB centre - // through the actual 3D model matrix, then compare it with detection. + // Full check: independently transform the fleet model's visual centre + // (its origin) through the actual 3D model matrix, then compare it with detection. // glTF [x,y,z] → Cesium-local [x,-z,y]. The effective rendered // computedScale is separate from modelMatrix and includes any // minimumPixelSize enlargement, so include it just as the renderer does. @@ -1469,7 +1475,7 @@ async function main() { const scale = Number.isFinite(model.computedScale) ? model.computedScale : (Number.isFinite(model.scale) ? model.scale : 1); - // airplane.glb is now bounding-box centred with transforms applied. + // Fleet models are drawn about their origin, transforms applied. const x = 0 * scale; const y = 0 * scale; const z = 0 * scale; @@ -2229,18 +2235,12 @@ async function main() { // ============================================================ console.log('\nGround 3D — grounded planes are model-eligible + snapped to the sampled skin'); const GROUND3D_STUB_H = 187.5; // stubbed tile-skin height (m, ellipsoid) - // Expected belly offsets (m): native origin-above-lowest-vertex × MODEL_SCALE - // × CLASS_SCALE_3D[klass] — constants match the layers' MODEL_BELLY_OFFSET_NATIVE - // / MODEL_SCALE pairs, which modelScale.test.mjs locks against the GLBs. - // Class is computed with the REAL classifier on the synthetic feed fields. - const g3dFlKlass = classifyAircraft({ typeCode: null, category: null }); - const g3dMilKlass = classifyAircraft({ typeCode: 'C130', category: undefined }); - const g3dFlOffset = 6.719 * 1 * (CLASS_SCALE_3D[g3dFlKlass] || 1); // airplane.glb, flights - // Hangar fleet (2026-08-16): military C130 → turboprop → the real atr72.glb, - // whose belly offset is the registry's measured bellyM (meters, scale 1) — - // NOT the jet.glb 5.631 × class formula. Pinned by modelScale.test.mjs. - const g3dMilOffset = CLASS_MODEL_REAL[g3dMilKlass]?.bellyM - ?? 5.631 * 1 * (CLASS_SCALE_3D[g3dMilKlass] || 1); // jet.glb fallback classes + // Expected belly offsets (m): the fleet model's wheel clearance at its + // life-size scale, the model chosen as the layers choose it + // (aircraftModelSpec) from the synthetic feed's fields: no type for the + // flights contact (the default airliner), a C130 for the military one. + const g3dFlOffset = aircraftModelSpec(FLEET_SET, { typeCode: null }).clearanceM; + const g3dMilOffset = aircraftModelSpec(FLEET_SET, { typeCode: 'C130' }).clearanceM; // Height-datum fix (Task 6): the flights-layer billboard for a grounded synthetic // (no geo_altitude in this stub feed, no warm terrain-height cache in a headless // run) now renders at baroM + geoidHeight(lat, lon) rather than the raw baro @@ -2509,10 +2509,10 @@ async function main() { const flDelta = g3dState.flModelRadius - g3dIngest.flBBRadius; const milDelta = g3dState.milModelRadius - g3dIngest.milBBRadius; const HEIGHT_TOL_M = 1.0; - record(`ground-3d: flights model height = sampled skin + belly offset (${g3dFlKlass})`, + record(`ground-3d: flights model height = sampled skin + belly offset (default airliner)`, Math.abs(flDelta - flWantDelta) <= HEIGHT_TOL_M, `Δradial=${flDelta.toFixed(2)} m want=${flWantDelta.toFixed(2)} m (stub ${GROUND3D_STUB_H} + offset ${g3dFlOffset.toFixed(2)} − alt ${(150 + g3dFlGeoidN).toFixed(2)} [150 baro + ${g3dFlGeoidN.toFixed(2)} geoidN])`); - record(`ground-3d: military model height = sampled skin + belly offset (${g3dMilKlass})`, + record(`ground-3d: military model height = sampled skin + belly offset (C130)`, Math.abs(milDelta - milWantDelta) <= HEIGHT_TOL_M, `Δradial=${milDelta.toFixed(2)} m want=${milWantDelta.toFixed(2)} m (stub ${GROUND3D_STUB_H} + offset ${g3dMilOffset.toFixed(2)} − alt 0)`); record('ground-3d: billboard→model handoff holds on the ground (icons hidden once models render)', @@ -2712,7 +2712,7 @@ async function main() { const scale = Number.isFinite(m.computedScale) ? m.computedScale : (Number.isFinite(m.scale) ? m.scale : 1); - // airplane.glb is now bounding-box centred with transforms applied. + // Fleet models are drawn about their origin, transforms applied. const x = 0 * scale; const y = 0 * scale; const z = 0 * scale; @@ -3722,8 +3722,8 @@ async function main() { // WARM ground, camera IN: the placement. The model is not merely visible, // it is standing ON the floor — within one class belly offset above it, - // never under it. MAX_BELLY_M clears the largest offset any class adds - // (widebody b789, 7.81 m in CLASS_MODEL_REAL); the floor itself is the + // never under it. MAX_BELLY_M clears the largest wheel clearance any + // fleet aircraft stands on at life size; the floor itself is the // hard lower bound, and a model placed at the raw feed altitude is ~240 m // under it. const MAX_BELLY_M = 12; diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 71cefceed..c18a43d85 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -57,8 +57,13 @@ export function ingestAisStreamEnvelope(envelope) { message.ReportB?.Valid === false ? undefined : message.ReportB?.ShipType, + Dimension: + message.ReportB?.Valid === false + ? undefined + : message.ReportB?.Dimension, } : message; + const dimensions = aisDimensions(fields.Dimension); // Static reports may be partial (including Class B's separate A/B parts). // Keep known values in the cache as well as in the current live row. const staticData = { @@ -84,6 +89,8 @@ export function ingestAisStreamEnvelope(envelope) { staticCode(live?.imo) || staticCode(previous.imo) || '', + length: dimensions.length ?? live?.length ?? previous.length ?? null, + beam: dimensions.beam ?? live?.beam ?? previous.beam ?? null, }; _aisStreamStatic.set(mmsi, staticData); mergeAisStaticIntoLiveVessel(mmsi, staticData); @@ -113,6 +120,8 @@ export function ingestAisStreamEnvelope(envelope) { imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), type: vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), + length: staticData.length ?? null, + beam: staticData.beam ?? null, speed: normalizedSpeedOverGround(message.Sog ?? message.SOG), course: normalizedCourseOverGround(message.Cog ?? message.COG), heading: normalizedHeading(message.TrueHeading ?? message.Heading), @@ -236,6 +245,25 @@ function mergeAisStaticIntoLiveVessel(mmsi, staticData) { if (staticData.type) existing.type = staticData.type; if (staticData.destination) existing.destination = staticData.destination; if (staticData.imo) existing.imo = staticData.imo; + if (staticData.length) existing.length = staticData.length; + if (staticData.beam) existing.beam = staticData.beam; +} + +/** + * A static report's hull size from its AIS dimensions: the antenna's + * distances to bow (A), stern (B), port (C) and starboard (D), in metres. + * Zero is AIS's "not available", so a side missing its two parts has none. + * @param {{A?: number, B?: number, C?: number, D?: number}|undefined} dimension + * @returns {{length: number|null, beam: number|null}} + */ +function aisDimensions(dimension) { + const part = (value) => { + const number = numberValue(value); + return number !== null && number > 0 ? number : 0; + }; + const length = part(dimension?.A) + part(dimension?.B); + const beam = part(dimension?.C) + part(dimension?.D); + return { length: length > 0 ? length : null, beam: beam > 0 ? beam : null }; } function vesselNameFromAis(metadata, message, staticData = {}) { diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 4cef4d4fe..b435fdf24 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -400,6 +400,27 @@ test('Enter finishes from the canvas and the label field, never from a focused b } }); +test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => { + const h = harness(); + try { + h.act('toggleDraw'); + h.tool.setShape('area'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.tool.addVertex(-97.74, 30.27); + + // src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe + // draws again and the control that had focus is gone. + h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes'); + assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area'); + } finally { + h.restore(); + } +}); + test('the live preview hangs at the height the click landed on', async () => { const h = harness(); try { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index c3d6c6f40..d89d60e67 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -109,6 +109,8 @@ export function createApplicationCatalog({ const vessels = createApplicationVessels({ source: sources.vessels, options: vesselOptions, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), }); const installations = createApplicationInstallations({ surface, @@ -167,7 +169,12 @@ export function createApplicationCatalog({ createApplicationTraffic({ source: sources.traffic, surface }), createApplicationCctv({ surface, source: sources.cctv }), createApplicationRadio({ surface, source: sources.radio }), - createApplicationTransit({ surface, source: sources.transit }), + createApplicationTransit({ + surface, + source: sources.transit, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), createApplicationBikeshare({ source: sources.bikeshare }), createApplicationDirections({ source: sources.directions }), createApplicationRecentImagery({ source: sources['recent-imagery'] }), @@ -200,7 +207,12 @@ export function createApplicationCatalog({ ...createOsmOverlayLayers(), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), - createApplicationRail({ surface, source: sources.rail }), + createApplicationRail({ + surface, + source: sources.rail, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), createApplicationFirms({ surface, id: 'local-firms', diff --git a/src/app/controls.js b/src/app/controls.js index def69325d..16da2c295 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -11,6 +11,8 @@ import { t } from '../i18n/index.js'; * whose address does not carry one. `localePicker: false` removes the language * picker, for a host that offers its own language choice (`switchLocale` from * the `./i18n` entry). + * `services.cockpitSubjects` adds the application's own first-person subjects + * to the Cockpit (src/cockpitSubjectProviders.js). */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, diff --git a/src/app/embed.js b/src/app/embed.js index 85c4f0752..32541f85a 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -94,17 +94,6 @@ export function panelAwareVisibility(doc = globalThis.document) { const PANEL_FRAME_MS = 33; const MISSED_FRAMES_MS = 250; -/** A render error's details, including the plain objects workers report. */ -function describeError(error) { - if (error instanceof Error) - return `${error.name}: ${error.message}\n${error.stack}`; - try { - return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {})); - } catch { - return String(error); - } -} - /** * Keep the globe drawing in an inline panel. Some hosts report a panel on * screen as hidden, which stops the browser's animation frames and with them @@ -122,33 +111,27 @@ export function keepPanelRendering( frameRequest = windowRef.requestAnimationFrame(onFrame); }; frameRequest = windowRef.requestAnimationFrame(onFrame); - // Cesium stops drawing after a render error, and so does the timer. - // Report the error in full: workers report plain objects, which Cesium - // prints as [object Object]. - let failed = false; - const removeErrorListener = viewer.scene?.renderError?.addEventListener( - (_scene, error) => { - failed = true; - console.error( - "[God's Eye View panel] render error:", - describeError(error), - ); - }, - ); + // Cesium stops its render loop after a render error, and the timer stops + // with it; Retry restarts both. The render failure notice logs the error + // (src/ui/renderFailure.js). const timer = windowRef.setInterval(() => { if ( - failed || + viewer.useDefaultRenderLoop === false || now() - lastFrame < MISSED_FRAMES_MS || viewer.isDestroyed?.() ) return; - viewer.resize(); - viewer.render(); + try { + viewer.resize(); + viewer.render(); + } catch { + // Raised as a render error by now (src/ui/renderFailure.js), which + // stops this timer; thrown on, it would only reach the host page. + } }, PANEL_FRAME_MS); return () => { windowRef.clearInterval(timer); windowRef.cancelAnimationFrame(frameRequest); - removeErrorListener?.(); }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index bcc5ada9b..e4e8a1d22 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -457,8 +457,7 @@ test('a panel keeps drawing from a timer while animation frames stop', () => { assert.equal(frame, null); }); -test('a panel reports a render error in full and stops drawing', () => { - let listener = null; +test('a panel stops drawing while its render loop is stopped', () => { let tick = null; const windowRef = { requestAnimationFrame: () => 1, @@ -467,31 +466,19 @@ test('a panel reports a render error in full and stops drawing', () => { clearInterval() {}, }; const drawn = []; - const viewer = { - resize() {}, - render: () => drawn.push('render'), - scene: { - renderError: { - addEventListener: (fn) => ((listener = fn), () => (listener = null)), - }, - }, - }; - const logged = []; - const original = console.error; - console.error = (...args) => logged.push(args.join(' ')); - try { - let clock = 0; - const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); - listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' }); - assert.match(logged[0], /render error: .*"message":"worker failed"/); - clock = 1000; - tick(); - assert.deepEqual(drawn, []); - stop(); - assert.equal(listener, null); - } finally { - console.error = original; - } + const viewer = { resize() {}, render: () => drawn.push('render') }; + let clock = 0; + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + // Cesium stops its render loop after a render error. + viewer.useDefaultRenderLoop = false; + clock = 1000; + tick(); + assert.deepEqual(drawn, []); + // Retry restarts it, and the timer draws again. + viewer.useDefaultRenderLoop = true; + tick(); + assert.deepEqual(drawn, ['render']); + stop(); }); test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => { diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js index cae0bf44c..65edc5d63 100644 --- a/src/app/layers/aisLiveVessels.js +++ b/src/app/layers/aisLiveVessels.js @@ -10,9 +10,19 @@ import * as focus from '../../data/focusDeemphasis.js'; import * as worldFocus from '../../worldFocus.js'; import * as render from '../../renderGovernor.js'; import * as readouts from '../../hudReadouts.js'; +import { fleetModelServices } from './fleetModels.js'; -/** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationVessels({ source, options = {} }) { +/** + * Construct one layer using the application scene owners and a supplied + * source. `displayParams` reads the Flights layer's DISPLAY-rail 3D + * preference, under which nearby vessels are drawn as fleet models. + */ +export function createApplicationVessels({ + source, + options = {}, + displayParams = null, + resolveAsset, +}) { return createVesselLayer({ source, options, @@ -28,6 +38,10 @@ export function createApplicationVessels({ source, options = {} }) { worldFocus, render, readouts, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/app/layers/fleetModels.js b/src/app/layers/fleetModels.js new file mode 100644 index 000000000..f6c5bcb7d --- /dev/null +++ b/src/app/layers/fleetModels.js @@ -0,0 +1,19 @@ +import { FLEET_MODEL_POLICY } from '../../layers/flights/policy.js'; + +/** + * The fleet-model service the Vessels, Transit and Rail layers draw their 3D + * models with: the Flights layer's DISPLAY-rail 3D preference + * (`{ models3d, models3dMode }`), the aircraft layers' icon-to-model rules, + * and the resolver the fleet set's files are fetched through. + * @param {{display?: (() => object|null)|null, + * resolveAsset?: (url: string) => string}} [options] + * @returns {object|null} Null without a display preference (no models). + */ +export function fleetModelServices({ + display = null, + resolveAsset = (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, +} = {}) { + if (!display) return null; + return Object.freeze({ display, policy: FLEET_MODEL_POLICY, resolveAsset }); +} diff --git a/src/app/layers/rail.js b/src/app/layers/rail.js index 9b5a3bb0e..fb3a05c51 100644 --- a/src/app/layers/rail.js +++ b/src/app/layers/rail.js @@ -1,5 +1,6 @@ import { createRailLayer } from '../../layers/rail/index.js'; import * as render from '../../renderGovernor.js'; +import { fleetModelServices } from './fleetModels.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; @@ -13,10 +14,18 @@ import { /** * Construct the Rail layer on the application's scene owners and its shared * ground floor, the same floor Transit and the aircraft stand on. - * @param {{surface: object, source?: object}} options + * `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under + * which nearby trains are drawn as fleet models. + * @param {{surface: object, source?: object, + * displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options * @returns {object} */ -export function createApplicationRail({ surface, source }) { +export function createApplicationRail({ + surface, + source, + displayParams = null, + resolveAsset, +}) { if (!surface?.groundFloor) throw new TypeError('Rail needs the application surface services'); const { groundFloor } = surface; @@ -45,6 +54,10 @@ export function createApplicationRail({ surface, source }) { globalThis.document?.body?.classList?.contains('cockpit-mode') === true, }, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js index 367fc425e..1b430a957 100644 --- a/src/app/layers/transit.js +++ b/src/app/layers/transit.js @@ -1,5 +1,6 @@ import { createTransitLayer } from '../../layers/transit/index.js'; import * as render from '../../renderGovernor.js'; +import { fleetModelServices } from './fleetModels.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; @@ -19,10 +20,19 @@ import { * own mesh floors. An earlier cut imported the legacy module-level singleton * instead and could not see either. * - * @param {{surface: object}} options + * `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under + * which nearby vehicles are drawn as fleet models. + * + * @param {{surface: object, source?: object, + * displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options * @returns {object} */ -export function createApplicationTransit({ surface, source }) { +export function createApplicationTransit({ + surface, + source, + displayParams = null, + resolveAsset, +}) { if (!surface?.groundFloor || !surface?.meshFloor) throw new TypeError('Transit needs the application surface services'); const { groundFloor, meshFloor } = surface; @@ -44,6 +54,10 @@ export function createApplicationTransit({ surface, source }) { allocateCorridorCells: groundFloor.allocateCorridorCells, }, mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells }, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/app/renderFailureInlineRetry.test.mjs b/src/app/renderFailureInlineRetry.test.mjs new file mode 100644 index 000000000..fa84e880f --- /dev/null +++ b/src/app/renderFailureInlineRetry.test.mjs @@ -0,0 +1,120 @@ +// Retry in an inline panel whose host stops animation frames, so the panel +// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop +// and useDefaultRenderLoop setter (CesiumWidget.prototype). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { keepPanelRendering } from './embed.js'; +import { renderLoopFixture } from '../ui/renderLoopFixture.mjs'; + +function setup() { + let tick = null; + let clock = 0; + // The host reports the panel hidden: its own animation frames never arrive. + const windowRef = { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setInterval: (fn) => ((tick = fn), 1), + clearInterval() {}, + }; + const previousError = console.error; + console.error = () => {}; + const state = { failure: null, drawn: 0 }; + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() {}, + initializeFrame() {}, + // Scene.render: a failing primitive update raises renderError + // (tryAndCatchError) and returns; a failing listener throws. + render() { + state.drawn += 1; + this.preUpdate.raiseEvent(this); + if (state.failure === 'caught') + this.renderError.raiseEvent(this, new Error('boom')); + if (state.failure === 'thrown') throw new Error('boom'); + this.postRender.raiseEvent(this); + }, + }; + const { frames, widget, viewer, store, runFrames, restore } = + renderLoopFixture(scene, { + viewer: { + isDestroyed: () => false, + render: () => widget.render(), + resize: () => widget.resize(), + }, + notice: { allowReload: false }, + }); + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + viewer.useDefaultRenderLoop = true; + const timerFrame = () => { + clock += 400; + tick(); + }; + return { + frames, + state, + store, + timerFrame, + animationFrames: runFrames, + restore: () => { + stop(); + restore(); + console.error = previousError; + }, + }; +} + +for (const failure of ['caught', 'thrown']) { + test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => { + const { frames, state, store, timerFrame, animationFrames, restore } = + setup(); + try { + // The host stops animation frames with the loop's next one queued. + assert.equal(frames.length, 1); + state.failure = failure; + timerFrame(); // an error thrown here does not reach the host page + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(typeof store.renderFailure.retry, 'function'); + state.failure = null; + store.renderFailure.retry(); + const before = state.drawn; + timerFrame(); + assert.ok(state.drawn > before, 'the panel draws again after Retry'); + assert.equal( + store.renderFailure, + null, + 'a clean frame clears the notice', + ); + assert.equal(frames.length, 1, 'Retry starts no second loop'); + // The host resumes frames: the globe draws once a frame. + state.drawn = 0; + animationFrames(3); + assert.equal(state.drawn, 3); + } finally { + restore(); + } + }); +} + +test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => { + const { frames, state, store, timerFrame, animationFrames, restore } = + setup(); + try { + state.failure = 'caught'; + animationFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(frames.length, 1); + state.failure = null; + store.renderFailure.retry(); + timerFrame(); + assert.equal(store.renderFailure, null); + assert.equal(frames.length, 1, 'Retry starts no second loop'); + state.drawn = 0; + animationFrames(3); + assert.equal(state.drawn, 3); + } finally { + restore(); + } +}); diff --git a/src/app/scene.js b/src/app/scene.js index b9799b8ac..a1afb0b60 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -15,6 +15,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { isEmbeddedInline } from './embed.js'; +import { installRenderFailureNotice } from '../ui/renderFailure.js'; import { worldClock, worldTimeFor } from '../time/worldClock.js'; import { followWorldTime } from '../time/viewerClock.js'; import { onLocaleChange, t } from '../i18n/index.js'; @@ -55,11 +57,17 @@ export async function createApplicationScene({ const viewer = createApplicationViewer({ container: 'cesiumContainer', creditContainer, + // The render failure notice below replaces Cesium's dead-end panel. + showRenderLoopErrors: false, }); defer(() => { uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); + // An inline panel must not reload or navigate its host's page. + defer( + installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }), + ); defer(installTrackpadPinchZoom(viewer)); defer( followWorldTime(viewer, worldTimeFor('sky'), { diff --git a/src/app/viewer.js b/src/app/viewer.js index e4288e8a3..adaa06406 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -375,8 +375,15 @@ export function installGlobeInViewRecovery( }; } -/** Cesium.Viewer options of the standard globe viewer. */ -export function applicationViewerOptions(creditContainer) { +/** + * Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors` + * false leaves render errors to the caller's own notice instead of Cesium's + * panel. + */ +export function applicationViewerOptions( + creditContainer, + { showRenderLoopErrors = true } = {}, +) { return { timeline: false, animation: false, @@ -396,16 +403,21 @@ export function applicationViewerOptions(creditContainer) { creditContainer, msaaSamples: 4, contextOptions: { webgl: { preserveDrawingBuffer: true } }, + showRenderLoopErrors, }; } /** Create the standard globe viewer in caller-owned, visible containers. */ -export function createApplicationViewer({ container, creditContainer }) { +export function createApplicationViewer({ + container, + creditContainer, + showRenderLoopErrors, +}) { if (!container || !creditContainer) throw new TypeError('Viewer and credit containers are required'); const viewer = new Cesium.Viewer( container, - applicationViewerOptions(creditContainer), + applicationViewerOptions(creditContainer, { showRenderLoopErrors }), ); try { viewer.targetFrameRate = 60; diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 53daa63d1..c304cac75 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -32,8 +32,15 @@ export function slewHeading(current, target, maxStepDeg) { * then uses this amount to converge on the layer's delayed display position. * Capping correction below forward speed prevents a late feed fix from * becoming a first-person surge or reversal while still removing drift. + * @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an + * application subject's own (cockpitSubjectStepSec). */ -export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { +export function cockpitAnchorCorrectionStep( + distanceM, + speedMps, + dtSec, + maxDtSec = 0.1, +) { if ( !Number.isFinite(distanceM) || distanceM <= 0 || @@ -41,13 +48,36 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { dtSec <= 0 ) return 0; - const dt = Math.min(0.1, dtSec); + const dt = Math.min( + Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1, + dtSec, + ); const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; const eased = distanceM * (1 - Math.exp(-1.25 * dt)); const correctionRateMps = Math.max(0.75, speed * 0.22); return Math.min(distanceM, eased, correctionRateMps * dt); } +/** + * The seconds an application subject's camera update advances its heading + * slew, ground lift and anchor by: the time since the last update, at most + * its update interval plus 0.1 s. A subject updated every 500 ms then turns + * and eases at its configured rates, while a long stall (a hidden tab) still + * moves it no further than one late update would. + * @param {number} elapsedMs Time since the last camera update. + * @param {number} intervalMs The subject's update interval (0: every frame). + * @returns {number} + */ +export function cockpitSubjectStepSec(elapsedMs, intervalMs) { + const elapsedSec = Number.isFinite(elapsedMs) + ? Math.max(0, elapsedMs) / 1000 + : 0; + const intervalSec = Number.isFinite(intervalMs) + ? Math.max(0, intervalMs) / 1000 + : 0; + return Math.min(intervalSec + 0.1, elapsedSec); +} + /** Return whether a throttled cockpit presentation update is due. */ export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) { if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) @@ -68,6 +98,42 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { return nowMs - startedMs >= timeoutMs; } +/** + * How fast the clearance a ground clamp adds under an application subject's + * eye comes and goes (m/s). + */ +export const COCKPIT_SUBJECT_LIFT_MPS = 1.5; + +/** + * The lift the ground clamp gives an application subject's eye this update. + * The floor is read from coarse cells that refine as tiles load, so the lift + * it asks for jumps; a ridden eye takes the clearance part of it at + * `rateMps`, while what it needs to stay out of the floor itself (`hardM`) + * applies at once. + * @param {number|null} previousM The lift given last update; null on entry. + * @param {number} targetM The lift the floor and clearance ask for (m). + * @param {number} hardM The lift that keeps the eye out of the floor (m). + * @param {number} dtSec Seconds since the last update. + * @param {number} [rateMps] + * @returns {number} At least 0, at least `hardM`. + */ +export function easeCockpitLift( + previousM, + targetM, + hardM, + dtSec, + rateMps = COCKPIT_SUBJECT_LIFT_MPS, +) { + const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0; + const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0; + if (!Number.isFinite(previousM)) return Math.max(target, hard); + const step = + Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) * + Math.max(0, Number.isFinite(dtSec) ? dtSec : 0); + const eased = previousM + Math.max(-step, Math.min(step, target - previousM)); + return Math.max(0, hard, eased); +} + /** * Keep the cockpit camera above the shared rendered-surface floor. * Unknown floors preserve the proposed camera height. diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index a59a4b029..104190fa8 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -8,8 +8,11 @@ import { cockpitAnchorCorrectionStep, cockpitAltitudeDisplayFt, cockpitGroundSafeHeight, + cockpitSubjectStepSec, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, + COCKPIT_SUBJECT_LIFT_MPS, + easeCockpitLift, compassDivisions, compassMarkerSlot, scrollingCompassDivisions, @@ -56,6 +59,28 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', () assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0); }); +test('an application subject on a slow cadence is corrected for its whole step', () => { + // The built-in views' step stays capped at 0.1 s... + const capped = cockpitAnchorCorrectionStep(100, 0, 0.5); + assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1)); + // ...while a subject updated every 500 ms is corrected for all of it. + const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5); + assert.ok(Math.abs(whole / capped - 5) < 1e-9); + assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped); +}); + +test('an application subject steps by its interval, never by a long stall', () => { + assert.equal(cockpitSubjectStepSec(16, 0), 0.016); + assert.equal(cockpitSubjectStepSec(500, 500), 0.5); + assert.equal(cockpitSubjectStepSec(520, 500), 0.52); + // A hidden tab's minutes count as one late update. + assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6); + assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1); + assert.equal(cockpitSubjectStepSec(-5, 500), 0); + assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0); + assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1); +}); + test('cockpit camera clearance never falls below the rendered ground floor', () => { assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69); assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200); @@ -435,3 +460,30 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', () assert.equal(formatCockpitEmergency(null), null); assert.equal(formatCockpitEmergency({ label: '' }), null); }); + +test('an application subject’s ground lift eases its clearance, never the floor', () => { + // On entry the lift asked for applies at once. + assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5); + // A floor cell that rises by a metre is met at the lift rate... + let lift = 0; + const frames = []; + for (let i = 0; i < 60; i += 1) { + lift = easeCockpitLift(lift, 1, 0, 0.016); + frames.push(lift); + } + assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9); + for (let i = 1; i < frames.length; i += 1) + assert.ok( + frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9, + ); + assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second'); + // ...and given back the same way. + assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9); + // What keeps the eye out of the floor itself is never eased. + assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200); + assert.equal( + easeCockpitLift(0.2, -3, -3, 0.016), + 0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016, + ); + assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0); +}); diff --git a/src/cockpitProgress.js b/src/cockpitProgress.js new file mode 100644 index 000000000..67a3be951 --- /dev/null +++ b/src/cockpitProgress.js @@ -0,0 +1,182 @@ +/** + * @module cockpitProgress + * @description Lay out a subject's progress line (cockpitSubjectProviders.js) + * at a width in pixels: where each tick, label and the marker go. + * + * Ticks sit at fractions of the line, 0 at the left. Ticks closer than + * `minTickPx` merge, so a long run still reads as a line: a labelled tick + * keeps its place and takes in the bare ticks beside it, and bare ticks merge + * with each other at their mean place. A merged tick takes the strongest tone + * of those it holds. Labels never overlap: where two would, the one of lower + * priority is left out (on a tie, the later one). + * + * Pure: no DOM. + */ + +/** Bare ticks closer than this merge (px). */ +export const COCKPIT_PROGRESS_MIN_TICK_PX = 6; +/** Room kept between two labels (px). */ +export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8; + +/** Tones from weakest to strongest, for a merged tick. */ +const TONE_RANK = Object.freeze([ + 'muted', + 'normal', + 'live', + 'estimate', + 'warning', + 'alert', +]); +const stronger = (a, b) => + TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a; + +/** A label's width estimate (px): its longer line in small capitals. */ +export function estimateProgressLabelPx(label) { + const longest = Math.max( + String(label?.text ?? '').length, + String(label?.detail ?? '').length, + ); + return longest * 6.4 + 8; +} + +/** + * The progress line at `widthPx`, or null when there is nothing to draw. + * @param {{ticks: Array<{at: number, tone: string, label: object|null}>, + * marker: {at: number, dwell: boolean}|null, tag: string}|null} progress + * As cockpitSubjectHudView gives it: ticks sorted by `at`. + * @param {number} widthPx + * @param {{minTickPx?: number, gapPx?: number, + * measure?: (label: object) => number}} [options] + * @returns {{ticks: Array<{x: number, tone: string, count: number, + * labelled: boolean}>, labels: Array<{x: number, left: number, + * width: number, align: string, text: string, detail: string, + * tone: string}>, marker: {x: number, dwell: boolean}|null, + * travelledX: number, tag: string}|null} + */ +export function cockpitProgressLayout( + progress, + widthPx, + { + minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX, + gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX, + measure = estimateProgressLabelPx, + } = {}, +) { + const ticks = progress?.ticks; + if (!Array.isArray(ticks) || ticks.length < 2) return null; + if (!Number.isFinite(widthPx) || widthPx <= 0) return null; + const placed = ticks.map((tick, order) => ({ + x: tick.at * widthPx, + tone: tick.tone, + label: tick.label, + order, + })); + const labelled = placed.filter((tick) => tick.label); + // Bare ticks beside a labelled one go into it. + const kept = labelled.map((tick) => ({ + x: tick.x, + tone: tick.tone, + count: 1, + labelled: true, + label: tick.label, + order: tick.order, + })); + const bare = []; + for (const tick of placed) { + if (tick.label) continue; + const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx); + if (host) { + host.tone = stronger(host.tone, tick.tone); + host.count += 1; + } else bare.push(tick); + } + // Bare ticks in a chain closer than minTickPx merge at their mean place. + let cluster = null; + const flush = () => { + if (!cluster) return; + kept.push({ + x: cluster.sum / cluster.count, + tone: cluster.tone, + count: cluster.count, + labelled: false, + label: null, + order: cluster.order, + }); + cluster = null; + }; + for (const tick of bare) { + if (cluster && tick.x - cluster.last < minTickPx) { + cluster.sum += tick.x; + cluster.count += 1; + cluster.last = tick.x; + cluster.tone = stronger(cluster.tone, tick.tone); + } else { + flush(); + cluster = { + sum: tick.x, + count: 1, + last: tick.x, + tone: tick.tone, + order: tick.order, + }; + } + } + flush(); + kept.sort((a, b) => a.x - b.x || a.order - b.order); + + // Labels by priority; one that would overlap a placed label is left out. + const boxes = []; + const candidates = kept + .filter((tick) => tick.labelled) + .sort((a, b) => b.label.priority - a.label.priority || a.order - b.order); + for (const tick of candidates) { + const width = Math.min(widthPx, Math.max(0, measure(tick.label))); + let left = + tick.label.align === 'start' + ? tick.x + : tick.label.align === 'end' + ? tick.x - width + : tick.x - width / 2; + left = Math.min(Math.max(0, left), widthPx - width); + const right = left + width; + if ( + boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx) + ) + continue; + boxes.push({ + x: tick.x, + left, + right, + width, + align: tick.label.align, + text: tick.label.text, + detail: tick.label.detail, + tone: tick.tone, + order: tick.order, + }); + } + boxes.sort((a, b) => a.left - b.left); + const marker = progress.marker + ? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell } + : null; + return { + ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({ + x, + tone, + count, + labelled: isLabelled, + })), + labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({ + x, + left, + width, + align, + text, + detail, + tone, + })), + marker, + travelledX: marker ? marker.x : 0, + tag: progress.tag || '', + }; +} diff --git a/src/cockpitProgress.test.mjs b/src/cockpitProgress.test.mjs new file mode 100644 index 000000000..f1bd9d819 --- /dev/null +++ b/src/cockpitProgress.test.mjs @@ -0,0 +1,144 @@ +// src/cockpitProgress.test.mjs — a subject's progress line laid out at a +// width: close bare ticks merge, labelled ticks keep their place, labels never +// overlap (the lower priority gives way) and the part before the marker is +// the travelled part. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cockpitProgressLayout } from './cockpitProgress.js'; + +const tick = (at, label = null, tone = 'normal') => ({ at, tone, label }); +const label = (text, priority, align = 'center', detail = '') => ({ + text, + detail, + priority, + align, +}); +/** Every label is 50 px wide. */ +const measure = () => 50; + +test('nothing is drawn without two ticks or a width', () => { + assert.equal(cockpitProgressLayout(null, 500), null); + assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null); + assert.equal( + cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0), + null, + ); +}); + +test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('A', 1, 'start')), + tick(0.1), + tick(0.101, null, 'warning'), + tick(0.102), + tick(0.5), + tick(1, label('Z', 1, 'end')), + ], + marker: { at: 0.3, dwell: false }, + tag: 'TIMETABLE', + }, + 1000, + { measure }, + ); + assert.deepEqual( + layout.ticks.map(({ x, count, tone, labelled }) => [ + Math.round(x), + count, + tone, + labelled, + ]), + [ + [0, 1, 'normal', true], + [101, 3, 'warning', false], + [500, 1, 'normal', false], + [1000, 1, 'normal', true], + ], + ); + assert.deepEqual(layout.marker, { x: 300, dwell: false }); + assert.equal(layout.travelledX, 300); + assert.equal(layout.tag, 'TIMETABLE'); +}); + +test('a labelled tick keeps its place and takes in the bare ticks beside it', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('A', 1, 'start')), + tick(0.498), + tick(0.5, label('Next', 3), 'alert'), + tick(0.503, null, 'warning'), + tick(1, label('Z', 1, 'end')), + ], + marker: null, + }, + 1000, + { measure }, + ); + const next = layout.ticks.find(({ x }) => Math.round(x) === 500); + assert.equal(next.count, 3); + assert.equal(next.labelled, true); + assert.equal(next.tone, 'alert'); + assert.equal(layout.ticks.length, 3); + assert.equal(layout.marker, null); + assert.equal(layout.travelledX, 0); +}); + +test('labels hang from their ticks inside the line, and overlaps give way by priority', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('First', 1, 'start')), + tick(0.04, label('Previous', 2)), + tick(0.5, label('Next', 3)), + tick(0.52, label('Close', 0)), + tick(1, label('Last', 1, 'end')), + ], + marker: { at: 0.2, dwell: true }, + }, + 1000, + { measure }, + ); + assert.deepEqual( + layout.labels.map(({ text, left }) => [text, Math.round(left)]), + [ + ['Previous', 15], + ['Next', 475], + ['Last', 950], + ], + ); + // The first stop's label would cover the previous stop's: it gives way. + assert.ok(!layout.labels.some(({ text }) => text === 'First')); + assert.ok(!layout.labels.some(({ text }) => text === 'Close')); + assert.deepEqual(layout.marker, { x: 200, dwell: true }); +}); + +test('a centred label at an edge is clamped inside the line', () => { + const layout = cockpitProgressLayout( + { ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null }, + 300, + { measure }, + ); + assert.equal(layout.labels[0].left, 0); + const right = cockpitProgressLayout( + { ticks: [tick(0), tick(1, label('Edge', 1))], marker: null }, + 300, + { measure }, + ); + assert.equal(right.labels[0].left, 250); +}); + +test('a long run of calls still lays out, every one a tick or part of one', () => { + const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59)); + ticks[0].label = label('First', 1, 'start'); + ticks[59].label = label('Last', 1, 'end'); + const narrow = cockpitProgressLayout({ ticks, marker: null }, 200); + const wide = cockpitProgressLayout({ ticks, marker: null }, 2000); + const count = (layout) => + layout.ticks.reduce((sum, entry) => sum + entry.count, 0); + assert.equal(count(narrow), 60); + assert.equal(count(wide), 60); + assert.ok(narrow.ticks.length < wide.ticks.length); + assert.equal(wide.ticks.length, 60); +}); diff --git a/src/cockpitSubjectProviders.js b/src/cockpitSubjectProviders.js new file mode 100644 index 000000000..88b91e62c --- /dev/null +++ b/src/cockpitSubjectProviders.js @@ -0,0 +1,644 @@ +/** + * @module cockpitSubjectProviders + * @description Subject providers: kinds of subject an application adds to the + * Cockpit beside its own aircraft, satellite and vessel. A provider names the + * subject entry would take, holds it while the view rides it, hands the camera + * a frame each update and the HUD a view model, and releases it on exit. Core + * knows nothing else about it. + * + * Everything a provider returns is normalized here before the Cockpit uses + * it: a malformed provider is left out, a malformed frame ends the view, and a + * malformed HUD view is read field by field, never thrown on. + * + * Pure: no Cesium, no DOM. + */ + +/** The kinds the Cockpit rides itself; no provider may take one. */ +export const COCKPIT_BUILT_IN_KINDS = Object.freeze([ + 'aircraft', + 'satellite', + 'vessel', +]); + +/** The tones a provider's HUD texts may take. */ +export const COCKPIT_SUBJECT_TONES = Object.freeze([ + 'normal', + 'muted', + 'live', + 'estimate', + 'warning', + 'alert', +]); + +/** + * The heights a frame may give (m, ellipsoidal): below the deepest dry + * ground, above anything ridden near it; outside, the frame stands on the + * floor. + */ +export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000; +export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000; + +/** The most readouts, sources and signals a HUD view shows. */ +export const COCKPIT_SUBJECT_MAX_READOUTS = 4; +export const COCKPIT_SUBJECT_MAX_SOURCES = 4; +export const COCKPIT_SUBJECT_MAX_SIGNALS = 4; +/** The most ticks a progress line takes. */ +export const COCKPIT_SUBJECT_MAX_TICKS = 512; + +const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/; +const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; +const ICON_PATTERN = /^[a-z0-9_]{1,40}$/; +const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/; +const REQUIRED_METHODS = Object.freeze([ + 'candidate', + 'hold', + 'read', + 'hud', + 'release', +]); +const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS); +const TONES = new Set(COCKPIT_SUBJECT_TONES); + +const isObject = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value); +const finite = (value) => typeof value === 'number' && Number.isFinite(value); +const clamp = (value, low, high) => Math.min(high, Math.max(low, value)); + +/** + * `value` as display text: trimmed, at most `max` characters (the last one + * an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is + * kept whole or left out); '' for anything that is not a string or a number. + */ +export function cockpitSubjectText(value, max) { + if (typeof value !== 'string' && !finite(value)) return ''; + const text = String(value).replace(/\s+/g, ' ').trim(); + if (text.length <= max) return text; + let end = max - 1; + const last = text.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end -= 1; + return `${text.slice(0, end)}…`; +} + +const tone = (value) => (TONES.has(value) ? value : 'normal'); + +/** A Material Symbols entry face, or null. */ +function faceOf(raw) { + if (!isObject(raw)) return null; + const label = cockpitSubjectText(raw.label, 16); + if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label) + return null; + return Object.freeze({ icon: raw.icon, label }); +} + +/** The labels a provider gives, normalized, or null without the required. */ +function labelsOf(raw) { + if (!isObject(raw)) return null; + const labels = { + view: cockpitSubjectText(raw.view, 48), + exit: cockpitSubjectText(raw.exit, 48), + exitText: cockpitSubjectText(raw.exitText, 24), + kicker: cockpitSubjectText(raw.kicker, 48), + }; + return labels.view && labels.exit && labels.exitText + ? Object.freeze(labels) + : null; +} + +/** A provider's look, normalized, or null without an aheadLabel. */ +function lookOf(raw) { + if (!isObject(raw)) return null; + const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64); + if (!aheadLabel) return null; + return Object.freeze({ + restPitchDeg: finite(raw.restPitchDeg) + ? clamp(raw.restPitchDeg, -90, 90) + : 0, + aheadLabel, + }); +} + +/** + * A provider's worded member `key` as it reads now, normalized: the value, + * or what the function it is returns when called on `provider` (so it can + * follow the page's language); null when it is malformed or a read throws. + * Reading the member, and reading what it gives, runs provider code too (a + * getter), so both are guarded as the call is. + */ +function wordedNow(provider, key, normalize) { + try { + const value = provider[key]; + return ( + normalize(typeof value === 'function' ? value.call(provider) : value) ?? + null + ); + } catch { + return null; + } +} + +/** A provider's name in a warning: its kind, cut, or its place in the list. */ +function providerName(provider, order) { + try { + if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32); + } catch { + // A kind getter that throws gives no name. + } + return `#${order}`; +} + +/** Why `provider` cannot be kept, or null. */ +function refusal(provider, kinds) { + if (!isObject(provider)) return 'not an object'; + if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind)) + return 'malformed kind'; + if (RESERVED.has(provider.kind)) return 'reserved kind'; + if (kinds.has(provider.kind)) return 'repeated kind'; + if ( + typeof provider.layerId !== 'string' || + !LAYER_ID_PATTERN.test(provider.layerId) + ) + return 'malformed layer id'; + for (const method of REQUIRED_METHODS) + if (typeof provider[method] !== 'function') return `no ${method}()`; + if ( + provider.isEntryAllowed !== undefined && + typeof provider.isEntryAllowed !== 'function' + ) + return 'isEntryAllowed is not a function'; + if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face'; + if (!wordedNow(provider, 'labels', labelsOf)) + return 'labels need view, exit and exitText'; + if (provider.look != null && !wordedNow(provider, 'look', lookOf)) + return 'look needs an aheadLabel'; + return null; +} + +/** + * The application's subject providers, checked once and frozen, in the order + * entry asks them: ascending `priority`, ties in list order. Each has + * - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique; + * - `layerId`: the catalog layer the subject belongs to (entry needs it on); + * - `priority`: a finite number (default 0); + * - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at + * most 16 characters, in the page's language; + * - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit + * button's accessible names, the exit button's text and the default kicker, + * in the page's language; + * - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as + * on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look + * ahead button named `aheadLabel`; + * `entryFace`, `labels` and `look` may each be a function returning the + * object: it is called on the provider again whenever they are shown and + * when the page's language changes, so its words can follow it + * (`./i18n`). A read that throws or is malformed keeps the last + * good one; `look` is on or off and `restPitchDeg` fixed as first read; + * - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0 + * updates the camera every rendered frame; + * - `headingSlewDps` (optional): how fast the view turns to a new heading, + * positive or Infinity, null for the Cockpit's default; + * - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`, + * `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all + * synchronous (createCockpitSubjectCaller) and called on the provider, so + * a class instance's methods keep their `this`. `isEntryAllowed` allows + * entry only by returning `true`. `hold` returns `true` once it holds the + * subject, or `false` to refuse entry; any other answer refuses it too, + * with one warning. A refused hold, and one that throws or returns a + * promise, is followed by `release(id, {restore: false})`, and by another + * once that promise settles; `release` must therefore be harmless for a + * subject it does not hold or already gave back. + * + * A provider that is not so, or whose members throw when read (a getter), is + * left out with one warning naming it; the providers after it are kept. + * @param {unknown} list + * @param {{warn?: (message: string) => void}} [options] + * @returns {ReadonlyArray} + */ +export function cockpitSubjectProviders( + list = [], + { warn = (message) => console.warn(message) } = {}, +) { + const kept = []; + const kinds = new Set(); + (Array.isArray(list) ? list : []).forEach((provider, order) => { + let reason; + let copy = null; + try { + reason = refusal(provider, kinds); + if (!reason) copy = keptProvider(provider, order); + } catch { + reason = 'a member threw when read'; + } + if (reason) { + warn( + `[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`, + ); + return; + } + kinds.add(copy.kind); + kept.push(copy); + }); + kept.sort((a, b) => a.priority - b.priority || a.order - b.order); + return Object.freeze(kept); +} + +/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */ +function keptProvider(provider, order) { + // Worded members are read again on each use (see above); the last good + // read stands in for one that is malformed or throws. + let face = wordedNow(provider, 'entryFace', faceOf); + let labels = wordedNow(provider, 'labels', labelsOf); + // On or off, and its rest pitch, as first read. + let look = wordedNow(provider, 'look', lookOf); + const restPitchDeg = look?.restPitchDeg ?? 0; + return Object.freeze({ + kind: provider.kind, + layerId: provider.layerId, + priority: finite(provider.priority) ? provider.priority : 0, + order, + get entryFace() { + face = wordedNow(provider, 'entryFace', faceOf) ?? face; + return face; + }, + get labels() { + labels = wordedNow(provider, 'labels', labelsOf) ?? labels; + return labels; + }, + get look() { + if (!look) return null; + const now = wordedNow(provider, 'look', lookOf); + if (now) + look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel }); + return look; + }, + cameraIntervalMs: + finite(provider.cameraIntervalMs) && + provider.cameraIntervalMs >= 0 && + provider.cameraIntervalMs <= 1000 + ? provider.cameraIntervalMs + : null, + headingSlewDps: + provider.headingSlewDps === Infinity || + (finite(provider.headingSlewDps) && provider.headingSlewDps > 0) + ? provider.headingSlewDps + : null, + // Bound, so a method that uses `this` (a class instance's, say) still + // reads its own provider when called on this copy. + candidate: provider.candidate.bind(provider), + isEntryAllowed: provider.isEntryAllowed + ? provider.isEntryAllowed.bind(provider) + : () => true, + hold: provider.hold.bind(provider), + read: provider.read.bind(provider), + hud: provider.hud.bind(provider), + release: provider.release.bind(provider), + }); +} + +/** + * A provider's candidate: `{id, name, face, selectedAt}`, or null without a + * usable id. `name` falls back to the id; `face` is null unless well formed; + * `selectedAt` (a number or a short string, else null) is a token the + * provider changes each time its subject is selected, even again: a + * subject selected after the built-in ones takes entry (entryCandidates). + * Null too when reading `raw` throws (a getter runs provider code). + * @param {unknown} raw What `candidate()` returned. + */ +export function cockpitSubjectCandidate(raw) { + try { + return candidateOf(raw); + } catch { + // A getter on what candidate() returned threw: no candidate. + return null; + } +} + +function candidateOf(raw) { + if (!isObject(raw) || typeof raw.id !== 'string') return null; + const id = raw.id.trim(); + if (!id || id.length > 128) return null; + const selectedAt = + finite(raw.selectedAt) || + (typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64) + ? raw.selectedAt + : null; + return Object.freeze({ + id, + name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64), + face: faceOf(raw.face), + selectedAt, + }); +} + +/** + * One camera frame from a provider, normalized, or null when it cannot place + * the camera (the view then ends): not an object, an id other than `heldId`, + * a position that is not finite, an eye with no valid `upM` (0..1000), or a + * read of it that throws (a getter runs provider code). + * A `heightM` that is not finite or lies outside + * COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null: + * the eye then stands on the shared floor, or where it last stood while that + * is cold. The anchor is `'inertial'` only when asked for and the frame + * gives a speed; otherwise it is `'exact'`. An id is compared with `heldId` + * trimmed, as `cockpitSubjectCandidate` trims it. + * @param {unknown} raw What `read()` returned. + * @param {string} heldId The subject the view holds. + * @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg, + * courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM, + * pitchDeg, minClearanceM}}`. + */ +export function cockpitSubjectFrame(raw, heldId) { + try { + return frameOf(raw, heldId); + } catch { + // A getter on what read() returned threw: as a malformed frame. + return null; + } +} + +function frameOf(raw, heldId) { + if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId) + return null; + const latitude = raw.latitude; + const longitude = raw.longitude; + if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90) + return null; + const eye = isObject(raw.eye) ? raw.eye : null; + if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null; + const heading = (value) => + finite(value) ? ((value % 360) + 360) % 360 : null; + const headingDeg = heading(raw.headingDeg); + const speedMps = + finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null; + return { + id: heldId, + name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64), + latitude, + longitude: ((((longitude + 180) % 360) + 360) % 360) - 180, + heightM: + finite(raw.heightM) && + raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M && + raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M + ? raw.heightM + : null, + headingDeg, + courseDeg: heading(raw.courseDeg) ?? headingDeg, + speedMps, + stale: raw.stale === true, + groundClamp: raw.groundClamp !== false, + // An inertial anchor runs on at the frame's speed; with none it would + // close on a moving subject at the correction's floor and fall behind. + anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial', + eye: { + upM: eye.upM, + forwardM: + finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500 + ? eye.forwardM + : 0, + pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0, + minClearanceM: + finite(eye.minClearanceM) && eye.minClearanceM >= 0 + ? eye.minClearanceM + : 0, + }, + }; +} + +/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */ +function rowsOf(raw, max, row) { + if (!Array.isArray(raw)) return []; + const rows = []; + const ids = new Set(); + for (const entry of raw) { + if (rows.length >= max) break; + if (!isObject(entry)) continue; + const id = typeof entry.id === 'string' ? entry.id : ''; + if (!ID_PATTERN.test(id) || ids.has(id)) continue; + const read = row(entry); + if (!read) continue; + ids.add(id); + rows.push({ id, ...read }); + } + return rows; +} + +/** A progress line, or null without at least two ticks. */ +function progressOf(raw) { + if (!isObject(raw) || !Array.isArray(raw.ticks)) return null; + const ticks = []; + for (const tick of raw.ticks) { + if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break; + if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1) + continue; + let label = null; + if (isObject(tick.label)) { + const text = cockpitSubjectText(tick.label.text, 40); + if (text) + label = { + text, + detail: cockpitSubjectText(tick.label.detail, 64), + priority: finite(tick.label.priority) + ? clamp(Math.round(tick.label.priority), 0, 9) + : 0, + align: ['start', 'center', 'end'].includes(tick.label.align) + ? tick.label.align + : 'center', + }; + } + ticks.push({ at: tick.at, tone: tone(tick.tone), label }); + } + if (ticks.length < 2) return null; + ticks.sort((a, b) => a.at - b.at); + const marker = + isObject(raw.marker) && finite(raw.marker.at) + ? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true } + : null; + return { + ticks, + marker, + tag: cockpitSubjectText(raw.tag, 16), + summary: cockpitSubjectText(raw.summary, 160), + }; +} + +/** + * A provider's HUD view model, normalized; never throws. Texts are coerced + * and cut to their caps, lists to their lengths, tones to + * COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent. + * Null when reading `raw` throws (a getter runs provider code), so the caller + * keeps the view it has, as when `hud()` itself throws. + * All text is shown as given: the provider words it in the page's language. + * @param {unknown} raw What `hud()` returned. + * @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value, + * unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}], + * sources: [{id, label, text, tone}], + * route: {title, from, to, status}|null, + * progress: {ticks: [{at, tone, label: {text, detail, priority, + * align}|null}], marker: {at, dwell}|null, tag, summary}|null, + * banner: {title, detail, tone}|null, veil: {level, streaks}|null, + * lookReadout, signals: [{id, tone, title, detail}]}`. + */ +export function cockpitSubjectHudView(raw) { + try { + return hudViewOf(raw); + } catch { + // A getter on what hud() returned threw: the caller keeps its last view. + return null; + } +} + +function hudViewOf(raw) { + const view = isObject(raw) ? raw : {}; + const meta = isObject(view.meta) ? view.meta : {}; + const speed = + isObject(view.speed) && + (view.speed.value === null || + (finite(view.speed.value) && view.speed.value >= 0)) + ? { + value: + view.speed.value === null + ? null + : Math.min(view.speed.value, 99_999), + unit: cockpitSubjectText(view.speed.unit, 12), + tag: cockpitSubjectText(view.speed.tag, 8), + tone: tone(view.speed.tone), + } + : null; + const route = isObject(view.route) + ? { + title: cockpitSubjectText(view.route.title, 32), + from: cockpitSubjectText(view.route.from, 48), + to: cockpitSubjectText(view.route.to, 48), + status: cockpitSubjectText(view.route.status, 64), + } + : null; + const bannerTitle = isObject(view.banner) + ? cockpitSubjectText(view.banner.title, 48) + : ''; + const veil = + isObject(view.veil) && finite(view.veil.level) + ? { + level: clamp(view.veil.level, 0, 1), + streaks: view.veil.streaks === true, + } + : null; + return { + kicker: cockpitSubjectText(view.kicker, 48), + title: cockpitSubjectText(view.title, 64), + meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) }, + speed, + readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => { + const label = cockpitSubjectText(row.label, 24); + const value = cockpitSubjectText(row.value, 48); + return label || value + ? { + label, + value, + detail: cockpitSubjectText(row.detail, 80), + tone: tone(row.tone), + } + : null; + }), + sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => { + const text = cockpitSubjectText(row.text, 120); + return text + ? { + label: cockpitSubjectText(row.label, 16), + text, + tone: tone(row.tone), + } + : null; + }), + route: route && (route.from || route.to) ? route : null, + progress: progressOf(view.progress), + banner: bannerTitle + ? { + title: bannerTitle, + detail: cockpitSubjectText(view.banner.detail, 96), + tone: tone(view.banner.tone), + } + : null, + veil: veil && veil.level > 0 ? veil : null, + lookReadout: cockpitSubjectText(view.lookReadout, 48), + signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => { + const title = cockpitSubjectText(row.title, 48); + return title + ? { + title, + detail: cockpitSubjectText(row.detail, 96), + tone: tone(row.tone), + } + : null; + }), + }; +} + +/** + * A guarded caller for provider methods: `call(provider, method, args, + * fallback)` returns what the method returns, or `fallback` when it throws, + * warning once per provider and method. + * + * Provider methods are synchronous: the Cockpit calls them on its render + * cadence and uses the answer at once. One that returns a promise (an async + * function, say) breaks that contract: the promise is refused as a throw + * would be (its rejection, if any, is swallowed), with one warning, and + * `onSettled`, when given, runs once the promise has settled, so a caller + * can undo work that lands late. + * + * `call.warnOnce(provider, method, message, error)` warns on the same + * once-per-provider-and-method footing, for an answer the caller finds + * malformed. + * @param {{warn?: (message: string, error: unknown) => void}} [options] + */ +export function createCockpitSubjectCaller({ + warn = (message, error) => console.warn(message, error), +} = {}) { + const warned = new Set(); + const warnOnce = (provider, method, message, error) => { + const key = `${provider.kind}:${method}`; + if (warned.has(key)) return; + warned.add(key); + warn( + `[Cockpit] subject provider ${provider.kind} ${method}() ${message}`, + error, + ); + }; + function call( + provider, + method, + args = [], + fallback = null, + onSettled = null, + ) { + let result; + let thenable; + try { + result = provider[method](...args); + // Reading `then` runs provider code too (a getter may throw), so it is + // guarded as the call is. + thenable = + result !== null && + (typeof result === 'object' || typeof result === 'function') && + typeof result.then === 'function'; + } catch (error) { + warnOnce(provider, method, 'failed', error); + return fallback; + } + if (thenable) { + // Its work may still land: `onSettled` runs once it has, either way. + const settled = () => { + if (typeof onSettled === 'function') onSettled(); + }; + Promise.resolve(result).then(settled, settled); + warnOnce( + provider, + method, + 'returned a promise; provider methods are synchronous', + null, + ); + return fallback; + } + return result; + } + call.warnOnce = warnOnce; + return call; +} diff --git a/src/cockpitSubjectProviders.test.mjs b/src/cockpitSubjectProviders.test.mjs new file mode 100644 index 000000000..2063cca41 --- /dev/null +++ b/src/cockpitSubjectProviders.test.mjs @@ -0,0 +1,698 @@ +// src/cockpitSubjectProviders.test.mjs — an application's subject providers +// are checked once and what they return is normalized before the Cockpit +// uses it: a malformed provider is left out, a malformed frame ends the view, +// and a malformed HUD view is read field by field and never thrown on. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + COCKPIT_BUILT_IN_KINDS, + COCKPIT_SUBJECT_MAX_READOUTS, + COCKPIT_SUBJECT_MAX_TICKS, + cockpitSubjectCandidate, + cockpitSubjectFrame, + cockpitSubjectHudView, + cockpitSubjectProviders, + cockpitSubjectText, + createCockpitSubjectCaller, +} from './cockpitSubjectProviders.js'; + +const noop = () => null; +/** A well-formed provider, with `overrides`. */ +const provider = (overrides = {}) => ({ + kind: 'example-kind', + layerId: 'example-layer', + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { + view: 'First-person view', + exit: 'Exit first-person view', + exitText: 'EXIT VIEW', + }, + candidate: noop, + hold: () => true, + read: noop, + hud: noop, + release: noop, + ...overrides, +}); + +const keep = (list) => { + const warnings = []; + const kept = cockpitSubjectProviders(list, { + warn: (message) => warnings.push(message), + }); + return { kept, warnings }; +}; + +test('well-formed providers are kept, frozen, in priority then list order', () => { + const { kept, warnings } = keep([ + provider({ kind: 'late', priority: 5 }), + provider({ kind: 'first-of-two', priority: 1 }), + provider({ kind: 'second-of-two', priority: 1 }), + provider({ kind: 'default-priority' }), + ]); + assert.deepEqual(warnings, []); + assert.deepEqual( + kept.map(({ kind }) => kind), + ['default-priority', 'first-of-two', 'second-of-two', 'late'], + ); + assert.ok(Object.isFrozen(kept)); + assert.ok(Object.isFrozen(kept[0])); + const [first] = kept; + assert.equal(first.priority, 0); + assert.equal(first.look, null); + assert.equal(first.cameraIntervalMs, null); + assert.equal(first.headingSlewDps, null); + assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows'); + assert.deepEqual(first.labels, { + view: 'First-person view', + exit: 'Exit first-person view', + exitText: 'EXIT VIEW', + kicker: '', + }); +}); + +test('malformed, reserved and repeated providers are left out with one warning each', () => { + const cases = [ + [null, /not an object/], + [provider({ kind: 'X' }), /malformed kind/], + [provider({ kind: 'a' }), /malformed kind/], + [provider({ layerId: 'Bad Layer' }), /malformed layer id/], + [provider({ hold: undefined }), /no hold\(\)/], + [provider({ read: 'read' }), /no read\(\)/], + [provider({ isEntryAllowed: true }), /isEntryAllowed/], + [provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/], + [provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/], + [provider({ labels: { view: 'v', exit: 'e' } }), /labels/], + [provider({ look: { restPitchDeg: -3 } }), /aheadLabel/], + ]; + for (const [entry, reason] of cases) { + const { kept, warnings } = keep([entry]); + assert.equal(kept.length, 0, String(reason)); + assert.equal(warnings.length, 1, String(reason)); + assert.match(warnings[0], reason); + } + for (const kind of COCKPIT_BUILT_IN_KINDS) { + const { kept, warnings } = keep([provider({ kind })]); + assert.equal(kept.length, 0, kind); + assert.match(warnings[0], /reserved kind/); + } + const { kept, warnings } = keep([provider(), provider()]); + assert.equal(kept.length, 1); + assert.match(warnings[0], /repeated kind/); + assert.deepEqual(keep('not a list').kept, []); +}); + +test('optional settings are kept when in range and defaulted otherwise', () => { + const [ranged] = keep([ + provider({ + priority: -2, + look: { restPitchDeg: -120, aheadLabel: 'Look ahead' }, + cameraIntervalMs: 0, + headingSlewDps: Infinity, + labels: { + view: 'v', + exit: 'e', + exitText: 'x', + kicker: 'K'.repeat(80), + }, + }), + ]).kept; + assert.equal(ranged.priority, -2); + assert.deepEqual(ranged.look, { + restPitchDeg: -90, + aheadLabel: 'Look ahead', + }); + assert.equal(ranged.cameraIntervalMs, 0); + assert.equal(ranged.headingSlewDps, Infinity); + assert.equal(ranged.labels.kicker.length, 48); + assert.ok(ranged.labels.kicker.endsWith('…')); + const [defaulted] = keep([ + provider({ + priority: NaN, + cameraIntervalMs: 5000, + headingSlewDps: -1, + }), + ]).kept; + assert.equal(defaulted.priority, 0); + assert.equal(defaulted.cameraIntervalMs, null); + assert.equal(defaulted.headingSlewDps, null); +}); + +test('a candidate needs an id; its name and face are read with care', () => { + assert.equal(cockpitSubjectCandidate(null), null); + assert.equal(cockpitSubjectCandidate({ id: 7 }), null); + assert.equal(cockpitSubjectCandidate({ id: ' ' }), null); + assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null); + assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), { + id: 'a1', + name: 'a1', + face: null, + selectedAt: null, + }); + // A selection token is a number or a short string. + assert.equal( + cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt, + 5, + ); + assert.equal( + cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt, + 'click-2', + ); + for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)]) + assert.equal( + cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt, + null, + ); + const read = cockpitSubjectCandidate({ + id: 'a1', + name: ' Example 12 ', + face: { icon: 'tram', label: 'RIDE' }, + }); + assert.equal(read.name, 'Example 12'); + assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' }); + assert.equal( + cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face, + null, + ); +}); + +test('a frame is the held subject at a finite place with an eye height, else null', () => { + const frame = (overrides = {}) => ({ + id: 'a1', + latitude: 47, + longitude: 8, + eye: { upM: 3.5 }, + ...overrides, + }); + const read = cockpitSubjectFrame(frame(), 'a1'); + assert.deepEqual(read, { + id: 'a1', + name: 'a1', + latitude: 47, + longitude: 8, + heightM: null, + headingDeg: null, + courseDeg: null, + speedMps: null, + stale: false, + groundClamp: true, + // No speed to run an inertial anchor on: it sits on the subject. + anchor: 'exact', + eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 }, + }); + assert.equal( + cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor, + 'inertial', + 'with a speed it runs on', + ); + // A held id is trimmed (cockpitSubjectCandidate), and so is a frame's. + assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1'); + for (const bad of [ + null, + frame({ id: 'other' }), + frame({ latitude: 91 }), + frame({ latitude: NaN }), + frame({ longitude: Infinity }), + frame({ eye: null }), + frame({ eye: { upM: -1 } }), + frame({ eye: { upM: 2000 } }), + frame({ eye: { upM: '3' } }), + ]) + assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad)); + const full = cockpitSubjectFrame( + frame({ + name: 'Example', + longitude: 190, + heightM: 412.5, + headingDeg: -10, + courseDeg: 725, + speedMps: 22, + stale: true, + groundClamp: false, + anchor: 'exact', + eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 }, + }), + 'a1', + ); + assert.equal(full.longitude, -170); + assert.equal(full.heightM, 412.5); + // A height outside any plausible ground reads as none. + for (const heightM of [-6_400_000, 1e9, Infinity]) + assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null); + assert.equal( + cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM, + -400, + ); + assert.equal(full.headingDeg, 350); + assert.equal(full.courseDeg, 5); + assert.equal(full.speedMps, 22); + assert.equal(full.stale, true); + assert.equal(full.groundClamp, false); + assert.equal(full.anchor, 'exact'); + assert.deepEqual(full.eye, { + upM: 2, + forwardM: 0, + pitchDeg: -90, + minClearanceM: 1.5, + }); + // Only exactly false turns the ground clamp off; course falls back to heading. + const loose = cockpitSubjectFrame( + frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }), + 'a1', + ); + assert.equal(loose.groundClamp, true); + assert.equal( + cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor, + 'inertial', + ); + assert.equal(loose.courseDeg, 90); + assert.equal(loose.speedMps, null); +}); + +test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => { + const empty = cockpitSubjectHudView(null); + assert.deepEqual(empty, { + kicker: '', + title: '', + meta: { text: '', tone: 'normal' }, + speed: null, + readouts: [], + sources: [], + route: null, + progress: null, + banner: null, + veil: null, + lookReadout: '', + signals: [], + }); + for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }]) + assert.doesNotThrow(() => cockpitSubjectHudView(odd)); + const view = cockpitSubjectHudView({ + kicker: 'LINE · OPERATOR', + title: 'Name → Destination', + meta: { text: 'LIVE', tone: 'bogus' }, + speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' }, + readouts: [ + ...Array.from({ length: 6 }, (_, i) => ({ + id: `r${i}`, + label: 'NEXT', + value: `Stop ${i}`, + tone: 'warning', + })), + ], + sources: [ + { id: 'track', label: 'TRACK', text: 'operator shape' }, + { id: 'track', label: 'TRACK', text: 'repeated id' }, + { id: 'Bad Id', text: 'refused' }, + { id: 'empty', label: 'X', text: '' }, + ], + route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' }, + banner: { title: 'BANNER', detail: 'detail', tone: 'alert' }, + veil: { level: 4, streaks: 1 }, + lookReadout: 'LOOK RIGHT', + signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }], + }); + assert.equal(view.meta.tone, 'normal'); + assert.deepEqual(view.speed, { + value: 84.4, + unit: 'km/h', + tag: 'LIVE', + tone: 'live', + }); + assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS); + assert.equal(view.readouts[0].tone, 'warning'); + assert.deepEqual( + view.sources.map(({ id }) => id), + ['track'], + ); + assert.deepEqual(view.route, { + title: 'ROUTE', + from: 'A', + to: 'B', + status: '3 stops', + }); + assert.equal(view.banner.tone, 'alert'); + assert.deepEqual(view.veil, { level: 1, streaks: false }); + assert.equal(view.lookReadout, 'LOOK RIGHT'); + assert.equal(view.signals[0].tone, 'warning'); + // A veil at 0, a speed below 0 and a route with no ends are absent. + const absent = cockpitSubjectHudView({ + veil: { level: 0 }, + speed: { value: -3 }, + route: { from: '', to: '' }, + }); + assert.equal(absent.veil, null); + assert.equal(absent.speed, null); + assert.equal(absent.route, null); + // A speed of null is a speed not yet known. + assert.equal( + cockpitSubjectHudView({ speed: { value: null } }).speed.value, + null, + ); +}); + +test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => { + const read = cockpitSubjectHudView({ + progress: { + ticks: [ + { at: 1, label: { text: 'End', align: 'end', priority: 1 } }, + { at: 0.5, tone: 'warning' }, + { at: 2 }, + { at: -0.1 }, + { at: 0, label: { text: 'Start', align: 'start', priority: 12 } }, + { at: 0.25, label: { text: '', detail: 'no text, no label' } }, + ], + marker: { at: 1.4, dwell: true }, + tag: 'TIMETABLE', + summary: 'From Start to End', + }, + }).progress; + assert.deepEqual( + read.ticks.map(({ at }) => at), + [0, 0.25, 0.5, 1], + ); + assert.equal(read.ticks[0].label.priority, 9); + assert.equal(read.ticks[0].label.align, 'start'); + assert.equal(read.ticks[1].label, null); + assert.equal(read.ticks[2].tone, 'warning'); + assert.deepEqual(read.marker, { at: 1, dwell: true }); + assert.equal(read.tag, 'TIMETABLE'); + assert.equal(read.summary, 'From Start to End'); + assert.equal( + cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress, + null, + ); + const many = cockpitSubjectHudView({ + progress: { + ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({ + at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40), + })), + }, + }).progress; + assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS); +}); + +test('texts are trimmed, collapsed and cut with an ellipsis', () => { + assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b'); + assert.equal(cockpitSubjectText('abcdef', 4), 'abc…'); + assert.equal(cockpitSubjectText(12.5, 10), '12.5'); + assert.equal(cockpitSubjectText({}, 10), ''); + assert.equal(cockpitSubjectText(NaN, 10), ''); +}); + +test('a cut never splits a surrogate pair', () => { + // The cut would fall between the emoji's halves: it is left out whole. + assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…'); + // One that fits before the cut is kept whole. + assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…'); + const lone = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const failing = { + kind: 'example-kind', + read() { + throw new Error('boom'); + }, + hud: (value) => value * 2, + }; + assert.equal(call(failing, 'read', [], 'fallback'), 'fallback'); + assert.equal(call(failing, 'read', [], 'fallback'), 'fallback'); + assert.equal(call(failing, 'hud', [21]), 42); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /example-kind read\(\) failed/); +}); + +test('a provider method that returns a promise is refused as a throw is, warning once', async () => { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const asynchronous = { + kind: 'example-kind', + hold: async () => true, + read: () => Promise.reject(new Error('late')), + }; + assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused'); + assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused'); + assert.equal(call(asynchronous, 'read', [], null), null); + assert.equal(warnings.length, 2, 'once per method'); + assert.match(warnings[0], /hold\(\) returned a promise/); + // The rejected promise is not left unhandled. + await new Promise((resolve) => setTimeout(resolve, 0)); +}); + +test('an answer whose `then` throws when read is handled as a throw', () => { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const tricky = { + kind: 'example-kind', + hold: () => ({ + get then() { + throw new Error('no then'); + }, + }), + }; + assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /example-kind hold\(\) failed/); +}); + +test('the caller’s warnOnce shares its once-per-provider-and-method record', () => { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const ride = { + kind: 'example-kind', + hold() { + throw new Error('boom'); + }, + }; + call.warnOnce(ride, 'read', 'is odd'); + call.warnOnce(ride, 'read', 'is odd again'); + call(ride, 'hold', ['a1']); + call.warnOnce(ride, 'hold', 'is odd'); + assert.deepEqual(warnings, [ + '[Cockpit] subject provider example-kind read() is odd', + '[Cockpit] subject provider example-kind hold() failed', + ]); +}); + +test('a class-based provider keeps its `this`, in its methods and its worded members', () => { + class Ride { + #word = 'RIDE'; + #held = null; + kind = 'example-kind'; + layerId = 'example-layer'; + speak(word) { + this.#word = word; + } + entryFace() { + return { icon: 'commute', label: this.#word }; + } + get labels() { + return { + view: `${this.#word} view`, + exit: 'Exit ride view', + exitText: 'EXIT RIDE', + }; + } + look() { + return { aheadLabel: `Look ahead, ${this.#word}` }; + } + candidate() { + return { id: 'a1', name: this.#word }; + } + isEntryAllowed(candidate) { + return candidate.id === 'a1' && this.#held === null; + } + hold(id) { + this.#held = id; + return true; + } + read() { + return this.#held; + } + hud(frame) { + return { title: `${this.#word} ${frame}` }; + } + release() { + this.#held = null; + return 'released'; + } + } + const instance = new Ride(); + const { kept, warnings } = keep([instance]); + assert.deepEqual(warnings, []); + const [ride] = kept; + assert.equal(ride.entryFace.label, 'RIDE'); + assert.equal(ride.labels.view, 'RIDE view'); + assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE'); + instance.speak('FAHRT'); + assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance'); + assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT'); + // Called on the kept copy, as the Cockpit calls them. + const failures = []; + const call = createCockpitSubjectCaller({ + warn: (message) => failures.push(message), + }); + assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' }); + assert.equal(ride.isEntryAllowed({ id: 'a1' }), true); + assert.equal(call(ride, 'hold', ['a1']), true); + assert.equal(ride.isEntryAllowed({ id: 'a1' }), false); + assert.equal(call(ride, 'read', [0, {}]), 'a1'); + assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' }); + assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released'); + assert.equal(call(ride, 'read', [0, {}]), null); + assert.deepEqual(failures, []); +}); + +test('labels, entry face and look given as functions are read again on each use', () => { + let lang = 'en'; + const words = { + en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' }, + de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' }, + }; + const { kept, warnings } = keep([ + provider({ + labels: () => words[lang], + entryFace: () => ({ + icon: 'commute', + label: { en: 'RIDE', de: 'FAHRT' }[lang], + }), + look: () => ({ + restPitchDeg: lang === 'de' ? 40 : -3, + aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead', + }), + }), + ]); + assert.deepEqual(warnings, []); + const [ride] = kept; + assert.equal(ride.labels.exitText, 'EXIT RIDE'); + assert.equal(ride.entryFace.label, 'RIDE'); + assert.equal(ride.look.aheadLabel, 'Look ahead'); + lang = 'de'; + assert.equal(ride.labels.exitText, 'VERLASSEN'); + assert.equal(ride.entryFace.label, 'FAHRT'); + assert.equal(ride.look.aheadLabel, 'Nach vorn'); + assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read'); + // A read that fails or is malformed keeps the last good one. + lang = 'xx'; + assert.equal(ride.labels.exitText, 'VERLASSEN'); + assert.equal(ride.entryFace.label, 'FAHRT'); + // Functions that are malformed from the start leave the provider out. + const bad = keep([ + provider({ kind: 'bad-labels', labels: () => null }), + provider({ + kind: 'thrown-face', + entryFace: () => { + throw new Error('no face'); + }, + }), + ]); + assert.deepEqual(bad.kept, []); + assert.equal(bad.warnings.length, 2); +}); + +/** `target` with a getter for `key` that throws (provider code runs on a read). */ +const throwingGetter = (target, key) => + Object.defineProperty(target, key, { + enumerable: true, + get() { + throw new Error(`${key} unavailable`); + }, + }); + +test('a provider whose getter throws is left out with one warning; the next is kept', () => { + const { kept, warnings } = keep([ + throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'), + throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'), + throwingGetter(provider({ kind: 'thrown-look' }), 'look'), + throwingGetter(provider(), 'kind'), + throwingGetter(provider({ kind: 'thrown-method' }), 'read'), + // Read only once the provider is accepted, so its kind must stay free. + throwingGetter(provider({ kind: 'example-kind' }), 'priority'), + provider({ kind: 'example-kind' }), + ]); + assert.deepEqual( + kept.map(({ kind }) => kind), + ['example-kind'], + ); + assert.deepEqual(warnings, [ + '[Cockpit] subject provider thrown-face left out: malformed entry face', + '[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText', + '[Cockpit] subject provider thrown-look left out: a member threw when read', + '[Cockpit] subject provider #3 left out: a member threw when read', + '[Cockpit] subject provider thrown-method left out: a member threw when read', + '[Cockpit] subject provider example-kind left out: a member threw when read', + ]); +}); + +test('a worded getter that starts throwing keeps the last good words', () => { + let failing = false; + const words = (value) => () => { + if (failing) throw new Error('translations unavailable'); + return value; + }; + const definition = provider(); + for (const [key, value] of Object.entries({ + labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' }, + entryFace: { icon: 'commute', label: 'RIDE' }, + look: { restPitchDeg: 10, aheadLabel: 'Look ahead' }, + })) + Object.defineProperty(definition, key, { get: words(value) }); + const { kept, warnings } = keep([definition]); + assert.deepEqual(warnings, []); + const [ride] = kept; + failing = true; + assert.equal(ride.labels.view, 'Ride view'); + assert.equal(ride.entryFace.label, 'RIDE'); + assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' }); + // So does a worded object whose own member throws when read. + failing = false; + Object.defineProperty(definition, 'labels', { + get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'), + }); + assert.equal(ride.labels.view, 'Ride view'); +}); + +test('a candidate, frame or HUD view whose getter throws reads as null', () => { + assert.equal( + cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')), + null, + ); + const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } }; + assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed'); + assert.equal( + cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'), + null, + ); + assert.equal( + cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'), + null, + ); + assert.equal( + cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')), + null, + ); + assert.equal( + cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }), + null, + ); + // Nothing to read is still the empty view, never null. + assert.equal(cockpitSubjectHudView(null).title, ''); +}); diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 6e3238ec5..ec6b1f32c 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -481,6 +481,24 @@ export function orbitEntryAllowed({ ); } +/** + * Whether an application subject provider's candidate can be ridden. Like the + * bridge and orbit it needs only its own layer on, and waits out a Context + * change in flight; the provider then has its own say (`isEntryAllowed`). + * @param {{layerEnabled: boolean, contextModeChanging: boolean, + * providerAllows: boolean}} state + * @returns {boolean} + */ +export function subjectEntryAllowed({ + layerEnabled, + contextModeChanging, + providerAllows, +}) { + return ( + Boolean(layerEnabled) && !contextModeChanging && providerAllows === true + ); +} + /** * Internal context-mode id → the word the voice tools accept for it. * diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index 61d07387f..a9d5cbbdf 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -64,7 +64,11 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { const geometricFeet = finiteNumber(aircraft?.alt_geom); const groundSpeedKnots = finiteNumber(aircraft?.gs); const verticalRateFpm = readAdsbVerticalRateFpm(aircraft); - const track = finiteNumber(aircraft?.track); + // A parked or slow aircraft reports no ground track, but often its true + // heading (surface messages): the way its nose points, which is what an + // aircraft standing still is drawn by. Magnetic heading is not true north. + const track = + finiteNumber(aircraft?.track) ?? finiteNumber(aircraft?.true_heading); return [ hex, diff --git a/src/data/adsbLolFallback.test.mjs b/src/data/adsbLolFallback.test.mjs index 873a8bbe2..a99d273de 100644 --- a/src/data/adsbLolFallback.test.mjs +++ b/src/data/adsbLolFallback.test.mjs @@ -49,3 +49,15 @@ test('keeps grounded fallback contacts and rejects rows without positions', () = assert.equal(normalized.states[0][7], null); assert.equal(normalized.states[0][8], true); }); + +test('an aircraft with no track takes its true heading, never its magnetic one', () => { + const at = (extra) => + normalizeAdsbLolAircraftState( + { hex: 'abc123', lat: 37.7, lon: -122.2, alt_baro: 'ground', ...extra }, + 1000, + )[10]; + assert.equal(at({ track: 56.25, true_heading: 40 }), 56.25, 'track first'); + assert.equal(at({ true_heading: 244.69 }), 244.69, 'true heading'); + assert.equal(at({ mag_heading: 278.44 }), null, 'not magnetic'); + assert.equal(at({}), null, 'none'); +}); diff --git a/src/data/aircraftClass.js b/src/data/aircraftClass.js index 1be15e84c..4b719b3ec 100644 --- a/src/data/aircraftClass.js +++ b/src/data/aircraftClass.js @@ -467,54 +467,3 @@ export const CLASS_SCALE_2D = { bizjet: 0.72, uav: 0.75, }; - -/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares - * the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when - * real per-class models land in CLASS_MODEL_URL. */ -export const CLASS_SCALE_3D = { - light: 0.75, - glider: 0.75, - turboprop: 0.85, - airliner: 1.0, - widebody: 1.3, - quadjet: 1.45, - helicopter: 0.8, - fastjet: 0.8, - bizjet: 0.8, - uav: 0.8, -}; - -/** Per-class glTF — all the shared airplane today; drop real assets in here. - * NOTE for future models: each asset may need its own heading offset (the - * shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */ -export const CLASS_MODEL_URL = { - light: '/models/airplane.glb', - glider: '/models/airplane.glb', - turboprop: '/models/airplane.glb', - airliner: '/models/airplane.glb', - widebody: '/models/airplane.glb', - quadjet: '/models/airplane.glb', - helicopter: '/models/airplane.glb', - fastjet: '/models/airplane.glb', - bizjet: '/models/airplane.glb', - uav: '/models/airplane.glb', -}; - -/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 — - * provenance in public/models/README.md). Every asset is vertex-baked to the - * airplane.glb convention: Y-up, X = length, Z = span, nose −X (so the layers' - * MODEL_HEADING_OFFSET_DEG = 180 applies unchanged), origin at bbox centre, - * REAL-WORLD METERS (render at scale 1 — no MODEL_SCALE, no CLASS_SCALE_3D). - * bellyM = origin→lowest-vertex distance (grounded-model lift, replaces the - * airplane.glb MODEL_BELLY_OFFSET_NATIVE formula); radiusM = bounding-sphere - * radius in meters (pixel-size math). Values measured from the shipped GLBs — - * pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet, - * glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */ -export const CLASS_MODEL_REAL = { - helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 }, - light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 }, - bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 }, - uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 }, - widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 }, - turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 }, -}; diff --git a/src/data/aircraftClass.test.mjs b/src/data/aircraftClass.test.mjs index c1676a279..84b7394c2 100644 --- a/src/data/aircraftClass.test.mjs +++ b/src/data/aircraftClass.test.mjs @@ -1,7 +1,7 @@ // src/data/aircraftClass.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js'; +import { classifyAircraft, CLASS_SCALE_2D } from './aircraftClass.js'; test('type-code classification (military layer path)', () => { assert.equal(classifyAircraft({ typeCode: 'F16' }), 'fastjet'); @@ -48,19 +48,8 @@ test('typeCode outranks category; unknown → airliner', () => { assert.equal(classifyAircraft({ typeCode: null, category: 0 }), 'airliner'); }); -test('scale/url tables cover every class', () => { +test('the icon scale table covers every class', () => { for (const kind of ['light','glider','turboprop','airliner','widebody','quadjet','helicopter','fastjet','bizjet','uav']) { assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind); - assert.ok(CLASS_SCALE_3D[kind] >= 0.75 && CLASS_SCALE_3D[kind] <= 1.45, kind); - assert.ok(typeof CLASS_MODEL_URL[kind] === 'string', kind); - } -}); - -test('CLASS_MODEL_REAL entries carry the fields the layers consume', () => { - for (const [kind, spec] of Object.entries(CLASS_MODEL_REAL)) { - assert.ok(spec.url.startsWith('/models/') && spec.url.endsWith('.glb'), kind); - assert.ok(spec.radiusM > 0 && spec.bellyM > 0, kind); - // every real-model class must also exist in the classifier tables - assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind); } }); diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs index 27deb2b46..f26c28abc 100644 --- a/src/data/aircraftEmergencyAnalyst.test.mjs +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -458,7 +458,8 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do ); flightState._trackedIcao = null; flightState.records.data.set('civil', { emergency: null }); - assert.equal(rendering._modelColor('civil'), normal); + // An ordinary model draws its mesh's own colours, whatever the billboard's. + assert.equal(rendering._modelColor('civil'), Cesium.Color.WHITE); } }); diff --git a/src/data/aircraftIcons.js b/src/data/aircraftIcons.js index bca4ca655..f664c861e 100644 --- a/src/data/aircraftIcons.js +++ b/src/data/aircraftIcons.js @@ -160,8 +160,8 @@ const BODIES = { L -3,31 L -8,34 L -8,30 L -6,16 L -27,26 L -27,20 L -5,-8 C -4.6,-16 -4,-24 -3.5,-30 Z" fill="white" ${STROKE}/>`, - // 2026-08-15 Hangar additions: two NEW classes shipped with the real-model - // fleet (CLASS_MODEL_REAL). Same contract as the set above: 96×96 nose-up, + // 2026-08-15 additions: two NEW classes (bizjet, uav). Same contract as + // the set above: 96×96 nose-up, // white fill, hairline stroke, tint-safe. // Business jet — slender fuselage, modest swept wing, aft engines, T-tail. bizjet: ` diff --git a/src/data/aircraftModelLighting.js b/src/data/aircraftModelLighting.js index c8051c67e..38725be93 100644 --- a/src/data/aircraftModelLighting.js +++ b/src/data/aircraftModelLighting.js @@ -1,3 +1,5 @@ +import * as Cesium from 'cesium'; + /** * Lighting options shared by every aircraft 3D model (Flights, Military * Flights, Local ADS-B). @@ -6,12 +8,24 @@ * the model and reads the result back from the GPU (`readPixels`) to light it, * when the model is first placed and again when it moves more than 1 km, when * the scene's atmosphere settings change, or every scene hour under sunlight - * lighting. Aircraft models take 94% of their surface from the class colour - * (`colorBlendAmount`), so that lighting contributes little; without it they - * are lit from Cesium's default spherical harmonics. Measured 2026-10-24 - * (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest - * 14.3-14.8 -> 12.1-12.5 ms. + * lighting. Without it they are lit from Cesium's default spherical + * harmonics. Measured 2026-10-24 (Intel Arc, Google 3D, ~150 models in the + * scene): GPU per frame at rest 14.3-14.8 -> 12.1-12.5 ms. */ export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({ enabled: false, }); + +/** + * How an aircraft model's colour meets its mesh. HIGHLIGHT multiplies, so + * white draws the mesh's own colours as built, and the tracked or emergency + * colour tints the mesh without hiding its paint. The IR boost draws the + * model flat white instead (MIX at 1, with its unlit shader). + * @param {boolean} irBoost + * @returns {{colorBlendMode: Cesium.ColorBlendMode, colorBlendAmount: number}} + */ +export function aircraftModelBlend(irBoost) { + return irBoost + ? { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 1 } + : { colorBlendMode: Cesium.ColorBlendMode.HIGHLIGHT, colorBlendAmount: 0 }; +} diff --git a/src/data/aircraftModelLighting.test.mjs b/src/data/aircraftModelLighting.test.mjs index 51fd9d97c..70be8e5d4 100644 --- a/src/data/aircraftModelLighting.test.mjs +++ b/src/data/aircraftModelLighting.test.mjs @@ -6,9 +6,8 @@ import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js'; /** * Every aircraft model drawn in the scene skips Cesium's dynamic environment - * map (a sky render and GPU readback per model). The preload calls only warm - * the glTF cache and are never added to the scene, so they are left out: - * drawn models are the calls that set a minimum pixel size. + * map (a sky render and GPU readback per model). Drawn models are the calls + * that set a minimum pixel size. */ const SOURCES = [ { @@ -36,7 +35,9 @@ test('the shared option turns the dynamic environment map off', () => { for (const { name, source } of SOURCES) { test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => { const calls = [ - ...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g), + ...source.matchAll( + /(?:fromGltfAsync\(|loadModel\(|(?:_createModel|createFleetModel)\(\s*\w+,\s*)\{([\s\S]*?)\n\s*\}[,)]/g, + ), ] .map((match) => match[1]) .filter((options) => /\bminimumPixelSize:/.test(options)); diff --git a/src/data/aircraftRecession.js b/src/data/aircraftRecession.js index 04c41e604..0cc012e68 100644 --- a/src/data/aircraftRecession.js +++ b/src/data/aircraftRecession.js @@ -243,7 +243,7 @@ export function applyAircraftBillboardTreatment({ /** * Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without - * disturbing Cesium's existing MIX/colorBlendAmount presentation semantics. + * changing the model's colour blend mode or amount. * @param {object} input * @param {object} input.model * @param {{withAlpha:(alpha:number)=>object}} input.baseColor diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 5ca3375f7..93569fc97 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -258,3 +258,61 @@ test('partial static data keeps newer live metadata ahead of the old static cach assert.equal(rowFor(mmsi).type, '80'); assert.equal(rowFor(mmsi).destination, 'HAMBURG'); }); + +// A static report's dimensions are the antenna's distances to bow (A), stern +// (B), port (C) and starboard (D): length A+B, beam C+D. Zero is AIS's "not +// available", so a vessel reporting none has no size, not a size of zero. +test('a static report gives a vessel its hull length and beam', () => { + ingestAisStreamEnvelope(positionReport('636019825', { Sog: 14 })); + ingestAisStreamEnvelope({ + MessageType: 'ShipStaticData', + Message: { + ShipStaticData: { + UserID: 636019825, + Name: 'EVER GIVEN', + Type: 70, + Dimension: { A: 330, B: 70, C: 30, D: 29 }, + }, + }, + MetaData: { MMSI: '636019825' }, + }); + const row = rowFor('636019825'); + assert.equal(row.length, 400); + assert.equal(row.beam, 59); + + ingestAisStreamEnvelope(positionReport('636019826', { Sog: 3 })); + ingestAisStreamEnvelope({ + MessageType: 'ShipStaticData', + Message: { + ShipStaticData: { + UserID: 636019826, + Type: 52, + Dimension: { A: 0, B: 0, C: 0, D: 0 }, + }, + }, + MetaData: { MMSI: '636019826' }, + }); + assert.equal(rowFor('636019826').length, null); + assert.equal(rowFor('636019826').beam, null); +}); + +test('a Class B report carries its dimensions in part B', () => { + ingestAisStreamEnvelope(positionReport('244000777', { Sog: 6 })); + ingestAisStreamEnvelope({ + MessageType: 'StaticDataReport', + Message: { + StaticDataReport: { + UserID: 244000777, + ReportB: { + Valid: true, + ShipType: 37, + Dimension: { A: 8, B: 4, C: 2, D: 2 }, + }, + }, + }, + MetaData: { MMSI: '244000777' }, + }); + const row = rowFor('244000777'); + assert.equal(row.length, 12); + assert.equal(row.beam, 4); +}); diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 8177b7414..0488a6d1e 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -28,11 +28,13 @@ for (const layer of LAYERS) { const source = readLayerSource(layer.path); test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => { - const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)]; - assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`); + const calls = [...source.matchAll(/_createModel\(\s*\w+,\s*\{([\s\S]*?)\n\s*\}\)/g)]; + assert.ok(calls.length >= 2, `expected fleet and tracked model calls; found ${calls.length}`); + assert.doesNotMatch(source, /Cesium\.Model\.fromGltfAsync\(/, + 'every model is built from the loaded fleet set through _createModel'); for (const [index, call] of calls.entries()) { assert.match(call[1], /\basynchronous:\s*false\b/, - `Model.fromGltfAsync call ${index + 1} must keep bounded GLB readiness independent of tile jobs`); + `_createModel call ${index + 1} must keep bounded GLB readiness independent of tile jobs`); } }); diff --git a/src/data/fleetModelInstances.js b/src/data/fleetModelInstances.js new file mode 100644 index 000000000..e3147c812 --- /dev/null +++ b/src/data/fleetModelInstances.js @@ -0,0 +1,462 @@ +import * as Cesium from 'cesium'; +import { horizonOccluder } from './iconOrientation.js'; +import { selectModelEligible } from './modelEligibility.js'; +import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js'; +import { fleetModelSet } from './fleetModels.js'; + +/** + * Cesium models of the procedural fleet set (`fleetModels.js`), and the + * icon-to-model handoff the Vessels, Transit and Rail layers share. + */ + +/** Every fleet GLB is nose -X: opposite Cesium's heading 0. */ +export const FLEET_HEADING_OFFSET_DEG = 180; + +/** + * Create a Cesium Model of `spec` from the up-front loaded bytes, so no model + * is fetched mid-session. Each model keeps its own URL as its base path: the + * glTF cache keys by it, and two models sharing one would share geometry. + * Rejects when the set is not loaded or the model's GLB failed to load. + * @param {object} spec `fleetModelSpec`. + * @param {object} options Cesium `Model.fromGltfAsync` options. + * @param {{resolveAsset?: (url: string) => string, set?: object|null, + * load?: (options: object) => Promise}} [context] + * @returns {Promise} Cesium Model. + */ +export function createFleetModel( + spec, + options, + { + resolveAsset = (url) => url, + set = fleetModelSet(), + // Looked up when called, so a replaced `fromGltfAsync` (tests) is the one used. + load = (o) => Cesium.Model.fromGltfAsync(o), + } = {}, +) { + const bytes = set?.bytes?.get(spec?.model); + if (!bytes) + return Promise.reject(new Error(`fleet model ${spec?.model} not loaded`)); + return load({ + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + ...options, + gltf: bytes, + basePath: resolveAsset(spec.url), + }); +} + +const tintShaders = new Map(); + +/** Models under the NVG/FLIR styles render unlit, as aircraft models do. */ +const IR_UNLIT_SHADER = new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, +}); + +/** + * The custom shader that paints a route colour where a transit model's + * `_TINT` vertex attribute marks it: drawn colour = vertex colour × + * mix(1, colour, _TINT). One shader per colour, shared by every model drawn in + * it (Cesium compiles the program once; uniforms are per shader). + * @param {Cesium.Color} color + * @returns {Cesium.CustomShader} + */ +export function fleetTintShader(color) { + const key = color.toCssHexString(); + let shader = tintShaders.get(key); + if (!shader) { + shader = new Cesium.CustomShader({ + mode: Cesium.CustomShaderMode.MODIFY_MATERIAL, + uniforms: { + u_routeColor: { + type: Cesium.UniformType.VEC3, + value: new Cesium.Cartesian3(color.red, color.green, color.blue), + }, + }, + fragmentShaderText: ` + void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { + vec3 route = czm_srgbToLinear(u_routeColor); + material.diffuse *= mix(vec3(1.0), route, fsInput.attributes.tint); + } + `, + }); + tintShaders.set(key, shader); + } + return shader; +} + +/** + * Write the world matrix of a fleet model standing at `position` heading + * `headingDeg` (clockwise from north) into `result`, stretched by the spec's + * beam and height scales relative to its length scale (a Model's own `scale` + * carries the length scale, so its minimum pixel size still applies). + * @param {Cesium.Cartesian3} position + * @param {number} headingDeg + * @param {object} spec `fleetModelSpec`. + * @param {Cesium.Matrix4} result + * @param {Cesium.HeadingPitchRoll} scratchHpr + * @returns {Cesium.Matrix4} + */ +export function fleetModelMatrix( + position, + headingDeg, + spec, + result, + scratchHpr, +) { + scratchHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + FLEET_HEADING_OFFSET_DEG, + ); + scratchHpr.pitch = 0; + scratchHpr.roll = 0; + Cesium.Transforms.headingPitchRollToFixedFrame( + position, + scratchHpr, + Cesium.Ellipsoid.WGS84, + undefined, + result, + ); + const [sx, sy, sz] = spec.scaleXYZ; + if (sx > 0 && (sy !== sx || sz !== sx)) { + // Cesium's glTF axis correction puts the model's length on local Y, its + // beam on local X and its height on local Z. + scratchScale.x = sy / sx; + scratchScale.y = 1; + scratchScale.z = sz / sx; + Cesium.Matrix4.multiplyByScale(result, scratchScale, result); + } + return result; +} +const scratchScale = new Cesium.Cartesian3(); + +/** + * 3D models for one layer's vehicles under the DISPLAY rail's 3D toggle (the + * Flights layer's `models3d`/`models3dMode`), on the same rules the aircraft + * layers follow: models only below the camera altitude ceiling, for the + * nearest `cap` vehicles in the add/keep band, on-screen first. A vehicle's + * icon stays the visual until its model is loaded and placed; the layer reads + * `modelOwnsVisual` back from each item and hides that icon. + * + * Each frame the layer passes its vehicles as items: + * `{position, headingDeg, spec, tint, pickId, show, groundLiftM}`, + * `position` the point the model's origin stands on (the waterline, the road + * or rail top; the spec's `elevationM` is added here), `groundLiftM` any lift + * the layer's icon carries above that point (taken off again), `tint` a + * Cesium.Color for a tinted model's route colour (or null), `show` false to + * keep it hidden. + * + * Under the luminance-mapped NVG/FLIR styles (`irBoost`) models render + * unlit flat white, as aircraft models do. With `waterline`, each model is + * clipped at its origin's horizontal plane and only the part above it is + * drawn: a ship's hull below the waterline would otherwise show through a + * plain globe, which Cesium does not depth-test models against. + * @param {object} options + * @param {object} options.viewer Cesium viewer. + * @param {() => object|null} [options.display] `{models3d, models3dMode, + * irBoost}`. + * @param {object} options.policy `{altitudeCeilingM, minimumPixelSize, + * cap, capAll, addM, addAllM, keepM, keepAllM, intervalMs}`. + * @param {(url: string) => string} [options.resolveAsset] + * @param {(spec: object, options: object) => Promise} [options.createModel] + * @param {boolean} [options.waterline] Clip each model at its waterline. + * @returns {object} + */ +export function createFleetModelLayer({ + viewer, + display = () => null, + policy, + waterline = false, + resolveAsset = (url) => url, + createModel = (spec, options) => + createFleetModel(spec, options, { resolveAsset }), +}) { + const collection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(collection); + /** @type {Map} id -> admitted model */ + const models = new Map(); + /** @type {Map} id -> generation of an in-flight load */ + const pending = new Map(); + let generation = 0; + let destroyed = false; + let lastEligibilityAt = -Infinity; + let eligible = new Set(); + let settling = 0; + /** Whether the models drawn now were built for the NVG/FLIR styles. */ + let irBoost = false; + const scratchHpr = new Cesium.HeadingPitchRoll(); + const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1); + const scratchCarto = new Cesium.Cartographic(); + const scratchPosition = new Cesium.Cartesian3(); + + function release(id) { + pending.delete(id); + const model = models.get(id); + if (!model) return; + models.delete(id); + if (!collection.isDestroyed()) collection.remove(model); + } + + function releaseAll() { + for (const id of models.keys()) release(id); + pending.clear(); + generation += 1; + } + + function preferences() { + return display?.() || null; + } + + function regime(prefs) { + if (!prefs?.models3d || !fleetModelSet()) return false; + const height = viewer.camera?.positionCartographic?.height ?? Infinity; + return height < policy.altitudeCeilingM; + } + + function shaderFor(item) { + if (irBoost) return IR_UNLIT_SHADER; + return item.spec.tinted && item.tint + ? fleetTintShader(item.tint) + : undefined; + } + + /** The clipping that keeps a model's part above its waterline. */ + function waterlineClipping() { + return new Cesium.ClippingPlaneCollection({ + // The planes sit in the model matrix's frame: its origin is the + // waterline and its Z axis the local vertical, so the plane z = 0 with + // its normal up keeps the hull above the water. + planes: [new Cesium.ClippingPlane(Cesium.Cartesian3.UNIT_Z, 0)], + edgeWidth: 0, + }); + } + + async function ensure(id, item, cap) { + const key = item.spec.key; + const current = models.get(id); + if (current && current._gevSpecKey === key) return; + if (current) release(id); + if (pending.has(id) || models.size + pending.size >= cap) return; + const loadGeneration = generation; + pending.set(id, loadGeneration); + let model; + try { + model = await createModel(item.spec, { + asynchronous: false, + minimumPixelSize: policy.minimumPixelSize, + scale: item.spec.scale, + customShader: shaderFor(item), + ...(irBoost + ? { + color: Cesium.Color.WHITE, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: 1, + } + : {}), + ...(waterline ? { clippingPlanes: waterlineClipping() } : {}), + id: item.pickId ?? id, + }); + } catch { + if (pending.get(id) === loadGeneration) pending.delete(id); + return; + } + const stale = + destroyed || + pending.get(id) !== loadGeneration || + generation !== loadGeneration || + !eligible.has(id) || + models.has(id); + if (pending.get(id) === loadGeneration) pending.delete(id); + if (stale) { + model.destroy?.(); + return; + } + model.id = item.pickId ?? id; + model._gevSpecKey = key; + model._gevTint = item.spec.tinted && !irBoost ? item.tint : null; + model._gevIrBoost = irBoost; + // Admitted, not yet the visual: placement turns it on. + model.show = false; + collection.add(model); + models.set(id, model); + } + + function refreshEligibility(items, prefs) { + const all = prefs.models3dMode === 'all'; + const cap = all ? policy.capAll : policy.cap; + const addM = all ? policy.addAllM : policy.addM; + const keepM = all ? policy.keepAllM : policy.keepM; + const camera = viewer.camera; + const cameraPosition = camera.positionWC; + const cull = camera.frustum.computeCullingVolume( + cameraPosition, + camera.directionWC, + camera.upWC, + ); + const candidates = []; + for (const [id, item] of items) { + if (!item.position || item.show === false || !item.spec) continue; + const distanceSq = Cesium.Cartesian3.distanceSquared( + cameraPosition, + item.position, + ); + if (distanceSq > keepM * keepM) continue; + Cesium.Cartesian3.clone(item.position, scratchSphere.center); + candidates.push([ + id, + distanceSq, + cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE, + ]); + } + candidates.sort((a, b) => a[1] - b[1]); + eligible = selectModelEligible(candidates, { + cap, + addDistSq: addM * addM, + isModeled: (id) => models.has(id), + }); + for (const id of models.keys()) if (!eligible.has(id)) release(id); + for (const id of pending.keys()) if (!eligible.has(id)) release(id); + for (const id of eligible) void ensure(id, items.get(id), cap); + } + + /** Where the model's origin stands: the item's point raised by the spec's elevation. */ + function placement(item) { + const lift = (item.spec.elevationM || 0) - (item.groundLiftM || 0); + if (!lift) return item.position; + const carto = Cesium.Cartographic.fromCartesian( + item.position, + Cesium.Ellipsoid.WGS84, + scratchCarto, + ); + if (!carto) return item.position; + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + carto.height + lift, + Cesium.Ellipsoid.WGS84, + scratchPosition, + ); + } + + return { + /** Ids whose model admission is current. */ + get eligible() { + return eligible; + }, + + /** + * Per-frame pass: re-derive eligibility at the policy's cadence, then + * place every admitted model and hand each item's visual to its model + * only once the model is ready and placed (`item.modelOwnsVisual`). + * @param {Map} items + * @param {number} nowMs + * @returns {boolean} Whether any model is drawn. + */ + frame(items, nowMs) { + if (destroyed) return false; + const prefs = preferences(); + if (!regime(prefs)) { + settling = 0; + if (models.size || pending.size) releaseAll(); + eligible = new Set(); + for (const item of items.values()) item.modelOwnsVisual = false; + return false; + } + // A model's look under NVG/FLIR is set when it is built (a custom + // shader on a ready model does not take), so a style change rebuilds. + const boost = prefs.irBoost === true; + if (boost !== irBoost) { + releaseAll(); + irBoost = boost; + lastEligibilityAt = -Infinity; + } + if (nowMs - lastEligibilityAt >= policy.intervalMs) { + lastEligibilityAt = nowMs; + refreshEligibility(items, prefs); + } + const occluder = horizonOccluder(viewer.camera); + let drawn = false; + settling = pending.size; + for (const [id, item] of items) { + const model = models.get(id); + item.modelOwnsVisual = false; + if (!model) continue; + if ( + !item.position || + item.show === false || + !occluder.isPointVisible(item.position) + ) { + model.show = false; + continue; + } + if (item.spec.key !== model._gevSpecKey) { + // A new type or size: the next eligibility pass loads it. + model.show = false; + lastEligibilityAt = -Infinity; + continue; + } + const tint = item.spec.tinted && !irBoost ? item.tint : null; + if (tint && !Cesium.Color.equals(tint, model._gevTint)) { + model.customShader = fleetTintShader(tint); + model._gevTint = tint; + } + fleetModelMatrix( + placement(item), + item.headingDeg, + item.spec, + model.modelMatrix, + scratchHpr, + ); + if (!model.ready) { + model.show = false; + settling += 1; + continue; + } + model.show = true; + item.modelOwnsVisual = true; + drawn = true; + } + for (const id of models.keys()) if (!items.has(id)) release(id); + return drawn; + }, + + /** + * Models loading or not yet ready after the last frame: the layer keeps + * frames coming while any are, so each one takes over as soon as it can. + */ + get settling() { + return settling; + }, + + /** + * Whether models are drawn now: 3D on, the set loaded and the camera + * below the altitude ceiling. A layer skips building items while not. + * @returns {boolean} + */ + active() { + return !destroyed && regime(preferences()); + }, + + /** The model drawing `id`, if one is admitted. */ + modelFor(id) { + return models.get(id) || null; + }, + + /** Show or hide every model (the layer's own visibility). */ + setVisible(show) { + if (!collection.isDestroyed()) collection.show = show; + }, + + /** Drop every model (3D off, layer disabled). */ + clear: releaseAll, + + /** Number of admitted models (tests and diagnostics). */ + get size() { + return models.size; + }, + + destroy() { + destroyed = true; + releaseAll(); + if (!viewer.isDestroyed?.() && !collection.isDestroyed()) + viewer.scene.primitives.remove(collection); + }, + }; +} diff --git a/src/data/fleetModelInstances.test.mjs b/src/data/fleetModelInstances.test.mjs new file mode 100644 index 000000000..1892e1751 --- /dev/null +++ b/src/data/fleetModelInstances.test.mjs @@ -0,0 +1,487 @@ +// The icon-to-model handoff the Vessels, Transit and Rail layers share +// (fleetModelInstances.js): models only under the DISPLAY 3D toggle and the +// altitude ceiling, at most `cap` of the nearest, each vehicle's icon the +// visual until its model is loaded, ready and placed, and a tinted model +// painted with its route colour. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import * as Cesium from 'cesium'; +import { + createFleetModel, + createFleetModelLayer, + fleetModelMatrix, + fleetTintShader, + FLEET_HEADING_OFFSET_DEG, +} from './fleetModelInstances.js'; +import { + loadFleetModelSet, + transitModelSpec, + vesselModelSpec, +} from './fleetModels.js'; +import { fetchPublicFile } from '../testSupport/fleetGlb.mjs'; + +const realInfo = console.info; +console.info = () => {}; +const set = await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = realInfo; + +const POLICY = Object.freeze({ + altitudeCeilingM: 800_000, + minimumPixelSize: 16, + cap: 2, + capAll: 4, + addM: 5_000, + addAllM: 20_000, + keepM: 6_000, + keepAllM: 24_000, + intervalMs: 80, +}); + +const CENTRE = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, 0); + +function fakeViewer(height = 1_000) { + const primitives = []; + const position = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, height); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + position, + new Cesium.Cartesian3(), + ); + const camera = { + positionWC: position, + positionCartographic: { height }, + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; + return { + camera, + scene: { + primitives: { + add(p) { + primitives.push(p); + return p; + }, + remove(p) { + const i = primitives.indexOf(p); + if (i >= 0) primitives.splice(i, 1); + return i >= 0; + }, + }, + }, + primitives, + isDestroyed: () => false, + }; +} + +function fakeModel(options) { + return { + ...options, + ready: false, + show: true, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; +} + +/** A vehicle `metres` east of the camera's foot. */ +function itemAt(metres, spec, extra = {}) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(CENTRE); + const position = Cesium.Matrix4.multiplyByPoint( + enu, + new Cesium.Cartesian3(metres, 0, 0), + new Cesium.Cartesian3(), + ); + return { position, headingDeg: 90, spec, tint: null, pickId: null, ...extra }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('models load from the up-front set, never by URL', async () => { + const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 32 }); + let options = null; + await createFleetModel( + spec, + { scale: spec.scale }, + { + set, + resolveAsset: (url) => `/base${url}`, + load: async (o) => { + options = o; + return {}; + }, + }, + ); + assert.ok(options.gltf instanceof Uint8Array, 'the loaded GLB bytes'); + assert.equal(options.gltf, set.bytes.get(spec.model)); + assert.equal(options.basePath, `/base${spec.url}`); + assert.equal(options.url, undefined, 'no fetch by URL'); + assert.deepEqual({ ...options.environmentMapOptions }, { enabled: false }); + await assert.rejects( + createFleetModel({ model: 'missing' }, {}, { set, load: async () => ({}) }), + /not loaded/, + ); +}); + +test('the icon stays the visual until the model is ready; only the nearest are drawn', async () => { + const viewer = fakeViewer(); + const display = { models3d: true, models3dMode: 'proximity' }; + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => display, + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel({ spec, ...options }); + created.push(model); + return model; + }, + }); + const spec = transitModelSpec(set, { mode: 'tram' }); + const items = new Map([ + ['near', itemAt(100, spec, { pickId: 'near' })], + ['mid', itemAt(1_000, spec, { pickId: 'mid' })], + ['far', itemAt(4_000, spec, { pickId: 'far' })], + ]); + layer.frame(items, 0); + await flush(); + assert.equal(created.length, POLICY.cap, 'the cap holds'); + assert.deepEqual( + created.map((m) => m.id).sort(), + ['mid', 'near'], + 'the nearest are modelled', + ); + assert.equal(created[0].scale, spec.scale, 'drawn life size'); + assert.equal(created[0].minimumPixelSize, POLICY.minimumPixelSize); + + layer.frame(items, 100); + for (const item of items.values()) + assert.equal(item.modelOwnsVisual, false, 'not ready: the icon draws'); + assert.ok( + layer.settling > 0, + 'the layer asks for frames while models settle', + ); + + for (const model of created) model.ready = true; + layer.frame(items, 200); + assert.equal(items.get('near').modelOwnsVisual, true); + assert.equal(items.get('mid').modelOwnsVisual, true); + assert.equal(items.get('far').modelOwnsVisual, false); + assert.equal(layer.settling, 0); + + // A placed model stands on its item's point, nose along its heading. + const near = created.find((m) => m.id === 'near'); + const origin = Cesium.Matrix4.getTranslation( + near.modelMatrix, + new Cesium.Cartesian3(), + ); + assert.ok( + Cesium.Cartesian3.distance(origin, items.get('near').position) < 1e-6, + ); + + // 3D off: every model goes and every icon comes back. + display.models3d = false; + layer.frame(items, 300); + assert.equal(layer.size, 0); + for (const item of items.values()) assert.equal(item.modelOwnsVisual, false); + assert.ok( + created.every((m) => m.destroyed), + 'released models are destroyed', + ); + layer.destroy(); + assert.equal(viewer.primitives.length, 0); +}); + +test('models stay off above the altitude ceiling', async () => { + const viewer = fakeViewer(POLICY.altitudeCeilingM + 1); + let calls = 0; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + calls += 1; + return fakeModel(options); + }, + }); + assert.equal(layer.active(), false); + layer.frame(new Map([['a', itemAt(0, transitModelSpec(set, {}))]]), 0); + await flush(); + assert.equal(calls, 0); + layer.destroy(); +}); + +test('a tinted model is painted with its route colour; an untinted one is not', async () => { + const viewer = fakeViewer(); + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel(options); + created.push(model); + return model; + }, + }); + const red = Cesium.Color.RED; + const tram = transitModelSpec(set, { mode: 'tram' }); + const ship = vesselModelSpec(set, { type: '70', length: 180 }); + assert.equal(tram.tinted, true); + assert.equal(ship.tinted, false); + layer.frame( + new Map([ + ['tram', itemAt(10, tram, { pickId: 'tram', tint: red })], + ['ship', itemAt(20, ship, { pickId: 'ship', tint: red })], + ]), + 0, + ); + await flush(); + const byId = Object.fromEntries(created.map((m) => [m.id, m])); + assert.equal(byId.tram.customShader, fleetTintShader(red)); + assert.equal(byId.ship.customShader, undefined); + assert.equal( + fleetTintShader(red), + fleetTintShader(Cesium.Color.RED), + 'one shader per colour', + ); + layer.destroy(); +}); + +test('an elevated vehicle is raised by its type elevation', async () => { + const viewer = fakeViewer(); + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel({ ...options, ready: true }); + model.ready = true; + created.push(model); + return model; + }, + }); + const monorail = transitModelSpec(set, { mode: 'monorail' }); + assert.equal(monorail.elevationM, set.types.transit.elevation.monorail); + const item = itemAt(10, monorail, { pickId: 'm' }); + const items = new Map([['m', item]]); + layer.frame(items, 0); + await flush(); + layer.frame(items, 100); + const origin = Cesium.Matrix4.getTranslation( + created[0].modelMatrix, + new Cesium.Cartesian3(), + ); + const lifted = Cesium.Cartographic.fromCartesian(origin).height; + const ground = Cesium.Cartographic.fromCartesian(item.position).height; + assert.ok(Math.abs(lifted - ground - monorail.elevationM) < 1e-3); + layer.destroy(); +}); + +test('a vessel stretched to its reported beam keeps its length scale on the model', () => { + const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 60 }); + assert.ok(spec.scaleXYZ[1] > spec.scaleXYZ[0], 'a wide hull for its length'); + const matrix = fleetModelMatrix( + CENTRE, + 0, + spec, + new Cesium.Matrix4(), + new Cesium.HeadingPitchRoll(), + ); + const scale = Cesium.Matrix4.getScale(matrix, new Cesium.Cartesian3()); + // Cesium's axis correction puts the beam on local X, the length on local Y. + assert.ok(Math.abs(scale.x - spec.scaleXYZ[1] / spec.scaleXYZ[0]) < 1e-9); + assert.ok(Math.abs(scale.y - 1) < 1e-9); + assert.equal(FLEET_HEADING_OFFSET_DEG, 180, 'fleet models are built nose -X'); +}); + +test('Vessels, Transit and Rail draw fleet models under the shared 3D toggle', () => { + for (const file of [ + 'src/layers/vessels/rendering.js', + 'src/layers/transit/rendering.js', + 'src/layers/rail/rendering.js', + ]) { + const source = fs.readFileSync( + new URL(`../../${file}`, import.meta.url), + 'utf8', + ); + assert.match( + source, + /createFleetModelLayer\(/, + `${file}: draws fleet models`, + ); + assert.match( + source, + /loadFleetModelSet\(/, + `${file}: loads the set up front`, + ); + assert.match( + source, + /modelOwnsVisual/, + `${file}: hands its icon to the model`, + ); + } + for (const file of [ + 'src/app/layers/aisLiveVessels.js', + 'src/app/layers/transit.js', + 'src/app/layers/rail.js', + ]) { + const source = fs.readFileSync( + new URL(`../../${file}`, import.meta.url), + 'utf8', + ); + assert.match( + source, + /fleet: fleetModelServices\(/, + `${file}: given the fleet service`, + ); + } + const catalog = fs.readFileSync( + new URL('../app/constructCatalog.js', import.meta.url), + 'utf8', + ); + assert.equal( + catalog.match(/displayParams: \(\) => flights\.getParams\(\)/g)?.length, + 4, + 'Local ADS-B, Vessels, Transit and Rail follow the Flights 3D toggle', + ); +}); + +/** A layer whose models are all ready as soon as they are created. */ +function readyLayer({ display, waterline = false } = {}) { + const viewer = fakeViewer(); + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => display, + policy: POLICY, + waterline, + createModel: async (spec, options) => { + const model = fakeModel(options); + model.ready = true; + created.push(model); + return model; + }, + }); + return { layer, created }; +} + +test('a model stands on the floor its icon is lifted above', async () => { + const { layer, created } = readyLayer({ display: { models3d: true } }); + const tram = transitModelSpec(set, { mode: 'tram' }); + const item = itemAt(10, tram, { pickId: 't', groundLiftM: 1.5 }); + const items = new Map([['t', item]]); + layer.frame(items, 0); + await flush(); + layer.frame(items, 100); + const origin = Cesium.Matrix4.getTranslation( + created[0].modelMatrix, + new Cesium.Cartesian3(), + ); + const drop = + Cesium.Cartographic.fromCartesian(item.position).height - + Cesium.Cartographic.fromCartesian(origin).height; + assert.ok(Math.abs(drop - 1.5) < 1e-3, `dropped ${drop} m`); + layer.destroy(); +}); + +test('under NVG/FLIR models are rebuilt unlit white, and rebuilt again on exit', async () => { + const display = { models3d: true, irBoost: false }; + const { layer, created } = readyLayer({ display }); + const tram = transitModelSpec(set, { mode: 'tram' }); + const items = new Map([ + ['t', itemAt(10, tram, { pickId: 't', tint: Cesium.Color.RED })], + ]); + layer.frame(items, 0); + await flush(); + assert.equal(created[0].customShader, fleetTintShader(Cesium.Color.RED)); + display.irBoost = true; + layer.frame(items, 100); + assert.ok(created[0].destroyed, 'the lit model is released'); + await flush(); + const boosted = created[1]; + assert.equal(boosted.customShader.lightingModel, Cesium.LightingModel.UNLIT); + assert.ok(Cesium.Color.equals(boosted.color, Cesium.Color.WHITE)); + assert.equal(boosted.colorBlendAmount, 1); + display.irBoost = false; + layer.frame(items, 200); + await flush(); + assert.ok(boosted.destroyed); + assert.equal(created[2].customShader, fleetTintShader(Cesium.Color.RED)); + layer.destroy(); +}); + +test('a waterline layer clips each model at its origin, keeping the part above', async () => { + const { layer, created } = readyLayer({ + display: { models3d: true }, + waterline: true, + }); + const ship = vesselModelSpec(set, { type: '70', length: 200, beam: 32 }); + layer.frame(new Map([['s', itemAt(10, ship, { pickId: 's' })]]), 0); + await flush(); + const planes = created[0].clippingPlanes; + assert.ok(planes instanceof Cesium.ClippingPlaneCollection); + assert.equal(planes.length, 1); + const plane = planes.get(0); + assert.equal(plane.distance, 0); + assert.ok(Cesium.Cartesian3.equals(plane.normal, Cesium.Cartesian3.UNIT_Z)); + assert.equal(planes.unionClippingRegions, false); + // Cesium keeps a point where normal · p + distance >= 0: above the waterline. + const above = Cesium.Cartesian3.dot( + plane.normal, + new Cesium.Cartesian3(0, 0, 3), + ); + const below = Cesium.Cartesian3.dot( + plane.normal, + new Cesium.Cartesian3(0, 0, -3), + ); + assert.ok(above + plane.distance > 0 && below + plane.distance < 0); + layer.destroy(); + + const plain = readyLayer({ display: { models3d: true } }); + plain.layer.frame( + new Map([['t', itemAt(10, transitModelSpec(set, { mode: 'tram' }))]]), + 0, + ); + await flush(); + assert.equal(plain.created[0].clippingPlanes, undefined, 'others unclipped'); + plain.layer.destroy(); +}); + +test('a vehicle drawn as its model keeps its card and its detection entry', () => { + const read = (file) => + fs.readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8'); + assert.match( + read('src/layers/transit/selection.js'), + /marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/, + ); + assert.match( + read('src/layers/rail/selection.js'), + /entry\.marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/, + ); + assert.match( + read('src/layers/transit/queries.js'), + /marker\.show === false && entry\.modelOwnsVisual !== true/, + ); + assert.match( + read('src/layers/vessels/queries.js'), + /!visual\.billboard\.show &&\s*!visual\.modelOwnsVisual/, + ); + assert.match( + read('src/layers/transit/rendering.js'), + /entry\.mode === 'unknown'/, + 'a vehicle of unknown mode keeps its icon', + ); +}); diff --git a/src/data/fleetModels.js b/src/data/fleetModels.js new file mode 100644 index 000000000..6b436bae6 --- /dev/null +++ b/src/data/fleetModels.js @@ -0,0 +1,545 @@ +/** + * @module fleetModels + * @description The project's procedural fleet models (`public/models/fleet/`): + * which model draws an aircraft, a vessel or a transit or rail vehicle, and at + * what size, from the set's `catalog.json` (each model's file, bounds, + * length, beam, clearance and icon class) and `types.json` (which model + * draws which type, and the fallbacks). + * + * Every model is metres, glTF Y-up, nose toward local -X, origin at the body + * centre (an aircraft's fuselage axis, a vessel's waterline, a road or rail + * vehicle's road or rail top), and is drawn life size: a type's published + * length over the model's built length is its scale. + * + * The resolvers are plain functions over the parsed JSON; `loadFleetModelSet` + * fetches the JSON and every GLB once, up front, so no model is fetched + * mid-session. + */ + +import { classifyAircraft } from './aircraftClass.js'; +import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; + +/** Where the model set is served, relative to the application base. */ +export const FLEET_MODEL_BASE = '/models/fleet/'; + +/** + * Longest and widest hulls afloat, metres (Seawise Giant 458 m; the TI + * tankers 68 m abeam). AIS gives the bow and stern from the antenna in 9 bits + * and each side in 6, their largest values (511, 63) meaning "that or more" + * or a set never configured: a 30 m tug reported 511 m long would be drawn 17 + * times its size. + */ +const MAX_HULL_LENGTH_M = 460; +const MAX_HULL_BEAM_M = 75; + +/** The model a known aircraft class draws when its type has no entry. */ +const CLASS_MODELS = Object.freeze({ + light: 'c172', + glider: 'glider', + turboprop: 'at72', + widebody: 'b789', + quadjet: 'b744', + helicopter: 'generic_heli', + fastjet: 't38', + bizjet: 'c56x', + uav: 'mq9', +}); + +/** OpenSky's integer aircraft category (state vector index 17) → ADS-B emitter category. */ +const OPENSKY_EMITTER_CATEGORY = Object.freeze({ + 2: 'A1', + 3: 'A2', + 4: 'A3', + 5: 'A4', + 6: 'A5', + 7: 'A6', + 8: 'A7', + 9: 'B1', + 10: 'B2', + 11: 'B3', + 12: 'B4', + 14: 'B6', + 15: 'B7', + 16: 'C1', + 17: 'C2', + 18: 'C3', + 19: 'C4', + 20: 'C5', +}); + +/** A text vessel type (as some AIS sources name it) → a representative AIS ship-type code. */ +const TEXT_SHIP_TYPES = [ + [/tanker|lng|lpg|gas carrier/i, 80], + [/cargo|container|bulk|carrier|freighter|ro-?ro/i, 70], + [/passenger|ferry|cruise/i, 60], + [/high.?speed/i, 40], + [/fishing|trawler/i, 30], + [/tug|towing/i, 52], + [/pilot/i, 50], + [/search|rescue|\bsar\b/i, 51], + [/dredg/i, 33], + [/military|naval|navy/i, 35], + [/law|police|patrol|coast ?guard/i, 55], + [/sail/i, 36], + [/pleasure|yacht/i, 37], +]; + +/** + * The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B + * emitter category `category` and callsign `callsign`: the first of the + * type's callsign rules (`aircraft_callsign`) the callsign matches, else the + * type's, else the category's, else `default_aircraft`. An airport vehicle + * (type ZVEH) is a fire truck, tug or van by its callsign, else a car. + * @param {object} types Parsed `types.json`. + * @param {string} [type] + * @param {string} [category] + * @param {string} [callsign] + * @returns {{model: string, length: number|null}} + */ +export function resolveAircraft(types, type, category, callsign) { + const t = type?.trim().toUpperCase(); + const index = aircraftCallsignRule(types, t, callsign); + if (index >= 0) { + const rule = types.aircraft_callsign[t][index]; + return { model: rule.model, length: rule.length }; + } + const [model, length] = + (t && (types.aircraft[t] ?? types.helicopters[t])) || + (category && types.categories[category]) || + types.default_aircraft; + return { model, length }; +} + +/** + * A callsign as the rules read it: upper case, spaces removed. + * @param {string} callsign + * @returns {string} + */ +function callsignKey(callsign) { + return String(callsign).toUpperCase().replace(/\s+/g, ''); +} + +/** + * Which of type `type`'s callsign rules (`types.json`'s + * `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match + * wins) `callsign` matches: its index, -1 when none does or the type has + * none. A callsign matches a rule when, upper-cased with spaces removed, it + * starts with one of the rule's prefixes. + * @param {object} types Parsed `types.json`. + * @param {string|null} [type] ICAO type. + * @param {string|null} [callsign] + * @returns {number} + */ +export function aircraftCallsignRule(types, type, callsign) { + const t = String(type ?? '') + .trim() + .toUpperCase(); + const rules = t ? types.aircraft_callsign?.[t] : undefined; + if (!rules || !callsign) return -1; + const c = callsignKey(callsign); + return rules.findIndex((rule) => + rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))), + ); +} + +/** + * Whether an aircraft of ICAO type `type` picks its model by callsign. + * @param {object} types Parsed `types.json`. + * @param {string|null} [type] + * @returns {boolean} + */ +export function aircraftModelByCallsign(types, type) { + const t = String(type ?? '') + .trim() + .toUpperCase(); + return Boolean(t && types.aircraft_callsign?.[t]); +} + +/** + * A reported length as one a hull can have, else undefined (drawn as built). + * @param {number} [length] + * @returns {number|undefined} + */ +export function hullLength(length) { + return length !== undefined && + length !== null && + length > 0 && + length <= MAX_HULL_LENGTH_M + ? length + : undefined; +} + +/** + * A reported beam as one a hull can have, else undefined. + * @param {number} [beam] + * @returns {number|undefined} + */ +export function hullBeam(beam) { + return beam !== undefined && + beam !== null && + beam > 0 && + beam <= MAX_HULL_BEAM_M + ? beam + : undefined; +} + +/** + * The model for a vessel of AIS ship type `shipType` and the reported length + * and beam (metres) and name: the first rule that matches in the first type + * range that has one, else `default_vessel`. Drawn at the reported length; + * when it is unknown (most live feeds send none), chosen and drawn at the + * range's `typical_len`, else as built. + * @param {object} types Parsed `types.json`. + * @param {number} [shipType] + * @param {number} [length] + * @param {number} [beam] + * @param {string} [name] + * @returns {{model: string, length: number|null}} + */ +export function resolveVessel(types, shipType, length, beam, name) { + const L = hullLength(length) ?? 0; + const B = hullBeam(beam) ?? 0; + if (shipType !== undefined && shipType !== null) { + for (const group of types.vessels) { + if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue; + const len = L > 0 ? L : (group.typical_len ?? 0); + const drawn = len > 0 ? len : null; + for (const rule of group.rules) { + if (rule.min_len && len < rule.min_len) continue; + if (rule.max_len && len > rule.max_len) continue; + if (rule.name_re && !new RegExp(rule.name_re).test(name ?? '')) + continue; + if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue; + return { model: rule.model, length: drawn }; + } + } + } + return { model: types.default_vessel, length: L > 0 ? L : null }; +} + +/** + * The model for a public transport vehicle: a rail category and mode + * (`transit`/`local`/`regional`/`national`/`international` × `rail`/`metro`/ + * `tram`), else a GTFS `route_type` (basic 0-12 or extended 100-1507), else a + * mode name (`bus`, `tram`, `subway`, `rail`, `ferry`, `light_rail`, …), + * else `transit.default`. Each comes with the length to draw it at. + * @param {object} types Parsed `types.json`. + * @param {{railCategory?: string, railMode?: string, routeType?: number|string, + * mode?: string}} vehicle + * @returns {{model: string, length: number|null}} + */ +export function resolveTransit( + types, + { railCategory, railMode, routeType, mode } = {}, +) { + const transit = types.transit; + const rail = railCategory ? transit.rail[railCategory] : null; + const routeKey = + routeType === undefined || routeType === null || routeType === '' + ? null + : String(Number(routeType)); + const [model, length] = + (rail && (rail[railMode || 'rail'] ?? rail.rail)) || + (routeKey && transit.route_types[routeKey]) || + (mode && transit.modes[mode]) || + transit.default; + return { model, length }; +} + +/** + * An AIS ship type as its numeric code: a code as given ("70"), or the code + * of the family a text type names ("Cargo" → 70); undefined when it names + * none. + * @param {string|number|null|undefined} type + * @returns {number|undefined} + */ +export function aisShipTypeCode(type) { + const text = String(type ?? '').trim(); + if (!text) return undefined; + if (/^\d+$/.test(text)) { + const code = Number(text); + return code > 0 && code < 100 ? code : undefined; + } + for (const [pattern, code] of TEXT_SHIP_TYPES) + if (pattern.test(text)) return code; + return undefined; +} + +/** + * An aircraft category as an ADS-B emitter category: an emitter string as + * given ("A3"), or OpenSky's integer category mapped to one. + * @param {string|number|null|undefined} category + * @returns {string|undefined} + */ +export function emitterCategory(category) { + if (typeof category === 'number') + return OPENSKY_EMITTER_CATEGORY[category] || undefined; + const text = String(category ?? '') + .trim() + .toUpperCase(); + if (/^\d+$/.test(text)) return OPENSKY_EMITTER_CATEGORY[Number(text)]; + return /^[A-D][0-7]$/.test(text) ? text : undefined; +} + +/** + * The scale of model `model` drawn `length` metres long, along the model's + * length, beam and height. A vessel's reported beam stretches it sideways; + * its height follows the mean of the two. + * @param {object} model Catalog entry. + * @param {number|null} length + * @param {number} [beam] A vessel's reported beam. + * @returns {[number, number, number]} + */ +export function placeScale(model, length, beam) { + if (length === null || length === undefined || !(model.length > 0)) + return [1, 1, 1]; + const sx = length / model.length; + const b = hullBeam(beam); + const sy = b !== undefined && model.beam > 0 ? b / model.beam : sx; + return [sx, sy, (sx + sy) / 2]; +} + +/** Half the diagonal of a model's bounds: the radius Cesium bounds it by. */ +function boundingRadius(model) { + const [x0, y0, z0] = model.min; + const [x1, y1, z1] = model.max; + return Math.hypot(x1 - x0, y1 - y0, z1 - z0) / 2; +} + +/** + * The drawing of model `name` at `length` metres: what the layers place. + * + * `scale` is the uniform scale (along the model's length) a Cesium Model + * takes; `scaleXYZ` the model's length, beam and height scales, for a vessel + * stretched to its reported beam. `clearanceM` is how far the model reaches + * below its origin at that scale (wheels below an aircraft's axis, a keel + * below the waterline): a grounded aircraft stands its origin that far up. + * `elevationM` lifts a vehicle off the route it follows (a monorail, a + * gondola). `tinted` models carry the `_TINT` vertex attribute marking where + * a route colour applies. `nativeRadiusM` (unscaled), `visualCenterNative` + * and `trailAnchorNative` (raw glTF axes, unscaled) serve the aircraft + * layers' pixel-size, bracket and trail geometry. + * @param {{catalog: object, types: object}} set + * @param {string} name Catalog model name. + * @param {number|null} length Metres along the body; null draws it as built. + * @param {{beam?: number}} [options] + * @returns {object} + */ +export function fleetModelSpec(set, name, length, { beam } = {}) { + const model = set.catalog[name]; + // Ground vehicles, balloons and obstacles are built at the size their type + // names (a tower's 60 m is its height, not a length to scale to). + const drawnLength = model.category === 'other' ? null : length; + const scaleXYZ = placeScale(model, drawnLength, beam); + const elevationM = set.types.transit?.elevation?.[name] || 0; + return Object.freeze({ + model: name, + url: FLEET_MODEL_BASE + model.file, + category: model.category, + icon: model.icon, + length: model.length * scaleXYZ[0], + scale: scaleXYZ[0], + scaleXYZ: Object.freeze(scaleXYZ), + clearanceM: model.clearance * scaleXYZ[2], + elevationM, + tinted: model.category === 'transit', + nativeRadiusM: boundingRadius(model), + visualCenterNative: CENTRED, + trailAnchorNative: FLEET_TRAIL_ANCHORS[name] || CENTRED, + key: `${name}@${scaleXYZ.map((s) => s.toFixed(4)).join('x')}`, + }); +} + +const CENTRED = Object.freeze([0, 0, 0]); + +/** `resolved` drawn from `set`, its model replaced by `fallback` when the catalog lacks it. */ +function specOf(set, resolved, fallback, options) { + const known = set.catalog[resolved.model] ? resolved : fallback(resolved); + return fleetModelSpec(set, known.model, known.length, options); +} + +/** + * The model an aircraft is drawn with. The type's callsign rules (an airport + * vehicle by its callsign), then the type's entry; then, for a + * type the map lacks, the model of its class (a fast jet, a helicopter, a + * glider) when the type names one; then the emitter category's; then + * `default_aircraft`. + * @param {{catalog: object, types: object}} set + * @param {{typeCode?: string|null, type?: string|null, + * category?: string|number|null, callsign?: string|null}} aircraft + * @returns {object} {@link fleetModelSpec} + */ +export function aircraftModelSpec( + set, + { typeCode, type, category, callsign } = {}, +) { + const { types, catalog } = set; + const code = String(typeCode ?? type ?? '') + .trim() + .toUpperCase(); + const emitter = emitterCategory(category); + const sign = callsign ? String(callsign) : undefined; + let resolved = resolveAircraft(types, code, emitter, sign); + // A callsign rule names the model outright; a type's class only stands in + // for a type the map lacks. + const ruled = aircraftCallsignRule(types, code, sign) >= 0; + const typed = code && (types.aircraft[code] || types.helicopters[code]); + if (!ruled && !typed && code) { + const model = CLASS_MODELS[classifyAircraft({ typeCode: code })]; + if (model && catalog[model]) + resolved = { model, length: catalog[model].length }; + } + return specOf(set, resolved, () => ({ + model: types.default_aircraft[0], + length: types.default_aircraft[1], + })); +} + +/** + * The model a vessel is drawn with, from its AIS ship type (a code or a text + * family), reported length and beam (metres) and name. + * @param {{catalog: object, types: object}} set + * @param {{type?: string|number|null, length?: number|null, + * beam?: number|null, name?: string|null}} vessel + * @returns {object} {@link fleetModelSpec} + */ +export function vesselModelSpec(set, { type, length, beam, name } = {}) { + // A beam counts only beside a reported length: a vessel drawn at its type's + // typical length keeps the model's proportions. + const reported = hullLength(length ?? undefined); + const hullWidth = reported === undefined ? undefined : (beam ?? undefined); + const resolved = resolveVessel( + set.types, + aisShipTypeCode(type), + reported, + hullWidth, + name ?? undefined, + ); + return specOf( + set, + resolved, + (r) => ({ model: set.types.default_vessel, length: r.length }), + { beam: hullWidth }, + ); +} + +/** + * The model a transit or rail vehicle is drawn with ({@link resolveTransit}). + * @param {{catalog: object, types: object}} set + * @param {object} vehicle + * @returns {object} {@link fleetModelSpec} + */ +export function transitModelSpec(set, vehicle) { + return specOf(set, resolveTransit(set.types, vehicle), () => ({ + model: set.types.transit.default[0], + length: set.types.transit.default[1], + })); +} + +/** + * Every model the type map names, defaults and fallbacks included. + * @param {object} types Parsed `types.json`. + * @returns {Set} + */ +export function namedFleetModels(types) { + const pairs = (map) => Object.values(map).map(([model]) => model); + const transit = types.transit; + return new Set([ + ...pairs(types.aircraft), + ...pairs(types.helicopters), + ...pairs(types.categories), + ...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) => + rules.map((rule) => rule.model), + ), + ...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)), + types.default_aircraft[0], + types.default_vessel, + ...pairs(transit.modes), + ...pairs(transit.route_types), + ...Object.values(transit.rail).flatMap(pairs), + transit.default[0], + ...Object.keys(transit.elevation), + ...Object.values(CLASS_MODELS), + ]); +} + +let loading = null; +let loaded = null; + +/** + * The model set once {@link loadFleetModelSet} has loaded it, else null. + * @returns {object|null} + */ +export function fleetModelSet() { + return loaded; +} + +/** + * Fetch the model set once for every layer: `catalog.json`, `types.json` and + * every GLB the catalog names, all up front, so no model is fetched while a + * session runs. Resolves to `{catalog, types, bytes, loadMs, totalBytes}`, + * `bytes` holding each model's GLB by name; a GLB that fails to load is left + * out (that model then draws nothing and its entities stay icons). + * @param {{resolveAsset?: (url: string) => string, + * fetchImpl?: typeof fetch, now?: () => number}} [options] + * @returns {Promise} + */ +export function loadFleetModelSet({ + resolveAsset = (url) => url, + fetchImpl = globalThis.fetch, + now = () => globalThis.performance?.now?.() ?? Date.now(), +} = {}) { + if (loading) return loading; + const started = now(); + const json = async (name) => { + const response = await fetchImpl(resolveAsset(FLEET_MODEL_BASE + name)); + if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`); + return response.json(); + }; + loading = (async () => { + const [catalog, types] = await Promise.all([ + json('catalog.json'), + json('types.json'), + ]); + const bytes = new Map(); + let totalBytes = 0; + await Promise.all( + Object.entries(catalog).map(async ([name, model]) => { + try { + const response = await fetchImpl( + resolveAsset(FLEET_MODEL_BASE + model.file), + ); + if (!response.ok) return; + const glb = new Uint8Array(await response.arrayBuffer()); + bytes.set(name, glb); + totalBytes += glb.byteLength; + } catch { + /* that model draws nothing; its entities stay icons */ + } + }), + ); + loaded = Object.freeze({ + catalog, + types, + bytes, + loadMs: now() - started, + totalBytes, + }); + console.info( + `[Fleet] ${bytes.size}/${Object.keys(catalog).length} models ` + + `(${(totalBytes / 1048576).toFixed(2)} MB) loaded in ${Math.round(loaded.loadMs)} ms`, + ); + return loaded; + })(); + // A failed catalog load may be retried by the next layer that starts. + loading.catch(() => { + loading = null; + }); + return loading; +} + +/** Forget the loaded set (tests). */ +export function resetFleetModelSet() { + loading = null; + loaded = null; +} diff --git a/src/data/fleetModels.test.mjs b/src/data/fleetModels.test.mjs new file mode 100644 index 000000000..f9ef47bc6 --- /dev/null +++ b/src/data/fleetModels.test.mjs @@ -0,0 +1,533 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + FLEET_MODEL_BASE, + aircraftModelSpec, + aisShipTypeCode, + emitterCategory, + fleetModelSet, + fleetModelSpec, + hullBeam, + hullLength, + loadFleetModelSet, + namedFleetModels, + placeScale, + resetFleetModelSet, + resolveAircraft, + resolveTransit, + resolveVessel, + transitModelSpec, + vesselModelSpec, +} from './fleetModels.js'; +import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; +import { + centrelineSegments, + distanceToSurface, + fleetMesh, + measuredTrailAnchor, + readGlb, + vertexBounds, +} from '../testSupport/fleetGlb.mjs'; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const DIR = path.join(ROOT, 'public', 'models', 'fleet'); +const json = (name) => + JSON.parse(fs.readFileSync(path.join(DIR, name), 'utf8')); +const catalog = json('catalog.json'); +const types = json('types.json'); +const set = { catalog, types }; + +// A slice of the set's types.json and catalog.json, as the resolver's own +// reference tests use, so these read independently of later model changes. +const sliceTypes = { + aircraft: { + A320: ['a320', 37.57], + A21N: ['a320', 44.51], + A319: ['a320', 33.84], + B38M: ['b738', 39.52], + ZVEH: ['car', 4.61], + GRND: ['radio_mast', 1.9], + }, + helicopters: { EC35: ['ec35', 10.2] }, + aircraft_callsign: { + ZVEH: [ + { prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }, + { prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 }, + { prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 }, + ], + }, + categories: { A1: ['c172', 8.3] }, + vessels: [ + { + types: [70, 79], + typical_len: 300, + rules: [ + { min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' }, + { + name_re: '\\b(LEADER|HIGHWAY|ACE|GLOVIS|HOEGH|SPIRIT OF)\\b', + min_len: 150, + model: 'car_carrier', + }, + { min_len: 250, model: 'container_ship' }, + { model: 'general_cargo' }, + ], + }, + { types: [31, 32], rules: [{ model: 'tug' }] }, + { types: [52, 52], rules: [{ model: 'tug' }] }, + { types: [0, 99], rules: [{ model: 'generic_vessel' }] }, + ], + default_aircraft: ['generic_plane', 36], + default_vessel: 'generic_vessel', +}; +const model = (category, file, length, beam, clearance) => ({ + category, + file, + min: [0, 0, 0], + max: [0, 0, 0], + length, + beam, + clearance, + tris: 1, + icon: 'jet', +}); +const sliceCatalog = { + a320: model('aircraft', 'aircraft/a320.glb', 37.57, 35.6, 3.063), + container_ship: model('ships', 'ships/container_ship.glb', 309.9, 48, 13.9), +}; + +test('aircraft resolve by type, then emitter category, then the generic plane', () => { + assert.deepEqual(resolveAircraft(sliceTypes, 'A320'), { + model: 'a320', + length: 37.57, + }); + assert.deepEqual(resolveAircraft(sliceTypes, ' b38m '), { + model: 'b738', + length: 39.52, + }); + assert.equal(resolveAircraft(sliceTypes, 'EC35').model, 'ec35'); + assert.deepEqual(resolveAircraft(sliceTypes, 'ZZZZ', 'A1'), { + model: 'c172', + length: 8.3, + }); + assert.deepEqual(resolveAircraft(sliceTypes, undefined, 'Q9'), { + model: 'generic_plane', + length: 36, + }); +}); + +test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => { + const zveh = (callsign) => + resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign); + assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 }); + assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), { + model: 'fire_truck', + length: 11.01, + }); + assert.equal(zveh('PUSH 14').model, 'pushback'); + assert.equal(zveh('TUG3').model, 'pushback'); + assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 }); + assert.equal(zveh('SQUID20').model, 'van'); + assert.equal(zveh('SQB9').model, 'van'); + assert.equal(zveh('S Q B 9').model, 'van'); + assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 }); + assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 }); + // Callsign rules belong to their type: a fire-named airliner is still an airliner. + assert.equal( + resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model, + 'a320', + ); + assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), { + model: 'radio_mast', + length: 1.9, + }); +}); + +test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => { + assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship'); + assert.equal( + resolveVessel(sliceTypes, 71, 199, 32, 'GLOVIS SPIRIT').model, + 'car_carrier', + ); + assert.equal(resolveVessel(sliceTypes, 79, 110, 11.4).model, 'inland_cargo'); + assert.equal(resolveVessel(sliceTypes, 70, 90, 15).model, 'general_cargo'); + assert.deepEqual(resolveVessel(sliceTypes, 52, 30, 10), { + model: 'tug', + length: 30, + }); + assert.deepEqual(resolveVessel(sliceTypes, undefined, 50), { + model: 'generic_vessel', + length: 50, + }); + assert.deepEqual(resolveVessel(sliceTypes, 0), { + model: 'generic_vessel', + length: null, + }); +}); + +test("vessels of unknown length are chosen and drawn at their range's typical length", () => { + assert.deepEqual(resolveVessel(sliceTypes, 70), { + model: 'container_ship', + length: 300, + }); + assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), { + model: 'container_ship', + length: 300, + }); + assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), { + model: 'general_cargo', + length: 90, + }); + assert.deepEqual(resolveVessel(sliceTypes, 31), { + model: 'tug', + length: null, + }); + // Every passenger, cargo and tanker range of the set has one. + for (const code of [60, 70, 80]) { + assert.ok(resolveVessel(types, code).length > 100, String(code)); + } +}); + +test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => { + assert.deepEqual(resolveVessel(sliceTypes, 52, 511), { + model: 'tug', + length: null, + }); + assert.equal(hullLength(511), undefined); + assert.equal(hullLength(0), undefined); + assert.equal(hullBeam(126), undefined); + assert.equal(hullBeam(32), 32); + const ship = sliceCatalog.container_ship; + assert.deepEqual(placeScale(ship, ship.length, 126), [1, 1, 1]); +}); + +test('scale: aircraft by the type length, vessels by length and beam', () => { + const a320 = sliceCatalog.a320; + const [sx, sy, sz] = placeScale(a320, 44.51); + assert.ok(Math.abs(sx - 44.51 / 37.57) < 1e-12); + assert.equal(sy, sx); + assert.equal(sz, sx); + const ship = sliceCatalog.container_ship; + assert.deepEqual(placeScale(ship, ship.length / 2, 24), [0.5, 0.5, 0.5]); + assert.deepEqual(placeScale(ship, null), [1, 1, 1]); +}); + +test('every model the type map names is in the catalog, and its file is a GLB on disk', () => { + for (const name of namedFleetModels(types)) + assert.ok( + catalog[name], + `${name}: named by types.json but not in the catalog`, + ); + assert.equal(catalog.generic_plane?.category, 'aircraft'); + assert.equal(catalog.generic_vessel?.category, 'ships'); + assert.equal(catalog.city_bus?.category, 'transit'); + for (const [name, metres] of Object.entries(types.transit.elevation)) { + assert.ok(catalog[name], name); + assert.ok(metres > 0, name); + } + let total = 0; + for (const [name, entry] of Object.entries(catalog)) { + const file = path.join(DIR, entry.file); + assert.ok(fs.existsSync(file), `${name}: ${entry.file} is missing`); + const bytes = fs.readFileSync(file); + assert.equal(bytes.subarray(0, 4).toString('latin1'), 'glTF', name); + assert.ok(entry.length > 0, name); + assert.ok(entry.tris <= 50_000, name); + total += bytes.length; + } + // The set ships with the repository and loads up front: keep it small. + assert.ok(total < 3 * 1024 * 1024, `fleet GLBs total ${total} bytes`); +}); + +test('transit bodies mark the share of the route colour they take; other models mark none', () => { + for (const [name, entry] of Object.entries(catalog)) { + const { json: gltf } = readGlb(path.join(DIR, entry.file)); + for (const mesh of gltf.meshes) + for (const primitive of mesh.primitives) + assert.equal( + '_TINT' in primitive.attributes, + entry.category === 'transit', + name, + ); + assert.equal( + fleetModelSpec(set, name, null).tinted, + entry.category === 'transit', + ); + } +}); + +test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => { + const zveh = (callsign) => + aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign }) + .model; + assert.equal(zveh('FIRE 2'), 'fire_truck'); + assert.equal(zveh('PUSHBACK1'), 'pushback'); + assert.equal(zveh('TUG 4'), 'pushback'); + assert.equal(zveh('DEICER 4'), 'van'); + assert.equal(zveh('SQUID20'), 'van'); + assert.equal(zveh('SQB9'), 'van'); + assert.equal(zveh('LEADER 7'), 'car'); + assert.equal(zveh('AOPS'), 'car'); + assert.equal(zveh(null), 'car'); + const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' }); + assert.equal(mast.model, 'radio_mast'); + assert.equal(mast.scale, 1); +}); + +test('a callsign rule outranks the class model of a type the map lacks', () => { + const ruled = { + ...set, + types: { + ...set.types, + aircraft_callsign: { + ...set.types.aircraft_callsign, + PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }], + }, + }, + }; + assert.equal( + aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model, + 'fire_truck', + ); + // No rule matched: the class model, as before. + assert.equal( + aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model, + 'generic_heli', + ); +}); + +test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => { + assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320'); + assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744'); + // An unlisted fast jet is still a fast jet, not the generic airliner. + assert.equal( + aircraftModelSpec(set, { typeCode: 'FA18', category: 'A3' }).model, + 't38', + ); + assert.equal(aircraftModelSpec(set, { typeCode: 'MQ9' }).model, 'mq9'); + assert.equal( + aircraftModelSpec(set, { typeCode: 'PUMA' }).model, + 'generic_heli', + ); + // An unlisted type of no special class falls to its category. + assert.equal( + aircraftModelSpec(set, { typeCode: 'ZZZZ', category: 'A5' }).model, + 'b77w', + ); + // OpenSky's integer category: 8 is rotorcraft (A7), 2 light (A1). + assert.equal(emitterCategory(8), 'A7'); + assert.equal(emitterCategory('a3'), 'A3'); + assert.equal(emitterCategory(1), undefined); + assert.equal(aircraftModelSpec(set, { category: 8 }).model, 'generic_heli'); + assert.equal(aircraftModelSpec(set, { category: 2 }).model, 'c172'); + assert.equal(aircraftModelSpec(set, {}).model, types.default_aircraft[0]); +}); + +test('aircraft specs are life size, standing on their scaled clearance', () => { + const spec = aircraftModelSpec(set, { typeCode: 'A21N' }); + const [, length] = types.aircraft.A21N; + assert.equal(spec.model, types.aircraft.A21N[0]); + assert.ok(Math.abs(spec.length - length) < 1e-9); + assert.ok(Math.abs(spec.scale - length / catalog[spec.model].length) < 1e-12); + assert.ok( + Math.abs(spec.clearanceM - catalog[spec.model].clearance * spec.scale) < + 1e-12, + ); + assert.equal(spec.url, `${FLEET_MODEL_BASE}${catalog[spec.model].file}`); + assert.equal(spec.tinted, false); + assert.equal(spec.elevationM, 0); + assert.ok( + spec.nativeRadiusM * spec.scale > 25 && + spec.nativeRadiusM * spec.scale < 60, + ); + assert.deepEqual(spec.trailAnchorNative, FLEET_TRAIL_ANCHORS[spec.model]); + // A grounded service vehicle and an obstacle are drawn as built: the + // tower's 60 m is its height, not a length to stretch it to. + const tower = aircraftModelSpec(set, { category: 'C3' }); + assert.equal(tower.model, 'tower'); + assert.equal(tower.scale, 1); + assert.equal(aircraftModelSpec(set, { category: 'C1' }).scale, 1); +}); + +test('vessel specs read AIS codes and text families, stretched to the reported beam', () => { + assert.equal(aisShipTypeCode('70'), 70); + assert.equal(aisShipTypeCode(' Cargo '), 70); + assert.equal(aisShipTypeCode('Tanker'), 80); + assert.equal(aisShipTypeCode('Tug'), 52); + assert.equal(aisShipTypeCode('0'), undefined); + assert.equal(aisShipTypeCode(''), undefined); + const box = vesselModelSpec(set, { type: '71', length: 366, beam: 51 }); + assert.equal(box.model, 'container_ship'); + assert.ok(Math.abs(box.length - 366) < 1e-9); + const [sx, sy, sz] = box.scaleXYZ; + assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12); + assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12); + // A beam with no reported length: the type's typical length, in the model's + // own proportions, not a typical-length hull squeezed to that beam. + const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 }); + assert.equal(unsized.model, 'container_ship'); + const [ux, uy, uz] = unsized.scaleXYZ; + assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12); + assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug'); + assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat'); + // No type, no size: the generic vessel as built. + const unknown = vesselModelSpec(set, {}); + assert.equal(unknown.model, types.default_vessel); + assert.equal(unknown.scale, 1); +}); + +test('transit specs: rail category and mode, then route type, then mode, then the default', () => { + assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), { + model: 'intercity', + length: types.transit.rail.national.rail[1], + }); + assert.equal( + resolveTransit(types, { railCategory: 'transit', railMode: 'metro' }).model, + 'metro', + ); + assert.equal( + resolveTransit(types, { railCategory: 'regional', railMode: 'tram' }).model, + 'light_rail', + ); + assert.equal(resolveTransit(types, { routeType: 3 }).model, 'city_bus'); + assert.equal(resolveTransit(types, { routeType: '0' }).model, 'tram'); + assert.equal(resolveTransit(types, { routeType: 109 }).model, 'commuter_emu'); + assert.equal(resolveTransit(types, { mode: 'subway' }).model, 'metro'); + assert.equal(resolveTransit(types, { mode: 'ferry' }).model, 'fast_ferry'); + assert.equal( + resolveTransit(types, { mode: 'unknown' }).model, + types.transit.default[0], + ); + const tram = transitModelSpec(set, { mode: 'tram' }); + assert.equal(tram.tinted, true); + assert.equal(tram.clearanceM, 0); + assert.ok(Math.abs(tram.length - types.transit.modes.tram[1]) < 1e-9); + assert.equal( + transitModelSpec(set, { routeType: 12 }).elevationM, + types.transit.elevation.monorail, + ); + assert.equal( + transitModelSpec(set, { mode: 'gondola' }).elevationM, + types.transit.elevation.gondola, + ); + assert.equal(transitModelSpec(set, { mode: 'ferry' }).tinted, false); +}); + +test('a model missing from the catalog falls back to the default of its kind', () => { + const thin = { + catalog: { ...catalog }, + types: { + ...types, + aircraft: { ...types.aircraft, XXXX: ['no_such_model', 20] }, + }, + }; + assert.equal( + aircraftModelSpec(thin, { typeCode: 'XXXX' }).model, + types.default_aircraft[0], + ); +}); + +const ANCHORED = Object.entries(catalog).filter(([, entry]) => + ['aircraft', 'helicopters', 'other'].includes(entry.category), +); + +test('every aircraft-layer model has a trail anchor on its real aft-belly hull', async () => { + assert.deepEqual( + new Set(Object.keys(FLEET_TRAIL_ANCHORS)), + new Set(ANCHORED.map(([name]) => name)), + 're-run scripts/measure-fleet-trail-anchors.mjs', + ); + for (const [name, entry] of ANCHORED) { + const mesh = await fleetMesh(path.join(DIR, entry.file)); + const { min, max } = vertexBounds(mesh.verts); + const anchor = FLEET_TRAIL_ANCHORS[name]; + // On the hull, not in the air beside it. + const offHull = distanceToSurface(anchor, mesh.verts, mesh.tris); + assert.ok( + offHull <= 1e-3, + `${name}: trail anchor is ${offHull.toFixed(4)} m off the hull`, + ); + // Aft of the middle, so the head segment does not run into the body. + assert.ok( + anchor[0] > (min[0] + max[0]) / 2, + `${name}: trail anchor is not aft`, + ); + // Nothing of the centreline hangs lower at or behind its station. + const lowest = centrelineSegments(mesh.verts, mesh.tris) + .flat() + .filter((p) => p[0] >= anchor[0] - 1e-3) + .reduce((low, p) => Math.min(low, p[1]), Infinity); + assert.ok( + anchor[1] <= lowest + 1e-3, + `${name}: trail anchor is above the hull aft of it`, + ); + assert.equal(anchor[2], 0, `${name}: trail anchor is off the centreline`); + // And it is the construction, re-derived. + const measured = measuredTrailAnchor(mesh); + for (const axis of [0, 1]) + assert.ok( + Math.abs(anchor[axis] - measured[axis]) <= 1e-3, + `${name} axis ${axis}: table ${anchor[axis]} vs measured ${measured[axis].toFixed(4)}`, + ); + } +}); + +test('catalog bounds match the GLBs they describe', async () => { + for (const [name, entry] of Object.entries(catalog)) { + const { min, max } = vertexBounds( + (await fleetMesh(path.join(DIR, entry.file))).verts, + ); + // The catalog is Z up (glTF Y), glTF Z across. + const tolerance = 0.01 * Math.max(1, entry.length); + assert.ok(Math.abs(min[0] - entry.min[0]) <= tolerance, `${name} min x`); + assert.ok(Math.abs(max[0] - entry.max[0]) <= tolerance, `${name} max x`); + assert.ok(Math.abs(min[1] - entry.min[2]) <= tolerance, `${name} min up`); + assert.ok( + Math.abs(-min[1] - entry.clearance) <= tolerance, + `${name} clearance`, + ); + assert.ok( + Math.abs(max[2] - min[2] - (entry.max[1] - entry.min[1])) <= tolerance, + `${name} beam`, + ); + } +}); + +test('the model set loads once, every GLB up front, shared by every layer', async () => { + resetFleetModelSet(); + const requested = []; + const fetchImpl = async (url) => { + requested.push(url); + const file = path.join(ROOT, 'public', url.replace(/^\/app/, '')); + if (!fs.existsSync(file)) return { ok: false, status: 404 }; + const bytes = fs.readFileSync(file); + return { + ok: true, + json: async () => JSON.parse(bytes.toString('utf8')), + arrayBuffer: async () => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ), + }; + }; + const resolveAsset = (url) => `/app${url}`; + const [first, second] = await Promise.all([ + loadFleetModelSet({ resolveAsset, fetchImpl }), + loadFleetModelSet({ resolveAsset, fetchImpl }), + ]); + assert.equal(first, second); + assert.equal(fleetModelSet(), first); + assert.equal(first.bytes.size, Object.keys(catalog).length); + assert.equal(requested.length, Object.keys(catalog).length + 2); + assert.ok(requested.every((url) => url.startsWith('/app/models/fleet/'))); + assert.ok(first.totalBytes > 0 && Number.isFinite(first.loadMs)); + await loadFleetModelSet({ resolveAsset, fetchImpl }); + assert.equal(requested.length, Object.keys(catalog).length + 2); + resetFleetModelSet(); +}); diff --git a/src/data/fleetTrailAnchors.js b/src/data/fleetTrailAnchors.js new file mode 100644 index 000000000..5a04aa2ad --- /dev/null +++ b/src/data/fleetTrailAnchors.js @@ -0,0 +1,67 @@ +/** + * Aft-belly trail attachment points of the fleet models the aircraft layers + * draw, in raw glTF axes (X along the body, nose toward -X; Y up), metres + * before scale, node transforms applied. Each is the point of the model's + * centreline profile (its z = 0 slice) nearest its aft-belly bounding-box + * corner: on the hull, aft, and on the centreline (see modelVisualAnchor.js). + * + * Written by scripts/measure-fleet-trail-anchors.mjs; re-measured against the + * GLBs by fleetModels.test.mjs. + */ +export const FLEET_TRAIL_ANCHORS = Object.freeze({ + a139: Object.freeze([6.8847, 0.2766, 0]), + a320: Object.freeze([17.2078, 0.7416, 0]), + a333: Object.freeze([29.7276, 1.1084, 0]), + a343: Object.freeze([30.0371, 1.2335, 0]), + a359: Object.freeze([31.0731, 1.1573, 0]), + a388: Object.freeze([34.3139, 1.9279, 0]), + as50: Object.freeze([5.2278, -0.0999, 0]), + at72: Object.freeze([12.8258, 0.6792, 0]), + b06: Object.freeze([4.62, -0.3002, 0]), + b407: Object.freeze([5.1264, -0.2797, 0]), + b738: Object.freeze([18.3819, 0.8391, 0]), + b744: Object.freeze([33.6232, 1.5411, 0]), + b763: Object.freeze([25.5464, 0.9889, 0]), + b77w: Object.freeze([34.4639, 1.2084, 0]), + b789: Object.freeze([29.0604, 1.0649, 0]), + balloon: Object.freeze([3.8215, -0.0009, 0]), + bcs3: Object.freeze([17.8658, 0.713, 0]), + be20: Object.freeze([6.6699, 0.5505, 0]), + blimp: Object.freeze([32.2493, -7.1382, 0]), + c17: Object.freeze([26.5207, 2.332, 0]), + c172: Object.freeze([4.0507, 0.4481, 0]), + c30j: Object.freeze([13.568, 0.9917, 0]), + c56x: Object.freeze([7.4627, 0.4526, 0]), + car: Object.freeze([2.1851, 0.301, 0]), + cl35: Object.freeze([10.4607, 0.8247, 0]), + crj9: Object.freeze([17.8413, 0.8624, 0]), + dh8d: Object.freeze([15.7902, 0.7501, 0]), + e175: Object.freeze([14.697, 0.6077, 0]), + e55p: Object.freeze([7.6566, 0.503, 0]), + ec35: Object.freeze([5.1003, 0.1471, 0]), + ec45: Object.freeze([5.8004, 0.555, 0]), + fire_truck: Object.freeze([5.39, 0.4227, 0]), + generic_heli: Object.freeze([5.4997, 0.1604, 0]), + generic_plane: Object.freeze([16.5228, 0.712, 0]), + glf4: Object.freeze([13.4603, 0.8243, 0]), + glider: Object.freeze([3.3997, 0.1283, 0]), + h47: Object.freeze([7.7303, 0.0035, 0]), + h60: Object.freeze([7.6295, 0.1365, 0]), + h64: Object.freeze([6.9228, -0.5553, 0]), + k35r: Object.freeze([28.0221, -2.4508, 0]), + md11: Object.freeze([30.8002, 1.2291, 0]), + mq9: Object.freeze([5.8498, -0.062, 0]), + p28a: Object.freeze([3.3695, 0.3959, 0]), + pc12: Object.freeze([6.6416, 0.5288, 0]), + pushback: Object.freeze([2.9536, 0.388, 0]), + r44: Object.freeze([4.4219, -0.3001, 0]), + radio_mast: Object.freeze([1.2, 0, 0]), + s76: Object.freeze([6.6001, 0.1915, 0]), + sr22: Object.freeze([3.6762, 0.2376, 0]), + t38: Object.freeze([7.0246, -0.3791, 0]), + tex2: Object.freeze([4.7383, 0.2302, 0]), + tower: Object.freeze([1.2952, -0.0007, 0]), + ultralight: Object.freeze([3.2271, 0.3259, 0]), + v22: Object.freeze([7.9868, 0.2956, 0]), + van: Object.freeze([2.695, 0.2812, 0]), +}); diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index d91914805..89e7ea33b 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -1,45 +1,33 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; -// Regression test for the giant-military-jet bug: MODEL_SCALE must put every -// rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is. -// -// airplane.glb and jet.glb are both transform-applied at real-world size and -// render with MODEL_SCALE 1. The airplane previously relied on a runtime ×24 -// calibration; that factor is now baked into its geometry so future consumers -// cannot miss it. The original military bug came from copying scale 24 onto the -// already meter-scale jet, rendering military models ~600–1000 m -// across — invisible at the ~6 km follow range where minimumPixelSize -// dominates, but zoom below ~1 km and the camera ends up INSIDE the model. -// -// The layer modules pull the full Cesium engine and don't export their -// constants, so (as in modelMatrix.test.mjs) we don't import them: constants -// are read from source, and each GLB's native bounding radius is computed from -// its POSITION accessor min/max with the node hierarchy's transforms applied -// (both legacy GLBs are now transform-applied, but the hierarchy-aware reader -// guards future asset updates). For jet.glb this yields 29.83 — Cesium's -// Model.boundingSphere.radius / scale measured in-app (2026-07-02). +// Aircraft models draw LIFE SIZE: every aircraft layer takes its model and +// scale from the procedural fleet set (fleetModels.js), a type's published +// length over the model's built length. These pins keep that true — no layer +// carries a scale constant of its own (the giant-military-jet bug was one +// copied across layers) — and keep the trail and bracket anchors riding the +// transform chain Cesium draws the models with. import { test } from 'node:test'; import * as Cesium from 'cesium'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js'; import { - MODEL_TRAIL_ANCHOR_NATIVE, - MODEL_VISUAL_CENTER_NATIVE, modelVisualAnchor, - trailAnchorForModel, trailHeadStart, modelAnchorWorld, } from './modelVisualAnchor.js'; +import { aircraftModelSpec } from './fleetModels.js'; +import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const FLEET = path.join(ROOT, 'public', 'models', 'fleet'); +const set = { + catalog: JSON.parse(fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8')), + types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')), +}; -/** Every class's rendered bounding radius (m) must land in this envelope — - * bigger than any clamped-down light aircraft, smaller than the largest real - * heavy (A380/An-124 bounding radius ~45 m), nowhere near the 500–1000 m of - * the scale-24 jet.glb bug. */ -const WORLD_RADIUS_MIN_M = 10; +/** Every aircraft drawn at its type's size: no rendered radius outside what aircraft span. */ +const WORLD_RADIUS_MIN_M = 2; const WORLD_RADIUS_MAX_M = 60; test('model-space anchors honor Cesium minimum-pixel computed scale', () => { @@ -54,560 +42,46 @@ test('model-space anchors honor Cesium minimum-pixel computed scale', () => { assert.deepEqual(result, { x: 79, y: 214, z: 272 }); }); -// --- minimal GLB bounds reader --------------------------------------------- - -function glbJson(file) { - const buf = fs.readFileSync(file); - assert.equal(buf.readUInt32LE(0), 0x46546c67, `${file}: not a GLB`); - assert.equal(buf.readUInt32LE(16), 0x4e4f534a, `${file}: first chunk not JSON`); - return JSON.parse(buf.subarray(20, 20 + buf.readUInt32LE(12)).toString('utf8')); -} - -// Column-major 4×4 helpers (glTF matrix layout). -const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; -function mul(a, b) { - const o = new Array(16).fill(0); - for (let c = 0; c < 4; c++) - for (let r = 0; r < 4; r++) - for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k]; - return o; -} -function nodeMatrix(node) { - if (node.matrix) return node.matrix; - const t = node.translation || [0, 0, 0]; - const [x, y, z, w] = node.rotation || [0, 0, 0, 1]; - const s = node.scale || [1, 1, 1]; - const rot = [ - 1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0, - 2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0, - 2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0, - 0, 0, 0, 1, - ]; - return mul( - [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, t[0], t[1], t[2], 1], - mul(rot, [s[0], 0, 0, 0, 0, s[1], 0, 0, 0, 0, s[2], 0, 0, 0, 0, 1]), - ); -} -function transformPoint(m, p) { - return [ - m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], - m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], - m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], - ]; -} - -/** Scene-space AABB over all mesh primitives' POSITION bounds, node transforms applied. */ -function nativeBounds(file) { - const gltf = glbJson(file); - const min = [Infinity, Infinity, Infinity]; - const max = [-Infinity, -Infinity, -Infinity]; - const visit = (idx, parent) => { - const node = gltf.nodes[idx]; - const m = mul(parent, nodeMatrix(node)); - if (node.mesh != null) { - for (const prim of gltf.meshes[node.mesh].primitives) { - const acc = gltf.accessors[prim.attributes.POSITION]; - if (!acc?.min || !acc?.max) continue; - for (let i = 0; i < 8; i++) { - const corner = transformPoint(m, [ - (i & 1) ? acc.max[0] : acc.min[0], - (i & 2) ? acc.max[1] : acc.min[1], - (i & 4) ? acc.max[2] : acc.min[2], - ]); - for (let a = 0; a < 3; a++) { - min[a] = Math.min(min[a], corner[a]); - max[a] = Math.max(max[a], corner[a]); - } - } - } - } - for (const child of node.children || []) visit(child, m); - }; - for (const n of gltf.scenes[gltf.scene ?? 0].nodes) visit(n, IDENTITY); - assert.ok(Number.isFinite(min[0]), `${file}: no POSITION bounds found`); - return { min, max }; -} - -// --- real mesh reader (vertices + triangles, not just accessor bounds) ------ -// -// The bounds reader above is enough to size a model, but NOT to site a point on -// it: an AABB corner is empty space. These read the POSITION buffers themselves -// so the trail anchor can be checked against actual triangles. - -const GLB_COMPONENT_READERS = { - 5120: (buf, o) => buf.readInt8(o), - 5121: (buf, o) => buf.readUInt8(o), - 5122: (buf, o) => buf.readInt16LE(o), - 5123: (buf, o) => buf.readUInt16LE(o), - 5125: (buf, o) => buf.readUInt32LE(o), - 5126: (buf, o) => buf.readFloatLE(o), -}; -const GLB_COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }; -const GLB_TYPE_COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }; - -/** JSON chunk plus the binary chunk of a GLB. */ -function glbChunks(file) { - const buf = fs.readFileSync(file); - assert.equal(buf.readUInt32LE(0), 0x46546c67, `${file}: not a GLB`); - const jsonLen = buf.readUInt32LE(12); - const gltf = JSON.parse(buf.subarray(20, 20 + jsonLen).toString('utf8')); - let bin = null; - for (let off = 20 + jsonLen; off + 8 <= buf.length;) { - const len = buf.readUInt32LE(off); - if (buf.readUInt32LE(off + 4) === 0x004e4942) { bin = buf.subarray(off + 8, off + 8 + len); break; } - off += 8 + len; - } - assert.ok(bin, `${file}: no BIN chunk`); - return { gltf, bin }; -} - -/** One accessor's elements, honouring bufferView byteStride. */ -function glbAccessor(gltf, bin, index) { - const acc = gltf.accessors[index]; - assert.ok(!acc.sparse, 'sparse accessors are not used by the bundled assets'); - const read = GLB_COMPONENT_READERS[acc.componentType]; - const size = GLB_COMPONENT_BYTES[acc.componentType]; - const n = GLB_TYPE_COMPONENTS[acc.type]; - const bv = gltf.bufferViews[acc.bufferView]; - const base = (bv.byteOffset || 0) + (acc.byteOffset || 0); - const stride = bv.byteStride || size * n; - const out = []; - for (let i = 0; i < acc.count; i++) { - const o = base + i * stride; - const el = []; - for (let c = 0; c < n; c++) el.push(read(bin, o + c * size)); - out.push(el); - } - return out; -} - -/** - * Every mesh vertex in Cesium model-local space, plus the triangle index list. - * Same glTF Y-up → Cesium Z-up mapping the anchors are expressed in. - */ -function nativeMesh(file) { - const { gltf, bin } = glbChunks(file); - assert.ok( - !(gltf.extensionsRequired || []).some((e) => e.toLowerCase().includes('draco')), - `${file}: Draco-compressed assets would need a decoder here`, - ); - const verts = []; - const tris = []; - const visit = (idx, parent) => { - const node = gltf.nodes[idx]; - const m = mul(parent, nodeMatrix(node)); - if (node.mesh != null) { - for (const prim of gltf.meshes[node.mesh].primitives) { - const pIdx = prim.attributes?.POSITION; - if (pIdx == null) continue; - const start = verts.length; - for (const p of glbAccessor(gltf, bin, pIdx)) { - // RAW glTF axes, deliberately: the anchor table stores raw glTF and - // lets Cesium's own axis correction do the rest (modelAnchorWorld). - // Converting here would re-introduce the second, hand-maintained - // convention whose half-correction put the anchor on the wrong axis. - verts.push(transformPoint(m, p)); - } - if (prim.indices != null) { - const ind = glbAccessor(gltf, bin, prim.indices).map((e) => e[0]); - for (let i = 0; i + 2 < ind.length; i += 3) { - tris.push([start + ind[i], start + ind[i + 1], start + ind[i + 2]]); - } - } else { - for (let i = start; i + 2 < verts.length; i += 3) tris.push([i, i + 1, i + 2]); - } - } - } - for (const child of node.children || []) visit(child, m); - }; - for (const n of gltf.scenes[gltf.scene ?? 0].nodes) visit(n, IDENTITY); - assert.ok(verts.length && tris.length, `${file}: no triangles found`); - return { verts, tris }; -} - -/** AABB over real vertices (Cesium model-local). */ -function vertexBounds(verts) { - const min = [Infinity, Infinity, Infinity]; - const max = [-Infinity, -Infinity, -Infinity]; - for (const v of verts) { - for (let a = 0; a < 3; a++) { - if (v[a] < min[a]) min[a] = v[a]; - if (v[a] > max[a]) max[a] = v[a]; +test('every typed aircraft renders at its published length, a real-world aircraft size', () => { + for (const map of [set.types.aircraft, set.types.helicopters]) { + for (const [type, [model, length]] of Object.entries(map)) { + const spec = aircraftModelSpec(set, { typeCode: type }); + assert.equal(spec.model, model, type); + // Balloons and obstacles ('other') are built at their own size. + const published = spec.category === 'other' ? set.catalog[model].length : length; + assert.ok(Math.abs(spec.length - published) < 1e-9, `${type}: drawn ${spec.length} m, published ${published} m`); + const radius = spec.nativeRadiusM * spec.scale; + assert.ok( + radius >= WORLD_RADIUS_MIN_M && radius <= WORLD_RADIUS_MAX_M, + `${type}: rendered radius ${radius.toFixed(2)} m outside [${WORLD_RADIUS_MIN_M}, ${WORLD_RADIUS_MAX_M}]`, + ); + // A grounded aircraft stands on its wheels: its origin the scaled + // clearance above the ground, never below it. + // A model built standing on its origin (a ground vehicle, a ground + // station's mast: its lowest point at z 0) has no clearance. + if (set.catalog[model].min[2] > -0.05) + assert.ok( + spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale, + `${type}: built standing, clearance ${spec.clearanceM}`, + ); + else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`); + assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`); } } - return { min, max }; -} +}); -const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); - -/** Closest point of triangle abc to p (Ericson, Real-Time Collision Detection). */ -function closestOnTriangle(p, a, b, c) { - const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]]; - const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2]; - const ab = sub(b, a); const ac = sub(c, a); const ap = sub(p, a); - const d1 = dot(ab, ap); const d2 = dot(ac, ap); - if (d1 <= 0 && d2 <= 0) return a; - const bp = sub(p, b); - const d3 = dot(ab, bp); const d4 = dot(ac, bp); - if (d3 >= 0 && d4 <= d3) return b; - const vc = d1 * d4 - d3 * d2; - if (vc <= 0 && d1 >= 0 && d3 <= 0) { - const v = d1 / (d1 - d3); - return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v]; - } - const cp = sub(p, c); - const d5 = dot(ab, cp); const d6 = dot(ac, cp); - if (d6 >= 0 && d5 <= d6) return c; - const vb = d5 * d2 - d1 * d6; - if (vb <= 0 && d2 >= 0 && d6 <= 0) { - const w = d2 / (d2 - d6); - return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w]; - } - const va = d3 * d6 - d5 * d4; - if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) { - const w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); - return [b[0] + (c[0] - b[0]) * w, b[1] + (c[1] - b[1]) * w, b[2] + (c[2] - b[2]) * w]; - } - const den = 1 / (va + vb + vc); - const v = vb * den; const w = vc * den; - return [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]; -} - -/** How far a point lies from the nearest triangle of the mesh. */ -function distanceToSurface(p, verts, tris) { - let best = Infinity; - for (const [i, j, k] of tris) { - const d = dist3(p, closestOnTriangle(p, verts[i], verts[j], verts[k])); - if (d < best) best = d; - } - return best; -} - -/** The hull's longitudinal cross-section: where triangles cross the y = 0 plane. */ -function centrelineProfile(verts, tris) { - const pts = []; - for (const [i, j, k] of tris) { - const t = [verts[i], verts[j], verts[k]]; - for (let e = 0; e < 3; e++) { - const a = t[e]; const b = t[(e + 1) % 3]; - if (a[2] === 0) pts.push(a); - if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) { - const s = a[2] / (a[2] - b[2]); - pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]); - } - } - } - return pts; -} - -/** Closest point of the centreline profile to `target` (segment-exact). */ -function closestProfilePoint(target, profile, tris, verts) { - // Walk the profile as the segments the slice actually produced, so the answer - // can fall between two vertices the way the real surface does. - const segs = []; - for (const [i, j, k] of tris) { - const t = [verts[i], verts[j], verts[k]]; - const pts = []; - for (let e = 0; e < 3; e++) { - const a = t[e]; const b = t[(e + 1) % 3]; - if (a[2] === 0) pts.push(a); - if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) { - const s = a[2] / (a[2] - b[2]); - pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]); - } - } - if (pts.length >= 2) segs.push([pts[0], pts[1]]); - } - let best = null; let bestD = Infinity; - for (const [a, b] of segs) { - const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - const den = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2]; - let q = a; - if (den > 0) { - let s = ((target[0] - a[0]) * ab[0] + (target[1] - a[1]) * ab[1] + (target[2] - a[2]) * ab[2]) / den; - s = Math.max(0, Math.min(1, s)); - q = [a[0] + ab[0] * s, a[1] + ab[1] * s, a[2] + ab[2] * s]; - } - const d = dist3(target, q); - if (d < bestD) { bestD = d; best = q; } - } - assert.ok(best, 'the centreline slice produced no segments'); - assert.ok(profile.length > 0, 'the centreline profile is empty'); - return best; -} - -/** Native bounding radius: half-diagonal of the scene-space AABB. */ -function nativeBoundingRadius(file) { - const { min, max } = nativeBounds(file); - return Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; -} - -/** How far the glTF origin sits above the model's lowest vertex (glTF is Y-up; - * Cesium maps that to local up). This is the native "belly depth" the grounded- - * model snap must lift by so the model rests on its gear/belly, not its origin. */ -function nativeOriginAboveLowestVertex(file) { - return -nativeBounds(file).min[1]; -} - -/** Scene-AABB centre converted from glTF Y-up to Cesium model-local Z-up. */ -function nativeVisualCenter(file) { - const { min, max } = nativeBounds(file); - const gltfCenter = min.map((value, axis) => (value + max[axis]) / 2); - return [gltfCenter[0], -gltfCenter[2], gltfCenter[1]]; -} - -// --- per-layer constants, read from source (see header) -------------------- - -function layerConstants(sourceFile) { - const src = readLayerSource(path.join(ROOT, sourceFile)); - const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/); - assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`); - const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/); - assert.ok(belly, `${sourceFile}: MODEL_BELLY_OFFSET_NATIVE not found`); - return { modelScale: Number(scale[1]), bellyOffsetNative: Number(belly[1]) }; -} - -function normalBillboardScaleByDistance(sourceFile) { - const src = readLayerSource(path.join(ROOT, sourceFile)); - const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); - assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); - const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); - assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); - return scalar.slice(1).map(Number); -} - -const LAYERS = [ - { - name: 'flights', - source: 'src/data/flights.js', - // All classes share one GLB today (see CLASS_MODEL_URL) — assert that, so - // a real per-class asset drop-in forces this test to grow with it. - asset: (() => { - const urls = new Set(Object.values(CLASS_MODEL_URL)); - assert.equal(urls.size, 1, 'CLASS_MODEL_URL: expected a single shared GLB'); - return [...urls][0]; - })(), - }, - { - name: 'military', - source: 'src/data/militaryFlights.js', - asset: (() => { - const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); - const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/); - assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found'); - return m[1]; - })(), - }, +const AIRCRAFT_LAYERS = [ + 'src/layers/flights/rendering.js', + 'src/layers/military/rendering.js', + 'src/layers/localAdsb/models.js', ]; -const measured = LAYERS.map((layer) => { - const { modelScale, bellyOffsetNative } = layerConstants(layer.source); - const assetPath = path.join(ROOT, 'public', layer.asset); - const nativeRadius = nativeBoundingRadius(assetPath); - return { - ...layer, modelScale, bellyOffsetNative, nativeRadius, - baseWorldRadius: nativeRadius * modelScale, - originAboveBelly: nativeOriginAboveLowestVertex(assetPath), - }; -}); - -// --- the invariants --------------------------------------------------------- - -for (const layer of measured) { - test(`${layer.name}: every class renders at real-world aircraft scale (${layer.asset} native r=${layer.nativeRadius.toFixed(2)} × MODEL_SCALE ${layer.modelScale})`, () => { - for (const [klass, mult] of Object.entries(CLASS_SCALE_3D)) { - const worldRadius = layer.baseWorldRadius * mult; - assert.ok( - worldRadius >= WORLD_RADIUS_MIN_M && worldRadius <= WORLD_RADIUS_MAX_M, - `${klass}: world bounding radius ${worldRadius.toFixed(1)} m outside ` + - `[${WORLD_RADIUS_MIN_M}, ${WORLD_RADIUS_MAX_M}] m — MODEL_SCALE is calibrated ` + - `for a different asset's native scale`, - ); - } - }); -} - -// Grounded-model belly offset (ground-3D, 2026-07-03): each layer's -// MODEL_BELLY_OFFSET_NATIVE must equal the GLB's actual origin-above-lowest-vertex -// height, else ground-snapped models sink into (const too small) or hover above -// (too big) the sampled tile skin. Absolute tolerance = 2% of the native bounding -// radius (sub-decimeter at world scale for both assets). -for (const layer of measured) { - test(`${layer.name}: MODEL_BELLY_OFFSET_NATIVE matches ${layer.asset}'s origin height above its belly`, () => { - const tol = layer.nativeRadius * 0.02; - assert.ok( - Math.abs(layer.bellyOffsetNative - layer.originAboveBelly) <= tol, - `MODEL_BELLY_OFFSET_NATIVE ${layer.bellyOffsetNative} vs measured ` + - `${layer.originAboveBelly.toFixed(3)} (tol ${tol.toFixed(3)}) — the constant was ` + - 'calibrated for a different asset; re-measure the GLB (glTF Y-up AABB, node transforms applied)', - ); - }); -} - -test('flights and military models render at matching world sizes (cross-layer parity)', () => { - const [flights, military] = measured; - const ratio = flights.baseWorldRadius / military.baseWorldRadius; - assert.ok( - ratio > 1 / 1.5 && ratio < 1.5, - `base world radii differ ×${(ratio >= 1 ? ratio : 1 / ratio).toFixed(1)} ` + - `(flights ${flights.baseWorldRadius.toFixed(1)} m vs military ${military.baseWorldRadius.toFixed(1)} m) — ` + - 'the layers render side by side, so a class must read the same size in both', - ); -}); - -// --- 2026-08-15 Hangar fleet: real per-class GLBs --------------------------- -// CLASS_MODEL_REAL assets are vertex-baked to REAL-WORLD METERS in the -// airplane.glb axis convention and render at scale 1, so the class-envelope -// test above does not apply to them; instead each registry entry's radiusM -// and bellyM must MATCH the shipped GLB (the layers consume these numbers for -// pixel-cap math and grounded-model lift). Envelope: a real C172 (r≈7.1 m) -// up to a real 787-9 (r≈44.1 m) — nothing clamped, nothing monstrous. -const REAL_RADIUS_MIN_M = 5; -const REAL_RADIUS_MAX_M = 60; -for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) { - test(`hangar fleet: ${klass} registry pins match the shipped GLB (${spec.url})`, () => { - const assetPath = path.join(ROOT, 'public', spec.url); - const radius = nativeBoundingRadius(assetPath); - const belly = nativeOriginAboveLowestVertex(assetPath); - assert.ok( - radius >= REAL_RADIUS_MIN_M && radius <= REAL_RADIUS_MAX_M, - `${klass}: bounding radius ${radius.toFixed(2)} m outside the real-aircraft ` + - `envelope [${REAL_RADIUS_MIN_M}, ${REAL_RADIUS_MAX_M}] m — the GLB is not baked to meters`, - ); - const tol = radius * 0.02; - assert.ok( - Math.abs(radius - spec.radiusM) <= tol, - `${klass}: registry radiusM ${spec.radiusM} vs measured ${radius.toFixed(3)} ` + - `(tol ${tol.toFixed(3)}) — re-measure the GLB and update CLASS_MODEL_REAL`, - ); - assert.ok( - Math.abs(belly - spec.bellyM) <= tol, - `${klass}: registry bellyM ${spec.bellyM} vs measured ${belly.toFixed(3)} ` + - `(tol ${tol.toFixed(3)}) — grounded models would sink or hover; update CLASS_MODEL_REAL`, - ); - }); -} - -// militaryFlights renders airplane.glb for its heavy classes with duplicated -// PLANE_* constants — pin them to the measured meter-scale GLB + flights' -// calibration so the copies cannot drift. -test('military layer airplane.glb constants match the measured GLB and flights calibration', () => { - const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); - const grab = (name) => { - const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`)); - assert.ok(m, `militaryFlights.js: ${name} not found`); - return Number(m[1]); - }; - const [flights] = measured; - assert.equal(grab('PLANE_MODEL_SCALE'), flights.modelScale, 'PLANE_MODEL_SCALE must match flights MODEL_SCALE'); - assert.ok( - Math.abs(grab('PLANE_NATIVE_RADIUS_M') - flights.nativeRadius) <= flights.nativeRadius * 0.02, - `PLANE_NATIVE_RADIUS_M vs measured ${flights.nativeRadius.toFixed(3)}`, - ); - assert.ok( - Math.abs(grab('PLANE_BELLY_OFFSET_NATIVE') - flights.originAboveBelly) <= flights.nativeRadius * 0.02, - `PLANE_BELLY_OFFSET_NATIVE vs measured ${flights.originAboveBelly.toFixed(3)}`, - ); -}); - -test('shared-model visual-centre metadata matches the shipped GLBs', () => { - for (const [url, expected] of Object.entries(MODEL_VISUAL_CENTER_NATIVE)) { - const measuredCenter = nativeVisualCenter(path.join(ROOT, 'public', url)); - measuredCenter.forEach((value, axis) => { - assert.ok( - Math.abs(value - expected[axis]) <= 0.001, - `${url} axis ${axis}: metadata ${expected[axis]} vs measured ${value.toFixed(6)}`, - ); - }); - } -}); - -// The tracked trail is drawn from a point BEHIND the aircraft to this anchor, -// so the anchor decides both whether the trail terminates ON the aeroplane and -// whether the last stretch of it crosses the airframe. An earlier round put the -// anchor on the aft-belly AABB CORNER, reasoning that a point aft of every -// vertex cannot be crossed. True, but a corner is empty space — 4.80 m from any -// real geometry on airplane.glb, 6.14 m on jet.glb — so the trail ended in mid -// air beside the model. The anchor is now a genuine hull point, and these pins -// re-derive it from the POSITION BUFFERS rather than restating the table. -test('every approved aircraft model has a trail anchor on its real aft-belly hull', () => { - const urls = new Set([ - ...Object.keys(MODEL_VISUAL_CENTER_NATIVE), - ...Object.values(CLASS_MODEL_REAL).map((spec) => spec.url), - ]); - assert.deepEqual( - new Set(Object.keys(MODEL_TRAIL_ANCHOR_NATIVE)), - urls, - 'trail-anchor metadata must cover every approved aircraft GLB', - ); - - for (const url of urls) { - const file = path.join(ROOT, 'public', url); - const { verts, tris } = nativeMesh(file); - const { min, max } = vertexBounds(verts); - const centre = min.map((value, axis) => (value + max[axis]) / 2); - const actual = MODEL_TRAIL_ANCHOR_NATIVE[url]; - - // 1. ON THE HULL. The property the AABB corner failed. Measured against the - // real triangles, not against a bounding box: the corner values sat - // metres out in open air, and this is what said so. - // Measured bound across the shipped assets is <= 5e-5 native: re-measured - // from these buffers they span 5.2e-6 (bell206) to 4.1e-5 (atr72), with - // airplane.glb at 1.5e-5 and jet.glb at 1.8e-5. None is exactly zero — the - // anchor is the nearest point of a triangulated profile, not a vertex — so - // 1e-4 is met with margin and is still tight enough to catch an anchor that - // has left the hull; the old 1e-3 was loose enough to be uninformative. - const offHull = distanceToSurface(actual, verts, tris); - assert.ok( - offHull <= 1e-4, - `${url}: trail anchor must lie ON the mesh — it is ${offHull.toFixed(4)} m off the ` + - 'nearest triangle, so the trail would end in mid air beside the aircraft', - ); - - // 2. AFT of the airframe's middle, or the head segment enters at the tail - // and stops inside the fuselage — the line through the model the review - // photographed. Every shipped asset clears 80 % of its aft extreme. - assert.ok( - actual[0] > centre[0], - `${url}: trail anchor must sit aft of the centroid (${actual[0]} <= ${centre[0]})`, - ); - assert.ok( - actual[0] >= max[0] * 0.8, - `${url}: trail anchor must sit on the AFT hull — ${actual[0].toFixed(3)} is only ` + - `${(actual[0] / max[0] * 100).toFixed(1)}% of the ${max[0].toFixed(3)} aft extreme`, - ); - - // 3. BELOW: nothing of the aircraft hangs lower than the anchor at or - // behind its own station, so the trail leaves from underneath rather - // than out of the roof. Stated against the station rather than against - // the global minimum on purpose — the global lowest vertex is a wingtip - // or a gear leg amidships, and demanding the anchor match it is what - // drove the previous round off the hull in the first place. - const profile = centrelineProfile(verts, tris); - const lowestAtOrAft = profile - .filter((p) => p[0] >= actual[0] - 1e-3) - .reduce((lowest, p) => Math.min(lowest, p[1]), Infinity); - assert.ok( - Number.isFinite(lowestAtOrAft) && actual[1] <= lowestAtOrAft + 1e-3, - `${url}: trail anchor must be the lowest centreline hull at or aft of its station ` + - `(anchor ${actual[1]}, hull reaches ${lowestAtOrAft})`, - ); - - // 4. ON THE CENTRELINE, so the attachment cannot swing screen-left or - // screen-right as the aircraft turns. - assert.equal(actual[2], 0, `${url}: trail anchor must stay on the longitudinal centreline`); - - // 5. And the value itself is the closest centreline hull point to the - // aft-belly corner — the construction, re-derived. This is what makes - // the table re-measurable rather than re-guessable. - const corner = [max[0], min[1], 0]; - const nearest = closestProfilePoint(corner, profile, tris, verts); - [0, 1].forEach((axis) => { - assert.ok( - Math.abs(actual[axis] - nearest[axis]) <= 1e-3, - `${url} trail axis ${axis}: metadata ${actual[axis]} vs re-measured ${nearest[axis].toFixed(6)}`, - ); - }); +test('every aircraft layer takes its model and scale from the fleet set, no layer scale of its own', () => { + for (const file of AIRCRAFT_LAYERS) { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + assert.match(source, /\baircraftLayerModelSpec\b/, `${file}: models come from aircraftLayerModelSpec`); + assert.match(source, /\bcreateFleetModel\(/, `${file}: models are built from the loaded fleet set`); + assert.doesNotMatch(source, /\bMODEL_SCALE\b|CLASS_SCALE_3D|\.glb['"]/, `${file}: no layer-owned model scale or asset`); } }); @@ -621,60 +95,46 @@ test('every approved aircraft model has a trail anchor on its real aft-belly hul // // A flat 1-D rig stands in for the world here: the model centre at the origin, // the aft-belly anchor at its own measured station, and the last body point -// swept out along +x. That is exactly the geometry the guard reasons about — -// two distances from one centre — so the arithmetic under test is the shipped -// arithmetic, with no transform chain in the way (that is the pin below). -const TRAIL_RIG_URLS = new Set([ - ...Object.keys(MODEL_VISUAL_CENTER_NATIVE), - ...Object.values(CLASS_MODEL_REAL).map((spec) => spec.url), -]); +// swept out along +x. +const TRAIL_RIG_MODELS = Object.keys(FLEET_TRAIL_ANCHORS); const trailRigAt = (d) => ({ x: d, y: 0, z: 0 }); const trailRigCentre = trailRigAt(0); /** The rig's world anchor: the model's own measured attachment point, laid out - * aft (+x) and below (y) exactly as far as it really sits from the centre. */ -function trailRigAnchor(url) { - const native = trailAnchorForModel(url); + * aft (+x) and vertically exactly as far as it really sits from the centre. */ +function trailRigAnchor(name) { + const native = FLEET_TRAIL_ANCHORS[name]; return { x: native[0] || 0, y: native[1] || 0, z: 0 }; } -/** Visible length of the head segment the guard would have drawn, in metres. - * Zero means nothing is drawn — which is the answer the whole guard exists for. */ +function trailRigEnvelope(name) { + const { min, max } = set.catalog[name]; + return Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; +} +/** Visible length of the head segment the guard would have drawn, in metres. */ function drawnHeadLength(start, anchor, centre, envelope) { const from = trailHeadStart(start, anchor, centre, envelope, { x: 0, y: 0, z: 0 }); if (!from) return 0; return Math.hypot(from.x - anchor.x, from.y - anchor.y, from.z - anchor.z); } +// The review's repro, in the numbers it used: an airliner of 34.41 m envelope +// whose aft-belly anchor sits 24.09 m aft and 2.22 m down. +const AIRLINER_ENVELOPE = 34.41; +const AIRLINER_ANCHOR = { x: 24.0879, y: -2.2163, z: 0 }; test('a stationary contact draws no trail head, and a moving one draws all of it', () => { - for (const url of TRAIL_RIG_URLS) { - const envelope = nativeBoundingRadius(path.join(ROOT, 'public', url)); - const anchor = trailRigAnchor(url); - // Parked: the last body point is where the aircraft is. Nothing at all — - // not a short segment, not a degenerate one. Nothing. + for (const name of TRAIL_RIG_MODELS) { + const envelope = trailRigEnvelope(name); + const anchor = trailRigAnchor(name); assert.equal( trailHeadStart(trailRigCentre, anchor, trailRigCentre, envelope, { x: 0, y: 0, z: 0 }), null, - `${url}: a parked contact's head segment is the anchor offset, not motion, ` + - `and must not be drawn (envelope ${envelope.toFixed(2)} m)`, + `${name}: a parked contact's head segment must not be drawn (envelope ${envelope.toFixed(2)} m)`, ); - // Still nothing while the last body point is no further out than the - // anchor's own station: every millimetre of that segment would be drawn - // FORWARD of the attachment point, into the fuselage. const station = Math.hypot(anchor.x, anchor.y, anchor.z); assert.equal(drawnHeadLength(trailRigAt(station * 0.5), anchor, trailRigCentre, envelope), 0, - `${url}: a segment that would run forward of its own anchor draws nothing`); - // At and past the envelope the segment is real and must be drawn IN FULL so - // it reaches the hull — the failure mode clipping introduced. Referentially - // the caller's own start point, so a contact that has cleared its own size - // is bit-for-bit what the containment rule drew. + `${name}: a segment that would run forward of its own anchor draws nothing`); const clear = trailRigAt(envelope * 1.01); assert.equal(trailHeadStart(clear, anchor, trailRigCentre, envelope, { x: 0, y: 0, z: 0 }), clear, - `${url}: once the contact clears its own envelope the whole head must draw`); + `${name}: once the contact clears its own envelope the whole head must draw`); } - - // The review's repro: airplane.glb, a prior fix 58 m aft of a 34.41 m - // envelope. The old length test suppressed this entirely; ~23.6 m of it is in - // open air, so it must draw. - const airliner = nativeBoundingRadius(path.join(ROOT, 'public', '/models/airplane.glb')); - const anchor = trailRigAnchor('/models/airplane.glb'); for (const [d, why] of [ [58, 'a fix 58 m aft is well outside the envelope and its segment must be drawn'], [7500, 'a jet covers ~7.5 km between fixes — that trail must never be shortened'], @@ -682,34 +142,22 @@ test('a stationary contact draws no trail head, and a moving one draws all of it ]) { const start = trailRigAt(d); assert.equal( - trailHeadStart(start, anchor, trailRigCentre, airliner, { x: 0, y: 0, z: 0 }), start, why, + trailHeadStart(start, AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE, { x: 0, y: 0, z: 0 }), start, why, ); } - // No model drawing (billboard owns the visual): nothing to cut through. const loose = trailRigAt(5); - assert.equal(trailHeadStart(loose, anchor, null, 0, { x: 0, y: 0, z: 0 }), loose, + assert.equal(trailHeadStart(loose, AIRLINER_ANCHOR, null, 0, { x: 0, y: 0, z: 0 }), loose, 'with no model envelope the whole segment always draws'); }); -// THE PIN FOR THE BOUNDARY FLASH. -// -// The containment rule this replaces was BINARY, and a binary rule at -// airplane.glb's 34.41 m envelope is a 10.33 m flash: a start at 34.40 m drew -// nothing, a start at 34.42 m drew the entire segment, and a contact whose next -// fix landed back inside dropped it again. Two centimetres of travel cannot -// change ten metres of what the operator sees, and the pin the round shipped -// with could not catch it because it only ever asked static questions. -// -// So this asks the continuity question directly: sweep the start out through -// the boundary in small steps and require the VISIBLE LENGTH to move by no more -// than the step allows. The bound is 3x the step — the drawn length is at worst -// the segment's own growth (1:1 with the step) plus the reveal sliding across a -// shell that is never shorter than the segment it reveals, so 3 is a ceiling -// with room, and it is nowhere near the 10.33 m the boolean produced. +// The containment rule trailHeadStart replaced was BINARY: at a 34.41 m +// envelope a start at 34.40 m drew nothing and one at 34.42 m the whole +// 10.33 m segment. Sweep the start out through the boundary in small steps and +// require the VISIBLE LENGTH to move by no more than the step allows. test('the trail head grows continuously across the envelope, never in one step', () => { - for (const url of TRAIL_RIG_URLS) { - const envelope = nativeBoundingRadius(path.join(ROOT, 'public', url)); - const anchor = trailRigAnchor(url); + for (const name of TRAIL_RIG_MODELS) { + const envelope = trailRigEnvelope(name); + const anchor = trailRigAnchor(name); const step = envelope / 400; let previous = 0; for (let d = 0; d <= envelope * 1.5; d += step) { @@ -717,74 +165,55 @@ test('the trail head grows continuously across the envelope, never in one step', const length = drawnHeadLength(start, anchor, trailRigCentre, envelope); assert.ok( length - previous <= step * 3 + 1e-9 && previous - length <= step * 3 + 1e-9, - `${url}: the head jumped ${Math.abs(length - previous).toFixed(3)} m across a ` + - `${step.toFixed(3)} m step at ${d.toFixed(3)} m — that is the flash, not motion`, + `${name}: the head jumped ${Math.abs(length - previous).toFixed(3)} m across a ` + + `${step.toFixed(3)} m step at ${d.toFixed(3)} m`, ); - // Never longer than the segment actually being drawn: the guard only ever - // takes trail away from the START, and only ever from inside the envelope. const full = Math.hypot(start.x - anchor.x, start.y - anchor.y, start.z - anchor.z); assert.ok(length <= full + 1e-9, - `${url}: drew ${length.toFixed(3)} m of a ${full.toFixed(3)} m segment at ${d.toFixed(3)} m`); + `${name}: drew ${length.toFixed(3)} m of a ${full.toFixed(3)} m segment at ${d.toFixed(3)} m`); previous = length; } } - - // The exact repro, in the numbers the review used. - const airliner = nativeBoundingRadius(path.join(ROOT, 'public', '/models/airplane.glb')); - const anchor = trailRigAnchor('/models/airplane.glb'); - const inside = drawnHeadLength(trailRigAt(34.40), anchor, trailRigCentre, airliner); - const outside = drawnHeadLength(trailRigAt(34.42), anchor, trailRigCentre, airliner); + const inside = drawnHeadLength(trailRigAt(34.40), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE); + const outside = drawnHeadLength(trailRigAt(34.42), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE); assert.ok(outside > 10, `the segment 34.42 m aft is ~10.3 m long and must draw (${outside})`); assert.ok(Math.abs(outside - inside) < 0.1, - 'crossing the 34.41 m envelope by 2 cm must change the trail by centimetres, ' + - `not by the whole ${outside.toFixed(2)} m segment (moved ${Math.abs(outside - inside).toFixed(3)} m)`); - - // And the flap: a contact whose fixes cross back and forth over the boundary - // must not strobe the whole segment on and off. Each visit draws essentially - // the same partial length as the last. + `crossing the envelope by 2 cm moved the trail ${Math.abs(outside - inside).toFixed(3)} m`); const flap = [34.40, 34.42, 34.40, 34.43, 34.39].map( - (d) => drawnHeadLength(trailRigAt(d), anchor, trailRigCentre, airliner)); - const spread = Math.max(...flap) - Math.min(...flap); - assert.ok(spread < 0.1, - `an in/out/in flap across the boundary moved the trail ${spread.toFixed(3)} m — ` + - 'the boolean rule moved the whole segment, and that is what the operator saw'); - assert.ok(Math.min(...flap) > 10, - 'every visit in that flap is a real ~10 m segment: the inside ones are partial, not absent'); + (d) => drawnHeadLength(trailRigAt(d), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE)); + assert.ok(Math.max(...flap) - Math.min(...flap) < 0.1, 'an in/out/in flap must not strobe the trail'); + assert.ok(Math.min(...flap) > 10, 'every visit in that flap is a real ~10 m segment'); }); -// THE PIN THAT WOULD HAVE CAUGHT THE 2026-08-23 FIELD REGRESSION. -// // The anchor is expressed in the model's own axes, and it only lands where it // is meant to if it rides the SAME transform chain Cesium renders the model -// with. It did not: the table was stored after a single glTF Y-up -> Z-up step -// and multiplied straight by `modelMatrix`, which is half of Cesium's -// correction (the `fromGltfAsync` defaults also apply Z_UP_TO_X_UP). The -// aircraft's longitudinal axis is raw glTF X, so the aft offset landed on the -// RENDERED model's LATERAL axis — a 90-degree error that `modelMatrix`'s -// heading then rotated into world space, putting the trail out to one side and -// flipping which side as the course changed. -// -// A single-heading harness cannot catch this, which is exactly how it reached -// the owner: at one heading a wrongly-framed offset can coincidentally point -// aft. So this sweeps headings and every shipped asset, and asserts the -// property directly — the anchor lies in the model's longitudinal/vertical -// plane, with NO lateral component. -// -// The mock carries a NON-IDENTITY `sceneGraph.components.transform`, because -// the chain has a third link and a mock without one cannot tell whether the -// code reads it: Cesium renders `modelMatrix x components.transform x -// axisCorrection`, and an asset with a baked root transform has to ride all -// three. A scale and an offset ALONG the model's own axes is the strongest -// choice the property survives — it changes both numbers the pin asserts -// (identity would leave them at the raw table values, which is the answer a -// dropped link also gives) while leaving lateral zero, which is the regression. +// with (`modelMatrix x components.transform x axisCorrection`). A table stored +// after half of Cesium's axis correction once put the aft offset on the +// rendered model's LATERAL axis — invisible at a single heading. So this sweeps +// headings and every fleet anchor, and asserts the anchor lies in the model's +// longitudinal/vertical plane, aft and at its own height. The mock carries a +// NON-IDENTITY root transform so a dropped link in the chain fails too. const TRAIL_ROOT_SCALE = 2; const TRAIL_ROOT_OFFSET = Object.freeze({ lengthwise: 5, vertical: -1 }); +function headingAxes(origin, headingDeg) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); + const axis = (i) => { + const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); + return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); + }; + const east = axis(0); const north = axis(1); const vertical = axis(2); + const rad = Cesium.Math.toRadians(headingDeg); + const lengthwise = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(rad), new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(rad), new Cesium.Cartesian3()), + new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const lateral = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(lengthwise, vertical, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + return { lengthwise, vertical, lateral }; +} + test('the trail anchor rides the rendered longitudinal axis at every heading', () => { const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 3000); - const sub = (a, b) => Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3()); - // Cesium-model-local: x is lateral, y lengthwise, z vertical once the axis - // correction has mapped raw glTF [x, y, z] -> [z, x, y]. const rootTransform = Cesium.Matrix4.fromTranslationQuaternionRotationScale( new Cesium.Cartesian3(0, TRAIL_ROOT_OFFSET.lengthwise, TRAIL_ROOT_OFFSET.vertical), Cesium.Quaternion.IDENTITY, @@ -796,133 +225,119 @@ test('the trail anchor rides the rendered longitudinal axis at every heading', ( const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame( position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4(), ); - for (const [url, anchor] of Object.entries(MODEL_TRAIL_ANCHOR_NATIVE)) { + const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()); + const { lengthwise, vertical, lateral } = headingAxes(origin, headingDeg); + for (const [name, anchor] of Object.entries(FLEET_TRAIL_ANCHORS)) { const model = { modelMatrix, computedScale: 1, sceneGraph: { components: { transform: rootTransform } }, }; - const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()); const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); - // Reference axes derived INDEPENDENTLY, from the ENU frame and the same - // heading the matrix was built with — deliberately NOT through - // modelAnchorWorld. Taking them through the function under test makes the - // check self-consistent: a wrong transform rotates the anchor and the - // reference frame together and the lateral component stays zero, which is - // how the first version of this pin passed against the very bug it was - // written for. - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); - const axis = (i) => { - const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); - return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); - }; - const east = axis(0); const north = axis(1); const vertical = axis(2); - const rad = Cesium.Math.toRadians(headingDeg); - const lengthwise = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(rad), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(rad), new Cesium.Cartesian3()), - new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const lateral = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(lengthwise, vertical, new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const offset = sub(world, origin); + const offset = Cesium.Cartesian3.subtract(world, origin, new Cesium.Cartesian3()); + const where = `${name} @ heading ${headingDeg}`; + const side = Cesium.Cartesian3.dot(offset, lateral); + assert.ok(Math.abs(side) < 1e-4, `${where}: the anchor must have NO lateral component (got ${side.toFixed(4)} m)`); const along = Cesium.Cartesian3.dot(offset, lengthwise); const up = Cesium.Cartesian3.dot(offset, vertical); - const side = Cesium.Cartesian3.dot(offset, lateral); - const where = `${url} @ heading ${headingDeg}`; - // The anchor must lie in the vertical plane through the heading. The - // regression put the whole longitudinal offset on THIS axis. - assert.ok(Math.abs(side) < 1e-4, - `${where}: the anchor must have NO lateral component (got ${side.toFixed(4)} m) — ` + - 'a sideways offset is the field regression, and it is invisible at a single heading'); - // SIGNED, not magnitudes. An `abs()` on both sides passes an anchor that - // landed on the nose instead of the tail, or on the roof instead of the - // belly — which are the two directions the table exists to fix. The - // expected values carry the root transform, so a dropped `components` - // link fails here rather than quietly rendering the raw table value. const expectedAlong = anchor[0] * TRAIL_ROOT_SCALE + TRAIL_ROOT_OFFSET.lengthwise; const expectedUp = anchor[1] * TRAIL_ROOT_SCALE + TRAIL_ROOT_OFFSET.vertical; assert.ok(Math.abs(along - expectedAlong) < 1e-4, - `${where}: the longitudinal offset must land AFT along the heading ` + - `(${along.toFixed(4)} vs ${expectedAlong.toFixed(4)})`); + `${where}: longitudinal offset ${along.toFixed(4)} vs ${expectedAlong.toFixed(4)}`); assert.ok(Math.abs(up - expectedUp) < 1e-4, - `${where}: the belly offset must land BELOW on the vertical axis ` + - `(${up.toFixed(4)} vs ${expectedUp.toFixed(4)})`); + `${where}: vertical offset ${up.toFixed(4)} vs ${expectedUp.toFixed(4)}`); } } }); -// A helicopter is in the sweep above by asset, but the field report also had a -// hovering H60 whose course is ill-defined. The anchor must not depend on the -// contact having a meaningful heading — it is a model-space offset, so an -// arbitrary heading still puts it aft of the hull. +// A hovering helicopter's course is ill-defined. The anchor is a model-space +// offset, so an arbitrary heading still puts it on the hull's centreline. test('a hovering rotorcraft anchors aft of its own hull, whatever its heading', () => { const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 300); - const anchor = MODEL_TRAIL_ANCHOR_NATIVE['/models/bell206.glb']; + const spec = aircraftModelSpec(set, { typeCode: 'H60' }); + const anchor = spec.trailAnchorNative; + assert.ok(anchor[0] > 0, 'the rotorcraft anchor sits aft'); for (const headingDeg of [0, 137.5, 271.9]) { const hpr = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(headingDeg), 0, 0); const model = { modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame( position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4()), - computedScale: 1, + computedScale: spec.scale, }; const origin = Cesium.Matrix4.getTranslation(model.modelMatrix, new Cesium.Cartesian3()); const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); - const ax = (i) => { - const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); - return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); - }; - const rad = Cesium.Math.toRadians(headingDeg); - const fwd = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(ax(1), Math.cos(rad), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(ax(0), Math.sin(rad), new Cesium.Cartesian3()), - new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const lateral = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(fwd, ax(2), new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const { lateral } = headingAxes(origin, headingDeg); const side = Cesium.Cartesian3.dot( Cesium.Cartesian3.subtract(world, origin, new Cesium.Cartesian3()), lateral); assert.ok(Math.abs(side) < 1e-4, - `bell206 @ heading ${headingDeg}: hovering rotorcraft anchor drifted sideways by ${side.toFixed(4)} m`); + `${spec.model} @ heading ${headingDeg}: hovering rotorcraft anchor drifted sideways by ${side.toFixed(4)} m`); } }); -test('legacy aircraft GLBs keep scale and orientation baked with no node transforms', () => { - for (const url of Object.keys(MODEL_VISUAL_CENTER_NATIVE)) { - const file = path.join(ROOT, 'public', url); - const gltf = glbJson(file); - for (const node of gltf.nodes || []) { - assert.equal(node.matrix, undefined, `${url}: ${node.name || 'node'} has an unapplied matrix`); - assert.equal(node.translation, undefined, `${url}: ${node.name || 'node'} has unapplied translation`); - assert.equal(node.rotation, undefined, `${url}: ${node.name || 'node'} has unapplied rotation`); - assert.equal(node.scale, undefined, `${url}: ${node.name || 'node'} has unapplied scale`); - } - const { min, max } = nativeBounds(file); - const extents = max.map((value, axis) => value - min[axis]); - assert.ok( - extents[1] < extents[0] && extents[1] < extents[2], - `${url}: glTF +Y must remain the short vertical axis; got ${extents.map((v) => v.toFixed(2)).join(' × ')}`, - ); - } -}); - -test('real per-class models remain origin-centred for visual anchoring', () => { - for (const spec of Object.values(CLASS_MODEL_REAL)) { - const center = nativeVisualCenter(path.join(ROOT, 'public', spec.url)); - const maxOffset = Math.max(...center.map(Math.abs)); - assert.ok( - maxOffset <= 0.001, - `${spec.url}: visual centre ${center.map((v) => v.toFixed(4)).join(', ')} ` + - 'must stay at the origin or receive explicit modelVisualAnchor metadata', - ); - } -}); +function normalBillboardScaleByDistance(sourceFile) { + const src = readLayerSource(path.join(ROOT, sourceFile)); + const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); + assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); + const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); + assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); + return scalar.slice(1).map(Number); +} test('civilian and military globe-view aircraft retain the established 3.0 near scale and 0.5 floor', () => { - for (const layer of LAYERS) { + for (const [name, source] of [ + ['flights', 'src/data/flights.js'], + ['military', 'src/data/militaryFlights.js'], + ]) { assert.deepEqual( - normalBillboardScaleByDistance(layer.source), + normalBillboardScaleByDistance(source), [1000, 3, 8000000, 0.5], - `${layer.name}: owner-established close sizing and the orbital floor must remain readable`, + `${name}: owner-established close sizing and the orbital floor must remain readable`, ); } }); + +test('a layer asking every tick gets one cached spec per type and category', async () => { + const { aircraftLayerModelSpec } = await import('../layers/flights/modelSpec.js'); + const a = aircraftLayerModelSpec({ typeCode: 'A320' }, set); + assert.equal(aircraftLayerModelSpec({ typeCode: 'A320' }, set), a); + assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a); + assert.ok(Object.isFrozen(a)); +}); + +test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => { + const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import( + '../layers/flights/modelSpec.js' + ); + const tender = aircraftLayerModelSpec( + { typeCode: 'ZVEH', callsign: 'FIRE 2' }, + set, + ); + const car = aircraftLayerModelSpec( + { typeCode: 'ZVEH', callsign: 'LEADER 7' }, + set, + ); + assert.equal(tender.model, 'fire_truck'); + assert.equal(car.model, 'car'); + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set), + aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set), + ); + assert.notEqual( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set), + ); + // Keyed by the rule matched, not the callsign: at most one entry per rule + // and one for the type's own model. + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set), + ); + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set), + ); + assert.equal( + aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set), + tender, + ); +}); diff --git a/src/data/modelVisualAnchor.js b/src/data/modelVisualAnchor.js index 733cd5049..6659fda8f 100644 --- a/src/data/modelVisualAnchor.js +++ b/src/data/modelVisualAnchor.js @@ -1,74 +1,15 @@ import * as Cesium from 'cesium'; + /** - * Model-space visual centres measured from the shipped GLBs' scene-space AABBs. + * Model-space anchors on the aircraft layers' fleet models: where brackets + * and readouts sit (a model's visual centre) and where a tracked trail + * attaches (its aft-belly point, measured per model in fleetTrailAnchors.js + * and handed to the layers with each model's spec by fleetModels.js). * - * glTF stores these assets Y-up. Cesium's model frame is Z-up, so a glTF - * centre `[x, y, z]` becomes `[x, -z, y]` before the layer's ENU modelMatrix - * is applied. Values remain in native model units; callers supply the Model's + * Values are native model units in raw glTF axes; callers supply the Model's * effective rendered `computedScale` because Cesium does not bake it into * modelMatrix and may raise it above `scale` to satisfy minimumPixelSize. */ -const CENTERED_MODEL = Object.freeze([0, 0, 0]); - -export const MODEL_VISUAL_CENTER_NATIVE = Object.freeze({ - '/models/airplane.glb': CENTERED_MODEL, - '/models/jet.glb': CENTERED_MODEL, -}); - -/** - * Aft-belly trail attachment points, measured on each approved model's actual - * MESH SURFACE. Every bundled asset is baked to one convention (see - * aircraftClass.js): origin at the bbox centre, X = length with the NOSE at - * local −X, glTF Y = up (Cesium-local Z here). - * - * Each value is the point of the hull's CENTRELINE PROFILE (the y = 0 slice of - * the mesh) closest to that model's aft-belly AABB corner. Three things follow - * from that construction, and each is asserted rather than described in - * modelScale.test.mjs: - * - * ON THE HULL. This is the correction that matters. The previous round put - * the anchor ON the AABB corner itself, reasoning that an anchor aft of - * every vertex makes non-intersection geometric. It does — but an AABB - * corner is EMPTY SPACE, and on these airframes it is a long way from - * any of them: 4.80 m out for airplane.glb and 6.14 m for jet.glb in - * native units, which at the 450 m framing is a visible gap between the - * aircraft and the end of its own trail. The trail must terminate on the - * aeroplane, so the anchor is a surface point. - * - * AFT. The head segment is drawn from a point behind the aircraft to this - * anchor, so an anchor near the middle makes the segment enter at the - * tail and stop mid-fuselage — a line drawn through the airframe. These - * sit at 83–96 % of each model's aft extreme, on the aft-most hull the - * centreline has. - * - * BELOW. Nothing of the aircraft is lower than the anchor at or behind its - * own station, so the trail leaves from underneath the way a wake does - * rather than out of the roof. On most assets that is a frank belly - * point; on jet.glb the aft centreline profile has swept up into the - * tail cone by then, so the anchor sits just above the model's vertical - * centre (+0.13 native on a 5.63 m half-height) at the exhaust — which - * is where a fast jet's wake leaves it anyway. - * - * Y stays on the centreline — the profile is sliced at y = 0 rather than taking - * the nearest surface point in the round — so the attachment cannot drift - * screen-left or screen-right as the aircraft turns. (The unconstrained nearest - * point would put jet.glb's anchor 1.29 m off-axis.) Values are native model - * units; callers scale them by Cesium's effective `computedScale`. - * - * Locked against the shipped GLBs by modelScale.test.mjs, which re-reads the - * POSITION buffers and re-derives every property above — re-measure, never - * re-guess, when an asset changes. - */ -export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({ - '/models/airplane.glb': Object.freeze([24.0879, -2.2163, 0]), - '/models/jet.glb': Object.freeze([18.9103, 0.1266, 0]), - '/models/bell206.glb': Object.freeze([5.2077, -0.0005, 0]), - '/models/c172.glb': Object.freeze([3.6816, -0.2425, 0]), - '/models/citation2.glb': Object.freeze([6.8804, -0.6136, 0]), - '/models/mq9.glb': Object.freeze([5.1951, -1.1516, 0]), - '/models/b789.glb': Object.freeze([30.1007, -3.4814, 0]), - '/models/atr72.glb': Object.freeze([12.9785, -1.2935, 0]), -}); /** @constant Cesium's OWN axis correction for a glTF loaded with the * `Model.fromGltfAsync` defaults (upAxis Y, forwardAxis Z), assembled from its @@ -134,27 +75,6 @@ export function modelAnchorWorld(model, nativeAnchor, result) { return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result); } -/** - * Return the model-local visual centre for an approved bundled GLB. - * Assets absent from the table were audited as origin-centred. - * @param {string} url Bundled model URL. - * @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset. - */ -export function visualCenterForModel(url) { - return MODEL_VISUAL_CENTER_NATIVE[url] || CENTERED_MODEL; -} - -/** - * Return the model-local aft-belly attachment point for a tracked trail. - * Unknown future assets safely fall back to their visual centre until their - * geometry is measured and added to the table. - * @param {string} url Bundled model URL. - * @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset. - */ -export function trailAnchorForModel(url) { - return MODEL_TRAIL_ANCHOR_NATIVE[url] || visualCenterForModel(url); -} - /** * Where a trail head segment should START being drawn: the whole segment, a * shortened piece of it, or nothing at all. @@ -171,7 +91,7 @@ export function trailAnchorForModel(url) { * mistake matters: * * - Testing segment LENGTH against the radius hid real trail. With - * `airplane.glb`'s 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m + * an airliner model's 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m * aft gives a 33.98 m segment — under the radius, so suppressed — even * though ~23.6 m of it is in open air behind the aircraft. It also made the * whole segment pop in at once on crossing ~58.4 m. diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 17fec219d..e673775cd 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -45,6 +45,18 @@ import militaryFlightsLayer, { } from './militaryFlights.js'; import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js'; import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js'; +import { loadFleetModelSet } from './fleetModels.js'; +import { aircraftLayerModelSpec } from '../layers/flights/modelSpec.js'; +import { fetchPublicFile } from '../testSupport/fleetGlb.mjs'; + +// The layers draw only once the fleet model set has loaded; load it from disk. +// Model creation itself is stubbed per test through Cesium.Model.fromGltfAsync. +const _silentInfo = console.info; +console.info = () => {}; +await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = _silentInfo; +const FLIGHTS_META = Object.freeze({ typeCode: 'A320', klass: 'airliner' }); +const MILITARY_META = Object.freeze({ typeCode: 'F16', klass: 'fastjet' }); // Sample altitudes are expressed RELATIVE to the band rather than as absolute // offsets. The thresholds have already been retuned once (1_000_000 → 150_000 @@ -201,7 +213,8 @@ const LAYERS = [ driveFleetModel: flightsDriveFleetModel, ensureFleetModel: flightsEnsureFleetModel, trackedBillboardColor: flightsTrackedBillboardColor, - bellyM: 6.719, + /** How far a grounded A320 stands its origin above the ground (scaled wheel clearance). */ + bellyM: aircraftLayerModelSpec(FLIGHTS_META).bellyM, seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedFlightRefreshStateForTest({ @@ -213,7 +226,7 @@ const LAYERS = [ tracked, models, modelCollection, - meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround }, + meta: { callsign: 'TEST123 ', altitude: 10_668, ...FLIGHTS_META, onGround }, }); return billboard; }, @@ -226,7 +239,7 @@ const LAYERS = [ driveFleetModel: militaryDriveFleetModel, ensureFleetModel: militaryEnsureFleetModel, trackedBillboardColor: militaryTrackedBillboardColor, - bellyM: 5.631 * 0.8, + bellyM: aircraftLayerModelSpec(MILITARY_META).bellyM, seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedMilitaryRefreshStateForTest({ @@ -238,7 +251,7 @@ const LAYERS = [ tracked, models, modelCollection, - meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet', onGround }, + meta: { callsign: 'TEST123 ', altitudeFt: 35_000, ...MILITARY_META, onGround }, }); return billboard; }, @@ -841,8 +854,8 @@ for (const fixture of LAYERS) { Date.now = () => now; let attempts = 0; const requested = []; - Cesium.Model.fromGltfAsync = ({ url }) => { - attempts++; requested.push(url); + Cesium.Model.fromGltfAsync = ({ basePath }) => { + attempts++; requested.push(basePath); return Promise.reject(new Error('simulated missing GLB')); }; // The give-up path warns once by design; keep the run's output clean. diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index 425a69ceb..a6b69c7e4 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -291,7 +291,7 @@ test('tracking layers write gevLabelModel and expose only their cached display p assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false); }); -test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => { +test('civilian and military trail heads use the lower-centre model anchor', async () => { const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => ( [name, readLayerSource(new URL(`./${name}`, import.meta.url))] ))); @@ -300,7 +300,5 @@ test('civilian and military trail heads use the lower-centre model anchor and we (/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source), `${name} trail head uses the dedicated lower-centre model anchor`, ); - assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'), - `${name} keeps diffuse texture contribution weak through code-side MIX`); } }); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 46a29dc2f..8adc01b0e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3953,5 +3953,12 @@ "Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات", "Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات", "Showing {shown} of {total}": "عرض {shown} من {total}", - "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات" + "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات", + "The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.", + "Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.", + "Reload": "إعادة التحميل", + "Retry": "إعادة المحاولة", + "Reset view": "إعادة ضبط العرض" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ca668a74e..4753051cc 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3605,5 +3605,12 @@ "Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen", "Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen", "Showing {shown} of {total}": "{shown} von {total} angezeigt", - "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN" + "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN", + "The globe stopped drawing": "Der Globus zeichnet nicht mehr", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.", + "Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.", + "Reload": "Neu laden", + "Retry": "Erneut versuchen", + "Reset view": "Ansicht zurücksetzen" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2a374d7ec..1c3fd8247 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3692,5 +3692,12 @@ "500–1,000 m": "500–1000 m", "1,000–2,000 m": "1000–2000 m", "2,000–3,000 m": "2000–3000 m", - "3,000 m and above": "3000 m o más" + "3,000 m and above": "3000 m o más", + "The globe stopped drawing": "El globo dejó de dibujarse", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.", + "Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.", + "Reload": "Recargar", + "Retry": "Reintentar", + "Reset view": "Restablecer la vista" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 98633538b..23fd088a3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3692,5 +3692,12 @@ "Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées", "Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées", "Showing {shown} of {total}": "Affichées : {shown} sur {total}", - "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES" + "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES", + "The globe stopped drawing": "Le globe ne s’affiche plus", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.", + "Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.", + "Reload": "Recharger", + "Retry": "Réessayer", + "Reset view": "Réinitialiser la vue" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 335515fb4..2140be34e 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3692,5 +3692,12 @@ "Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים", "Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים", "Showing {shown} of {total}": "מוצגים {shown} מתוך {total}", - "BROWSE DATA LIBRARY": "עיון בספריית הנתונים" + "BROWSE DATA LIBRARY": "עיון בספריית הנתונים", + "The globe stopped drawing": "הגלובוס הפסיק להצטייר", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.", + "Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.", + "Reload": "טען מחדש", + "Retry": "נסה שוב", + "Reset view": "איפוס תצוגה" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c6127b3fb..6f3fef7dc 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3692,5 +3692,12 @@ "Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte", "Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte", "Showing {shown} of {total}": "Mostrati: {shown} su {total}", - "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI" + "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI", + "The globe stopped drawing": "Il globo ha smesso di disegnare", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.", + "Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.", + "Reload": "Ricarica", + "Retry": "Riprova", + "Reset view": "Ripristina vista" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 62c517f21..6aeb580ac 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3518,5 +3518,12 @@ "Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除", "Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除", "Showing {shown} of {total}": "{total}件中{shown}件を表示", - "BROWSE DATA LIBRARY": "データライブラリを見る" + "BROWSE DATA LIBRARY": "データライブラリを見る", + "The globe stopped drawing": "地球儀の描画が止まりました", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。", + "Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。", + "Reload": "再読み込み", + "Retry": "再試行", + "Reset view": "ビューをリセット" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5d3cfb253..3cfde1eea 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3692,5 +3692,12 @@ "Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas", "Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas", "Showing {shown} of {total}": "Mostrando {shown} de {total}", - "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS" + "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS", + "The globe stopped drawing": "O globo parou de desenhar", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.", + "Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.", + "Reload": "Recarregar", + "Retry": "Tentar novamente", + "Reset view": "Redefinir vista" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ef8b144e5..b7f2b42d7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3779,5 +3779,12 @@ "500–1,000 m": "500–1000 м", "1,000–2,000 m": "1000–2000 м", "2,000–3,000 m": "2000–3000 м", - "3,000 m and above": "3000 м и выше" + "3,000 m and above": "3000 м и выше", + "The globe stopped drawing": "Глобус перестал отрисовываться", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.", + "Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.", + "Reload": "Перезагрузить", + "Retry": "Повторить", + "Reset view": "Сбросить вид" } diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 87c66ef3f..acfd2916d 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3605,5 +3605,12 @@ "Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager", "Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager", "Showing {shown} of {total}": "Visar {shown} av {total}", - "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET" + "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET", + "The globe stopped drawing": "Globen ritas inte längre", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.", + "Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.", + "Reload": "Ladda om", + "Retry": "Försök igen", + "Reset view": "Återställ vy" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 4b079ac0a..81fb172f5 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3779,5 +3779,12 @@ "Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»", "Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»", "Showing {shown} of {total}": "Показано {shown} з {total}", - "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ" + "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ", + "The globe stopped drawing": "Глобус перестав відмальовуватися", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.", + "Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.", + "Reload": "Перезавантажити", + "Retry": "Повторити", + "Reset view": "Скинути вигляд" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8311259cd..715c25ace 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3518,5 +3518,12 @@ "Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目", "Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目", "Showing {shown} of {total}": "显示 {shown} / {total}", - "BROWSE DATA LIBRARY": "浏览数据资源库" + "BROWSE DATA LIBRARY": "浏览数据资源库", + "The globe stopped drawing": "地球已停止绘制", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。", + "Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。", + "Reload": "重新加载", + "Retry": "重试", + "Reset view": "重置视图" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index fd201af36..bd2fa477f 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3518,5 +3518,12 @@ "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目", "Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目", "Showing {shown} of {total}": "顯示 {shown} / {total}", - "BROWSE DATA LIBRARY": "瀏覽數據資源庫" + "BROWSE DATA LIBRARY": "瀏覽數據資源庫", + "The globe stopped drawing": "地球已停止繪製", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。", + "Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。", + "Reload": "重新加載", + "Retry": "重試", + "Reset view": "重置視圖" } diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js index 1132c36c8..c107239c4 100644 --- a/src/layers/flights/enrichment.js +++ b/src/layers/flights/enrichment.js @@ -164,10 +164,10 @@ export function createEnrichment({ const bb = flightState._billboards.get(icao24); if (bb) parts.rendering._applyFleetBillboardPresentation(icao24, bb); - // Hangar fleet: the class's GLB/scale may have changed — resync the - // live model, any in-flight load, and the tracked standalone model. - parts.rendering._syncModelToClass(icao24); } + // The type picks the fleet model and its length — resync the live + // model, any in-flight load, and the tracked standalone model. + parts.rendering._syncModelToClass(icao24); } if (icao24 === flightState._trackedIcao && flightState._trackedEntity) parts.tracking._updateTrackedLabelModel(icao24); diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index adfb11cc4..a435e6793 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { PLANE_MODEL_URL } from './policy.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; export function createLifecycle({ flightState, @@ -53,29 +53,19 @@ export function createLifecycle({ registerSpriteCollection('flights', flightState._billboardCollection); flightState._modelCollection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(flightState._modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { + // Every fleet model loads once, up front, for every layer that draws + // them (fleetModels.js), so no model is fetched while the session runs. + // Captured against this epoch so a destroy/re-init mid-load doesn't flip + // the flag for a torn-down lifecycle. + { const epoch = flightState._modelEpoch; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(PLANE_MODEL_URL), - asynchronous: false, - }) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; + loadFleetModelSet({ resolveAsset }) + .then(() => { + if (epoch === flightState._modelEpoch) flightState._planeModelLoaded = true; - } else { - try { - m.destroy(); - } catch { - /* gone */ - } - } }) .catch(() => { - /* tracked plane just stays a billboard a beat longer */ + /* aircraft stay billboards */ }); } flightState._billboards = new Map(); @@ -296,14 +286,6 @@ export function createLifecycle({ flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) flightState._modelPending.clear(); flightState._modelGen.clear(); - if (flightState._preloadModel) { - try { - flightState._preloadModel.destroy(); - } catch { - /* gone */ - } - flightState._preloadModel = null; - } flightState._planeModelLoaded = false; flightState._billboards.clear(); flightState._cullPositions.clear(); diff --git a/src/layers/flights/modelSpec.js b/src/layers/flights/modelSpec.js index 850433824..2a4455b0e 100644 --- a/src/layers/flights/modelSpec.js +++ b/src/layers/flights/modelSpec.js @@ -1,52 +1,83 @@ import { - CLASS_MODEL_REAL, - CLASS_MODEL_URL, - CLASS_SCALE_3D, -} from '../../data/aircraftClass.js'; -import { - trailAnchorForModel, - visualCenterForModel, -} from '../../data/modelVisualAnchor.js'; -import { - MODEL_BELLY_OFFSET_NATIVE, - MODEL_COLOR_BLEND_AMOUNT, - MODEL_NATIVE_RADIUS_M, - MODEL_SCALE, - PLANE_MODEL_URL, -} from './policy.js'; + aircraftCallsignRule, + aircraftModelByCallsign, + aircraftModelSpec, + fleetModelSet, +} from '../../data/fleetModels.js'; /** - * The glTF asset, scale and measured geometry a civil aircraft class renders - * with. Real per-class GLBs are baked to real-world meters (scale 1, measured - * belly and radius); classes without one share airplane.glb at the class's - * 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw - * the same model for the same class. - * @param {string} klass Aircraft class from `classifyAircraft`. - * @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number, - * blendAmount:number, visualCenterNative:object, trailAnchorNative:object}} + * What an aircraft draws with before the fleet model set has loaded: no + * model (its billboard stays the visual), with neutral geometry for any + * reader that asks. */ -export function civilAircraftModelSpec(klass) { - const real = CLASS_MODEL_REAL[klass]; - if (real) { - return { - url: real.url, - scale: 1, - nativeRadiusM: real.radiusM, - bellyM: real.bellyM, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; +export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({ + model: null, + url: null, + scale: 1, + scaleXYZ: Object.freeze([1, 1, 1]), + length: 0, + nativeRadiusM: 20, + clearanceM: 0, + bellyM: 0, + elevationM: 0, + tinted: false, + visualCenterNative: Object.freeze([0, 0, 0]), + trailAnchorNative: Object.freeze([0, 0, 0]), + key: 'unloaded', +}); + +/** + * The fleet model, life-size scale and measured geometry an aircraft draws + * with, chosen by its ICAO type and emitter category, and by its callsign + * for the types that pick by callsign (`fleetModels.js`). + * Shared by the Flights, Military Flights and Local ADS-B layers so all three + * draw the same model for the same aircraft. `bellyM` is the model's scaled + * clearance: how far a grounded aircraft's origin stands above the ground. + * @param {{typeCode?: string|null, type?: string|null, + * category?: string|number|null, + * callsign?: string|null}|null|undefined} aircraft + * @param {object|null} [set] The loaded fleet model set. + * @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before + * the set has loaded. + */ +export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) { + if (!set) return UNLOADED_AIRCRAFT_MODEL_SPEC; + // Layers ask on every tick; each type and category is resolved once per set. + let cache = specCaches.get(set); + if (!cache) specCaches.set(set, (cache = new Map())); + const key = aircraftModelSpecCacheKey(aircraft, set); + let layerSpec = cache.get(key); + if (!layerSpec) { + const spec = aircraftModelSpec(set, { + typeCode: aircraft?.typeCode ?? aircraft?.type ?? null, + category: aircraft?.category ?? null, + callsign: aircraft?.callsign ?? null, + }); + layerSpec = Object.freeze({ + ...spec, + bellyM: spec.clearanceM, + }); + cache.set(key, layerSpec); } - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; - return { - url, - scale, - nativeRadiusM: MODEL_NATIVE_RADIUS_M, - bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(url), - trailAnchorNative: trailAnchorForModel(url), - }; + return layerSpec; +} + +/** @type {WeakMap>} set -> cache key -> spec */ +const specCaches = new WeakMap(); + +/** + * Cache key of an aircraft's model spec: what selects its model. For a type + * that picks its model by callsign (an airport vehicle) the key adds the + * rule its callsign matches, or `type` for none, so such a type caches at + * most one entry per rule and one for its own model. + * @param {object|null|undefined} aircraft + * @param {object|null} [set] The loaded fleet model set. + * @returns {string} + */ +export function aircraftModelSpecCacheKey(aircraft, set = fleetModelSet()) { + const type = aircraft?.typeCode ?? aircraft?.type ?? ''; + const key = `${type}|${aircraft?.category ?? ''}`; + if (!set || !aircraftModelByCallsign(set.types, type)) return key; + const rule = aircraftCallsignRule(set.types, type, aircraft?.callsign); + return `${key}|${rule >= 0 ? `r${rule}` : 'type'}`; } diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index 637da2bb6..a0ee22ade 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -431,7 +431,7 @@ export function createMotion({ const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); if (parts.rendering._modelOwnsVisual(icao24)) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(icao24)?.klass, + flightState.records.data.get(icao24), ); const scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, @@ -614,7 +614,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); return modelVisualAnchor( flightState._trackedModel.modelMatrix, @@ -659,7 +659,7 @@ export function createMotion({ ) return 0; const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const scale = Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -673,7 +673,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); // Through the model's OWN render chain (modelVisualAnchor's hand-rolled // half-correction put this offset on the lateral axis — see diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 58d6a86ea..3b89a26f6 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -39,8 +39,6 @@ export const GROUND_SCALE = 0.8; // the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each // backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet). -export const PLANE_MODEL_URL = '/models/airplane.glb'; - export const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) @@ -55,10 +53,6 @@ export const TRACKED_MODEL_MIN_PX = 40; export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -export const MODEL_NATIVE_RADIUS_M = 34.41; - -export const MODEL_SCALE = 1; -// airplane.glb is transform-applied and baked to real-world meters // Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost. export const MODEL_MAX = 150; @@ -90,20 +84,7 @@ export const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km export const MODEL_HEADING_OFFSET_DEG = 180; -// airplane.glb nose is opposite Cesium heading-0 -// Owner launch-polish direction: models should read as clean light silhouettes, -// with only a weak diffuse contribution from the existing approved textures. - -export const MODEL_COLOR_BLEND_AMOUNT = 0.94; - -// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its -// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as -// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m -// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled -// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB -// by modelScale.test.mjs. - -export const MODEL_BELLY_OFFSET_NATIVE = 6.719; +// every fleet model is nose -X: opposite Cesium heading 0 export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); @@ -203,6 +184,24 @@ export const LANDED_SPEED_MAX_MPS = 23; export const FLEET_DR_INTERVAL_MS = 80; +/** + * The icon-to-model rules the aircraft layers follow, as the Vessels, Transit + * and Rail layers take them (`createFleetModelLayer`): the same altitude + * ceiling, minimum pixel size, proximity/all caps, add/keep radii and + * eligibility cadence, so every layer turns icons into models at one distance. + */ +export const FLEET_MODEL_POLICY = Object.freeze({ + altitudeCeilingM: MODEL_ALT_CEIL_M, + minimumPixelSize: MODEL_MIN_PX, + cap: MODEL_MAX, + capAll: MODEL_MAX_ALL, + addM: MODEL_PROX_ADD_M, + addAllM: MODEL_ALL_ADD_M, + keepM: MODEL_PROX_KEEP_M, + keepAllM: MODEL_ALL_KEEP_M, + intervalMs: FLEET_DR_INTERVAL_MS, +}); + /** @constant {number} Max ms between rotation passes while the camera is idle. */ export const ROTATION_REFRESH_MS = 1000; diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 3c00b1824..c43b7c787 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -480,9 +480,7 @@ export function createQueries({ // and rises only as the coarse ground-floor cell warms. Zero extra sampling and // no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep // `bb.position` — sprite and bracket are co-located there, so association holds. - const spec = modelOwnsVisual - ? parts.rendering._modelSpec(info?.klass) - : null; + const spec = modelOwnsVisual ? parts.rendering._modelSpec(info) : null; const pos = isTracked ? parts.motion._trackedVisualCached() || bb.position : modelOwnsVisual diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 63cc6f5f5..02c50f3d8 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -5,6 +5,11 @@ import { } from '../../data/renderAltitude.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; import { aircraftEmergency } from '../../data/aircraftEmergency.js'; +import { + aircraftCallsignRule, + aircraftModelSpec, + fleetModelSet, +} from '../../data/fleetModels.js'; import { approxDistanceKm, GROUND_FLOOR_CLAMP_RADIUS_KM, @@ -21,6 +26,7 @@ export class FlightRecords { cachedGroundFloor, floorAltitudeM, now = () => Date.now(), + fleetModels = fleetModelSet, }) { this.services = { geoidHeight, @@ -29,6 +35,8 @@ export class FlightRecords { }; /** The time records are judged at: the layer's clock. */ this.now = now; + /** The loaded fleet model set, or null before it loads. */ + this.fleetModels = fleetModels; this.data = new Map(); // Facts about an airframe, not a moment, kept across a reset. this.airframes = new Map(); @@ -92,7 +100,6 @@ export class FlightRecords { verticalRateMps: vertical_rate, } = observation; - const onGround = on_ground === true; this.missingPolls.delete(icao24); // Sticky merge: OpenSky intermittently drops callsign/velocity/track for // aircraft it still positions — hold last-known-good instead of @@ -102,6 +109,27 @@ export class FlightRecords { // The airframe's facts: this record's, or those kept across a reset. const airframe = prevMeta ?? this.airframes.get(icao24); this.airframes.delete(icao24); + // A ground vehicle or ground station (a feed's ZVEH or GRND: a model + // built standing, catalog category `other`) takes the feed's own type, + // which no airframe lookup knows; every other aircraft keeps waiting for + // its looked-up type. With no altitude at all it stands on the ground, + // where it is, instead of at the airborne default. + const groundModel = (typeCode) => + drawnAsGroundModel( + this.fleetModels(), + typeCode, + category, + stickyText(callsign, prevMeta?.callsign), + ); + const feedType = observation.typeCode || null; + const typeCode = + airframe?.typeCode ?? + (feedType && groundModel(feedType) ? feedType : null); + const onGround = + on_ground === true || + (!Number.isFinite(baro_alt) && + !Number.isFinite(geo_alt) && + groundModel(typeCode)); // Grounded planes with no baro reading sit at 0 m, not the 10 km // airborne default (a parked plane must never float). // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky @@ -319,8 +347,9 @@ export class FlightRecords { prevMeta?.lastContactEpochMs, null, ), - // adsbdb enrichment — written by the enrichment callbacks, carried across polls: - typeCode: airframe?.typeCode ?? null, + // adsbdb enrichment — written by the enrichment callbacks, carried + // across polls; a ground vehicle's or station's from the feed: + typeCode, typeName: airframe?.typeName ?? null, registration: airframe?.registration ?? null, airline: routeHeld ? (airframe.airline ?? null) : null, @@ -408,3 +437,33 @@ export class FlightRecords { this.geoidNCache.delete(id); } } + +/** Per loaded fleet model set: whether a type, category and callsign rule draw a ground model. */ +const groundModelCaches = new WeakMap(); + +/** + * Whether an aircraft of this type, emitter category and callsign is drawn + * with a model built standing on the ground (catalog category `other`: an + * airport vehicle, a ground station's mast). False before the set loads. + * Answered once per type, category and callsign rule matched. + * @param {object|null} set The loaded fleet model set. + * @param {string|null} typeCode + * @param {string|number|null} category + * @param {string|null} callsign + * @returns {boolean} + */ +function drawnAsGroundModel(set, typeCode, category, callsign) { + if (!set || !typeCode) return false; + let cache = groundModelCaches.get(set); + if (!cache) groundModelCaches.set(set, (cache = new Map())); + const rule = aircraftCallsignRule(set.types, typeCode, callsign); + const key = `${typeCode}|${category ?? ''}|${rule}`; + let ground = cache.get(key); + if (ground === undefined) { + ground = + aircraftModelSpec(set, { typeCode, category, callsign }).category === + 'other'; + cache.set(key, ground); + } + return ground; +} diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs index 9fab9e129..cafe55cbf 100644 --- a/src/layers/flights/records.test.mjs +++ b/src/layers/flights/records.test.mjs @@ -1,13 +1,28 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { FlightRecords } from './records.js'; -function records() { +const FLEET = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../public/models/fleet', +); +const fleetSet = { + catalog: JSON.parse( + fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8'), + ), + types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')), +}; + +function records(fleetModels = () => null) { return new FlightRecords({ geoidHeight: () => 40, cachedGroundFloor: () => null, floorAltitudeM: (altitude) => altitude, approxDistanceKm: () => 0, + fleetModels, }); } const view = () => ({ @@ -260,3 +275,47 @@ test('a repeated fix time marks the contact as not updated; removal is unchanged store.forget('abc123'); assert.equal(store.fixRepeated.has('abc123'), false); }); + +test('a ground vehicle or station with no altitude stands on the ground; an aircraft does not', () => { + const store = records(() => fleetSet); + const noAltitude = { baroAltitudeM: null, ellipsoidAltitudeM: null }; + const vehicle = store.receive( + observation({ + id: 'v1', + typeCode: 'ZVEH', + callsign: 'FIRE 2', + ...noAltitude, + }), + view(), + ).meta; + assert.equal(vehicle.onGround, true); + assert.equal(vehicle.typeCode, 'ZVEH'); + assert.equal(vehicle.altitude, 0); + const mast = store.receive( + observation({ id: 'm1', typeCode: 'GRND', callsign: 'RX5', ...noAltitude }), + view(), + ).meta; + assert.equal(mast.onGround, true); + assert.equal(mast.typeCode, 'GRND'); + // An airliner with no altitude keeps the airborne default, and its feed + // type still waits for the airframe lookup. + const plane = store.receive( + observation({ id: 'p1', typeCode: 'A320', ...noAltitude }), + view(), + ).meta; + assert.equal(plane.onGround, false); + assert.equal(plane.altitude, 10000); + assert.equal(plane.typeCode, null); + // A vehicle reporting an altitude is placed by it. + const reported = store.receive( + observation({ id: 'v2', typeCode: 'ZVEH', baroAltitudeM: 12 }), + view(), + ).meta; + assert.equal(reported.onGround, false); + // Before the model set loads nothing is grounded by its model. + const unloaded = records().receive( + observation({ id: 'v3', typeCode: 'ZVEH', ...noAltitude }), + view(), + ).meta; + assert.equal(unloaded.onGround, false); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index bf8dbb5ee..64d9064ff 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -2,7 +2,11 @@ import * as Cesium from 'cesium'; import { julianAt } from '../../time/julian.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; -import { civilAircraftModelSpec } from './modelSpec.js'; +import { + aircraftLayerModelSpec, + aircraftModelSpecCacheKey, +} from './modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; @@ -41,7 +45,10 @@ import { STALE_ALPHA, displayDelayMs, } from './policy.js'; -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; +import { + AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + aircraftModelBlend, +} from '../../data/aircraftModelLighting.js'; export function createRendering({ flightState, @@ -115,11 +122,14 @@ export function createRendering({ return Number.POSITIVE_INFINITY; } - function _modelSpec(klass) { - const cached = flightState._specCache.get(klass); + /** The fleet model an aircraft draws with, by its type and emitter category. */ + function _modelSpec(meta) { + const key = aircraftModelSpecCacheKey(meta); + const cached = flightState._specCache.get(key); if (cached) return cached; - const spec = civilAircraftModelSpec(klass); - flightState._specCache.set(klass, spec); + const spec = aircraftLayerModelSpec(meta); + // Not cached before the model set loads: the answer changes once it has. + if (spec.model) flightState._specCache.set(key, spec); return spec; } @@ -185,11 +195,12 @@ export function createRendering({ * @returns {void} */ - /** Model tint, mirroring the billboard color rules. */ + /** Model colour: the mesh's own (white), tinted cyan while tracked and red + * while broadcasting an emergency. */ function _modelColor(icao24) { if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN; - return _fleetBillboardColor(icao24); + return _isEmergency(icao24) ? EMERGENCY_TINT : Cesium.Color.WHITE; } /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude @@ -309,7 +320,7 @@ export function createRendering({ Cesium.Ellipsoid.WGS84, flightState._scratchGroundCarto, ); - carto.height = h + _modelSpec(meta.klass).bellyM; + carto.height = h + _modelSpec(meta).bellyM; return Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, @@ -416,16 +427,13 @@ export function createRendering({ return !!model && model.ready === true && model.show === true; } - /** Spec identity for a LOADED model: URL and scale together (same-URL classes - * differ by scale — airliner vs quadjet both ship airplane.glb). */ + /** Spec identity for a LOADED model: the model and its scale together (one + * model draws several types, each at its own length). */ - const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; - }; + const _specKeyFor = (meta) => _modelSpec(meta).key; - /** Class-change model sync (enrichment AND poll-path klass updates): when the - * aircraft's live model or in-flight load no longer matches its class's spec, + /** Type-change model sync (enrichment AND poll-path type/category updates): when the + * aircraft's live model or in-flight load no longer matches its type's spec, * drop it so the eligibility pass reloads the right asset at the right scale. * Gap-proof: the fleet billboard is re-shown BEFORE the release so the * contact never goes invisible for the tick gap; _releaseModel's generation @@ -433,7 +441,7 @@ export function createRendering({ * the same rule (its billboard entity is always the fallback visual). */ function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const key = _specKeyFor(flightState.records.data.get(icao24)); const current = flightState._models.get(icao24); if ( (current && current._gevSpecKey !== key) || @@ -478,6 +486,11 @@ export function createRendering({ flightState._irReloadQueue = null; } + /** A Cesium Model of `spec` from the fleet set loaded up front (no fetch mid-session). */ + function _createModel(spec, options) { + return createFleetModel(spec, options, { resolveAsset }); + } + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ async function _ensureModel(icao24) { @@ -497,6 +510,8 @@ export function createRendering({ _modelCap() ) return; + // Until the fleet model set has loaded the aircraft stays a billboard. + if (!_modelSpec(flightState.records.data.get(icao24)).model) return; const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale flightState._modelPending.add(icao24); @@ -505,21 +520,17 @@ export function createRendering({ // aircraft mid-load, the post-await admission below rejects the stale asset. // Boost state likewise: the creation options bake it in, so a mid-load // toggle must reject too (the reload queue only covers ADMITTED models). - const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const specKey = _specKeyFor(flightState.records.data.get(icao24)); const loadIrBoost = flightState._irBoost; try { - const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), + const spec = _modelSpec(flightState.records.data.get(icao24)); + model = await _createModel(spec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // Launch presentation keeps the code-side tint dominant for every approved - // model; IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount, + ...aircraftModelBlend(flightState._irBoost), customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, @@ -560,7 +571,7 @@ export function createRendering({ flightState._models.has(icao24) || flightState._models.size >= _modelCap() || // Class reclassified mid-load → this GLB/scale is for the OLD class. - _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || + _specKeyFor(flightState.records.data.get(icao24)) !== specKey || // IR boost flipped mid-load → this model baked the wrong shader/tint. flightState._irBoost !== loadIrBoost; if (stale) { @@ -716,6 +727,9 @@ export function createRendering({ return; } if ( + // Until the fleet model set has loaded the billboard carries it. + _modelSpec(flightState.records.data.get(flightState._trackedIcao)) + .model && !flightState._trackedModel && !flightState._trackedModelLoading && parts.tracking._trackedModelLoadAllowed() @@ -723,23 +737,19 @@ export function createRendering({ flightState._trackedModelLoading = true; const gen = flightState._trackedModelGen; const trackedSpec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedKey = _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), + _createModel(trackedSpec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: TRACKED_MODEL_MIN_PX, scale: trackedSpec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // The tracked aircraft uses the same dominant light tint as the fleet; - // IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount, + ...aircraftModelBlend(flightState._irBoost), customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, @@ -766,7 +776,7 @@ export function createRendering({ // _trackedModel was still null): drop the stale asset; driver reloads. if ( _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { @@ -840,7 +850,7 @@ export function createRendering({ _applyTrackedModelFade(flightState._trackedModel); if (!flightState._trackedModel.ready) return; const spec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); flightState._trackedModel.scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 5219dcb09..cf6025dba 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -198,9 +198,14 @@ export function createFlightSnapshotRenderer({ ) { rendering._applyFleetBillboardPresentation(icao24, bb); } - // Poll-path class change (category updates): same model resync rule - // as the enrichment path — the class's GLB/scale may have changed. - if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); + // Poll-path type or category change: same model resync rule as the + // enrichment path — the fleet model or its scale may have changed. + if ( + prevMeta?.klass !== meta.klass || + prevMeta?.category !== meta.category || + prevMeta?.typeCode !== meta.typeCode + ) + rendering._syncModelToClass(icao24); } else { // Start where the fleet pass will draw it: the fleet renders behind // real time, so a fresh contact's displayed position is its fix diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index fd77dc113..17bf8b560 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -20,17 +20,13 @@ export function createFlightState({ source, services }) { * layer); see followsClock and displayDelayMs in policy.js. */ flightState.timeline = null; - /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs - * vertex-baked to real-world METERS in the airplane.glb axis convention, so - * they render at scale 1 with their own measured belly lift and bounding - * radius. Every other class keeps the shared-airplane.glb formula - * (MODEL_SCALE × CLASS_SCALE_3D). nativeRadiusM is PER SCALE UNIT — pixel-cap - * math multiplies it by `scale`, so world radius = nativeRadiusM × scale in - * both branches. The code-side MIX tint dominates every existing asset so - * class silhouettes stay light without modifying third-party GLBs/textures. */ - /* Specs are static per class, and the detection weld now asks for one per - * MODELED contact per frame (up to the fleet cap) on top of the 12 Hz fleet - * pass — so this is memoized, as militaryFlights.js already does. */ + /** Model spec per aircraft type and emitter category (aircraftLayerModelSpec in + * flights/modelSpec.js): the procedural fleet model that draws the type, life size, + * with its measured clearance, bounding radius and trail anchor. nativeRadiusM is PER + * SCALE UNIT — pixel-cap math multiplies it by `scale`. The code-side MIX tint dominates + * every model so silhouettes stay light (and this layer's own colour). Memoized: the + * detection weld asks for one per MODELED contact per frame on top of the 12 Hz fleet + * pass. */ flightState._specCache = new Map(); @@ -79,16 +75,11 @@ export function createFlightState({ source, services }) { // throttle the "more planes in view than the cap" console notice // The tracked entity's billboard goes transparent (rather than hidden) once the model takes // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its - // alpha after the GLB is preloaded so the model is ready to render the instant the billboard - // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not - // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + // alpha after the fleet model set is loaded so the model is ready to render the instant the + // billboard fades — no gap, no double-image. The set loads once at init (fleetModels.js). flightState._planeModelLoaded = false; - /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ - - flightState._preloadModel = null; - flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); flightState._scratchModelMtx = new Cesium.Matrix4(); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index de4215387..7cf01031c 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -254,6 +254,9 @@ export function createLocalAdsbLayer({ ); marker.evidence = evidence; marker.klass = klass; + // What picks its fleet model: the adsbdb type, else the emitter category. + marker.typeCode = enrichment.get(marker.record.icao)?.typeCode || null; + marker.category = marker.record.category || null; const billboard = marker.entity?.billboard; if (!billboard) return; if (marker.iconKind !== klass) { @@ -697,7 +700,6 @@ export function createLocalAdsbLayer({ if (viewer.scene?.primitives) { models = createLocalAdsbModels({ viewer, - color, resolveAsset, groundSnap: services.groundSnap?.createGroundSnap?.() || null, ...(loadModel ? { loadModel } : {}), diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 263c21210..414bca52f 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -12,6 +12,15 @@ import { localAdsbReceiverLine, localAdsbStatus, } from './index.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; +import { aircraftLayerModelSpec } from '../flights/modelSpec.js'; +import { fetchPublicFile } from '../../testSupport/fleetGlb.mjs'; + +// The layer draws models only once the fleet model set has loaded; load it from disk. +const realInfo = console.info; +console.info = () => {}; +await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = realInfo; function fakeReceiver(initial = {}) { const state = { @@ -494,7 +503,7 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o assert.equal(queries.length, 2, 'each key is asked for once'); }); -test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => { +test('the DISPLAY 3D toggle gives local aircraft their fleet models in their mesh colours', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; const loads = []; @@ -529,21 +538,20 @@ test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t await layer.update(); assert.equal(loads.length, 1); const [model] = loads; + const spec = aircraftLayerModelSpec({ category: 'A7' }); assert.equal( - model.url, - '/models/bell206.glb', - 'the Flights helicopter model', + model.basePath, + '/models/fleet/helicopters/generic_heli.glb', + 'the Flights rotorcraft model', ); - assert.equal(model.scale, 1); + assert.ok(model.gltf instanceof Uint8Array, 'built from the loaded set'); + assert.equal(model.scale, spec.scale, 'drawn life size'); assert.equal(model.id, 'local-adsb:a0b702'); assert.ok( - Cesium.Color.equals( - model.color, - Cesium.Color.fromCssColorString('#ff4fd8'), - ), + Cesium.Color.equals(model.color, Cesium.Color.WHITE), + 'the mesh draws its own colours', ); - assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX); - assert.equal(model.colorBlendAmount, 0.94); + assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.HIGHLIGHT); assert.equal(model.show, true); const heli = sources[0].entities.getById('local-adsb:a0b702'); assert.equal( diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 55fc2d11a..307412a78 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -1,7 +1,9 @@ import * as Cesium from 'cesium'; import { horizonOccluder } from '../../data/iconOrientation.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; -import { civilAircraftModelSpec } from '../flights/modelSpec.js'; +import { aircraftLayerModelSpec } from '../flights/modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; import { FLEET_DR_INTERVAL_MS, MODEL_ALL_ADD_M, @@ -14,24 +16,32 @@ import { MODEL_PROX_ADD_M, MODEL_PROX_KEEP_M, } from '../flights/policy.js'; -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; +import { + AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + aircraftModelBlend, +} from '../../data/aircraftModelLighting.js'; -const specKey = (spec) => `${spec.url}@${spec.scale}`; +const specOf = (marker) => + aircraftLayerModelSpec({ + typeCode: marker?.typeCode, + category: marker?.category, + callsign: marker?.record?.callsign, + }); /** * 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle. * * Mirrors the public Flights fleet: models only below the altitude ceiling, * the same proximity/all caps and add/keep radii, the same visible-first - * eligibility (`selectModelEligible`), the same per-class asset and scale - * (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here - * magenta, as Flights tints military amber). Each model owns its matrix. A + * eligibility (`selectModelEligible`), the same fleet model per type, life + * size (`aircraftLayerModelSpec`) and the same colours: the mesh's own + * (`aircraftModelBlend`). Each model owns its matrix. A * billboard stays the visual until its model is loaded AND placed; a grounded * aircraft's model rides the one-shot ground snap plus the class belly - * offset, and without a resolved ground it stays a billboard. + * offset, and without a resolved ground it stays a billboard. Every fleet + * model loads once, up front, when the layer starts (`loadFleetModelSet`). * @param {object} options * @param {object} options.viewer Cesium viewer. - * @param {Cesium.Color} options.color Model tint. * @param {(url: string) => string} options.resolveAsset * @param {object|null} [options.groundSnap] `createGroundSnap()` instance. * @param {(options: object) => Promise} [options.loadModel] @@ -39,11 +49,13 @@ const specKey = (spec) => `${spec.url}@${spec.scale}`; */ export function createLocalAdsbModels({ viewer, - color, resolveAsset, groundSnap = null, loadModel = (options) => Cesium.Model.fromGltfAsync(options), }) { + void loadFleetModelSet({ resolveAsset }).catch(() => { + /* aircraft stay billboards */ + }); const collection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(collection); /** @type {Map} id -> admitted model */ @@ -75,8 +87,9 @@ export function createLocalAdsbModels({ } async function ensure(id, marker, cap) { - const spec = civilAircraftModelSpec(marker.klass); - const key = specKey(spec); + const spec = specOf(marker); + if (!spec.model) return; + const key = spec.key; const current = models.get(id); if (current && current._gevSpecKey === key) return; if (current) release(id); @@ -85,17 +98,19 @@ export function createLocalAdsbModels({ pending.set(id, loadGeneration); let model; try { - model = await loadModel({ - url: resolveAsset(spec.url), - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color, - colorBlendMode: Cesium.ColorBlendMode.MIX, - colorBlendAmount: spec.blendAmount, - id, - }); + model = await createFleetModel( + spec, + { + asynchronous: false, + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color: Cesium.Color.WHITE, + ...aircraftModelBlend(false), + id, + }, + { resolveAsset, load: loadModel }, + ); } catch { if (pending.get(id) === loadGeneration) pending.delete(id); return; @@ -106,7 +121,7 @@ export function createLocalAdsbModels({ generation !== loadGeneration || !eligible.has(id) || models.has(id) || - specKey(civilAircraftModelSpec(marker.klass)) !== key; + specOf(marker).key !== key; if (pending.get(id) === loadGeneration) pending.delete(id); if (stale) { model.destroy?.(); diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 695286803..e1cf507e3 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { JET_MODEL_URL } from './policy.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; export function createLifecycle({ flightState, @@ -47,29 +47,19 @@ export function createLifecycle({ registerSpriteCollection('military', flightState._billboardCollection); flightState._modelCollection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(flightState._modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { + // Every fleet model loads once, up front, for every layer that draws + // them (fleetModels.js), so no model is fetched while the session runs. + // Captured against this epoch so a destroy/re-init mid-load doesn't flip + // the flag for a torn-down lifecycle. + { const epoch = flightState._modelEpoch; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(JET_MODEL_URL), - asynchronous: false, - }) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; + loadFleetModelSet({ resolveAsset }) + .then(() => { + if (epoch === flightState._modelEpoch) flightState._planeModelLoaded = true; - } else { - try { - m.destroy(); - } catch { - /* gone */ - } - } }) .catch(() => { - /* tracked plane just stays a billboard a beat longer */ + /* aircraft stay billboards */ }); } flightState._billboards = new Map(); @@ -269,14 +259,6 @@ export function createLifecycle({ flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) flightState._modelPending.clear(); flightState._modelGen.clear(); - if (flightState._preloadModel) { - try { - flightState._preloadModel.destroy(); - } catch { - /* gone */ - } - flightState._preloadModel = null; - } flightState._planeModelLoaded = false; flightState._billboards.clear(); flightState._cullPositions.clear(); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 6828b788e..bcff2e34a 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -301,7 +301,7 @@ export function createMotion({ const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); if (parts.rendering._modelOwnsVisual(icao24)) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(icao24)?.klass, + flightState.records.data.get(icao24), ); const scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, @@ -459,7 +459,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); return modelVisualAnchor( flightState._trackedModel.modelMatrix, @@ -502,7 +502,7 @@ export function createMotion({ ) return 0; const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const scale = Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -519,7 +519,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); // Through the model's OWN render chain (modelVisualAnchor's hand-rolled // half-correction put this offset on the lateral axis — see diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index cafbaada2..ac23188ff 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -58,20 +58,18 @@ export const EMERGENCY_ICON_COLOR = export const GROUND_SCALE = 0.8; // --- 3D model rendering (mirrors flights.js) ----------------------------------------- -// When enabled, military aircraft render as 3D glTF jet models once the camera is below +// When enabled, military aircraft render as 3D glTF models once the camera is below // MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is // FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity') // or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion -// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer -// apart from the commercial flights layer. - -export const JET_MODEL_URL = '/models/jet.glb'; +// can't tank the frame (no instancing yet). Each aircraft draws the fleet model of its +// type, as the flights layer does; the amber tint sets this layer apart. export const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) export const MODEL_MIN_PX = 24; -// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) +// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js) export const TRACKED_MODEL_MIN_PX = 40; // keep the glTF silhouette comparable to the selected 2D glyph at handoff @@ -79,17 +77,6 @@ export const TRACKED_MODEL_MIN_PX = 40; export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -export const MODEL_NATIVE_RADIUS_M = 29.83; - -// jet.glb is transform-applied at real-world scale — native bounding radius -// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching -// flights.js world sizes. At the old copied ×24, models rendered -// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize -// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by -// modelScale.test.mjs. - -export const MODEL_SCALE = 1; - // Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity // and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js). @@ -119,32 +106,7 @@ export const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km export const MODEL_HEADING_OFFSET_DEG = 180; -// every aircraft GLB is exported nose -X in the shared transform-applied convention -// Preserve the layer's amber identity while reducing approved texture/livery -// contribution to a weak diffuse hint, matching civilian launch presentation. - -export const MODEL_COLOR_BLEND_AMOUNT = 0.94; - -// airplane.glb (the shared 747) constants for this layer's heavy classes. The -// asset has its former 24× runtime calibration baked into transform-applied -// meter-scale geometry; these values mirror flights.js and are regression-pinned. - -export const PLANE_MODEL_URL = '/models/airplane.glb'; - -export const PLANE_MODEL_SCALE = 1; - -export const PLANE_NATIVE_RADIUS_M = 34.41; - -export const PLANE_BELLY_OFFSET_NATIVE = 6.719; - -// Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this -// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node -// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03). -// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model -// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to -// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs. - -export const MODEL_BELLY_OFFSET_NATIVE = 5.631; +// every fleet model is nose -X: opposite Cesium heading 0 /** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 4af14b3ba..edd3930c2 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -484,9 +484,7 @@ export function createQueries({ // contacts read the translation the fleet tick already wrote into their // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket // are co-located anyway. - const spec = modelOwnsVisual - ? parts.rendering._modelSpec(info?.klass) - : null; + const spec = modelOwnsVisual ? parts.rendering._modelSpec(info) : null; const pos = isTracked ? parts.motion._trackedVisualCached() || bb.position : modelOwnsVisual diff --git a/src/layers/military/records.js b/src/layers/military/records.js index b18e946b8..72ffd2677 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -181,6 +181,8 @@ export class MilitaryFlightRecords { newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs), callsign: stickyText(callsign, prevMeta?.callsign), type: stickyType, + // The emitter category, kept for the fleet model a type-less row draws. + category: aircraft?.category ?? prevMeta?.category ?? null, // Type outranks category automatically inside classifyAircraft. klass: classifyAircraft({ typeCode: stickyType, diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index d783bd4a4..7e19b4567 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -1,12 +1,9 @@ +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { - CLASS_MODEL_REAL, - CLASS_SCALE_3D, - CLASS_SCALE_2D, -} from '../../data/aircraftClass.js'; -import { - visualCenterForModel, - trailAnchorForModel, -} from '../../data/modelVisualAnchor.js'; + aircraftLayerModelSpec, + aircraftModelSpecCacheKey, +} from '../flights/modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; import * as Cesium from 'cesium'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; @@ -20,14 +17,6 @@ import { import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; import { - PLANE_MODEL_SCALE, - PLANE_MODEL_URL, - PLANE_NATIVE_RADIUS_M, - PLANE_BELLY_OFFSET_NATIVE, - MODEL_SCALE, - JET_MODEL_URL, - MODEL_NATIVE_RADIUS_M, - MODEL_BELLY_OFFSET_NATIVE, MODEL_HEADING_OFFSET_DEG, BILLBOARD_SCALE, GROUND_SCALE, @@ -45,7 +34,6 @@ import { MODEL_PROX_KEEP_M, IR_RELOAD_BATCH, MODEL_MIN_PX, - MODEL_COLOR_BLEND_AMOUNT, TRACKED_MODEL_MIN_PX, TRACKED_MODEL_MAX_PX, FLEET_DR_INTERVAL_MS, @@ -54,7 +42,10 @@ import { COURSE_MAX_DPS, STALE_ALPHA, } from './policy.js'; -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; +import { + AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + aircraftModelBlend, +} from '../../data/aircraftModelLighting.js'; export function createRendering({ flightState, @@ -83,48 +74,19 @@ export function createRendering({ return Number.POSITIVE_INFINITY; } - function _modelSpec(klass) { - let spec = flightState._specCache.get(klass); + /** The fleet model an aircraft draws with, by its type and emitter category + * (shared with flights.js), in this layer's own tint. */ + function _modelSpec(meta) { + const key = aircraftModelSpecCacheKey(meta); + let spec = flightState._specCache.get(key); if (spec) return spec; - const real = CLASS_MODEL_REAL[klass]; - if (real) { - spec = { - url: real.url, - scale: 1, - nativeRadiusM: real.radiusM, - bellyM: real.bellyM, - headingOffsetDeg: 180, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; - } else if ( - klass === 'airliner' || - klass === 'quadjet' || - klass === 'glider' - ) { - const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { - url: PLANE_MODEL_URL, - scale, - nativeRadiusM: PLANE_NATIVE_RADIUS_M, - bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, - headingOffsetDeg: 180, - visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), - trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL), - }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { - url: JET_MODEL_URL, - scale, - nativeRadiusM: MODEL_NATIVE_RADIUS_M, - bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, - visualCenterNative: visualCenterForModel(JET_MODEL_URL), - trailAnchorNative: trailAnchorForModel(JET_MODEL_URL), - }; - } - flightState._specCache.set(klass, spec); + const base = aircraftLayerModelSpec(meta); + spec = Object.freeze({ + ...base, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + }); + // Not cached before the model set loads: the answer changes once it has. + if (spec.model) flightState._specCache.set(key, spec); return spec; } @@ -191,11 +153,15 @@ export function createRendering({ const _iconKind = (icao24, klass) => tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); - /** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ + /** Model colour: the mesh's own (white), tinted amber while tracked and red + * while broadcasting an emergency. */ function _modelColor(icao24) { if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; - return _fleetBillboardColor(icao24); + const info = flightState.records.data.get(icao24); + return isEmergency(info?.emergency) + ? EMERGENCY_ICON_COLOR + : Cesium.Color.WHITE; } /** Fleet (untracked) tint: red while the latest poll broadcast an emergency, @@ -312,7 +278,7 @@ export function createRendering({ Cesium.Ellipsoid.WGS84, flightState._scratchGroundCarto, ); - carto.height = h + _modelSpec(meta.klass).bellyM; + carto.height = h + _modelSpec(meta).bellyM; return Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, @@ -350,7 +316,7 @@ export function createRendering({ displayPos, course, model.modelMatrix, - _modelSpec(flightState.records.data.get(icao24)?.klass).headingOffsetDeg, + _modelSpec(flightState.records.data.get(icao24)).headingOffsetDeg, ); if (!model.ready) { model.show = false; // not loaded yet → keep the 2D icon, no half-model flash @@ -385,20 +351,17 @@ export function createRendering({ return !!model && model.ready === true && model.show === true; } - /** Spec identity for a LOADED model — mirror of flights.js: URL and scale - * together (same-URL classes differ by scale). */ + /** Spec identity for a LOADED model — mirror of flights.js: the model and + * its scale together. */ - const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; - }; + const _specKeyFor = (meta) => _modelSpec(meta).key; - /** Class-change model sync — mirror of flights.js: drop a live model or - * in-flight load whose spec no longer matches the class, re-showing the + /** Type-change model sync — mirror of flights.js: drop a live model or + * in-flight load whose spec no longer matches the type, re-showing the * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const key = _specKeyFor(flightState.records.data.get(icao24)); const current = flightState._models.get(icao24); if ( (current && current._gevSpecKey !== key) || @@ -445,6 +408,11 @@ export function createRendering({ /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + /** A Cesium Model of `spec` from the fleet set loaded up front (no fetch mid-session). */ + function _createModel(spec, options) { + return createFleetModel(spec, options, { resolveAsset }); + } + async function _ensureModel(icao24) { // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet // tick skips it, so a model here would be orphaned + double-rendered. @@ -461,6 +429,8 @@ export function createRendering({ _modelCap() ) return; + // Until the fleet model set has loaded the aircraft stays a billboard. + if (!_modelSpec(flightState.records.data.get(icao24)).model) return; const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale flightState._modelPending.add(icao24); @@ -468,20 +438,17 @@ export function createRendering({ // Spec identity captured at load START (mirror of flights.js): a mid-load // reclassification makes the post-await admission reject the stale asset. // Boost state likewise — creation options bake it in. - const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const specKey = _specKeyFor(flightState.records.data.get(icao24)); const loadIrBoost = flightState._irBoost; try { - const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), + const spec = _modelSpec(flightState.records.data.get(icao24)); + model = await _createModel(spec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + ...aircraftModelBlend(flightState._irBoost), customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, @@ -522,7 +489,7 @@ export function createRendering({ flightState._models.has(icao24) || flightState._models.size >= _modelCap() || // Class reclassified mid-load → this GLB/scale is for the OLD class. - _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || + _specKeyFor(flightState.records.data.get(icao24)) !== specKey || // IR boost flipped mid-load → this model baked the wrong shader/tint. flightState._irBoost !== loadIrBoost; if (stale) { @@ -676,6 +643,9 @@ export function createRendering({ return; } if ( + // Until the fleet model set has loaded the billboard carries it. + _modelSpec(flightState.records.data.get(flightState._trackedIcao)) + .model && !flightState._trackedModel && !flightState._trackedModelLoading && parts.tracking._trackedModelLoadAllowed() @@ -683,22 +653,19 @@ export function createRendering({ flightState._trackedModelLoading = true; const gen = flightState._trackedModelGen; const trackedSpec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedKey = _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), + _createModel(trackedSpec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: TRACKED_MODEL_MIN_PX, scale: trackedSpec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + ...aircraftModelBlend(flightState._irBoost), customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, @@ -724,7 +691,7 @@ export function createRendering({ // _trackedModel was still null): drop the stale asset; driver reloads. if ( _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { @@ -793,7 +760,7 @@ export function createRendering({ return; } const spec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); _modelMatrix( displayPos, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 94e1921a5..0f04d7172 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -164,10 +164,15 @@ export function createMilitarySnapshotRenderer({ ) { rendering._applyFleetBillboardPresentation(icao24, bb); } - // Per-class GLBs: a class change can mean a different asset OR scale — - // resync the live model, any in-flight load, and the tracked - // standalone (mirror of flights.js). - if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); + // Per-type fleet models: a type or category change can mean a + // different model OR scale — resync the live model, any in-flight + // load, and the tracked standalone (mirror of flights.js). + if ( + prevMeta?.klass !== meta.klass || + prevMeta?.type !== meta.type || + prevMeta?.category !== meta.category + ) + rendering._syncModelToClass(icao24); // Round 5: depth policy is uniform (always depth-test-free, see // _groundDepthDistance) — nothing to flip on landing/takeoff. } else { diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 4d9febafd..9bdf2684c 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -19,15 +19,13 @@ export function createFlightState({ source, services }) { * layer); see followsClock and displayDelayMs in policy.js. */ flightState.timeline = null; - /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: - * military contacts should read as their WEIGHT CLASS, always in this layer's - * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → - * 180° offset); airliner/quadjet/glider get the shared 747 silhouette - * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); - * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays - * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, - * and the tint must dominate any livery (owner: "stays yellow"). Specs are - * static per class — memoized (the fleet pass asks at 12 Hz per model). */ + /** Model spec per aircraft type and emitter category (aircraftLayerModelSpec in + * flights/modelSpec.js): the procedural fleet model that draws the type, life size, + * with its measured clearance, bounding radius and trail anchor. nativeRadiusM is PER + * SCALE UNIT — pixel-cap math multiplies it by `scale`. The code-side MIX tint dominates + * every model so silhouettes stay light (and in this layer's own colour). Memoized: the + * detection weld asks for one per MODELED contact per frame on top of the 12 Hz fleet + * pass. */ flightState._specCache = new Map(); @@ -76,16 +74,11 @@ export function createFlightState({ source, services }) { // throttle the "more planes in view than the cap" console notice // The tracked entity's billboard goes transparent (rather than hidden) once the model takes // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its - // alpha after the GLB is preloaded so the model is ready to render the instant the billboard - // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not - // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + // alpha after the fleet model set is loaded so the model is ready to render the instant the + // billboard fades — no gap, no double-image. The set loads once at init (fleetModels.js). flightState._planeModelLoaded = false; - /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ - - flightState._preloadModel = null; - flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); flightState._scratchModelMtx = new Cesium.Matrix4(); diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js new file mode 100644 index 000000000..80cfc1daa --- /dev/null +++ b/src/layers/rail/cockpitSubject.js @@ -0,0 +1,340 @@ +import { pointAtS } from '../../data/railPaths.js'; +import { getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { displayMotion } from '../transit/movement.js'; + +/** Each line's read-only copy for a snapshot's `path.line` (lineOf). */ +const lines = new WeakMap(); + +/** + * A line as a snapshot hands it out: a frozen copy of the layer's path, its + * number arrays frozen arrays, built once per line. `pointAtS` and + * `projectOnPath` read it as they read the path, and nothing written to it + * reaches the layer. + */ +function lineOf(path) { + let line = lines.get(path); + if (!line) { + const copy = {}; + for (const [name, value] of Object.entries(path)) + copy[name] = ArrayBuffer.isView(value) + ? Object.freeze(Array.from(value)) + : value; + line = Object.freeze(copy); + lines.set(path, line); + } + return line; +} + +const NO_STOPS = Object.freeze([]); +/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */ +const stopLists = new WeakMap(); + +/** Whether `copy` still holds each of `list`'s stops, field by field. */ +function sameStops(copy, list) { + if (copy.length !== list.length) return false; + for (let i = 0; i < list.length; i++) { + const stop = list[i]; + const kept = copy[i]; + if ( + !stop || + typeof stop !== 'object' || + !kept || + typeof kept !== 'object' + ) { + if (!Object.is(stop, kept)) return false; + continue; + } + const names = Object.keys(stop); + if (names.length !== Object.keys(kept).length) return false; + for (const name of names) + if (!Object.is(stop[name], kept[name])) return false; + } + return true; +} + +/** + * A trip's stops as a snapshot hands them out: a frozen array of frozen + * copies, kept per list the stops provider returns while that list still + * holds the same stops, so a provider that updates its list in place is + * copied again. + */ +function stopsOf(list) { + if (!Array.isArray(list)) return NO_STOPS; + let stops = stopLists.get(list); + if (!stops || !sameStops(stops, list)) { + stops = Object.freeze( + list.map((stop) => + stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : stop, + ), + ); + stopLists.set(list, stops); + } + return stops; +} + +/** + * `previous` when it holds each of `next`'s values, else a frozen copy of + * `next`: a part of a snapshot that did not change is the one read before. + * `next` is a draft, filled again on every read. + */ +function reuse(previous, next) { + if (!previous) return Object.freeze({ ...next }); + for (const name in next) + if (!Object.is(previous[name], next[name])) + return Object.freeze({ ...next }); + return previous; +} + +/** + * Read access to one train, and a hold on it for a first-person view. + * + * `getSelectedTrain()` and `getCockpitSubject()` describe the selected train + * and the held one as a frozen snapshot, read again on each call: where it is + * along its line, how fast it moves and how that is known, and its stops. A + * call while nothing in it changed returns the snapshot it returned before, + * and a part that did not change (its feed, its line, its stops) is the same + * object in the next one, so a view can read it on every frame. `path.line` + * is a frozen copy of the train's line for `pointAtS` and `projectOnPath`. + * `setCockpitSubject(key)` holds a train: its marker, 3D model, card and + * selected line are hidden, it stays selected (so its stops stay fresh and its feed stays + * polled at any distance) and clicks and Escape no longer change the + * selection. A held train keeps moving, hidden, and keeps the selection when + * its category is turned off, and is the copy of its train drawn though + * another feed's copy would outrank it (dedupe.js). Released, it is a train + * like any other: one in a category turned off is deselected and stops, and + * a better copy takes over at the next poll. The hold ends with + * `setCockpitSubject(null)`, and on its own when the train is removed or the + * layer is turned off. A train removed while held leaves its feed polled + * until the next `setCockpitSubject` call, so a view that waits for it holds + * it again if the feed lists it again. + * `focusTrain(key)` selects a train and hands it the camera, as a click does. + * A snapshot's `selectedAtMs` is when the user last selected the selected + * train (a click, or `focusTrain`), even again, for a view's entry to tell a + * re-selection. It is null for any other train, after a deselection, and for + * a train selected only by a hold: a hold is not a user's selection. + * + * @param {object} context The layer's state, parts, services and timetable. + * @returns {{methods: object}} + */ +export function createRailCockpitSubject({ + state, + parts, + services, + timetable, +}) { + const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M) + ? services.ground.GROUND_FLOOR_LIFT_M + : 0; + const finiteOrNull = (value) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + const scratch = {}; + /** Each train's last snapshot, returned again while nothing in it changed. */ + const views = new WeakMap(); + /** The drafts trainView fills on every read (reuse). */ + const draft = { + view: {}, + feed: {}, + position: {}, + path: {}, + motion: {}, + provenance: {}, + }; + + /** The catalog's public entry for a feed, else the registry's. */ + function feedOf(feedId, previous) { + const listed = state._catalog?.find((feed) => feed.id === feedId); + const feed = listed ?? getRegisteredRailFeed(feedId); + if (!feed) return null; + const next = draft.feed; + next.id = feed.id; + next.name = feed.name ?? null; + next.operator = feed.operator ?? null; + next.region = feed.region ?? null; + next.positions = listed?.positions ?? feed.positions ?? 'gps'; + next.tripUpdates = + typeof listed?.tripUpdates === 'boolean' + ? listed.tripUpdates + : typeof feed.tripUpdatesUrl === 'string'; + next.attribution = feed.attribution ?? null; + next.license = feed.license ?? null; + next.licenseUrl = feed.licenseUrl ?? null; + return reuse(previous, next); + } + + /** The route's short (else long) name in the feed's timetable, or null. */ + function routeNameOf(entry) { + const routeId = entry.record?.routeId; + if (!routeId) return null; + const schedule = timetable?.scheduleFor?.(entry.feedId); + const route = schedule?.routes?.[routeId]; + const name = route?.short || route?.long; + return typeof name === 'string' && name ? name : null; + } + + /** + * Which way the train runs along its shape: as its binding learnt it from + * successive reports, else from its drawn course against the line's + * bearing there; 0 while neither tells. + */ + function directionOf(entry) { + const binding = entry.path; + if (binding.direction === 1 || binding.direction === -1) + return binding.direction; + const course = finiteOrNull(entry.courseDeg); + const point = pointAtS(binding.path, binding.drawS, scratch); + if (course === null || !Number.isFinite(point?.bearingDeg)) return 0; + const turn = Math.abs(((course - point.bearingDeg + 540) % 360) - 180); + return turn > 90 ? -1 : 1; + } + + /** The train as a frozen snapshot (see the module comment), or null. */ + function trainView(entry) { + if (!entry?.record) return null; + const previous = views.get(entry); + const record = entry.record; + const sample = entry.sample ?? null; + const drawn = entry.drawnHeight ?? null; + const binding = entry.path ?? null; + const reported = finiteOrNull(record.speedMps); + const derived = finiteOrNull(sample?.segmentSpeedMps); + const trip = state._selectedKey === entry.key ? state._trip : null; + const status = state._feedStatus.get(entry.feedId); + + const position = draft.position; + position.latitude = + finiteOrNull(drawn?.lat) ?? finiteOrNull(sample?.lat) ?? record.lat; + position.longitude = + finiteOrNull(drawn?.lon) ?? finiteOrNull(sample?.lon) ?? record.lon; + position.railHeightM = Number.isFinite(drawn?.h) ? drawn.h - liftM : null; + position.heightQuality = Number.isFinite(drawn?.q) ? drawn.q : 0; + position.atMs = finiteOrNull(drawn?.at); + + let path = null; + if (binding && binding.path) { + path = draft.path; + path.shapeIndex = binding.shapeIndex; + path.s = binding.drawS; + path.direction = directionOf(entry); + path.lengthM = binding.path.length; + path.via = binding.via; + path.builtAt = state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null; + path.line = lineOf(binding.path); + } + + const motion = draft.motion; + motion.reportedSpeedMps = reported; + motion.derivedSpeedMps = derived; + motion.speedSource = + reported !== null ? 'reported' : derived !== null ? 'derived' : null; + motion.courseDeg = finiteOrNull(entry.courseDeg); + motion.phase = sample?.phase ?? null; + motion.word = displayMotion(entry, Date.now()).word; + motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs); + motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs); + motion.feedStale = status?.stale === true; + + const provenance = draft.provenance; + provenance.positionSource = record.positionSource ?? 'gps'; + provenance.timestampSource = record.timestampSource ?? null; + + const view = draft.view; + view.key = entry.key; + view.feedId = entry.feedId; + view.selectedAtMs = + state._selectedKey === entry.key ? state._selectedAtMs : null; + view.id = String(record.id ?? ''); + view.tripId = record.tripId || null; + view.routeId = record.routeId || null; + view.routeName = routeNameOf(entry); + view.label = record.label || null; + view.trainNumber = record.trainNumber || null; + view.headsign = trip?.headsign || null; + view.category = entry.category ?? null; + view.mode = entry.railMode ?? null; + view.feed = feedOf(entry.feedId, previous?.feed); + view.position = reuse(previous?.position, position); + view.path = path && reuse(previous?.path, path); + view.motion = reuse(previous?.motion, motion); + view.provenance = reuse(previous?.provenance, provenance); + view.stops = stopsOf(trip?.stops); + view.match = trip?.match ?? null; + const snapshot = reuse(previous, view); + views.set(entry, snapshot); + return snapshot; + } + + /** A train's marker and playback again, after a hold on it began or ended. */ + function replay(entry) { + parts.rendering.paint(entry); + parts.rendering.schedulePlayback(entry); + parts.rendering.syncRenderHold(); + } + + const methods = { + /** The selected train (see createRailCockpitSubject), or null. */ + getSelectedTrain() { + return trainView(state._trains.get(state._selectedKey)); + }, + /** The held train, or null when none is held. */ + getCockpitSubject() { + return trainView(state._trains.get(state._cockpitSubjectKey)); + }, + /** + * Hold the train `key` for a first-person view, or release the hold with + * null. A train it holds is selected, if it was not already. + * @param {string|null} key + * @returns {boolean} False for a key the layer does not list. A listed + * train is held even when its category is off or another feed's copy + * is drawn: the held copy is then the one drawn. Releasing returns true. + */ + setCockpitSubject(key) { + const previous = state._trains.get(state._cockpitSubjectKey); + if (key === null || key === undefined) { + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = null; + if (previous) { + // Its category turned off meanwhile: deselected, as the toggle + // does to any other train (controls.js). + if ( + previous.key === state._selectedKey && + state._categories[previous.category] === false + ) + parts.selection.clearSelection(); + replay(previous); + } + parts.selection.refreshSelectedCard(true); + parts.routes.applyHold?.(); + return true; + } + const entry = state._trains.get(key); + if (!entry) return false; + if (state._selectedKey !== key) { + parts.selection.selectTrain(key); + // A hold is not a selection the user made (selectedAtMs). + state._selectedAtMs = null; + } + state._cockpitSubjectKey = key; + state._cockpitLostFeedId = null; + // Another feed's copy drew it: the held copy is drawn now (dedupe.js). + if (entry.duplicate === true) parts.ingestion.duplicates?.refresh(); + if (previous && previous !== entry) replay(previous); + replay(entry); + parts.selection.refreshSelectedCard(true); + parts.routes.applyHold?.(); + return true; + }, + /** + * Select the train `key` and hand it the camera, as a click on it does. + * @param {string} key + * @returns {boolean} False for a train the layer does not show: gone, in + * a category turned off, or a copy another feed draws (dedupe.js). + */ + focusTrain(key) { + const entry = state._trains.get(key); + if (!entry || !parts.rendering.categoryShown(entry)) return false; + parts.selection.selectAndFocusTrain(key); + return true; + }, + }; + return { methods, trainView }; +} diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs new file mode 100644 index 000000000..4700e71e6 --- /dev/null +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -0,0 +1,504 @@ +// The Rail layer's read API for a first-person view: the selected or held +// train as a snapshot (where it is along its line and which way it runs, its +// speed and how that is known, its feed's credit, its stops), and a hold that +// hides its marker and card, keeps it selected and ends when it goes. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_POLL_MS, + STOP_REFRESH_MS, + railTrainFollowable, + railTrainKey, +} from './policy.js'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; +import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js'; +import { publicRailCatalog } from '../../data/railFeeds.js'; +import { pointAtS } from '../../data/railPaths.js'; +import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs'; + +const M_PER_DEG = 111_320; +const LAT = 42.36; +const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180); +const LON0 = -71.08; + +/** A flat [lat, lon, ...] line due east from LON0, one vertex per 100 m. */ +function eastLine(lengthM) { + const out = []; + for (let d = 0; d <= lengthM; d += 100) out.push(LAT, LON0 + d / KX); + return out; +} + +const PACK = Object.freeze({ + version: 1, + feedId: 'mbta-rail', + builtAt: '2026-09-09T00:00:00.000Z', + shapes: [eastLine(6000)], + dist: [[]], + shapeSources: ['operator'], + trips: { 'trip-T1': 0, 'trip-T2': 0 }, + routes: { R1: [0] }, +}); + +/** Two trains on the line, T1 running east and T2 west, a step a poll. */ +async function onTheLine(t, options = {}) { + const app = harness(t, { + paths: { 'mbta-rail': PACK }, + at: BOSTON, + ...options, + }); + let step = 0; + app.feeds.set('mbta-rail', () => [ + train('T1', LAT, LON0 + (1000 + step * 150) / KX, { speedMps: 15 }), + train('T2', LAT, LON0 + (5000 - step * 150) / KX), + ]); + await app.enable(); + for (let i = 0; i < 4; i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + return app; +} + +const T1 = railTrainKey('mbta-rail', 'T1'); +const T2 = railTrainKey('mbta-rail', 'T2'); + +test('the selected train reads as a snapshot of its place, motion and sources', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.getSelectedTrain(), null, 'nothing selected'); + app.parts().selection.selectTrain(T1); + const view = app.layer.getSelectedTrain(); + assert.ok(Object.isFrozen(view)); + assert.equal(view.key, T1); + assert.equal(view.feedId, 'mbta-rail'); + assert.equal(view.id, 'T1'); + assert.equal(view.tripId, 'trip-T1'); + assert.equal(view.routeId, 'R1'); + assert.equal(view.category, 'regional'); + assert.equal(view.mode, 'rail'); + const listed = publicRailCatalog().find((feed) => feed.id === 'mbta-rail'); + assert.equal(view.feed.attribution, listed.attribution); + assert.equal(view.feed.license, listed.license); + assert.equal(view.feed.licenseUrl, listed.licenseUrl); + assert.equal(view.feed.positions, 'gps'); + assert.equal(view.path.shapeIndex, 0); + assert.equal(view.path.builtAt, PACK.builtAt); + assert.equal(view.path.via, 'trip'); + assert.ok(Math.abs(view.path.lengthM - 6000) < 1); + assert.ok(view.path.s > 1000 && view.path.s < 2000, `s ${view.path.s}`); + assert.equal(view.path.direction, 1, 'runs along the shape'); + assert.equal(typeof view.path.line.length, 'number'); + assert.equal(view.motion.reportedSpeedMps, 15); + assert.equal(view.motion.speedSource, 'reported'); + assert.equal(view.provenance.positionSource, 'gps'); + assert.equal(view.provenance.timestampSource, 'vehicle'); + const drawn = app.entry(T1).drawnHeight; + assert.equal(view.position.railHeightM, drawn.h - 1.5); + assert.ok(Number.isFinite(view.position.latitude)); + assert.ok(Array.isArray(view.stops)); + + app.parts().selection.selectTrain(T2); + const west = app.layer.getSelectedTrain(); + assert.equal(west.path.direction, -1, 'runs against the shape'); + assert.equal(west.motion.reportedSpeedMps, null); + assert.equal( + west.motion.speedSource, + west.motion.derivedSpeedMps === null ? null : 'derived', + ); +}); + +/** A trip's stops as a stops provider lists them. */ +const stopList = (...names) => + names.map((name, i) => ({ + stopId: name, + name, + lat: LAT, + lon: LON0 + (i * 2000) / KX, + state: 'upcoming', + })); + +test('a snapshot read again while nothing changed is the one read before', async (t) => { + let list = stopList('Alpha', 'Bravo', 'Charlie'); + const app = await onTheLine(t, { + stops: { stopsFor: () => ({ stops: list, headsign: 'Charlie' }) }, + }); + app.parts().selection.selectTrain(T1); + const first = app.layer.getSelectedTrain(); + assert.equal(app.layer.getSelectedTrain(), first, 'the same snapshot'); + // Its stops are frozen copies of the provider's. + assert.equal(first.stops.length, 3); + assert.ok(Object.isFrozen(first.stops)); + assert.ok(Object.isFrozen(first.stops[0])); + assert.notEqual(first.stops[0], list[0]); + assert.equal(first.stops[0].name, 'Alpha'); + + // It moves on: a new snapshot at its new place, what did not change kept. + app.run(RAIL_POLL_MS); + await app.poll(); + app.run(RAIL_POLL_MS); + const moved = app.layer.getSelectedTrain(); + assert.notEqual(moved, first, 'a new snapshot'); + assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.path)); + assert.ok(moved.path.s > first.path.s, 'at its new place'); + assert.equal(moved.stops, first.stops, 'the same stops'); + assert.equal(moved.feed, first.feed); + assert.equal(moved.provenance, first.provenance); + assert.equal(moved.path.line, first.path.line); + assert.equal(app.layer.getSelectedTrain(), moved); + + // The provider lists other stops: new ones. + list = stopList('Delta', 'Echo'); + app.run(STOP_REFRESH_MS); + const later = app.layer.getSelectedTrain(); + assert.notEqual(later.stops, first.stops); + assert.deepEqual( + later.stops.map((stop) => stop.name), + ['Delta', 'Echo'], + ); + // A provider that updates its list in place: the stops are copied again. + list[0].state = 'passed'; + app.run(STOP_REFRESH_MS); + const updated = app.layer.getSelectedTrain(); + assert.notEqual(updated.stops, later.stops); + assert.equal(updated.stops[0].state, 'passed'); + assert.equal(app.layer.getSelectedTrain().stops, updated.stops); +}); + +test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => { + const app = await onTheLine(t); + app.parts().selection.selectTrain(T1); + const { line, s } = app.layer.getSelectedTrain().path; + const own = app.entry(T1).path.path; + assert.notEqual(line, own, 'not the layer’s own'); + assert.ok(Object.isFrozen(line)); + for (const name of ['lat', 'lon', 'x', 'y', 's', 'box']) + assert.ok(Object.isFrozen(line[name]), name); + const lat = own.lat[1]; + assert.throws(() => { + line.lat[1] = 0; + }, TypeError); + assert.equal(own.lat[1], lat, 'the layer’s line is as it was'); + assert.deepEqual(pointAtS(line, s), pointAtS(own, s), 'it reads the same'); + // Copied once per line: T2 runs on the same one. + app.parts().selection.selectTrain(T2); + assert.equal(app.layer.getSelectedTrain().path.line, line); +}); + +test('a held train hides its marker and card and keeps the selection', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.setCockpitSubject('rail:mbta-rail:nope'), false); + assert.equal(app.layer.getCockpitSubject(), null); + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.equal(app.state()._selectedKey, T1, 'holding selects it'); + assert.equal(app.layer.getCockpitSubject().key, T1); + app.run(1000); + assert.equal(app.entry(T1).marker.show, false); + assert.equal(app.entry(T2).marker.show, true); + // From inside the train its selected line would run under the eye. + assert.equal(app.state()._route.primitive.show, false, 'no line'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // Escape is the view's while it holds the train. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, T1); + // It still moves. + const before = app.layer.getCockpitSubject().path.s; + app.run(RAIL_POLL_MS); + await app.poll(); + app.run(RAIL_POLL_MS); + assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving'); + + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.getCockpitSubject(), null); + app.run(1000); + assert.equal(app.entry(T1).marker.show, true); + assert.equal(app.state()._route.primitive.show, true, 'the line is back'); + assert.equal( + (app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length, + 1, + 'the card is back', + ); +}); + +test('a held train draws no 3D model either; let go, its model draws again', async (t) => { + const models = await fleetModelFakes(t); + const app = await onTheLine(t, { fleet: models.fleet, altitude: 1_000 }); + // A model's load answers off the frame; the next frames place it. + const frames = async () => { + app.run(500); + await app.settle(); + app.run(500); + }; + await frames(); + assert.equal(models.drawn(T1).length, 1, 'near the camera: its model'); + assert.equal(app.entry(T1).modelOwnsVisual, true); + assert.equal(app.entry(T1).marker.show, false, 'its icon hands over'); + + assert.equal(app.layer.setCockpitSubject(T1), true); + app.run(250); + // The view's eye is inside the train. + assert.equal(models.drawn(T1).length, 0, 'no model'); + assert.equal(app.entry(T1).modelOwnsVisual, false); + assert.equal(app.entry(T1).marker.show, false, 'and no icon'); + assert.equal(models.drawn(T2).length, 1, 'another train keeps its model'); + await frames(); + assert.equal(models.drawn(T1).length, 0, 'not on a later frame either'); + assert.equal(app.entry(T1).marker.show, false); + + assert.equal(app.layer.setCockpitSubject(null), true); + await frames(); + assert.equal(models.drawn(T1).length, 1, 'let go: its model again'); + assert.equal(app.entry(T1).modelOwnsVisual, true); + assert.equal(app.entry(T1).marker.show, false); +}); + +const MADRID = { lat: 40.4168, lon: -3.7038 }; + +test('a held train stays the copy drawn when another feed’s copy outranks it, and keeps moving', async (t) => { + const app = harness(t, { at: MADRID }); + // One Alvia in two feeds: a station-event copy, held, and a poll later a + // GPS copy that would outrank it. + let step = 0; + let gpsCopy = false; + const lat = (offset) => MADRID.lat + offset + step * 0.003; + app.feeds.set('renfe-cercanias', () => [ + train('A1', lat(0), MADRID.lon, { + label: 'ALVIA 4073', + positionSource: 'event', + }), + ]); + app.feeds.set('renfe-larga-distancia', () => + gpsCopy ? [train('B1', lat(0.01), MADRID.lon, { label: '4073' })] : [], + ); + await app.enable(); + const a = railTrainKey('renfe-cercanias', 'A1'); + const b = railTrainKey('renfe-larga-distancia', 'B1'); + for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(railTrainFollowable(app.entry(a)), 'the train is moving'); + assert.equal(app.layer.setCockpitSubject(a), true); + + gpsCopy = true; + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + // The view knows the train by its key, and the copies stand apart: the + // held copy stays drawn and selected, the other is the one hidden. + assert.equal(app.layer.getCockpitSubject()?.key, a); + assert.equal(app.state()._selectedKey, a); + assert.equal(app.entry(a).duplicate, false); + assert.equal(app.entry(b).duplicate, true); + assert.equal(app.entry(a).marker.show, false); + assert.equal(app.entry(b).marker.show, false, 'no other marker on it'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // It moves between polls, frame by frame. + assert.ok(app.state()._moving.has(app.entry(a)), 'still playing'); + const before = app.layer.getCockpitSubject().position.latitude; + app.run(2000); + assert.ok( + app.layer.getCockpitSubject().position.latitude > before, + 'still moving', + ); + + // Let go: the better copy takes over at the next poll, with the selection. + app.layer.setCockpitSubject(null); + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.entry(a).duplicate, true); + assert.equal(app.entry(b).duplicate, false); + assert.equal(app.state()._selectedKey, b); + assert.equal(app.entry(b).marker.show, true); + assert.equal( + (app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length, + 1, + 'its card', + ); + // The copy hidden is not focused: the selection stays on the one drawn. + assert.equal(app.layer.focusTrain(a), false); + assert.equal(app.state()._selectedKey, b); + + // A hold of the copy hidden draws it at once, in place of the other. + assert.equal(app.layer.setCockpitSubject(a), true); + assert.equal(app.state()._selectedKey, a); + assert.equal(app.entry(a).duplicate, false); + assert.equal(app.entry(b).duplicate, true); + assert.equal(app.entry(b).marker.show, false); + assert.ok(app.state()._moving.has(app.entry(a)), 'playing'); +}); + +test('a held train keeps the selection and moves, hidden, when its category is turned off', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.run(1000); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing'); + app.layer.setParams({ regional: false }); + assert.equal(app.state()._selectedKey, T1, 'still selected'); + assert.equal(app.layer.getCockpitSubject()?.key, T1); + assert.equal(app.entry(T1).marker.show, false); + assert.equal(app.entry(T2).marker.show, false, 'its category is hidden'); + assert.equal(app.state()._route.primitive.show, false, 'no line'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // It moves between polls, frame by frame. + assert.ok(app.state()._moving.has(app.entry(T1)), 'still playing'); + const before = app.layer.getCockpitSubject().path.s; + app.run(2000); + assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving'); + assert.equal(app.entry(T1).marker.show, false, 'and still hidden'); + + // Let go: a train of a hidden category, deselected and stopped as the + // toggle would have left it. + app.layer.setCockpitSubject(null); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._moving.has(app.entry(T1)), false); + assert.equal(app.entry(T1).marker.show, false); + const parked = app.entry(T1).path.drawS; + app.run(2000); + assert.equal(app.entry(T1).path.drawS, parked, 'stopped'); + // Held again, hidden, it plays at once; let go, it stops again. + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing at once'); + app.layer.setCockpitSubject(null); + assert.equal(app.state()._moving.has(app.entry(T1)), false); + // Shown again, it is drawn and moves as any other. + app.layer.setParams({ regional: true }); + assert.equal(app.entry(T1).marker.show, true); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing again'); +}); + +test('a ride let go with restore does not focus a train its category hides', async (t) => { + const app = await onTheLine(t); + // The documented provider: hold on entry; on exit, release, then focus + // the train again when asked to restore. + assert.equal(app.layer.setCockpitSubject(T1), true); + app.layer.setParams({ regional: false }); + app.layer.setCockpitSubject(null); + assert.equal(app.layer.focusTrain(T1), false, 'not drawn'); + assert.equal(app.state()._selectedKey, null, 'not selected'); + assert.equal(app.parts().follow.stats(), null, 'not followed'); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.focusRequests.length, 0, 'not flown to'); + assert.equal(app.entry(T1).marker.show, false); + + // Shown again, it focuses as before. + app.layer.setParams({ regional: true }); + assert.equal(app.layer.focusTrain(T1), true); + assert.equal(app.state()._selectedKey, T1); + assert.ok(app.parts().follow.isFollowing(T1), 'the camera is handed to it'); +}); + +test('a held train that goes takes the hold with it; focusTrain selects', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.feeds.set('mbta-rail', () => [train('T2', LAT, LON0 + 4000 / KX)]); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.equal(app.entry(T1), undefined, 'the train is gone'); + assert.equal(app.layer.getCockpitSubject(), null); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.layer.focusTrain(T1), false); + assert.equal(app.layer.focusTrain(T2), true); + assert.equal(app.state()._selectedKey, T2); +}); + +test('a held train its feed drops leaves the feed polled until the view lets go', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + // From the cab the camera looks along the ground, here past the feed's range. + await app.moveCamera({ lat: 40.71, lon: -74.0 }); + assert.equal(app.state()._heldFeedId, 'mbta-rail', 'held for the train'); + const others = () => [train('T2', LAT, LON0 + 4000 / KX)]; + app.feeds.set('mbta-rail', others); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + await app.recheck(); + assert.equal(app.entry(T1), undefined, 'the train is gone'); + assert.equal(app.layer.getCockpitSubject(), null); + assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'its feed is polled'); + await app.moveCamera({ lat: 40.72, lon: -74.0 }); + assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'whatever the camera'); + + // Listed again: the view holds it again. + app.feeds.set('mbta-rail', () => [ + train('T1', LAT, LON0 + 2500 / KX, { speedMps: 15 }), + ...others(), + ]); + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.equal(app.state()._cockpitLostFeedId, null); + assert.equal(app.state()._selectedKey, T1); + + // Let go, and the feed is the viewport's again: out of range, released. + app.parts().ingestion.removeTrain(T1); + assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail'); + app.layer.setCockpitSubject(null); + await app.moveCamera({ lat: 40.71, lon: -74.0 }); + assert.equal(app.state()._activeFeeds.has('mbta-rail'), false); + assert.equal(app.state()._trains.size, 0); +}); + +test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.focusTrain(T1), true); + const first = app.layer.getSelectedTrain().selectedAtMs; + assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it'); + // Selected again: a later stamp. + app.run(1000); + app.layer.focusTrain(T1); + assert.ok(app.layer.getSelectedTrain().selectedAtMs > first); + // A hold of another train is not the user's selection. + app.layer.setCockpitSubject(T2); + assert.equal(app.layer.getCockpitSubject().key, T2); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + app.layer.setCockpitSubject(null); + // Nor after a deselection (Escape), then a hold. + app.layer.focusTrain(T1); + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._selectedAtMs, null, 'deselection clears it'); + app.layer.setCockpitSubject(T2); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + // Releasing twice is harmless: the selection stays. + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.state()._selectedKey, T2); + assert.equal(app.layer.getCockpitSubject(), null); +}); + +test('turning the layer off ends the hold', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.parts().ingestion.removeTrain(T1); + assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail'); + app.layer.disable(app.viewer); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.state()._cockpitLostFeedId, null); + assert.equal(app.layer.getCockpitSubject(), null); +}); diff --git a/src/layers/rail/controls.js b/src/layers/rail/controls.js index 0899ed8e9..f937cd6ae 100644 --- a/src/layers/rail/controls.js +++ b/src/layers/rail/controls.js @@ -91,7 +91,13 @@ export function createControls({ state, parts, timetable }) { return true; state._categories = next; const selected = state._trains.get(state._selectedKey); - if (selected && next[selected.category] === false) + // A held train keeps the selection until its view lets it go + // (cockpitSubject.js). + if ( + selected && + next[selected.category] === false && + selected.key !== state._cockpitSubjectKey + ) parts.selection.clearSelection(); parts.rendering.applyCategoryVisibility(); state._revision += 1; diff --git a/src/layers/rail/dedupe.js b/src/layers/rail/dedupe.js index e1ce79f78..f699c0c67 100644 --- a/src/layers/rail/dedupe.js +++ b/src/layers/rail/dedupe.js @@ -25,9 +25,13 @@ import { greatCircleKm } from '../../geoDistance.js'; * 2. the line: a copy bound to its operator's shape (a built pack), then * one without. * The copy drawn stays drawn until another scores strictly higher, so equal - * copies never trade places. A losing copy keeps its playback and is only - * hidden; when a feed leaves the viewport its copies' partners come back. - * The comparison runs once per poll, never per frame. + * copies never trade places. A copy a first-person view holds + * (cockpitSubject.js) stays drawn whatever the scores until the view lets + * go: the view knows the train by that copy's key and rides it where that + * copy's feed puts it, tens of kilometres from another feed's copy at + * times. A losing copy keeps its playback and is only hidden; when a feed + * leaves the viewport its copies' partners come back. The comparison runs + * once per poll, never per frame. */ /** @@ -288,10 +292,12 @@ function better(a, b) { * scores strictly higher. * @param {object[][]} groups From matchCopies. * @param {Map} [previous] copy key → key drawn last time. + * @param {string|null} [held] A copy drawn whatever the scores: the train a + * first-person view holds. * @returns {{winners: Map, hidden: Set}} * `winners`: every grouped copy's key → the key drawn for its train. */ -export function chooseCopies(groups, previous = new Map()) { +export function chooseCopies(groups, previous = new Map(), held = null) { const winners = new Map(); const hidden = new Set(); for (const group of groups) { @@ -305,6 +311,7 @@ export function chooseCopies(groups, previous = new Map()) { scored.reduce((a, b) => (better(b.score, a.score) ? b : a)); for (const candidate of scored) if (better(candidate.score, best.score)) best = candidate; + best = scored.find(({ copy }) => copy.key === held) ?? best; for (const { copy } of scored) { winners.set(copy.key, best.copy.key); if (copy.key !== best.copy.key) hidden.add(copy.key); @@ -318,7 +325,8 @@ export function chooseCopies(groups, previous = new Map()) { * loaded feed carries, marks the copies not drawn (`entry.duplicate`), and * moves a selection that sat on a copy now hidden to the copy drawn, so the * card follows the same physical train (its feed line names the new - * source). + * source). A held copy is never the one hidden, so the hold and the + * selection stay on it. * @param {{state: object, parts: object, * defer?: (fn: Function) => unknown}} context */ @@ -355,6 +363,7 @@ export function createDuplicateFilter({ const { winners, hidden } = chooseCopies( matchCopies(entries.map(copyOf)), previous, + state._cockpitSubjectKey ?? null, ); previous = winners; for (const entry of entries) { diff --git a/src/layers/rail/dedupe.test.mjs b/src/layers/rail/dedupe.test.mjs index ac3f61f6b..0d6538d3c 100644 --- a/src/layers/rail/dedupe.test.mjs +++ b/src/layers/rail/dedupe.test.mjs @@ -252,6 +252,32 @@ test('the filter hides the weaker copy and the selection follows its train', () assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 }); }); +test('a copy a first-person view holds stays drawn, with the selection', () => { + const pair = [ + copy('a', 'f1', { number: '160', position: 'event' }), + copy('b', 'f2', { number: '160', position: 'gps' }), + ]; + assert.equal(chooseCopies([pair]).winners.get('a'), 'b'); + const held = chooseCopies([pair], new Map(), 'a'); + assert.equal(held.winners.get('b'), 'a', 'drawn whatever the scores'); + assert.deepEqual([...held.hidden], ['b']); + // Let go, the better copy takes over. + assert.equal(chooseCopies([pair], held.winners).winners.get('a'), 'b'); + + const layer = fakeLayer({ selected: 'feed-a:v160' }); + layer.state._cockpitSubjectKey = 'feed-a:v160'; + const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() }); + filter.refresh(); + const [unbound, operator] = layer.trains; + assert.equal(unbound.duplicate, false, 'the held copy'); + assert.equal(operator.duplicate, true); + assert.deepEqual(layer.calls.selected, [], 'the selection stays on it'); + layer.state._cockpitSubjectKey = null; + filter.refresh(); + assert.equal(unbound.duplicate, true); + assert.deepEqual(layer.calls.selected, [operator.key]); +}); + test('a short number after a local product names a line, not a train (S1, RB 1, U6)', () => { for (const label of ['S1', 'RB 1', 'U6', 'S 12', 'RE5']) assert.equal(recordTrainNumber({ label }), null, label); diff --git a/src/layers/rail/index.js b/src/layers/rail/index.js index 0a191c62a..ec6322fc3 100644 --- a/src/layers/rail/index.js +++ b/src/layers/rail/index.js @@ -7,6 +7,7 @@ import { createIngestion } from './ingestion.js'; import { createViewport } from './viewport.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; +import { createRailCockpitSubject } from './cockpitSubject.js'; import { createRouteDisplay } from './routeDisplay.js'; import { createRailSchedules } from './schedules.js'; import { getRegisteredRailFeed } from '../../data/railFeeds.js'; @@ -78,12 +79,24 @@ function createState({ services }) { _stopAnchors: new Map(), _categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY }, _selectedKey: null, + /** When the user last selected a train, even the selected one (ms). */ + _selectedAtMs: null, _selectedCardAt: 0, _selectedCardText: null, /** The camera's follow of the selected train (follow.js), or null. */ _follow: null, /** A feed polled only for the selected train (viewport.js), or null. */ _heldFeedId: null, + /** + * The train a first-person view holds (cockpitSubject.js), or null: its + * marker, 3D model and card are hidden and the selection stays on it. + */ + _cockpitSubjectKey: null, + /** + * The feed of a held train the feed dropped (ingestion.js), polled until + * the view takes a train again or lets go (cockpitSubject.js), or null. + */ + _cockpitLostFeedId: null, _revision: 0, _controlsListener: null, _dataManager: null, @@ -175,11 +188,13 @@ export function createRailLayer({ parts.ingestion = createIngestion(context); parts.viewport = createViewport(context); parts.lifecycle = createLifecycle(context); + parts.cockpit = createRailCockpitSubject(context); return Object.assign( { updateInterval: RAIL_POLL_MS }, parts.lifecycle.methods, parts.controls.methods, + parts.cockpit.methods, { /** Test seams: this instance's own state and parts. */ _railStateForTest: () => state, diff --git a/src/layers/rail/ingestion.js b/src/layers/rail/ingestion.js index 54d885e37..8bd4a342f 100644 --- a/src/layers/rail/ingestion.js +++ b/src/layers/rail/ingestion.js @@ -69,6 +69,13 @@ export function createIngestion({ state, services, parts, source }) { function removeTrain(key) { const entry = state._trains.get(key); if (!entry) return; + // A first-person view on it loses its subject (cockpitSubject.js); its + // feed stays polled until the view lets go, so the train is found again + // if the feed lists it again. + if (state._cockpitSubjectKey === key) { + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = entry.feedId; + } if (state._selectedKey === key) parts.selection.clearSelection(); if (entry.marker) state._markers?.remove(entry.marker); entry.marker = null; diff --git a/src/layers/rail/lifecycle.js b/src/layers/rail/lifecycle.js index e44dc0ce3..67501df7c 100644 --- a/src/layers/rail/lifecycle.js +++ b/src/layers/rail/lifecycle.js @@ -95,6 +95,7 @@ export function createLifecycle({ state, services, parts, timetable }) { parts.rendering.onPreRender, ); state._networkFade = -1; + parts.rendering.startModels(); parts.viewport.loadCatalog(); parts.viewport.runProximityCheck(); restoreSpriteOrder(viewer); @@ -125,8 +126,11 @@ export function createLifecycle({ state, services, parts, timetable }) { parts.ingestion.abortAllInFlight(); state._activeFeeds.clear(); state._heldFeedId = null; + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = null; state._feedStatus.clear(); dropAllTrains(); + parts.rendering.stopModels(); // Floor cells and track samples queued for trains now gone. parts.rendering.reset(); parts.routes.hideAll(); @@ -154,6 +158,7 @@ export function createLifecycle({ state, services, parts, timetable }) { /** Tear down the collection and release every shared registration. */ destroy(viewer) { this.disable(viewer); + parts.rendering.stopModels(true); parts.routes.destroy(); if (state._markers) { unregisterSpriteCollection('rail', state._markers); diff --git a/src/layers/rail/pathBinding.js b/src/layers/rail/pathBinding.js index 36814c036..9eba452d3 100644 --- a/src/layers/rail/pathBinding.js +++ b/src/layers/rail/pathBinding.js @@ -47,11 +47,31 @@ export function pathWindowM(dtMs) { return window > PATH_WINDOW_MAX_M ? NaN : window; } +/** Arc moved between two reports before it says which way a train runs (m). */ +const DIRECTION_MIN_M = 1; + +/** +1 or -1 for an arc step longer than DIRECTION_MIN_M, else `known`. */ +function directionOf(step, known) { + return Number.isFinite(step) && Math.abs(step) > DIRECTION_MIN_M + ? Math.sign(step) + : known; +} + function createBinding(shapeIndex, path, via, s, fix) { return { shapeIndex, path, via, + /** + * Which way the drawn train runs along the shape: +1 along it, -1 + * against it, 0 until two reports or a playback segment tell. Kept until + * a rebind. Once playback has placed the train, only playback changes + * it: the newest reports run ahead of the drawn train, so a reversal + * they show turns it when playback reaches it. + */ + direction: 0, + /** The direction the last placed playback segment ran, 0 before one. */ + drawDirection: 0, /** Arc and time of the newest report. */ fixS: s, fixT: fix.t, @@ -139,6 +159,8 @@ export function followFix(binding, fix) { pathWindowM(fix.t - binding.fixT), ); if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false; + if (!binding.drawDirection) + binding.direction = directionOf(hit.s - binding.fixS, binding.direction); binding.fixS = hit.s; binding.fixT = fix.t; binding.fixLat = fix.lat; @@ -230,6 +252,13 @@ export function placeOnPath(binding, sample, segment, out) { cache.sFrom = sFrom; cache.sTo = sTo; cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo); + if (cache.ok) { + binding.drawDirection = directionOf( + sTo - sFrom, + binding.drawDirection || binding.direction || 0, + ); + binding.direction = binding.drawDirection; + } } if (!cache.ok) return false; const f = Number.isFinite(sample.fraction) diff --git a/src/layers/rail/pathBinding.test.mjs b/src/layers/rail/pathBinding.test.mjs index c8983a696..7b9e11c0c 100644 --- a/src/layers/rail/pathBinding.test.mjs +++ b/src/layers/rail/pathBinding.test.mjs @@ -201,3 +201,64 @@ test('a segment whose reports do not fit the line keeps the straight line', () = assert.equal(placeOnPath(binding, stale.sample, stale.segment, out), false); assert.equal(placeOnPath(null, stale.sample, stale.segment, out), false); }); + +test('a binding learns which way its train runs, and keeps it through a stop', () => { + const p = pack(); + const ids = { tripId: 'full', routeId: 'main' }; + const along = bindTrainPath(p, ids, fix(0, 500, 0)); + assert.equal(along.direction, 0, 'one report tells no direction'); + assert.ok(followFix(along, fix(10_000, 700, 0))); + assert.equal(along.direction, 1); + // Standing still (under a metre) keeps what it learnt. + assert.ok(followFix(along, fix(20_000, 700.4, 0))); + assert.equal(along.direction, 1); + const against = bindTrainPath(p, ids, fix(0, 1500, 0)); + assert.ok(followFix(against, fix(10_000, 1200, 0))); + assert.equal(against.direction, -1); + // A playback segment between two reports tells it too. + const played = bindTrainPath(p, ids, fix(0, 300, 0)); + const segment = { + from: { seq: 1, ...fix(0, 300, 0) }, + to: { seq: 2, ...fix(10_000, 100, 0) }, + }; + assert.ok( + placeOnPath(played, { fromSeq: 1, toSeq: 2, fraction: 0.5 }, segment, {}), + ); + assert.equal(played.direction, -1); + // A rebind starts again. + const rebound = rebindTrainPath( + p, + { routeId: 'main' }, + along, + fix(30_000, 2000, -300), + ); + assert.equal(rebound?.direction ?? 0, 0); +}); + +test('a report that runs back turns the drawn train only when playback does', () => { + const p = pack(); + const ids = { tripId: 'full', routeId: 'main' }; + const binding = bindTrainPath(p, ids, fix(0, 300, 0)); + const forward = { + from: { seq: 1, ...fix(0, 300, 0) }, + to: { seq: 2, ...fix(10_000, 500, 0) }, + }; + const sample = (fraction) => ({ fromSeq: 1, toSeq: 2, fraction }); + assert.ok(placeOnPath(binding, sample(0.2), forward, {})); + assert.equal(binding.direction, 1); + // The newest report lies behind the last one (a reversal, or GPS jitter) + // while playback still draws the forward segment. + assert.ok(followFix(binding, fix(10_000, 500, 0))); + assert.ok(followFix(binding, fix(20_000, 450, 0))); + assert.ok(placeOnPath(binding, sample(0.6), forward, {})); + assert.equal(binding.direction, 1, 'the drawn train still runs forward'); + // Playback reaches the reversed segment: now it turns. + const back = { + from: { seq: 2, ...fix(10_000, 500, 0) }, + to: { seq: 3, ...fix(20_000, 450, 0) }, + }; + assert.ok( + placeOnPath(binding, { fromSeq: 2, toSeq: 3, fraction: 0.5 }, back, {}), + ); + assert.equal(binding.direction, -1); +}); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index eff1ace98..21b719980 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -26,6 +26,22 @@ import { UNSELECTED_TRAIN_ALPHA, } from './policy.js'; import { placeOnPath } from './pathBinding.js'; +import { + fleetModelSet, + loadFleetModelSet, + transitModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; + +/** How often the trains near enough for a model are gathered again (wall ms). */ +const MODEL_CANDIDATES_MS = 500; +/** Each category's colour, painted where a train model marks it. */ +const CATEGORY_TINTS = Object.fromEntries( + Object.entries(RAIL_CATEGORY_COLORS).map(([category, css]) => [ + category, + Cesium.Color.fromCssColorString(css), + ]), +); const SPRITE_PX = 64; /** Billboard tints; the setter copies, so sharing one instance is safe. */ @@ -507,6 +523,27 @@ export function createRendering({ state, services, parts }) { ); } + /** + * Whether a train's icon is drawn: shown, not drawn by its model, and no + * first-person view rides it (cockpitSubject.js). A held train keeps its + * playback; only its icon and model hide. + */ + function markerShown(entry) { + return ( + categoryShown(entry) && + !entry.modelOwnsVisual && + entry.key !== state._cockpitSubjectKey + ); + } + + /** + * Whether the train's playback runs: it is drawn, or a first-person view + * rides it, which keeps it moving, hidden, in a category turned off. + */ + function playing(entry) { + return categoryShown(entry) || entry.key === state._cockpitSubjectKey; + } + /** * A train bound to its railway line is drawn on the line, at the arc its * playback has reached; any other train (or a bound train whose reports do @@ -536,7 +573,7 @@ export function createRendering({ state, services, parts }) { undefined, scratchPosition, ); - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); } /** While a train is selected, every other train is drawn see-through. */ @@ -554,7 +591,7 @@ export function createRendering({ state, services, parts }) { entry.marker.width = size; entry.marker.height = size; entry.marker.color = tintFor(entry); - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); } /** Re-tint every train after the selection appeared, moved or cleared. */ @@ -593,7 +630,7 @@ export function createRendering({ state, services, parts }) { place(entry); const sample = entry.sample; const moving = - categoryShown(entry) && + playing(entry) && sample?.phase === 'playing' && sample.segmentSpeedMps > 0; if (moving) { @@ -601,7 +638,7 @@ export function createRendering({ state, services, parts }) { return; } state._moving.delete(entry); - if (categoryShown(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { + if (playing(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { entry.wakeTimer = setTimeout( () => { entry.wakeTimer = null; @@ -658,7 +695,7 @@ export function createRendering({ state, services, parts }) { function cullSelected() { const entry = state._trains.get(state._selectedKey); if (!entry?.marker) return; - const shown = categoryShown(entry) && facesCamera(entry.marker.position); + const shown = markerShown(entry) && facesCamera(entry.marker.position); if (entry.marker.show !== shown) entry.marker.show = shown; parts.routes.cullStops(facesCamera); } @@ -666,6 +703,134 @@ export function createRendering({ state, services, parts }) { function onPreRender() { advanceMoving(); if (state._enabled && state._selectedKey !== null) cullSelected(); + if (state._enabled) updateModels(); + } + + // Under the DISPLAY rail's 3D toggle, trains near the camera are drawn as + // the project's procedural fleet models, chosen by category and mode, + // painted in their category colour where the model marks it, on the + // aircraft layers' icon-to-model rules (services.fleet). The fleet set + // loads once, up front, when the layer starts. + let fleetModels = null; + /** Items drawn last frame, and the map the next frame's are gathered into. */ + let modelItems = new Map(); + let spareItems = new Map(); + let modelCandidatesAtMs = -Infinity; + + /** Load the fleet set and create the layer's models (on enable). */ + function startModels() { + const fleet = services.fleet; + if (!fleet?.policy || !state._viewer) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; trains keep their icons */ + }); + fleetModels ||= createFleetModelLayer({ + viewer: state._viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + }); + } + + /** Hand a train's visual back to its icon. */ + function handBack(entry) { + if (!entry.modelOwnsVisual) return; + entry.modelOwnsVisual = false; + if (entry.marker) entry.marker.show = markerShown(entry); + } + + function modelItem(entry, set) { + let item = modelItems.get(entry.key); + if (!item || item.entry !== entry) + item = { + entry, + pickId: entry.key, + modelOwnsVisual: false, + // Markers are drawn this far above the track; a model stands on it. + groundLiftM: GROUND_FLOOR_LIFT_M, + }; + const specKey = `${entry.category}|${entry.railMode}`; + if (item.specKey !== specKey) { + item.specKey = specKey; + item.spec = transitModelSpec(set, { + railCategory: entry.category, + railMode: entry.railMode, + }); + item.tint = CATEGORY_TINTS[entry.category] || CATEGORY_TINTS.local; + } + item.position = entry.marker.position; + item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0; + // A train a first-person view rides draws no model: the camera is inside + // it (cockpitSubject.js). + item.show = categoryShown(entry) && entry.key !== state._cockpitSubjectKey; + return item; + } + + function updateModels() { + if (!fleetModels) return; + if (!fleetModels.active() || state._trains.size === 0) { + if (modelItems.size) { + for (const item of modelItems.values()) handBack(item.entry); + modelItems.clear(); + fleetModels.frame(modelItems, performance.now()); + } + return; + } + const set = fleetModelSet(); + const now = performance.now(); + const next = spareItems; + next.clear(); + if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) { + modelCandidatesAtMs = now; + const camera = state._viewer?.camera; + const policy = services.fleet.policy; + const keepM = + services.fleet.display?.()?.models3dMode === 'all' + ? policy.keepAllM + : policy.keepM; + const limitSq = keepM * keepM * 1.44; + if (camera?.positionWC) + for (const entry of state._trains.values()) { + if (!entry.marker || !categoryShown(entry)) continue; + if ( + Cesium.Cartesian3.distanceSquared( + camera.positionWC, + entry.marker.position, + ) > limitSq + ) + continue; + next.set(entry.key, modelItem(entry, set)); + } + } else { + for (const [key, item] of modelItems) + if (state._trains.get(key) === item.entry && item.entry.marker) + next.set(key, modelItem(item.entry, set)); + } + for (const [key, item] of modelItems) + if (next.get(key) !== item) handBack(item.entry); + spareItems = modelItems; + modelItems = next; + fleetModels.frame(modelItems, now); + for (const item of modelItems.values()) { + const entry = item.entry; + if (item.modelOwnsVisual) { + entry.modelOwnsVisual = true; + entry.marker.show = false; + } else handBack(entry); + } + if (fleetModels.settling) governorRequestRender('rail-models'); + } + + /** Drop every model (layer disabled); `destroy` also removes the collection. */ + function stopModels(destroy = false) { + for (const item of modelItems.values()) item.entry.modelOwnsVisual = false; + modelItems.clear(); + spareItems.clear(); + modelCandidatesAtMs = -Infinity; + if (destroy) { + fleetModels?.destroy(); + fleetModels = null; + } else fleetModels?.clear(); } function advanceMoving() { @@ -697,7 +862,7 @@ export function createRendering({ state, services, parts }) { function applyCategoryVisibility() { for (const entry of state._trains.values()) { if (!entry.marker) continue; - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); schedulePlayback(entry); } syncRenderHold(); @@ -720,5 +885,7 @@ export function createRendering({ state, services, parts }) { applyCategoryVisibility, categoryShown, reset, + startModels, + stopModels, }; } diff --git a/src/layers/rail/routeDisplay.js b/src/layers/rail/routeDisplay.js index 283d19295..69c1dc840 100644 --- a/src/layers/rail/routeDisplay.js +++ b/src/layers/rail/routeDisplay.js @@ -427,10 +427,29 @@ export function createRouteDisplay({ updateSplit(); } + /** + * Show the selected line unless a first-person view rides its train + * (cockpitSubject.js): from inside the train it would run up the middle of + * the view and under the eye. + */ + function applyHold() { + const route = state._route; + if (!route?.primitive) return; + const shown = !( + state._cockpitSubjectKey !== null && + state._cockpitSubjectKey === state._selectedKey + ); + if (route.primitive.show !== shown) { + route.primitive.show = shown; + governorRequestRender('rail-route'); + } + } + /** Move the travelled/ahead split to where the train is drawn now. */ function updateSplit() { const route = state._route; if (!route?.primitive) return; + applyHold(); const binding = route.binding; const s = binding ? binding.drawS : NaN; if (route.direction === 0 && Number.isFinite(s)) { @@ -777,6 +796,7 @@ export function createRouteDisplay({ applyNetwork, onCamera, syncRoute, + applyHold, refreshStops, cullStops, onSelectionChanged, diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js index 4f4564bc6..e644c26d1 100644 --- a/src/layers/rail/selection.js +++ b/src/layers/rail/selection.js @@ -19,10 +19,15 @@ import { t } from '../../i18n/index.js'; export function createSelection({ state, services, parts }) { const { governorRequestRender } = services.render; - /** The card's anchor: the marker's current position, read on every paint. */ + /** + * The card's anchor: the marker's current position, read on every paint, + * whether the train is drawn as its marker or as its 3D model. + */ function anchorFor(entry) { return () => { - const p = entry.marker?.show !== false ? entry.marker?.position : null; + const drawn = + entry.marker?.show !== false || entry.modelOwnsVisual === true; + const p = drawn ? entry.marker?.position : null; return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null; }; } @@ -30,6 +35,16 @@ export function createSelection({ state, services, parts }) { function refreshSelectedCard(force) { const entry = state._trains.get(state._selectedKey); if (!entry) return; + // A train a first-person view rides shows no card. + if (entry.key === state._cockpitSubjectKey) { + state._selectedCardText = null; + state._overlayHost.setEntries( + RAIL_SELECTED_OVERLAY_SOURCE_ID, + [], + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + return; + } const now = Date.now(); if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS) return; @@ -98,6 +113,8 @@ export function createSelection({ state, services, parts }) { */ function selectAndFocusTrain(key) { if (!state._trains.has(key)) return; + // A selection by the user, even of the selected train (cockpitSubject.js). + state._selectedAtMs = Date.now(); const following = parts.follow.isFollowing(key); // Another train's follow lets go first, so this is never taken for a // move between two copies of one train. @@ -115,6 +132,7 @@ export function createSelection({ state, services, parts }) { const hadSelection = state._selectedKey !== null; parts.follow.release({ untrack }); state._selectedKey = null; + state._selectedAtMs = null; state._selectedCardText = null; if (hadSelection) { parts.rendering.paintAll(); @@ -131,6 +149,8 @@ export function createSelection({ state, services, parts }) { } function onKeyDown(event) { + // While a first-person view rides the train, Escape is the view's. + if (state._cockpitSubjectKey) return; if (event.key === 'Escape' && state._selectedKey) clearSelection(); } @@ -141,6 +161,8 @@ export function createSelection({ state, services, parts }) { ); state._clickHandler.setInputAction((click) => { if (!state._enabled || !isPointerFree()) return; + // A held train keeps the selection until its view lets it go. + if (state._cockpitSubjectKey) return; const picked = viewer.scene.pick(click.position); const id = typeof picked?.id === 'string' ? picked.id : null; if (id && state._trains.has(id)) selectAndFocusTrain(id); diff --git a/src/layers/rail/viewport.js b/src/layers/rail/viewport.js index 03771bd6c..065687d7d 100644 --- a/src/layers/rail/viewport.js +++ b/src/layers/rail/viewport.js @@ -148,6 +148,12 @@ export function createViewport({ state, services, parts, source }) { )) if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); } + // A first-person view whose train its feed dropped keeps the feed polled + // until it lets go (cockpitSubject.js): from the cab the camera looks + // along the ground, often at a point beyond the feed's range. + const lost = state._cockpitLostFeedId; + if (lost && !desired.has(lost) && state._activeFeeds.has(lost)) + desired.set(lost, state._activeFeeds.get(lost)); // The selected train's feed stays polled however far out or away the // camera goes, so the train and its line stay on the map; while nothing // else would keep the feed, it carries the selected train alone and its diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js new file mode 100644 index 000000000..d598e572a --- /dev/null +++ b/src/layers/transit/cockpitSubject.js @@ -0,0 +1,242 @@ +import * as Cesium from 'cesium'; +import { + getRegisteredTransitFeed, + transitRouteLabel, +} from '../../data/transitFeeds.js'; +import { displayMotion } from './movement.js'; + +/** + * `previous` when it holds each of `next`'s values, else a frozen copy of + * `next`: a part of a snapshot that did not change is the one read before. + * `next` is a draft, filled again on every read. + */ +function reuse(previous, next) { + if (!previous) return Object.freeze({ ...next }); + for (const name in next) + if (!Object.is(previous[name], next[name])) + return Object.freeze({ ...next }); + return previous; +} + +/** + * Read access to one vehicle, and a hold on it for a first-person view. + * + * `getSelectedVehicle()` and `getCockpitSubject()` describe the selected + * vehicle and the held one as a frozen snapshot, read again on each call: + * where it is drawn, how fast it moves and how that is known. A call while + * nothing in it changed returns the snapshot it returned before, and a part + * that did not change (its feed, its stop) is the same object in the next + * one, so a view can read it on every frame. Transit has no route shapes or + * stops, so a snapshot names only the vehicle's last reported stop. + * `setCockpitSubject(key)` holds a vehicle: its sprite, 3D model, card, + * trail and detection box are hidden, it keeps moving when it is out of the camera's + * view, its feed stays polled, and clicks and Escape no longer change the + * selection. The hold ends with `setCockpitSubject(null)`, and on + * its own when the vehicle is removed or the layer is turned off. + * `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's + * `selectedAtMs` is when the user last selected the selected vehicle (a + * click, or `focusVehicle`), even again. It is null for any other vehicle, + * after a deselection, and for a vehicle selected only by a hold: a hold is + * not a user's selection. + * + * @param {object} context The layer's state, parts and services. + * @returns {{methods: object}} + */ +export function createTransitCockpitSubject({ state, parts, services }) { + const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M) + ? services.ground.GROUND_FLOOR_LIFT_M + : 0; + const finiteOrNull = (value) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + /** Each vehicle's last snapshot, returned again while nothing changed. */ + const views = new WeakMap(); + /** The drafts vehicleView fills on every read (reuse). */ + const draft = { + view: {}, + feed: {}, + position: {}, + motion: {}, + stop: {}, + provenance: {}, + }; + + /** The feed in use, else the registry's entry for it. */ + function feedEntry(feedId) { + return state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId); + } + + function feedOf(feed, previous) { + if (!feed) return null; + const next = draft.feed; + next.id = feed.id; + next.name = feed.name ?? null; + next.operator = feed.operator ?? null; + next.region = feed.region ?? null; + next.attribution = feed.attribution ?? null; + next.license = feed.license ?? null; + next.licenseUrl = feed.licenseUrl ?? null; + return reuse(previous, next); + } + + const scratchCartographic = new Cesium.Cartographic(); + /** + * The floor under the vehicle where playback draws it, without the shared + * lift: the drawn marker's height while the frame pass places it (in view, + * or held), which near the ground follows the terrain between reports; + * else its segment's two reports' floors at the playback fraction. Null + * while either floor is unknown, as on cold terrain, so a view waits for + * the surface. Not `entry.heightM`, the floor at the newest report, which + * playback has not reached yet. + */ + function drawnFloorOf(entry) { + const segment = entry.segment; + if (!segment || entry.heightPending) return null; + const placed = + entry.surfaceReady === true && + entry.hasRendered && + entry.marker?.position && + (state._visible.has(entry) || entry.key === state._cockpitSubjectKey); + if (placed) { + const drawn = Cesium.Cartographic.fromCartesian( + entry.marker.position, + undefined, + scratchCartographic, + ); + if (drawn) return drawn.height - liftM; + } + const from = segment.from?.h; + const to = segment.to?.h; + if (!Number.isFinite(from) || !Number.isFinite(to)) return null; + const f = Number.isFinite(segment.fraction) + ? Math.max(0, Math.min(1, segment.fraction)) + : 1; + return from + (to - from) * f - liftM; + } + + /** The vehicle as a frozen snapshot (see the module comment), or null. */ + function vehicleView(entry) { + if (!entry?.record) return null; + const previous = views.get(entry); + const record = entry.record; + const sample = entry.sample ?? null; + const feed = feedEntry(entry.feedId); + const reported = finiteOrNull(record.speedMps); + const derived = finiteOrNull(sample?.segmentSpeedMps); + const status = state._feedStatus.get(entry.feedId); + + const position = draft.position; + position.latitude = finiteOrNull(sample?.lat) ?? record.lat; + position.longitude = finiteOrNull(sample?.lon) ?? record.lon; + position.groundHeightM = drawnFloorOf(entry); + position.atMs = finiteOrNull(sample?.displayT); + + const motion = draft.motion; + motion.reportedSpeedMps = reported; + motion.derivedSpeedMps = derived; + motion.speedSource = + reported !== null ? 'reported' : derived !== null ? 'derived' : null; + motion.courseDeg = finiteOrNull(entry.courseDeg); + motion.reportedBearingDeg = finiteOrNull(record.bearing); + motion.segmentCourseDeg = finiteOrNull(sample?.segmentCourseDeg); + motion.phase = sample?.phase ?? null; + motion.word = displayMotion(entry, Date.now()).word; + motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs); + motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs); + motion.feedStale = status?.stale === true; + + const stop = draft.stop; + stop.stopId = record.stopId || null; + stop.status = record.status || null; + + const provenance = draft.provenance; + provenance.positionSource = 'gps'; + provenance.timestampSource = record.timestampSource ?? null; + + const view = draft.view; + view.key = entry.key; + view.feedId = entry.feedId; + view.selectedAtMs = + state._selectedKey === entry.key ? state._selectedAtMs : null; + view.id = String(record.id ?? ''); + view.routeId = record.routeId || null; + view.routeLabel = + (feed && record.routeId + ? transitRouteLabel(feed, record.routeId) + : null) || null; + view.tripId = record.tripId || null; + view.directionId = record.directionId ?? null; + view.label = record.label || null; + view.mode = entry.mode ?? null; + view.modeInferred = entry.modeInferred === true; + view.feed = feedOf(feed, previous?.feed); + view.position = reuse(previous?.position, position); + view.motion = reuse(previous?.motion, motion); + view.stop = reuse(previous?.stop, stop); + view.provenance = reuse(previous?.provenance, provenance); + const snapshot = reuse(previous, view); + views.set(entry, snapshot); + return snapshot; + } + + /** Show or hide a vehicle's sprite and trail for the hold. */ + function repaint(entry) { + if (!entry?.marker) return; + if (entry.key === state._cockpitSubjectKey) entry.marker.show = false; + else parts.rendering.placeSample(entry); + parts.rendering.schedulePlayback(entry); + parts.trails.update(); + state._detectRevision += 1; + } + + const methods = { + /** The selected vehicle (see createTransitCockpitSubject), or null. */ + getSelectedVehicle() { + return vehicleView(state._vehicles.get(state._selectedKey)); + }, + /** The held vehicle, or null when none is held. */ + getCockpitSubject() { + return vehicleView(state._vehicles.get(state._cockpitSubjectKey)); + }, + /** + * Hold the vehicle `key` for a first-person view, or release the hold + * with null. A vehicle it holds is selected, if it was not already. + * @param {string|null} key + * @returns {boolean} False for a vehicle the layer does not show. + */ + setCockpitSubject(key) { + const previous = state._vehicles.get(state._cockpitSubjectKey); + if (key === null || key === undefined) { + state._cockpitSubjectKey = null; + // The hold kept it in play out of view: it leaves until the next + // visibility sweep finds it in view again. + if (previous) { + state._visible.delete(previous); + parts.rendering.requestVisibility(); + } + repaint(previous); + parts.selection.refreshSelectedCard(true); + return true; + } + const entry = state._vehicles.get(key); + if (!entry?.marker) return false; + if (state._selectedKey !== key) parts.selection.selectVehicle(key); + state._cockpitSubjectKey = key; + state._visible.add(entry); + if (previous && previous !== entry) repaint(previous); + repaint(entry); + parts.selection.refreshSelectedCard(true); + return true; + }, + /** + * Select the vehicle `key`, as a click on it does. + * @param {string} key + * @returns {boolean} False for a vehicle the layer does not show. + */ + focusVehicle(key) { + if (!state._vehicles.get(key)?.marker) return false; + parts.selection.selectVehicle(key, { byUser: true }); + return true; + }, + }; + return { methods, vehicleView }; +} diff --git a/src/layers/transit/index.js b/src/layers/transit/index.js index 288043f36..75d7c971b 100644 --- a/src/layers/transit/index.js +++ b/src/layers/transit/index.js @@ -11,6 +11,7 @@ import { createQueries } from './queries.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; import { createTesting } from './testing.js'; +import { createTransitCockpitSubject } from './cockpitSubject.js'; import { TRANSIT_POLL_MS } from './policy.js'; /** @@ -56,12 +57,14 @@ export function createTransitLayer({ parts.controls = createControls(context); parts.lifecycle = createLifecycle(context); parts.testing = createTesting(context); + parts.cockpit = createTransitCockpitSubject(context); return Object.assign( { updateInterval: TRANSIT_POLL_MS }, parts.lifecycle.methods, parts.queries.methods, parts.controls.methods, + parts.cockpit.methods, parts.testing, ); } diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index afce6a4aa..3f5669c65 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -71,6 +71,8 @@ export function createIngestion({ state, services, parts, source }) { function removeVehicle(key) { const entry = state._vehicles.get(key); if (!entry) return; + // A first-person view on it loses its subject (cockpitSubject.js). + if (state._cockpitSubjectKey === key) state._cockpitSubjectKey = null; if (state._selectedKey === key) parts.selection.clearSelection(); if (entry.marker) entry.markerCollection?.remove(entry.marker); entry.marker = null; diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index f8784c360..79ea627fe 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -151,6 +151,7 @@ export function createLifecycle({ state, services, parts }) { state._vehicles.clear(); parts.height.clear(); state._selectedKey = null; + state._cockpitSubjectKey = null; state._lastUpdate = null; state._error = null; state._limitWarned = false; @@ -195,6 +196,7 @@ export function createLifecycle({ state, services, parts }) { parts.rendering.onPreRender, ); } + parts.rendering.startModels(); parts.viewport.runProximityCheck(); restoreSpriteOrder(viewer); }, @@ -243,6 +245,7 @@ export function createLifecycle({ state, services, parts }) { destroyTrack(entry.track); } state._vehicles.clear(); + parts.rendering.stopModels(); parts.height.clear(); state._markers?.removeAll(); state._animatedMarkers?.removeAll(); @@ -285,6 +288,7 @@ export function createLifecycle({ state, services, parts }) { destroy(viewer) { this.disable(viewer); parts.coverage.destroy(); + parts.rendering.stopModels(true); if (state._markers) { unregisterSpriteCollection('transit', state._markers); viewer?.scene?.primitives?.remove(state._markers); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index fee9a60ed..71d9d1df4 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -7,9 +7,11 @@ import { FEED_STALE_AFTER_MS, MISSED_POLLS_TO_DROP, TRANSIT_POLL_MS, + TRANSIT_SELECTED_OVERLAY_SOURCE_ID, VEHICLE_MAX_FIX_AGE_MS, } from './policy.js'; import { FLOOR_WARM_PER_POLL } from './policy.js'; +import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs'; import { FUTURE_STAMP_TOLERANCE_MS, ORDER_REJECTS_BEFORE_RESET, @@ -65,6 +67,25 @@ function snapshot( }; } +/** A camera's place and frame looking straight down from `altitude` over `at`. */ +function lookingDown(at, altitude) { + const positionWC = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + positionWC, + new Cesium.Cartesian3(), + ); + return { + positionWC, + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; +} + /** A vehicle record as the proxy emits it, with its timestamp source resolved. */ function vehicle(id, lat, lon, atSeconds, extra = {}) { return { @@ -159,6 +180,9 @@ function harness( east: at.lon + 0.1, north: at.lat + 0.1, }, + // Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera + // looking straight down for them; without them vehicles stay icons. + fleet = null, } = {}, ) { const viewRect = { ...view }; @@ -219,6 +243,7 @@ function harness( viewRect.north, ); }, + ...(fleet ? lookingDown(at, altitude) : {}), }, scene: { canvas: { addEventListener() {}, removeEventListener() {} }, @@ -265,6 +290,7 @@ function harness( const layer = createTransitLayer({ source, services: { + ...(fleet ? { fleet } : {}), ground, mesh: { sampleMeshFloorCells(scene, points, options) { @@ -727,6 +753,27 @@ test('disable aborts in-flight work and a response that lands late is ignored', assert.equal(app.vehicles().length, 1); }); +test('a held vehicle with no floor yet tells its view no height', async (t) => { + // Not the ellipsoid: a first-person view waits for the surface instead. + const app = harness(t, { floorAt: () => undefined }); + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + app.settle(); + const entry = app.vehicles()[0]; + assert.equal(entry.heightPending, true, 'it has nothing to stand on'); + assert.ok(app.layer.setCockpitSubject(entry.key) !== false); + assert.equal(app.layer.getCockpitSubject().position.groundHeightM, null); +}); + test('a vehicle waits for its floor rather than standing on the ellipsoid', async (t) => { // The shared ground-floor system answers from the rendered mesh cell or the // real DEM cell; until one of them is warm there is no honest height to draw @@ -1774,11 +1821,19 @@ test('a segment climbs from the ground it left to the ground it reaches', async assert.ok(Math.abs(entry.heightM - 101.5) < 1, 'the destination is uphill'); const height = () => Cesium.Cartographic.fromCartesian(entry.marker.position).height; + // A first-person view reads the floor where the vehicle is drawn, not the + // newest report's floor uphill. + app.layer.setCockpitSubject(entry.key); + const floor = () => app.layer.getCockpitSubject().position.groundHeightM; app.run(400); assert.ok( height() < 30, `still on the ground it is drawn on, got ${height().toFixed(1)} m`, ); + assert.ok( + Math.abs(floor() + 1.5 - height()) < 1, + `its snapshot stands where it is drawn, got ${floor().toFixed(1)} m`, + ); // Halfway along the segment it is halfway up; at the end, on the high ground. const firstFixAt = start - AGE_MS; @@ -1792,6 +1847,7 @@ test('a segment climbs from the ground it left to the ground it reaches', async Math.abs(height() - 101.5) < 1.5, `and arrives on the high ground, got ${height().toFixed(1)} m`, ); + assert.ok(Math.abs(floor() - 100) < 1.5, 'and so does its snapshot'); }); test('a lone impossible fix leaves the displayed vehicle and health untouched', async (t) => { const app = harness(t, { floorAt: () => 5 }); @@ -4142,3 +4198,209 @@ test('the prompt names the altitude the gate opens at, not 3,000 km', async (t) 'Fly below 2,850 km to a covered region', ); }); + +/** A bus running north a step a poll, polled `polls` times; its key. */ +async function busRunningNorth(app, polls = 4, extra = {}) { + let step = 0; + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [ + vehicle('bus-1', 42.36 + step * 0.001, -71.06, reported(), { + stopId: 'place-1', + status: 'IN_TRANSIT_TO', + ...extra, + }), + ], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + app.settle(); + for (let i = 0; i < polls; i += 1) { + step += 1; + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + } + return 'mbta:bus-1'; +} + +test('the selected vehicle reads as a snapshot of its place, motion and source', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app, 4, { speedMps: 9 }); + assert.equal(app.layer.getSelectedVehicle(), null); + app.layer._transitPartsForTest().selection.selectVehicle(key); + const view = app.layer.getSelectedVehicle(); + assert.ok(Object.isFrozen(view)); + assert.equal(view.key, key); + assert.equal(view.feedId, 'mbta'); + assert.equal(view.id, 'bus-1'); + assert.equal(view.routeId, '1'); + assert.equal(typeof view.mode, 'string'); + assert.ok(view.feed.attribution, 'the feed names its credit'); + assert.equal(view.motion.reportedSpeedMps, 9); + assert.equal(view.motion.speedSource, 'reported'); + assert.equal(view.provenance.positionSource, 'gps'); + assert.deepEqual(view.stop, { stopId: 'place-1', status: 'IN_TRANSIT_TO' }); + const entry = app.vehicles()[0]; + // Where it is drawn, without the shared lift (read back from the marker). + assert.ok( + Math.abs(view.position.groundHeightM - (entry.heightM - 1.5)) < 1e-3, + ); + assert.ok(Number.isFinite(view.position.latitude)); +}); + +test('a vehicle read again while nothing changed is the snapshot read before', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + app.layer._transitPartsForTest().selection.selectVehicle(key); + const first = app.layer.getSelectedVehicle(); + assert.equal(app.layer.getSelectedVehicle(), first, 'the same snapshot'); + // It moves on: a new snapshot at its new place, what did not change kept. + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + app.run(TRANSIT_POLL_MS); + const moved = app.layer.getSelectedVehicle(); + assert.notEqual(moved, first, 'a new snapshot'); + assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.position)); + assert.ok(moved.position.latitude > first.position.latitude, 'moved'); + assert.equal(moved.feed, first.feed); + assert.equal(moved.stop, first.stop); + assert.equal(moved.provenance, first.provenance); + assert.equal(app.layer.getSelectedVehicle(), moved); +}); + +test('a held vehicle draws no 3D model either; let go, its model draws again', async (t) => { + const models = await fleetModelFakes(t); + const app = harness(t, { fleet: models.fleet, altitude: 1_000 }); + const key = await busRunningNorth(app); + const [entry] = app.vehicles(); + assert.notEqual(entry.mode, 'unknown', 'a mode its model is chosen by'); + // The throttled sweep runs; a model's load answers off the frame and the + // next frames place it. + const frames = async () => { + app.advance(250); + app.settle(); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + app.settle(); + }; + await frames(); + assert.equal(models.drawn(key).length, 1, 'near the camera: its model'); + assert.equal(entry.modelOwnsVisual, true); + assert.equal(entry.marker.show, false, 'its icon hands over'); + + assert.equal(app.layer.setCockpitSubject(key), true); + app.frame(); + // The view's eye is inside the vehicle. + assert.equal(models.drawn(key).length, 0, 'no model'); + assert.equal(entry.modelOwnsVisual, false); + assert.equal(entry.marker.show, false, 'and no icon'); + await frames(); + assert.equal(models.drawn(key).length, 0, 'not on a later frame either'); + assert.equal(entry.marker.show, false); + + assert.equal(app.layer.setCockpitSubject(null), true); + await frames(); + assert.equal(models.drawn(key).length, 1, 'let go: its model again'); + assert.equal(entry.modelOwnsVisual, true); + assert.equal(entry.marker.show, false); +}); + +test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + // A vehicle only a hold selected reports none. + assert.equal(app.layer.setCockpitSubject(key), true); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + app.layer.setCockpitSubject(null); + assert.equal(app.layer.setCockpitSubject(null), true, 'twice is harmless'); + assert.equal(app.state()._selectedKey, key); + // The user's selection stamps it, again on a re-selection. + assert.equal(app.layer.focusVehicle(key), true); + const first = app.layer.getSelectedVehicle().selectedAtMs; + assert.ok(Number.isFinite(first)); + app.run(1000); + app.layer.focusVehicle(key); + assert.ok(app.layer.getSelectedVehicle().selectedAtMs > first); + // Deselected, then held: none. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._selectedAtMs, null); + app.layer.setCockpitSubject(key); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); +}); + +test('a held vehicle hides, keeps moving out of view, and lets go when it goes', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + assert.equal(app.layer.setCockpitSubject('mbta:nope'), false); + assert.equal(app.layer.setCockpitSubject(key), true); + assert.equal(app.state()._selectedKey, key, 'holding selects it'); + assert.equal(app.layer.getCockpitSubject().key, key); + assert.equal( + app.overlaySources.has(TRANSIT_SELECTED_OVERLAY_SOURCE_ID), + false, + 'no card', + ); + const [entry] = app.vehicles(); + app.frame(); + assert.equal(entry.marker.show, false); + // The camera's view leaves it, as a view from inside it does: it plays on. + app.lookAt({ west: 10, south: 10, east: 11, north: 11 }); + app.settle(); + assert.equal( + app.layer._transitPartsForTest().rendering.vehicleInView(entry), + true, + ); + const before = app.layer.getCockpitSubject().position.latitude; + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + app.run(TRANSIT_POLL_MS); + assert.ok( + app.layer.getCockpitSubject().position.latitude > before, + 'still moving', + ); + // Escape and clicks are the view's while it holds the vehicle. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, key); + + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.getCockpitSubject(), null); + // Let go, it is out of play until a sweep finds it in view. + assert.equal(app.state()._visible.has(entry), false, 'out of play'); + app.lookAt({ + west: -71.2, + south: 42.2, + east: -70.9, + north: 42.5, + }); + app.advance(250); + app.settle(); + assert.equal(entry.marker.show, true, 'shown again'); + assert.equal(app.layer.focusVehicle('mbta:nope'), false); + assert.equal(app.layer.focusVehicle(key), true); + + app.layer.setCockpitSubject(key); + app.silence('mbta'); + app.serve('mbta', () => ({ + status: 200, + body: snapshot('mbta', 'MBTA', [], { fetchedAt: Date.now() }), + })); + for (let i = 0; i < MISSED_POLLS_TO_DROP + 1; i += 1) { + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + } + assert.equal(app.vehicles().length, 0, 'the vehicle is gone'); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.layer.getCockpitSubject(), null); +}); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 7a9f2e074..32633e775 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -129,7 +129,9 @@ export function createQueries({ state, parts }) { const visible = []; for (const entry of state._visible) { const marker = entry.marker; - if (!marker?.position || marker.show === false) continue; + // A vehicle drawn as its 3D model is present though its marker is hidden. + if (!marker?.position) continue; + if (marker.show === false && entry.modelOwnsVisual !== true) continue; visible.push(entry); } let chosen = visible; diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 664b1688d..058aa3a5d 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -27,6 +27,12 @@ import { TRANSIT_MODE_COLORS, vehicleNearView, } from './policy.js'; +import { + fleetModelSet, + loadFleetModelSet, + transitModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; export const MODE_CESIUM_COLORS = Object.fromEntries( Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [ @@ -100,7 +106,12 @@ export function createRendering({ state, services, parts }) { ); entry.surfaceReady = !!known; entry.heightPending = !known; - entry.marker.show = !!known && vehicleInView(entry); + // A vehicle a first-person view rides keeps moving, unseen. + entry.marker.show = + !!known && + vehicleInView(entry) && + !entry.modelOwnsVisual && + entry.key !== state._cockpitSubjectKey; if (known) { entry.marker.position = state._scratchCartesian; entry.hasRendered = true; @@ -191,7 +202,7 @@ export function createRendering({ state, services, parts }) { * @returns {boolean} */ function vehicleInView(entry) { - return state._visible.has(entry); + return state._visible.has(entry) || entry.key === state._cockpitSubjectKey; } /** Cesium colour for a preset tuple, cached per profile+mode. */ @@ -403,6 +414,7 @@ export function createRendering({ state, services, parts }) { const now = state.now(); if (state._vehicles.size === 0) { state._moving.clear(); + updateModels(); syncRenderHold(); return; } @@ -450,9 +462,117 @@ export function createRendering({ state, services, parts }) { : 0; } if (state._heightDirty.size) governorRequestRender('transit-height-batch'); + updateModels(); syncRenderHold(); } + // Under the DISPLAY rail's 3D toggle, vehicles in view near the camera are + // drawn as the project's procedural fleet models, chosen by mode, painted + // in their mode colour where the model marks it, on the aircraft layers' + // icon-to-model rules (services.fleet). The fleet set loads once, up front, + // when the layer starts. + let fleetModels = null; + /** Items drawn last frame, and the map the next frame's are gathered into. */ + let modelItems = new Map(); + let spareItems = new Map(); + /** The lift icons are drawn with above the floor; a model stands on the floor. */ + const groundLiftM = services.ground?.GROUND_FLOOR_LIFT_M || 0; + + /** Load the fleet set and create the layer's models (on enable). */ + function startModels() { + const fleet = services.fleet; + if (!fleet?.policy || !state._viewer) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; vehicles keep their icons */ + }); + fleetModels ||= createFleetModelLayer({ + viewer: state._viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + }); + } + + /** Hand an entry's visual back to its icon, unless a view rides it. */ + function handBack(entry) { + if (!entry.modelOwnsVisual) return; + entry.modelOwnsVisual = false; + if (entry.marker) + entry.marker.show = + vehicleInView(entry) && + !entry.heightPending && + entry.surfaceReady !== false && + entry.key !== state._cockpitSubjectKey; + } + + function updateModels() { + if (!fleetModels) return; + if (!fleetModels.active() || state._vehicles.size === 0) { + if (modelItems.size) { + for (const item of modelItems.values()) handBack(item.entry); + modelItems.clear(); + fleetModels.frame(modelItems, performance.now()); + } + return; + } + const set = fleetModelSet(); + const next = spareItems; + next.clear(); + for (const entry of state._visible) { + // A vehicle of no known mode keeps its icon: no model says what it is. + if (!entry.marker || !entry.hasRendered || entry.mode === 'unknown') + continue; + let item = modelItems.get(entry.key); + if (!item || item.entry !== entry) { + item = { + entry, + pickId: entry.key, + modelOwnsVisual: false, + groundLiftM, + }; + } + if (item.mode !== entry.mode) { + item.mode = entry.mode; + item.spec = transitModelSpec(set, { mode: entry.mode }); + item.tint = + MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; + } + item.position = entry.marker.position; + item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0; + // A vehicle a first-person view rides draws no model: the camera is + // inside it (cockpitSubject.js). + item.show = + !entry.heightPending && + entry.surfaceReady !== false && + entry.key !== state._cockpitSubjectKey; + next.set(entry.key, item); + } + for (const [key, item] of modelItems) + if (next.get(key) !== item) handBack(item.entry); + spareItems = modelItems; + modelItems = next; + fleetModels.frame(modelItems, performance.now()); + for (const item of modelItems.values()) { + const entry = item.entry; + if (item.modelOwnsVisual) { + entry.modelOwnsVisual = true; + entry.marker.show = false; + } else handBack(entry); + } + if (fleetModels.settling) governorRequestRender('transit-models'); + } + + /** Drop every model (layer disabled); `destroy` also removes the collection. */ + function stopModels(destroy = false) { + for (const item of modelItems.values()) item.entry.modelOwnsVisual = false; + modelItems.clear(); + spareItems.clear(); + if (destroy) { + fleetModels?.destroy(); + fleetModels = null; + } else fleetModels?.clear(); + } + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); const sphere = new Cesium.BoundingSphere(undefined, 5); const cameraPosition = new Cesium.Cartesian3(); @@ -531,7 +651,10 @@ export function createRendering({ state, services, parts }) { } else if (frustum) inFrustum = frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE; - const visible = (frustum || bounds) && horizon && inFrustum; + // A held vehicle (cockpitSubject.js) is the camera's own: it stays in + // play wherever the view looks. + const held = entry.key === state._cockpitSubjectKey; + const visible = held || ((frustum || bounds) && horizon && inFrustum); const visibility = (entry.visibility ||= {}); visibility.bounds = bounds; visibility.boundsApplied = !frustum; @@ -556,7 +679,11 @@ export function createRendering({ state, services, parts }) { } } entry.marker.show = - visible && !entry.heightPending && entry.surfaceReady !== false; + !held && + visible && + !entry.heightPending && + entry.surfaceReady !== false && + !entry.modelOwnsVisual; visibility.heightPending = !!entry.heightPending; visibility.surfaceReady = entry.surfaceReady ?? null; schedulePlayback(entry); @@ -621,5 +748,7 @@ export function createRendering({ state, services, parts }) { setStylePreset, onPreRender, syncRenderHold, + startModels, + stopModels, }; } diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index fd066ca55..26bf024c1 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -24,14 +24,16 @@ export function createSelection({ state, services, parts }) { * (the tracked-aircraft readout uses the same), so the anchor moves with the * sprite at frame rate, through poll arrivals, settling, a late floor and a * reset alike, with no per-frame work here. Null when there is nothing to - * anchor to: a hidden or removed marker draws no card. + * anchor to: a hidden or removed marker draws no card, unless the vehicle's + * 3D model is drawing it in the marker's place. * @param {object} entry * @returns {() => Cesium.Cartesian3|null} */ function anchorFor(entry) { return () => { const marker = entry.marker; - const p = marker?.show !== false ? marker?.position : null; + const drawn = marker?.show !== false || entry.modelOwnsVisual === true; + const p = drawn ? marker?.position : null; return p && Number.isFinite(p.x) && Number.isFinite(p.y) && @@ -46,6 +48,12 @@ export function createSelection({ state, services, parts }) { ? state._vehicles.get(state._selectedKey) : null; if (!entry) return; + // A vehicle a first-person view rides shows no card. + if (entry.key === state._cockpitSubjectKey) { + state._selectedCardText = null; + state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); + return; + } const now = Date.now(); if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS) return; @@ -91,6 +99,7 @@ export function createSelection({ state, services, parts }) { : null; parts.trails.clear(); state._selectedKey = null; + state._selectedAtMs = null; state._selectedCardText = null; state._detectRevision += 1; // Cleared first: the styling path reads the selection from state. @@ -98,11 +107,18 @@ export function createSelection({ state, services, parts }) { state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); } - function selectVehicle(key) { + /** + * Select a vehicle. `byUser` marks a selection the user made (a click, or + * focusVehicle), even of the selected vehicle again: its time is the + * snapshot's `selectedAtMs` (cockpitSubject.js). A first-person view's hold + * selects without it. + */ + function selectVehicle(key, { byUser = false } = {}) { clearSelection(); const entry = state._vehicles.get(key); if (!entry?.marker) return; state._selectedKey = key; + if (byUser) state._selectedAtMs = Date.now(); state._detectRevision += 1; parts.rendering.paintSelected(entry, true); parts.trails.select(entry); @@ -111,6 +127,8 @@ export function createSelection({ state, services, parts }) { } function onKeyDown(event) { + // While a first-person view rides the vehicle, Escape is the view's. + if (state._cockpitSubjectKey) return; if (event.key === 'Escape' && state._selectedKey) clearSelection(); } @@ -123,6 +141,8 @@ export function createSelection({ state, services, parts }) { // Ambient selection never competes for a pointer a tool has claimed: // placing a draw vertex on top of a bus must not also select the bus. if (!isPointerFree()) return; + // A held vehicle keeps the selection until its view lets it go. + if (state._cockpitSubjectKey) return; const scene = viewer.scene; // Pick buffers precede post-processing. Map a sensor pixel back through // the active shader's barrel distortion and quantization before picking. @@ -179,11 +199,11 @@ export function createSelection({ state, services, parts }) { typeof primitiveId === 'string' && state._vehicles.has(primitiveId) ) { - selectVehicle(primitiveId); + selectVehicle(primitiveId, { byUser: true }); return; } if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) { - selectVehicle(picked.id); + selectVehicle(picked.id, { byUser: true }); return; } } diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 7e13b77e5..0c8168bcc 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -98,6 +98,15 @@ export function createState({ services }) { /** @type {string|null} */ state._selectedKey = null; + /** When the user last selected a vehicle, even the selected one (ms). */ + state._selectedAtMs = null; + /** + * The vehicle a first-person view holds (cockpitSubject.js), or null: its + * sprite, 3D model, card, trail and detection box are hidden, and it keeps + * moving out of the camera's view. + * @type {string|null} + */ + state._cockpitSubjectKey = null; state._selectedCardAt = 0; /** The card text last published, so an unchanged card is not re-sent. */ state._selectedCardText = null; diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 47973c1a2..4080766ee 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -235,8 +235,11 @@ export function createTrails({ state, services, parts, source }) { } function update() { if (!selected || !renderer) return; + // A first-person view on the vehicle rides where its trail would start. renderer.setVisible( - state._enabled && state._vehicles.get(selected.key) === selected, + state._enabled && + state._vehicles.get(selected.key) === selected && + selected.key !== state._cockpitSubjectKey, ); renderer.setDisplaySample(selected.sample, selected.marker?.position); } diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index affdb0b89..6b1e8c242 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -102,6 +102,10 @@ export function createViewport({ state, services, parts }) { if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); } } + // A vehicle a first-person view rides keeps its feed polled. + const held = state._vehicles.get(state._cockpitSubjectKey); + if (held && state._activeFeeds.has(held.feedId)) + desired.set(held.feedId, state._activeFeeds.get(held.feedId)); for (const feedId of [...state._activeFeeds.keys()]) { if (!desired.has(feedId)) { state._inFlight.get(feedId)?.controller.abort(); diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index 735f32daa..c291c8e35 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -213,6 +213,8 @@ export function createIngestion({ imo: record.imo, type: record.type, destination: record.destination, + length: record.lengthM ?? null, + beam: record.beamM ?? null, speed: record.speedMps == null ? null : record.speedMps / 0.514444, course: record.courseDeg, heading: record.headingDeg, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 4c457c198..fd7b6becd 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -248,6 +248,7 @@ export function createLifecycle({ if (state.preRenderRemover) { state.preRenderRemover(); } + components.rendering.destroyModels(); resetState(); }, }; diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index d927def6d..d204bfadb 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -664,7 +664,13 @@ export function createQueries({ if ((idx - start) % stride !== 0) continue; const record = records[idx]; const visual = components.rendering.getVisual(record); - if (visual.billboard && !visual.billboard.show) continue; + // A vessel drawn as its model is present though its chevron is hidden. + if ( + visual.billboard && + !visual.billboard.show && + !visual.modelOwnsVisual + ) + continue; const position = visual.billboard?.position || visual.position; if (!position) continue; result.push({ diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index 17296cc62..7b70a3105 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -25,6 +25,9 @@ export function normalizeVessel(row) { imo: String(row.imo || ''), type: String(row.type || ''), destination: String(row.destination || ''), + // Hull length and beam (metres), null when unreported. + length: positiveNumber(row.length), + beam: positiveNumber(row.beam), speed: finiteNumber(row.speed), course: finiteNumber(row.course), heading: finiteNumber(row.heading), @@ -46,6 +49,11 @@ function stampFix(record, receivedAtMs, feedLatestEpoch) { record.fixFeedEpoch = feedLatestEpoch; } +function positiveNumber(value) { + const number = finiteNumber(value); + return number !== null && number > 0 ? number : null; +} + function finiteNumber(value) { if (value === null || value === undefined || value === '') return null; const number = Number(value); @@ -147,6 +155,8 @@ export class VesselRecords { record.imo = next.imo; record.type = next.type; record.destination = next.destination; + record.length = next.length; + record.beam = next.beam; record.speed = next.speed; record.course = next.course; record.heading = next.heading; diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 7a6aa6739..45be14033 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -22,10 +22,19 @@ import { followsClock, } from './policy.js'; import { vesselPositionAt } from './motion.js'; +import { + fleetModelSet, + loadFleetModelSet, + vesselModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; /** How often vessels move on as the world clock plays (wall ms). */ const FOLLOW_POSITION_MS = 250; +/** How often the vessels near enough for a model are gathered again (wall ms). */ +const MODEL_CANDIDATES_MS = 500; + export function createRendering({ vesselState, services, @@ -56,6 +65,12 @@ export function createRendering({ VESSEL_LIFT_M, ); visual.position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM); + // A ship model's origin is its waterline: the sea surface, unlifted. + visual.waterline = Cesium.Cartesian3.fromDegrees( + lon, + lat, + heightM - VESSEL_LIFT_M, + ); visual.surfacePosition = Cesium.Cartesian3.fromDegrees(lon, lat, 0); visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( visual.position, @@ -101,6 +116,7 @@ export function createRendering({ } function ensureCollections(viewer) { + ensureModels(viewer); if (!viewer || state.billboardCollection) return; state.billboardCollection = new Cesium.BillboardCollection({ blendOption: Cesium.BlendOption.TRANSLUCENT, @@ -203,6 +219,143 @@ export function createRendering({ return icon; } + // Under the DISPLAY rail's 3D toggle, vessels near the camera are drawn as + // the project's procedural fleet models at their reported length and beam, + // on the aircraft layers' icon-to-model rules (services.fleet). The fleet + // set loads once, up front, when the layer starts. + let fleetModels = null; + /** Items drawn last frame, and the map the next frame's are gathered into. */ + let modelItems = new Map(); + let spareItems = new Map(); + let modelCandidatesAtMs = -Infinity; + const modelOccluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); + + function ensureModels(viewer) { + const fleet = services.fleet; + if (!viewer || fleetModels || !fleet?.policy) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; vessels keep their icons */ + }); + fleetModels = createFleetModelLayer({ + viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + // A hull below the waterline would show through a plain globe. + waterline: true, + }); + } + + /** + * Whether a vessel's chevron is drawn: on the camera's side of the Earth, + * not the bridge camera's own vessel, and not drawn by its model. The + * visibility pass and the model handoff both decide by this. + */ + function billboardShown(record, occluder) { + const visual = getVisual(record); + return ( + isVisible(visual.surfacePosition, occluder) && + record.id !== state.cockpitSubjectId && + !visual.modelOwnsVisual + ); + } + + /** The model item for `record`, its spec rebuilt only when its type or size changes. */ + function modelItem(record, set) { + let item = modelItems.get(record.id); + if (!item || item.record !== record) + item = { record, pickId: record, tint: null, modelOwnsVisual: false }; + const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`; + if (item.specKey !== specKey) { + item.specKey = specKey; + item.spec = vesselModelSpec(set, record); + } + item.position = getVisual(record).waterline; + item.headingDeg = vesselCourseDeg(record); + item.show = record.id !== state.cockpitSubjectId; + return item; + } + + /** Gather the vessels within the models' keep radius of the camera into `into`. */ + function gatherModelItems(set, into) { + const camera = state.viewer?.camera; + if (!camera?.positionWC) return; + const policy = services.fleet.policy; + const keepM = + services.fleet.display?.()?.models3dMode === 'all' + ? policy.keepAllM + : policy.keepM; + const limitSq = keepM * keepM * 1.44; + for (const record of state.records.all) { + const waterline = getVisual(record).waterline; + if (!waterline) continue; + if ( + Cesium.Cartesian3.distanceSquared(camera.positionWC, waterline) > + limitSq + ) + continue; + into.set(record.id, modelItem(record, set)); + } + } + + /** Give a vessel's chevron back when its model no longer draws it. */ + function handBackVisual(record, occluder) { + const visual = getVisual(record); + if (!visual.modelOwnsVisual) return; + visual.modelOwnsVisual = false; + if (visual.billboard) + visual.billboard.show = billboardShown(record, occluder); + } + + function updateModels() { + if (!fleetModels) return; + const camera = state.viewer?.camera; + const occluder = camera?.positionWC ? modelOccluder : null; + if (occluder) occluder.cameraPosition = camera.positionWC; + if (!fleetModels.active()) { + if (modelItems.size) { + modelItems.clear(); + fleetModels.frame(modelItems, performance.now()); + for (const record of state.records.all) + handBackVisual(record, occluder); + } + return; + } + const set = fleetModelSet(); + const now = performance.now(); + const next = spareItems; + next.clear(); + if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) { + modelCandidatesAtMs = now; + gatherModelItems(set, next); + } else { + for (const [id, item] of modelItems) + if (state.records.byId.get(id) === item.record) + next.set(id, modelItem(item.record, set)); + } + for (const [id, item] of modelItems) + if (next.get(id) !== item) handBackVisual(item.record, occluder); + spareItems = modelItems; + modelItems = next; + fleetModels.frame(modelItems, now); + for (const item of modelItems.values()) { + const visual = getVisual(item.record); + if (item.modelOwnsVisual) { + visual.modelOwnsVisual = true; + if (visual.billboard) visual.billboard.show = false; + } else handBackVisual(item.record, occluder); + } + if (fleetModels.settling) governorRequestRender('ais-vessels-models'); + } + + function destroyModels() { + fleetModels?.destroy(); + fleetModels = null; + modelItems = new Map(); + spareItems = new Map(); + modelCandidatesAtMs = -Infinity; + } + // Vessels move only when a poll lands, so the layer does not hold the // continuous render loop. Its passes run in preRender, which idle mode fires // only for rendered frames: a forced pass (new data, selection) requests its @@ -216,6 +369,7 @@ export function createRendering({ state.preRenderRemover = viewer.scene.preRender.addEventListener(() => { if (followsClock(vesselState)) followClockPositions(); updateVisibility(); + if (state.feed.enabled) updateModels(); }); } @@ -306,8 +460,11 @@ export function createRendering({ const visible = isVisible(visual.surfacePosition, occluder); if (visual.billboard) { // The bridge camera sits on this sprite; showing it would fill the view. + // The same rule as billboardShown, its horizon test already made. visual.billboard.show = - visible && record.id !== state.cockpitSubjectId; + visible && + record.id !== state.cockpitSubjectId && + !visual.modelOwnsVisual; if (visible && doRotations && scene) { const rot = screenProjectedRotation( scene, @@ -582,6 +739,13 @@ export function createRendering({ if (state.billboardCollection) { state.billboardCollection.show = show; } + fleetModels?.setVisible(show); + if (!show && fleetModels) { + fleetModels.clear(); + for (const item of modelItems.values()) + getVisual(item.record).modelOwnsVisual = false; + modelItems.clear(); + } vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show); } return { @@ -607,5 +771,7 @@ export function createRendering({ labelPriority, hasUsefulName, setVisible, + updateModels, + destroyModels, }; } diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index afa952503..4c2e164a9 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -248,7 +248,8 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { lon: hasPosition ? position.lon : null, altitudeFt: altitude, groundSpeedKt: finiteOrNull(entry.gs), - trackDeg: normalizeTrack(entry.track), + // No track (parked, slow): the true heading, where the nose points. + trackDeg: normalizeTrack(entry.track ?? entry.true_heading), verticalRateFpm: readAdsbVerticalRateFpm(entry), lastPositionAt: hasPosition ? receivedAt - Math.max(0, seenPosS) * 1000 diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs index aa14133b6..37bac81a2 100644 --- a/src/sources/adsbRecords.test.mjs +++ b/src/sources/adsbRecords.test.mjs @@ -68,6 +68,34 @@ test('dump1090 aircraft.json maps to the shared local ADS-B records', () => { assert.deepEqual(summary, { heard: 4, positioned: 2 }); }); +test('a dump1090 aircraft with no track takes its true heading, never its magnetic one', () => { + const trackOf = (extra) => + normalizeDump1090Aircraft( + { + now: 1, + aircraft: [ + { + hex: 'a4418c', + lat: 37.6, + lon: -122.38, + alt_baro: 'ground', + seen: 1, + ...extra, + }, + ], + }, + 1000, + )[0].trackDeg; + assert.equal( + trackOf({ track: 56.25, true_heading: 40 }), + 56.25, + 'track first', + ); + assert.equal(trackOf({ true_heading: 185.62 }), 185.62, 'true heading'); + assert.equal(trackOf({ mag_heading: 278.44 }), null, 'not magnetic'); + assert.equal(trackOf({}), null, 'none'); +}); + test('dump1090 adapter skips invalid addresses and tolerates missing fields', () => { const records = normalizeDump1090Aircraft( { diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 481bb6113..85b3acdc5 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -71,7 +71,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { ellipsoidAltitudeM: geoFt == null ? null : geoFt * 0.3048, onGround: cleanText(row.alt_baro).toLowerCase() === 'ground', speedMps: speedKts == null ? null : speedKts * 0.514444, - courseDeg: finite(row.track), + // No track (parked, slow): the true heading, where the nose points. + courseDeg: finite(row.track) ?? finite(row.true_heading), verticalRateMps: rateFtMin == null ? null : rateFtMin * 0.00508, category: row.category ?? null, typeCode: cleanText(row.t), diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 0fe9ae913..c789458ec 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -168,6 +168,22 @@ test('readsb units and position/contact ages are normalized once against the sou ); }); +test('a readsb aircraft with no track takes its true heading, never its magnetic one', () => { + const course = (extra) => + normalizeReadsbAircraft( + { hex: 'a4418c', lat: 37.6, lon: -122.38, alt_baro: 'ground', ...extra }, + now, + ).courseDeg; + assert.equal( + course({ track: 56.25, true_heading: 40 }), + 56.25, + 'track first', + ); + assert.equal(course({ true_heading: 185.62 }), 185.62, 'true heading'); + assert.equal(course({ mag_heading: 278.44 }), null, 'not magnetic'); + assert.equal(course({}), null, 'none'); +}); + test('readsb vertical rate falls back to the GNSS rate when no baro rate is sent', () => { // readsb reports the rate under geom_rate when the aircraft's source is // GNSS; 26 of 157 positioned rows in a live /v2/mil fetch had only that. diff --git a/src/sources/live/vessels.js b/src/sources/live/vessels.js index 366ef2beb..3ea9c113f 100644 --- a/src/sources/live/vessels.js +++ b/src/sources/live/vessels.js @@ -10,6 +10,12 @@ function vesselMmsi(row) { return MMSI_PATTERN.test(text) ? text : ''; } +/** A positive measurement, else null. */ +function positive(value) { + const number = finite(value); + return number !== null && number > 0 ? number : null; +} + /** A row's stable id when its source names none: its MMSI or identifier. */ function defaultVesselId(row) { return String(row?.mmsi || row?.input_identifier || ''); @@ -39,6 +45,9 @@ export function normalizeVesselObservation(row, reference = null, id = null) { imo: String(row.imo || ''), type: String(row.type_specific || row.type || ''), destination: String(row.destination || ''), + // Hull length and beam (metres) when the source reports them. + lengthM: positive(row.length), + beamM: positive(row.beam), speedMps: speedKts == null ? null : speedKts * 0.514444, courseDeg: finite(row.course), headingDeg: finite(row.heading), diff --git a/src/testSupport/fleetGlb.mjs b/src/testSupport/fleetGlb.mjs new file mode 100644 index 000000000..c797cc836 --- /dev/null +++ b/src/testSupport/fleetGlb.mjs @@ -0,0 +1,356 @@ +/** + * Reads the procedural fleet models' GLBs in Node: their JSON chunk, and their + * triangles in scene space (raw glTF axes, node transforms applied, metres). + * + * The GLBs use EXT_meshopt_compression and KHR_mesh_quantization, so their + * buffers are decoded with the meshoptimizer decoder Cesium itself ships (a + * dependency of `@cesium/engine`, resolved through it rather than named as one + * of this package's own). + */ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +let decoderPromise = null; + +/** The meshoptimizer decoder Cesium decodes these models with. */ +async function meshoptDecoder() { + decoderPromise ??= (async () => { + const fromHere = createRequire(import.meta.url); + const fromCesium = createRequire(fromHere.resolve('cesium/package.json')); + const fromEngine = createRequire( + fromCesium.resolve('@cesium/engine/package.json'), + ); + const module = await import( + pathToFileURL(fromEngine.resolve('meshoptimizer')).href + ); + await module.MeshoptDecoder.ready; + return module.MeshoptDecoder; + })(); + return decoderPromise; +} + +/** + * A GLB's JSON and binary chunks. + * @param {string} file + * @returns {{json: object, bin: Uint8Array}} + */ +export function readGlb(file) { + const bytes = fs.readFileSync(file); + if (bytes.readUInt32LE(0) !== 0x46546c67) + throw new Error(`${file}: not a GLB`); + const jsonLength = bytes.readUInt32LE(12); + const json = JSON.parse(bytes.subarray(20, 20 + jsonLength).toString('utf8')); + const binStart = 20 + jsonLength; + const bin = + binStart + 8 <= bytes.length + ? new Uint8Array( + bytes.buffer, + bytes.byteOffset + binStart + 8, + bytes.readUInt32LE(binStart), + ) + : new Uint8Array(0); + return { json, bin }; +} + +/** Column-major node matrix from a glTF node's TRS (or its matrix). */ +function nodeMatrix(node) { + if (node.matrix) return node.matrix; + const t = node.translation || [0, 0, 0]; + const [x, y, z, w] = node.rotation || [0, 0, 0, 1]; + const s = node.scale || [1, 1, 1]; + return [ + (1 - 2 * (y * y + z * z)) * s[0], + 2 * (x * y + z * w) * s[0], + 2 * (x * z - y * w) * s[0], + 0, + 2 * (x * y - z * w) * s[1], + (1 - 2 * (x * x + z * z)) * s[1], + 2 * (y * z + x * w) * s[1], + 0, + 2 * (x * z + y * w) * s[2], + 2 * (y * z - x * w) * s[2], + (1 - 2 * (x * x + y * y)) * s[2], + 0, + t[0], + t[1], + t[2], + 1, + ]; +} + +function multiply(a, b) { + const out = new Array(16).fill(0); + for (let c = 0; c < 4; c++) + for (let r = 0; r < 4; r++) + for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k]; + return out; +} + +const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }; +const READERS = { + 5120: ['getInt8', 1, 127], + 5121: ['getUint8', 1, 255], + 5122: ['getInt16', 2, 32767], + 5123: ['getUint16', 2, 65535], + 5125: ['getUint32', 4, 4294967295], + 5126: ['getFloat32', 4, 1], +}; + +/** + * Every element of accessor `index`, normalised integers mapped to -1..1 or + * 0..1 the way a renderer reads them. + */ +function readAccessor(gltf, views, index) { + const accessor = gltf.accessors[index]; + const view = gltf.bufferViews[accessor.bufferView]; + const bytes = views[accessor.bufferView]; + const [getter, size, max] = READERS[accessor.componentType]; + const components = COMPONENTS[accessor.type]; + const stride = view.byteStride || size * components; + const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const out = new Float64Array(accessor.count * components); + for (let i = 0; i < accessor.count; i++) + for (let c = 0; c < components; c++) { + const value = data[getter]( + (accessor.byteOffset || 0) + i * stride + c * size, + true, + ); + out[i * components + c] = accessor.normalized + ? Math.max(value / max, -1) + : value; + } + return out; +} + +/** Each buffer view's bytes, meshopt-compressed ones decoded. */ +async function bufferViews(gltf, bin) { + const decoder = await meshoptDecoder(); + return gltf.bufferViews.map((view) => { + const meshopt = view.extensions?.EXT_meshopt_compression; + if (!meshopt) + return bin.subarray( + view.byteOffset || 0, + (view.byteOffset || 0) + view.byteLength, + ); + const source = bin.subarray( + meshopt.byteOffset || 0, + (meshopt.byteOffset || 0) + meshopt.byteLength, + ); + const target = new Uint8Array(meshopt.count * meshopt.byteStride); + decoder.decodeGltfBuffer( + target, + meshopt.count, + meshopt.byteStride, + source, + meshopt.mode, + meshopt.filter, + ); + return target; + }); +} + +/** + * A fleet model's triangles in scene space: raw glTF axes (X along the body, + * nose toward -X; Y up; Z across), node transforms applied, metres. + * @param {string} file + * @returns {Promise<{verts: number[][], tris: number[][]}>} + */ +export async function fleetMesh(file) { + const { json: gltf, bin } = readGlb(file); + const views = await bufferViews(gltf, bin); + const verts = []; + const tris = []; + const visit = (index, parent) => { + const node = gltf.nodes[index]; + const m = multiply(parent, nodeMatrix(node)); + if (node.mesh != null) { + for (const primitive of gltf.meshes[node.mesh].primitives) { + const base = verts.length; + const positions = readAccessor( + gltf, + views, + primitive.attributes.POSITION, + ); + for (let i = 0; i < positions.length; i += 3) { + const [x, y, z] = [positions[i], positions[i + 1], positions[i + 2]]; + verts.push([ + m[0] * x + m[4] * y + m[8] * z + m[12], + m[1] * x + m[5] * y + m[9] * z + m[13], + m[2] * x + m[6] * y + m[10] * z + m[14], + ]); + } + const indices = readAccessor(gltf, views, primitive.indices); + for (let i = 0; i + 2 < indices.length; i += 3) + tris.push([ + base + indices[i], + base + indices[i + 1], + base + indices[i + 2], + ]); + } + } + for (const child of node.children || []) visit(child, m); + }; + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + for (const root of gltf.scenes[gltf.scene ?? 0].nodes) visit(root, identity); + return { verts, tris }; +} + +/** Axis-aligned bounds of a vertex list. */ +export function vertexBounds(verts) { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (const v of verts) + for (let a = 0; a < 3; a++) { + if (v[a] < min[a]) min[a] = v[a]; + if (v[a] > max[a]) max[a] = v[a]; + } + return { min, max }; +} + +const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + +/** Closest point of triangle abc to p (Ericson, Real-Time Collision Detection). */ +function closestOnTriangle(p, a, b, c) { + const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]]; + const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2]; + const ab = sub(b, a); + const ac = sub(c, a); + const ap = sub(p, a); + const d1 = dot(ab, ap); + const d2 = dot(ac, ap); + if (d1 <= 0 && d2 <= 0) return a; + const bp = sub(p, b); + const d3 = dot(ab, bp); + const d4 = dot(ac, bp); + if (d3 >= 0 && d4 <= d3) return b; + const vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) { + const v = d1 / (d1 - d3); + return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v]; + } + const cp = sub(p, c); + const d5 = dot(ab, cp); + const d6 = dot(ac, cp); + if (d6 >= 0 && d5 <= d6) return c; + const vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) { + const w = d2 / (d2 - d6); + return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w]; + } + const va = d3 * d6 - d5 * d4; + if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { + const w = (d4 - d3) / (d4 - d3 + (d5 - d6)); + return [ + b[0] + (c[0] - b[0]) * w, + b[1] + (c[1] - b[1]) * w, + b[2] + (c[2] - b[2]) * w, + ]; + } + const den = 1 / (va + vb + vc); + const v = vb * den; + const w = vc * den; + return [ + a[0] + ab[0] * v + ac[0] * w, + a[1] + ab[1] * v + ac[1] * w, + a[2] + ab[2] * v + ac[2] * w, + ]; +} + +/** How far a point lies from the nearest triangle of the mesh. */ +export function distanceToSurface(p, verts, tris) { + let best = Infinity; + for (const [i, j, k] of tris) { + const d = dist3(p, closestOnTriangle(p, verts[i], verts[j], verts[k])); + if (d < best) best = d; + } + return best; +} + +/** The segments where the mesh's triangles cross the z = 0 (centreline) plane. */ +export function centrelineSegments(verts, tris) { + const segments = []; + for (const [i, j, k] of tris) { + const t = [verts[i], verts[j], verts[k]]; + const points = []; + for (let e = 0; e < 3; e++) { + const a = t[e]; + const b = t[(e + 1) % 3]; + if (a[2] === 0) points.push(a); + if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) { + const s = a[2] / (a[2] - b[2]); + points.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]); + } + } + if (points.length >= 2) segments.push([points[0], points[1]]); + } + return segments; +} + +/** Closest point of the centreline segments to `target`, or null. */ +export function closestCentrelinePoint(target, segments) { + let best = null; + let bestD = Infinity; + for (const [a, b] of segments) { + const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const den = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2]; + let q = a; + if (den > 0) { + let s = + ((target[0] - a[0]) * ab[0] + + (target[1] - a[1]) * ab[1] + + (target[2] - a[2]) * ab[2]) / + den; + s = Math.max(0, Math.min(1, s)); + q = [a[0] + ab[0] * s, a[1] + ab[1] * s, a[2] + ab[2] * s]; + } + const d = dist3(target, q); + if (d < bestD) { + bestD = d; + best = q; + } + } + return best; +} + +/** + * The trail attachment point a model's construction gives: the point of its + * centreline profile nearest its aft-belly bounding-box corner, in scene + * space (raw glTF axes, metres). Null when the centreline crosses no triangle. + * @param {{verts: number[][], tris: number[][]}} mesh + * @returns {number[]|null} + */ +export function measuredTrailAnchor({ verts, tris }) { + const { min, max } = vertexBounds(verts); + return closestCentrelinePoint( + [max[0], min[1], 0], + centrelineSegments(verts, tris), + ); +} + +/** The repository's `public/` directory, where the fleet set is served from. */ +const PUBLIC_DIR = new URL('../../public/', import.meta.url); + +/** + * A `fetch` that serves the repository's `public/` files from disk, for + * loading the fleet model set in Node. + * @param {string} url Site path, e.g. `/models/fleet/catalog.json`. + * @returns {Promise<{ok: boolean, status: number, json: () => Promise, + * arrayBuffer: () => Promise}>} + */ +export async function fetchPublicFile(url) { + const file = new URL(String(url).replace(/^\//, ''), PUBLIC_DIR); + let body; + try { + body = fs.readFileSync(file); + } catch { + return { ok: false, status: 404 }; + } + return { + ok: true, + status: 200, + json: async () => JSON.parse(body.toString('utf8')), + arrayBuffer: async () => + body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + }; +} diff --git a/src/testSupport/fleetModelFakes.mjs b/src/testSupport/fleetModelFakes.mjs new file mode 100644 index 000000000..3a51e994a --- /dev/null +++ b/src/testSupport/fleetModelFakes.mjs @@ -0,0 +1,68 @@ +// Fleet models for a layer test (fleetModelInstances.js): the project's model +// set loaded from public/, a DISPLAY 3D toggle that is on, and Cesium's glTF +// loader replaced by one that answers at once with a ready model, recorded. +import * as Cesium from 'cesium'; +import { loadFleetModelSet } from '../data/fleetModels.js'; +import { fetchPublicFile } from './fleetGlb.mjs'; + +/** A model policy whose eligibility is read again on every frame. */ +export const FLEET_TEST_POLICY = Object.freeze({ + altitudeCeilingM: 800_000, + minimumPixelSize: 16, + cap: 4, + capAll: 8, + addM: 5_000, + addAllM: 20_000, + keepM: 6_000, + keepAllM: 24_000, + intervalMs: 0, +}); + +/** + * Load the fleet set (once a process, as the app does) and fake the loader + * for this test. Call it before the layer's harness, which replaces `fetch`. + * @param {import('node:test').TestContext} t + * @returns {Promise<{fleet: object, created: object[], + * drawn: (id: string) => object[]}>} `fleet` for the layer's services; + * `drawn(id)` the models drawn for the vehicle `id` now. + */ +export async function fleetModelFakes(t) { + const info = console.info; + console.info = () => {}; + try { + await loadFleetModelSet({ fetchImpl: fetchPublicFile }); + } finally { + console.info = info; + } + const created = []; + t.mock.method(Cesium.Model, 'fromGltfAsync', async (options) => { + const model = { + ...options, + ready: true, + show: false, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; + created.push(model); + return model; + }); + return { + fleet: { + policy: FLEET_TEST_POLICY, + display: () => ({ models3d: true, models3dMode: 'proximity' }), + resolveAsset: (url) => url, + }, + created, + drawn: (id) => + created.filter( + (model) => model.id === id && !model.destroyed && model.show, + ), + }; +} diff --git a/src/testSupport/railLayerHarness.mjs b/src/testSupport/railLayerHarness.mjs index 5f7a2ae62..ddfcba030 100644 --- a/src/testSupport/railLayerHarness.mjs +++ b/src/testSupport/railLayerHarness.mjs @@ -12,6 +12,24 @@ import { publicRailCatalog } from '../data/railFeeds.js'; export const RAIL_HARNESS_T0 = 1_789_000_000_000; export const BOSTON = Object.freeze({ lat: 42.3601, lon: -71.0589 }); +/** A camera's frame looking straight down from `altitude` over `at`. */ +function lookingDown(at, altitude) { + const position = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + position, + new Cesium.Cartesian3(), + ); + return { + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; +} + /** A train as the rail proxy sends it. */ export function railTrain(id, lat, lon, extra = {}) { return { @@ -64,7 +82,7 @@ const headersOf = (values = {}) => ({ * catalog?: object[]|Promise|Function|false, * ground?: object, * stops?: object|null, scheduleStops?: boolean, - * paths?: Record|null}} [options] + * paths?: Record|null, fleet?: object|null}} [options] * `ground`: a floor service in place of the always-cold stub; `catalog`: * the server's feed catalog (it may list fewer feeds than are bundled; a * promise answers when it settles, a function is called per request and @@ -74,7 +92,9 @@ const headersOf = (values = {}) => ({ * layer's own provider, its schedule tier, in its place; * `paths`: feed id → the path pack the proxy serves, or a * failure status it answers with (404 for any other feed; without it the - * source has no path route at all). + * source has no path route at all); `fleet`: the fleet-model services + * (testSupport/fleetModelFakes.mjs), with a camera that looks straight + * down for them (none by default: trains stay icons). */ export function railLayerHarness( t, @@ -86,6 +106,7 @@ export function railLayerHarness( stops = null, scheduleStops = false, paths = null, + fleet = null, } = {}, ) { const T0 = RAIL_HARNESS_T0; @@ -179,6 +200,8 @@ export function railLayerHarness( cancelFlight() { this.flightsCancelled += 1; }, + // What the fleet models' eligibility reads: a camera looking down. + ...(fleet ? lookingDown(at, altitude) : {}), }, scene: { canvas: { addEventListener() {}, removeEventListener() {} }, @@ -308,6 +331,7 @@ export function railLayerHarness( cachedGroundFloor: () => null, warmGroundFloor() {}, }, + ...(fleet ? { fleet } : {}), camera: { framing: WORLD_FOCUS_FRAMING.train, requestWorldFocus(detail) { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b6d77b912..1fd86824f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -400,6 +400,9 @@ export class StyleManager extends ShellFacade { owner: 'cockpit-orbit', switchPolicy: 'esri-if-photoreal', }) ?? null, + // The application's own first-person subjects + // (src/cockpitSubjectProviders.js), checked by the coordinator. + cockpitSubjects: services.cockpitSubjects, }, elements: { _ppToggles: this._ppToggles, @@ -1197,6 +1200,11 @@ export class StyleManager extends ShellFacade { toggleEnabled: (...args) => this._toggleCctvEnabled(...args), runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + // Clear selected data layers and Context isolation switch the layer + // off without the user asking: the panel keeps its state for both. + preservePanelState: () => + this._preservePanelStateDuringLayerClear || + this._contextSuspendsLayer('cctv'), showToast: (message) => this._showToast(message), syncViewport: () => this._syncCctvPanelViewport(), }, diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 4e8911695..1e227c92e 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -54,6 +54,7 @@ export class CctvControls { this._cctvSummaryTypingTimer = null; this._lastCctvSummaryText = ''; this._lastSeenCctvActiveId = null; + this._wasCctvEnabled = false; this._cctvChipHideTimer = null; this._cctvChipWasBusy = false; // The camera grid chip as last published; its label stays while hidden. diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index a3dbef786..bc9c83c9b 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -513,3 +513,96 @@ test('the panel meta line reads a bearing that rounds to north as HDG 0°', () = assert.match(meta(359.4), /· HDG 359° ·/); assert.match(meta(359.6), /· HDG 0° ·/); }); + +/** The panel collapses the test asked for, as `[panelId, collapsed]` pairs. */ +function collapseCalls(controls) { + const calls = []; + controls.actions.setPanelCollapsed = (panelId, collapsed) => { + calls.push([panelId, collapsed]); + // The panel chrome owns the class the controls read back. + controls._cctvPanel.classList.toggle('collapsed', collapsed); + }; + return calls; +} + +const activeState = { + enabled: true, + count: 1, + cameraIndex: [], + activeCameraId: camera.id, + activeCamera: camera, +}; + +test('switching the layer off collapses the panel it opened', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls._renderCctvState(activeState); + assert.deepEqual(calls, [['cctv-panel', false]]); + // The layer keeps its active camera while off. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); + // A panel reopened by hand stays open through later notifications while + // the layer is off: only the on → off change collapses. + controls._cctvPanel.classList.remove('collapsed'); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.equal(calls.length, 2); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); +}); + +test('a layer that was never on leaves an open panel alone', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] }); + controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] }); + assert.deepEqual(calls, []); +}); + +test('the data manager switching the layer off collapses the panel too', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + let on = true; + controls.actions.isEnabled = () => on; + controls._renderCctvState(activeState); + on = false; + controls._renderCctvState(activeState); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); +}); + +test('a layer the application suspends keeps its panel through the round trip', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + // Clear selected data layers and Context isolation both answer true. + let suspended = true; + controls.actions.preservePanelState = () => suspended; + controls._renderCctvState(activeState); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + suspended = false; + controls._renderCctvState(activeState); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + assert.ok(calls.every(([, collapsed]) => collapsed === false)); + // The next switch-off is the user's again. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); +}); + +test('a panel the accordion folded still has its saved state closed', (t) => { + const { controls } = panel(t); + controls._renderCctvState(activeState); + const calls = []; + controls.actions.setPanelCollapsed = (...args) => calls.push(args); + // Folded for layout only: the class is there, the saved state is open. The + // panel chrome tells the two apart, so the controls always ask it. + controls._cctvPanel.classList.add('collapsed'); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls, [['cctv-panel', true]]); +}); + +test('the meta line of a disabled layer does not describe its last camera', () => { + const off = { ...activeState, enabled: false, count: 3 }; + assert.equal( + presentCctvPanel(off, false).meta, + '3 cameras loaded · enable CCTV to activate', + ); + assert.match(presentCctvPanel(activeState, true).meta, /^Austin · HDG /); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 7eab9bc2d..2f4cb8584 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -90,7 +90,9 @@ export function presentCctvPanel(state, enabled) { } function metaText(state, enabled, activeCamera, catalogCount) { - if (activeCamera) { + // The layer keeps its active camera while off: a disabled panel must not + // describe a feed it no longer shows. + if (enabled && activeCamera) { const provider = cctvSourceLabelText( activeCamera.sourceLabel || activeCamera.provider || 'Configured Source', ); @@ -188,6 +190,20 @@ export function _renderCctvState(state) { }); } this._lastSeenCctvActiveId = effectiveActiveId; + // Switching the layer off closes the panel it opened: nothing is left to + // show. Only the on → off change collapses, so a panel the user opens + // while the layer is off stays open. Clear selected data layers and + // Context isolation keep the panel: the layer comes back with it. The + // panel chrome decides whether anything changes: a panel the desktop + // accordion only folded still has an open saved state to close. + if ( + this._wasCctvEnabled && + !enabled && + !this.actions.preservePanelState?.() + ) { + this.actions.setPanelCollapsed('cctv-panel', true); + } + this._wasCctvEnabled = enabled; this._updateCctvSyncChip(state?.loading, enabled); diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index e2046d72f..85678ad1f 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -164,7 +164,7 @@ export function maybeRefreshRegionalBrief(info) { !Number.isFinite(info.longitude) ) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`; + const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' || info.generic === true ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`; if (subjectId !== this.regionalBriefSubjectId) { this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index e97b7f232..ed66369b9 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -3,6 +3,8 @@ import * as Cesium from 'cesium'; import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, + easeCockpitLift, + cockpitSubjectStepSec, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, slewHeading, @@ -16,6 +18,7 @@ import { waterlineProbePoints, } from '../cockpitSubjects.js'; import { easeLook } from '../cockpitLook.js'; +import { cockpitSubjectFrame } from '../cockpitSubjectProviders.js'; import { COCKPIT_HEADING_SLEW_DPS, COCKPIT_FORWARD_OFFSET_M, @@ -53,6 +56,10 @@ export function update() { } const nowMs = performance.now(); + if (this.subjectProvider) { + updateSubject.call(this, nowMs); + return; + } if (this.subjectKind === 'vessel') { updateVessel.call(this, nowMs); return; @@ -297,9 +304,128 @@ function updateVessel(nowMs) { ); } +/** + * Drive the camera from an application provider's subject: read its frame, + * stand the eye `eye.upM` above `heightM` (the shared floor where the frame + * gives none) and slew toward its heading. A frame that is null, malformed or + * for another subject ends the view, as an aircraft tracked meanwhile does. + * The update runs on the provider's `cameraIntervalMs` (0: every frame), and + * its rates advance by the time that interval spans (cockpitSubjectStepSec). + */ +function updateSubject(nowMs) { + const provider = this.subjectProvider; + if (this.viewer.trackedEntity?.position) { + this.exit({ restoreTracking: false }); + return; + } + const intervalMs = provider.cameraIntervalMs ?? COCKPIT_CAMERA_UPDATE_MS; + if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, intervalMs)) return; + this.lastCameraUpdateMs = nowMs; + const frame = cockpitSubjectFrame( + this.callSubject(provider, 'read', [nowMs, this.subjectContext()], null), + this.subjectId, + ); + if (!frame) { + this.exit({ restoreTracking: false }); + return; + } + this.lastSubjectFrame = frame; + let heightM = frame.heightM; + if (heightM === null) { + this.services.warmGroundFloor?.([ + { lat: frame.latitude, lon: frame.longitude }, + ]); + const floorM = this.services.cachedGroundFloor?.( + frame.latitude, + frame.longitude, + ); + // A frame with no height stands on the shared floor; while that is cold + // it keeps the height it last stood at. + heightM = Number.isFinite(floorM) ? floorM : this.subjectHeightM; + } + if (!Number.isFinite(heightM)) { + // Nothing to stand on yet, from the first frame: the camera stays where + // it is while the floor is acquired, its HUD saying so, rather than + // standing on the ellipsoid (kilometres underground on a plateau). The + // wait is bounded as the built-in views' is. Unlike a parked aircraft, + // whose fallback is its own reported altitude, such a subject has no + // height at all to fall back to: any number would be a guess, so the + // view ends and the provider gets its subject back. + if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; + if ( + cockpitSurfaceWaitExpired( + nowMs, + this.surfaceWaitStartedMs, + COCKPIT_GROUND_WAIT_TIMEOUT_MS, + ) + ) { + this.exit({ restoreTracking: false }); + return; + } + this.surfaceAcquiring = true; + if ( + cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS) + ) { + this.lastHudUpdateMs = nowMs; + this.updateHud(this.subjectInfo(frame), nowMs); + } + return; + } + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.subjectHeightM = heightM; + const target = Cesium.Cartesian3.fromDegrees( + frame.longitude, + frame.latitude, + heightM, + Cesium.Ellipsoid.WGS84, + this.scratchTarget, + ); + const dtSec = cockpitSubjectStepSec(nowMs - this.lastFrameMs, intervalMs); + this.lastFrameMs = nowMs; + if (frame.headingDeg !== null) { + const rate = provider.headingSlewDps ?? COCKPIT_HEADING_SLEW_DPS; + this.heading = + rate === Infinity + ? frame.headingDeg + : slewHeading( + this.heading ?? frame.headingDeg, + frame.headingDeg, + rate * dtSec, + ); + } + let look = null; + if (provider.look) { + this.lookView = easeLook( + this.lookView, + this.lookTarget, + nowMs - (this.lookLastMs || nowMs), + ); + this.lookLastMs = nowMs; + look = this.lookView; + } + placeCamera.call( + this, + target, + this.subjectInfo(frame), + nowMs, + dtSec, + { + forwardM: frame.eye.forwardM, + upM: frame.eye.upM, + pitchDeg: frame.eye.pitchDeg, + minClearanceM: frame.eye.minClearanceM, + }, + look, + ); +} + /** * Place the camera for one update: advance the inertial anchor, clamp it and * the eye against the rendered surface, and look along the slewed heading. + * A subject with `anchor: 'exact'` keeps the anchor on its target (its layer + * already moves it smoothly), and one with `groundClamp: false` (inside a + * structure, say) is never pushed up to the floor: a lift it had eases out. * @param {Cesium.Cartesian3} target Authoritative subject position. * @param {object} info Subject info; `motionCourse` (when present) is the * direction the subject moves, otherwise it moves where it faces. @@ -316,6 +442,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; + this.subjectLiftM = null; } // First-person motion cannot use the delayed feed correction as a raw @@ -339,7 +466,8 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { ? Math.max(0, info.velocityMps) : 0; - if (info.stale) { + const groundClamp = info.groundClamp !== false; + if (info.stale || info.anchor === 'exact') { // A feed backoff has no authoritative velocity epoch to advance from. // Hold the cockpit on the exact layer-rendered position so the camera // cannot coast away while the icon correctly remains fixed. @@ -377,10 +505,13 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { const correctionDistanceM = Cesium.Cartesian3.magnitude( this.scratchCorrection, ); + // An application subject's step is already bounded by its own update + // interval (cockpitSubjectStepSec); the built-in views keep the 0.1 s. const correctionStepM = cockpitAnchorCorrectionStep( correctionDistanceM, speedMps, dtSec, + info.generic === true ? dtSec : undefined, ); if (correctionStepM > 0 && correctionDistanceM > 0) { Cesium.Cartesian3.multiplyByScalar( @@ -498,7 +629,14 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M; } - if (anchorCartographic && Number.isFinite(cockpitFloorM)) { + // An application subject's anchor is where its provider puts it; only its + // eye is kept out of the floor, below. + if ( + groundClamp && + info.generic !== true && + anchorCartographic && + Number.isFinite(cockpitFloorM) + ) { const minimumAnchorHeightM = cockpitGroundSafeHeight( anchorCartographic.height, cockpitFloorM, @@ -579,7 +717,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, ); - if (cameraCartographic) { + if (groundClamp && cameraCartographic) { const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon); @@ -589,11 +727,26 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { cameraFloorM, ); } - const safeHeightM = cockpitGroundSafeHeight( + let safeHeightM = cockpitGroundSafeHeight( cameraCartographic.height, cockpitFloorM, profile.minClearanceM, ); + // An application subject's eye rides its provider's smoothed height: + // the clearance over the coarse, refining floor eases in and out + // rather than jumping at each cell (easeCockpitLift). + if (info.generic === true) { + const hardM = + cockpitGroundSafeHeight(cameraCartographic.height, cockpitFloorM, 0) - + cameraCartographic.height; + this.subjectLiftM = easeCockpitLift( + this.subjectLiftM, + safeHeightM - cameraCartographic.height, + hardM, + dtSec, + ); + safeHeightM = cameraCartographic.height + this.subjectLiftM; + } if (safeHeightM !== cameraCartographic.height) { cameraCartographic.height = safeHeightM; Cesium.Ellipsoid.WGS84.cartographicToCartesian( @@ -601,6 +754,17 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { this.scratchCamera, ); } + } else if (info.generic === true) { + // Inside a structure the floor holds the eye up no more: the lift it gave + // goes at the rate it came (easeCockpitLift), never at once. + this.subjectLiftM = easeCockpitLift(this.subjectLiftM, 0, 0, dtSec); + if (cameraCartographic && this.subjectLiftM > 0) { + cameraCartographic.height += this.subjectLiftM; + Cesium.Ellipsoid.WGS84.cartographicToCartesian( + cameraCartographic, + this.scratchCamera, + ); + } } this.viewer.camera.setView({ diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index f7f236dea..34ba0a341 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -3,12 +3,18 @@ import { readAircraftInfo, readSatelliteInfo, readVesselInfo, + readSubjectCandidate, readSubjectLayerId, + subjectContext, + subjectInfo, + entryCandidates, + resolveEntry, resolveEntryKind, dispatchCockpitModeChanged, toggleTrackedTr3b, syncTr3bToggle, syncEntry, + refreshSubjectWords, publishPresenceView, navigateContext, _adoptTrackedEntity, @@ -100,6 +106,7 @@ import { import { DEFAULT_RADAR_RANGE_NM, nextRadarRange } from '../cockpitRadar.js'; import { VESSEL_BRIDGE_PITCH_DEG } from '../cockpitSubjects.js'; import { stepLook } from '../cockpitLook.js'; +import { createCockpitSubjectCaller } from '../cockpitSubjectProviders.js'; import * as Cesium from 'cesium'; /** Whether the user asked for reduced motion: look turns then jump. */ @@ -120,6 +127,8 @@ export class CockpitViewController { isEntryAllowed = null, isBridgeEntryAllowed = null, isOrbitEntryAllowed = null, + subjectProviders = [], + isSubjectEntryAllowed = null, isUtilityDisclosureOpen = null, onEntered = null, onExited = null, @@ -134,10 +143,27 @@ export class CockpitViewController { this.disposed = false; this.active = false; /** - * 'aircraft', 'vessel' or 'satellite' while active; which subject the - * camera rides. + * 'aircraft', 'vessel', 'satellite' or a provider's kind while active; + * which subject the camera rides. */ this.subjectKind = null; + /** + * The application's subject providers (cockpitSubjectProviders.js), + * checked by the coordinator; the one ridden while active, else null. + */ + this.subjectProviders = Array.isArray(subjectProviders) + ? subjectProviders + : []; + this.subjectProvider = null; + /** The ridden provider subject's last frame and normalized HUD view. */ + this.lastSubjectFrame = null; + this.subjectHud = null; + /** The Live Signals rows the provider's HUD view pushed. */ + this.subjectSignalKeys = new Set(); + /** Provider methods run through one guard: a throw warns once. */ + this.callSubject = createCockpitSubjectCaller(); + /** The look's rest pitch for a provider's view; null on a bridge. */ + this.lookRestPitchDeg = null; /** * The vessel subject's id or the satellite's NORAD id; aircraft are * identified by trackedEntity. @@ -160,6 +186,10 @@ export class CockpitViewController { this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; + /** The height an application subject's frame last stood at (m). */ + this.subjectHeightM = null; + /** The ground clamp's eased lift under that subject's eye (m). */ + this.subjectLiftM = null; /** * Where the bridge view is turned to (`lookTarget`) and where the camera * looks on its way there (`lookView`): yaw off the bow, starboard @@ -203,6 +233,11 @@ export class CockpitViewController { typeof isOrbitEntryAllowed === 'function' ? isOrbitEntryAllowed : () => false; + // Nor a provider's subject without a gate for it. + this.isSubjectEntryAllowed = + typeof isSubjectEntryAllowed === 'function' + ? isSubjectEntryAllowed + : () => false; this.isUtilityDisclosureOpen = typeof isUtilityDisclosureOpen === 'function' ? isUtilityDisclosureOpen @@ -246,7 +281,14 @@ export class CockpitViewController { entryAvailable: false, // Which subject the entry button would enter, and the active subject. entryKind: null, + // A provider's entry face, `{icon, label}`; null for the built-ins. + entryFace: null, subjectKind: null, + // While a provider's subject is ridden: its HUD and exit labels, and + // `{aheadLabel}` when it looks around. + subjectGeneric: false, + subjectLabels: null, + subjectLook: null, tr3b: null, context: false, // The Contact and briefing cards' collapse, which their toggles show. @@ -508,6 +550,21 @@ export class CockpitViewController { if (reset && hadFocus) this.mapViewButton?.focus({ preventScroll: true }); return reset; } + readSubjectCandidate(provider) { + return readSubjectCandidate.call(this, provider); + } + subjectContext() { + return subjectContext.call(this); + } + subjectInfo(frame) { + return subjectInfo.call(this, frame); + } + entryCandidates(nowMs) { + return entryCandidates.call(this, nowMs); + } + resolveEntry(nowMs) { + return resolveEntry.call(this, nowMs); + } readSubjectLayerId() { return readSubjectLayerId.call(this); } @@ -529,6 +586,9 @@ export class CockpitViewController { syncEntry() { return syncEntry.call(this); } + refreshSubjectWords() { + return refreshSubjectWords.call(this); + } navigateContext(direction, options) { return navigateContext.call(this, direction, options); } @@ -568,6 +628,19 @@ export class CockpitViewController { presentLook() { return presentLook.call(this); } + /** Whether the active view looks around: a bridge, or a provider's with `look`. */ + get looksAround() { + return ( + this.active && + (this.subjectKind === 'vessel' || Boolean(this.subjectProvider?.look)) + ); + } + /** Where the look rests: the bridge's pitch, or the provider's. */ + get lookRestPitch() { + return Number.isFinite(this.lookRestPitchDeg) + ? this.lookRestPitchDeg + : VESSEL_BRIDGE_PITCH_DEG; + } /** * Turn the bridge view one arrow step (see `lookKeyStep`). The camera * eases there unless the user prefers reduced motion. @@ -575,7 +648,7 @@ export class CockpitViewController { * @returns {boolean} True when a bridge view was turned. */ turnLook(step) { - if (!this.active || this.subjectKind !== 'vessel' || !step) return false; + if (!this.looksAround || !step) return false; this.lookTarget = stepLook(this.lookTarget, step); if (prefersReducedMotion()) this.lookView = { ...this.lookTarget }; this.presentLook(); @@ -588,7 +661,7 @@ export class CockpitViewController { * as entry and exit do. */ resetLook({ immediate = false } = {}) { - this.lookTarget = { yawDeg: 0, pitchDeg: VESSEL_BRIDGE_PITCH_DEG }; + this.lookTarget = { yawDeg: 0, pitchDeg: this.lookRestPitch }; if (immediate || prefersReducedMotion()) this.lookView = { ...this.lookTarget }; this.lookLastMs = performance.now(); @@ -601,7 +674,7 @@ export class CockpitViewController { * @returns {boolean} True on a bridge. */ lookAhead() { - if (!this.active || this.subjectKind !== 'vessel') return false; + if (!this.looksAround) return false; const button = document.getElementById('cockpit-look-ahead'); const hadFocus = !!button && button.contains(document.activeElement); this.resetLook(); @@ -718,6 +791,9 @@ export class CockpitViewController { this.relocalizeBrief(); this.publishVision(createVisionState(this.visionMode)); this.routeSignature = null; + // A provider's words, ridden or on the entry, follow the language. + this.refreshSubjectWords(); + if (!this.active) this.syncEntry(); if (this.active && this.lastSubjectInfo) { this.rewordingSignals = true; try { diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index c431a2ef5..baf6cc862 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -5,7 +5,9 @@ import { bridgeEntryAllowed, cockpitEntryAllowed, orbitEntryAllowed, + subjectEntryAllowed, } from '../contextModePolicy.js'; +import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; import { enterCockpitWithTracking } from '../cockpitTracking.js'; import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js'; @@ -60,8 +62,11 @@ export class CockpitCoordinator { regionalDistanceM, weatherCodeLabel, acquireOrbitImagery, + cockpitSubjects, } = services; this._cockpitDisplayPortal = null; + // The application's own subjects (services.cockpitSubjects), checked once. + const subjectProviders = cockpitSubjectProviders(cockpitSubjects ?? []); this.cockpitView = new CockpitViewController(viewer, { services: { flightsLayer, @@ -108,6 +113,21 @@ export class CockpitCoordinator { vesselsEnabled: !!this._dataManager?.isEnabled('ais-live-vessels'), contextModeChanging: this._contextModeChanging, }), + subjectProviders, + // A provider's subject needs its own layer on, no Context change in + // flight, and the provider's own say. + isSubjectEntryAllowed: (provider, candidate) => + subjectEntryAllowed({ + layerEnabled: this._isLayerEnabled(provider.layerId), + contextModeChanging: this._contextModeChanging, + providerAllows: + this.cockpitView?.callSubject( + provider, + 'isEntryAllowed', + [candidate], + false, + ) === true, + }), isOrbitEntryAllowed: () => orbitEntryAllowed({ satellitesEnabled: !!this._dataManager?.isEnabled('satellites'), @@ -197,6 +217,9 @@ export class CockpitCoordinator { this.cockpitView.subjectKind === 'satellite' ) return 'Cockpit is in an orbital view — exit it first, then track an aircraft to enter Cockpit'; + // And in an application's own first-person view. + if (this.cockpitView?.active && this.cockpitView.subjectProvider) + return 'Cockpit is in another first-person view — exit it first, then track an aircraft to enter Cockpit'; if (!this._aircraftLayerEnabled) return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit'; return null; @@ -216,6 +239,17 @@ export class CockpitCoordinator { active && this.cockpitView?.subjectKind === 'satellite' ? this.cockpitView.readSatelliteInfo?.() : null; + // And a provider's subject in use. + const provided = + active && this.cockpitView?.subjectProvider + ? { + kind: this.cockpitView.subjectProvider.kind, + id: this.cockpitView.subjectId, + layerId: this.cockpitView.subjectProvider.layerId, + callsign: null, + name: this.cockpitView.lastSubjectFrame?.name ?? null, + } + : null; const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); // "Could Cockpit be ENTERED right now" — so it is false while already // inside, unconditionally. Cockpit takes the entity off @@ -244,30 +278,32 @@ export class CockpitCoordinator { ? 'subject-layer-off' : 'no-tracked-aircraft', visionMode: this.cockpitView?.visionMode || null, - subject: satellite - ? { - kind: 'satellite', - id: satellite.id, - layerId: satellite.layerId, - callsign: null, - name: satellite.name, - } - : vessel + subject: provided + ? provided + : satellite ? { - kind: 'vessel', - id: vessel.id, - layerId: vessel.layerId, + kind: 'satellite', + id: satellite.id, + layerId: satellite.layerId, callsign: null, - name: vessel.name, + name: satellite.name, } - : info + : vessel ? { - kind: 'aircraft', - id: info.icao24 || info.id || null, - layerId: info.layerId || null, - callsign: info.callsign || null, + kind: 'vessel', + id: vessel.id, + layerId: vessel.layerId, + callsign: null, + name: vessel.name, } - : null, + : info + ? { + kind: 'aircraft', + id: info.icao24 || info.id || null, + layerId: info.layerId || null, + callsign: info.callsign || null, + } + : null, navigation: snapshot ? { canPrevious: Boolean(snapshot.navigation?.canPrevious), diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index fd5dfb52b..8abebe9e9 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -55,8 +55,11 @@ function fixture( changing = false, dataManager: suppliedManager = null, readContext = null, + subjects = [], + vessel = null, } = {}, ) { + let selectedVessel = vessel; document.body.replaceChildren(); const host = document.body.appendChild(document.createElement('div')); const pinia = createPinia(); @@ -101,7 +104,13 @@ function fixture( services: { flightsLayer: { getTrackedInfo: trackedFrom('flights') }, militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') }, - aisLiveVesselsLayer: { getCockpitSubject: () => null }, + // Like the layer, no subject while Live AIS Vessels is off. + aisLiveVesselsLayer: { + getCockpitSubject: () => + selectedVessel && layers.has('ais-live-vessels') + ? { ...selectedVessel } + : null, + }, // Like the layer, no subject while Satellites is off. satellitesLayer: { getCockpitSubject: () => @@ -113,6 +122,7 @@ function fixture( isTr3b: () => false, holdContinuousRender() {}, releaseContinuousRender() {}, + cockpitSubjects: subjects, }, elements: {}, operations: { @@ -147,6 +157,7 @@ function fixture( 'cockpit-mode', 'cockpit-bridge', 'cockpit-orbit', + 'cockpit-subject', ); }); /** Turn a layer off and tell the Context owner, as the data manager does. */ @@ -171,10 +182,35 @@ function fixture( viewer.trackedEntity = entityFor(subject); entities.add(viewer.trackedEntity); }; + /** Select a vessel on the map, or none. */ + const selectVessel = (info) => { + selectedVessel = info; + }; const $ = (selector) => document.querySelector(selector); - return { $, coordinator, cockpit, store, layers, disable, retrack, pinia }; + return { + $, + coordinator, + cockpit, + store, + layers, + disable, + retrack, + selectVessel, + pinia, + }; } +/** A selected vessel as the AIS layer gives it to the bridge view. */ +const VESSEL = Object.freeze({ + id: '211000001', + name: 'EXAMPLE', + latitude: 54, + longitude: 10, + headingDeg: 90, + courseDeg: 90, + speedMps: 5, +}); + test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => { const { coordinator, cockpit } = fixture(t, { enabled: ['flights'], @@ -567,6 +603,32 @@ test('the readout entry reads BRIDGE for a selected vessel', async (t) => { assert.match(readoutEntry.textContent, /BRIDGE/); }); +test('without providers the built-in order decides: a vessel selected after an aircraft still reads COCKPIT', async (t) => { + const { $, cockpit, store, selectVessel } = fixture(t, { + enabled: ['flights', 'ais-live-vessels'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'aircraft'); + await new Promise((resolve) => setTimeout(resolve, 2)); + selectVessel(VESSEL); + cockpit.syncEntry(); + await nextTick(); + assert.equal(store.presence.entryKind, 'aircraft'); + assert.match($('#cockpit-entry-readout').textContent, /COCKPIT/); + // And the other way round: an aircraft tracked after the vessel. + const later = fixture(t, { + enabled: ['flights', 'ais-live-vessels'], + vessel: VESSEL, + }); + later.cockpit.syncEntry(); + assert.equal(later.store.presence.entryKind, 'vessel'); + await new Promise((resolve) => setTimeout(resolve, 2)); + later.retrack({ layerId: 'flights', info: CIVIL }); + later.cockpit.syncEntry(); + assert.equal(later.store.presence.entryKind, 'aircraft'); +}); + test('a tracked satellite needs Satellites on for the ORBIT entry', async (t) => { const { $, cockpit } = fixture(t, { satellite: SATELLITE }); cockpit.syncEntry(); @@ -648,3 +710,148 @@ test('turning Satellites off ends the orbital view', (t) => { disable('satellites'); assert.equal(cockpit.active, false); }); + +/** An application provider whose subject sits on `example-layer`. */ +function exampleSubject() { + const released = []; + return { + released, + provider: { + kind: 'example-kind', + layerId: 'example-layer', + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { view: 'Example view', exit: 'Exit example', exitText: 'EXIT' }, + candidate: () => ({ id: 'a1', name: 'Example 12' }), + hold: () => true, + read: () => ({ + id: 'a1', + name: 'Example 12', + latitude: 30, + longitude: -97, + heightM: 200, + headingDeg: 90, + eye: { upM: 2 }, + }), + hud: () => ({ title: 'Example 12' }), + release: (id, { restore }) => released.push([id, restore]), + }, + }; +} + +test("a provider's subject needs only its own layer, and never a Context change in flight", async (t) => { + const { provider } = exampleSubject(); + let changing = false; + const off = fixture(t, { subjects: [provider] }); + off.cockpit.syncEntry(); + await nextTick(); + assert.equal(off.$('#cockpit-entry').hidden, true, 'its layer is off'); + assert.equal(off.cockpit.enter(), false); + const on = fixture(t, { + enabled: ['example-layer'], + subjects: [provider], + readContext: () => ({ mode: null, changing }), + }); + changing = true; + on.cockpit.syncEntry(); + await nextTick(); + assert.equal(on.$('#cockpit-entry').hidden, true, 'Context is changing'); + changing = false; + on.cockpit.syncEntry(); + await nextTick(); + assert.equal(on.$('#cockpit-entry').hidden, false); + assert.match(on.$('#cockpit-entry').textContent, /RIDE/); + assert.match(on.$('#cockpit-entry-readout').textContent, /commute/); +}); + +test("a provider's view reports its subject, refuses voice entry and ends with its layer", async (t) => { + const { provider, released } = exampleSubject(); + const { cockpit, coordinator, disable } = fixture(t, { + enabled: ['example-layer', 'flights'], + subjects: [provider], + }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const state = coordinator.getCockpitState(); + assert.equal(state.active, true); + assert.deepEqual(state.subject, { + kind: 'example-kind', + id: 'a1', + layerId: 'example-layer', + callsign: null, + name: 'Example 12', + }); + assert.equal(coordinator.controlCockpit('enter').ok, false); + assert.match(coordinator._cockpitEntryRefusal(), /another first-person view/); + disable('flights'); + assert.equal(cockpit.active, true, 'another layer is free to go off'); + disable('example-layer'); + assert.equal(cockpit.active, false, 'its own layer going off ends it'); + assert.deepEqual(released, [['a1', false]]); +}); + +test("a provider's guards: a throwing candidate, gate or release, and a gate that is not true", async (t) => { + const warned = []; + t.mock.method(console, 'warn', (message) => warned.push(String(message))); + const entered = (provider) => { + const { cockpit } = fixture(t, { + enabled: ['example-layer'], + subjects: [provider], + }); + cockpit.syncEntry(); + return { cockpit, entered: cockpit.enter() }; + }; + const { provider: base } = exampleSubject(); + // A candidate() that throws offers nothing. + const noCandidate = entered({ + ...base, + candidate: () => { + throw new Error('candidate failed'); + }, + }); + assert.equal(noCandidate.entered, false); + assert.equal(noCandidate.cockpit.resolveEntry(), null); + // An isEntryAllowed() that throws, or answers anything but true, shuts + // the gate. + for (const isEntryAllowed of [ + () => { + throw new Error('gate failed'); + }, + () => 1, + () => 'yes', + () => ({}), + ]) { + const gated = entered({ ...base, isEntryAllowed }); + assert.equal(gated.entered, false, String(isEntryAllowed)); + assert.equal(gated.cockpit.active, false); + } + // A release() that throws still ends the view. + const { provider } = exampleSubject(); + const releasing = entered({ + ...provider, + release: () => { + throw new Error('release failed'); + }, + }); + assert.equal(releasing.entered, true); + assert.equal(releasing.cockpit.exit(), true); + assert.equal(releasing.cockpit.active, false); + assert.equal(document.body.classList.contains('cockpit-subject'), false); + for (const method of ['candidate', 'isEntryAllowed', 'release']) + assert.ok( + warned.some((line) => line.includes(`${method}() failed`)), + method, + ); +}); + +test('a malformed provider is left out and the built-in kinds are unchanged', async (t) => { + t.mock.method(console, 'warn', () => {}); + const { cockpit } = fixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + subjects: [{ kind: 'aircraft', layerId: 'flights' }], + }); + assert.deepEqual(cockpit.subjectProviders, []); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + assert.equal(cockpit.subjectKind, 'aircraft'); +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 4ce5d5b63..9574068ce 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -41,6 +41,9 @@ function subjectWorldPosition(kind) { } if (kind === 'satellite') return this.services.satellitesLayer?.getCockpitPosition?.() ?? null; + // An application's subject has no position before its view is entered: + // the selected vessel's sprite must not stand in for it. + if (kind !== 'vessel') return null; const info = this.readVesselInfo(); if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) return null; @@ -189,7 +192,7 @@ const LOOK_KEY_OWNERS = [ * @returns {boolean} True when the key was taken. */ function handleLookKey(event) { - if (!this.active || this.subjectKind !== 'vessel') return false; + if (!this.looksAround) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; const step = lookKeyStep(event.key, event.shiftKey); const reset = !event.repeat && (event.key === 'Home' || event.key === '0'); diff --git a/src/ui/cockpitInstrumentState.js b/src/ui/cockpitInstrumentState.js index 0ba28d89a..ccc6a5b89 100644 --- a/src/ui/cockpitInstrumentState.js +++ b/src/ui/cockpitInstrumentState.js @@ -52,7 +52,7 @@ function createRimState(value) { */ export function createInstrumentState() { return { - // 'aircraft' or 'vessel': which readouts the HUD labels and shows. + // 'aircraft', 'vessel' or 'subject': which readouts the HUD shows. kind: 'aircraft', layer: null, callsign: t('AIRCRAFT'), @@ -76,6 +76,19 @@ export function createInstrumentState() { look: { offset: false, yawDeg: 0, readout: '' }, clock: '--:--:--Z', position: '--° · --°', + // A provider subject's HUD (kind 'subject', cockpitSubjectProviders.js): + // its kicker, speed unit and tag, readouts, sources, banner, veil and + // progress line, all worded by the provider. + kicker: '', + speedUnit: '', + speedTag: '', + speedTone: 'normal', + readouts: [], + sources: [], + banner: null, + veil: null, + progress: null, + lookEnabled: false, }; } @@ -86,8 +99,10 @@ export function createInstrumentState() { */ export function createRouteState() { return { - // A vessel's card shows only the free-text AIS destination. + // A vessel's card shows only the free-text AIS destination; a provider's + // subject's (kind 'subject') carries its own title. kind: 'aircraft', + title: '', visible: false, from: t('UNKNOWN'), to: t('UNKNOWN'), diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index c5e9863cd..62cb0e6ec 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -35,6 +35,7 @@ import { vesselCameraProfile, } from '../cockpitSubjects.js'; import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js'; +import { cockpitSubjectHudView } from '../cockpitSubjectProviders.js'; import { RADAR_MAX_CONTACTS, radarPicture, @@ -90,6 +91,13 @@ export function publishInstrumentsView() { compass: view.compass.map((division) => ({ ...division })), compassMarkers: { ...view.compassMarkers }, look: { ...view.look }, + // A provider's view model is rebuilt on each HUD update, never edited + // in place, so it is published as it stands. + readouts: view.readouts, + sources: view.sources, + banner: view.banner, + veil: view.veil, + progress: view.progress, }); } @@ -118,24 +126,162 @@ function wordLook({ side, bearingDeg, elevation, angleDeg }) { */ export function presentLook() { const target = this.lookTarget; + // A provider's view words its own readout (its HUD view's `lookReadout`). + const provider = Boolean(this.subjectProvider); + const rest = provider ? this.lookRestPitch : VESSEL_BRIDGE_PITCH_DEG; const offset = - this.active && - this.subjectKind === 'vessel' && - lookIsOffset(target, VESSEL_BRIDGE_PITCH_DEG); + (this.active && this.subjectKind === 'vessel') || + (provider && this.looksAround) + ? lookIsOffset(target, rest) + : false; this.instruments.look = offset ? { offset: true, yawDeg: wrapLookYaw(target.yawDeg), - readout: wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)), + readout: provider + ? (this.subjectHud?.lookReadout ?? '') + : wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)), } : { offset: false, yawDeg: 0, readout: '' }; } +/** Live Signals' tone for a provider's signal row. */ +const SUBJECT_SIGNAL_TONES = Object.freeze({ + warning: 'warning', + alert: 'warning', +}); + +/** + * Present a provider subject's HUD: its view model (cockpitSubjectHudView) + * in the generic readouts, the compass at the camera's heading, the route + * card, and its signals in Live Signals. A HUD view that is null, a `hud()` + * that throws, or a view whose reading throws keeps the last one. Contact + * navigation stays with aircraft, so the Contact card is withdrawn. + */ +function updateSubjectHud(info, nowMs, forceContext) { + const provider = this.subjectProvider; + this.lastSubjectInfo = info; + const frame = this.lastSubjectFrame; + const look = { + yawDeg: this.lookTarget?.yawDeg ?? 0, + pitchDeg: this.lookTarget?.pitchDeg ?? 0, + offset: + this.looksAround && lookIsOffset(this.lookTarget, this.lookRestPitch), + }; + const raw = frame + ? this.callSubject( + provider, + 'hud', + [frame, Object.freeze({ ...this.subjectContext(), look })], + null, + ) + : null; + if (raw != null) + this.subjectHud = cockpitSubjectHudView(raw) ?? this.subjectHud; + const hud = this.subjectHud ?? cockpitSubjectHudView(null); + const view = this.instruments; + view.kind = 'subject'; + view.trackSource = null; + view.kicker = hud.kicker || provider.labels.kicker; + view.callsign = hud.title || info.name || ''; + // While the floor under a subject with no height is acquired + // (cockpitCamera.js), the meta line says so in place of the provider's. + const acquiring = this.surfaceAcquiring === true; + view.meta.text = acquiring ? t('ACQUIRING SURFACE') : hud.meta.text; + view.meta.tone = acquiring ? 'warning' : hud.meta.tone; + view.meta.emergency = !acquiring && hud.meta.tone === 'alert'; + const speed = hud.speed?.value ?? null; + view.speed = hud.speed + ? { + text: speed === null ? '---' : formatNumber(Math.round(speed)), + value: speed === null ? null : Math.round(speed), + immediate: forceContext, + } + : null; + view.speedUnit = hud.speed?.unit ?? ''; + view.speedTag = hud.speed?.tag ?? ''; + view.speedTone = hud.speed?.tone ?? 'normal'; + view.speedRim.unavailable = true; + view.altitude = null; + view.altitudeRim.unavailable = true; + view.course = null; + const heading = normalizeHeading(this.heading ?? info.track ?? 0); + view.heading = { + text: formatHeadingReadout(heading), + value: heading, + immediate: forceContext, + }; + const viewHeading = normalizeHeading( + heading + (provider.look ? (this.lookView?.yawDeg ?? 0) : 0), + ); + view.compass = scrollingCompassDivisions(viewHeading).map((division) => ({ + slot: division.slot, + heading: division.heading, + active: division.nearest, + label: compassPointLabel(formatCompassDivision(division.heading)), + })); + view.compassMarkers = { + prograde: compassMarkerSlot(heading, viewHeading), + retrograde: compassMarkerSlot(heading + 180, viewHeading), + }; + view.clock = new Date().toISOString().slice(11, 19) + 'Z'; + view.position = `${formatCockpitCoordinate(info.latitude, 'N', 'S')} · ${formatCockpitCoordinate(info.longitude, 'E', 'W')}`; + view.readouts = hud.readouts; + view.sources = hud.sources; + view.banner = hud.banner; + view.veil = hud.veil; + view.progress = hud.progress; + view.lookEnabled = Boolean(provider.look); + view.layer = provider.layerId; + this.presentLook(); + this.publishInstrumentsView(); + const route = this.routeView; + route.kind = 'subject'; + route.title = hud.route?.title ?? ''; + route.from = hud.route?.from ?? ''; + route.to = hud.route?.to ?? ''; + route.status = hud.route?.status ?? ''; + route.visible = Boolean(hud.route); + // No destination arrow; clearing also publishes the card. + this.clearPredictiveRoute(); + if ( + forceContext || + cockpitUiUpdateDue( + nowMs, + this.lastContextUpdateMs, + COCKPIT_CONTEXT_UPDATE_MS, + ) + ) { + this.lastContextUpdateMs = nowMs; + this.updateLocalPosition(info); + this.maybeRefreshRegionalBrief(info); + this.showContextCard(false); + const keys = new Set(); + for (const signal of hud.signals) { + const key = `subject:${signal.id}`; + keys.add(key); + this.pushCockpitSignal( + key, + SUBJECT_SIGNAL_TONES[signal.tone] ?? 'info', + signal.title, + signal.detail || signal.title, + ); + } + for (const key of this.subjectSignalKeys ?? []) + if (!keys.has(key)) this.dropCockpitSignal(key); + this.subjectSignalKeys = keys; + } +} + export function updateHud( info, nowMs = performance.now(), forceContext = false, ) { + if (this.subjectProvider && info?.generic === true) { + updateSubjectHud.call(this, info, nowMs, forceContext); + return; + } if (info?.kind === 'vessel') { updateVesselHud.call(this, info, nowMs, forceContext); return; diff --git a/src/ui/cockpitOrbitalView.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index 72130a9e8..26dad341b 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -994,7 +994,7 @@ test('a place that lands after the exit is dropped', async (t) => { assert.equal(store.orbital, null, 'no HUD comes back for a stale answer'); }); -test('a tracked aircraft takes entry before a tracked satellite', async (t) => { +test('an aircraft and a satellite first seen in one sync: the aircraft takes entry, by the fixed order', async (t) => { const aircraftInfo = { icao24: 'abc123', callsign: 'TEST1', @@ -1007,6 +1007,9 @@ test('a tracked aircraft takes entry before a tracked satellite', async (t) => { }; const { cockpit, viewer, aircraft, store } = fixture(t, { aircraftInfo }); viewer.trackedEntity = aircraft; + // The satellite and the aircraft are both new to this sync; the fixed order + // decides between subjects first seen together. Selected one after the + // other, the later one takes entry (cockpitEntryGate.test.mjs). cockpit.syncEntry(); assert.equal(store.presence.entryKind, 'aircraft'); }); diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 344c6f799..99727c7be 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -184,11 +184,15 @@ const COCKPIT_ENTRY_FACES = Object.freeze({ }); /** - * The entry's glyph and localized label for the subject it would ride. - * @param {'aircraft'|'vessel'|'satellite'|null|undefined} kind + * The entry's glyph and localized label for the subject it would ride. An + * application provider's subject brings its own face, already in the page's + * language (`presence.entryFace`). + * @param {string|null|undefined} kind + * @param {{icon: string, label: string}|null} [face] * @returns {{icon: string, label: string}} */ -export function cockpitEntryFace(kind) { - const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; - return { icon: face.icon, label: t(face.label) }; +export function cockpitEntryFace(kind, face = null) { + if (face?.icon && face?.label) return { icon: face.icon, label: face.label }; + const known = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; + return { icon: known.icon, label: t(known.label) }; } diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs new file mode 100644 index 000000000..e8549442f --- /dev/null +++ b/src/ui/cockpitSubjectView.test.mjs @@ -0,0 +1,1182 @@ +// src/ui/cockpitSubjectView.test.mjs — an application provider's subject in +// the Cockpit: its entry face, the hold and release around the view, the +// frame that places the camera (eye height, ground clamp, exact anchor), the +// generic HUD it words, the most recent selection winning entry, and the +// ways the view ends. Over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import * as Cesium from 'cesium'; +import { CockpitViewController } from './cockpitController.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import CockpitView from './views/CockpitView.vue'; +import ContextView from './views/ContextView.vue'; +import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js'; +import { getLocale, setLocale } from '../i18n/index.js'; +import { COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js'; +import { COCKPIT_SUBJECT_LIFT_MPS } from '../cockpitMath.js'; +import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js'; + +const LAT = 47; +const LON = 8; +const FLOOR_M = 400; + +/** A HUD view model as a provider words it. */ +const HUD = Object.freeze({ + kicker: 'LINE 12 · OPERATOR', + title: 'Example 12 → Terminus', + meta: { text: 'LIVE TRACK · EYE 3.5 M', tone: 'live' }, + speed: { value: 84, unit: 'km/h', tag: 'LIVE', tone: 'live' }, + readouts: [ + { + id: 'next', + label: 'NEXT STOP', + value: 'Central', + detail: '14:32 · T−4 · +2 MIN', + tone: 'normal', + }, + ], + sources: [{ id: 'track', label: 'TRACK', text: 'operator shape' }], + route: { title: 'LINE', from: 'Origin', to: 'Terminus', status: '9 stops' }, + banner: { title: 'IN STRUCTURE', detail: '2.4 KM · OUT IN 1:30' }, + veil: { level: 0.8, streaks: true }, + progress: { + ticks: [ + { at: 0, label: { text: 'Origin', align: 'start', priority: 1 } }, + { at: 0.5 }, + { at: 1, label: { text: 'Terminus', align: 'end', priority: 1 } }, + ], + marker: { at: 0.25, dwell: false }, + tag: 'TIMETABLE', + summary: 'Route progress: Origin to Terminus', + }, + lookReadout: 'LOOK RIGHT 45°', + signals: [{ id: 'note', title: 'NOTE', detail: 'a provider row' }], +}); + +/** A scriptable provider; `calls` records what the Cockpit asked of it. */ +function subjectProvider(overrides = {}) { + const calls = []; + const subject = { + candidate: { id: 'a1', name: 'Example 12' }, + frame: { + id: 'a1', + name: 'Example 12', + latitude: LAT, + longitude: LON, + heightM: FLOOR_M, + headingDeg: 90, + speedMps: 20, + groundClamp: true, + anchor: 'exact', + eye: { upM: 3.5, forwardM: 0, pitchDeg: -3, minClearanceM: 1.5 }, + }, + hud: HUD, + allowed: true, + hold: true, + }; + const definition = { + kind: 'example-kind', + layerId: 'example-layer', + priority: 1, + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { + view: 'Example ride view', + exit: 'Exit example view', + exitText: 'EXIT RIDE', + kicker: 'RIDE', + }, + look: { restPitchDeg: -3, aheadLabel: 'Look ahead along the line' }, + cameraIntervalMs: 0, + headingSlewDps: Infinity, + candidate: () => subject.candidate, + isEntryAllowed: (candidate) => { + calls.push(['allowed', candidate.id]); + return subject.allowed; + }, + hold: (id) => { + calls.push(['hold', id]); + return subject.hold; + }, + read: (nowMs, ctx) => { + calls.push(['read', typeof ctx.groundHeightAt]); + if (subject.frame instanceof Error) throw subject.frame; + return typeof subject.frame === 'function' + ? subject.frame(ctx) + : subject.frame; + }, + hud: (frame, ctx) => { + calls.push(['hud', frame.id, ctx.look.offset]); + if (subject.hud instanceof Error) throw subject.hud; + return subject.hud; + }, + release: (id, options) => calls.push(['release', id, options.restore]), + ...overrides, + }; + return { definition, subject, calls }; +} + +function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp( + defineComponent(() => () => [h(ContextView), h(CockpitView)]), + ).use(pinia); + app.mount(host); + const store = useCockpitStore(pinia); + const listeners = () => ({ addEventListener: () => () => {} }); + const shots = []; + const aircraftPosition = Cesium.Cartesian3.fromDegrees(LON, LAT, 3000); + const aircraft = { + position: { + getValue: (time, result) => + Cesium.Cartesian3.clone(aircraftPosition, result), + }, + show: true, + gevTrackedId: 'flights:abc123', + }; + const viewer = { + trackedEntity: undefined, + clock: { currentTime: null }, + camera: { + heading: 0, + cancelFlight() {}, + setView({ destination, orientation }) { + shots.push({ + destination: Cesium.Cartesian3.clone(destination), + direction: Cesium.Cartesian3.clone(orientation.direction), + }); + }, + }, + entities: { contains: (candidate) => candidate === aircraft }, + scene: { + preUpdate: listeners(), + screenSpaceCameraController: { enableInputs: true }, + }, + trackedEntityChanged: listeners(), + }; + let vesselSelected = vessel; + let trackedAircraftInfo = null; + let floorM = FLOOR_M; + const warned = []; + t.mock.method(console, 'warn', (message) => warned.push(String(message))); + const subjectProviders = cockpitSubjectProviders( + providers.map((entry) => entry.definition), + ); + const signals = []; + const dropped = []; + const cockpit = new CockpitViewController(viewer, { + services: { + flightsLayer: { getTrackedInfo: () => trackedAircraftInfo }, + militaryFlightsLayer: { getTrackedInfo: () => null }, + aisLiveVesselsLayer: { + getCockpitSubject: () => + vesselSelected + ? { ...vesselCockpitInfo(vesselSelected), seaSurfaceHeightM: 0 } + : null, + setCockpitSubject: () => true, + }, + militaryAwarenessLayer: {}, + isTr3b: () => false, + holdContinuousRender() {}, + releaseContinuousRender() {}, + cachedGroundFloor: () => floorM, + warmGroundFloor() {}, + GROUND_FLOOR_LIFT_M: 1.5, + }, + isBridgeEntryAllowed: () => true, + subjectProviders, + isSubjectEntryAllowed: (provider, candidate) => + layerOn && provider.isEntryAllowed(candidate) === true, + publish: { + presence: (view) => store.publishPresence(view), + instruments: (view) => store.publishInstruments(view), + route: (view) => store.publishRoute(view), + radar: (view) => store.publishRadar(view), + }, + }); + Object.assign(cockpit, { + setVisionMode() {}, + showBriefPage() {}, + startBriefRotation() {}, + stopBriefRotation() {}, + pushCockpitSignal(key, tone, title, detail) { + signals.push({ key, tone, title, detail }); + }, + dropCockpitSignal(key) { + dropped.push(key); + }, + scheduleContextLayout() {}, + updateLocalPosition() {}, + maybeRefreshRegionalBrief() {}, + showContextCard() {}, + }); + t.after(() => { + cockpit.stop(); + app.unmount(); + document.body.classList.remove( + 'cockpit-mode', + 'cockpit-subject', + 'cockpit-bridge', + ); + }); + const $ = (selector) => document.querySelector(selector); + return { + $, + cockpit, + viewer, + aircraft, + store, + shots, + warned, + signals, + dropped, + selectVessel(record) { + vesselSelected = record; + }, + /** Track the fixture's aircraft as Live Flights does, or untrack it. */ + trackAircraft(info) { + trackedAircraftInfo = info; + viewer.trackedEntity = info ? aircraft : undefined; + }, + setFloor(value) { + floorM = value; + }, + }; +} + +function manualClock(t, startMs) { + const originalNow = performance.now; + const originalDateNow = Date.now; + let nowMs = startMs; + performance.now = () => nowMs; + Date.now = () => nowMs; + t.after(() => { + performance.now = originalNow; + Date.now = originalDateNow; + }); + return { + advance(ms) { + nowMs += ms; + }, + }; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); +} + +/** The provider's last release call. */ +const lastRelease = (calls) => calls.findLast(([name]) => name === 'release'); + +/** The camera's height above the ellipsoid in its last shot (m). */ +const shotHeight = (shots) => + Cesium.Cartographic.fromCartesian(shots.at(-1).destination).height; + +const VESSEL = Object.freeze({ + id: '211000000', + mmsi: '211000000', + name: 'NORDIC STAR', + type: '70', + lat: 54.5, + lon: 10.2, + speed: 12, + course: 95, + heading: 90, + missedRefreshes: 0, +}); + +/** The fixture's aircraft as Live Flights reports it once tracked. */ +const TRACKED_AIRCRAFT = Object.freeze({ + icao24: 'abc123', + callsign: 'UAL123', + track: 92, + latitude: LAT, + longitude: LON, +}); + +test('a provider candidate offers its own entry face; a shut gate hides it', async (t) => { + const ride = subjectProvider(); + const { $, cockpit, store } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + await nextTick(); + const entry = $('#cockpit-entry'); + assert.equal(entry.hidden, false); + assert.match(entry.textContent, /commute/); + assert.match(entry.textContent, /RIDE/); + assert.equal(store.presence.entryKind, 'example-kind'); + // A candidate may bring its own face. + ride.subject.candidate = { + id: 'a2', + name: 'Tram 4', + face: { icon: 'tram', label: 'BOARD' }, + }; + cockpit.syncEntry(); + await nextTick(); + assert.match(entry.textContent, /tram/); + assert.match(entry.textContent, /BOARD/); + ride.subject.allowed = false; + cockpit.syncEntry(); + await nextTick(); + assert.equal(entry.hidden, true, "the provider's own rule shuts entry"); + assert.equal(cockpit.enter(), false); + ride.subject.allowed = true; + ride.subject.candidate = null; + cockpit.syncEntry(); + await nextTick(); + assert.equal(entry.hidden, true, 'no candidate, no entry'); +}); + +test('entry holds the subject, takes the camera and shows the generic HUD', async (t) => { + manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit, shots, store, signals } = fixture(t, { + providers: [ride], + }); + cockpit.syncEntry(); + await nextTick(); + $('#cockpit-entry').click(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal(cockpit.subjectKind, 'example-kind'); + assert.deepEqual( + ride.calls.filter(([name]) => name === 'hold'), + [['hold', 'a1']], + ); + assert.ok(document.body.classList.contains('cockpit-subject')); + assert.equal(store.presence.subjectGeneric, true); + const hud = $('#cockpit-hud'); + assert.equal(hud.getAttribute('data-subject'), 'example-kind'); + assert.ok(hud.hasAttribute('data-generic')); + assert.equal(hud.getAttribute('aria-label'), 'Example ride view'); + assert.equal( + $('#map-view-switch').getAttribute('aria-label'), + 'Exit example view', + ); + assert.match($('#map-view-switch').textContent, /EXIT RIDE/); + // The provider's words, not the aircraft's. + assert.equal($('.cockpit-kicker').textContent, HUD.kicker); + assert.equal($('#cockpit-callsign').textContent, HUD.title); + assert.equal($('#cockpit-aircraft-meta').getAttribute('data-tone'), 'live'); + assert.match($('.cockpit-speed').textContent, /84/); + assert.match($('.cockpit-speed').textContent, /km\/h/); + assert.match($('.cockpit-speed').textContent, /LIVE/); + assert.equal($('.cockpit-scan-array'), null); + assert.equal($('#cockpit-altitude-rim'), null); + assert.equal($('#cockpit-speed-rim'), null); + assert.equal($('.cockpit-roll-arc'), null); + assert.equal($('.cockpit-reticle'), null); + const card = $('[data-readout="next"]'); + assert.match(card.textContent, /NEXT STOP/); + assert.match(card.textContent, /Central/); + assert.match($('[data-source="track"]').textContent, /operator shape/); + assert.match($('.cockpit-banner').textContent, /IN STRUCTURE/); + const veil = $('.cockpit-veil'); + assert.equal(hud.firstElementChild, veil, 'the veil lies under the HUD'); + assert.ok(veil.classList.contains('streaks')); + assert.equal(veil.style.opacity, '0.8'); + const progress = $('.cockpit-progress'); + assert.equal( + progress.getAttribute('aria-label'), + 'Route progress: Origin to Terminus', + ); + assert.equal(progress.querySelectorAll('.cockpit-progress-tick').length, 3); + assert.match(progress.textContent, /Origin/); + assert.match(progress.textContent, /TIMETABLE/); + assert.ok(hud.hasAttribute('data-progress')); + assert.match($('.cockpit-help').textContent, /ARROWS LOOK/); + // The route card is titled by the provider, not as a flight plan. + const route = $('#cockpit-route'); + assert.equal(route.hidden, false); + assert.equal($('.cockpit-route-kicker', route).textContent, 'LINE'); + assert.equal(route.getAttribute('aria-label'), 'LINE'); + // TRACK ACQUIRED names the subject; the HUD view's rows reach Live Signals. + assert.deepEqual(signals[0], { + key: 'track', + tone: 'track', + title: 'TRACK ACQUIRED', + detail: 'Example 12', + }); + assert.ok( + signals.some( + (row) => row.key === 'subject:note' && row.detail === 'a provider row', + ), + ); + assert.ok(shots.length >= 1 || cockpit.lastSubjectFrame); +}); + +test('a HUD view with no speed shows no speed readout; one with no value shows it unknown', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.hud = { ...HUD, speed: undefined }; + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + await settle(); + assert.ok($('.cockpit-speed') === null, 'no speed readout'); + assert.ok($('#cockpit-speed-value') === null, 'no speed value'); + assert.ok($('#cockpit-heading-value') !== null, 'the heading stays'); + ride.subject.hud = { ...HUD, speed: { value: null, unit: 'km/h' } }; + clock.advance(1000); + cockpit.update(); + await settle(); + assert.match($('.cockpit-speed').textContent, /---/); + assert.match($('.cockpit-speed').textContent, /km\/h/); +}); + +test('a progress line is an image named by its summary, else hidden from assistive technology', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.hud = { ...HUD, progress: { ...HUD.progress, summary: '' } }; + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + await settle(); + let progress = $('.cockpit-progress'); + assert.equal(progress.hasAttribute('role'), false); + assert.equal(progress.hasAttribute('aria-label'), false); + assert.equal(progress.getAttribute('aria-hidden'), 'true'); + ride.subject.hud = HUD; + clock.advance(1000); + cockpit.update(); + await settle(); + progress = $('.cockpit-progress'); + assert.equal(progress.getAttribute('role'), 'img'); + assert.equal(progress.getAttribute('aria-label'), HUD.progress.summary); + assert.equal(progress.hasAttribute('aria-hidden'), false); +}); + +test('the eye stands its height above the frame, and the ground clamp can be lifted', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + // Every frame updates (cameraIntervalMs 0). + const before = shots.length; + clock.advance(16); + cockpit.update(); + assert.equal(shots.length, before + 1); + // Inside a structure the frame stands below the rendered floor: clamped, + // the eye is pushed out of the floor at once and its clearance eases in; + // a view entered with groundClamp false stays inside (a clamp lifted + // mid-ride lets the eye down gradually, below). + setFloor(FLOOR_M + 200); + clock.advance(16); + cockpit.update(); + const lifted = shotHeight(shots); + assert.ok(lifted >= FLOOR_M + 200 - 0.01, 'out of the floor at once'); + assert.ok(lifted < FLOOR_M + 200 + 1.5 - 0.01, 'the clearance eases in'); + for (let i = 0; i < 80; i += 1) { + clock.advance(16); + cockpit.update(); + } + assert.ok(shotHeight(shots) >= FLOOR_M + 200 + 1.5 - 0.01, 'clamped'); + ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; + cockpit.exit(); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + assert.ok( + Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01, + 'not clamped', + ); + // No height in the frame: the eye stands on the shared floor. + setFloor(FLOOR_M); + ride.subject.frame = { ...ride.subject.frame, heightM: null }; + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); +}); + +test('a ground clamp lifted mid-ride lets the eye down at the rate it came up', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + // The floor rises to the eye: its clearance eases in. + setFloor(FLOOR_M + 3.5); + for (let i = 0; i < 80; i += 1) { + clock.advance(16); + cockpit.update(); + } + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 5)) < 0.01, 'clamped'); + // Into a structure: the lift goes at the same rate, not at once. + ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; + clock.advance(100); + cockpit.update(); + assert.ok( + Math.abs( + shotHeight(shots) - (FLOOR_M + 5 - COCKPIT_SUBJECT_LIFT_MPS * 0.1), + ) < 0.01, + 'eased out', + ); + for (let i = 0; i < 12; i += 1) { + clock.advance(100); + cockpit.update(); + } + assert.ok( + Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01, + 'inside the structure', + ); +}); + +test('a subject updated every 500 ms turns, eases and closes on its target at its rates', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider({ cameraIntervalMs: 500, headingSlewDps: 10 }); + ride.subject.frame = { + ...ride.subject.frame, + anchor: 'inertial', + speedMps: 0, + }; + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(500); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + // One 500 ms update: the subject turns, the floor rises to the eye and the + // frame moves 100 m east of the anchor. Each rate advances by the 0.5 s, + // five times the 0.1 s a frame's step is capped at. + ride.subject.frame = { + ...ride.subject.frame, + headingDeg: 180, + longitude: LON + 100 / (111_320 * Math.cos(Cesium.Math.toRadians(LAT))), + }; + setFloor(FLOOR_M + 3.5); + clock.advance(500); + cockpit.update(); + assert.ok(Math.abs(cockpit.heading - 95) < 1e-6, 'heading 10°/s × 0.5 s'); + assert.ok( + Math.abs( + shotHeight(shots) - (FLOOR_M + 3.5 + COCKPIT_SUBJECT_LIFT_MPS * 0.5), + ) < 0.01, + 'lift 1.5 m/s × 0.5 s', + ); + const ground = (shot) => { + const at = Cesium.Cartographic.fromCartesian(shot.destination); + return Cesium.Cartesian3.fromRadians(at.longitude, at.latitude, 0); + }; + const closedM = Cesium.Cartesian3.distance( + ground(shots.at(-1)), + ground(shots.at(-2)), + ); + // A standing subject's anchor closes at the correction's 0.75 m/s. + assert.ok(Math.abs(closedM - 0.75 * 0.5) < 0.01, 'anchor 0.75 m/s × 0.5 s'); + // A long stall (a hidden tab) moves it as one late update would. + clock.advance(60_000); + cockpit.update(); + assert.ok(Math.abs(cockpit.heading - 101) < 1e-6, 'at most 0.6 s'); +}); + +test('an exact anchor sits on the subject, wherever the frame moves it', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + for (const step of [1, 2, 3]) { + const longitude = LON + step * 0.001; + ride.subject.frame = { ...ride.subject.frame, longitude }; + clock.advance(16); + cockpit.update(); + const at = Cesium.Cartographic.fromCartesian(shots.at(-1).destination); + assert.ok( + Math.abs(Cesium.Math.toDegrees(at.longitude) - longitude) < 1e-7, + `step ${step}`, + ); + } +}); + +test('the view ends when the frame stops coming, and gives the subject back', async (t) => { + const clock = manualClock(t, 10_000); + for (const ending of [ + null, + { latitude: LAT, longitude: LON, id: 'other', eye: { upM: 2 } }, + // A frame whose getter throws runs provider code: as a malformed frame. + Object.defineProperty( + { id: 'a1', longitude: LON, eye: { upM: 2 } }, + 'latitude', + { + get() { + throw new Error('latitude unavailable'); + }, + }, + ), + ]) { + const ride = subjectProvider(); + const { cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + ride.subject.frame = ending; + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(document.body.classList.contains('cockpit-subject'), false); + } +}); + +test('leaving by the exit control asks the provider to restore its subject', async (t) => { + const ride = subjectProvider(); + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + $('#map-view-switch').click(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', true]); +}); + +test('entry fails when the gate is shut, the hold fails or no first frame comes, releasing only what it held', async (t) => { + const shut = subjectProvider(); + shut.subject.allowed = false; + let app = fixture(t, { providers: [shut] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.equal( + shut.calls.some(([name]) => name === 'hold' || name === 'release'), + false, + 'a shut gate neither holds nor releases', + ); + const blank = subjectProvider(); + blank.subject.frame = null; + app = fixture(t, { providers: [blank] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.deepEqual(lastRelease(blank.calls), ['release', 'a1', false]); + assert.equal(app.cockpit.active, false); + // A hold that throws may have half happened: it is released. + const thrown = subjectProvider({ + hold: (id) => { + thrown.calls.push(['hold', id]); + throw new Error('hold failed'); + }, + }); + app = fixture(t, { providers: [thrown] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.deepEqual(lastRelease(thrown.calls), ['release', 'a1', false]); + assert.equal(app.warned.length, 1); + // So is one that answers with a promise: provider methods are synchronous. + const later = subjectProvider({ + hold: async () => true, + }); + app = fixture(t, { providers: [later] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.deepEqual(lastRelease(later.calls), ['release', 'a1', false]); + assert.match(app.warned[0], /returned a promise/); +}); + +test('a hold that answers false refuses entry; any answer but true or false warns once; each is released', async (t) => { + for (const [answer, warns] of [ + [false, false], + [undefined, true], + [1, true], + ['held', true], + ]) { + const ride = subjectProvider(); + ride.subject.hold = answer; + const { cockpit, warned } = fixture(t, { providers: [ride] }); + const holdWarnings = () => + warned.filter((message) => + /example-kind hold\(\) must return true or false/.test(message), + ).length; + cockpit.syncEntry(); + assert.equal(cockpit.enter(), false, `hold answered ${String(answer)}`); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(holdWarnings(), warns ? 1 : 0); + assert.equal(warned.length, warns ? 1 : 0, 'no other warning'); + // Once per provider and method. + assert.equal(cockpit.enter(), false); + assert.equal(holdWarnings(), warns ? 1 : 0); + assert.equal( + ride.calls.filter(([name]) => name === 'release').length, + 2, + 'released each time', + ); + } +}); + +test('a class-based provider keeps its `this` from entry to release', async (t) => { + manualClock(t, 10_000); + const { frame } = subjectProvider().subject; + class Ride { + #word = 'RIDE'; + #held = null; + calls = []; + kind = 'example-kind'; + layerId = 'example-layer'; + entryFace() { + return { icon: 'commute', label: this.#word }; + } + get labels() { + return { + view: `${this.#word} view`, + exit: 'Exit ride view', + exitText: `EXIT ${this.#word}`, + }; + } + look() { + return { aheadLabel: `Look ahead, ${this.#word}` }; + } + candidate() { + return { id: 'a1', name: `Example ${this.#word}` }; + } + isEntryAllowed(candidate) { + return this.#held === null && candidate.id === 'a1'; + } + hold(id) { + this.#held = id; + this.calls.push(['hold', id]); + return true; + } + read() { + this.calls.push(['read', this.#held]); + return this.#held ? { ...frame, id: this.#held } : null; + } + hud(view) { + this.calls.push(['hud', view.id]); + return { ...HUD, title: `${this.#word} ${view.id}` }; + } + release(id, { restore }) { + this.calls.push(['release', id, restore]); + this.#held = null; + } + } + const ride = new Ride(); + const { $, cockpit, warned } = fixture(t, { + providers: [{ definition: ride }], + }); + cockpit.syncEntry(); + await nextTick(); + assert.match($('#cockpit-entry').textContent, /RIDE/); + $('#cockpit-entry').click(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, 'RIDE a1'); + assert.equal($('#cockpit-hud').getAttribute('aria-label'), 'RIDE view'); + assert.match($('#map-view-switch').textContent, /EXIT RIDE/); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Look ahead, RIDE', + ); + assert.ok(ride.calls.some(([name, id]) => name === 'read' && id === 'a1')); + assert.ok(ride.calls.some(([name, id]) => name === 'hud' && id === 'a1')); + $('#map-view-switch').click(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', true]); + assert.deepEqual(warned, []); +}); + +test('a frame with no height and a cold floor never stands the eye on the ellipsoid', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.frame = { ...ride.subject.frame, heightM: null }; + const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + // Nothing to stand on from the first frame: the camera waits where it is, + // its HUD saying so and still read from the provider. + setFloor(undefined); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const before = shots.length; + const huds = () => ride.calls.filter(([name]) => name === 'hud').length; + const hudsBefore = huds(); + for (let i = 0; i < 5; i += 1) { + clock.advance(200); + cockpit.update(); + } + await settle(); + assert.equal(shots.length, before, 'no camera placed'); + assert.equal(cockpit.active, true, 'the view waits'); + assert.ok(huds() > hudsBefore, 'the HUD is not frozen'); + assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE'); + assert.equal( + $('#cockpit-aircraft-meta').getAttribute('data-tone'), + 'warning', + ); + // The floor arrives: the eye stands on it. + setFloor(FLOOR_M); + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + // It goes cold again: the eye keeps the height it stood at, never 0 m, + // with the ground clamp off too. + setFloor(undefined); + ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; + for (let i = 0; i < 5; i += 1) { + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + } + // A height out of any plausible range reads as none. + ride.subject.frame = { ...ride.subject.frame, heightM: -6_400_000 }; + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); +}); + +test('a floor that never comes ends the view after the bounded wait, releasing the subject', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.frame = { ...ride.subject.frame, heightM: null }; + const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + setFloor(undefined); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const before = shots.length; + // Acquiring, the HUD updating, for just under the wait... + for (let elapsed = 0; elapsed < COCKPIT_GROUND_WAIT_TIMEOUT_MS - 250;) { + clock.advance(250); + elapsed += 250; + cockpit.update(); + } + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE'); + // ...then no guessed height: the view ends and the subject goes back. + clock.advance(500); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal(shots.length, before, 'never placed on a guess'); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); +}); + +test('an asynchronous hold that lands after entry was refused is released again', async (t) => { + let heldId = null; + const releases = []; + const ride = subjectProvider({ + hold: async (id) => { + await Promise.resolve(); + heldId = id; + return true; + }, + release: (id, options) => { + releases.push([id, options.restore]); + heldId = null; + }, + }); + const { cockpit, warned } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), false, 'refused at once'); + assert.deepEqual(releases, [['a1', false]]); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(heldId, null, 'the late hold is let go'); + assert.deepEqual(releases, [ + ['a1', false], + ['a1', false], + ]); + assert.equal(cockpit.active, false); + assert.match(warned[0], /hold\(\) returned a promise/); +}); + +test('a provider’s labels, entry face and look follow a change of language', async (t) => { + t.after(() => setLocale('en')); + setLocale('en'); + const clock = manualClock(t, 10_000); + const words = { + en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' }, + de: { + view: 'Fahrtansicht', + exit: 'Fahrtansicht verlassen', + exitText: 'VERLASSEN', + }, + }; + const german = () => getLocale().startsWith('de'); + const ride = subjectProvider({ + labels: () => words[german() ? 'de' : 'en'], + entryFace: () => ({ icon: 'commute', label: german() ? 'FAHRT' : 'RIDE' }), + look: () => ({ + restPitchDeg: -3, + aheadLabel: german() ? 'Nach vorn sehen' : 'Look ahead', + }), + }); + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + await nextTick(); + assert.match($('#cockpit-entry').textContent, /RIDE/); + // On the entry, before the view. + setLocale('de'); + await settle(); + assert.match($('#cockpit-entry').textContent, /FAHRT/); + setLocale('en'); + await settle(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + await settle(); + assert.match($('#map-view-switch').textContent, /EXIT RIDE/); + // While riding. + setLocale('de'); + await settle(); + assert.match($('#map-view-switch').textContent, /VERLASSEN/); + assert.equal( + $('#map-view-switch').getAttribute('aria-label'), + 'Fahrtansicht verlassen', + ); + assert.equal($('#cockpit-hud').getAttribute('aria-label'), 'Fahrtansicht'); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Nach vorn sehen', + ); +}); + +test('a read that throws ends the view once; a hud that throws keeps the last', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit, warned } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + ride.subject.hud = new Error('hud failed'); + clock.advance(200); + cockpit.update(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept'); + // So does a view whose getter throws when read. + ride.subject.hud = Object.defineProperty({ ...HUD }, 'title', { + get() { + throw new Error('title unavailable'); + }, + }); + clock.advance(200); + cockpit.update(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept'); + ride.subject.frame = new Error('read failed'); + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal( + warned.filter((message) => /example-kind read\(\) failed/.test(message)) + .length, + 1, + ); + assert.equal( + warned.filter((message) => /example-kind hud\(\) failed/.test(message)) + .length, + 1, + ); +}); + +test('an aircraft tracked during the view ends it, never adopted', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, viewer, aircraft } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + viewer.trackedEntity = aircraft; + assert.equal(cockpit._adoptTrackedEntity(performance.now()), false); + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(cockpit.navigateContext(1), false); +}); + +test('the most recently selected subject wins entry', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, store, selectVessel } = fixture(t, { + providers: [ride], + vessel: VESSEL, + }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel'); + // A subject selected after the vessel takes the entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12' }; + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'example-kind'); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Another vessel selected later takes it back. + clock.advance(500); + selectVessel({ ...VESSEL, id: '211000099', mmsi: '211000099' }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel'); + // The same subject selected again comes back to the front. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'example-kind', 're-selected'); + clock.advance(500); + selectVessel({ ...VESSEL, id: '211000098', mmsi: '211000098' }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel'); + clock.advance(500); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel', 'an unchanged token'); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 3 }; + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'example-kind'); + // The newest decides alone: its shut gate leaves no entry. + clock.advance(500); + ride.subject.candidate = { id: 'a3', name: 'Example 3' }; + ride.subject.allowed = false; + cockpit.syncEntry(); + assert.equal(store.presence.entryAvailable, false); + assert.equal(cockpit.enter(), false); +}); + +test('the built-in kinds keep their order; a subject takes entry only when selected after all of them', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, selectVessel, trackAircraft } = fixture(t, { + providers: [ride], + }); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // A subject selected after the aircraft was tracked takes entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12' }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // A vessel selected after it: a built-in kind is newer, so the built-in + // order decides, as without providers, and the aircraft comes first. + clock.advance(500); + selectVessel(VESSEL); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // The subject selected again is newer than every built-in one. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // An aircraft tracked after it takes entry back. + selectVessel(null); + trackAircraft(null); + cockpit.syncEntry(); + clock.advance(500); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); +}); + +test('a new id under the same selectedAt is not a new selection', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 1 }; + const { cockpit, trackAircraft } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // An aircraft tracked after it takes entry. + clock.advance(500); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // The selection moves to another copy of the same subject: same token. + clock.advance(500); + ride.subject.candidate = { id: 'b1', name: 'Example 12', selectedAt: 1 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // A new token is a new selection. + clock.advance(500); + ride.subject.candidate = { id: 'b1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Without a token, a new id is a new selection. + clock.advance(500); + trackAircraft(null); + cockpit.syncEntry(); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + clock.advance(500); + ride.subject.candidate = { id: 'c1', name: 'Example 3' }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); +}); + +test('a subject first seen together with a built-in one comes after it', async (t) => { + manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, store } = fixture(t, { providers: [ride], vessel: VESSEL }); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'vessel'); + assert.equal(store.presence.entryKind, 'vessel'); +}); + +test("a double-click on the selected vessel does not enter an application's subject", async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, store, viewer } = fixture(t, { + providers: [ride], + vessel: VESSEL, + }); + cockpit.syncEntry(); + // The application's subject is selected after the vessel: it takes entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 1 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Every world position lands under the pointer, the vessel's sprite too. + viewer.scene.cartesianToCanvasCoordinates = (_position, result = {}) => + Object.assign(result, { x: 400, y: 300 }); + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }), false); + await settle(); + assert.notEqual(store.presence.active, true); +}); + +test('a provider view with look turns with the arrow keys and words its readout', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + assert.equal(cockpit.lookTarget.pitchDeg, -3, 'rests at its own pitch'); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + clock.advance(200); + cockpit.update(); + await settle(); + assert.notEqual(cockpit.lookTarget.yawDeg, 0); + assert.ok(ride.calls.some(([name, , offset]) => name === 'hud' && offset)); + assert.equal($('.cockpit-look').hidden, false); + assert.equal($('#cockpit-look-readout').textContent, HUD.lookReadout); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Look ahead along the line', + ); + // Without `look` the keys leave the view alone. + const still = subjectProvider({ look: undefined }); + const other = fixture(t, { providers: [still] }); + other.cockpit.syncEntry(); + other.cockpit.enter(); + assert.equal( + other.cockpit.turnLook({ axis: 'yaw', sign: 1, stepDeg: 15 }), + false, + ); +}); + +test('rows a HUD view no longer lists leave Live Signals', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, dropped } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + ride.subject.hud = { ...HUD, signals: [] }; + clock.advance(1000); + cockpit.update(); + assert.ok(dropped.includes('subject:note')); +}); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 069441149..8327d5a07 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -5,6 +5,10 @@ import { normalizeHeading, resolveTrackedAircraftInfo, } from '../cockpitMath.js'; +import { + cockpitSubjectCandidate, + cockpitSubjectFrame, +} from '../cockpitSubjectProviders.js'; /** The camera's own heading in degrees, or 0 when it has none. */ function cameraHeadingDeg(camera) { @@ -33,15 +37,187 @@ export function readSatelliteInfo() { return this.services.satellitesLayer?.getCockpitSubject?.() || null; } +/** + * What an application subject provider would have entry ride now, or null. + * @param {object} provider A provider from cockpitSubjectProviders. + * @returns {{id: string, name: string, face: object|null}|null} + */ +export function readSubjectCandidate(provider) { + return cockpitSubjectCandidate( + this.callSubject(provider, 'candidate', [], null), + ); +} + +/** + * What a provider's `read` and `hud` receive besides the frame: the shared + * ground floor at a point (metres, or null), a way to warm its cells, and the + * floor service's lift. + */ +export function subjectContext() { + this._subjectContext ??= Object.freeze({ + groundHeightAt: (latitude, longitude) => { + const height = this.services.cachedGroundFloor?.(latitude, longitude); + return Number.isFinite(height) ? height : null; + }, + warmGround: (points) => { + this.services.warmGroundFloor?.(points); + }, + liftM: Number.isFinite(this.services.GROUND_FLOOR_LIFT_M) + ? this.services.GROUND_FLOOR_LIFT_M + : 0, + }); + return this._subjectContext; +} + +/** + * The ridden provider subject as the camera and HUD read it: the frame's + * place and motion, named like the built-in kinds' info. + * @param {object} frame From cockpitSubjectFrame. + */ +export function subjectInfo(frame) { + return { + kind: this.subjectProvider?.kind ?? null, + generic: true, + layerId: this.subjectProvider?.layerId ?? null, + id: frame.id, + name: frame.name, + latitude: frame.latitude, + longitude: frame.longitude, + track: frame.headingDeg, + motionCourse: frame.courseDeg, + velocityMps: frame.speedMps ?? 0, + stale: frame.stale, + groundClamp: frame.groundClamp, + anchor: frame.anchor, + onGround: false, + }; +} + +/** The built-in kinds' fixed entry order. */ +const BUILT_IN_ENTRY_ORDER = Object.freeze({ + aircraft: 0, + satellite: 1, + vessel: 2, +}); + +/** + * Every subject entry could ride now, in entry order: a tracked aircraft, + * the tracked satellite, the selected vessel and each provider's candidate. + * The built-in kinds keep their fixed order (aircraft, satellite, vessel), + * whenever each was selected. A provider's candidate goes ahead of them only + * when it was selected after every built-in one present: a train picked + * after an aircraft was tracked takes entry, an aircraft tracked after it + * takes it back. Providers' candidates are ordered most recently selected + * first, then by priority. A candidate's time is when this controller first + * saw it (it looks on its entry cadence and on tracking changes), or saw it + * selected again: another subject of a built-in kind, a provider candidate + * whose `selectedAt` changes, or one without a `selectedAt` whose id changes. + * A new id under the same `selectedAt` is the same selection, as when a + * train's selection moves to another feed's copy of it. One + * first seen together with a built-in candidate comes after it. With no + * provider, entry is the built-in order alone. Each carries `allowed()`, its + * kind's entry gate. + * @param {number} [nowMs] + * @returns {Array<{kind: string, id: string, seenAtMs: number, order: number, + * allowed: () => boolean, provider?: object, candidate?: object}>} + */ +export function entryCandidates(nowMs = performance.now()) { + const seen = (this._entrySeen ??= new Map()); + const list = []; + const note = (kind, id, entry, selectedAt = null) => { + const last = seen.get(kind); + // A provider's token says when its subject was selected: a new id under + // the same token (a train handed to another feed's copy of it) is not a + // new selection. Without a token, a new id is. + if ( + !last || + last.selectedAt !== selectedAt || + (selectedAt === null && last.id !== id) + ) + seen.set(kind, { id, selectedAt, atMs: nowMs }); + else if (last.id !== id) seen.set(kind, { ...last, id }); + list.push({ kind, id, seenAtMs: seen.get(kind).atMs, ...entry }); + }; + const aircraft = this.readAircraftInfo(); + if (aircraft && this.viewer.trackedEntity?.position) + note( + 'aircraft', + String( + this.viewer.trackedEntity.gevTrackedId || + aircraft.icao24 || + aircraft.id || + 'aircraft', + ), + { + order: BUILT_IN_ENTRY_ORDER.aircraft, + allowed: () => this.isEntryAllowed(), + }, + ); + const satellite = this.readSatelliteInfo(); + if (satellite) + note('satellite', String(satellite.noradId ?? satellite.id ?? ''), { + order: BUILT_IN_ENTRY_ORDER.satellite, + allowed: () => this.isOrbitEntryAllowed(), + }); + const vessel = this.readVesselInfo(); + if (vessel) + note('vessel', String(vessel.id ?? ''), { + order: BUILT_IN_ENTRY_ORDER.vessel, + allowed: () => this.isBridgeEntryAllowed(), + }); + this.subjectProviders.forEach((provider, index) => { + const candidate = this.readSubjectCandidate(provider); + if (candidate) + note( + provider.kind, + candidate.id, + { + order: 3 + index, + provider, + candidate, + allowed: () => this.isSubjectEntryAllowed(provider, candidate), + }, + candidate.selectedAt, + ); + }); + // A subject gone and back counts as selected again. + for (const kind of [...seen.keys()]) + if (!list.some((entry) => entry.kind === kind)) seen.delete(kind); + const builtIns = list + .filter((entry) => !entry.provider) + .sort((a, b) => a.order - b.order); + const provided = list + .filter((entry) => entry.provider) + .sort((a, b) => b.seenAtMs - a.seenAtMs || a.order - b.order); + const builtInSeenAtMs = Math.max( + -Infinity, + ...builtIns.map((entry) => entry.seenAtMs), + ); + const later = provided.filter((entry) => entry.seenAtMs > builtInSeenAtMs); + const earlier = provided.filter((entry) => entry.seenAtMs <= builtInSeenAtMs); + return [...later, ...builtIns, ...earlier]; +} + +/** + * The subject entry would ride: the first in entry order (entryCandidates). + * It decides alone, so when its own gate is shut entry is unavailable rather + * than taken by the next one. + * @returns {object|null} An entry of entryCandidates, or null. + */ +export function resolveEntry(nowMs = performance.now()) { + return this.entryCandidates(nowMs)[0] ?? null; +} + /** * The layer the active view rides: Live AIS Vessels on a bridge, Satellites - * in orbit, else the aircraft's own layer. It is read from the tracked - * entity's id once the layer has dropped its tracking, so a layer turned off - * still names itself. - * @returns {'flights'|'military'|'ais-live-vessels'|'satellites'|null} + * in orbit, a provider's own layer, else the aircraft's own layer. It is read + * from the tracked entity's id once the layer has dropped its tracking, so a + * layer turned off still names itself. + * @returns {string|null} */ export function readSubjectLayerId() { if (!this.active) return null; + if (this.subjectProvider) return this.subjectProvider.layerId; if (this.subjectKind === 'vessel') return 'ais-live-vessels'; if (this.subjectKind === 'satellite') return 'satellites'; const layerId = this.readAircraftInfo()?.layerId; @@ -51,23 +227,20 @@ export function readSubjectLayerId() { } /** - * The subject entry would take: a Cesium-tracked aircraft first, then the - * tracked satellite, else the selected vessel. Vessels never take Cesium - * tracking, so an aircraft and a vessel cannot be live through the same owner. - * @returns {'aircraft'|'satellite'|'vessel'|null} + * The kind entry would take, the first in entry order's (resolveEntry): a + * Cesium-tracked aircraft, the tracked satellite, the selected vessel or a + * provider's candidate. + * @returns {string|null} */ export function resolveEntryKind() { - if (this.readAircraftInfo() && this.viewer.trackedEntity?.position) - return 'aircraft'; - if (this.readSatelliteInfo()) return 'satellite'; - return this.readVesselInfo() ? 'vessel' : null; + return this.resolveEntry()?.kind ?? null; } export function dispatchCockpitModeChanged(active, info = null) { const subjectId = active ? String( info?.icao24 || - (info?.kind === 'vessel' ? info.id : '') || + (info?.kind === 'vessel' || info?.generic === true ? info.id : '') || info?.noradId || '', ) @@ -76,9 +249,13 @@ export function dispatchCockpitModeChanged(active, info = null) { : null; const layerId = active && - ['flights', 'military', 'ais-live-vessels', 'satellites'].includes( + (['flights', 'military', 'ais-live-vessels', 'satellites'].includes( info?.layerId, - ) + ) || + (info?.generic === true && + this.subjectProviders.some( + (provider) => provider.layerId === info.layerId, + ))) ? info.layerId : null; window.dispatchEvent( @@ -127,40 +304,60 @@ export function syncTr3bToggle(info) { this.publishPresenceView(); } +/** + * Word the ridden provider subject's labels and Look ahead button again, + * after a change of the page's language: a provider may give them as + * functions (cockpitSubjectProviders). + */ +export function refreshSubjectWords() { + const provider = this.subjectProvider; + if (!this.active || !provider) return; + this.presence.subjectLabels = { ...provider.labels }; + const look = provider.look; + this.presence.subjectLook = look ? { aheadLabel: look.aheadLabel } : null; + this.publishPresenceView(); +} + export function syncEntry() { if (this.destroyed) return false; if (this.active) return; - const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); - this.syncTr3bToggle(trackedContact ? info : null); - const orbitContact = !trackedContact && !!this.readSatelliteInfo(); - const bridgeContact = - !trackedContact && !orbitContact && !!this.readVesselInfo(); - let entryKind = null; - if (trackedContact) entryKind = this.isEntryAllowed() ? 'aircraft' : null; - else if (orbitContact) - entryKind = this.isOrbitEntryAllowed() ? 'satellite' : null; - else if (bridgeContact) - entryKind = this.isBridgeEntryAllowed() ? 'vessel' : null; + const candidates = this.entryCandidates(performance.now()); + const tracked = candidates.some(({ kind }) => kind === 'aircraft'); + this.syncTr3bToggle(tracked ? this.readAircraftInfo() : null); + // The first in entry order decides alone (resolveEntry). + const chosen = candidates[0] ?? null; + const entryKind = chosen?.allowed() ? chosen.kind : null; + const face = + entryKind && chosen.provider + ? (chosen.candidate.face ?? chosen.provider.entryFace) + : null; const available = entryKind !== null; // Change-only publishes: this runs on a preUpdate cadence, and a fresh // presence every frame would re-render the views even when nothing // changed. (perf item 9) if ( this.presence.entryAvailable === available && - this.presence.entryKind === entryKind + this.presence.entryKind === entryKind && + this.presence.entryFace?.icon === face?.icon && + this.presence.entryFace?.label === face?.label ) return; this.presence.entryAvailable = available; this.presence.entryKind = entryKind; + this.presence.entryFace = face && { icon: face.icon, label: face.label }; this.publishPresenceView(); } export function navigateContext(direction, options = {}) { if (this.destroyed) return false; - // Contact navigation moves between aircraft; a bridge does not jump ship - // and an orbital view stays with its satellite. - if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite') + // Contact navigation moves between aircraft; a bridge does not jump ship, + // an orbital view stays with its satellite and a provider's view with its + // subject. + if ( + this.subjectKind === 'vessel' || + this.subjectKind === 'satellite' || + this.subjectProvider + ) return false; const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; const wasActive = this.active; @@ -180,9 +377,14 @@ export function navigateContext(direction, options = {}) { } export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { - // An aircraft tracked from the bridge or from orbit ends that view (see the - // vessel and orbital camera updates); it is never adopted as its subject. - if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite') + // An aircraft tracked from the bridge, from orbit or from a provider's view + // ends that view (see the camera updates); it is never adopted as its + // subject. + if ( + this.subjectKind === 'vessel' || + this.subjectKind === 'satellite' || + this.subjectProvider + ) return false; const nextEntity = this.viewer.trackedEntity; if ( @@ -213,16 +415,153 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { } /** - * Enter first person on a tracked aircraft or, with none tracked, the - * tracked satellite's orbital view or the selected vessel's bridge. - * @param {{kind?: 'aircraft'|'satellite'|'vessel'|null}} [options] Restrict - * entry to one subject kind; voice entry is aircraft-only. + * Enter a provider's view on its candidate: hold it, read a first frame, then + * take the camera as the built-in views do. Entry fails when the gate is shut, + * the hold answers anything but `true` or no frame comes; past the gate, a + * failed entry releases the subject, since the hold may have taken it. + * @param {object} provider From cockpitSubjectProviders. + * @returns {boolean} + */ +function enterSubject(provider) { + const candidate = this.readSubjectCandidate(provider); + if (!candidate || !this.isSubjectEntryAllowed(provider, candidate)) + return false; + const failed = {}; + const release = () => + this.callSubject(provider, 'release', [candidate.id, { restore: false }]); + // An asynchronous hold may land after it was refused: it is released + // again once it settles, unless this subject is being ridden by then. + const releaseLate = () => { + if ( + this.active && + this.subjectProvider === provider && + this.subjectId === candidate.id + ) + return; + release(); + }; + const held = this.callSubject( + provider, + 'hold', + [candidate.id], + failed, + releaseLate, + ); + if (held !== true) { + // `false` refuses entry; a hold that threw, returned a promise or + // answered anything else (warned of once) may still have taken the + // subject. Each is released. + if (held !== false && held !== failed) + this.callSubject.warnOnce( + provider, + 'hold', + 'must return true or false; entry refused', + held, + ); + release(); + return false; + } + const frame = cockpitSubjectFrame( + this.callSubject( + provider, + 'read', + [performance.now(), this.subjectContext()], + null, + ), + candidate.id, + ); + if (!frame) { + this.callSubject(provider, 'release', [candidate.id, { restore: false }]); + return false; + } + this.entryFocusOrigin = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + this.onCameraTakeover?.(); + this.viewer.camera.cancelFlight(); + this.subjectKind = provider.kind; + this.subjectProvider = provider; + this.subjectId = candidate.id; + this.lastSubjectFrame = frame; + this.subjectHud = null; + this.subjectSignalKeys = new Set(); + this.vesselWaterline = null; + this.vesselFix = null; + this.trackedEntity = null; + this.heading = normalizeHeading( + frame.headingDeg ?? cameraHeadingDeg(this.viewer.camera), + ); + this.lastFrameMs = performance.now(); + this.lastCameraUpdateMs = 0; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.lastGroundProbeMs = 0; + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + this.cockpitAnchorValid = false; + this.subjectHeightM = null; + this.lookRestPitchDeg = provider.look?.restPitchDeg ?? null; + this.resetLook({ immediate: true }); + this.active = true; + this.services.holdContinuousRender('cockpit'); + this.viewer.trackedEntity = undefined; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + document.body.classList.add('cockpit-mode', 'cockpit-subject'); + this._tr3bSignature = null; + Object.assign(this.presence, { + active: true, + entryAvailable: false, + entryKind: null, + entryFace: null, + subjectKind: provider.kind, + subjectGeneric: true, + subjectLabels: { ...provider.labels }, + subjectLook: provider.look + ? { aheadLabel: provider.look.aheadLabel } + : null, + tr3b: null, + }); + this.publishPresenceView(); + this.publishRadar(null); + this.signalItems = []; + this.signalSignatures.clear(); + this.showBriefPage(0); + this.startBriefRotation(); + this.pushCockpitSignal('track', 'track', t('TRACK ACQUIRED'), frame.name); + const info = this.subjectInfo(frame); + this.updateHud(info, performance.now(), true); + this.setVisionMode(this.getInitialVisionMode()); + this.scheduleContextLayout(); + Promise.resolve().then(() => { + if (this.active && !this.destroyed) + this.mapViewButton?.focus({ preventScroll: true }); + }); + this.onEntered?.(); + this.onRide?.(provider.layerId); + this.dispatchCockpitModeChanged(true, info); + return true; +} + +/** + * Enter first person on the first subject in entry order (resolveEntry): a + * tracked aircraft, the tracked satellite's orbital view, the selected + * vessel's bridge or an application provider's subject. + * @param {{kind?: string|null}} [options] Restrict entry to one subject kind; + * voice entry is aircraft-only. * @returns {boolean} True when the view was entered. */ export function enter({ kind = null } = {}) { if (this.destroyed) return false; if (this.active) return false; const subjectKind = kind ?? this.resolveEntryKind() ?? 'aircraft'; + const provider = this.subjectProviders.find( + (entry) => entry.kind === subjectKind, + ); + if (provider) return enterSubject.call(this, provider); + if (!['aircraft', 'satellite', 'vessel'].includes(subjectKind)) return false; const vessel = subjectKind === 'vessel'; const satellite = subjectKind === 'satellite'; const allowed = vessel @@ -371,11 +710,17 @@ export function enter({ kind = null } = {}) { export function exit({ restoreTracking = true } = {}) { if (!this.active) return false; const entity = this.trackedEntity; + const provider = this.subjectProvider; + const providerSubject = provider ? this.subjectId : null; const vesselSubject = this.subjectKind === 'vessel' ? this.subjectId : null; const satelliteSubject = this.subjectKind === 'satellite' ? this.subjectId : null; this.active = false; this.subjectKind = null; + this.subjectProvider = null; + this.lastSubjectFrame = null; + this.subjectHud = null; + this.lookRestPitchDeg = null; this.vesselWaterline = null; this.vesselFix = null; this.subjectId = null; @@ -399,6 +744,7 @@ export function exit({ restoreTracking = true } = {}) { 'cockpit-mode', 'cockpit-bridge', 'cockpit-orbit', + 'cockpit-subject', ); // The camera's own field of view returns before any follow resumes. this.endOrbital(); @@ -406,6 +752,9 @@ export function exit({ restoreTracking = true } = {}) { this.publishUtilityLayout(null); this.presence.active = false; this.presence.subjectKind = null; + this.presence.subjectGeneric = false; + this.presence.subjectLabels = null; + this.presence.subjectLook = null; this.publishPresenceView(); this.routeView.visible = false; this.clearPredictiveRoute(); @@ -419,6 +768,13 @@ export function exit({ restoreTracking = true } = {}) { this.services.aisLiveVesselsLayer?.setCockpitSubject?.(null); if (satelliteSubject !== null) this.services.satellitesLayer?.setCockpitSubject?.(null); + // The provider gives its subject back; `restore` asks it to bring the map + // back to it, as the built-in kinds' refocus does. + if (provider) + this.callSubject(provider, 'release', [ + providerSubject, + { restore: restoreTracking === true }, + ]); this.dispatchCockpitModeChanged(false); if (restoreTracking && entity && this.viewer.entities.contains(entity)) { this.viewer.trackedEntity = entity; diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index 81da5f20e..7acb31ef5 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -51,6 +51,8 @@ export class ContextControls { this._contextRestoreState = null; this._contextLayerReactionPromises = new Set(); this._preservePanelStateDuringLayerClear = false; + /** Layers Context isolation is switching off right now. */ + this._isolatingLayerIds = new Set(); this._userFacingContextNotificationTokens = new Set(); this._clearSelectedLayersPromise = null; this._clearSelectedLayersManagerPromise = null; @@ -59,6 +61,16 @@ export class ContextControls { showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); } + /** + * Whether Context isolation is switching this layer off right now: it is + * suspended, and leaving the mode restores it. A switch-off by the user, + * inside the mode or out of it, is not. + * @param {string} layerId Layer id. + * @returns {boolean} + */ + suspendsLayer(layerId) { + return this._isolatingLayerIds.has(layerId); + } setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); } diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs index 33b3c4d10..2bceb7e75 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -153,3 +153,56 @@ test('a rejected tracked reaction settles without an unhandled rejection', async await controls._waitForContextLayerSettlement(); assert.equal(controls._contextLayerReactionPromises.size, 0); }); + +test('only a layer Context isolation is switching off is suspended', async (t) => { + const { controls } = fixture(t); + const enabled = new Set(['cctv']); + // What the CCTV panel is told at each visibility change of its layer. + const seen = []; + controls._dataManager = { + layers: new Map([['cctv', {}]]), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => null, + setLayerParams() {}, + isEnabled: (layerId) => enabled.has(layerId), + isEffectivelyEnabled: (layerId) => enabled.has(layerId), + async setEnabled(layerId, next) { + // The layer tells its panel it is off while it is being disabled. + if (next) enabled.add(layerId); + else enabled.delete(layerId); + seen.push([layerId, next, controls.suspendsLayer(layerId)]); + return true; + }, + async restoreEnabledLayerIds(layerIds) { + for (const layerId of layerIds) await this.setEnabled(layerId, true); + }, + }; + assert.equal(controls.suspendsLayer('cctv'), false); + controls._captureContextSessionSnapshot(); + await controls._clearLayersOutsideContextMode('flights'); + assert.deepEqual(seen, [['cctv', false, true]]); + assert.equal(controls.suspendsLayer('cctv'), false, 'only while isolating'); + // Inside the mode the user turns CCTV back on, then off: their own. + await controls._dataManager.setEnabled('cctv', true); + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); + await controls._restoreContextSession(); + assert.equal(enabled.has('cctv'), true); + // A switch-off after the mode is left is the user's own too. + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); +}); + +test('a failed isolation leaves no layer suspended', async (t) => { + const { controls } = fixture(t); + controls._dataManager = { + layers: new Map([['cctv', {}]]), + isEffectivelyEnabled: () => true, + isEnabled: () => true, + setEnabled: async () => { + throw new Error('disable failed'); + }, + }; + await assert.rejects(controls._clearLayersOutsideContextMode('flights')); + assert.equal(controls.suspendsLayer('cctv'), false); +}); diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js index e8a69b5fc..1f17faeef 100644 --- a/src/ui/contextTransactions.js +++ b/src/ui/contextTransactions.js @@ -240,25 +240,35 @@ export async function _clearLayersOutsideContextMode( ) { const allowed = contextAllowedLayerIds(mode); const pending = []; - for (const [layerId] of this._dataManager.layers || []) { - // Effective visibility: a disallowed layer still mid-ENABLING must be - // isolated too, or it settles ON inside the exclusive mode. - if ( - !allowed.has(layerId) && - this._dataManager.isEffectivelyEnabled(layerId) - ) { - pending.push({ - layerId, - transition: this._dataManager.setEnabled(layerId, false, { - notificationToken, - ...(signal ? { signal } : {}), - }), - }); + // Panels read this while their layer goes off (suspendsLayer): a layer + // isolation switches off is suspended, not switched off by the user. + const isolating = []; + let results; + try { + for (const [layerId] of this._dataManager.layers || []) { + // Effective visibility: a disallowed layer still mid-ENABLING must be + // isolated too, or it settles ON inside the exclusive mode. + if ( + !allowed.has(layerId) && + this._dataManager.isEffectivelyEnabled(layerId) + ) { + if (!this._isolatingLayerIds.has(layerId)) { + this._isolatingLayerIds.add(layerId); + isolating.push(layerId); + } + pending.push({ + layerId, + transition: this._dataManager.setEnabled(layerId, false, { + notificationToken, + ...(signal ? { signal } : {}), + }), + }); + } } + results = await Promise.all(pending.map(({ transition }) => transition)); + } finally { + for (const layerId of isolating) this._isolatingLayerIds.delete(layerId); } - const results = await Promise.all( - pending.map(({ transition }) => transition), - ); const failed = pending .filter( ({ layerId }, index) => diff --git a/src/ui/renderFailure.js b/src/ui/renderFailure.js new file mode 100644 index 000000000..b593f57f4 --- /dev/null +++ b/src/ui/renderFailure.js @@ -0,0 +1,216 @@ +import { useStatusStore } from './stores/status.js'; +import { applicationPinia } from './vueRuntime.js'; +import { describeError } from '../app/errors.js'; +import { appRootUrl } from '../share/shareLinkPath.js'; + +/** + * Show a recoverable notice when the globe stops drawing, instead of a black + * globe behind working controls. Retry restarts the render loop, or reloads + * after a lost WebGL context; Reset opens the app's root address. With + * `allowReload: false`, Reset is dropped and a lost context shows no notice: + * only a reload draws again, and the page says so itself. + * Returns a function that removes the notice. + */ +export function installRenderFailureNotice( + viewer, + { + store = useStatusStore(applicationPinia()), + locationRef = globalThis.location, + allowReload = true, + logError = (error) => + console.error('[Render] The globe stopped drawing:', error), + } = {}, +) { + const { scene } = viewer; + const { canvas } = viewer; + let notice = null; + let contextLost = false; + // With rethrowRenderErrors, Scene throws the error it has just raised; + // the render() wrapper must not raise it again. + let raisedByScene = null; + let frameFailed = false; + let removeFrameListeners = null; + + function retry() { + if (contextLost) { + locationRef.reload(); + return; + } + viewer.useDefaultRenderLoop = true; + scene.requestRender(); + } + + function reset() { + locationRef.assign(appRootUrl(locationRef.href)); + } + + function stopWatchingFrames() { + removeFrameListeners?.(); + removeFrameListeners = null; + } + + function clear() { + stopWatchingFrames(); + if (store.renderFailure === notice) store.publishRenderFailure(null); + notice = null; + } + + // Cesium raises postRender after a failed frame too, so only a frame that + // starts and ends without a render error clears the notice. + function watchFrames() { + if (removeFrameListeners || contextLost) return; + const removeStart = scene.preUpdate.addEventListener(() => { + frameFailed = false; + raisedByScene = null; + }); + const removeEnd = scene.postRender.addEventListener(() => { + if (!frameFailed) clear(); + }); + removeFrameListeners = () => { + removeStart(); + removeEnd(); + }; + } + + function present(detail) { + const reason = contextLost ? 'context-lost' : 'render-error'; + // A loop restarted into the same error keeps the notice as it was. + if (notice?.reason === reason && notice.detail === detail) return; + notice = { + reason, + detail, + retry, + reset: allowReload ? reset : null, + }; + store.publishRenderFailure(notice); + watchFrames(); + } + + const onRenderError = (_scene, error) => { + raisedByScene = { error }; + frameFailed = true; + logError(error); + // A lost context fails every frame after it; the notice keeps saying why. + if (contextLost) return; + present(error ? describeError(error) : ''); + }; + const onContextLost = () => { + contextLost = true; + // No frame can clear this one; only a reload draws again. + stopWatchingFrames(); + // A page that must not reload has nothing to offer: an inline panel + // shows its own lost-context notice (src/app/globePanelRuntime.js). + if (!allowReload) { + clear(); + return; + } + present(''); + }; + + const removeRenderError = scene.renderError.addEventListener(onRenderError); + // An error thrown outside Scene's error handling (a frame listener, onTick, + // resize) stops the loop without a renderError. Raise one, so Cesium, an + // inline panel's timer and this notice all see it. First finish the event + // raises it cut short, so listeners added or removed meanwhile apply. + const unguard = ['resize', 'render'].map((name) => + reportThrown(viewer.cesiumWidget, name, (error) => { + frameEvents(viewer).forEach(finishRaise); + if (!raisedByScene || raisedByScene.error !== error) + scene.renderError.raiseEvent(scene, error); + raisedByScene = null; + }), + ); + canvas.addEventListener('webglcontextlost', onContextLost); + return () => { + removeRenderError(); + unguard.forEach((restore) => restore()); + canvas.removeEventListener('webglcontextlost', onContextLost); + clear(); + }; +} + +/** + * Events Cesium raises in a frame outside Scene's error handling, which a + * listener's error can leave mid-raise. Only top-level tilesets are reached. + */ +function frameEvents(viewer) { + const { scene } = viewer; + const events = [ + scene.preUpdate, + scene.postUpdate, + scene.preRender, + scene.postRender, + scene.camera?.changed, + scene.camera?.moveStart, + scene.camera?.moveEnd, + viewer.clock?.onTick, + viewer.clock?.onStop, + scene.globe?.tileLoadProgressEvent, + ]; + const primitives = scene.primitives; + for (let i = 0; i < (primitives?.length ?? 0); i += 1) { + const tileset = primitives.get(i); + events.push( + tileset?.loadProgress, + tileset?.allTilesLoaded, + tileset?.initialTilesLoaded, + ); + } + return events; +} + +/** + * Finish a raise a throwing listener cut short. Until a raise ends, Cesium's + * `Event` only queues added and removed listeners; apply that queue. + * These are Cesium's private fields (`_invokingListeners`, `_toAdd`, + * `_toRemove`, `_listeners`, and the widget's `_renderLoopRunning`), checked + * against cesium 1.138 / @cesium/engine 22.3.0; the render loop fixture tests + * drive the real classes and fail when an upgrade renames them. + */ +function finishRaise(event) { + if (!event?._invokingListeners) return; + event._invokingListeners = false; + for (const [listener, scopes] of event._toAdd) { + const added = event._listeners.get(listener) ?? new Set(); + scopes.forEach((scope) => added.add(scope)); + event._listeners.set(listener, added); + } + event._toAdd.clear(); + for (const [listener, scopes] of event._toRemove) { + const left = event._listeners.get(listener); + scopes.forEach((scope) => left?.delete(scope)); + if (left?.size === 0) event._listeners.delete(listener); + } + event._toRemove.clear(); +} + +/** + * Report an error the widget's `name` method throws, and rethrow it. + * + * A render error marks the loop stopped, but a call made while it ran leaves + * its next frame queued, and that frame stops it. Keep the loop marked + * running, so a Retry or a shown tab before that frame starts no second loop. + * After a throw, the loop's own catch still marks it stopped. + */ +function reportThrown(widget, name, report) { + const original = widget?.[name]; + if (typeof original !== 'function') return () => {}; + const own = Object.hasOwn(widget, name); + const guarded = function (...args) { + const looping = widget._renderLoopRunning; + try { + return original.apply(this, args); + } catch (error) { + report(error); + throw error; + } finally { + if (looping) widget._renderLoopRunning = true; + } + }; + widget[name] = guarded; + return () => { + if (widget[name] !== guarded) return; + if (own) widget[name] = original; + else delete widget[name]; + }; +} diff --git a/src/ui/renderFailure.test.mjs b/src/ui/renderFailure.test.mjs new file mode 100644 index 000000000..913348740 --- /dev/null +++ b/src/ui/renderFailure.test.mjs @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; + +function fixture(t, options = {}) { + const canvasListeners = new Map(); + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() { + this.requests = (this.requests ?? 0) + 1; + }, + }; + const viewer = { + scene, + useDefaultRenderLoop: true, + canvas: { + addEventListener: (name, listener) => canvasListeners.set(name, listener), + removeEventListener: (name, listener) => { + if (canvasListeners.get(name) === listener) + canvasListeners.delete(name); + }, + }, + }; + const store = { + renderFailure: null, + published: [], + publishRenderFailure(next) { + this.renderFailure = next; + this.published.push(next); + }, + }; + const visits = []; + const locationRef = { + href: 'https://gev.example/share/AQc?lang=ru#lat=48.8584&lon=2.2945', + reload: () => visits.push('reload'), + assign: (url) => visits.push(String(url)), + }; + const logged = []; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef, + logError: (error) => logged.push(error), + ...options, + }); + t.after(uninstall); + // One Cesium frame: preUpdate, an optional render error, then postRender, + // which Cesium raises after a failed frame too. + const frame = (error) => { + scene.preUpdate.raiseEvent(scene); + if (error) { + viewer.useDefaultRenderLoop = false; + scene.renderError.raiseEvent(scene, error); + } + scene.postRender.raiseEvent(scene); + }; + return { + viewer, + scene, + store, + visits, + logged, + uninstall, + frame, + loseContext: () => canvasListeners.get('webglcontextlost')?.({}), + canvasListeners, + }; +} + +test('a render error shows a notice that survives the failed frame', (t) => { + const f = fixture(t); + assert.equal(f.store.renderFailure, null); + const error = new Error('Expected width to be greater than 0'); + f.frame(error); + const notice = f.store.renderFailure; + assert.equal(notice.reason, 'render-error'); + assert.equal(notice.detail, 'Expected width to be greater than 0'); + assert.equal(typeof notice.retry, 'function'); + assert.equal(typeof notice.reset, 'function'); + assert.deepEqual(f.logged, [error]); +}); + +test('Retry restarts the render loop and a clean frame clears the notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.retry(); + assert.equal(f.viewer.useDefaultRenderLoop, true); + assert.equal(f.scene.requests, 1); + assert.ok(f.store.renderFailure, 'the notice waits for a frame'); + f.frame(); + assert.equal(f.store.renderFailure, null); + // Clearing stops the per-frame listeners. + assert.equal(f.scene.preUpdate.numberOfListeners, 0); + assert.equal(f.scene.postRender.numberOfListeners, 0); +}); + +test('a loop restarted into the same error keeps one notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + const first = f.store.renderFailure; + f.store.renderFailure.retry(); + f.frame(new Error('boom')); + assert.equal(f.store.renderFailure, first); + assert.equal(f.store.published.length, 1); + f.frame(new Error('other')); + assert.equal(f.store.renderFailure.detail, 'other'); + assert.ok(!f.store.published.includes(null), 'no new appearance'); +}); + +test('a new failure after recovery is a new appearance', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.retry(); + f.frame(); + f.frame(new Error('boom')); + assert.deepEqual( + f.store.published.map((notice) => notice?.reason ?? null), + ['render-error', null, 'render-error'], + ); +}); + +test('a render error without a value shows no detail', (t) => { + const f = fixture(t); + f.viewer.useDefaultRenderLoop = false; + f.scene.renderError.raiseEvent(f.scene, undefined); + assert.equal(f.store.renderFailure.reason, 'render-error'); + assert.equal(f.store.renderFailure.detail, ''); +}); + +test('Reset opens the app root without the restored view', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.reset(); + assert.deepEqual(f.visits, ['https://gev.example/?lang=ru']); +}); + +test('a lost context offers a reload and no frame clears it', (t) => { + const f = fixture(t); + f.loseContext(); + const notice = f.store.renderFailure; + assert.equal(notice.reason, 'context-lost'); + assert.equal(notice.detail, ''); + // Cesium's next frame then fails on the dead drawing buffer. + f.frame(new Error('Expected width to be greater than 0')); + assert.equal(f.store.renderFailure, notice, 'the notice keeps saying why'); + f.frame(); + assert.ok(f.store.renderFailure, 'a lost context never draws again'); + f.store.renderFailure.retry(); + assert.deepEqual(f.visits, ['reload']); +}); + +test('a page that must not reload offers neither reload nor reset', (t) => { + const f = fixture(t, { allowReload: false }); + f.frame(new Error('boom')); + assert.equal(typeof f.store.renderFailure.retry, 'function'); + assert.equal(f.store.renderFailure.reset, null); + // Only a reload draws again, which this page must not offer: the notice + // goes, and the page's own lost-context notice speaks instead. + f.loseContext(); + assert.equal(f.store.renderFailure, null); + f.frame(new Error('Expected width to be greater than 0')); + assert.equal(f.store.renderFailure, null); +}); + +test('uninstall removes the listeners and the notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.uninstall(); + assert.equal(f.store.renderFailure, null); + assert.equal(f.scene.renderError.numberOfListeners, 0); + assert.equal(f.scene.postRender.numberOfListeners, 0); + assert.equal(f.canvasListeners.size, 0); + f.frame(new Error('later')); + assert.equal(f.store.renderFailure, null); +}); diff --git a/src/ui/renderFailureEventRetry.test.mjs b/src/ui/renderFailureEventRetry.test.mjs new file mode 100644 index 000000000..a41697493 --- /dev/null +++ b/src/ui/renderFailureEventRetry.test.mjs @@ -0,0 +1,207 @@ +// Retry after a scene event listener throws. Cesium's Event.raiseEvent has no +// try/finally, so the event stays mid-raise and only queues listener changes. +// Drives Cesium's real Event and startRenderLoop. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function setup() { + // Scene.render as in Scene.js:4369: preUpdate always, preRender/postRender + // only when a frame is rendered (requestRenderMode idle skips them). + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRenderMode: true, + _renderRequested: false, + rendered: 0, + requestRender() { + this._renderRequested = true; + }, + initializeFrame() {}, + render(time) { + this.preUpdate.raiseEvent(this, time); + const shouldRender = !this.requestRenderMode || this._renderRequested; + if (!shouldRender) return; + this._renderRequested = false; + this.preRender.raiseEvent(this, time); + this.rendered += 1; + this.postRender.raiseEvent(this, time); + }, + }; + return { scene, ...renderLoopFixture(scene) }; +} + +for (const eventName of ['postRender', 'preUpdate']) { + test(`idle: a ${eventName} listener that threw once; a successful Retry clears the notice`, () => { + const { scene, viewer, store, runFrames, restore } = setup(); + try { + let broken = true; + scene[eventName].addEventListener(() => { + if (broken) throw new TypeError('listener failed'); + }); + scene.requestRender(); + viewer.useDefaultRenderLoop = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(viewer.useDefaultRenderLoop, false); + broken = false; // the cause goes away + store.renderFailure.retry(); + const before = scene.rendered; + runFrames(10); // idle: no camera move, no render request + assert.ok(scene.rendered > before, 'the globe drew again'); + assert.equal(store.renderFailure, null, 'the notice is cleared'); + } finally { + restore(); + } + }); +} + +test('a throwing postRender listener removed before Retry is not called again', () => { + const { scene, viewer, store, runFrames, restore } = setup(); + try { + let calls = 0; + const remove = scene.postRender.addEventListener(() => { + calls += 1; + throw new TypeError('layer listener failed'); + }); + scene.requestRender(); + viewer.useDefaultRenderLoop = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + remove(); // the user turns the layer off + store.renderFailure.retry(); + runFrames(5); + assert.equal(calls, 1, 'the removed listener ran again after Retry'); + assert.equal(viewer.useDefaultRenderLoop, true, 'the loop keeps running'); + assert.equal(store.renderFailure, null, 'the notice is cleared'); + } finally { + restore(); + } +}); + +const listenerCount = (event) => + [...event._listeners.values()].reduce((sum, scopes) => sum + scopes.size, 0); + +// While the loop is stopped, a layer turned off and on again, or a ground +// task in worldAnnotationRenderer that starts, times out and starts again. +const RESUBSCRIBE = { + 'turned off and on': (event, listener) => { + event.removeEventListener(listener); + event.addEventListener(listener); + }, + 'added, removed and added again': (event, listener) => { + event.addEventListener(listener); + event.removeEventListener(listener); + event.addEventListener(listener); + }, +}; + +for (const [name, resubscribe] of Object.entries(RESUBSCRIBE)) { + test(`a listener ${name} while the loop is stopped runs after Retry`, () => { + const { scene, viewer, store, runFrames, restore } = setup(); + try { + let broken = true; + scene.postRender.addEventListener(() => { + if (broken) throw new TypeError('layer listener failed'); + }); + let calls = 0; + const listener = () => { + calls += 1; + }; + if (name === 'turned off and on') + scene.postRender.addEventListener(listener); + scene.requestRender(); + viewer.useDefaultRenderLoop = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + resubscribe(scene.postRender, listener); + broken = false; + store.renderFailure.retry(); + calls = 0; + scene.requestRender(); + runFrames(3); + assert.ok(calls > 0, 'the re-added listener runs after Retry'); + assert.equal( + scene.postRender.numberOfListeners, + listenerCount(scene.postRender), + ); + } finally { + restore(); + } + }); +} + +// Every event a listener's error can cut short is finished when the error +// surfaces, so a listener added at once is subscribed. +const FRAME_EVENTS = { + 'scene.preUpdate': (v) => v.scene.preUpdate, + 'scene.postUpdate': (v) => v.scene.postUpdate, + 'scene.preRender': (v) => v.scene.preRender, + 'scene.postRender': (v) => v.scene.postRender, + 'camera.changed': (v) => v.scene.camera.changed, + 'camera.moveStart': (v) => v.scene.camera.moveStart, + 'camera.moveEnd': (v) => v.scene.camera.moveEnd, + 'clock.onTick': (v) => v.clock.onTick, + 'clock.onStop': (v) => v.clock.onStop, + 'globe.tileLoadProgressEvent': (v) => v.scene.globe.tileLoadProgressEvent, + 'tileset.loadProgress': (v) => v.scene.primitives.get(0).loadProgress, + 'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded, + 'tileset.initialTilesLoaded': (v) => + v.scene.primitives.get(0).initialTilesLoaded, +}; + +for (const [name, pick] of Object.entries(FRAME_EVENTS)) { + test(`a listener that throws in ${name} leaves it finished`, () => { + const events = () => ({ + changed: new Cesium.Event(), + moveStart: new Cesium.Event(), + moveEnd: new Cesium.Event(), + }); + const tileset = { + loadProgress: new Cesium.Event(), + allTilesLoaded: new Cesium.Event(), + initialTilesLoaded: new Cesium.Event(), + }; + const viewer = { + scene: { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + camera: events(), + globe: { tileLoadProgressEvent: new Cesium.Event() }, + primitives: { length: 1, get: () => tileset }, + }, + clock: { onTick: new Cesium.Event(), onStop: new Cesium.Event() }, + canvas: { addEventListener() {}, removeEventListener() {} }, + cesiumWidget: { + _renderLoopRunning: false, + render: () => pick(viewer).raiseEvent(), + }, + }; + const store = { publishRenderFailure() {} }; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, + logError() {}, + }); + try { + const event = pick(viewer); + event.addEventListener(() => { + throw new TypeError('listener failed'); + }); + assert.throws(() => viewer.cesiumWidget.render(), TypeError); + assert.equal(event._invokingListeners, false); + const listener = () => {}; + event.addEventListener(listener); + assert.ok(event._listeners.has(listener)); + } finally { + uninstall(); + } + }); +} diff --git a/src/ui/renderFailureLoopErrors.test.mjs b/src/ui/renderFailureLoopErrors.test.mjs new file mode 100644 index 000000000..1446c149f --- /dev/null +++ b/src/ui/renderFailureLoopErrors.test.mjs @@ -0,0 +1,46 @@ +// Errors thrown in the render loop outside Scene's tryAndCatchError. Drives +// Cesium's real startRenderLoop through the useDefaultRenderLoop setter. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function runLoop(render) { + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() {}, + initializeFrame() {}, + render, + }; + const fixture = renderLoopFixture(scene); + fixture.viewer.useDefaultRenderLoop = true; + fixture.runFrames(3); + return { ...fixture, uninstall: fixture.restore }; +} + +test('an error thrown outside tryAndCatchError still shows the notice', () => { + // Scene.render raises preRender outside tryAndCatchError (Scene.js:4439), + // so a listener's exception propagates. + const { widget, store, logged, uninstall } = runLoop(function () { + this.preUpdate.raiseEvent(this); + throw new TypeError("Cannot read properties of undefined (reading 'x')"); + }); + assert.equal(widget.useDefaultRenderLoop, false); + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(logged.length, 1); + uninstall(); +}); + +test('an error Scene raised and threw on with rethrowRenderErrors is reported once', () => { + const { store, logged, uninstall } = runLoop(function () { + this.preUpdate.raiseEvent(this); + const error = new Error('boom'); + this.renderError.raiseEvent(this, error); + throw error; + }); + assert.equal(store.renderFailure?.detail, 'boom'); + assert.equal(logged.length, 1); + uninstall(); +}); diff --git a/src/ui/renderFailureSecondLoop.test.mjs b/src/ui/renderFailureSecondLoop.test.mjs new file mode 100644 index 000000000..2b5c6196f --- /dev/null +++ b/src/ui/renderFailureSecondLoop.test.mjs @@ -0,0 +1,95 @@ +// Retry after an error Scene catches itself: the loop is marked stopped but +// its next frame is still queued, so an early Retry must not start a second +// loop. Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function setup({ targetFrameRate } = {}) { + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + broken: false, + drawn: 0, + requestRender() {}, + initializeFrame() {}, + // Scene.render (Scene.js:4369): an update error is caught by + // tryAndCatchError, which raises renderError and returns normally. + render(time) { + this.drawn += 1; + this.preUpdate.raiseEvent(this, time); + if (this.broken) this.renderError.raiseEvent(this, new Error('boom')); + this.preRender.raiseEvent(this, time); + this.postRender.raiseEvent(this, time); + }, + }; + return { scene, ...renderLoopFixture(scene, { targetFrameRate }) }; +} + +for (const targetFrameRate of [undefined, 60]) { + test(`targetFrameRate ${targetFrameRate}: Retry before the next frame after an error Scene caught keeps one render loop`, () => { + const { frames, scene, viewer, store, runFrames, restore } = setup({ + targetFrameRate, + }); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + // The cause is gone; Retry runs (a queued Enter on the focused Retry) + // before the loop's next animation frame. + scene.broken = false; + store.renderFailure.retry(); + assert.equal(frames.length, 1, 'one animation frame queued after Retry'); + scene.drawn = 0; + runFrames(3); + assert.equal(store.renderFailure, null); + assert.equal(scene.drawn, 3, 'the globe draws once a frame'); + } finally { + restore(); + } + }); +} + +test('control: Retry after the next frame ran keeps one render loop', () => { + const { frames, scene, viewer, store, runFrames, restore } = setup(); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + scene.broken = false; + runFrames(1); + assert.equal(frames.length, 0, 'the stopped loop queued nothing'); + store.renderFailure.retry(); + scene.drawn = 0; + runFrames(3); + assert.equal(scene.drawn, 3); + } finally { + restore(); + } +}); + +test('a tab hidden and shown again before the next frame after an error Scene caught keeps one render loop', () => { + const { scene, viewer, runFrames, restore } = setup(); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + scene.broken = false; + // src/app/tools.js turns the loop off while the tab is hidden and back on + // when it is shown, before the queued frame runs. + viewer.useDefaultRenderLoop = false; + viewer.useDefaultRenderLoop = true; + scene.drawn = 0; + runFrames(3); + assert.equal(scene.drawn, 3, 'the globe draws once a frame'); + } finally { + restore(); + } +}); diff --git a/src/ui/renderFailureView.test.mjs b/src/ui/renderFailureView.test.mjs new file mode 100644 index 000000000..89213bffa --- /dev/null +++ b/src/ui/renderFailureView.test.mjs @@ -0,0 +1,173 @@ +// The scene chrome renders the render failure notice and its actions. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useStatusStore } from './stores/status.js'; +import SceneChromeView from './views/SceneChromeView.vue'; + +function mount(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(SceneChromeView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + return useStatusStore(pinia); +} + +const notice = () => document.getElementById('render-failure'); +const buttons = () => + [...(notice()?.querySelectorAll('button') ?? [])].map((button) => + button.textContent.trim(), + ); + +test('a render failure shows an alert dialog that moves focus to Retry', async (t) => { + const store = mount(t); + assert.equal(notice(), null, 'no notice while the globe draws'); + const calls = []; + store.publishRenderFailure({ + reason: 'render-error', + detail: 'Expected width to be greater than 0', + retry: () => calls.push('retry'), + reset: () => calls.push('reset'), + }); + await nextTick(); + await nextTick(); + const element = notice(); + assert.equal(element.getAttribute('role'), 'alertdialog'); + assert.equal( + document.getElementById(element.getAttribute('aria-labelledby')) + .textContent, + 'The globe stopped drawing', + ); + assert.match( + document.getElementById(element.getAttribute('aria-describedby')) + .textContent, + /^Rendering stopped after an error\./, + ); + assert.equal( + element.querySelector('.render-failure-detail').textContent, + 'Expected width to be greater than 0', + ); + assert.deepEqual(buttons(), ['Retry', 'Reset view']); + assert.equal(document.activeElement?.textContent.trim(), 'Retry'); + for (const button of element.querySelectorAll('button')) button.click(); + assert.deepEqual(calls, ['retry', 'reset']); + store.publishRenderFailure(null); + await nextTick(); + assert.equal(notice(), null); +}); + +test('a lost context offers Reload, and a panel that cannot reload offers nothing', async (t) => { + const store = mount(t); + store.publishRenderFailure({ + reason: 'context-lost', + detail: '', + retry: () => {}, + reset: () => {}, + }); + await nextTick(); + assert.match( + document.getElementById('render-failure-message').textContent, + /3D graphics/, + ); + assert.equal(notice().querySelector('.render-failure-detail'), null); + assert.deepEqual(buttons(), ['Reload', 'Reset view']); + store.publishRenderFailure({ + reason: 'context-lost', + detail: '', + retry: null, + reset: null, + }); + await nextTick(); + assert.deepEqual(buttons(), []); + assert.equal(notice().querySelector('.render-failure-hint'), null); +}); + +const failure = () => ({ + reason: 'render-error', + detail: '', + retry: () => {}, + reset: null, +}); +const settle = async () => { + await nextTick(); + await nextTick(); +}; + +test('focus goes back where it was when the globe draws again', async (t) => { + const store = mount(t); + const search = document.body.appendChild(document.createElement('input')); + search.focus(); + store.publishRenderFailure(failure()); + await settle(); + assert.equal(document.activeElement?.textContent.trim(), 'Retry'); + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, search); +}); + +// The globe's canvas, as CesiumWidget builds it inside the container. +const addGlobeCanvas = () => { + const widget = document.createElement('div'); + widget.className = 'cesium-widget'; + document.getElementById('cesiumContainer').appendChild(widget); + return widget.appendChild(document.createElement('canvas')); +}; + +test('focus goes to the globe canvas when the element it came from is gone', async (t) => { + const store = mount(t); + const canvas = addGlobeCanvas(); + const search = document.body.appendChild(document.createElement('input')); + search.focus(); + store.publishRenderFailure(failure()); + await settle(); + search.remove(); + store.publishRenderFailure(null); + await settle(); + // The map's keys, Enter to finish a drawing among them, read the canvas + // as the map (src/annotations/drawTool.js). + assert.equal(document.activeElement, canvas); + assert.equal(canvas.getAttribute('tabindex'), '-1'); + canvas.blur(); + assert.equal(canvas.hasAttribute('tabindex'), false, 'only this once'); +}); + +test('focus goes to the globe canvas when the element it came from cannot take it', async (t) => { + const store = mount(t); + const canvas = addGlobeCanvas(); + const button = document.body.appendChild(document.createElement('button')); + button.focus(); + store.publishRenderFailure(failure()); + await settle(); + button.disabled = true; + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, canvas); + // A press on the globe leaves focus on the page again, as Cesium has it. + canvas.dispatchEvent(new Event('pointerdown')); + assert.equal(document.activeElement, document.body); + assert.equal(canvas.hasAttribute('tabindex'), false); +}); + +test('focus the user moved while the notice was up stays put', async (t) => { + const store = mount(t); + store.publishRenderFailure(failure()); + await settle(); + const chat = document.body.appendChild(document.createElement('textarea')); + chat.focus(); + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, chat); +}); + +test('the globe drawing from the start leaves focus alone', async (t) => { + mount(t); + await settle(); + assert.equal(document.activeElement, document.body); + assert.equal( + document.getElementById('cesiumContainer').hasAttribute('tabindex'), + false, + ); +}); diff --git a/src/ui/renderLoopFixture.mjs b/src/ui/renderLoopFixture.mjs new file mode 100644 index 000000000..562e2d3f7 --- /dev/null +++ b/src/ui/renderLoopFixture.mjs @@ -0,0 +1,72 @@ +// A widget on Cesium's real CesiumWidget.prototype (startRenderLoop, the +// useDefaultRenderLoop setter, render) around a scene double, with animation +// frames queued by hand and the render failure notice installed. +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; + +export function renderLoopFixture( + scene, + { targetFrameRate, viewer: viewerExtras = {}, notice = {} } = {}, +) { + const frames = []; + const previousRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => frames.push(fn); + const widget = Object.create(Cesium.CesiumWidget.prototype); + Object.assign(widget, { + _scene: scene, + _clock: { tick() {} }, + _canRender: true, + _useDefaultRenderLoop: false, + _renderLoopRunning: false, + // What src/app/scene.js passes to the viewer. + _showRenderLoopErrors: false, + _targetFrameRate: targetFrameRate, + isDestroyed: () => false, + resize() {}, + }); + // CesiumWidget's own renderError handler, which marks the loop stopped. + scene.renderError.addEventListener(() => { + widget._useDefaultRenderLoop = false; + widget._renderLoopRunning = false; + }); + const viewer = Object.assign( + { + scene, + cesiumWidget: widget, + canvas: { addEventListener() {}, removeEventListener() {} }, + get useDefaultRenderLoop() { + return widget.useDefaultRenderLoop; + }, + set useDefaultRenderLoop(value) { + widget.useDefaultRenderLoop = value; + }, + }, + viewerExtras, + ); + const store = { + renderFailure: null, + publishRenderFailure(next) { + this.renderFailure = next; + }, + }; + const logged = []; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, + logError: (error) => logged.push(error), + ...notice, + }); + let time = 0; + const runFrames = (n) => { + for (let i = 0; i < n; i += 1) { + // Above a 60 fps target's interval, so every frame draws. + time += 17; + for (const fn of frames.splice(0)) fn(time); + } + }; + const restore = () => { + uninstall(); + globalThis.requestAnimationFrame = previousRaf; + }; + return { frames, widget, viewer, store, logged, runFrames, restore }; +} diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 678b69c79..276a95547 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -891,6 +891,10 @@ export class ShellFacade { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; } + _contextSuspendsLayer(layerId) { + return this._contextControls?.suspendsLayer(layerId) ?? false; + } + _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); } diff --git a/src/ui/stores/status.js b/src/ui/stores/status.js index 34f66233d..d51fefd03 100644 --- a/src/ui/stores/status.js +++ b/src/ui/stores/status.js @@ -7,7 +7,9 @@ import { markRaw, shallowRef } from 'vue'; * road network chip, CctvControls the camera grid chip; the toast shows * what any owner's createToast() (src/ui/toast.js) publishes, and the title * bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its - * last text while hidden, so the next change flaps from what was shown. + * last text while hidden, so the next change flaps from what was shown. The + * render failure notice is what installRenderFailureNotice() + * (src/ui/renderFailure.js) publishes while the globe cannot draw. */ export const useStatusStore = defineStore('gev-status', () => { /** @@ -32,6 +34,13 @@ export const useStatusStore = defineStore('gev-status', () => { */ const fps = shallowRef(null); + /** + * The globe's render failure, `{reason, detail, retry, reset}`; null while + * the globe draws. `reason` is 'render-error' or 'context-lost', and `retry` + * and `reset` are null where that action is not offered. + */ + const renderFailure = shallowRef(null); + /** @param {?object} next A fresh presentation of the loading status. */ function publishLoading(next) { loading.value = next && markRaw(next); @@ -57,6 +66,11 @@ export const useStatusStore = defineStore('gev-status', () => { fps.value = next && markRaw(next); } + /** @param {?object} next A fresh presentation of the render failure. */ + function publishRenderFailure(next) { + renderFailure.value = next && markRaw(next); + } + return { fps, publishFps, @@ -68,5 +82,7 @@ export const useStatusStore = defineStore('gev-status', () => { publishTraffic, cctv, publishCctv, + renderFailure, + publishRenderFailure, }; }); diff --git a/src/ui/styles/cockpitSubject.css b/src/ui/styles/cockpitSubject.css new file mode 100644 index 000000000..37031ee8b --- /dev/null +++ b/src/ui/styles/cockpitSubject.css @@ -0,0 +1,315 @@ +/* An application provider's first-person view + (src/cockpitSubjectProviders.js): a window view with the provider's board, + sources, banner, darkening veil and progress line. Every rule is scoped to + the generic view, so the built-in views keep their own looks. */ +#cockpit-hud[data-generic] { + --cockpit-subject-panel: rgba(0, 7, 12, 0.66); + --cockpit-subject-live: var(--cockpit-accent); + --cockpit-subject-estimate: #c9b6ff; + --cockpit-subject-warning: #ffb800; + --cockpit-subject-alert: var(--cockpit-emergency); + --cockpit-subject-progress-band: 46px; +} +#cockpit-hud[data-generic] [data-tone='muted'] { + opacity: 0.62; +} +#cockpit-hud[data-generic] [data-tone='live'] { + color: var(--cockpit-subject-live); +} +#cockpit-hud[data-generic] [data-tone='estimate'] { + color: var(--cockpit-subject-estimate); +} +#cockpit-hud[data-generic] [data-tone='warning'] { + color: var(--cockpit-subject-warning); +} +#cockpit-hud[data-generic] [data-tone='alert'] { + color: var(--cockpit-subject-alert); +} + +/* The veil darkens the picture under the HUD; its streaks are lights + passing the window. */ +#cockpit-hud[data-generic] .cockpit-veil { + position: absolute; + inset: 0; + background: #000; + pointer-events: none; + transition: opacity 0.25s linear; +} +#cockpit-hud[data-generic] .cockpit-veil.streaks::before { + content: ''; + position: absolute; + inset: 34% -40% 46%; + background: repeating-linear-gradient( + 90deg, + transparent 0 22%, + rgba(255, 214, 150, 0.16) 23%, + rgba(255, 236, 200, 0.42) 24%, + rgba(255, 214, 150, 0.16) 25%, + transparent 26% 48% + ); + filter: blur(3px); + animation: cockpit-veil-streaks 1.4s linear infinite; +} +@keyframes cockpit-veil-streaks { + from { + transform: translateX(0); + } + to { + transform: translateX(-26%); + } +} +@media (prefers-reduced-motion: reduce) { + #cockpit-hud[data-generic] .cockpit-veil.streaks::before { + animation: none; + } + #cockpit-hud[data-generic] .cockpit-veil { + transition: none; + } +} + +/* The banner: top centre, below the top line. */ +#cockpit-hud[data-generic] .cockpit-banner { + position: absolute; + top: clamp(92px, 13vh, 128px); + left: 50%; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + max-width: min(560px, 80vw); + padding: 6px 14px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 56%, transparent); + border-radius: 2px; + background: var(--cockpit-subject-panel); + text-align: center; + transform: translateX(-50%); +} +#cockpit-hud[data-generic] .cockpit-banner strong { + font-size: 15px; + letter-spacing: 0.14em; +} +#cockpit-hud[data-generic] .cockpit-banner small { + font-size: 11px; + letter-spacing: 0.08em; +} + +/* The board: cards on the left, below the page's left rail and the Intel + HUD's top corner (both stay on in first person), the sources under them. */ +#cockpit-hud[data-generic] { + --cockpit-subject-board-top: max(23rem, 41vh); +} +#cockpit-hud[data-generic] .cockpit-board { + position: absolute; + top: var(--cockpit-subject-board-top); + left: clamp(22px, 4vw, 58px); + display: flex; + flex-direction: column; + gap: 8px; + width: min(320px, 30vw); + min-width: 220px; + max-height: calc(100vh - var(--cockpit-subject-board-top) - 11rem); + overflow: hidden; +} +#cockpit-hud[data-generic] .cockpit-board-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 10px; + border-left: 2px solid + color-mix(in srgb, var(--cockpit-accent) 72%, transparent); + background: var(--cockpit-subject-panel); +} +#cockpit-hud[data-generic] .cockpit-board-card small { + font-size: 10px; + letter-spacing: 0.16em; + opacity: 0.78; +} +#cockpit-hud[data-generic] .cockpit-board-card strong { + font-size: 17px; + letter-spacing: 0.04em; + overflow-wrap: anywhere; +} +#cockpit-hud[data-generic] .cockpit-board-card span { + font-size: 12px; + letter-spacing: 0.06em; +} +#cockpit-hud[data-generic] .cockpit-sources { + display: flex; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 6px 10px; + background: rgba(0, 7, 12, 0.5); + font-size: 10px; + letter-spacing: 0.06em; +} +#cockpit-hud[data-generic] .cockpit-sources div { + display: flex; + gap: 6px; +} +#cockpit-hud[data-generic] .cockpit-sources dt { + flex: none; + min-width: 64px; + opacity: 0.7; + letter-spacing: 0.14em; +} +#cockpit-hud[data-generic] .cockpit-sources dd { + margin: 0; + overflow-wrap: anywhere; +} + +/* The speed's tag (live or estimated) sits where a label would. */ +#cockpit-hud[data-generic] .cockpit-speed-tag { + font-weight: 700; +} +/* With no speed readout the heading still holds the middle of the row. */ +#cockpit-hud[data-generic] .cockpit-heading-value { + grid-column: 2; +} +#cockpit-hud[data-generic] .cockpit-aircraft-meta[data-tone='warning'], +#cockpit-hud[data-generic] .cockpit-aircraft-meta[data-tone='alert'] { + font-weight: 700; +} + +/* Look-around: the readout and LOOK AHEAD centred under the style switcher, + in the sky over the forward view; the bottom corners hold the position + readouts. Styled as on a bridge. */ +#cockpit-hud[data-generic] .cockpit-look { + position: absolute; + top: calc(clamp(22px, 4vh, 42px) + 70px); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; +} +#cockpit-hud[data-generic] .cockpit-look[hidden] { + display: none; +} +#cockpit-hud[data-generic] .cockpit-look-readout { + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 46%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.62); + color: var(--cockpit-accent); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + white-space: nowrap; +} +#cockpit-hud[data-generic] .cockpit-look-ahead { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 64%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.78); + color: #f4fbff; + font: inherit; + font-size: 10px; + letter-spacing: 0.14em; + cursor: pointer; + pointer-events: auto; +} +#cockpit-hud[data-generic] .cockpit-look-ahead:hover { + border-color: var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-look-ahead:focus-visible { + outline: 2px solid var(--cockpit-accent); + outline-offset: 2px; +} + +/* The progress line takes a band along the bottom edge; the readouts that + sit there move up above it. */ +#cockpit-hud[data-generic][data-progress] + :is(.cockpit-help, .cockpit-position-readout) { + bottom: calc( + clamp(22px, 4vh, 42px) + var(--cockpit-subject-progress-band) - 14px + ); +} +#cockpit-hud[data-generic][data-progress] .cockpit-compass { + bottom: calc(clamp(44px, 7vh, 72px) + var(--cockpit-subject-progress-band)); +} +#cockpit-hud[data-generic] .cockpit-progress { + position: absolute; + right: clamp(22px, 4vw, 58px); + bottom: 6px; + left: clamp(22px, 4vw, 58px); + height: var(--cockpit-subject-progress-band); +} +#cockpit-hud[data-generic] .cockpit-progress-line { + position: absolute; + right: 0; + bottom: 8px; + left: 0; + height: 2px; + background: color-mix(in srgb, var(--cockpit-accent) 78%, transparent); +} +#cockpit-hud[data-generic] .cockpit-progress-travelled { + position: absolute; + inset: 0 auto 0 0; + background: rgba(0, 7, 12, 0.72); +} +#cockpit-hud[data-generic] .cockpit-progress-tick { + position: absolute; + bottom: 5px; + width: 2px; + height: 8px; + margin-left: -1px; + background: currentColor; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.labelled { + height: 12px; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.merged { + width: 4px; + margin-left: -2px; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.travelled { + opacity: 0.45; +} +#cockpit-hud[data-generic] .cockpit-progress-label { + position: absolute; + bottom: 20px; + display: flex; + flex-direction: column; + overflow: hidden; + font-size: 10px; + letter-spacing: 0.06em; + line-height: 1.2; + white-space: nowrap; +} +#cockpit-hud[data-generic] .cockpit-progress-label[data-align='center'] { + align-items: center; + text-align: center; +} +#cockpit-hud[data-generic] .cockpit-progress-label[data-align='end'] { + align-items: flex-end; + text-align: end; +} +#cockpit-hud[data-generic] .cockpit-progress-label small { + opacity: 0.82; +} +#cockpit-hud[data-generic] .cockpit-progress-marker { + position: absolute; + bottom: 4px; + width: 10px; + height: 10px; + margin-left: -5px; + border-radius: 50%; + background: var(--cockpit-accent); + box-shadow: 0 0 10px var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-progress-marker.dwell { + background: transparent; + border: 2px solid var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-progress-tag { + position: absolute; + right: 0; + bottom: -2px; + font-size: 9px; + letter-spacing: 0.14em; + opacity: 0.7; +} diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index eebb4e785..ce212a46d 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -405,6 +405,76 @@ html:not([lang='en']) #global-loading-detail { } } +/* ── Render failure notice ───────────────────── */ +/* Above the loading screen, so a globe that fails while it loads says so too, + and above floating panels such as the Québec camera viewer (2000). */ +#render-failure { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 2001; + box-sizing: border-box; + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + overflow-y: auto; + padding: 20px 22px; + background: var(--glass-bg); + border: 1px solid #ff7272; + border-radius: 12px; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 0 24px rgba(255, 88, 88, 0.18); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.5; +} + +#render-failure-title { + margin: 0 0 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 2px; + text-transform: uppercase; + color: #ff7272; +} + +#render-failure p { + margin: 0 0 10px; +} + +#render-failure .render-failure-detail { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +#render-failure .render-failure-hint { + font-size: 12px; + color: var(--text-secondary); +} + +#render-failure .render-failure-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +#render-failure .gev-action-button { + font-size: 11px; + padding: 8px 14px; +} + +@media (pointer: coarse) { + #render-failure .gev-action-button { + min-height: 44px; + } +} + /* ── Toast Notification ──────────────────────── */ #toast { position: fixed; diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index d02122878..0d7fc4e9a 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -13,6 +13,7 @@ import { useGlobeActionsStore } from '../stores/globeActions.js'; import CockpitBriefing from './panels/CockpitBriefing.vue'; import CockpitContext from './panels/CockpitContext.vue'; import CockpitOrbital from './panels/CockpitOrbital.vue'; +import CockpitProgress from './panels/CockpitProgress.vue'; import CockpitRadar from './panels/CockpitRadar.vue'; import CockpitRollingValue from './panels/CockpitRollingValue.vue'; import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; @@ -29,30 +30,58 @@ const active = computed(() => Boolean(presence.value?.active)); const bridge = computed(() => presence.value?.subjectKind === 'vessel'); // A satellite subject keeps only its orbital HUD over the nadir picture. const orbital = computed(() => presence.value?.subjectKind === 'satellite'); +// An application provider's subject (src/cockpitSubjectProviders.js): a +// window view whose readouts, labels and texts the provider words. +const generic = computed(() => presence.value?.subjectGeneric === true); +const labels = computed(() => presence.value?.subjectLabels ?? null); const subject = computed(() => - bridge.value ? 'vessel' : orbital.value ? 'satellite' : 'aircraft', + generic.value + ? presence.value.subjectKind + : bridge.value + ? 'vessel' + : orbital.value + ? 'satellite' + : 'aircraft', ); const hudLabel = computed(() => - bridge.value - ? t('Ship bridge view') - : orbital.value - ? t('Satellite orbital view') - : t('Aircraft cockpit view'), + generic.value + ? labels.value?.view + : bridge.value + ? t('Ship bridge view') + : orbital.value + ? t('Satellite orbital view') + : t('Aircraft cockpit view'), ); const exitLabel = computed(() => - bridge.value - ? t('Exit bridge view') - : orbital.value - ? t('Exit orbital view') - : t('Exit cockpit view'), + generic.value + ? labels.value?.exit + : bridge.value + ? t('Exit bridge view') + : orbital.value + ? t('Exit orbital view') + : t('Exit cockpit view'), ); const exitText = computed(() => - bridge.value - ? t('EXIT BRIDGE') - : orbital.value - ? t('EXIT ORBIT') - : t('EXIT COCKPIT'), + generic.value + ? labels.value?.exitText + : bridge.value + ? t('EXIT BRIDGE') + : orbital.value + ? t('EXIT ORBIT') + : t('EXIT COCKPIT'), ); +// Look-around: on a bridge, or in a provider's view that has it. +const looks = computed( + () => bridge.value || (generic.value && Boolean(presence.value?.subjectLook)), +); +const aheadLabel = computed(() => + generic.value + ? presence.value?.subjectLook?.aheadLabel + : t('Look ahead: turn the bridge view back to the bow'), +); +const veilStyle = computed(() => ({ + opacity: String(hud.value.veil?.level ?? 0), +})); const globe = useGlobeActionsStore(); const { resetBusy } = storeToRefs(globe); const initialInstruments = createInstrumentState(); @@ -92,7 +121,9 @@ const tickStyle = (tick) =>