diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index b1f44764c..6290835a2 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..cbb99d124 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:** 121 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/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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 07df2ea24..80e7b8860 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. @@ -2420,8 +2420,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 @@ -3653,20 +3654,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 @@ -3701,7 +3704,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 @@ -4877,8 +4880,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. @@ -7337,8 +7341,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, 121 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): its ICAO type's entry in `types.json`, 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. `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/121 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. 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 model's own vertex colour ~6%; military models are flat amber, or emergency red. 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. +- 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). 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; 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/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..1ce347760 --- /dev/null +++ b/public/models/fleet/catalog.json @@ -0,0 +1,2301 @@ +{ + "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, + -0.906, + 0.019 + ], + "max": [ + 2.3, + 0.906, + 1.948 + ], + "length": 4.61, + "beam": 1.812, + "clearance": 0.0, + "tris": 206, + "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": [ + -5.51, + -1.5, + 0.027 + ], + "max": [ + 5.5, + 1.5, + 4.08 + ], + "length": 11.01, + "beam": 3.0, + "clearance": 0.0, + "tris": 314, + "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.0, + -1.45, + 0.024 + ], + "max": [ + 3.0, + 1.45, + 2.1 + ], + "length": 6.0, + "beam": 2.9, + "clearance": 0.0, + "tris": 176, + "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" + }, + "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.0, + 0.019 + ], + "max": [ + 2.75, + 1.0, + 2.438 + ], + "length": 5.51, + "beam": 2.0, + "clearance": 0.0, + "tris": 206, + "icon": "vehicle" + } +} \ No newline at end of file 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..f8a2f6f8f 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..a07a2e0ae 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..9d04c17f3 Binary files /dev/null and b/public/models/fleet/other/pushback.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..0cf6ebaac 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..d0a62f091 --- /dev/null +++ b/public/models/fleet/types.json @@ -0,0 +1,2001 @@ +{ + "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 + ] + }, + "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 + ] + }, + "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 d2cce50af..42cc132d7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1435,5 +1435,9 @@ "src/ui/ownerFlagSheet.js", "src/data/placePacksLoading.test.mjs", "src/ui/placePacksLoading.js", - "src/ui/placePacksLoading.test.mjs" + "src/ui/placePacksLoading.test.mjs", + "src/data/fleetModels.test.mjs", + "src/data/fleetModelInstances.test.mjs", + "src/testSupport/fleetGlb.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 1fde2e2f8..c1dc35c25 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1505,6 +1505,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", @@ -1579,6 +1582,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", @@ -1678,6 +1686,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", @@ -2546,6 +2561,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", @@ -3813,6 +3832,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", @@ -5167,6 +5190,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", @@ -5250,6 +5280,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", 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/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/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/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/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.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/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..360c59af3 --- /dev/null +++ b/src/data/fleetModels.js @@ -0,0 +1,481 @@ +/** + * @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) and ADS-B + * emitter category `category`: the type's, else the category's, else + * `default_aircraft`. + * @param {object} types Parsed `types.json`. + * @param {string} [type] + * @param {string} [category] + * @returns {{model: string, length: number|null}} + */ +export function resolveAircraft(types, type, category) { + const t = type?.trim().toUpperCase(); + const [model, length] = + (t && (types.aircraft[t] ?? types.helicopters[t])) || + (category && types.categories[category]) || + types.default_aircraft; + return { model, length }; +} + +/** + * 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 entry first; 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}} aircraft + * @returns {object} {@link fleetModelSpec} + */ +export function aircraftModelSpec(set, { typeCode, type, category } = {}) { + const { types, catalog } = set; + const code = String(typeCode ?? type ?? '') + .trim() + .toUpperCase(); + const emitter = emitterCategory(category); + let resolved = resolveAircraft(types, code, emitter); + const typed = code && (types.aircraft[code] || types.helicopters[code]); + if (!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), + ...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..8d773901f --- /dev/null +++ b/src/data/fleetModels.test.mjs @@ -0,0 +1,457 @@ +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], + }, + helicopters: { EC35: ['ec35', 10.2] }, + 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('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: 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..4c109801b --- /dev/null +++ b/src/data/fleetTrailAnchors.js @@ -0,0 +1,66 @@ +/** + * 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.3, 0.418, 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.5, 0.6051, 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([3, 0.2499, 0]), + r44: Object.freeze([4.4219, -0.3001, 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.75, 0.418, 0]), +}); diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index d91914805..e11bd0783 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,39 @@ 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. + 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 +88,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 +135,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 +158,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 +218,81 @@ 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)); +}); 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/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..b9aa95379 100644 --- a/src/layers/flights/modelSpec.js +++ b/src/layers/flights/modelSpec.js @@ -1,52 +1,70 @@ -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'; +import { aircraftModelSpec, fleetModelSet } from '../../data/fleetModels.js'; +import { MODEL_COLOR_BLEND_AMOUNT } from './policy.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, +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, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + 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 (`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}|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); + let layerSpec = cache.get(key); + if (!layerSpec) { + const spec = aircraftModelSpec(set, { + typeCode: aircraft?.typeCode ?? aircraft?.type ?? null, + category: aircraft?.category ?? null, + }); + layerSpec = Object.freeze({ + ...spec, + bellyM: spec.clearanceM, blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; + }); + 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. + * @param {object|null|undefined} aircraft + * @returns {string} + */ +export function aircraftModelSpecCacheKey(aircraft) { + return `${aircraft?.typeCode ?? aircraft?.type ?? ''}|${aircraft?.category ?? ''}`; } 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..21a559769 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,21 +84,12 @@ 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 +// every fleet model is nose -X: 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. +// with only a weak diffuse contribution from the models' own colours. 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; - export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); export const COCKPIT_CONTACT_SIZE_PX = 6; @@ -203,6 +188,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/rendering.js b/src/layers/flights/rendering.js index bf8dbb5ee..1ebbf495f 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'; @@ -115,11 +119,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; } @@ -309,7 +316,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 +423,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 +437,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 +482,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 +506,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,12 +516,11 @@ 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, @@ -560,7 +570,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 +726,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,14 +736,13 @@ 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, @@ -766,7 +778,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 +852,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..a8c62b2bc 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) { diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 263c21210..431caf9b6 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 = { @@ -529,12 +538,14 @@ 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( diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 55fc2d11a..ef5c9bd02 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, @@ -16,19 +18,24 @@ import { } from '../flights/policy.js'; import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; -const specKey = (spec) => `${spec.url}@${spec.scale}`; +const specOf = (marker) => + aircraftLayerModelSpec({ + typeCode: marker?.typeCode, + category: marker?.category, + }); /** * 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 + * eligibility (`selectModelEligible`), the same fleet model per type, life + * size (`aircraftLayerModelSpec`) and the same tint path (a MIX colour blend, here * magenta, as Flights tints military amber). 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. @@ -44,6 +51,9 @@ export function createLocalAdsbModels({ 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 +85,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 +96,20 @@ 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, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: spec.blendAmount, + id, + }, + { resolveAsset, load: loadModel }, + ); } catch { if (pending.get(id) === loadGeneration) pending.delete(id); return; @@ -106,7 +120,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..7a62bc92d 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,33 +106,12 @@ 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. +// every fleet model is nose -X: opposite Cesium heading 0 +// Preserve the layer's amber identity while reducing the models' own colours +// 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; - /** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ export const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0); 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..a63955a36 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, @@ -83,48 +72,20 @@ 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, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + 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; } @@ -312,7 +273,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 +311,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 +346,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 +403,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 +424,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,12 +433,11 @@ 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, @@ -522,7 +486,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 +640,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,14 +650,13 @@ 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, @@ -724,7 +690,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 +759,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/lifecycle.js b/src/layers/rail/lifecycle.js index e44dc0ce3..bcf79fadc 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); @@ -127,6 +128,7 @@ export function createLifecycle({ state, services, parts, timetable }) { state._heldFeedId = 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 +156,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/rendering.js b/src/layers/rail/rendering.js index eff1ace98..db124b752 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,11 @@ export function createRendering({ state, services, parts }) { ); } + /** Whether a train's icon is drawn: shown, and not drawn by its model. */ + function markerShown(entry) { + return categoryShown(entry) && !entry.modelOwnsVisual; + } + /** * 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 +557,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 +575,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. */ @@ -658,7 +679,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 +687,132 @@ 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; + item.show = categoryShown(entry); + 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 +844,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 +867,7 @@ export function createRendering({ state, services, parts }) { applyCategoryVisibility, categoryShown, reset, + startModels, + stopModels, }; } diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js index 4f4564bc6..8a4dc3a87 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; }; } diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 90210063a..2b24f51de 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -195,6 +195,7 @@ export function createLifecycle({ state, services, parts }) { parts.rendering.onPreRender, ); } + parts.rendering.startModels(); parts.viewport.runProximityCheck(); restoreSpriteOrder(viewer); }, @@ -242,6 +243,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(); @@ -282,6 +284,7 @@ export function createLifecycle({ state, services, parts }) { */ destroy(viewer) { this.disable(viewer); + parts.rendering.stopModels(true); if (state._markers) { unregisterSpriteCollection('transit', state._markers); viewer?.scene?.primitives?.remove(state._markers); 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..0888945a4 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,8 @@ export function createRendering({ state, services, parts }) { ); entry.surfaceReady = !!known; entry.heightPending = !known; - entry.marker.show = !!known && vehicleInView(entry); + entry.marker.show = + !!known && vehicleInView(entry) && !entry.modelOwnsVisual; if (known) { entry.marker.position = state._scratchCartesian; entry.hasRendered = true; @@ -403,6 +410,7 @@ export function createRendering({ state, services, parts }) { const now = state.now(); if (state._vehicles.size === 0) { state._moving.clear(); + updateModels(); syncRenderHold(); return; } @@ -450,9 +458,111 @@ 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. */ + function handBack(entry) { + if (!entry.modelOwnsVisual) return; + entry.modelOwnsVisual = false; + if (entry.marker) + entry.marker.show = + vehicleInView(entry) && + !entry.heightPending && + entry.surfaceReady !== false; + } + + 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; + item.show = !entry.heightPending && entry.surfaceReady !== false; + 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(); @@ -556,7 +666,10 @@ export function createRendering({ state, services, parts }) { } } entry.marker.show = - visible && !entry.heightPending && entry.surfaceReady !== false; + visible && + !entry.heightPending && + entry.surfaceReady !== false && + !entry.modelOwnsVisual; visibility.heightPending = !!entry.heightPending; visibility.surfaceReady = entry.surfaceReady ?? null; schedulePlayback(entry); @@ -621,5 +734,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..d557b1b99 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) && 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/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), + }; +}