diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index b1f44764c..6290835a2 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -1,13 +1,13 @@ # Data Sources & Attribution -God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources; bundled 3D-model provenance is recorded in [`public/models/README.md`](public/models/README.md). +God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources. The bundled 3D models are the project's own procedural fleet models, MIT-licensed with the code ([`public/models/README.md`](public/models/README.md)). How to read this: - **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms. - **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. - **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes. -- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status. +- **The bundled 3D models are the project's own.** Every model under `public/models/` is a procedural fleet model generated by the project maintainers and covered by the repository's MIT license; [`public/models/README.md`](public/models/README.md) describes the set. --- diff --git a/LICENSE b/LICENSE index 4f01699a2..86483f2d6 100644 --- a/LICENSE +++ b/LICENSE @@ -56,7 +56,6 @@ In short: the MIT grant does not extend to any third-party data. See DATA_SOURCES.md for the full per-source license + attribution summary, and remove any dataset whose terms don't fit your use. -The 3D models under public/models/ are also third-party works and are NOT -licensed under MIT. They remain under their individual licenses. See -public/models/README.md for the author, source, license, modification notice, -and attribution covering each model file. +The 3D models under public/models/ are the project's procedural fleet +models, generated from code by the project maintainers, and are covered by +the MIT license above like the source code. See public/models/README.md. diff --git a/README.md b/README.md index 74479d046..cbb99d124 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat - **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit. - **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. - **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. -- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in. +- **🛫 3D fleet:** 121 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in. - **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir. - **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. - **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. diff --git a/public/models/README.md b/public/models/README.md index aa7f40fbd..3375af3b6 100644 --- a/public/models/README.md +++ b/public/models/README.md @@ -1,20 +1,33 @@ -# Bundled 3D Model Attribution +# Bundled 3D Models -The model files in this directory are third-party visual assets. They are not -covered by the repository's MIT source-code license; each remains available -under the license listed below. +Every 3D model shipped in this directory is one of the project's procedural +fleet models under [`fleet/`](fleet/): aircraft, helicopters, ships, buoys, +transit and rail vehicles, and a few other airborne and ground objects. They +are generated from code by the project maintainers, contain no third-party +artwork, and are covered by the repository's [MIT license](../../LICENSE) +like the source code. -| File | Original work and creator | Source | License | Project modifications | -|---|---|---|---|---| -| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. | -| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. | -| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `mq9.glb` | “MQ-9” by [IProZenoN](https://sketchfab.com/IProZenoN) | [Sketchfab model](https://sketchfab.com/3d-models/mq-9-fabe963feb354c5584b51f9c470c3f7e) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `b789.glb` | “Boeing 787-9” by [Nobilis 2](https://sketchfab.com/nobilishornet2) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-787-9-b6711e2e698e4e469675c1154a50b7a3) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `atr72.glb` | “ATR 72 - 600” by [Oyan3D](https://sketchfab.com/oyan3D) | [Sketchfab model](https://sketchfab.com/3d-models/atr-72-600-1e1a7186f7444d288675262fcee44744) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures removed with dominant material colors baked into PBR factors (flat “abstracted” style), orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | +| Path | What it holds | +|---|---| +| `fleet/catalog.json` | Every model by name: its file, category, built length, beam and bounds (metres, 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. diff --git a/public/models/airplane.glb b/public/models/airplane.glb deleted file mode 100644 index 016a56f1d..000000000 Binary files a/public/models/airplane.glb and /dev/null differ diff --git a/public/models/atr72.glb b/public/models/atr72.glb deleted file mode 100644 index 8946dc39c..000000000 Binary files a/public/models/atr72.glb and /dev/null differ diff --git a/public/models/b789.glb b/public/models/b789.glb deleted file mode 100644 index be9849d74..000000000 Binary files a/public/models/b789.glb and /dev/null differ diff --git a/public/models/bell206.glb b/public/models/bell206.glb deleted file mode 100644 index 029fe1339..000000000 Binary files a/public/models/bell206.glb and /dev/null differ diff --git a/public/models/c172.glb b/public/models/c172.glb deleted file mode 100644 index 3c5155433..000000000 Binary files a/public/models/c172.glb and /dev/null differ diff --git a/public/models/citation2.glb b/public/models/citation2.glb deleted file mode 100644 index 2ec47d6dc..000000000 Binary files a/public/models/citation2.glb and /dev/null differ diff --git a/public/models/jet.glb b/public/models/jet.glb deleted file mode 100644 index bb2f99f9f..000000000 Binary files a/public/models/jet.glb and /dev/null differ diff --git a/public/models/mq9.glb b/public/models/mq9.glb deleted file mode 100644 index 47bc21f91..000000000 Binary files a/public/models/mq9.glb and /dev/null differ diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index fb33654b1..b9151bbbd 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -40,7 +40,7 @@ * the DISPLAYED (render-delayed) time within 35° — catches * sign/offset inversion in the whole chain (chord lags the * tangent by ≤ half a segment ≈ 22.5°) - * Both layers: flights (airplane.glb, offset 180°) then military (jet.glb, + * Both layers: flights (fleet models, offset 180°) then military (fleet models, * normalized nose -X, offset 180°). * * VISUAL — with the turning flights plane tracked in 3D, capture screenshots @@ -721,7 +721,7 @@ async function main() { // ======================================================================== // Phase 1 — FLIGHTS layer: track TRN001, sample displayed course 30 s // ======================================================================== - console.log('\nPhase 1 — flights layer (airplane.glb, heading offset 180°)'); + console.log('\nPhase 1 — flights layer (fleet models, heading offset 180°)'); await page.evaluate((icao) => { window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao); }, TURN.flights[0].icao); @@ -807,7 +807,7 @@ async function main() { // ======================================================================== // Phase 2 — MILITARY layer: track TRNMIL1 (flights layer auto-clears) // ======================================================================== - console.log('\nPhase 2 — military layer (jet.glb, heading offset 180°)'); + console.log('\nPhase 2 — military layer (fleet models, heading offset 180°)'); await page.evaluate((hex) => { window.__godsEyeView.dataManager.layers.get('military').module.trackById(hex); }, TURN.military[0].hex); diff --git a/scripts/qa-sky-model-lighting.mjs b/scripts/qa-sky-model-lighting.mjs index 2f401506b..206d19d78 100644 --- a/scripts/qa-sky-model-lighting.mjs +++ b/scripts/qa-sky-model-lighting.mjs @@ -75,7 +75,7 @@ try { } const position = C.Cartesian3.fromDegrees(0, 0, 10000); const model = await C.Model.fromGltfAsync({ - url: '/models/airplane.glb', + url: '/models/fleet/aircraft/a320.glb', modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position), }); scene.primitives.add(model); diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index 7e5b824c0..190a0c651 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -15,9 +15,9 @@ * per-class glyph (image data-URI === aircraftIcon(klass), * imported from src) and per-class scale (flights: * CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D) - * B3 models : with 3D on, fleet Cesium.Model.scale === the layer's - * MODEL_SCALE (both 1; legacy scale is baked into the GLB) - * × CLASS_SCALE_3D[klass] per icao; tracked too + * B3 models : with 3D on, fleet Cesium.Model.scale === the life-size + * scale of the fleet model the aircraft's type or category + * selects (aircraftModelSpec) per icao; tracked too * B4 clean : no console errors * * Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows, @@ -33,15 +33,18 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; import { stubPlanespotters } from './qa-photo-stub.mjs'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; -// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military -// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only -// fastjet/unknown keep the per-layer jet/airplane MODEL_SCALE formula. -const flightsWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 : FL_MODEL_SCALE * CLASS_SCALE_3D[klass]); -const MIL_PLANE_CLASSES = new Set(['airliner', 'quadjet', 'glider']); -const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 - : MIL_PLANE_CLASSES.has(klass) ? MIL_PLANE_MODEL_SCALE * CLASS_SCALE_3D[klass] - : MIL_MODEL_SCALE * CLASS_SCALE_3D[klass]); +import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js'; +import { aircraftModelSpec } from '../src/data/fleetModels.js'; +// Every aircraft draws its fleet model life size: its type's length over the +// model's built length, chosen by type code (military) or category (flights). +const FLEET_SET = Object.freeze({ + catalog: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/catalog.json', import.meta.url), 'utf8')), + types: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/types.json', import.meta.url), 'utf8')), +}); +const flightsWantScale = (icao) => + aircraftModelSpec(FLEET_SET, { category: SPRITES.flights.find((p) => p.icao === icao)?.category }).scale; +const militaryWantScale = (hex) => + aircraftModelSpec(FLEET_SET, { typeCode: SPRITES.military.find((p) => p.hex === hex)?.t }).scale; import { aircraftIcon } from '../src/data/aircraftIcons.js'; // --------------------------------------------------------------------------- @@ -58,9 +61,6 @@ const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = getFlag('--headful'); const SHOT_DIR = path.resolve('qa-shots/b5'); -const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale) -const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb -const MIL_MODEL_SCALE = 1; // militaryFlights.js (jet.glb is already real-world scale, native radius ~29.8 m) const MIL_BILLBOARD_SCALE = 0.7; // militaryFlights BILLBOARD_SCALE const CHROME_EXECUTABLE_CANDIDATES = [ @@ -134,10 +134,9 @@ const SPRITES = { // course 0 like the rest — keeps the row a clean size-comparison line (2D // rotation is demoed by caa004 and gated by qa-heading-b3 anyway). { hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 }, - // 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a - // real-world-scale asset (native bounding radius ~29.8) rendered at - // MODEL_SCALE 1, so models are ~22–43 m radius — same 150 m spacing and - // 450–900 m shot ranges as the flights cluster. + // 3D cluster: C17 vs F16 (tracked) vs B744 vs C130, each drawn life size + // (~8–36 m radius) — same 150 m spacing and 450–900 m shot ranges as the + // flights cluster. { hex: 'ddb001', flight: '3DMWID', t: 'C17', lon0: CLUSTER_LON0 - mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, { hex: 'ddb002', flight: '3DMFJ', t: 'F16', lon0: CLUSTER_LON0, lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, { hex: 'ddb003', flight: '3DMQUA', t: 'B744', lon0: CLUSTER_LON0 + mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, @@ -464,7 +463,7 @@ async function main() { // ======================================================================== // B3 + 3D screenshots — flights cluster // ======================================================================== - console.log('\n3D — flights cluster (airplane.glb, per-class scale)'); + console.log('\n3D — flights cluster (fleet models, life size)'); await page.evaluate(() => { const dm = window.__godsEyeView.dataManager; dm.layers.get('flights').module.setParams({ models3d: true }); @@ -481,20 +480,20 @@ async function main() { const flModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready)); const modelScaleBad = []; for (const [id, want] of [ - ['cbb001', flightsWantScale(expectedFlights.get('cbb001'))], - ['cbb003', flightsWantScale(expectedFlights.get('cbb003'))], + ['cbb001', flightsWantScale('cbb001')], + ['cbb003', flightsWantScale('cbb003')], ]) { const m = flModels.find((x) => x.id === id); if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`); } const trackedM = flModels.find((x) => x.id === 'cbb002'); - const trackedWant = flightsWantScale(expectedFlights.get('cbb002')); + const trackedWant = flightsWantScale('cbb002'); if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) { modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`); } - record('B3a flights: model scales match the per-class registry (all GLBs meter-scale; shared @1×class)', + record('B3a flights: model scales match each type's life-size fleet model', modelScaleBad.length === 0, - modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry'); + modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact'); console.log(` world bounding radii (m): ${flModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`); await sleep(1500); @@ -509,7 +508,7 @@ async function main() { // ======================================================================== // B3 + 3D screenshots — military cluster // ======================================================================== - console.log('\n3D — military cluster (jet.glb, per-class scale)'); + console.log('\n3D — military cluster (fleet models, life size)'); await page.evaluate(() => { const dm = window.__godsEyeView.dataManager; dm.layers.get('military').module.setParams({ models3d: true }); @@ -526,25 +525,25 @@ async function main() { const milModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready)); const modelScaleBad = []; for (const [id, want] of [ - ['ddb001', militaryWantScale(expectedMilitary.get('ddb001'))], - ['ddb003', militaryWantScale(expectedMilitary.get('ddb003'))], - ['ddb004', militaryWantScale(expectedMilitary.get('ddb004'))], + ['ddb001', militaryWantScale('ddb001')], + ['ddb003', militaryWantScale('ddb003')], + ['ddb004', militaryWantScale('ddb004')], ]) { const m = milModels.find((x) => x.id === id); if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`); } const trackedM = milModels.find((x) => x.id === 'ddb002'); - const trackedWant = militaryWantScale(expectedMilitary.get('ddb002')); + const trackedWant = militaryWantScale('ddb002'); if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) { modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`); } - record('B3b military: model scales match the weight-class registry (real/heavy/jet assets @1×class)', + record('B3b military: model scales match each type's life-size fleet model', modelScaleBad.length === 0, - modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry'); + modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact'); console.log(` world bounding radii (m): ${milModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`); await sleep(1500); - // jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the + // Fleet models are drawn life size — same shot ranges as the // flights cluster (close zoom no longer puts the camera inside a model). await zoomToRange(900); await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') }); diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index f87250430..10d3e3979 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -97,9 +97,15 @@ import fs from 'node:fs'; import puppeteer from 'puppeteer'; import { respondToPlanespotters } from './qa-photo-stub.mjs'; import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs'; -import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; +import { aircraftModelSpec } from '../src/data/fleetModels.js'; import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js'; +/** The fleet model set the aircraft layers draw from, read from disk. */ +const FLEET_SET = Object.freeze({ + catalog: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/catalog.json', import.meta.url), 'utf8')), + types: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/types.json', import.meta.url), 'utf8')), +}); + // --------------------------------------------------------------------------- // Args // --------------------------------------------------------------------------- @@ -547,13 +553,13 @@ async function main() { // Independent GLB capability control. Product model absence cannot prove // that the browser backend is incapable: that is the condition this - // harness is meant to catch. Load the already-approved airplane asset + // harness is meant to catch. Load one of the shipped fleet models // directly through Cesium, outside the Flights/Military model pipelines, // then remove the control before exercising product behavior. const glbControlStarted = await evalPage(async () => { window.__qaGlbControl = null; window.__qaGlbControlError = null; - const asset = await fetch('/models/airplane.glb'); + const asset = await fetch('/models/fleet/aircraft/a320.glb'); const assetBytes = asset.ok ? (await asset.arrayBuffer()).byteLength : 0; if (!asset.ok || assetBytes === 0) { throw new Error(`GLB control asset unavailable: HTTP ${asset.status}`); @@ -564,7 +570,7 @@ async function main() { Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 9000), ); void Cesium.Model.fromGltfAsync({ - url: '/models/airplane.glb', + url: '/models/fleet/aircraft/a320.glb', modelMatrix, scale: 1, }).then((model) => { @@ -1454,8 +1460,8 @@ async function main() { }, trackedIcao); console.log(' (3D model did not render under SwiftShader; testing the shared display-position contract directly)'); } else { - // Full check: independently transform airplane.glb's measured AABB centre - // through the actual 3D model matrix, then compare it with detection. + // Full check: independently transform the fleet model's visual centre + // (its origin) through the actual 3D model matrix, then compare it with detection. // glTF [x,y,z] → Cesium-local [x,-z,y]. The effective rendered // computedScale is separate from modelMatrix and includes any // minimumPixelSize enlargement, so include it just as the renderer does. @@ -1469,7 +1475,7 @@ async function main() { const scale = Number.isFinite(model.computedScale) ? model.computedScale : (Number.isFinite(model.scale) ? model.scale : 1); - // airplane.glb is now bounding-box centred with transforms applied. + // Fleet models are drawn about their origin, transforms applied. const x = 0 * scale; const y = 0 * scale; const z = 0 * scale; @@ -2229,18 +2235,12 @@ async function main() { // ============================================================ console.log('\nGround 3D — grounded planes are model-eligible + snapped to the sampled skin'); const GROUND3D_STUB_H = 187.5; // stubbed tile-skin height (m, ellipsoid) - // Expected belly offsets (m): native origin-above-lowest-vertex × MODEL_SCALE - // × CLASS_SCALE_3D[klass] — constants match the layers' MODEL_BELLY_OFFSET_NATIVE - // / MODEL_SCALE pairs, which modelScale.test.mjs locks against the GLBs. - // Class is computed with the REAL classifier on the synthetic feed fields. - const g3dFlKlass = classifyAircraft({ typeCode: null, category: null }); - const g3dMilKlass = classifyAircraft({ typeCode: 'C130', category: undefined }); - const g3dFlOffset = 6.719 * 1 * (CLASS_SCALE_3D[g3dFlKlass] || 1); // airplane.glb, flights - // Hangar fleet (2026-08-16): military C130 → turboprop → the real atr72.glb, - // whose belly offset is the registry's measured bellyM (meters, scale 1) — - // NOT the jet.glb 5.631 × class formula. Pinned by modelScale.test.mjs. - const g3dMilOffset = CLASS_MODEL_REAL[g3dMilKlass]?.bellyM - ?? 5.631 * 1 * (CLASS_SCALE_3D[g3dMilKlass] || 1); // jet.glb fallback classes + // Expected belly offsets (m): the fleet model's wheel clearance at its + // life-size scale, the model chosen as the layers choose it + // (aircraftModelSpec) from the synthetic feed's fields: no type for the + // flights contact (the default airliner), a C130 for the military one. + const g3dFlOffset = aircraftModelSpec(FLEET_SET, { typeCode: null }).clearanceM; + const g3dMilOffset = aircraftModelSpec(FLEET_SET, { typeCode: 'C130' }).clearanceM; // Height-datum fix (Task 6): the flights-layer billboard for a grounded synthetic // (no geo_altitude in this stub feed, no warm terrain-height cache in a headless // run) now renders at baroM + geoidHeight(lat, lon) rather than the raw baro @@ -2509,10 +2509,10 @@ async function main() { const flDelta = g3dState.flModelRadius - g3dIngest.flBBRadius; const milDelta = g3dState.milModelRadius - g3dIngest.milBBRadius; const HEIGHT_TOL_M = 1.0; - record(`ground-3d: flights model height = sampled skin + belly offset (${g3dFlKlass})`, + record(`ground-3d: flights model height = sampled skin + belly offset (default airliner)`, Math.abs(flDelta - flWantDelta) <= HEIGHT_TOL_M, `Δradial=${flDelta.toFixed(2)} m want=${flWantDelta.toFixed(2)} m (stub ${GROUND3D_STUB_H} + offset ${g3dFlOffset.toFixed(2)} − alt ${(150 + g3dFlGeoidN).toFixed(2)} [150 baro + ${g3dFlGeoidN.toFixed(2)} geoidN])`); - record(`ground-3d: military model height = sampled skin + belly offset (${g3dMilKlass})`, + record(`ground-3d: military model height = sampled skin + belly offset (C130)`, Math.abs(milDelta - milWantDelta) <= HEIGHT_TOL_M, `Δradial=${milDelta.toFixed(2)} m want=${milWantDelta.toFixed(2)} m (stub ${GROUND3D_STUB_H} + offset ${g3dMilOffset.toFixed(2)} − alt 0)`); record('ground-3d: billboard→model handoff holds on the ground (icons hidden once models render)', @@ -2712,7 +2712,7 @@ async function main() { const scale = Number.isFinite(m.computedScale) ? m.computedScale : (Number.isFinite(m.scale) ? m.scale : 1); - // airplane.glb is now bounding-box centred with transforms applied. + // Fleet models are drawn about their origin, transforms applied. const x = 0 * scale; const y = 0 * scale; const z = 0 * scale; @@ -3722,8 +3722,8 @@ async function main() { // WARM ground, camera IN: the placement. The model is not merely visible, // it is standing ON the floor — within one class belly offset above it, - // never under it. MAX_BELLY_M clears the largest offset any class adds - // (widebody b789, 7.81 m in CLASS_MODEL_REAL); the floor itself is the + // never under it. MAX_BELLY_M clears the largest wheel clearance any + // fleet aircraft stands on at life size; the floor itself is the // hard lower bound, and a model placed at the raw feed altitude is ~240 m // under it. const MAX_BELLY_M = 12; diff --git a/src/data/aircraftClass.js b/src/data/aircraftClass.js index 1be15e84c..4b719b3ec 100644 --- a/src/data/aircraftClass.js +++ b/src/data/aircraftClass.js @@ -467,54 +467,3 @@ export const CLASS_SCALE_2D = { bizjet: 0.72, uav: 0.75, }; - -/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares - * the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when - * real per-class models land in CLASS_MODEL_URL. */ -export const CLASS_SCALE_3D = { - light: 0.75, - glider: 0.75, - turboprop: 0.85, - airliner: 1.0, - widebody: 1.3, - quadjet: 1.45, - helicopter: 0.8, - fastjet: 0.8, - bizjet: 0.8, - uav: 0.8, -}; - -/** Per-class glTF — all the shared airplane today; drop real assets in here. - * NOTE for future models: each asset may need its own heading offset (the - * shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */ -export const CLASS_MODEL_URL = { - light: '/models/airplane.glb', - glider: '/models/airplane.glb', - turboprop: '/models/airplane.glb', - airliner: '/models/airplane.glb', - widebody: '/models/airplane.glb', - quadjet: '/models/airplane.glb', - helicopter: '/models/airplane.glb', - fastjet: '/models/airplane.glb', - bizjet: '/models/airplane.glb', - uav: '/models/airplane.glb', -}; - -/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 — - * provenance in public/models/README.md). Every asset is vertex-baked to the - * airplane.glb convention: Y-up, X = length, Z = span, nose −X (so the layers' - * MODEL_HEADING_OFFSET_DEG = 180 applies unchanged), origin at bbox centre, - * REAL-WORLD METERS (render at scale 1 — no MODEL_SCALE, no CLASS_SCALE_3D). - * bellyM = origin→lowest-vertex distance (grounded-model lift, replaces the - * airplane.glb MODEL_BELLY_OFFSET_NATIVE formula); radiusM = bounding-sphere - * radius in meters (pixel-size math). Values measured from the shipped GLBs — - * pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet, - * glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */ -export const CLASS_MODEL_REAL = { - helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 }, - light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 }, - bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 }, - uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 }, - widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 }, - turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 }, -}; diff --git a/src/data/aircraftClass.test.mjs b/src/data/aircraftClass.test.mjs index c1676a279..84b7394c2 100644 --- a/src/data/aircraftClass.test.mjs +++ b/src/data/aircraftClass.test.mjs @@ -1,7 +1,7 @@ // src/data/aircraftClass.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js'; +import { classifyAircraft, CLASS_SCALE_2D } from './aircraftClass.js'; test('type-code classification (military layer path)', () => { assert.equal(classifyAircraft({ typeCode: 'F16' }), 'fastjet'); @@ -48,19 +48,8 @@ test('typeCode outranks category; unknown → airliner', () => { assert.equal(classifyAircraft({ typeCode: null, category: 0 }), 'airliner'); }); -test('scale/url tables cover every class', () => { +test('the icon scale table covers every class', () => { for (const kind of ['light','glider','turboprop','airliner','widebody','quadjet','helicopter','fastjet','bizjet','uav']) { assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind); - assert.ok(CLASS_SCALE_3D[kind] >= 0.75 && CLASS_SCALE_3D[kind] <= 1.45, kind); - assert.ok(typeof CLASS_MODEL_URL[kind] === 'string', kind); - } -}); - -test('CLASS_MODEL_REAL entries carry the fields the layers consume', () => { - for (const [kind, spec] of Object.entries(CLASS_MODEL_REAL)) { - assert.ok(spec.url.startsWith('/models/') && spec.url.endsWith('.glb'), kind); - assert.ok(spec.radiusM > 0 && spec.bellyM > 0, kind); - // every real-model class must also exist in the classifier tables - assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind); } }); diff --git a/src/data/aircraftIcons.js b/src/data/aircraftIcons.js index bca4ca655..f664c861e 100644 --- a/src/data/aircraftIcons.js +++ b/src/data/aircraftIcons.js @@ -160,8 +160,8 @@ const BODIES = { L -3,31 L -8,34 L -8,30 L -6,16 L -27,26 L -27,20 L -5,-8 C -4.6,-16 -4,-24 -3.5,-30 Z" fill="white" ${STROKE}/>`, - // 2026-08-15 Hangar additions: two NEW classes shipped with the real-model - // fleet (CLASS_MODEL_REAL). Same contract as the set above: 96×96 nose-up, + // 2026-08-15 additions: two NEW classes (bizjet, uav). Same contract as + // the set above: 96×96 nose-up, // white fill, hairline stroke, tint-safe. // Business jet — slender fuselage, modest swept wing, aft engines, T-tail. bizjet: ` diff --git a/src/data/aircraftModelLighting.test.mjs b/src/data/aircraftModelLighting.test.mjs index 51fd9d97c..70be8e5d4 100644 --- a/src/data/aircraftModelLighting.test.mjs +++ b/src/data/aircraftModelLighting.test.mjs @@ -6,9 +6,8 @@ import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js'; /** * Every aircraft model drawn in the scene skips Cesium's dynamic environment - * map (a sky render and GPU readback per model). The preload calls only warm - * the glTF cache and are never added to the scene, so they are left out: - * drawn models are the calls that set a minimum pixel size. + * map (a sky render and GPU readback per model). Drawn models are the calls + * that set a minimum pixel size. */ const SOURCES = [ { @@ -36,7 +35,9 @@ test('the shared option turns the dynamic environment map off', () => { for (const { name, source } of SOURCES) { test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => { const calls = [ - ...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g), + ...source.matchAll( + /(?:fromGltfAsync\(|loadModel\(|(?:_createModel|createFleetModel)\(\s*\w+,\s*)\{([\s\S]*?)\n\s*\}[,)]/g, + ), ] .map((match) => match[1]) .filter((options) => /\bminimumPixelSize:/.test(options)); diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 8177b7414..0488a6d1e 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -28,11 +28,13 @@ for (const layer of LAYERS) { const source = readLayerSource(layer.path); test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => { - const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)]; - assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`); + const calls = [...source.matchAll(/_createModel\(\s*\w+,\s*\{([\s\S]*?)\n\s*\}\)/g)]; + assert.ok(calls.length >= 2, `expected fleet and tracked model calls; found ${calls.length}`); + assert.doesNotMatch(source, /Cesium\.Model\.fromGltfAsync\(/, + 'every model is built from the loaded fleet set through _createModel'); for (const [index, call] of calls.entries()) { assert.match(call[1], /\basynchronous:\s*false\b/, - `Model.fromGltfAsync call ${index + 1} must keep bounded GLB readiness independent of tile jobs`); + `_createModel call ${index + 1} must keep bounded GLB readiness independent of tile jobs`); } }); diff --git a/src/data/fleetModelInstances.js b/src/data/fleetModelInstances.js new file mode 100644 index 000000000..fbd710193 --- /dev/null +++ b/src/data/fleetModelInstances.js @@ -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}} [context] + * @returns {Promise} Cesium Model. + */ +export function createFleetModel( + spec, + options, + { + resolveAsset = (url) => url, + set = fleetModelSet(), + // Looked up when called, so a replaced `fromGltfAsync` (tests) is the one used. + load = (o) => Cesium.Model.fromGltfAsync(o), + } = {}, +) { + const bytes = set?.bytes?.get(spec?.model); + if (!bytes) + return Promise.reject(new Error(`fleet model ${spec?.model} not loaded`)); + return load({ + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + ...options, + gltf: bytes, + basePath: resolveAsset(spec.url), + }); +} + +const tintShaders = new Map(); + +/** + * 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} [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} id -> admitted model */ + const models = new Map(); + /** @type {Map} id -> generation of an in-flight load */ + const pending = new Map(); + let generation = 0; + let destroyed = false; + let lastEligibilityAt = -Infinity; + let eligible = new Set(); + let settling = 0; + 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} 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); + }, + }; +} diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index d91914805..908b45001 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -1,45 +1,33 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; -// Regression test for the giant-military-jet bug: MODEL_SCALE must put every -// rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is. -// -// airplane.glb and jet.glb are both transform-applied at real-world size and -// render with MODEL_SCALE 1. The airplane previously relied on a runtime ×24 -// calibration; that factor is now baked into its geometry so future consumers -// cannot miss it. The original military bug came from copying scale 24 onto the -// already meter-scale jet, rendering military models ~600–1000 m -// across — invisible at the ~6 km follow range where minimumPixelSize -// dominates, but zoom below ~1 km and the camera ends up INSIDE the model. -// -// The layer modules pull the full Cesium engine and don't export their -// constants, so (as in modelMatrix.test.mjs) we don't import them: constants -// are read from source, and each GLB's native bounding radius is computed from -// its POSITION accessor min/max with the node hierarchy's transforms applied -// (both legacy GLBs are now transform-applied, but the hierarchy-aware reader -// guards future asset updates). For jet.glb this yields 29.83 — Cesium's -// Model.boundingSphere.radius / scale measured in-app (2026-07-02). +// Aircraft models draw LIFE SIZE: every aircraft layer takes its model and +// scale from the procedural fleet set (fleetModels.js), a type's published +// length over the model's built length. These pins keep that true — no layer +// carries a scale constant of its own (the giant-military-jet bug was one +// copied across layers) — and keep the trail and bracket anchors riding the +// transform chain Cesium draws the models with. import { test } from 'node:test'; import * as Cesium from 'cesium'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js'; import { - MODEL_TRAIL_ANCHOR_NATIVE, - MODEL_VISUAL_CENTER_NATIVE, modelVisualAnchor, - trailAnchorForModel, trailHeadStart, modelAnchorWorld, } from './modelVisualAnchor.js'; +import { aircraftModelSpec } from './fleetModels.js'; +import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const FLEET = path.join(ROOT, 'public', 'models', 'fleet'); +const set = { + catalog: JSON.parse(fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8')), + types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')), +}; -/** Every class's rendered bounding radius (m) must land in this envelope — - * bigger than any clamped-down light aircraft, smaller than the largest real - * heavy (A380/An-124 bounding radius ~45 m), nowhere near the 500–1000 m of - * the scale-24 jet.glb bug. */ -const WORLD_RADIUS_MIN_M = 10; +/** Every aircraft drawn at its type's size: no rendered radius outside what aircraft span. */ +const WORLD_RADIUS_MIN_M = 2; const WORLD_RADIUS_MAX_M = 60; test('model-space anchors honor Cesium minimum-pixel computed scale', () => { @@ -54,560 +42,39 @@ test('model-space anchors honor Cesium minimum-pixel computed scale', () => { assert.deepEqual(result, { x: 79, y: 214, z: 272 }); }); -// --- minimal GLB bounds reader --------------------------------------------- - -function glbJson(file) { - const buf = fs.readFileSync(file); - assert.equal(buf.readUInt32LE(0), 0x46546c67, `${file}: not a GLB`); - assert.equal(buf.readUInt32LE(16), 0x4e4f534a, `${file}: first chunk not JSON`); - return JSON.parse(buf.subarray(20, 20 + buf.readUInt32LE(12)).toString('utf8')); -} - -// Column-major 4×4 helpers (glTF matrix layout). -const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; -function mul(a, b) { - const o = new Array(16).fill(0); - for (let c = 0; c < 4; c++) - for (let r = 0; r < 4; r++) - for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k]; - return o; -} -function nodeMatrix(node) { - if (node.matrix) return node.matrix; - const t = node.translation || [0, 0, 0]; - const [x, y, z, w] = node.rotation || [0, 0, 0, 1]; - const s = node.scale || [1, 1, 1]; - const rot = [ - 1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0, - 2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0, - 2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0, - 0, 0, 0, 1, - ]; - return mul( - [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, t[0], t[1], t[2], 1], - mul(rot, [s[0], 0, 0, 0, 0, s[1], 0, 0, 0, 0, s[2], 0, 0, 0, 0, 1]), - ); -} -function transformPoint(m, p) { - return [ - m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], - m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], - m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], - ]; -} - -/** Scene-space AABB over all mesh primitives' POSITION bounds, node transforms applied. */ -function nativeBounds(file) { - const gltf = glbJson(file); - const min = [Infinity, Infinity, Infinity]; - const max = [-Infinity, -Infinity, -Infinity]; - const visit = (idx, parent) => { - const node = gltf.nodes[idx]; - const m = mul(parent, nodeMatrix(node)); - if (node.mesh != null) { - for (const prim of gltf.meshes[node.mesh].primitives) { - const acc = gltf.accessors[prim.attributes.POSITION]; - if (!acc?.min || !acc?.max) continue; - for (let i = 0; i < 8; i++) { - const corner = transformPoint(m, [ - (i & 1) ? acc.max[0] : acc.min[0], - (i & 2) ? acc.max[1] : acc.min[1], - (i & 4) ? acc.max[2] : acc.min[2], - ]); - for (let a = 0; a < 3; a++) { - min[a] = Math.min(min[a], corner[a]); - max[a] = Math.max(max[a], corner[a]); - } - } - } - } - for (const child of node.children || []) visit(child, m); - }; - for (const n of gltf.scenes[gltf.scene ?? 0].nodes) visit(n, IDENTITY); - assert.ok(Number.isFinite(min[0]), `${file}: no POSITION bounds found`); - return { min, max }; -} - -// --- real mesh reader (vertices + triangles, not just accessor bounds) ------ -// -// The bounds reader above is enough to size a model, but NOT to site a point on -// it: an AABB corner is empty space. These read the POSITION buffers themselves -// so the trail anchor can be checked against actual triangles. - -const GLB_COMPONENT_READERS = { - 5120: (buf, o) => buf.readInt8(o), - 5121: (buf, o) => buf.readUInt8(o), - 5122: (buf, o) => buf.readInt16LE(o), - 5123: (buf, o) => buf.readUInt16LE(o), - 5125: (buf, o) => buf.readUInt32LE(o), - 5126: (buf, o) => buf.readFloatLE(o), -}; -const GLB_COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }; -const GLB_TYPE_COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }; - -/** JSON chunk plus the binary chunk of a GLB. */ -function glbChunks(file) { - const buf = fs.readFileSync(file); - assert.equal(buf.readUInt32LE(0), 0x46546c67, `${file}: not a GLB`); - const jsonLen = buf.readUInt32LE(12); - const gltf = JSON.parse(buf.subarray(20, 20 + jsonLen).toString('utf8')); - let bin = null; - for (let off = 20 + jsonLen; off + 8 <= buf.length;) { - const len = buf.readUInt32LE(off); - if (buf.readUInt32LE(off + 4) === 0x004e4942) { bin = buf.subarray(off + 8, off + 8 + len); break; } - off += 8 + len; - } - assert.ok(bin, `${file}: no BIN chunk`); - return { gltf, bin }; -} - -/** One accessor's elements, honouring bufferView byteStride. */ -function glbAccessor(gltf, bin, index) { - const acc = gltf.accessors[index]; - assert.ok(!acc.sparse, 'sparse accessors are not used by the bundled assets'); - const read = GLB_COMPONENT_READERS[acc.componentType]; - const size = GLB_COMPONENT_BYTES[acc.componentType]; - const n = GLB_TYPE_COMPONENTS[acc.type]; - const bv = gltf.bufferViews[acc.bufferView]; - const base = (bv.byteOffset || 0) + (acc.byteOffset || 0); - const stride = bv.byteStride || size * n; - const out = []; - for (let i = 0; i < acc.count; i++) { - const o = base + i * stride; - const el = []; - for (let c = 0; c < n; c++) el.push(read(bin, o + c * size)); - out.push(el); - } - return out; -} - -/** - * Every mesh vertex in Cesium model-local space, plus the triangle index list. - * Same glTF Y-up → Cesium Z-up mapping the anchors are expressed in. - */ -function nativeMesh(file) { - const { gltf, bin } = glbChunks(file); - assert.ok( - !(gltf.extensionsRequired || []).some((e) => e.toLowerCase().includes('draco')), - `${file}: Draco-compressed assets would need a decoder here`, - ); - const verts = []; - const tris = []; - const visit = (idx, parent) => { - const node = gltf.nodes[idx]; - const m = mul(parent, nodeMatrix(node)); - if (node.mesh != null) { - for (const prim of gltf.meshes[node.mesh].primitives) { - const pIdx = prim.attributes?.POSITION; - if (pIdx == null) continue; - const start = verts.length; - for (const p of glbAccessor(gltf, bin, pIdx)) { - // RAW glTF axes, deliberately: the anchor table stores raw glTF and - // lets Cesium's own axis correction do the rest (modelAnchorWorld). - // Converting here would re-introduce the second, hand-maintained - // convention whose half-correction put the anchor on the wrong axis. - verts.push(transformPoint(m, p)); - } - if (prim.indices != null) { - const ind = glbAccessor(gltf, bin, prim.indices).map((e) => e[0]); - for (let i = 0; i + 2 < ind.length; i += 3) { - tris.push([start + ind[i], start + ind[i + 1], start + ind[i + 2]]); - } - } else { - for (let i = start; i + 2 < verts.length; i += 3) tris.push([i, i + 1, i + 2]); - } - } - } - for (const child of node.children || []) visit(child, m); - }; - for (const n of gltf.scenes[gltf.scene ?? 0].nodes) visit(n, IDENTITY); - assert.ok(verts.length && tris.length, `${file}: no triangles found`); - return { verts, tris }; -} - -/** AABB over real vertices (Cesium model-local). */ -function vertexBounds(verts) { - const min = [Infinity, Infinity, Infinity]; - const max = [-Infinity, -Infinity, -Infinity]; - for (const v of verts) { - for (let a = 0; a < 3; a++) { - if (v[a] < min[a]) min[a] = v[a]; - if (v[a] > max[a]) max[a] = v[a]; +test('every typed aircraft renders at its published length, a real-world aircraft size', () => { + for (const map of [set.types.aircraft, set.types.helicopters]) { + for (const [type, [model, length]] of Object.entries(map)) { + const spec = aircraftModelSpec(set, { typeCode: type }); + assert.equal(spec.model, model, type); + // Balloons and obstacles ('other') are built at their own size. + const published = spec.category === 'other' ? set.catalog[model].length : length; + assert.ok(Math.abs(spec.length - published) < 1e-9, `${type}: drawn ${spec.length} m, published ${published} m`); + const radius = spec.nativeRadiusM * spec.scale; + assert.ok( + radius >= WORLD_RADIUS_MIN_M && radius <= WORLD_RADIUS_MAX_M, + `${type}: rendered radius ${radius.toFixed(2)} m outside [${WORLD_RADIUS_MIN_M}, ${WORLD_RADIUS_MAX_M}]`, + ); + // A grounded aircraft stands on its wheels: its origin the scaled + // clearance above the ground, never below it. + assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`); + assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`); } } - return { min, max }; -} +}); -const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); - -/** Closest point of triangle abc to p (Ericson, Real-Time Collision Detection). */ -function closestOnTriangle(p, a, b, c) { - const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]]; - const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2]; - const ab = sub(b, a); const ac = sub(c, a); const ap = sub(p, a); - const d1 = dot(ab, ap); const d2 = dot(ac, ap); - if (d1 <= 0 && d2 <= 0) return a; - const bp = sub(p, b); - const d3 = dot(ab, bp); const d4 = dot(ac, bp); - if (d3 >= 0 && d4 <= d3) return b; - const vc = d1 * d4 - d3 * d2; - if (vc <= 0 && d1 >= 0 && d3 <= 0) { - const v = d1 / (d1 - d3); - return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v]; - } - const cp = sub(p, c); - const d5 = dot(ab, cp); const d6 = dot(ac, cp); - if (d6 >= 0 && d5 <= d6) return c; - const vb = d5 * d2 - d1 * d6; - if (vb <= 0 && d2 >= 0 && d6 <= 0) { - const w = d2 / (d2 - d6); - return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w]; - } - const va = d3 * d6 - d5 * d4; - if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) { - const w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); - return [b[0] + (c[0] - b[0]) * w, b[1] + (c[1] - b[1]) * w, b[2] + (c[2] - b[2]) * w]; - } - const den = 1 / (va + vb + vc); - const v = vb * den; const w = vc * den; - return [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]; -} - -/** How far a point lies from the nearest triangle of the mesh. */ -function distanceToSurface(p, verts, tris) { - let best = Infinity; - for (const [i, j, k] of tris) { - const d = dist3(p, closestOnTriangle(p, verts[i], verts[j], verts[k])); - if (d < best) best = d; - } - return best; -} - -/** The hull's longitudinal cross-section: where triangles cross the y = 0 plane. */ -function centrelineProfile(verts, tris) { - const pts = []; - for (const [i, j, k] of tris) { - const t = [verts[i], verts[j], verts[k]]; - for (let e = 0; e < 3; e++) { - const a = t[e]; const b = t[(e + 1) % 3]; - if (a[2] === 0) pts.push(a); - if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) { - const s = a[2] / (a[2] - b[2]); - pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]); - } - } - } - return pts; -} - -/** Closest point of the centreline profile to `target` (segment-exact). */ -function closestProfilePoint(target, profile, tris, verts) { - // Walk the profile as the segments the slice actually produced, so the answer - // can fall between two vertices the way the real surface does. - const segs = []; - for (const [i, j, k] of tris) { - const t = [verts[i], verts[j], verts[k]]; - const pts = []; - for (let e = 0; e < 3; e++) { - const a = t[e]; const b = t[(e + 1) % 3]; - if (a[2] === 0) pts.push(a); - if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) { - const s = a[2] / (a[2] - b[2]); - pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]); - } - } - if (pts.length >= 2) segs.push([pts[0], pts[1]]); - } - let best = null; let bestD = Infinity; - for (const [a, b] of segs) { - const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - const den = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2]; - let q = a; - if (den > 0) { - let s = ((target[0] - a[0]) * ab[0] + (target[1] - a[1]) * ab[1] + (target[2] - a[2]) * ab[2]) / den; - s = Math.max(0, Math.min(1, s)); - q = [a[0] + ab[0] * s, a[1] + ab[1] * s, a[2] + ab[2] * s]; - } - const d = dist3(target, q); - if (d < bestD) { bestD = d; best = q; } - } - assert.ok(best, 'the centreline slice produced no segments'); - assert.ok(profile.length > 0, 'the centreline profile is empty'); - return best; -} - -/** Native bounding radius: half-diagonal of the scene-space AABB. */ -function nativeBoundingRadius(file) { - const { min, max } = nativeBounds(file); - return Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; -} - -/** How far the glTF origin sits above the model's lowest vertex (glTF is Y-up; - * Cesium maps that to local up). This is the native "belly depth" the grounded- - * model snap must lift by so the model rests on its gear/belly, not its origin. */ -function nativeOriginAboveLowestVertex(file) { - return -nativeBounds(file).min[1]; -} - -/** Scene-AABB centre converted from glTF Y-up to Cesium model-local Z-up. */ -function nativeVisualCenter(file) { - const { min, max } = nativeBounds(file); - const gltfCenter = min.map((value, axis) => (value + max[axis]) / 2); - return [gltfCenter[0], -gltfCenter[2], gltfCenter[1]]; -} - -// --- per-layer constants, read from source (see header) -------------------- - -function layerConstants(sourceFile) { - const src = readLayerSource(path.join(ROOT, sourceFile)); - const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/); - assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`); - const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/); - assert.ok(belly, `${sourceFile}: MODEL_BELLY_OFFSET_NATIVE not found`); - return { modelScale: Number(scale[1]), bellyOffsetNative: Number(belly[1]) }; -} - -function normalBillboardScaleByDistance(sourceFile) { - const src = readLayerSource(path.join(ROOT, sourceFile)); - const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); - assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); - const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); - assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); - return scalar.slice(1).map(Number); -} - -const LAYERS = [ - { - name: 'flights', - source: 'src/data/flights.js', - // All classes share one GLB today (see CLASS_MODEL_URL) — assert that, so - // a real per-class asset drop-in forces this test to grow with it. - asset: (() => { - const urls = new Set(Object.values(CLASS_MODEL_URL)); - assert.equal(urls.size, 1, 'CLASS_MODEL_URL: expected a single shared GLB'); - return [...urls][0]; - })(), - }, - { - name: 'military', - source: 'src/data/militaryFlights.js', - asset: (() => { - const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); - const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/); - assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found'); - return m[1]; - })(), - }, +const AIRCRAFT_LAYERS = [ + 'src/layers/flights/rendering.js', + 'src/layers/military/rendering.js', + 'src/layers/localAdsb/models.js', ]; -const measured = LAYERS.map((layer) => { - const { modelScale, bellyOffsetNative } = layerConstants(layer.source); - const assetPath = path.join(ROOT, 'public', layer.asset); - const nativeRadius = nativeBoundingRadius(assetPath); - return { - ...layer, modelScale, bellyOffsetNative, nativeRadius, - baseWorldRadius: nativeRadius * modelScale, - originAboveBelly: nativeOriginAboveLowestVertex(assetPath), - }; -}); - -// --- the invariants --------------------------------------------------------- - -for (const layer of measured) { - test(`${layer.name}: every class renders at real-world aircraft scale (${layer.asset} native r=${layer.nativeRadius.toFixed(2)} × MODEL_SCALE ${layer.modelScale})`, () => { - for (const [klass, mult] of Object.entries(CLASS_SCALE_3D)) { - const worldRadius = layer.baseWorldRadius * mult; - assert.ok( - worldRadius >= WORLD_RADIUS_MIN_M && worldRadius <= WORLD_RADIUS_MAX_M, - `${klass}: world bounding radius ${worldRadius.toFixed(1)} m outside ` + - `[${WORLD_RADIUS_MIN_M}, ${WORLD_RADIUS_MAX_M}] m — MODEL_SCALE is calibrated ` + - `for a different asset's native scale`, - ); - } - }); -} - -// Grounded-model belly offset (ground-3D, 2026-07-03): each layer's -// MODEL_BELLY_OFFSET_NATIVE must equal the GLB's actual origin-above-lowest-vertex -// height, else ground-snapped models sink into (const too small) or hover above -// (too big) the sampled tile skin. Absolute tolerance = 2% of the native bounding -// radius (sub-decimeter at world scale for both assets). -for (const layer of measured) { - test(`${layer.name}: MODEL_BELLY_OFFSET_NATIVE matches ${layer.asset}'s origin height above its belly`, () => { - const tol = layer.nativeRadius * 0.02; - assert.ok( - Math.abs(layer.bellyOffsetNative - layer.originAboveBelly) <= tol, - `MODEL_BELLY_OFFSET_NATIVE ${layer.bellyOffsetNative} vs measured ` + - `${layer.originAboveBelly.toFixed(3)} (tol ${tol.toFixed(3)}) — the constant was ` + - 'calibrated for a different asset; re-measure the GLB (glTF Y-up AABB, node transforms applied)', - ); - }); -} - -test('flights and military models render at matching world sizes (cross-layer parity)', () => { - const [flights, military] = measured; - const ratio = flights.baseWorldRadius / military.baseWorldRadius; - assert.ok( - ratio > 1 / 1.5 && ratio < 1.5, - `base world radii differ ×${(ratio >= 1 ? ratio : 1 / ratio).toFixed(1)} ` + - `(flights ${flights.baseWorldRadius.toFixed(1)} m vs military ${military.baseWorldRadius.toFixed(1)} m) — ` + - 'the layers render side by side, so a class must read the same size in both', - ); -}); - -// --- 2026-08-15 Hangar fleet: real per-class GLBs --------------------------- -// CLASS_MODEL_REAL assets are vertex-baked to REAL-WORLD METERS in the -// airplane.glb axis convention and render at scale 1, so the class-envelope -// test above does not apply to them; instead each registry entry's radiusM -// and bellyM must MATCH the shipped GLB (the layers consume these numbers for -// pixel-cap math and grounded-model lift). Envelope: a real C172 (r≈7.1 m) -// up to a real 787-9 (r≈44.1 m) — nothing clamped, nothing monstrous. -const REAL_RADIUS_MIN_M = 5; -const REAL_RADIUS_MAX_M = 60; -for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) { - test(`hangar fleet: ${klass} registry pins match the shipped GLB (${spec.url})`, () => { - const assetPath = path.join(ROOT, 'public', spec.url); - const radius = nativeBoundingRadius(assetPath); - const belly = nativeOriginAboveLowestVertex(assetPath); - assert.ok( - radius >= REAL_RADIUS_MIN_M && radius <= REAL_RADIUS_MAX_M, - `${klass}: bounding radius ${radius.toFixed(2)} m outside the real-aircraft ` + - `envelope [${REAL_RADIUS_MIN_M}, ${REAL_RADIUS_MAX_M}] m — the GLB is not baked to meters`, - ); - const tol = radius * 0.02; - assert.ok( - Math.abs(radius - spec.radiusM) <= tol, - `${klass}: registry radiusM ${spec.radiusM} vs measured ${radius.toFixed(3)} ` + - `(tol ${tol.toFixed(3)}) — re-measure the GLB and update CLASS_MODEL_REAL`, - ); - assert.ok( - Math.abs(belly - spec.bellyM) <= tol, - `${klass}: registry bellyM ${spec.bellyM} vs measured ${belly.toFixed(3)} ` + - `(tol ${tol.toFixed(3)}) — grounded models would sink or hover; update CLASS_MODEL_REAL`, - ); - }); -} - -// militaryFlights renders airplane.glb for its heavy classes with duplicated -// PLANE_* constants — pin them to the measured meter-scale GLB + flights' -// calibration so the copies cannot drift. -test('military layer airplane.glb constants match the measured GLB and flights calibration', () => { - const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); - const grab = (name) => { - const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`)); - assert.ok(m, `militaryFlights.js: ${name} not found`); - return Number(m[1]); - }; - const [flights] = measured; - assert.equal(grab('PLANE_MODEL_SCALE'), flights.modelScale, 'PLANE_MODEL_SCALE must match flights MODEL_SCALE'); - assert.ok( - Math.abs(grab('PLANE_NATIVE_RADIUS_M') - flights.nativeRadius) <= flights.nativeRadius * 0.02, - `PLANE_NATIVE_RADIUS_M vs measured ${flights.nativeRadius.toFixed(3)}`, - ); - assert.ok( - Math.abs(grab('PLANE_BELLY_OFFSET_NATIVE') - flights.originAboveBelly) <= flights.nativeRadius * 0.02, - `PLANE_BELLY_OFFSET_NATIVE vs measured ${flights.originAboveBelly.toFixed(3)}`, - ); -}); - -test('shared-model visual-centre metadata matches the shipped GLBs', () => { - for (const [url, expected] of Object.entries(MODEL_VISUAL_CENTER_NATIVE)) { - const measuredCenter = nativeVisualCenter(path.join(ROOT, 'public', url)); - measuredCenter.forEach((value, axis) => { - assert.ok( - Math.abs(value - expected[axis]) <= 0.001, - `${url} axis ${axis}: metadata ${expected[axis]} vs measured ${value.toFixed(6)}`, - ); - }); - } -}); - -// The tracked trail is drawn from a point BEHIND the aircraft to this anchor, -// so the anchor decides both whether the trail terminates ON the aeroplane and -// whether the last stretch of it crosses the airframe. An earlier round put the -// anchor on the aft-belly AABB CORNER, reasoning that a point aft of every -// vertex cannot be crossed. True, but a corner is empty space — 4.80 m from any -// real geometry on airplane.glb, 6.14 m on jet.glb — so the trail ended in mid -// air beside the model. The anchor is now a genuine hull point, and these pins -// re-derive it from the POSITION BUFFERS rather than restating the table. -test('every approved aircraft model has a trail anchor on its real aft-belly hull', () => { - const urls = new Set([ - ...Object.keys(MODEL_VISUAL_CENTER_NATIVE), - ...Object.values(CLASS_MODEL_REAL).map((spec) => spec.url), - ]); - assert.deepEqual( - new Set(Object.keys(MODEL_TRAIL_ANCHOR_NATIVE)), - urls, - 'trail-anchor metadata must cover every approved aircraft GLB', - ); - - for (const url of urls) { - const file = path.join(ROOT, 'public', url); - const { verts, tris } = nativeMesh(file); - const { min, max } = vertexBounds(verts); - const centre = min.map((value, axis) => (value + max[axis]) / 2); - const actual = MODEL_TRAIL_ANCHOR_NATIVE[url]; - - // 1. ON THE HULL. The property the AABB corner failed. Measured against the - // real triangles, not against a bounding box: the corner values sat - // metres out in open air, and this is what said so. - // Measured bound across the shipped assets is <= 5e-5 native: re-measured - // from these buffers they span 5.2e-6 (bell206) to 4.1e-5 (atr72), with - // airplane.glb at 1.5e-5 and jet.glb at 1.8e-5. None is exactly zero — the - // anchor is the nearest point of a triangulated profile, not a vertex — so - // 1e-4 is met with margin and is still tight enough to catch an anchor that - // has left the hull; the old 1e-3 was loose enough to be uninformative. - const offHull = distanceToSurface(actual, verts, tris); - assert.ok( - offHull <= 1e-4, - `${url}: trail anchor must lie ON the mesh — it is ${offHull.toFixed(4)} m off the ` + - 'nearest triangle, so the trail would end in mid air beside the aircraft', - ); - - // 2. AFT of the airframe's middle, or the head segment enters at the tail - // and stops inside the fuselage — the line through the model the review - // photographed. Every shipped asset clears 80 % of its aft extreme. - assert.ok( - actual[0] > centre[0], - `${url}: trail anchor must sit aft of the centroid (${actual[0]} <= ${centre[0]})`, - ); - assert.ok( - actual[0] >= max[0] * 0.8, - `${url}: trail anchor must sit on the AFT hull — ${actual[0].toFixed(3)} is only ` + - `${(actual[0] / max[0] * 100).toFixed(1)}% of the ${max[0].toFixed(3)} aft extreme`, - ); - - // 3. BELOW: nothing of the aircraft hangs lower than the anchor at or - // behind its own station, so the trail leaves from underneath rather - // than out of the roof. Stated against the station rather than against - // the global minimum on purpose — the global lowest vertex is a wingtip - // or a gear leg amidships, and demanding the anchor match it is what - // drove the previous round off the hull in the first place. - const profile = centrelineProfile(verts, tris); - const lowestAtOrAft = profile - .filter((p) => p[0] >= actual[0] - 1e-3) - .reduce((lowest, p) => Math.min(lowest, p[1]), Infinity); - assert.ok( - Number.isFinite(lowestAtOrAft) && actual[1] <= lowestAtOrAft + 1e-3, - `${url}: trail anchor must be the lowest centreline hull at or aft of its station ` + - `(anchor ${actual[1]}, hull reaches ${lowestAtOrAft})`, - ); - - // 4. ON THE CENTRELINE, so the attachment cannot swing screen-left or - // screen-right as the aircraft turns. - assert.equal(actual[2], 0, `${url}: trail anchor must stay on the longitudinal centreline`); - - // 5. And the value itself is the closest centreline hull point to the - // aft-belly corner — the construction, re-derived. This is what makes - // the table re-measurable rather than re-guessable. - const corner = [max[0], min[1], 0]; - const nearest = closestProfilePoint(corner, profile, tris, verts); - [0, 1].forEach((axis) => { - assert.ok( - Math.abs(actual[axis] - nearest[axis]) <= 1e-3, - `${url} trail axis ${axis}: metadata ${actual[axis]} vs re-measured ${nearest[axis].toFixed(6)}`, - ); - }); +test('every aircraft layer takes its model and scale from the fleet set, no layer scale of its own', () => { + for (const file of AIRCRAFT_LAYERS) { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + assert.match(source, /\baircraftLayerModelSpec\b/, `${file}: models come from aircraftLayerModelSpec`); + assert.match(source, /\bcreateFleetModel\(/, `${file}: models are built from the loaded fleet set`); + assert.doesNotMatch(source, /\bMODEL_SCALE\b|CLASS_SCALE_3D|\.glb['"]/, `${file}: no layer-owned model scale or asset`); } }); @@ -621,60 +88,46 @@ test('every approved aircraft model has a trail anchor on its real aft-belly hul // // A flat 1-D rig stands in for the world here: the model centre at the origin, // the aft-belly anchor at its own measured station, and the last body point -// swept out along +x. That is exactly the geometry the guard reasons about — -// two distances from one centre — so the arithmetic under test is the shipped -// arithmetic, with no transform chain in the way (that is the pin below). -const TRAIL_RIG_URLS = new Set([ - ...Object.keys(MODEL_VISUAL_CENTER_NATIVE), - ...Object.values(CLASS_MODEL_REAL).map((spec) => spec.url), -]); +// swept out along +x. +const TRAIL_RIG_MODELS = Object.keys(FLEET_TRAIL_ANCHORS); const trailRigAt = (d) => ({ x: d, y: 0, z: 0 }); const trailRigCentre = trailRigAt(0); /** The rig's world anchor: the model's own measured attachment point, laid out - * aft (+x) and below (y) exactly as far as it really sits from the centre. */ -function trailRigAnchor(url) { - const native = trailAnchorForModel(url); + * aft (+x) and vertically exactly as far as it really sits from the centre. */ +function trailRigAnchor(name) { + const native = FLEET_TRAIL_ANCHORS[name]; return { x: native[0] || 0, y: native[1] || 0, z: 0 }; } -/** Visible length of the head segment the guard would have drawn, in metres. - * Zero means nothing is drawn — which is the answer the whole guard exists for. */ +function trailRigEnvelope(name) { + const { min, max } = set.catalog[name]; + return Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; +} +/** Visible length of the head segment the guard would have drawn, in metres. */ function drawnHeadLength(start, anchor, centre, envelope) { const from = trailHeadStart(start, anchor, centre, envelope, { x: 0, y: 0, z: 0 }); if (!from) return 0; return Math.hypot(from.x - anchor.x, from.y - anchor.y, from.z - anchor.z); } +// The review's repro, in the numbers it used: an airliner of 34.41 m envelope +// whose aft-belly anchor sits 24.09 m aft and 2.22 m down. +const AIRLINER_ENVELOPE = 34.41; +const AIRLINER_ANCHOR = { x: 24.0879, y: -2.2163, z: 0 }; test('a stationary contact draws no trail head, and a moving one draws all of it', () => { - for (const url of TRAIL_RIG_URLS) { - const envelope = nativeBoundingRadius(path.join(ROOT, 'public', url)); - const anchor = trailRigAnchor(url); - // Parked: the last body point is where the aircraft is. Nothing at all — - // not a short segment, not a degenerate one. Nothing. + for (const name of TRAIL_RIG_MODELS) { + const envelope = trailRigEnvelope(name); + const anchor = trailRigAnchor(name); assert.equal( trailHeadStart(trailRigCentre, anchor, trailRigCentre, envelope, { x: 0, y: 0, z: 0 }), null, - `${url}: a parked contact's head segment is the anchor offset, not motion, ` + - `and must not be drawn (envelope ${envelope.toFixed(2)} m)`, + `${name}: a parked contact's head segment must not be drawn (envelope ${envelope.toFixed(2)} m)`, ); - // Still nothing while the last body point is no further out than the - // anchor's own station: every millimetre of that segment would be drawn - // FORWARD of the attachment point, into the fuselage. const station = Math.hypot(anchor.x, anchor.y, anchor.z); assert.equal(drawnHeadLength(trailRigAt(station * 0.5), anchor, trailRigCentre, envelope), 0, - `${url}: a segment that would run forward of its own anchor draws nothing`); - // At and past the envelope the segment is real and must be drawn IN FULL so - // it reaches the hull — the failure mode clipping introduced. Referentially - // the caller's own start point, so a contact that has cleared its own size - // is bit-for-bit what the containment rule drew. + `${name}: a segment that would run forward of its own anchor draws nothing`); const clear = trailRigAt(envelope * 1.01); assert.equal(trailHeadStart(clear, anchor, trailRigCentre, envelope, { x: 0, y: 0, z: 0 }), clear, - `${url}: once the contact clears its own envelope the whole head must draw`); + `${name}: once the contact clears its own envelope the whole head must draw`); } - - // The review's repro: airplane.glb, a prior fix 58 m aft of a 34.41 m - // envelope. The old length test suppressed this entirely; ~23.6 m of it is in - // open air, so it must draw. - const airliner = nativeBoundingRadius(path.join(ROOT, 'public', '/models/airplane.glb')); - const anchor = trailRigAnchor('/models/airplane.glb'); for (const [d, why] of [ [58, 'a fix 58 m aft is well outside the envelope and its segment must be drawn'], [7500, 'a jet covers ~7.5 km between fixes — that trail must never be shortened'], @@ -682,34 +135,22 @@ test('a stationary contact draws no trail head, and a moving one draws all of it ]) { const start = trailRigAt(d); assert.equal( - trailHeadStart(start, anchor, trailRigCentre, airliner, { x: 0, y: 0, z: 0 }), start, why, + trailHeadStart(start, AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE, { x: 0, y: 0, z: 0 }), start, why, ); } - // No model drawing (billboard owns the visual): nothing to cut through. const loose = trailRigAt(5); - assert.equal(trailHeadStart(loose, anchor, null, 0, { x: 0, y: 0, z: 0 }), loose, + assert.equal(trailHeadStart(loose, AIRLINER_ANCHOR, null, 0, { x: 0, y: 0, z: 0 }), loose, 'with no model envelope the whole segment always draws'); }); -// THE PIN FOR THE BOUNDARY FLASH. -// -// The containment rule this replaces was BINARY, and a binary rule at -// airplane.glb's 34.41 m envelope is a 10.33 m flash: a start at 34.40 m drew -// nothing, a start at 34.42 m drew the entire segment, and a contact whose next -// fix landed back inside dropped it again. Two centimetres of travel cannot -// change ten metres of what the operator sees, and the pin the round shipped -// with could not catch it because it only ever asked static questions. -// -// So this asks the continuity question directly: sweep the start out through -// the boundary in small steps and require the VISIBLE LENGTH to move by no more -// than the step allows. The bound is 3x the step — the drawn length is at worst -// the segment's own growth (1:1 with the step) plus the reveal sliding across a -// shell that is never shorter than the segment it reveals, so 3 is a ceiling -// with room, and it is nowhere near the 10.33 m the boolean produced. +// The containment rule trailHeadStart replaced was BINARY: at a 34.41 m +// envelope a start at 34.40 m drew nothing and one at 34.42 m the whole +// 10.33 m segment. Sweep the start out through the boundary in small steps and +// require the VISIBLE LENGTH to move by no more than the step allows. test('the trail head grows continuously across the envelope, never in one step', () => { - for (const url of TRAIL_RIG_URLS) { - const envelope = nativeBoundingRadius(path.join(ROOT, 'public', url)); - const anchor = trailRigAnchor(url); + for (const name of TRAIL_RIG_MODELS) { + const envelope = trailRigEnvelope(name); + const anchor = trailRigAnchor(name); const step = envelope / 400; let previous = 0; for (let d = 0; d <= envelope * 1.5; d += step) { @@ -717,74 +158,55 @@ test('the trail head grows continuously across the envelope, never in one step', const length = drawnHeadLength(start, anchor, trailRigCentre, envelope); assert.ok( length - previous <= step * 3 + 1e-9 && previous - length <= step * 3 + 1e-9, - `${url}: the head jumped ${Math.abs(length - previous).toFixed(3)} m across a ` + - `${step.toFixed(3)} m step at ${d.toFixed(3)} m — that is the flash, not motion`, + `${name}: the head jumped ${Math.abs(length - previous).toFixed(3)} m across a ` + + `${step.toFixed(3)} m step at ${d.toFixed(3)} m`, ); - // Never longer than the segment actually being drawn: the guard only ever - // takes trail away from the START, and only ever from inside the envelope. const full = Math.hypot(start.x - anchor.x, start.y - anchor.y, start.z - anchor.z); assert.ok(length <= full + 1e-9, - `${url}: drew ${length.toFixed(3)} m of a ${full.toFixed(3)} m segment at ${d.toFixed(3)} m`); + `${name}: drew ${length.toFixed(3)} m of a ${full.toFixed(3)} m segment at ${d.toFixed(3)} m`); previous = length; } } - - // The exact repro, in the numbers the review used. - const airliner = nativeBoundingRadius(path.join(ROOT, 'public', '/models/airplane.glb')); - const anchor = trailRigAnchor('/models/airplane.glb'); - const inside = drawnHeadLength(trailRigAt(34.40), anchor, trailRigCentre, airliner); - const outside = drawnHeadLength(trailRigAt(34.42), anchor, trailRigCentre, airliner); + const inside = drawnHeadLength(trailRigAt(34.40), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE); + const outside = drawnHeadLength(trailRigAt(34.42), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE); assert.ok(outside > 10, `the segment 34.42 m aft is ~10.3 m long and must draw (${outside})`); assert.ok(Math.abs(outside - inside) < 0.1, - 'crossing the 34.41 m envelope by 2 cm must change the trail by centimetres, ' + - `not by the whole ${outside.toFixed(2)} m segment (moved ${Math.abs(outside - inside).toFixed(3)} m)`); - - // And the flap: a contact whose fixes cross back and forth over the boundary - // must not strobe the whole segment on and off. Each visit draws essentially - // the same partial length as the last. + `crossing the envelope by 2 cm moved the trail ${Math.abs(outside - inside).toFixed(3)} m`); const flap = [34.40, 34.42, 34.40, 34.43, 34.39].map( - (d) => drawnHeadLength(trailRigAt(d), anchor, trailRigCentre, airliner)); - const spread = Math.max(...flap) - Math.min(...flap); - assert.ok(spread < 0.1, - `an in/out/in flap across the boundary moved the trail ${spread.toFixed(3)} m — ` + - 'the boolean rule moved the whole segment, and that is what the operator saw'); - assert.ok(Math.min(...flap) > 10, - 'every visit in that flap is a real ~10 m segment: the inside ones are partial, not absent'); + (d) => drawnHeadLength(trailRigAt(d), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE)); + assert.ok(Math.max(...flap) - Math.min(...flap) < 0.1, 'an in/out/in flap must not strobe the trail'); + assert.ok(Math.min(...flap) > 10, 'every visit in that flap is a real ~10 m segment'); }); -// THE PIN THAT WOULD HAVE CAUGHT THE 2026-08-23 FIELD REGRESSION. -// // The anchor is expressed in the model's own axes, and it only lands where it // is meant to if it rides the SAME transform chain Cesium renders the model -// with. It did not: the table was stored after a single glTF Y-up -> Z-up step -// and multiplied straight by `modelMatrix`, which is half of Cesium's -// correction (the `fromGltfAsync` defaults also apply Z_UP_TO_X_UP). The -// aircraft's longitudinal axis is raw glTF X, so the aft offset landed on the -// RENDERED model's LATERAL axis — a 90-degree error that `modelMatrix`'s -// heading then rotated into world space, putting the trail out to one side and -// flipping which side as the course changed. -// -// A single-heading harness cannot catch this, which is exactly how it reached -// the owner: at one heading a wrongly-framed offset can coincidentally point -// aft. So this sweeps headings and every shipped asset, and asserts the -// property directly — the anchor lies in the model's longitudinal/vertical -// plane, with NO lateral component. -// -// The mock carries a NON-IDENTITY `sceneGraph.components.transform`, because -// the chain has a third link and a mock without one cannot tell whether the -// code reads it: Cesium renders `modelMatrix x components.transform x -// axisCorrection`, and an asset with a baked root transform has to ride all -// three. A scale and an offset ALONG the model's own axes is the strongest -// choice the property survives — it changes both numbers the pin asserts -// (identity would leave them at the raw table values, which is the answer a -// dropped link also gives) while leaving lateral zero, which is the regression. +// with (`modelMatrix x components.transform x axisCorrection`). A table stored +// after half of Cesium's axis correction once put the aft offset on the +// rendered model's LATERAL axis — invisible at a single heading. So this sweeps +// headings and every fleet anchor, and asserts the anchor lies in the model's +// longitudinal/vertical plane, aft and at its own height. The mock carries a +// NON-IDENTITY root transform so a dropped link in the chain fails too. const TRAIL_ROOT_SCALE = 2; const TRAIL_ROOT_OFFSET = Object.freeze({ lengthwise: 5, vertical: -1 }); +function headingAxes(origin, headingDeg) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); + const axis = (i) => { + const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); + return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); + }; + const east = axis(0); const north = axis(1); const vertical = axis(2); + const rad = Cesium.Math.toRadians(headingDeg); + const lengthwise = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(rad), new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(rad), new Cesium.Cartesian3()), + new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const lateral = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(lengthwise, vertical, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + return { lengthwise, vertical, lateral }; +} + test('the trail anchor rides the rendered longitudinal axis at every heading', () => { const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 3000); - const sub = (a, b) => Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3()); - // Cesium-model-local: x is lateral, y lengthwise, z vertical once the axis - // correction has mapped raw glTF [x, y, z] -> [z, x, y]. const rootTransform = Cesium.Matrix4.fromTranslationQuaternionRotationScale( new Cesium.Cartesian3(0, TRAIL_ROOT_OFFSET.lengthwise, TRAIL_ROOT_OFFSET.vertical), Cesium.Quaternion.IDENTITY, @@ -796,133 +218,73 @@ test('the trail anchor rides the rendered longitudinal axis at every heading', ( const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame( position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4(), ); - for (const [url, anchor] of Object.entries(MODEL_TRAIL_ANCHOR_NATIVE)) { + const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()); + const { lengthwise, vertical, lateral } = headingAxes(origin, headingDeg); + for (const [name, anchor] of Object.entries(FLEET_TRAIL_ANCHORS)) { const model = { modelMatrix, computedScale: 1, sceneGraph: { components: { transform: rootTransform } }, }; - const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()); const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); - // Reference axes derived INDEPENDENTLY, from the ENU frame and the same - // heading the matrix was built with — deliberately NOT through - // modelAnchorWorld. Taking them through the function under test makes the - // check self-consistent: a wrong transform rotates the anchor and the - // reference frame together and the lateral component stays zero, which is - // how the first version of this pin passed against the very bug it was - // written for. - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); - const axis = (i) => { - const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); - return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); - }; - const east = axis(0); const north = axis(1); const vertical = axis(2); - const rad = Cesium.Math.toRadians(headingDeg); - const lengthwise = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(rad), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(rad), new Cesium.Cartesian3()), - new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const lateral = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(lengthwise, vertical, new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const offset = sub(world, origin); + const offset = Cesium.Cartesian3.subtract(world, origin, new Cesium.Cartesian3()); + const where = `${name} @ heading ${headingDeg}`; + const side = Cesium.Cartesian3.dot(offset, lateral); + assert.ok(Math.abs(side) < 1e-4, `${where}: the anchor must have NO lateral component (got ${side.toFixed(4)} m)`); const along = Cesium.Cartesian3.dot(offset, lengthwise); const up = Cesium.Cartesian3.dot(offset, vertical); - const side = Cesium.Cartesian3.dot(offset, lateral); - const where = `${url} @ heading ${headingDeg}`; - // The anchor must lie in the vertical plane through the heading. The - // regression put the whole longitudinal offset on THIS axis. - assert.ok(Math.abs(side) < 1e-4, - `${where}: the anchor must have NO lateral component (got ${side.toFixed(4)} m) — ` + - 'a sideways offset is the field regression, and it is invisible at a single heading'); - // SIGNED, not magnitudes. An `abs()` on both sides passes an anchor that - // landed on the nose instead of the tail, or on the roof instead of the - // belly — which are the two directions the table exists to fix. The - // expected values carry the root transform, so a dropped `components` - // link fails here rather than quietly rendering the raw table value. const expectedAlong = anchor[0] * TRAIL_ROOT_SCALE + TRAIL_ROOT_OFFSET.lengthwise; const expectedUp = anchor[1] * TRAIL_ROOT_SCALE + TRAIL_ROOT_OFFSET.vertical; assert.ok(Math.abs(along - expectedAlong) < 1e-4, - `${where}: the longitudinal offset must land AFT along the heading ` + - `(${along.toFixed(4)} vs ${expectedAlong.toFixed(4)})`); + `${where}: longitudinal offset ${along.toFixed(4)} vs ${expectedAlong.toFixed(4)}`); assert.ok(Math.abs(up - expectedUp) < 1e-4, - `${where}: the belly offset must land BELOW on the vertical axis ` + - `(${up.toFixed(4)} vs ${expectedUp.toFixed(4)})`); + `${where}: vertical offset ${up.toFixed(4)} vs ${expectedUp.toFixed(4)}`); } } }); -// A helicopter is in the sweep above by asset, but the field report also had a -// hovering H60 whose course is ill-defined. The anchor must not depend on the -// contact having a meaningful heading — it is a model-space offset, so an -// arbitrary heading still puts it aft of the hull. +// A hovering helicopter's course is ill-defined. The anchor is a model-space +// offset, so an arbitrary heading still puts it on the hull's centreline. test('a hovering rotorcraft anchors aft of its own hull, whatever its heading', () => { const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 300); - const anchor = MODEL_TRAIL_ANCHOR_NATIVE['/models/bell206.glb']; + const spec = aircraftModelSpec(set, { typeCode: 'H60' }); + const anchor = spec.trailAnchorNative; + assert.ok(anchor[0] > 0, 'the rotorcraft anchor sits aft'); for (const headingDeg of [0, 137.5, 271.9]) { const hpr = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(headingDeg), 0, 0); const model = { modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame( position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4()), - computedScale: 1, + computedScale: spec.scale, }; const origin = Cesium.Matrix4.getTranslation(model.modelMatrix, new Cesium.Cartesian3()); const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); - const ax = (i) => { - const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); - return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); - }; - const rad = Cesium.Math.toRadians(headingDeg); - const fwd = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(ax(1), Math.cos(rad), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(ax(0), Math.sin(rad), new Cesium.Cartesian3()), - new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const lateral = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(fwd, ax(2), new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const { lateral } = headingAxes(origin, headingDeg); const side = Cesium.Cartesian3.dot( Cesium.Cartesian3.subtract(world, origin, new Cesium.Cartesian3()), lateral); assert.ok(Math.abs(side) < 1e-4, - `bell206 @ heading ${headingDeg}: hovering rotorcraft anchor drifted sideways by ${side.toFixed(4)} m`); + `${spec.model} @ heading ${headingDeg}: hovering rotorcraft anchor drifted sideways by ${side.toFixed(4)} m`); } }); -test('legacy aircraft GLBs keep scale and orientation baked with no node transforms', () => { - for (const url of Object.keys(MODEL_VISUAL_CENTER_NATIVE)) { - const file = path.join(ROOT, 'public', url); - const gltf = glbJson(file); - for (const node of gltf.nodes || []) { - assert.equal(node.matrix, undefined, `${url}: ${node.name || 'node'} has an unapplied matrix`); - assert.equal(node.translation, undefined, `${url}: ${node.name || 'node'} has unapplied translation`); - assert.equal(node.rotation, undefined, `${url}: ${node.name || 'node'} has unapplied rotation`); - assert.equal(node.scale, undefined, `${url}: ${node.name || 'node'} has unapplied scale`); - } - const { min, max } = nativeBounds(file); - const extents = max.map((value, axis) => value - min[axis]); - assert.ok( - extents[1] < extents[0] && extents[1] < extents[2], - `${url}: glTF +Y must remain the short vertical axis; got ${extents.map((v) => v.toFixed(2)).join(' × ')}`, - ); - } -}); - -test('real per-class models remain origin-centred for visual anchoring', () => { - for (const spec of Object.values(CLASS_MODEL_REAL)) { - const center = nativeVisualCenter(path.join(ROOT, 'public', spec.url)); - const maxOffset = Math.max(...center.map(Math.abs)); - assert.ok( - maxOffset <= 0.001, - `${spec.url}: visual centre ${center.map((v) => v.toFixed(4)).join(', ')} ` + - 'must stay at the origin or receive explicit modelVisualAnchor metadata', - ); - } -}); +function normalBillboardScaleByDistance(sourceFile) { + const src = readLayerSource(path.join(ROOT, sourceFile)); + const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); + assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); + const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); + assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); + return scalar.slice(1).map(Number); +} test('civilian and military globe-view aircraft retain the established 3.0 near scale and 0.5 floor', () => { - for (const layer of LAYERS) { + for (const [name, source] of [ + ['flights', 'src/data/flights.js'], + ['military', 'src/data/militaryFlights.js'], + ]) { assert.deepEqual( - normalBillboardScaleByDistance(layer.source), + normalBillboardScaleByDistance(source), [1000, 3, 8000000, 0.5], - `${layer.name}: owner-established close sizing and the orbital floor must remain readable`, + `${name}: owner-established close sizing and the orbital floor must remain readable`, ); } }); diff --git a/src/data/modelVisualAnchor.js b/src/data/modelVisualAnchor.js index 733cd5049..6659fda8f 100644 --- a/src/data/modelVisualAnchor.js +++ b/src/data/modelVisualAnchor.js @@ -1,74 +1,15 @@ import * as Cesium from 'cesium'; + /** - * Model-space visual centres measured from the shipped GLBs' scene-space AABBs. + * Model-space anchors on the aircraft layers' fleet models: where brackets + * and readouts sit (a model's visual centre) and where a tracked trail + * attaches (its aft-belly point, measured per model in fleetTrailAnchors.js + * and handed to the layers with each model's spec by fleetModels.js). * - * glTF stores these assets Y-up. Cesium's model frame is Z-up, so a glTF - * centre `[x, y, z]` becomes `[x, -z, y]` before the layer's ENU modelMatrix - * is applied. Values remain in native model units; callers supply the Model's + * Values are native model units in raw glTF axes; callers supply the Model's * effective rendered `computedScale` because Cesium does not bake it into * modelMatrix and may raise it above `scale` to satisfy minimumPixelSize. */ -const CENTERED_MODEL = Object.freeze([0, 0, 0]); - -export const MODEL_VISUAL_CENTER_NATIVE = Object.freeze({ - '/models/airplane.glb': CENTERED_MODEL, - '/models/jet.glb': CENTERED_MODEL, -}); - -/** - * Aft-belly trail attachment points, measured on each approved model's actual - * MESH SURFACE. Every bundled asset is baked to one convention (see - * aircraftClass.js): origin at the bbox centre, X = length with the NOSE at - * local −X, glTF Y = up (Cesium-local Z here). - * - * Each value is the point of the hull's CENTRELINE PROFILE (the y = 0 slice of - * the mesh) closest to that model's aft-belly AABB corner. Three things follow - * from that construction, and each is asserted rather than described in - * modelScale.test.mjs: - * - * ON THE HULL. This is the correction that matters. The previous round put - * the anchor ON the AABB corner itself, reasoning that an anchor aft of - * every vertex makes non-intersection geometric. It does — but an AABB - * corner is EMPTY SPACE, and on these airframes it is a long way from - * any of them: 4.80 m out for airplane.glb and 6.14 m for jet.glb in - * native units, which at the 450 m framing is a visible gap between the - * aircraft and the end of its own trail. The trail must terminate on the - * aeroplane, so the anchor is a surface point. - * - * AFT. The head segment is drawn from a point behind the aircraft to this - * anchor, so an anchor near the middle makes the segment enter at the - * tail and stop mid-fuselage — a line drawn through the airframe. These - * sit at 83–96 % of each model's aft extreme, on the aft-most hull the - * centreline has. - * - * BELOW. Nothing of the aircraft is lower than the anchor at or behind its - * own station, so the trail leaves from underneath the way a wake does - * rather than out of the roof. On most assets that is a frank belly - * point; on jet.glb the aft centreline profile has swept up into the - * tail cone by then, so the anchor sits just above the model's vertical - * centre (+0.13 native on a 5.63 m half-height) at the exhaust — which - * is where a fast jet's wake leaves it anyway. - * - * Y stays on the centreline — the profile is sliced at y = 0 rather than taking - * the nearest surface point in the round — so the attachment cannot drift - * screen-left or screen-right as the aircraft turns. (The unconstrained nearest - * point would put jet.glb's anchor 1.29 m off-axis.) Values are native model - * units; callers scale them by Cesium's effective `computedScale`. - * - * Locked against the shipped GLBs by modelScale.test.mjs, which re-reads the - * POSITION buffers and re-derives every property above — re-measure, never - * re-guess, when an asset changes. - */ -export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({ - '/models/airplane.glb': Object.freeze([24.0879, -2.2163, 0]), - '/models/jet.glb': Object.freeze([18.9103, 0.1266, 0]), - '/models/bell206.glb': Object.freeze([5.2077, -0.0005, 0]), - '/models/c172.glb': Object.freeze([3.6816, -0.2425, 0]), - '/models/citation2.glb': Object.freeze([6.8804, -0.6136, 0]), - '/models/mq9.glb': Object.freeze([5.1951, -1.1516, 0]), - '/models/b789.glb': Object.freeze([30.1007, -3.4814, 0]), - '/models/atr72.glb': Object.freeze([12.9785, -1.2935, 0]), -}); /** @constant Cesium's OWN axis correction for a glTF loaded with the * `Model.fromGltfAsync` defaults (upAxis Y, forwardAxis Z), assembled from its @@ -134,27 +75,6 @@ export function modelAnchorWorld(model, nativeAnchor, result) { return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result); } -/** - * Return the model-local visual centre for an approved bundled GLB. - * Assets absent from the table were audited as origin-centred. - * @param {string} url Bundled model URL. - * @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset. - */ -export function visualCenterForModel(url) { - return MODEL_VISUAL_CENTER_NATIVE[url] || CENTERED_MODEL; -} - -/** - * Return the model-local aft-belly attachment point for a tracked trail. - * Unknown future assets safely fall back to their visual centre until their - * geometry is measured and added to the table. - * @param {string} url Bundled model URL. - * @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset. - */ -export function trailAnchorForModel(url) { - return MODEL_TRAIL_ANCHOR_NATIVE[url] || visualCenterForModel(url); -} - /** * Where a trail head segment should START being drawn: the whole segment, a * shortened piece of it, or nothing at all. @@ -171,7 +91,7 @@ export function trailAnchorForModel(url) { * mistake matters: * * - Testing segment LENGTH against the radius hid real trail. With - * `airplane.glb`'s 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m + * an airliner model's 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m * aft gives a 33.98 m segment — under the radius, so suppressed — even * though ~23.6 m of it is in open air behind the aircraft. It also made the * whole segment pop in at once on crossing ~58.4 m. diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 17fec219d..e673775cd 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -45,6 +45,18 @@ import militaryFlightsLayer, { } from './militaryFlights.js'; import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js'; import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js'; +import { loadFleetModelSet } from './fleetModels.js'; +import { aircraftLayerModelSpec } from '../layers/flights/modelSpec.js'; +import { fetchPublicFile } from '../testSupport/fleetGlb.mjs'; + +// The layers draw only once the fleet model set has loaded; load it from disk. +// Model creation itself is stubbed per test through Cesium.Model.fromGltfAsync. +const _silentInfo = console.info; +console.info = () => {}; +await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = _silentInfo; +const FLIGHTS_META = Object.freeze({ typeCode: 'A320', klass: 'airliner' }); +const MILITARY_META = Object.freeze({ typeCode: 'F16', klass: 'fastjet' }); // Sample altitudes are expressed RELATIVE to the band rather than as absolute // offsets. The thresholds have already been retuned once (1_000_000 → 150_000 @@ -201,7 +213,8 @@ const LAYERS = [ driveFleetModel: flightsDriveFleetModel, ensureFleetModel: flightsEnsureFleetModel, trackedBillboardColor: flightsTrackedBillboardColor, - bellyM: 6.719, + /** How far a grounded A320 stands its origin above the ground (scaled wheel clearance). */ + bellyM: aircraftLayerModelSpec(FLIGHTS_META).bellyM, seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedFlightRefreshStateForTest({ @@ -213,7 +226,7 @@ const LAYERS = [ tracked, models, modelCollection, - meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround }, + meta: { callsign: 'TEST123 ', altitude: 10_668, ...FLIGHTS_META, onGround }, }); return billboard; }, @@ -226,7 +239,7 @@ const LAYERS = [ driveFleetModel: militaryDriveFleetModel, ensureFleetModel: militaryEnsureFleetModel, trackedBillboardColor: militaryTrackedBillboardColor, - bellyM: 5.631 * 0.8, + bellyM: aircraftLayerModelSpec(MILITARY_META).bellyM, seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedMilitaryRefreshStateForTest({ @@ -238,7 +251,7 @@ const LAYERS = [ tracked, models, modelCollection, - meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet', onGround }, + meta: { callsign: 'TEST123 ', altitudeFt: 35_000, ...MILITARY_META, onGround }, }); return billboard; }, @@ -841,8 +854,8 @@ for (const fixture of LAYERS) { Date.now = () => now; let attempts = 0; const requested = []; - Cesium.Model.fromGltfAsync = ({ url }) => { - attempts++; requested.push(url); + Cesium.Model.fromGltfAsync = ({ basePath }) => { + attempts++; requested.push(basePath); return Promise.reject(new Error('simulated missing GLB')); }; // The give-up path warns once by design; keep the run's output clean. diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js index 1132c36c8..c107239c4 100644 --- a/src/layers/flights/enrichment.js +++ b/src/layers/flights/enrichment.js @@ -164,10 +164,10 @@ export function createEnrichment({ const bb = flightState._billboards.get(icao24); if (bb) parts.rendering._applyFleetBillboardPresentation(icao24, bb); - // Hangar fleet: the class's GLB/scale may have changed — resync the - // live model, any in-flight load, and the tracked standalone model. - parts.rendering._syncModelToClass(icao24); } + // The type picks the fleet model and its length — resync the live + // model, any in-flight load, and the tracked standalone model. + parts.rendering._syncModelToClass(icao24); } if (icao24 === flightState._trackedIcao && flightState._trackedEntity) parts.tracking._updateTrackedLabelModel(icao24); diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index adfb11cc4..a435e6793 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { PLANE_MODEL_URL } from './policy.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; export function createLifecycle({ flightState, @@ -53,29 +53,19 @@ export function createLifecycle({ registerSpriteCollection('flights', flightState._billboardCollection); flightState._modelCollection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(flightState._modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { + // Every fleet model loads once, up front, for every layer that draws + // them (fleetModels.js), so no model is fetched while the session runs. + // Captured against this epoch so a destroy/re-init mid-load doesn't flip + // the flag for a torn-down lifecycle. + { const epoch = flightState._modelEpoch; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(PLANE_MODEL_URL), - asynchronous: false, - }) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; + loadFleetModelSet({ resolveAsset }) + .then(() => { + if (epoch === flightState._modelEpoch) flightState._planeModelLoaded = true; - } else { - try { - m.destroy(); - } catch { - /* gone */ - } - } }) .catch(() => { - /* tracked plane just stays a billboard a beat longer */ + /* aircraft stay billboards */ }); } flightState._billboards = new Map(); @@ -296,14 +286,6 @@ export function createLifecycle({ flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) flightState._modelPending.clear(); flightState._modelGen.clear(); - if (flightState._preloadModel) { - try { - flightState._preloadModel.destroy(); - } catch { - /* gone */ - } - flightState._preloadModel = null; - } flightState._planeModelLoaded = false; flightState._billboards.clear(); flightState._cullPositions.clear(); diff --git a/src/layers/flights/modelSpec.js b/src/layers/flights/modelSpec.js index 850433824..f063d8827 100644 --- a/src/layers/flights/modelSpec.js +++ b/src/layers/flights/modelSpec.js @@ -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 ?? ''}`; } diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index 637da2bb6..a0ee22ade 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -431,7 +431,7 @@ export function createMotion({ const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); if (parts.rendering._modelOwnsVisual(icao24)) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(icao24)?.klass, + flightState.records.data.get(icao24), ); const scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, @@ -614,7 +614,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); return modelVisualAnchor( flightState._trackedModel.modelMatrix, @@ -659,7 +659,7 @@ export function createMotion({ ) return 0; const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const scale = Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -673,7 +673,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); // Through the model's OWN render chain (modelVisualAnchor's hand-rolled // half-correction put this offset on the lateral axis — see diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 58d6a86ea..21a559769 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -39,8 +39,6 @@ export const GROUND_SCALE = 0.8; // the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each // backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet). -export const PLANE_MODEL_URL = '/models/airplane.glb'; - export const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) @@ -55,10 +53,6 @@ export const TRACKED_MODEL_MIN_PX = 40; export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -export const MODEL_NATIVE_RADIUS_M = 34.41; - -export const MODEL_SCALE = 1; -// airplane.glb is transform-applied and baked to real-world meters // Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost. export const MODEL_MAX = 150; @@ -90,21 +84,12 @@ export const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km export const MODEL_HEADING_OFFSET_DEG = 180; -// airplane.glb nose is opposite Cesium heading-0 +// every fleet model is nose -X: opposite Cesium heading 0 // Owner launch-polish direction: models should read as clean light silhouettes, -// with only a weak diffuse contribution from the existing approved textures. +// with only a weak diffuse contribution from the models' own colours. export const MODEL_COLOR_BLEND_AMOUNT = 0.94; -// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its -// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as -// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m -// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled -// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB -// by modelScale.test.mjs. - -export const MODEL_BELLY_OFFSET_NATIVE = 6.719; - export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); export const COCKPIT_CONTACT_SIZE_PX = 6; @@ -203,6 +188,24 @@ export const LANDED_SPEED_MAX_MPS = 23; export const FLEET_DR_INTERVAL_MS = 80; +/** + * The icon-to-model rules the aircraft layers follow, as the Vessels, Transit + * and Rail layers take them (`createFleetModelLayer`): the same altitude + * ceiling, minimum pixel size, proximity/all caps, add/keep radii and + * eligibility cadence, so every layer turns icons into models at one distance. + */ +export const FLEET_MODEL_POLICY = Object.freeze({ + altitudeCeilingM: MODEL_ALT_CEIL_M, + minimumPixelSize: MODEL_MIN_PX, + cap: MODEL_MAX, + capAll: MODEL_MAX_ALL, + addM: MODEL_PROX_ADD_M, + addAllM: MODEL_ALL_ADD_M, + keepM: MODEL_PROX_KEEP_M, + keepAllM: MODEL_ALL_KEEP_M, + intervalMs: FLEET_DR_INTERVAL_MS, +}); + /** @constant {number} Max ms between rotation passes while the camera is idle. */ export const ROTATION_REFRESH_MS = 1000; diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 3c00b1824..c43b7c787 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -480,9 +480,7 @@ export function createQueries({ // and rises only as the coarse ground-floor cell warms. Zero extra sampling and // no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep // `bb.position` — sprite and bracket are co-located there, so association holds. - const spec = modelOwnsVisual - ? parts.rendering._modelSpec(info?.klass) - : null; + const spec = modelOwnsVisual ? parts.rendering._modelSpec(info) : null; const pos = isTracked ? parts.motion._trackedVisualCached() || bb.position : modelOwnsVisual diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index bf8dbb5ee..1ebbf495f 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -2,7 +2,11 @@ import * as Cesium from 'cesium'; import { julianAt } from '../../time/julian.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; -import { civilAircraftModelSpec } from './modelSpec.js'; +import { + aircraftLayerModelSpec, + aircraftModelSpecCacheKey, +} from './modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; @@ -115,11 +119,14 @@ export function createRendering({ return Number.POSITIVE_INFINITY; } - function _modelSpec(klass) { - const cached = flightState._specCache.get(klass); + /** The fleet model an aircraft draws with, by its type and emitter category. */ + function _modelSpec(meta) { + const key = aircraftModelSpecCacheKey(meta); + const cached = flightState._specCache.get(key); if (cached) return cached; - const spec = civilAircraftModelSpec(klass); - flightState._specCache.set(klass, spec); + const spec = aircraftLayerModelSpec(meta); + // Not cached before the model set loads: the answer changes once it has. + if (spec.model) flightState._specCache.set(key, spec); return spec; } @@ -309,7 +316,7 @@ export function createRendering({ Cesium.Ellipsoid.WGS84, flightState._scratchGroundCarto, ); - carto.height = h + _modelSpec(meta.klass).bellyM; + carto.height = h + _modelSpec(meta).bellyM; return Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, @@ -416,16 +423,13 @@ export function createRendering({ return !!model && model.ready === true && model.show === true; } - /** Spec identity for a LOADED model: URL and scale together (same-URL classes - * differ by scale — airliner vs quadjet both ship airplane.glb). */ + /** Spec identity for a LOADED model: the model and its scale together (one + * model draws several types, each at its own length). */ - const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; - }; + const _specKeyFor = (meta) => _modelSpec(meta).key; - /** Class-change model sync (enrichment AND poll-path klass updates): when the - * aircraft's live model or in-flight load no longer matches its class's spec, + /** Type-change model sync (enrichment AND poll-path type/category updates): when the + * aircraft's live model or in-flight load no longer matches its type's spec, * drop it so the eligibility pass reloads the right asset at the right scale. * Gap-proof: the fleet billboard is re-shown BEFORE the release so the * contact never goes invisible for the tick gap; _releaseModel's generation @@ -433,7 +437,7 @@ export function createRendering({ * the same rule (its billboard entity is always the fallback visual). */ function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const key = _specKeyFor(flightState.records.data.get(icao24)); const current = flightState._models.get(icao24); if ( (current && current._gevSpecKey !== key) || @@ -478,6 +482,11 @@ export function createRendering({ flightState._irReloadQueue = null; } + /** A Cesium Model of `spec` from the fleet set loaded up front (no fetch mid-session). */ + function _createModel(spec, options) { + return createFleetModel(spec, options, { resolveAsset }); + } + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ async function _ensureModel(icao24) { @@ -497,6 +506,8 @@ export function createRendering({ _modelCap() ) return; + // Until the fleet model set has loaded the aircraft stays a billboard. + if (!_modelSpec(flightState.records.data.get(icao24)).model) return; const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale flightState._modelPending.add(icao24); @@ -505,12 +516,11 @@ export function createRendering({ // aircraft mid-load, the post-await admission below rejects the stale asset. // Boost state likewise: the creation options bake it in, so a mid-load // toggle must reject too (the reload queue only covers ADMITTED models). - const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const specKey = _specKeyFor(flightState.records.data.get(icao24)); const loadIrBoost = flightState._irBoost; try { - const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), + const spec = _modelSpec(flightState.records.data.get(icao24)); + model = await _createModel(spec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: MODEL_MIN_PX, @@ -560,7 +570,7 @@ export function createRendering({ flightState._models.has(icao24) || flightState._models.size >= _modelCap() || // Class reclassified mid-load → this GLB/scale is for the OLD class. - _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || + _specKeyFor(flightState.records.data.get(icao24)) !== specKey || // IR boost flipped mid-load → this model baked the wrong shader/tint. flightState._irBoost !== loadIrBoost; if (stale) { @@ -716,6 +726,9 @@ export function createRendering({ return; } if ( + // Until the fleet model set has loaded the billboard carries it. + _modelSpec(flightState.records.data.get(flightState._trackedIcao)) + .model && !flightState._trackedModel && !flightState._trackedModelLoading && parts.tracking._trackedModelLoadAllowed() @@ -723,14 +736,13 @@ export function createRendering({ flightState._trackedModelLoading = true; const gen = flightState._trackedModelGen; const trackedSpec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedKey = _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), + _createModel(trackedSpec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: TRACKED_MODEL_MIN_PX, @@ -766,7 +778,7 @@ export function createRendering({ // _trackedModel was still null): drop the stale asset; driver reloads. if ( _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { @@ -840,7 +852,7 @@ export function createRendering({ _applyTrackedModelFade(flightState._trackedModel); if (!flightState._trackedModel.ready) return; const spec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); flightState._trackedModel.scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 5219dcb09..cf6025dba 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -198,9 +198,14 @@ export function createFlightSnapshotRenderer({ ) { rendering._applyFleetBillboardPresentation(icao24, bb); } - // Poll-path class change (category updates): same model resync rule - // as the enrichment path — the class's GLB/scale may have changed. - if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); + // Poll-path type or category change: same model resync rule as the + // enrichment path — the fleet model or its scale may have changed. + if ( + prevMeta?.klass !== meta.klass || + prevMeta?.category !== meta.category || + prevMeta?.typeCode !== meta.typeCode + ) + rendering._syncModelToClass(icao24); } else { // Start where the fleet pass will draw it: the fleet renders behind // real time, so a fresh contact's displayed position is its fix diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index fd77dc113..17bf8b560 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -20,17 +20,13 @@ export function createFlightState({ source, services }) { * layer); see followsClock and displayDelayMs in policy.js. */ flightState.timeline = null; - /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs - * vertex-baked to real-world METERS in the airplane.glb axis convention, so - * they render at scale 1 with their own measured belly lift and bounding - * radius. Every other class keeps the shared-airplane.glb formula - * (MODEL_SCALE × CLASS_SCALE_3D). nativeRadiusM is PER SCALE UNIT — pixel-cap - * math multiplies it by `scale`, so world radius = nativeRadiusM × scale in - * both branches. The code-side MIX tint dominates every existing asset so - * class silhouettes stay light without modifying third-party GLBs/textures. */ - /* Specs are static per class, and the detection weld now asks for one per - * MODELED contact per frame (up to the fleet cap) on top of the 12 Hz fleet - * pass — so this is memoized, as militaryFlights.js already does. */ + /** Model spec per aircraft type and emitter category (aircraftLayerModelSpec in + * flights/modelSpec.js): the procedural fleet model that draws the type, life size, + * with its measured clearance, bounding radius and trail anchor. nativeRadiusM is PER + * SCALE UNIT — pixel-cap math multiplies it by `scale`. The code-side MIX tint dominates + * every model so silhouettes stay light (and this layer's own colour). Memoized: the + * detection weld asks for one per MODELED contact per frame on top of the 12 Hz fleet + * pass. */ flightState._specCache = new Map(); @@ -79,16 +75,11 @@ export function createFlightState({ source, services }) { // throttle the "more planes in view than the cap" console notice // The tracked entity's billboard goes transparent (rather than hidden) once the model takes // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its - // alpha after the GLB is preloaded so the model is ready to render the instant the billboard - // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not - // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + // alpha after the fleet model set is loaded so the model is ready to render the instant the + // billboard fades — no gap, no double-image. The set loads once at init (fleetModels.js). flightState._planeModelLoaded = false; - /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ - - flightState._preloadModel = null; - flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); flightState._scratchModelMtx = new Cesium.Matrix4(); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index de4215387..a8c62b2bc 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -254,6 +254,9 @@ export function createLocalAdsbLayer({ ); marker.evidence = evidence; marker.klass = klass; + // What picks its fleet model: the adsbdb type, else the emitter category. + marker.typeCode = enrichment.get(marker.record.icao)?.typeCode || null; + marker.category = marker.record.category || null; const billboard = marker.entity?.billboard; if (!billboard) return; if (marker.iconKind !== klass) { diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 263c21210..431caf9b6 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -12,6 +12,15 @@ import { localAdsbReceiverLine, localAdsbStatus, } from './index.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; +import { aircraftLayerModelSpec } from '../flights/modelSpec.js'; +import { fetchPublicFile } from '../../testSupport/fleetGlb.mjs'; + +// The layer draws models only once the fleet model set has loaded; load it from disk. +const realInfo = console.info; +console.info = () => {}; +await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = realInfo; function fakeReceiver(initial = {}) { const state = { @@ -529,12 +538,14 @@ test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t await layer.update(); assert.equal(loads.length, 1); const [model] = loads; + const spec = aircraftLayerModelSpec({ category: 'A7' }); assert.equal( - model.url, - '/models/bell206.glb', - 'the Flights helicopter model', + model.basePath, + '/models/fleet/helicopters/generic_heli.glb', + 'the Flights rotorcraft model', ); - assert.equal(model.scale, 1); + assert.ok(model.gltf instanceof Uint8Array, 'built from the loaded set'); + assert.equal(model.scale, spec.scale, 'drawn life size'); assert.equal(model.id, 'local-adsb:a0b702'); assert.ok( Cesium.Color.equals( diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 55fc2d11a..ef5c9bd02 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -1,7 +1,9 @@ import * as Cesium from 'cesium'; import { horizonOccluder } from '../../data/iconOrientation.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; -import { civilAircraftModelSpec } from '../flights/modelSpec.js'; +import { aircraftLayerModelSpec } from '../flights/modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; import { FLEET_DR_INTERVAL_MS, MODEL_ALL_ADD_M, @@ -16,19 +18,24 @@ import { } from '../flights/policy.js'; import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; -const specKey = (spec) => `${spec.url}@${spec.scale}`; +const specOf = (marker) => + aircraftLayerModelSpec({ + typeCode: marker?.typeCode, + category: marker?.category, + }); /** * 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle. * * Mirrors the public Flights fleet: models only below the altitude ceiling, * the same proximity/all caps and add/keep radii, the same visible-first - * eligibility (`selectModelEligible`), the same per-class asset and scale - * (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here + * eligibility (`selectModelEligible`), the same fleet model per type, life + * size (`aircraftLayerModelSpec`) and the same tint path (a MIX colour blend, here * magenta, as Flights tints military amber). Each model owns its matrix. A * billboard stays the visual until its model is loaded AND placed; a grounded * aircraft's model rides the one-shot ground snap plus the class belly - * offset, and without a resolved ground it stays a billboard. + * offset, and without a resolved ground it stays a billboard. Every fleet + * model loads once, up front, when the layer starts (`loadFleetModelSet`). * @param {object} options * @param {object} options.viewer Cesium viewer. * @param {Cesium.Color} options.color Model tint. @@ -44,6 +51,9 @@ export function createLocalAdsbModels({ groundSnap = null, loadModel = (options) => Cesium.Model.fromGltfAsync(options), }) { + void loadFleetModelSet({ resolveAsset }).catch(() => { + /* aircraft stay billboards */ + }); const collection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(collection); /** @type {Map} id -> admitted model */ @@ -75,8 +85,9 @@ export function createLocalAdsbModels({ } async function ensure(id, marker, cap) { - const spec = civilAircraftModelSpec(marker.klass); - const key = specKey(spec); + const spec = specOf(marker); + if (!spec.model) return; + const key = spec.key; const current = models.get(id); if (current && current._gevSpecKey === key) return; if (current) release(id); @@ -85,17 +96,20 @@ export function createLocalAdsbModels({ pending.set(id, loadGeneration); let model; try { - model = await loadModel({ - url: resolveAsset(spec.url), - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color, - colorBlendMode: Cesium.ColorBlendMode.MIX, - colorBlendAmount: spec.blendAmount, - id, - }); + model = await createFleetModel( + spec, + { + asynchronous: false, + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: spec.blendAmount, + id, + }, + { resolveAsset, load: loadModel }, + ); } catch { if (pending.get(id) === loadGeneration) pending.delete(id); return; @@ -106,7 +120,7 @@ export function createLocalAdsbModels({ generation !== loadGeneration || !eligible.has(id) || models.has(id) || - specKey(civilAircraftModelSpec(marker.klass)) !== key; + specOf(marker).key !== key; if (pending.get(id) === loadGeneration) pending.delete(id); if (stale) { model.destroy?.(); diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 695286803..e1cf507e3 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { JET_MODEL_URL } from './policy.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; export function createLifecycle({ flightState, @@ -47,29 +47,19 @@ export function createLifecycle({ registerSpriteCollection('military', flightState._billboardCollection); flightState._modelCollection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(flightState._modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { + // Every fleet model loads once, up front, for every layer that draws + // them (fleetModels.js), so no model is fetched while the session runs. + // Captured against this epoch so a destroy/re-init mid-load doesn't flip + // the flag for a torn-down lifecycle. + { const epoch = flightState._modelEpoch; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(JET_MODEL_URL), - asynchronous: false, - }) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; + loadFleetModelSet({ resolveAsset }) + .then(() => { + if (epoch === flightState._modelEpoch) flightState._planeModelLoaded = true; - } else { - try { - m.destroy(); - } catch { - /* gone */ - } - } }) .catch(() => { - /* tracked plane just stays a billboard a beat longer */ + /* aircraft stay billboards */ }); } flightState._billboards = new Map(); @@ -269,14 +259,6 @@ export function createLifecycle({ flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) flightState._modelPending.clear(); flightState._modelGen.clear(); - if (flightState._preloadModel) { - try { - flightState._preloadModel.destroy(); - } catch { - /* gone */ - } - flightState._preloadModel = null; - } flightState._planeModelLoaded = false; flightState._billboards.clear(); flightState._cullPositions.clear(); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 6828b788e..bcff2e34a 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -301,7 +301,7 @@ export function createMotion({ const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); if (parts.rendering._modelOwnsVisual(icao24)) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(icao24)?.klass, + flightState.records.data.get(icao24), ); const scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, @@ -459,7 +459,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); return modelVisualAnchor( flightState._trackedModel.modelMatrix, @@ -502,7 +502,7 @@ export function createMotion({ ) return 0; const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const scale = Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -519,7 +519,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); // Through the model's OWN render chain (modelVisualAnchor's hand-rolled // half-correction put this offset on the lateral axis — see diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index cafbaada2..7a62bc92d 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -58,20 +58,18 @@ export const EMERGENCY_ICON_COLOR = export const GROUND_SCALE = 0.8; // --- 3D model rendering (mirrors flights.js) ----------------------------------------- -// When enabled, military aircraft render as 3D glTF jet models once the camera is below +// When enabled, military aircraft render as 3D glTF models once the camera is below // MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is // FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity') // or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion -// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer -// apart from the commercial flights layer. - -export const JET_MODEL_URL = '/models/jet.glb'; +// can't tank the frame (no instancing yet). Each aircraft draws the fleet model of its +// type, as the flights layer does; the amber tint sets this layer apart. export const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) export const MODEL_MIN_PX = 24; -// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) +// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js) export const TRACKED_MODEL_MIN_PX = 40; // keep the glTF silhouette comparable to the selected 2D glyph at handoff @@ -79,17 +77,6 @@ export const TRACKED_MODEL_MIN_PX = 40; export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -export const MODEL_NATIVE_RADIUS_M = 29.83; - -// jet.glb is transform-applied at real-world scale — native bounding radius -// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching -// flights.js world sizes. At the old copied ×24, models rendered -// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize -// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by -// modelScale.test.mjs. - -export const MODEL_SCALE = 1; - // Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity // and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js). @@ -119,33 +106,12 @@ export const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km export const MODEL_HEADING_OFFSET_DEG = 180; -// every aircraft GLB is exported nose -X in the shared transform-applied convention -// Preserve the layer's amber identity while reducing approved texture/livery -// contribution to a weak diffuse hint, matching civilian launch presentation. +// every fleet model is nose -X: opposite Cesium heading 0 +// Preserve the layer's amber identity while reducing the models' own colours +// to a weak diffuse hint, matching civilian launch presentation. export const MODEL_COLOR_BLEND_AMOUNT = 0.94; -// airplane.glb (the shared 747) constants for this layer's heavy classes. The -// asset has its former 24× runtime calibration baked into transform-applied -// meter-scale geometry; these values mirror flights.js and are regression-pinned. - -export const PLANE_MODEL_URL = '/models/airplane.glb'; - -export const PLANE_MODEL_SCALE = 1; - -export const PLANE_NATIVE_RADIUS_M = 34.41; - -export const PLANE_BELLY_OFFSET_NATIVE = 6.719; - -// Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this -// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node -// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03). -// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model -// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to -// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs. - -export const MODEL_BELLY_OFFSET_NATIVE = 5.631; - /** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ export const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0); diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 4af14b3ba..edd3930c2 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -484,9 +484,7 @@ export function createQueries({ // contacts read the translation the fleet tick already wrote into their // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket // are co-located anyway. - const spec = modelOwnsVisual - ? parts.rendering._modelSpec(info?.klass) - : null; + const spec = modelOwnsVisual ? parts.rendering._modelSpec(info) : null; const pos = isTracked ? parts.motion._trackedVisualCached() || bb.position : modelOwnsVisual diff --git a/src/layers/military/records.js b/src/layers/military/records.js index b18e946b8..72ffd2677 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -181,6 +181,8 @@ export class MilitaryFlightRecords { newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs), callsign: stickyText(callsign, prevMeta?.callsign), type: stickyType, + // The emitter category, kept for the fleet model a type-less row draws. + category: aircraft?.category ?? prevMeta?.category ?? null, // Type outranks category automatically inside classifyAircraft. klass: classifyAircraft({ typeCode: stickyType, diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index d783bd4a4..a63955a36 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -1,12 +1,9 @@ +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { - CLASS_MODEL_REAL, - CLASS_SCALE_3D, - CLASS_SCALE_2D, -} from '../../data/aircraftClass.js'; -import { - visualCenterForModel, - trailAnchorForModel, -} from '../../data/modelVisualAnchor.js'; + aircraftLayerModelSpec, + aircraftModelSpecCacheKey, +} from '../flights/modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; import * as Cesium from 'cesium'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; @@ -20,14 +17,6 @@ import { import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; import { - PLANE_MODEL_SCALE, - PLANE_MODEL_URL, - PLANE_NATIVE_RADIUS_M, - PLANE_BELLY_OFFSET_NATIVE, - MODEL_SCALE, - JET_MODEL_URL, - MODEL_NATIVE_RADIUS_M, - MODEL_BELLY_OFFSET_NATIVE, MODEL_HEADING_OFFSET_DEG, BILLBOARD_SCALE, GROUND_SCALE, @@ -83,48 +72,20 @@ export function createRendering({ return Number.POSITIVE_INFINITY; } - function _modelSpec(klass) { - let spec = flightState._specCache.get(klass); + /** The fleet model an aircraft draws with, by its type and emitter category + * (shared with flights.js), in this layer's own tint. */ + function _modelSpec(meta) { + const key = aircraftModelSpecCacheKey(meta); + let spec = flightState._specCache.get(key); if (spec) return spec; - const real = CLASS_MODEL_REAL[klass]; - if (real) { - spec = { - url: real.url, - scale: 1, - nativeRadiusM: real.radiusM, - bellyM: real.bellyM, - headingOffsetDeg: 180, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; - } else if ( - klass === 'airliner' || - klass === 'quadjet' || - klass === 'glider' - ) { - const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { - url: PLANE_MODEL_URL, - scale, - nativeRadiusM: PLANE_NATIVE_RADIUS_M, - bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, - headingOffsetDeg: 180, - visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), - trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL), - }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { - url: JET_MODEL_URL, - scale, - nativeRadiusM: MODEL_NATIVE_RADIUS_M, - bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, - visualCenterNative: visualCenterForModel(JET_MODEL_URL), - trailAnchorNative: trailAnchorForModel(JET_MODEL_URL), - }; - } - flightState._specCache.set(klass, spec); + const base = aircraftLayerModelSpec(meta); + spec = Object.freeze({ + ...base, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + }); + // Not cached before the model set loads: the answer changes once it has. + if (spec.model) flightState._specCache.set(key, spec); return spec; } @@ -312,7 +273,7 @@ export function createRendering({ Cesium.Ellipsoid.WGS84, flightState._scratchGroundCarto, ); - carto.height = h + _modelSpec(meta.klass).bellyM; + carto.height = h + _modelSpec(meta).bellyM; return Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, @@ -350,7 +311,7 @@ export function createRendering({ displayPos, course, model.modelMatrix, - _modelSpec(flightState.records.data.get(icao24)?.klass).headingOffsetDeg, + _modelSpec(flightState.records.data.get(icao24)).headingOffsetDeg, ); if (!model.ready) { model.show = false; // not loaded yet → keep the 2D icon, no half-model flash @@ -385,20 +346,17 @@ export function createRendering({ return !!model && model.ready === true && model.show === true; } - /** Spec identity for a LOADED model — mirror of flights.js: URL and scale - * together (same-URL classes differ by scale). */ + /** Spec identity for a LOADED model — mirror of flights.js: the model and + * its scale together. */ - const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; - }; + const _specKeyFor = (meta) => _modelSpec(meta).key; - /** Class-change model sync — mirror of flights.js: drop a live model or - * in-flight load whose spec no longer matches the class, re-showing the + /** Type-change model sync — mirror of flights.js: drop a live model or + * in-flight load whose spec no longer matches the type, re-showing the * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const key = _specKeyFor(flightState.records.data.get(icao24)); const current = flightState._models.get(icao24); if ( (current && current._gevSpecKey !== key) || @@ -445,6 +403,11 @@ export function createRendering({ /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + /** A Cesium Model of `spec` from the fleet set loaded up front (no fetch mid-session). */ + function _createModel(spec, options) { + return createFleetModel(spec, options, { resolveAsset }); + } + async function _ensureModel(icao24) { // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet // tick skips it, so a model here would be orphaned + double-rendered. @@ -461,6 +424,8 @@ export function createRendering({ _modelCap() ) return; + // Until the fleet model set has loaded the aircraft stays a billboard. + if (!_modelSpec(flightState.records.data.get(icao24)).model) return; const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale flightState._modelPending.add(icao24); @@ -468,12 +433,11 @@ export function createRendering({ // Spec identity captured at load START (mirror of flights.js): a mid-load // reclassification makes the post-await admission reject the stale asset. // Boost state likewise — creation options bake it in. - const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const specKey = _specKeyFor(flightState.records.data.get(icao24)); const loadIrBoost = flightState._irBoost; try { - const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), + const spec = _modelSpec(flightState.records.data.get(icao24)); + model = await _createModel(spec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: MODEL_MIN_PX, @@ -522,7 +486,7 @@ export function createRendering({ flightState._models.has(icao24) || flightState._models.size >= _modelCap() || // Class reclassified mid-load → this GLB/scale is for the OLD class. - _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || + _specKeyFor(flightState.records.data.get(icao24)) !== specKey || // IR boost flipped mid-load → this model baked the wrong shader/tint. flightState._irBoost !== loadIrBoost; if (stale) { @@ -676,6 +640,9 @@ export function createRendering({ return; } if ( + // Until the fleet model set has loaded the billboard carries it. + _modelSpec(flightState.records.data.get(flightState._trackedIcao)) + .model && !flightState._trackedModel && !flightState._trackedModelLoading && parts.tracking._trackedModelLoadAllowed() @@ -683,14 +650,13 @@ export function createRendering({ flightState._trackedModelLoading = true; const gen = flightState._trackedModelGen; const trackedSpec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedKey = _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), + _createModel(trackedSpec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: TRACKED_MODEL_MIN_PX, @@ -724,7 +690,7 @@ export function createRendering({ // _trackedModel was still null): drop the stale asset; driver reloads. if ( _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { @@ -793,7 +759,7 @@ export function createRendering({ return; } const spec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); _modelMatrix( displayPos, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 94e1921a5..0f04d7172 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -164,10 +164,15 @@ export function createMilitarySnapshotRenderer({ ) { rendering._applyFleetBillboardPresentation(icao24, bb); } - // Per-class GLBs: a class change can mean a different asset OR scale — - // resync the live model, any in-flight load, and the tracked - // standalone (mirror of flights.js). - if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); + // Per-type fleet models: a type or category change can mean a + // different model OR scale — resync the live model, any in-flight + // load, and the tracked standalone (mirror of flights.js). + if ( + prevMeta?.klass !== meta.klass || + prevMeta?.type !== meta.type || + prevMeta?.category !== meta.category + ) + rendering._syncModelToClass(icao24); // Round 5: depth policy is uniform (always depth-test-free, see // _groundDepthDistance) — nothing to flip on landing/takeoff. } else { diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 4d9febafd..9bdf2684c 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -19,15 +19,13 @@ export function createFlightState({ source, services }) { * layer); see followsClock and displayDelayMs in policy.js. */ flightState.timeline = null; - /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: - * military contacts should read as their WEIGHT CLASS, always in this layer's - * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → - * 180° offset); airliner/quadjet/glider get the shared 747 silhouette - * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); - * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays - * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, - * and the tint must dominate any livery (owner: "stays yellow"). Specs are - * static per class — memoized (the fleet pass asks at 12 Hz per model). */ + /** Model spec per aircraft type and emitter category (aircraftLayerModelSpec in + * flights/modelSpec.js): the procedural fleet model that draws the type, life size, + * with its measured clearance, bounding radius and trail anchor. nativeRadiusM is PER + * SCALE UNIT — pixel-cap math multiplies it by `scale`. The code-side MIX tint dominates + * every model so silhouettes stay light (and in this layer's own colour). Memoized: the + * detection weld asks for one per MODELED contact per frame on top of the 12 Hz fleet + * pass. */ flightState._specCache = new Map(); @@ -76,16 +74,11 @@ export function createFlightState({ source, services }) { // throttle the "more planes in view than the cap" console notice // The tracked entity's billboard goes transparent (rather than hidden) once the model takes // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its - // alpha after the GLB is preloaded so the model is ready to render the instant the billboard - // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not - // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + // alpha after the fleet model set is loaded so the model is ready to render the instant the + // billboard fades — no gap, no double-image. The set loads once at init (fleetModels.js). flightState._planeModelLoaded = false; - /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ - - flightState._preloadModel = null; - flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); flightState._scratchModelMtx = new Cesium.Matrix4();