mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
feat(flights): aircraft draw life-size fleet models chosen by type
Flights, Military Flights and Local ADS-B draw each aircraft with the fleet model its ICAO type, class or emitter category selects, scaled to the type's length, built from the up-front loaded bytes. The eight third-party aircraft models and their attribution are removed; every shipped model is now the project's own.
This commit is contained in:
1 parent
f6760ad96c
commit
fcc44d0eaf
45 files changed
+970
-1371
No files matched your search
+2
-2
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+30
-17
@@ -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, glTF axes, Z up, nose toward −X), 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).
|
||||
- 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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+32
-33
@@ -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') });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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: `
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
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<object>}} [context]
|
||||
* @returns {Promise<object>} 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();
|
||||
|
||||
/**
|
||||
* 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}`, `position` the point
|
||||
* the model's origin stands on (the waterline, the road or rail top; the
|
||||
* spec's `elevationM` is added here), `tint` a Cesium.Color for a tinted
|
||||
* model's route colour (or null), `show` false to keep it hidden.
|
||||
* @param {object} options
|
||||
* @param {object} options.viewer Cesium viewer.
|
||||
* @param {() => object|null} [options.display] `{models3d, models3dMode}`.
|
||||
* @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<object>} [options.createModel]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createFleetModelLayer({
|
||||
viewer,
|
||||
display = () => null,
|
||||
policy,
|
||||
resolveAsset = (url) => url,
|
||||
createModel = (spec, options) =>
|
||||
createFleetModel(spec, options, { resolveAsset }),
|
||||
}) {
|
||||
const collection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(collection);
|
||||
/** @type {Map<string, object>} id -> admitted model */
|
||||
const models = new Map();
|
||||
/** @type {Map<string, number>} 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;
|
||||
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) {
|
||||
return item.spec.tinted && item.tint
|
||||
? fleetTintShader(item.tint)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
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),
|
||||
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 ? item.tint : null;
|
||||
// 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(), ...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;
|
||||
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<string, object>} 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;
|
||||
}
|
||||
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 ? 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+135
-773
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,52 +1,58 @@
|
||||
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,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(real.url),
|
||||
trailAnchorNative: trailAnchorForModel(real.url),
|
||||
};
|
||||
}
|
||||
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,
|
||||
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;
|
||||
const spec = aircraftModelSpec(set, {
|
||||
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
|
||||
category: aircraft?.category ?? null,
|
||||
});
|
||||
return Object.freeze({
|
||||
...spec,
|
||||
bellyM: spec.clearanceM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(url),
|
||||
trailAnchorNative: trailAnchorForModel(url),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ?? ''}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, object>} 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?.();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user