diff --git a/README.md b/README.md index cbb99d124..1d7cb46d3 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat - **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit. - **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. - **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. -- **🛫 3D 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. +- **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in. - **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir. - **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. - **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. diff --git a/changelog.d/1729-fleet-ground-vehicles.md b/changelog.d/1729-fleet-ground-vehicles.md new file mode 100644 index 000000000..ed198f27a --- /dev/null +++ b/changelog.d/1729-fleet-ground-vehicles.md @@ -0,0 +1,5 @@ +- Draw airport ground vehicles and ground stations as what they are instead of + the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback + tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m + radio mast, and one with no altitude stands on the ground instead of at the + 10 km airborne default (samehkhamis, #1729). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 57c3743c4..c651a95a8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7429,7 +7429,7 @@ pins and share links already supply complete boxes. normal four-samples-per-250-ms budget. Retry timestamps more than the maximum 30-second backoff ahead of the current clock are ignored. A smaller rollback can retain a bounded retry delay of up to 30 seconds. -- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 121 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): its ICAO type's entry in `types.json`, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/121 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. +- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay. - **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. diff --git a/public/models/fleet/catalog.json b/public/models/fleet/catalog.json index 1ce347760..243b60bee 100644 --- a/public/models/fleet/catalog.json +++ b/public/models/fleet/catalog.json @@ -1785,6 +1785,25 @@ "tris": 1172, "icon": "heli" }, + "radio_mast": { + "category": "other", + "file": "other/radio_mast.glb", + "min": [ + -0.7, + -0.815, + 0.0 + ], + "max": [ + 1.2, + 0.815, + 11.8 + ], + "length": 1.9, + "beam": 1.63, + "clearance": 0.0, + "tris": 940, + "icon": "vehicle" + }, "regional_dmu": { "category": "transit", "file": "transit/regional_dmu.glb", diff --git a/public/models/fleet/other/radio_mast.glb b/public/models/fleet/other/radio_mast.glb new file mode 100644 index 000000000..1242c7d41 Binary files /dev/null and b/public/models/fleet/other/radio_mast.glb differ diff --git a/public/models/fleet/types.json b/public/models/fleet/types.json index d0a62f091..2b2388bb1 100644 --- a/public/models/fleet/types.json +++ b/public/models/fleet/types.json @@ -983,6 +983,14 @@ "SHIP": [ "blimp", 75.0 + ], + "ZVEH": [ + "car", + 4.61 + ], + "GRND": [ + "radio_mast", + 1.9 ] }, "helicopters": { @@ -1143,6 +1151,34 @@ 5.0 ] }, + "aircraft_callsign": { + "ZVEH": [ + { + "prefixes": [ + "FIRE" + ], + "model": "fire_truck", + "length": 11.01 + }, + { + "prefixes": [ + "PUSH", + "TUG" + ], + "model": "pushback", + "length": 6.0 + }, + { + "prefixes": [ + "DEICER", + "SQUID", + "SQB" + ], + "model": "van", + "length": 5.51 + } + ] + }, "vessels": [ { "types": [ diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e4c9b6230..541968ed7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -4815,7 +4815,9 @@ "src/data/approxDistance.js", "src/data/renderAltitude.js", "src/data/aircraftClass.js", - "src/data/aircraftEmergency.js" + "src/data/aircraftEmergency.js", + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js" ], "external": [] }, diff --git a/src/data/fleetModels.js b/src/data/fleetModels.js index 360c59af3..6b436bae6 100644 --- a/src/data/fleetModels.js +++ b/src/data/fleetModels.js @@ -85,16 +85,24 @@ const TEXT_SHIP_TYPES = [ ]; /** - * The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B - * emitter category `category`: the type's, else the category's, else - * `default_aircraft`. + * The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B + * emitter category `category` and callsign `callsign`: the first of the + * type's callsign rules (`aircraft_callsign`) the callsign matches, else the + * type's, else the category's, else `default_aircraft`. An airport vehicle + * (type ZVEH) is a fire truck, tug or van by its callsign, else a car. * @param {object} types Parsed `types.json`. * @param {string} [type] * @param {string} [category] + * @param {string} [callsign] * @returns {{model: string, length: number|null}} */ -export function resolveAircraft(types, type, category) { +export function resolveAircraft(types, type, category, callsign) { const t = type?.trim().toUpperCase(); + const index = aircraftCallsignRule(types, t, callsign); + if (index >= 0) { + const rule = types.aircraft_callsign[t][index]; + return { model: rule.model, length: rule.length }; + } const [model, length] = (t && (types.aircraft[t] ?? types.helicopters[t])) || (category && types.categories[category]) || @@ -102,6 +110,51 @@ export function resolveAircraft(types, type, category) { return { model, length }; } +/** + * A callsign as the rules read it: upper case, spaces removed. + * @param {string} callsign + * @returns {string} + */ +function callsignKey(callsign) { + return String(callsign).toUpperCase().replace(/\s+/g, ''); +} + +/** + * Which of type `type`'s callsign rules (`types.json`'s + * `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match + * wins) `callsign` matches: its index, -1 when none does or the type has + * none. A callsign matches a rule when, upper-cased with spaces removed, it + * starts with one of the rule's prefixes. + * @param {object} types Parsed `types.json`. + * @param {string|null} [type] ICAO type. + * @param {string|null} [callsign] + * @returns {number} + */ +export function aircraftCallsignRule(types, type, callsign) { + const t = String(type ?? '') + .trim() + .toUpperCase(); + const rules = t ? types.aircraft_callsign?.[t] : undefined; + if (!rules || !callsign) return -1; + const c = callsignKey(callsign); + return rules.findIndex((rule) => + rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))), + ); +} + +/** + * Whether an aircraft of ICAO type `type` picks its model by callsign. + * @param {object} types Parsed `types.json`. + * @param {string|null} [type] + * @returns {boolean} + */ +export function aircraftModelByCallsign(types, type) { + const t = String(type ?? '') + .trim() + .toUpperCase(); + return Boolean(t && types.aircraft_callsign?.[t]); +} + /** * A reported length as one a hull can have, else undefined (drawn as built). * @param {number} [length] @@ -306,24 +359,32 @@ function specOf(set, resolved, fallback, options) { } /** - * The model an aircraft is drawn with. The type's entry first; then, for a + * The model an aircraft is drawn with. The type's callsign rules (an airport + * vehicle by its callsign), then the type's entry; then, for a * type the map lacks, the model of its class (a fast jet, a helicopter, a * glider) when the type names one; then the emitter category's; then * `default_aircraft`. * @param {{catalog: object, types: object}} set * @param {{typeCode?: string|null, type?: string|null, - * category?: string|number|null}} aircraft + * category?: string|number|null, callsign?: string|null}} aircraft * @returns {object} {@link fleetModelSpec} */ -export function aircraftModelSpec(set, { typeCode, type, category } = {}) { +export function aircraftModelSpec( + set, + { typeCode, type, category, callsign } = {}, +) { const { types, catalog } = set; const code = String(typeCode ?? type ?? '') .trim() .toUpperCase(); const emitter = emitterCategory(category); - let resolved = resolveAircraft(types, code, emitter); + const sign = callsign ? String(callsign) : undefined; + let resolved = resolveAircraft(types, code, emitter, sign); + // A callsign rule names the model outright; a type's class only stands in + // for a type the map lacks. + const ruled = aircraftCallsignRule(types, code, sign) >= 0; const typed = code && (types.aircraft[code] || types.helicopters[code]); - if (!typed && code) { + if (!ruled && !typed && code) { const model = CLASS_MODELS[classifyAircraft({ typeCode: code })]; if (model && catalog[model]) resolved = { model, length: catalog[model].length }; @@ -387,6 +448,9 @@ export function namedFleetModels(types) { ...pairs(types.aircraft), ...pairs(types.helicopters), ...pairs(types.categories), + ...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) => + rules.map((rule) => rule.model), + ), ...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)), types.default_aircraft[0], types.default_vessel, diff --git a/src/data/fleetModels.test.mjs b/src/data/fleetModels.test.mjs index 8d773901f..f9ef47bc6 100644 --- a/src/data/fleetModels.test.mjs +++ b/src/data/fleetModels.test.mjs @@ -51,8 +51,17 @@ const sliceTypes = { A21N: ['a320', 44.51], A319: ['a320', 33.84], B38M: ['b738', 39.52], + ZVEH: ['car', 4.61], + GRND: ['radio_mast', 1.9], }, helicopters: { EC35: ['ec35', 10.2] }, + aircraft_callsign: { + ZVEH: [ + { prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }, + { prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 }, + { prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 }, + ], + }, categories: { A1: ['c172', 8.3] }, vessels: [ { @@ -112,6 +121,33 @@ test('aircraft resolve by type, then emitter category, then the generic plane', }); }); +test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => { + const zveh = (callsign) => + resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign); + assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 }); + assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), { + model: 'fire_truck', + length: 11.01, + }); + assert.equal(zveh('PUSH 14').model, 'pushback'); + assert.equal(zveh('TUG3').model, 'pushback'); + assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 }); + assert.equal(zveh('SQUID20').model, 'van'); + assert.equal(zveh('SQB9').model, 'van'); + assert.equal(zveh('S Q B 9').model, 'van'); + assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 }); + assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 }); + // Callsign rules belong to their type: a fire-named airliner is still an airliner. + assert.equal( + resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model, + 'a320', + ); + assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), { + model: 'radio_mast', + length: 1.9, + }); +}); + test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => { assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship'); assert.equal( @@ -225,6 +261,46 @@ test('transit bodies mark the share of the route colour they take; other models } }); +test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => { + const zveh = (callsign) => + aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign }) + .model; + assert.equal(zveh('FIRE 2'), 'fire_truck'); + assert.equal(zveh('PUSHBACK1'), 'pushback'); + assert.equal(zveh('TUG 4'), 'pushback'); + assert.equal(zveh('DEICER 4'), 'van'); + assert.equal(zveh('SQUID20'), 'van'); + assert.equal(zveh('SQB9'), 'van'); + assert.equal(zveh('LEADER 7'), 'car'); + assert.equal(zveh('AOPS'), 'car'); + assert.equal(zveh(null), 'car'); + const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' }); + assert.equal(mast.model, 'radio_mast'); + assert.equal(mast.scale, 1); +}); + +test('a callsign rule outranks the class model of a type the map lacks', () => { + const ruled = { + ...set, + types: { + ...set.types, + aircraft_callsign: { + ...set.types.aircraft_callsign, + PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }], + }, + }, + }; + assert.equal( + aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model, + 'fire_truck', + ); + // No rule matched: the class model, as before. + assert.equal( + aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model, + 'generic_heli', + ); +}); + test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => { assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320'); assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744'); diff --git a/src/data/fleetTrailAnchors.js b/src/data/fleetTrailAnchors.js index 4c109801b..d74268e9a 100644 --- a/src/data/fleetTrailAnchors.js +++ b/src/data/fleetTrailAnchors.js @@ -55,6 +55,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({ pc12: Object.freeze([6.6416, 0.5288, 0]), pushback: Object.freeze([3, 0.2499, 0]), r44: Object.freeze([4.4219, -0.3001, 0]), + radio_mast: Object.freeze([1.2, 0, 0]), s76: Object.freeze([6.6001, 0.1915, 0]), sr22: Object.freeze([3.6762, 0.2376, 0]), t38: Object.freeze([7.0246, -0.3791, 0]), diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index e11bd0783..89e7ea33b 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -57,7 +57,14 @@ test('every typed aircraft renders at its published length, a real-world aircraf ); // 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}`); + // A model built standing on its origin (a ground vehicle, a ground + // station's mast: its lowest point at z 0) has no clearance. + if (set.catalog[model].min[2] > -0.05) + assert.ok( + spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale, + `${type}: built standing, clearance ${spec.clearanceM}`, + ); + else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`); assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`); } } @@ -296,3 +303,41 @@ test('a layer asking every tick gets one cached spec per type and category', asy assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a); assert.ok(Object.isFrozen(a)); }); + +test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => { + const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import( + '../layers/flights/modelSpec.js' + ); + const tender = aircraftLayerModelSpec( + { typeCode: 'ZVEH', callsign: 'FIRE 2' }, + set, + ); + const car = aircraftLayerModelSpec( + { typeCode: 'ZVEH', callsign: 'LEADER 7' }, + set, + ); + assert.equal(tender.model, 'fire_truck'); + assert.equal(car.model, 'car'); + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set), + aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set), + ); + assert.notEqual( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set), + ); + // Keyed by the rule matched, not the callsign: at most one entry per rule + // and one for the type's own model. + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set), + ); + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set), + ); + assert.equal( + aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set), + tender, + ); +}); diff --git a/src/layers/flights/modelSpec.js b/src/layers/flights/modelSpec.js index b9aa95379..444a22702 100644 --- a/src/layers/flights/modelSpec.js +++ b/src/layers/flights/modelSpec.js @@ -1,4 +1,9 @@ -import { aircraftModelSpec, fleetModelSet } from '../../data/fleetModels.js'; +import { + aircraftCallsignRule, + aircraftModelByCallsign, + aircraftModelSpec, + fleetModelSet, +} from '../../data/fleetModels.js'; import { MODEL_COLOR_BLEND_AMOUNT } from './policy.js'; /** @@ -25,12 +30,14 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({ /** * The fleet model, life-size scale and measured geometry an aircraft draws - * with, chosen by its ICAO type and emitter category (`fleetModels.js`). + * with, chosen by its ICAO type and emitter category, and by its callsign + * for the types that pick by callsign (`fleetModels.js`). * Shared by the Flights, Military Flights and Local ADS-B layers so all three * draw the same model for the same aircraft. `bellyM` is the model's scaled * clearance: how far a grounded aircraft's origin stands above the ground. * @param {{typeCode?: string|null, type?: string|null, - * category?: string|number|null}|null|undefined} aircraft + * category?: string|number|null, + * callsign?: string|null}|null|undefined} aircraft * @param {object|null} [set] The loaded fleet model set. * @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before * the set has loaded. @@ -40,12 +47,13 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) { // Layers ask on every tick; each type and category is resolved once per set. let cache = specCaches.get(set); if (!cache) specCaches.set(set, (cache = new Map())); - const key = aircraftModelSpecCacheKey(aircraft); + const key = aircraftModelSpecCacheKey(aircraft, set); let layerSpec = cache.get(key); if (!layerSpec) { const spec = aircraftModelSpec(set, { typeCode: aircraft?.typeCode ?? aircraft?.type ?? null, category: aircraft?.category ?? null, + callsign: aircraft?.callsign ?? null, }); layerSpec = Object.freeze({ ...spec, @@ -61,10 +69,18 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) { const specCaches = new WeakMap(); /** - * Cache key of an aircraft's model spec: what selects its model. + * Cache key of an aircraft's model spec: what selects its model. For a type + * that picks its model by callsign (an airport vehicle) the key adds the + * rule its callsign matches, or `type` for none, so such a type caches at + * most one entry per rule and one for its own model. * @param {object|null|undefined} aircraft + * @param {object|null} [set] The loaded fleet model set. * @returns {string} */ -export function aircraftModelSpecCacheKey(aircraft) { - return `${aircraft?.typeCode ?? aircraft?.type ?? ''}|${aircraft?.category ?? ''}`; +export function aircraftModelSpecCacheKey(aircraft, set = fleetModelSet()) { + const type = aircraft?.typeCode ?? aircraft?.type ?? ''; + const key = `${type}|${aircraft?.category ?? ''}`; + if (!set || !aircraftModelByCallsign(set.types, type)) return key; + const rule = aircraftCallsignRule(set.types, type, aircraft?.callsign); + return `${key}|${rule >= 0 ? `r${rule}` : 'type'}`; } diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 63cc6f5f5..02c50f3d8 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -5,6 +5,11 @@ import { } from '../../data/renderAltitude.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; import { aircraftEmergency } from '../../data/aircraftEmergency.js'; +import { + aircraftCallsignRule, + aircraftModelSpec, + fleetModelSet, +} from '../../data/fleetModels.js'; import { approxDistanceKm, GROUND_FLOOR_CLAMP_RADIUS_KM, @@ -21,6 +26,7 @@ export class FlightRecords { cachedGroundFloor, floorAltitudeM, now = () => Date.now(), + fleetModels = fleetModelSet, }) { this.services = { geoidHeight, @@ -29,6 +35,8 @@ export class FlightRecords { }; /** The time records are judged at: the layer's clock. */ this.now = now; + /** The loaded fleet model set, or null before it loads. */ + this.fleetModels = fleetModels; this.data = new Map(); // Facts about an airframe, not a moment, kept across a reset. this.airframes = new Map(); @@ -92,7 +100,6 @@ export class FlightRecords { verticalRateMps: vertical_rate, } = observation; - const onGround = on_ground === true; this.missingPolls.delete(icao24); // Sticky merge: OpenSky intermittently drops callsign/velocity/track for // aircraft it still positions — hold last-known-good instead of @@ -102,6 +109,27 @@ export class FlightRecords { // The airframe's facts: this record's, or those kept across a reset. const airframe = prevMeta ?? this.airframes.get(icao24); this.airframes.delete(icao24); + // A ground vehicle or ground station (a feed's ZVEH or GRND: a model + // built standing, catalog category `other`) takes the feed's own type, + // which no airframe lookup knows; every other aircraft keeps waiting for + // its looked-up type. With no altitude at all it stands on the ground, + // where it is, instead of at the airborne default. + const groundModel = (typeCode) => + drawnAsGroundModel( + this.fleetModels(), + typeCode, + category, + stickyText(callsign, prevMeta?.callsign), + ); + const feedType = observation.typeCode || null; + const typeCode = + airframe?.typeCode ?? + (feedType && groundModel(feedType) ? feedType : null); + const onGround = + on_ground === true || + (!Number.isFinite(baro_alt) && + !Number.isFinite(geo_alt) && + groundModel(typeCode)); // Grounded planes with no baro reading sit at 0 m, not the 10 km // airborne default (a parked plane must never float). // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky @@ -319,8 +347,9 @@ export class FlightRecords { prevMeta?.lastContactEpochMs, null, ), - // adsbdb enrichment — written by the enrichment callbacks, carried across polls: - typeCode: airframe?.typeCode ?? null, + // adsbdb enrichment — written by the enrichment callbacks, carried + // across polls; a ground vehicle's or station's from the feed: + typeCode, typeName: airframe?.typeName ?? null, registration: airframe?.registration ?? null, airline: routeHeld ? (airframe.airline ?? null) : null, @@ -408,3 +437,33 @@ export class FlightRecords { this.geoidNCache.delete(id); } } + +/** Per loaded fleet model set: whether a type, category and callsign rule draw a ground model. */ +const groundModelCaches = new WeakMap(); + +/** + * Whether an aircraft of this type, emitter category and callsign is drawn + * with a model built standing on the ground (catalog category `other`: an + * airport vehicle, a ground station's mast). False before the set loads. + * Answered once per type, category and callsign rule matched. + * @param {object|null} set The loaded fleet model set. + * @param {string|null} typeCode + * @param {string|number|null} category + * @param {string|null} callsign + * @returns {boolean} + */ +function drawnAsGroundModel(set, typeCode, category, callsign) { + if (!set || !typeCode) return false; + let cache = groundModelCaches.get(set); + if (!cache) groundModelCaches.set(set, (cache = new Map())); + const rule = aircraftCallsignRule(set.types, typeCode, callsign); + const key = `${typeCode}|${category ?? ''}|${rule}`; + let ground = cache.get(key); + if (ground === undefined) { + ground = + aircraftModelSpec(set, { typeCode, category, callsign }).category === + 'other'; + cache.set(key, ground); + } + return ground; +} diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs index 9fab9e129..cafe55cbf 100644 --- a/src/layers/flights/records.test.mjs +++ b/src/layers/flights/records.test.mjs @@ -1,13 +1,28 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { FlightRecords } from './records.js'; -function records() { +const FLEET = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../public/models/fleet', +); +const fleetSet = { + catalog: JSON.parse( + fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8'), + ), + types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')), +}; + +function records(fleetModels = () => null) { return new FlightRecords({ geoidHeight: () => 40, cachedGroundFloor: () => null, floorAltitudeM: (altitude) => altitude, approxDistanceKm: () => 0, + fleetModels, }); } const view = () => ({ @@ -260,3 +275,47 @@ test('a repeated fix time marks the contact as not updated; removal is unchanged store.forget('abc123'); assert.equal(store.fixRepeated.has('abc123'), false); }); + +test('a ground vehicle or station with no altitude stands on the ground; an aircraft does not', () => { + const store = records(() => fleetSet); + const noAltitude = { baroAltitudeM: null, ellipsoidAltitudeM: null }; + const vehicle = store.receive( + observation({ + id: 'v1', + typeCode: 'ZVEH', + callsign: 'FIRE 2', + ...noAltitude, + }), + view(), + ).meta; + assert.equal(vehicle.onGround, true); + assert.equal(vehicle.typeCode, 'ZVEH'); + assert.equal(vehicle.altitude, 0); + const mast = store.receive( + observation({ id: 'm1', typeCode: 'GRND', callsign: 'RX5', ...noAltitude }), + view(), + ).meta; + assert.equal(mast.onGround, true); + assert.equal(mast.typeCode, 'GRND'); + // An airliner with no altitude keeps the airborne default, and its feed + // type still waits for the airframe lookup. + const plane = store.receive( + observation({ id: 'p1', typeCode: 'A320', ...noAltitude }), + view(), + ).meta; + assert.equal(plane.onGround, false); + assert.equal(plane.altitude, 10000); + assert.equal(plane.typeCode, null); + // A vehicle reporting an altitude is placed by it. + const reported = store.receive( + observation({ id: 'v2', typeCode: 'ZVEH', baroAltitudeM: 12 }), + view(), + ).meta; + assert.equal(reported.onGround, false); + // Before the model set loads nothing is grounded by its model. + const unloaded = records().receive( + observation({ id: 'v3', typeCode: 'ZVEH', ...noAltitude }), + view(), + ).meta; + assert.equal(unloaded.onGround, false); +}); diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index ef5c9bd02..9f6700847 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -22,6 +22,7 @@ const specOf = (marker) => aircraftLayerModelSpec({ typeCode: marker?.typeCode, category: marker?.category, + callsign: marker?.record?.callsign, }); /**