From 2c4c66c710e4dcc7794e7de0cc2c3f505f767cbc Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 19:40:39 -0800 Subject: [PATCH] Draw civil flights from an icon set moved on the GPU Every flight is now a row in the layer's icon set. Its motion segment follows the same regime `_deadReckon` picks (interpolate between fixes, warm up backward from the oldest, coast forward from the newest), and one shared `_motionRegime` makes that choice for both. The shader moves every untracked contact between polls, so the 12 Hz fleet tick no longer touches each billboard. It now only re-plans expired rows, floors grounded contacts, advances focus while needed, and runs the 3D model regime. Grounded contacts the floor lifts are pinned to a hold at the floored position. Their nose glides on the GPU. Contacts within 50 km re-floor every tick, and farther ones take turns about once a second. Low contacts are judged for the horizon at no lower than 12 m above the ellipsoid, as before. Below hover speed a contact's nose holds. A row whose fleet model is drawn keeps its focus updated, because the model reads it. Untracking re-plans and re-floors the icon from the tracked course. On the same views with about 6,700 OpenSky flights, frame gaps at p95 drop from 28-40 ms to 17 ms, and the fleet tick from 16-37 ms to about 2 ms at most. --- scripts/package-boundaries.json | 12 + src/data/aircraftIcons.js | 2 + src/data/aircraftRecession.js | 34 ++ src/data/cockpitModels.test.mjs | 12 +- src/data/flights.js | 4 + src/data/flights.test.mjs | 129 ++++-- src/data/iconSegment.js | 20 +- src/data/iconSegment.test.mjs | 32 ++ src/data/iconSet.js | 26 +- src/data/iconSet.test.mjs | 24 ++ src/data/iconSetPrimitive.js | 13 +- src/data/militaryAwareness.test.mjs | 2 +- src/data/nearbyModelHandoff.test.mjs | 14 +- src/data/routeSegment.test.mjs | 26 +- src/data/tr3bRegistry.test.mjs | 6 +- src/data/trackedModelRegime.test.mjs | 23 +- src/layers/flights/enrichment.js | 22 +- src/layers/flights/evidence.js | 105 ++--- src/layers/flights/icons.js | 471 +++++++++++++++++++++ src/layers/flights/icons.test.mjs | 120 ++++++ src/layers/flights/index.js | 3 + src/layers/flights/lifecycle.js | 56 ++- src/layers/flights/motion.js | 242 ++++++----- src/layers/flights/ownership.test.mjs | 3 +- src/layers/flights/policy.js | 8 + src/layers/flights/queries.js | 159 ++++---- src/layers/flights/queries.test.mjs | 51 ++- src/layers/flights/rendering.js | 539 +++++++------------------ src/layers/flights/snapshotRenderer.js | 127 ++---- src/layers/flights/state.js | 42 +- src/layers/flights/testing.js | 79 +++- src/layers/flights/tracking.js | 176 ++++---- src/ui/cyberTheme.test.mjs | 2 +- 33 files changed, 1598 insertions(+), 986 deletions(-) create mode 100644 src/layers/flights/icons.js create mode 100644 src/layers/flights/icons.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f3467710f..731b9da19 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1315,6 +1315,12 @@ "src/layers/flights/policy.js", "src/layers/flights/queries.js", "src/layers/flights/rendering.js", + "src/layers/flights/icons.js", + "src/data/iconSetPrimitive.js", + "src/data/iconSet.js", + "src/data/iconSegment.js", + "src/data/iconAtlas.js", + "src/data/aircraftRecession.js", "src/data/modelMirror.js", "src/data/billboardMirror.js", "src/layers/aircraft/visualState.js", @@ -2249,6 +2255,7 @@ "./standalone/catalog" ], "modules": [ + "src/layers/flights/icons.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -3248,6 +3255,11 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/layers/flights/icons.js", + "src/data/iconSetPrimitive.js", + "src/data/iconSet.js", + "src/data/iconSegment.js", + "src/data/iconAtlas.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", diff --git a/src/data/aircraftIcons.js b/src/data/aircraftIcons.js index bca4ca655..cbcff065b 100644 --- a/src/data/aircraftIcons.js +++ b/src/data/aircraftIcons.js @@ -252,6 +252,8 @@ const _b64 = (s) => * NEAR the display size lets the browser's SVG AA do the work: crisp AND * smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */ const FLEET_RASTER_PX = 64; +/** Raster size of the default fleet glyph. */ +export const FLEET_ICON_PX = FLEET_RASTER_PX; /** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph * (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so * it stays crisp at its biggest on-screen sizes. */ diff --git a/src/data/aircraftRecession.js b/src/data/aircraftRecession.js index 984038d16..77c7b160e 100644 --- a/src/data/aircraftRecession.js +++ b/src/data/aircraftRecession.js @@ -243,6 +243,40 @@ export function applyAircraftBillboardTreatment({ return _treatmentResult; } +/** + * The alpha an aircraft draws with: base × max(combined floor, focus × limb + * haze). Icon sets compose the same thing in their shader; models need it here. + * @param {object} input + * @param {number} input.baseAlpha + * @param {number} input.focusFactor + * @param {number} input.cameraDistanceM + * @param {number} input.cameraHeightM + * @param {Partial} [input.params] + * @returns {number} + */ +export function aircraftTreatmentAlpha({ + baseAlpha, + focusFactor, + cameraDistanceM, + cameraHeightM, + params, +}) { + const tuning = resolvedParams(params); + const factors = aircraftRecessionFactorsResolved( + cameraDistanceM, + cameraHeightM, + tuning, + _scratchFactors, + ); + return ( + baseAlpha * + Math.max( + tuning.combinedAlphaFloor, + clamp(focusFactor, 0, 1) * factors.alpha, + ) + ); +} + /** * Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without * disturbing Cesium's existing MIX/colorBlendAmount presentation semantics. diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 2f4930985..2c8bc5f28 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -83,24 +83,24 @@ for (const layer of LAYERS) { test(`${layer.name}: near AIR state is independent from model admission`, () => { assert.match(source, /nextCockpitNearContacts\(/, 'Cockpit derives a separate near-contact hysteresis set'); - assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)/, + assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)|!flightState\._cockpitNearContacts\.has\(icao24\);\s*if \(cockpitDot\) \{\s*const dot = iconAtlas\.slot\(cockpitContactDotImage\(\)/, 'only out-of-range Cockpit contacts become dots'); // `_iconKind` is identity for every unconverted contact (see // tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the // operator explicitly converted into a TR-3B. - assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*aircraftVisuals\.read\(bb\)\?\.iconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/, + assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*aircraftVisuals\.read\(bb\)\?\.iconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)|aircraftIcon\(\s*parts\.rendering\._iconKind\(icao24, info\?\.klass\),?\s*\)/, 'near contacts and model fallbacks retain the class-derived aircraft silhouette'); - assert.match(source, /setBillboard\(\s*bb,\s*'rotation',\s*0\s*\);/, + assert.match(source, /setBillboard\(\s*bb,\s*'rotation',\s*0\s*\);|set\.setFlag\(row, ICON_ROTATES, false\);/, 'far dots are reset to a rotation-free presentation'); - assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/, + assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)|set\.setFlag\(row, ICON_ROTATES, true\);/, 'near 2D silhouettes continue to receive projected course'); - assert.match(source, /if\s*\(\s*billboardState\(bb\)\.show,?\s*\)\s*setBillboard\(\s*bb,\s*'show',\s*false\s*\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/, + assert.match(source, /if\s*\(\s*billboardState\(bb\)\.show,?\s*\)\s*setBillboard\(\s*bb,\s*'show',\s*false\s*\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders|icons\.setShown\(row, false\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/, 'the gap-proof billboard-to-model handoff remains intact'); }); test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => { const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/); - assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);/); + assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);|for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/); }); } diff --git a/src/data/flights.js b/src/data/flights.js index 6e2d35dbc..8c56fb38e 100644 --- a/src/data/flights.js +++ b/src/data/flights.js @@ -40,5 +40,9 @@ export const _driveFleetModelHandoffForTest = flightsLayer.testing._driveFleetModelHandoffForTest; export const _ensureFleetModelForTest = flightsLayer.testing._ensureFleetModelForTest; +export const _iconForTest = flightsLayer.testing._iconForTest; +export const _setIconForTest = flightsLayer.testing._setIconForTest; +export const _floorGroundedIconsForTest = + flightsLayer.testing._floorGroundedIconsForTest; export const mapAnalystRecord = flightsLayer.mapAnalystRecord; export default flightsLayer; diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index d6a06e5b9..487a9d538 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -14,6 +14,8 @@ import flightsLayer, { _setTrackedFlightRefreshStateForTest, _floorGroundedDisplayPositionForTest, _clearDisplayFloorStateForTest, + _iconForTest, + _floorGroundedIconsForTest, mapAnalystRecord, } from './flights.js'; import { setMilitaryLayerActive } from './militaryRegistry.js'; @@ -225,18 +227,9 @@ test('nonempty OpenSky payload with zero usable rows cannot prove share target a test('a newly seen aircraft appears at its delayed position, not ahead of it', async () => { // The fleet renders RENDER_DELAY_SEC behind real time, so a fresh contact's // first displayed position is its fix projected back to that delayed time. - // Creating the billboard at the raw fix drew it ahead until the next fleet + // Creating the icon at the raw fix drew it ahead until the next fleet // tick, which then jumped it back about one delay of travel. - const added = []; - const billboardCollection = { - show: false, - add(options) { - const billboard = { ...options }; - added.push(billboard); - return billboard; - }, - remove() {}, - }; + const billboardCollection = { show: false }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ icao24: 'seed00', @@ -293,9 +286,9 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a }; try { await flightsLayer.update(viewer); - const billboard = added.find((entry) => entry.id === icao24); - assert.ok(billboard, 'the new contact gets a billboard'); - const shown = Cesium.Cartographic.fromCartesian(billboard.position); + const icon = _iconForTest(icao24); + assert.ok(icon, 'the new contact gets an icon'); + const shown = Cesium.Cartographic.fromCartesian(icon.position); const fix = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 10_700); const behindM = Cesium.Cartesian3.distance( Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 10_700), @@ -1066,21 +1059,12 @@ const _floorCarto = (pos) => test('a newly seen taxiing aircraft starts on the floor under its delayed position', async () => { // A grounded contact is drawn at its delayed position lifted onto the floor - // under it. Creating the billboard without that lift started it on the + // under it. Creating the icon without that lift started it on the // floor of its reported fix, then the next fleet tick raised it. _clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); - const added = []; - const billboardCollection = { - show: false, - add(options) { - const billboard = { ...options }; - added.push(billboard); - return billboard; - }, - remove() {}, - }; + const billboardCollection = { show: false }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ icao24: 'seed00', @@ -1142,9 +1126,9 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi }; try { await flightsLayer.update(viewer); - const billboard = added.find((entry) => entry.id === icao24); - assert.ok(billboard, 'the new contact gets a billboard'); - const shown = _floorCarto(billboard.position); + const icon = _iconForTest(icao24); + assert.ok(icon, 'the new contact gets an icon'); + const shown = _floorCarto(icon.position); assert.ok( Cesium.Math.toDegrees(shown.longitude) < fixLon, 'drawn behind the fix', @@ -1160,6 +1144,95 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi } }); +test('far grounded contacts take turns on the floor; near ones refloor every tick', async () => { + // The floor pass is the fleet tick's only per-contact CPU work. Past + // FLOOR_NEAR_M a contact is refloored once per FLOOR_FAR_EVERY_TICKS. + _clearDisplayFloorStateForTest(); + _clearMeshFloorCellsForTest(); + setMeshFloorPreferred(true); + const viewer = { camera: { positionCartographic: null }, scene: {} }; + _setTrackedFlightRefreshStateForTest({ + icao24: 'seed00', + entity: null, + billboard: { + show: false, + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + billboardCollection: { show: false }, + viewer, + meta: { rawLat: 0, rawLon: 0, onGround: false }, + tracked: false, + }); + const icao24 = 'taxi02'; + const fixLon = -97.66; + const fixLat = 30.2; + for (let k = -40; k <= 40; k += 1) { + reportMeshFloorCell(fixLat, fixLon + k * 0.0001, 131.5); + } + const nowSec = Math.floor(Date.now() / 1000); + const realFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (!String(url).startsWith('/api/flights')) { + return { ok: true, status: 200, json: async () => ({ ac: [] }) }; + } + return { + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ + time: nowSec, + states: [ + [ + icao24, + 'TAXI02 ', + 'United States', + nowSec - 2, + nowSec - 2, + fixLon, + fixLat, + null, + true, + 10, + 90, + 0, + null, + null, + null, + null, + null, + 5, + ], + ], + }), + }; + }; + try { + await flightsLayer.update(viewer); + const start = Cesium.JulianDate.now(); + const at = (s) => + Cesium.JulianDate.addSeconds(start, s, new Cesium.JulianDate()); + const far = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 3_000_000); + const near = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 10_000); + let last = _iconForTest(icao24).position; + const moves = (camera, from, to) => { + let count = 0; + for (let tick = from; tick <= to; tick += 1) { + _floorGroundedIconsForTest(at(tick), camera); + const now = _iconForTest(icao24).position; + if (Cesium.Cartesian3.distance(now, last) > 1) count += 1; + last = now; + } + return count; + }; + assert.equal(moves(far, 1, 24), 2, 'far: once per 12 ticks'); + assert.equal(moves(near, 25, 30), 6, 'near: every tick'); + } finally { + globalThis.fetch = realFetch; + _clearMeshFloorCellsForTest(); + _clearDisplayFloorStateForTest(); + } +}); + test('display floor: a grounded contact that drifted onto a higher cell is lifted', () => { _clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); diff --git a/src/data/iconSegment.js b/src/data/iconSegment.js index c23addfb4..421311059 100644 --- a/src/data/iconSegment.js +++ b/src/data/iconSegment.js @@ -45,7 +45,7 @@ const COURSE0 = 19; // deg const COURSE_SLOPE = 20; // lerp: deg per unit fraction const SLEW_OFFSET = 21; // deg still to glide at SLEW_T0 const SLEW_T0 = 22; // s -const SLEW_RATE = 23; // deg/s +const SLEW_RATE = 23; // deg/s; negative holds the course at the glide's start const DEG = Math.PI / 180; const STRAIGHT_BELOW_RAD_PER_SEC = 1e-4; @@ -140,6 +140,8 @@ export function writeArcSegment(out, row, segment) { /** * Glide the displayed course from `fromCourseDeg` onto the segment's own course, * at most `rateDps`, starting at `t0` (the rate limit at a segment boundary). + * A rate of zero or less holds `fromCourseDeg` for the whole segment instead + * (a hover: the nose keeps its heading however the track moves). * Call after writing the segment. * @param {Float32Array} out * @param {number} row @@ -153,7 +155,7 @@ export function writeCourseGlide(out, row, fromCourseDeg, t0, rateDps) { const target = targetCourse(out, o, t0); out[o + SLEW_OFFSET] = norm180(fromCourseDeg - target); out[o + SLEW_T0] = t0; - out[o + SLEW_RATE] = Math.max(0, rateDps); + out[o + SLEW_RATE] = rateDps > 0 ? rateDps : -1; } /** @@ -195,6 +197,8 @@ export function segmentCourse(seg, row, tau) { const o = row * SEGMENT_STRIDE; const course = targetCourse(seg, o, tau); const offset = seg[o + SLEW_OFFSET]; + if (seg[o + SLEW_RATE] < 0) + return norm360(targetCourse(seg, o, seg[o + SLEW_T0]) + offset); if (offset === 0) return norm360(course); // limitCourseStep: the displayed course turns at the rate toward the segment's // own (possibly turning) course until it reaches it, then follows it. The @@ -207,6 +211,17 @@ export function segmentCourse(seg, row, tau) { return norm360(turned < gap ? start + offset + toward * turned : course); } +/** + * The course the row glides toward at `tau` (its segment's own course). + * @param {Float32Array} seg + * @param {number} row + * @param {number} tau + * @returns {number} + */ +export function segmentTargetCourse(seg, row, tau) { + return norm360(targetCourse(seg, row * SEGMENT_STRIDE, tau)); +} + /** The segment's own course at `tau`, before any glide. */ function targetCourse(seg, o, tau) { const kind = seg[o + KIND]; @@ -310,6 +325,7 @@ float iconTargetCourse(vec4 seg3, vec4 seg4, vec4 seg5, float tau) { float iconSegmentCourse(vec4 seg3, vec4 seg4, vec4 seg5, float tau) { float course = iconTargetCourse(seg3, seg4, seg5, tau); float offset = seg5.y; + if (seg5.w < 0.0) return iconTargetCourse(seg3, seg4, seg5, seg5.z) + offset; if (offset == 0.0) return course; float toward = -sign(offset); float start = iconTargetCourse(seg3, seg4, seg5, seg5.z); diff --git a/src/data/iconSegment.test.mjs b/src/data/iconSegment.test.mjs index 6384d91aa..0beec4b6e 100644 --- a/src/data/iconSegment.test.mjs +++ b/src/data/iconSegment.test.mjs @@ -213,3 +213,35 @@ test('the course glide never outruns the rate while its target turns', () => { 'then it follows the turn', ); }); + +test('a zero-rate glide holds the course however the segment turns', () => { + const seg = new Float32Array(SEGMENT_STRIDE); + const to = Cesium.Cartesian3.fromDegrees(-97.04, 32.90001, 10_500); + for (const from of [0, 30]) { + writeLerpSegment(seg, 0, { + from: fix, + to, + t0: 0, + t1: 30, + course0Deg: 0, + courseSlopeDeg: 90, + }); + writeCourseGlide(seg, 0, from, 0, 0); + for (const tau of [0, 10, 20, 30, 60]) + assert.ok( + courseGap(segmentCourse(seg, 0, tau), from) < 1e-4, + `from ${from}° at τ=${tau}`, + ); + } + writeArcSegment(seg, 0, { + fix, + t0: 0, + minDt: 0, + maxDt: 300, + speedMps: 1, + trackDeg: 90, + turnRateDps: 3, + }); + writeCourseGlide(seg, 0, 90, 0, 0); + assert.ok(courseGap(segmentCourse(seg, 0, 20), 90) < 1e-4, 'arc turn held'); +}); diff --git a/src/data/iconSet.js b/src/data/iconSet.js index 6ebf55672..25ec33172 100644 --- a/src/data/iconSet.js +++ b/src/data/iconSet.js @@ -43,8 +43,14 @@ export const ICON_SHOWN = 1; export const ICON_ROTATES = 2; /** Fade and shrink toward the globe's limb (aircraft recession). */ export const ICON_RECEDES = 4; -/** Lift the row 12 m along the normal before the horizon test (fixes near the ground). */ +/** + * Judge the row at no lower than ICON_CULL_HEIGHT_M above the ellipsoid in the + * horizon test. A fix near or below the ellipsoid (a grounded contact at geoid + * −31 m before its floor warms) otherwise reads as beyond the limb. + */ export const ICON_CULL_LIFT = 8; +/** The ellipsoid height a CULL_LIFT row is raised to for the horizon test. */ +export const ICON_CULL_HEIGHT_M = 12; /** Move the epoch forward once render times pass this many seconds. */ const REBASE_AFTER_SEC = 1800; @@ -422,6 +428,24 @@ export function createIconSet({ capacity = 1024, epoch } = {}) { course(row, tau) { return segmentCourse(segments, row, tau); }, + /** The point the horizon test judges for a row drawn at `position`. */ + cullPoint(row, position, result = new Cesium.Cartesian3()) { + if (!(style[row * STYLE_STRIDE + FLAGS] & ICON_CULL_LIFT)) + return Cesium.Cartesian3.clone(position, result); + // Height above the ellipsoid along the geocentric ray, as the shader + // computes it: exact to millimetres this close to the surface. + const radius = Cesium.Cartesian3.magnitude(position); + Cesium.Cartesian3.multiplyComponents( + position, + Cesium.Ellipsoid.WGS84.oneOverRadii, + result, + ); + const height = radius * (1 - 1 / Cesium.Cartesian3.magnitude(result)); + const lift = Math.max(0, ICON_CULL_HEIGHT_M - height); + Cesium.Cartesian3.normalize(position, result); + Cesium.Cartesian3.multiplyByScalar(result, lift, result); + return Cesium.Cartesian3.add(position, result, result); + }, }; function markRow(row) { diff --git a/src/data/iconSet.test.mjs b/src/data/iconSet.test.mjs index 934b83e53..84e11c48b 100644 --- a/src/data/iconSet.test.mjs +++ b/src/data/iconSet.test.mjs @@ -4,6 +4,8 @@ import * as Cesium from 'cesium'; import { createIconSet, DirtyRows, + ICON_CULL_HEIGHT_M, + ICON_CULL_LIFT, ICON_ROTATES, ICON_SHOWN, } from './iconSet.js'; @@ -225,3 +227,25 @@ test('rebasing moves the epoch without moving any row', () => { assert.equal(set.expires(row), 1100); assert.deepEqual(set.dirty.segments.runs(set.count), [[0, 0]]); }); + +test('a cull-lift row is judged at no lower than the cull height above the ellipsoid', () => { + const set = createIconSet(); + const row = set.add('a'); + const height = (p) => Cesium.Cartographic.fromCartesian(p).height; + const at = (h) => Cesium.Cartesian3.fromDegrees(-84.43, 33.64, h); + assert.ok( + Math.abs(height(set.cullPoint(row, at(-31))) - -31) < 1e-6, + 'unflagged: as drawn', + ); + set.setFlag(row, ICON_CULL_LIFT, true); + for (const h of [-31, 0, 5]) { + assert.ok( + Math.abs(height(set.cullPoint(row, at(h))) - ICON_CULL_HEIGHT_M) < 0.01, + `${h} m is judged at ${ICON_CULL_HEIGHT_M} m`, + ); + } + assert.ok( + Math.abs(height(set.cullPoint(row, at(131))) - 131) < 1e-6, + 'a contact floored higher keeps its own height', + ); +}); diff --git a/src/data/iconSetPrimitive.js b/src/data/iconSetPrimitive.js index f832cfdf7..6aaf9907e 100644 --- a/src/data/iconSetPrimitive.js +++ b/src/data/iconSetPrimitive.js @@ -16,7 +16,7 @@ */ import * as Cesium from 'cesium'; import { ICON_SEGMENT_GLSL, SEGMENT_STRIDE } from './iconSegment.js'; -import { ICON_CULL_LIFT, STYLE_STRIDE } from './iconSet.js'; +import { ICON_CULL_HEIGHT_M, ICON_CULL_LIFT, STYLE_STRIDE } from './iconSet.js'; import { iconAtlas, MAX_ICON_SLOTS } from './iconAtlas.js'; import { cameraLimbDistanceM, @@ -25,8 +25,6 @@ import { /** On-screen device pixels above which a row draws its near (large) image. */ const NEAR_IMAGE_PX = 76; -/** Rows lifted this far before the horizon test when flagged (low fixes). */ -const CULL_LIFT_M = 12; const ATTRIBUTE_LOCATIONS = Object.freeze({ corner: 0, @@ -80,6 +78,13 @@ bool iconFlag(float flags, float bit) { return mod(floor(flags / bit), 2.0) == 1.0; } +// Raise a near-ground point to at least the cull height (iconSet.cullPoint). +vec3 iconCullPoint(vec3 world) { + float radius = length(world); + float height = radius * (1.0 - 1.0 / length(world * czm_ellipsoidInverseRadii)); + return world + world / radius * max(0.0, ${ICON_CULL_HEIGHT_M.toFixed(1)} - height); +} + bool occludedByEllipsoid(vec3 world) { vec3 cv = u_horizon.xyz; float vh2 = u_horizon.w; @@ -101,7 +106,7 @@ void main() { vec3 rte = iconSegmentPosition(seg0, seg1, seg2, seg3, seg4, u_tau); vec3 world = u_cameraPosition + rte; - vec3 cullPoint = iconFlag(flags, ${ICON_CULL_LIFT.toFixed(1)}) ? world + normalize(world) * ${CULL_LIFT_M.toFixed(1)} : world; + vec3 cullPoint = iconFlag(flags, ${ICON_CULL_LIFT.toFixed(1)}) ? iconCullPoint(world) : world; if (occludedByEllipsoid(cullPoint)) return; vec4 positionEC = czm_modelViewRelativeToEye * vec4(rte, 1.0); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 5a17be256..529544d2d 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1660,7 +1660,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol [ 'flights', readLayerSource(new URL('./flights.js', import.meta.url)), - /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/, + /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*const icons = flightState\._icons;\s*\n\s*if\s*\(\s*!icons\s*\|\|\s*!icons\.show\s*\|\|\s*icons\.count\s*===\s*0,?\s*\)\s*return\s*null;/, ], [ 'militaryFlights', diff --git a/src/data/nearbyModelHandoff.test.mjs b/src/data/nearbyModelHandoff.test.mjs index 665ef72b5..449ec6a80 100644 --- a/src/data/nearbyModelHandoff.test.mjs +++ b/src/data/nearbyModelHandoff.test.mjs @@ -10,7 +10,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import flightsLayer, { _setTrackedFlightRefreshStateForTest } from './flights.js'; +import flightsLayer, { + _iconForTest, + _setTrackedFlightRefreshStateForTest, +} from './flights.js'; import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js'; const ICAO = 'abc123'; @@ -39,6 +42,8 @@ const LAYERS = [ name: 'commercial', layer: flightsLayer, visualCenterM: Cesium.Cartesian3.ZERO, + // Where the icon is drawn: the seeded position as the GPU encodes it. + spritePosition: () => _iconForTest(ICAO).position, seed({ billboardShow, models = [] }) { _setTrackedFlightRefreshStateForTest({ icao24: ICAO, @@ -61,6 +66,7 @@ const LAYERS = [ name: 'military', layer: militaryFlightsLayer, visualCenterM: Cesium.Cartesian3.ZERO, + spritePosition: () => contactBillboard(true).position, seed({ billboardShow, models = [] }) { _setTrackedMilitaryRefreshStateForTest({ icao24: ICAO, @@ -149,8 +155,8 @@ for (const fixture of LAYERS) { const spritePosition = detectionPosition(fixture.layer); assert.ok(spritePosition, 'the 2D sprite publishes a detection candidate'); assert.ok( - Cesium.Cartesian3.equalsEpsilon(spritePosition, contactBillboard(true).position, 0, 1e-6), - 'the 2D bracket stays welded to the billboard position', + Cesium.Cartesian3.equalsEpsilon(spritePosition, fixture.spritePosition(), 0, 1e-6), + 'the 2D bracket stays welded to the drawn sprite position', ); const modelMatrix = Cesium.Matrix4.fromRotationTranslation(MODEL_ROTATION, MODEL_POSITION); @@ -195,7 +201,7 @@ for (const fixture of LAYERS) { const loadingPosition = detectionPosition(fixture.layer); assert.ok(loadingPosition, 'the billboard remains detectable while its model loads'); assert.ok( - Cesium.Cartesian3.equalsEpsilon(loadingPosition, contactBillboard(true).position, 0, 1e-6), + Cesium.Cartesian3.equalsEpsilon(loadingPosition, fixture.spritePosition(), 0, 1e-6), 'a shown-but-unready zero-scale model cannot steal the detection anchor', ); }); diff --git a/src/data/routeSegment.test.mjs b/src/data/routeSegment.test.mjs index e48d4645d..9eee62c56 100644 --- a/src/data/routeSegment.test.mjs +++ b/src/data/routeSegment.test.mjs @@ -3,7 +3,6 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { routePlausible } from './routePlausible.js'; import { createTracking } from '../layers/flights/tracking.js'; -import { addBillboard } from './billboardMirror.js'; const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' }; const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' }; @@ -116,15 +115,14 @@ test('unjudgeable endpoint geometry and missing coordinates keep the loose contr assert.equal(plausible(beyond, null, null), true); }); -test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => { - const collection = new Cesium.BillboardCollection(); - t.after(() => collection.destroy()); +test('the actual native tracked label hides the wrong leg and keeps a healthy route', () => { const flightState = { records: { data: new Map(), missingPolls: new Map() }, feed: { _backoff: false }, - _billboards: new Map(), _scratchCarto: new Cesium.Cartographic(), }; + // Where each contact's icon is drawn. + const drawn = new Map(); // Only application host services are supplied. Route admission, WGS84 // billboard conversion and tracked-label formatting are the actual owners. const services = Object.fromEntries( @@ -141,7 +139,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro ].map((name) => [name, {}]), ); services.aircraftPresentation = { isTr3b: () => false }; - const parts = { queries: { _contactLabel: (_, info) => info.callsign } }; + const parts = { + queries: { _contactLabel: (_, info) => info.callsign }, + icons: { _iconPosition: (id) => drawn.get(id) ?? null }, + }; const tracking = createTracking({ flightState, services, parts }); for (const [position, expected] of [ [beyond, false], @@ -155,18 +156,9 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro verticalRate: 0, route, }); - flightState._billboards.set( + drawn.set( id, - addBillboard(collection, { - id, - width: 20, - height: 20, - position: Cesium.Cartesian3.fromDegrees( - position.lon, - position.lat, - 10000, - ), - }), + Cesium.Cartesian3.fromDegrees(position.lon, position.lat, 10000), ); assert.equal( tracking._trackedLabelText(id).includes('SFO → LAX'), diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index 654dce572..d2053832a 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -233,7 +233,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria // 1. The 3D model handoff is SUPPRESSED for a converted contact — there is // no TR-3B GLB, so the triangle billboard stays the visual. - assert.match(source, /modelEligible\.has\(icao24\) && !isTr3b\(icao24\)/, + assert.match(source, /modelEligible\.has\(icao24\) && !isTr3b\(icao24\)|for \(const icao24 of modelEligible\) \{\s*if \(isTr3b\(icao24\)\) continue;/, `${name}: fleet model handoff skips converted contacts`); // The tracked regime became default-on and camera-distance driven // (2026-08-19), so the suppression moved from a conjunct on @@ -245,7 +245,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria // 2. The billboard is never hidden by that suppression — it must keep // satisfying the getNearby/getDetectableObjects visibility guards, so a // converted contact still works in Contacts and Cockpit. - assert.match(source, /if\s*\(\s*(bb|modelled)\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*setBillboard\(\s*\1,\s*'show',\s*true,?\s*\);/, + assert.match(source, /if\s*\(\s*(bb|modelled)\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*setBillboard\(\s*\1,\s*'show',\s*true,?\s*\);|if\s*\(\s*row >= 0\s*&&\s*id\s*!==\s*flightState\._trackedIcao\s*\)\s*flightState\._icons\.setShown\(row, true\);/, `${name}: converting restores the billboard the model handoff had hidden`); // 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no @@ -411,7 +411,7 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => { const source = readLayerSource(new URL(`./${name}`, import.meta.url)); // Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated // ambient-enrichment candidate loop that also builds a `cand`. - const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\(\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0]; + const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\((?:\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*|let row = 0; row < icons\.count; row\+\+)\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0]; assert.ok(loop, `${name}: the model-eligibility candidate loop is present`); assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`); assert.match(loop, /if \(isTr3b\(icao\)\) continue;/, diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index d30adb4e8..072aab15f 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -34,6 +34,8 @@ import flightsLayer, { _trackedBillboardColorForTest as flightsTrackedBillboardColor, _driveFleetModelHandoffForTest as flightsDriveFleetModel, _ensureFleetModelForTest as flightsEnsureFleetModel, + _iconForTest as flightsIcon, + _setIconForTest as flightsSetIcon, } from './flights.js'; import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest, @@ -186,6 +188,25 @@ function contactBillboard() { }; } +/** A flights contact's icon row, read and written the way these tests read a + * billboard: where it is drawn and whether the layer shows it. */ +function flightIconView(icao24) { + return { + get position() { + return flightsIcon(icao24)?.position; + }, + set position(position) { + flightsSetIcon(icao24, { position }); + }, + get show() { + return flightsIcon(icao24)?.shown === true; + }, + set show(shown) { + flightsSetIcon(icao24, { shown }); + }, + }; +} + /** Records what the driver hands to the scene, so a test can assert that a * failing loader never put a model up. */ function recordingModelCollection() { @@ -216,7 +237,7 @@ const LAYERS = [ modelCollection, meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround }, }); - return billboard; + return flightIconView(icao24); }, }, { diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js index a3b2984a4..b174591a0 100644 --- a/src/layers/flights/enrichment.js +++ b/src/layers/flights/enrichment.js @@ -9,7 +9,6 @@ import { ENRICH_AMBIENT_REFILL_WINDOW_MS, ENRICH_AMBIENT_PER_SWEEP, } from './policy.js'; -import { billboardState } from '../../data/billboardMirror.js'; export function createEnrichment({ flightState, @@ -94,9 +93,7 @@ export function createEnrichment({ }); if (klass !== meta.klass) { meta.klass = klass; - const bb = flightState._billboards.get(icao24); - if (bb) - parts.rendering._applyFleetBillboardPresentation(icao24, bb); + parts.icons._applyIconPresentation(icao24); // 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); @@ -182,8 +179,7 @@ export function createEnrichment({ if ( flightState._enrichAmbientBudget <= 0 || !flightState._viewer || - !flightState._billboardCollection || - !flightState._billboardCollection.show + !flightState._icons?.show ) return; try { @@ -196,13 +192,19 @@ export function createEnrichment({ camera.upWC, ); const cand = []; - for (const [icao24, bb] of flightState._billboards) { + const icons = flightState._icons; + const tau = parts.icons._displayTau(); + for (let row = 0; row < icons.count; row++) { + const icao24 = icons.idAt(row); if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) - const { position } = billboardState(bb); - if (!position || !occluder.isPointVisible(position)) continue; // beyond the limb - Cesium.Cartesian3.clone(position, flightState._scratchModelBS.center); + const position = icons.position( + row, + tau, + flightState._scratchModelBS.center, + ); + if (!occluder.isPointVisible(position)) continue; // beyond the limb if ( cull.computeVisibility(flightState._scratchModelBS) === Cesium.Intersect.OUTSIDE diff --git a/src/layers/flights/evidence.js b/src/layers/flights/evidence.js index 49407daff..0e02411f1 100644 --- a/src/layers/flights/evidence.js +++ b/src/layers/flights/evidence.js @@ -1,11 +1,11 @@ import * as Cesium from 'cesium'; -import { aircraftIcon } from '../../data/aircraftIcons.js'; import { FOCUS_EVIDENCE_DEV } from './policy.js'; +import { writeHoldSegment } from '../../data/iconSegment.js'; import { - addBillboard, - billboardState, - setBillboard, -} from '../../data/billboardMirror.js'; + aircraftRecessionFactors, + aircraftTreatmentAlpha, +} from '../../data/aircraftRecession.js'; +import { horizonOccluder } from '../../data/iconOrientation.js'; export function createEvidence({ flightState, @@ -44,17 +44,14 @@ export function createEvidence({ /** Replace the real fleet with deterministic explicit-position contacts. */ function _setFocusEvidenceAircraft(records = []) { - if ( - !FOCUS_EVIDENCE_DEV || - !flightState._billboardCollection || - !flightState._viewer - ) + if (!FOCUS_EVIDENCE_DEV || !flightState._icons || !flightState._viewer) return { ok: false, count: 0 }; if (flightState._trackedIcao) parts.tracking._clearTracking(); parts.rendering._releaseModels(); - for (const bb of flightState._billboards.values()) - flightState._billboardCollection.remove(bb); - flightState._billboards.clear(); + const icons = flightState._icons; + icons.clear(); + flightState._grounded.clear(); + flightState._floorPinned.clear(); flightState.records.data.clear(); flightState._positionHistory.clear(); flightState._displayCourse.clear(); @@ -86,32 +83,19 @@ export function createEvidence({ rawLon: record.longitude ?? null, }; flightState.records.data.set(id, meta); - flightState._cullPositions.delete(id); flightState._focusEvidenceIds.add(id); - const bb = addBillboard(flightState._billboardCollection, { - position, - image: aircraftIcon(parts.rendering._iconKind(id, klass)), - width: 20, - height: 20, - scale: parts.rendering._fleetBillboardScale(id, klass), - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: parts.rendering._fleetBillboardColor(id), - sizeInMeters: false, - scaleByDistance: parts.rendering._normalBillboardScaleByDistance(), - disableDepthTestDistance: parts.rendering._groundDepthDistance(), - id, - show: true, - }); - flightState._billboards.set(id, bb); + parts.icons._addIcon(id); + const row = icons.row(id); + writeHoldSegment(icons.segments, row, position, meta.true_track); + icons.segmentWritten(row); } - flightState.feed._count = flightState._billboards.size; + flightState.feed._count = icons.count; flightState._lastFleetTickMs = 0; flightState._viewer.scene.requestRender?.(); return { ok: true, count: flightState.feed._count }; } - /** Update explicit evidence positions without rebuilding billboards. */ + /** Update explicit evidence positions without rebuilding rows. */ function _moveFocusEvidenceAircraft(records = []) { if (!FOCUS_EVIDENCE_DEV) return { ok: false, moved: 0 }; @@ -122,15 +106,21 @@ export function createEvidence({ .toLowerCase(); if (!flightState._focusEvidenceIds.has(id)) continue; const position = _focusEvidencePosition(record); - const bb = flightState._billboards.get(id); - if (!position || !bb) continue; - setBillboard(bb, 'position', position); + const row = flightState._icons?.row(id) ?? -1; + if (!position || row < 0) continue; const meta = flightState.records.data.get(id); if (meta) { if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg; if (Number.isFinite(record.velocityMps)) meta.velocity = record.velocityMps; } + writeHoldSegment( + flightState._icons.segments, + row, + position, + meta?.true_track ?? 0, + ); + flightState._icons.segmentWritten(row); moved += 1; } flightState._lastFleetTickMs = 0; @@ -142,27 +132,42 @@ export function createEvidence({ function _focusEvidenceSnapshot() { if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return []; + const icons = flightState._icons; + const { scene, camera } = flightState._viewer; + const occluder = horizonOccluder(camera); + const cameraHeightM = camera.positionCartographic?.height; return [...flightState._focusEvidenceIds].map((id) => { - const bb = flightState._billboards.get(id); - const screen = billboardState(bb)?.position - ? Cesium.SceneTransforms.worldToWindowCoordinates( - flightState._viewer.scene, - billboardState(bb).position, - ) + const row = icons?.row(id) ?? -1; + const position = row < 0 ? null : parts.icons._iconPosition(id); + const cameraDistanceM = position + ? Cesium.Cartesian3.distance(camera.positionWC, position) + : null; + const screen = position + ? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position) + : null; + // What the icon shader composes: base scale × limb taper, and base + // alpha × max(floor, focus × limb haze). + const factors = position + ? aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }) : null; return { id, - show: billboardState(bb)?.show === true, - scale: billboardState(bb)?.scale ?? null, - alpha: bb?.color?.alpha ?? null, + show: + !!position && + icons.shown(row) && + parts.icons._aboveHorizon(row, position, occluder), + scale: factors ? icons.scale(row) * factors.scale : null, + alpha: position + ? aircraftTreatmentAlpha({ + baseAlpha: icons.alpha(row), + focusFactor: icons.focus(row), + cameraDistanceM, + cameraHeightM, + }) + : null, x: screen?.x ?? null, y: screen?.y ?? null, - cameraDistanceM: billboardState(bb)?.position - ? Cesium.Cartesian3.distance( - flightState._viewer.camera.positionWC, - billboardState(bb).position, - ) - : null, + cameraDistanceM, }; }); } diff --git a/src/layers/flights/icons.js b/src/layers/flights/icons.js new file mode 100644 index 000000000..8bf4df65f --- /dev/null +++ b/src/layers/flights/icons.js @@ -0,0 +1,471 @@ +import * as Cesium from 'cesium'; +import { + COURSE_HOLD_SPEED_MPS, + courseBetweenCartesians, + courseSlewCapDps, + norm180, + speedRamp, + staleCoastLimitSeconds, +} from '../../data/motionModel.js'; +import { + createIconSet, + ICON_CULL_LIFT, + ICON_RECEDES, + ICON_ROTATES, +} from '../../data/iconSet.js'; +import { + segmentCourse, + segmentTargetCourse, + writeArcSegment, + writeCourseGlide, + writeHoldSegment, + writeLerpSegment, +} from '../../data/iconSegment.js'; +import { IconSetPrimitive } from '../../data/iconSetPrimitive.js'; +import { iconAtlas } from '../../data/iconAtlas.js'; +import { + aircraftIcon, + FLEET_ICON_PX, + TRACKED_ICON_PX, +} from '../../data/aircraftIcons.js'; +import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { + COCKPIT_CONTACT_SIZE_PX, + COURSE_MAX_DPS, + FLOOR_FAR_EVERY_TICKS, + FLOOR_NEAR_M, + RENDER_DELAY_SEC, +} from './policy.js'; + +/** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */ +const COCKPIT_DOT_PX = 16; +/** Pinned grounded rows rewrite only past this displayed move (m²) or turn (°). */ +const PIN_MOVE_SQ_M = 1; +const PIN_TURN_DEG = 0.1; + +/** + * The flights layer's contacts as one icon set: each row's motion segment + * mirrors `_deadReckon`'s regime for the delayed display time, so the GPU + * moves every untracked contact between polls with no per-frame CPU pass. + */ +export function createIcons({ flightState, services, parts }) { + const { focusPassIsNeeded } = services.focus; + const scratchTime = new Cesium.JulianDate(); + const scratchPosition = new Cesium.Cartesian3(); + const scratchCull = new Cesium.Cartesian3(); + const scratchScaleByDistance = new Cesium.NearFarScalar(); + let normalScaleByDistance = null; + let cockpitScaleByDistance = null; + + /** The delayed instant every untracked contact is drawn at. */ + function _displayTime(result = scratchTime) { + return Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(result), + -RENDER_DELAY_SEC, + result, + ); + } + + function _displayTau() { + return flightState._icons.seconds(_displayTime()); + } + + /** Create the set and its primitive for one layer lifetime. */ + function _createIcons(viewer) { + flightState._icons = createIconSet({ epoch: _displayTime() }); + flightState._iconPrimitive = new IconSetPrimitive({ + set: flightState._icons, + renderTime: () => _displayTime(flightState._scratchIconRenderTime), + }); + viewer.scene.primitives.add(flightState._iconPrimitive); + return flightState._iconPrimitive; + } + + /** Where a contact is drawn right now, or null when it has no row. */ + function _iconPosition(icao24, result = new Cesium.Cartesian3()) { + const set = flightState._icons; + const row = set.row(icao24); + return row < 0 ? null : set.position(row, _displayTau(), result); + } + + /** The nose direction a contact is drawn with right now. */ + function _iconCourse(icao24) { + const set = flightState._icons; + const row = set.row(icao24); + return row < 0 ? null : set.course(row, _displayTau()); + } + + /** Whether the layer draws this contact's icon (not tracked, not model-owned). */ + function _iconShown(icao24) { + const set = flightState._icons; + const row = set.row(icao24); + return row >= 0 && set.shown(row); + } + + /** The camera's horizon test, or null when there is no placed camera. */ + function _occluder() { + const camera = flightState._viewer?.camera; + return camera?.positionWC ? horizonOccluder(camera) : null; + } + + /** Whether a drawn row at `position` clears the camera's horizon, judged + * exactly as the icon shader judges it. No camera, no horizon. */ + function _aboveHorizon(row, position, occluder) { + return ( + !occluder || + occluder.isPointVisible( + flightState._icons.cullPoint(row, position, scratchCull), + ) + ); + } + + /** + * Write the segment `_deadReckon` would follow from now on: interpolation + * between the bracketing fixes, the warm-up arc back from the oldest fix, or + * the coast arc past the newest. The displayed nose glides from where it is + * onto the new course at the fleet's slew cap; a hovering contact keeps its + * nose. The row expires when the regime would change. + * @param {string} icao24 + * @param {{fromCourseDeg?: number|null}} [options] Nose to glide from when + * the row's own segment is not what was on screen (tracking handoff). + */ + function _planIcon(icao24, { fromCourseDeg } = {}) { + const set = flightState._icons; + const row = set.row(icao24); + if (row < 0) return; + if (flightState._focusEvidenceIds.has(icao24)) return; + if (flightState._floorPinned.has(icao24)) return; + const history = flightState._positionHistory.get(icao24); + if (!history?.length) return; + const info = flightState.records.data.get(icao24); + const renderTime = _displayTime(); + const tau = set.seconds(renderTime); + const seg = set.segments; + const shownCourse = + fromCourseDeg ?? + (set.expires(row) === -Infinity ? null : set.course(row, tau)); + + const regime = parts.motion._motionRegime(history, info, renderTime); + let speed; + let expiresAt = Infinity; + if (regime.kind === 'between') { + const { a, b, span, trackFrom, trackTo, chordCourse } = regime; + speed = regime.speed; + const blend = chordCourse == null ? 0 : regime.blend; + const t0 = set.seconds(a.time); + if (span > 0) { + writeLerpSegment(seg, row, { + from: a.position, + to: b.position, + t0, + t1: set.seconds(b.time), + course0Deg: + trackFrom + blend * norm180((chordCourse ?? 0) - trackFrom), + courseSlopeDeg: norm180(trackTo - trackFrom) * (1 - blend), + }); + } else { + writeHoldSegment( + seg, + row, + b.position, + trackTo + blend * norm180((chordCourse ?? 0) - trackTo), + ); + } + expiresAt = set.seconds(b.time); + } else { + const { fix, heading } = regime; + speed = regime.speed; + const t0 = set.seconds(fix.time); + if (speed === 0) { + writeHoldSegment(seg, row, fix.position, heading); + } else { + writeArcSegment(seg, row, { + fix: fix.position, + t0, + minDt: regime.minDt, + maxDt: regime.maxDt, + speedMps: speed, + trackDeg: heading, + turnRateDps: regime.turnRateDps, + }); + } + // A warm-up's arc ends at its fix; a coast's runs to its limit. + if (regime.maxDt === 0) expiresAt = t0; + } + // Below hover speed the nose holds what is on screen (or, for a new row, + // where this segment starts); above it, it glides on at the slew cap. + const hold = speed < COURSE_HOLD_SPEED_MPS; + if (hold || shownCourse != null) { + writeCourseGlide( + seg, + row, + shownCourse ?? segmentCourse(seg, row, tau), + tau, + hold ? 0 : courseSlewCapDps(speed, COURSE_MAX_DPS), + ); + } + set.segmentWritten(row, expiresAt); + } + + /** Re-plan every row whose regime ended before `tau`. */ + function _replanExpired(tau) { + const set = flightState._icons; + const due = []; + for (let row = 0; row < set.count; row++) + if (set.expires(row) <= tau) due.push(set.idAt(row)); + for (const icao24 of due) _planIcon(icao24); + } + + /** + * Grounded contacts stand on the floor under where they are DRAWN, which the + * GPU cannot sample. While the floor lifts one, the CPU pins its row to a + * hold at the floored dead-reckoned position each tick (rewritten only when + * it moved); once nothing lifts it the row goes back to its planned segment. + */ + function _floorGroundedIcons(renderTime, tau, nowMs, cameraPosition) { + const set = flightState._icons; + // Past FLOOR_NEAR_M a taxiing contact moves well under a pixel per tick, + // so far rows take turns: each refreshes every FLOOR_FAR_EVERY_TICKS. Their + // eases onto a lower floor advance per call, so they run that much slower + // out there too — sub-pixel at that range, and full rate once near. + const turn = flightState._floorTick++ % FLOOR_FAR_EVERY_TICKS; + const nearSq = FLOOR_NEAR_M * FLOOR_NEAR_M; + for (const icao24 of flightState._grounded) { + if (icao24 === flightState._trackedIcao) continue; + const row = set.row(icao24); + if (row < 0) continue; + if ( + row % FLOOR_FAR_EVERY_TICKS !== turn && + cameraPosition && + Cesium.Cartesian3.distanceSquared( + cameraPosition, + set.position(row, tau, scratchPosition), + ) > nearSq + ) + continue; + _floorIcon(icao24, renderTime, tau, nowMs); + } + } + + /** One grounded contact's floor pass (see `_floorGroundedIcons`). */ + function _floorIcon(icao24, renderTime, tau, nowMs) { + const set = flightState._icons; + const row = set.row(icao24); + if (row < 0) return; + const info = flightState.records.data.get(icao24); + const raw = parts.motion._deadReckon( + icao24, + flightState._scratchFleetPos, + renderTime, + ); + const rawCourse = + flightState._drCourseDeg ?? (info && info.true_track) ?? 0; + const hold = flightState._drCourseHold; + const speed = flightState._drSpeedMps ?? info?.velocity ?? NaN; + const display = parts.motion._floorGroundedDisplayPosition( + icao24, + info, + raw, + parts.rendering._modelOwnsVisual(icao24), + nowMs, + ); + if (!display || display === raw) { + if (flightState._floorPinned.delete(icao24)) + _planIcon(icao24, { fromCourseDeg: set.course(row, tau) }); + return; + } + const planned = set.expires(row) !== -Infinity; + const prev = planned ? set.course(row, tau) : null; + // The pin holds the position; the GPU glides the course onto the target, + // so the rate stays exact however seldom the pin is rewritten. + const target = hold && prev != null ? prev : rawCourse; + if ( + flightState._floorPinned.has(icao24) && + Cesium.Cartesian3.distanceSquared( + display, + set.position(row, tau, scratchPosition), + ) <= PIN_MOVE_SQ_M && + Math.abs(norm180(target - segmentTargetCourse(set.segments, row, tau))) <= + PIN_TURN_DEG + ) + return; + flightState._floorPinned.add(icao24); + writeHoldSegment(set.segments, row, display, target); + if (prev != null) + writeCourseGlide( + set.segments, + row, + prev, + tau, + courseSlewCapDps(speed, COURSE_MAX_DPS), + ); + set.segmentWritten(row); + } + + /** + * Advance focus de-emphasis for every drawn row (and every row a fleet model + * draws for, whose alpha reads it), only while a focus target exists or some + * row is still relaxing back to full emphasis. + */ + function _focusIcons(tau, scene, camera, nowMs, target) { + if (!focusPassIsNeeded(target, flightState._iconFocusActive)) return; + const { advanceProjectedSpriteFocus, nearFarScalarValueAtDistance } = + services.focus; + const set = flightState._icons; + let active = 0; + for (let row = 0; row < set.count; row++) { + if (!set.shown(row) && !flightState._models.has(set.idAt(row))) continue; + const position = set.position(row, tau, scratchPosition); + const distanceScale = nearFarScalarValueAtDistance( + set.scaleByDistance(row, scratchScaleByDistance), + Cesium.Cartesian3.distance(camera.positionWC, position), + ); + const half = set.scale(row) * distanceScale * 0.5; + const focus = advanceProjectedSpriteFocus( + set.metaAt(row), + position, + scene, + camera, + nowMs, + target, + undefined, + set.width(row) * half, + set.height(row) * half, + ); + set.setFocus(row, focus.factor); + if (focus.active || focus.transitioning) active++; + } + flightState._iconFocusActive = active; + } + + /** Image, size, scale, tint and freshness for one row under the current + * normal/cockpit contract. Motion and rotation stay with the segment. */ + function _applyIconPresentation(icao24) { + const set = flightState._icons; + const row = set.row(icao24); + if (row < 0) return; + const info = flightState.records.data.get(icao24); + const tracked = icao24 === flightState._trackedIcao; + const cockpitDot = + flightState._cockpitContactMode && + !tracked && + !flightState._cockpitNearContacts.has(icao24); + if (cockpitDot) { + const dot = iconAtlas.slot(cockpitContactDotImage(), COCKPIT_DOT_PX); + set.setImage(row, dot, dot); + set.setSize(row, COCKPIT_CONTACT_SIZE_PX, COCKPIT_CONTACT_SIZE_PX); + set.setScale(row, 1); + cockpitScaleByDistance ??= + parts.rendering._cockpitBillboardScaleByDistance(); + set.setScaleByDistance(row, cockpitScaleByDistance); + set.setColor(row, parts.rendering._cockpitContactColor(icao24, info)); + set.setFlag(row, ICON_ROTATES, false); + } else { + set.setImage( + row, + iconAtlas.slot( + aircraftIcon(parts.rendering._iconKind(icao24, info?.klass)), + FLEET_ICON_PX, + ), + iconAtlas.slot( + aircraftIcon( + parts.rendering._iconKind(icao24, info?.klass), + TRACKED_ICON_PX, + ), + TRACKED_ICON_PX, + ), + ); + const size = tracked ? 24 : 20; + set.setSize(row, size, size); + set.setScale( + row, + parts.rendering._fleetBillboardScale(icao24, info?.klass), + ); + normalScaleByDistance ??= + parts.rendering._normalBillboardScaleByDistance(); + set.setScaleByDistance(row, normalScaleByDistance); + set.setColor(row, parts.rendering._fleetBillboardColor(icao24, info)); + set.setFlag(row, ICON_ROTATES, true); + } + set.setFlag(row, ICON_RECEDES, true); + set.setFlag( + row, + ICON_CULL_LIFT, + Number.isFinite(info?.renderAltitudeM) && info.renderAltitudeM < 10, + ); + set.setAlpha(row, flightState.records.missingPolls.get(icao24) ? 0.45 : 1); + } + + /** Add a row for a new contact, hidden while it is the tracked one. */ + function _addIcon(icao24) { + const set = flightState._icons; + const row = set.add(icao24, { layer: 'flights', id: icao24 }); + _applyIconPresentation(icao24); + _settleIcon(icao24); + set.setShown(row, icao24 !== flightState._trackedIcao); + return row; + } + + /** + * Plan a row afresh, dropping any floor pin (a pin stops at the last floor + * pass, which skips a tracked contact), and let a dimmed row relax. A grounded contact goes straight onto + * the floor under where it is drawn, not its fix's floor until the next fleet + * tick raises it. + * @param {string} icao24 + * @param {{fromCourseDeg?: number}} [options] The course on screen to glide from. + */ + function _settleIcon(icao24, options) { + const set = flightState._icons; + flightState._floorPinned.delete(icao24); + // A row that sat out the focus pass (tracked) may come back dimmed after + // the pass went idle; count it so the pass runs and relaxes it. + if (set.focus(set.row(icao24)) < 1) flightState._iconFocusActive += 1; + _planIcon(icao24, options); + if (flightState._grounded.has(icao24)) { + const renderTime = _displayTime(); + _floorIcon(icao24, renderTime, set.seconds(renderTime), Date.now()); + } + } + + /** Drop a contact and everything the layer keeps for it. */ + function _forgetContact(icao24) { + flightState._icons?.remove(icao24); + parts.rendering._releaseModel(icao24); + flightState.records.forget(icao24); + flightState._positionHistory.delete(icao24); + flightState._displayCourse.delete(icao24); + flightState._groundSnap.forget(icao24); + flightState._displayFloorState.delete(icao24); + flightState._grounded.delete(icao24); + flightState._floorPinned.delete(icao24); + } + + /** Icon ids, so ray samples never land on an aircraft instead of the ground. */ + function _iconIds() { + const set = flightState._icons; + const ids = new Array(set?.count ?? 0); + for (let row = 0; row < ids.length; row++) ids[row] = set.idAt(row); + return ids; + } + + return { + _displayTime, + _displayTau, + _createIcons, + _iconPosition, + _iconCourse, + _iconShown, + _occluder, + _aboveHorizon, + _planIcon, + _settleIcon, + _replanExpired, + _floorGroundedIcons, + _focusIcons, + _applyIconPresentation, + _addIcon, + _forgetContact, + _iconIds, + }; +} diff --git a/src/layers/flights/icons.test.mjs b/src/layers/flights/icons.test.mjs new file mode 100644 index 000000000..1357e93ac --- /dev/null +++ b/src/layers/flights/icons.test.mjs @@ -0,0 +1,120 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createFlightState } from './state.js'; +import { createIcons } from './icons.js'; +import { createIconSet } from '../../data/iconSet.js'; +import { writeHoldSegment } from '../../data/iconSegment.js'; + +function services(focus = {}) { + return { + focus: { focusPassIsNeeded: () => true, ...focus }, + groundSnap: { createGroundSnap: () => ({ clear() {}, forget() {} }) }, + }; +} + +function setup({ focus, motion = {}, rendering = {} } = {}) { + const svc = services(focus); + const flightState = createFlightState({ services: svc }); + flightState._icons = createIconSet(); + const parts = { + motion, + rendering: { _modelOwnsVisual: () => false, ...rendering }, + }; + const icons = createIcons({ flightState, services: svc, parts }); + return { flightState, icons, set: flightState._icons }; +} + +function hold(set, id, position, course = 0) { + const row = set.add(id, { layer: 'flights', id }); + writeHoldSegment(set.segments, row, position, course); + set.segmentWritten(row); + return row; +} + +const at = (lon, h = 1000) => Cesium.Cartesian3.fromDegrees(lon, 30, h); + +test('focus advances for drawn rows and for rows a fleet model draws for', () => { + const advanced = []; + const { flightState, icons, set } = setup({ + focus: { + nearFarScalarValueAtDistance: () => 1, + advanceProjectedSpriteFocus(meta) { + advanced.push(meta.id); + return { factor: 0.4, active: true }; + }, + }, + }); + set.setShown(hold(set, 'drawn', at(0)), true); + const modeled = hold(set, 'modeled', at(0.1)); + hold(set, 'hidden', at(0.2)); + flightState._models.set('modeled', {}); + + icons._focusIcons(0, {}, { positionWC: at(0, 50_000) }, 0, {}); + assert.deepEqual(advanced, ['drawn', 'modeled']); + assert.ok( + Math.abs(set.focus(modeled) - 0.4) < 1e-6, + "the model's alpha reads it", + ); + assert.equal(flightState._iconFocusActive, 2); +}); + +test('settling a pinned grounded contact re-floors it where it is now, nose kept', () => { + const taxied = at(0.01, 100); + const floored = at(0.01, 131.5); + const { flightState, icons, set } = setup({ + motion: { + _motionRegime: () => ({ + kind: 'fix', + fix: { position: taxied, time: Cesium.JulianDate.now() }, + speed: 0, + heading: 200, + turnRateDps: 0, + minDt: -60, + maxDt: 60, + }), + _deadReckon(icao24, result) { + flightState._drCourseDeg = 200; + flightState._drSpeedMps = 0; + flightState._drCourseHold = true; + return Cesium.Cartesian3.clone(taxied, result); + }, + _floorGroundedDisplayPosition: () => floored, + }, + }); + // Pinned before it was tracked, 1 km back, facing 10°. + const row = hold(set, 'g', at(0, 131.5), 10); + flightState._floorPinned.add('g'); + flightState._grounded.add('g'); + flightState.records.data.set('g', { onGround: true }); + flightState._positionHistory.set('g', [{}]); + + icons._settleIcon('g', { fromCourseDeg: 123 }); + const tau = icons._displayTau(); + assert.ok(Cesium.Cartesian3.distance(set.position(row, tau), floored) < 0.01); + assert.ok(Math.abs(set.course(row, tau) - 123) < 1e-3, 'tracked nose kept'); + assert.ok(flightState._floorPinned.has('g'), 'pinned on the new floor'); +}); + +test('a contact that comes back dimmed re-arms the idle focus pass', () => { + const advanced = []; + const { flightState, icons, set } = setup({ + focus: { + focusPassIsNeeded: (target, active) => target != null || active > 0, + nearFarScalarValueAtDistance: () => 1, + advanceProjectedSpriteFocus(meta) { + advanced.push(meta.id); + return { factor: 1, active: false, transitioning: false }; + }, + }, + motion: { _motionRegime: () => null }, + }); + const row = hold(set, 'was-tracked', at(0)); + set.setFocus(row, 0.2); + flightState._iconFocusActive = 0; + icons._settleIcon('was-tracked'); + set.setShown(row, true); + icons._focusIcons(0, {}, { positionWC: at(0, 50_000) }, 0, null); + assert.deepEqual(advanced, ['was-tracked']); + assert.equal(set.focus(row), 1); +}); diff --git a/src/layers/flights/index.js b/src/layers/flights/index.js index f778c98b1..c42a0f9fb 100644 --- a/src/layers/flights/index.js +++ b/src/layers/flights/index.js @@ -1,6 +1,7 @@ import { createFlightSnapshotRenderer } from './snapshotRenderer.js'; import { createFlightState } from './state.js'; import { createRendering } from './rendering.js'; +import { createIcons } from './icons.js'; import { createMotion } from './motion.js'; import { createTracking } from './tracking.js'; import { createController } from './controller.js'; @@ -21,6 +22,7 @@ export function createCivilFlightLayer({ const layer = {}; const context = { flightState, services, parts, layer, resolveAsset }; parts.rendering = createRendering(context); + parts.icons = createIcons(context); parts.motion = createMotion(context); parts.tracking = createTracking(context); parts.controller = createController(context); @@ -38,6 +40,7 @@ export function createCivilFlightLayer({ rendering: parts.rendering, tracking: parts.tracking, motion: parts.motion, + icons: parts.icons, enrichment: parts.enrichment, queries: parts.queries, }); diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 0ca944647..225e8bf82 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -34,7 +34,7 @@ export function createLifecycle({ /** * Initialize the flights layer. - * Creates the BillboardCollection, resets all state, and installs the + * Creates the icon set and its primitive, resets all state, and installs the * click-to-track handler on the scene canvas. * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. */ @@ -48,9 +48,7 @@ export function createLifecycle({ clearFocusTarget('flights'); flightState._focusEvidenceIds.clear(); flightState._viewer = viewer; - flightState._billboardCollection = new Cesium.BillboardCollection(); - viewer.scene.primitives.add(flightState._billboardCollection); - registerSpriteCollection('flights', flightState._billboardCollection); + registerSpriteCollection('flights', parts.icons._createIcons(viewer)); 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 @@ -78,8 +76,9 @@ export function createLifecycle({ /* tracked plane just stays a billboard a beat longer */ }); } - flightState._billboards = new Map(); - flightState._cullPositions.clear(); + flightState._grounded.clear(); + flightState._floorPinned.clear(); + flightState._iconFocusActive = 0; flightState._detectionObjects = new Map(); flightState.records.data = new Map(); flightState._positionHistory = new Map(); @@ -126,16 +125,15 @@ export function createLifecycle({ restoreSpriteOrder(viewer); - console.log('[Data:Flights] Initialized with billboard icons'); + console.log('[Data:Flights] Initialized with GPU icons'); }, /** - * Show the billboard collection and re-install the click handler. + * Show the icons and re-install the click handler. * @param {Cesium.Viewer} viewer */ enable(viewer) { - if (flightState._billboardCollection) - flightState._billboardCollection.show = true; + if (flightState._icons) flightState._icons.show = true; holdContinuousRender('flights'); // per-frame animator (perf wave 2) if (flightState._modelCollection) flightState._modelCollection.show = true; @@ -156,11 +154,10 @@ export function createLifecycle({ }); } parts.tracking._installClickHandler(viewer); - registerPickOwner('flights', (pickedId) => - flightState._billboards.has(pickedId), + registerPickOwner( + 'flights', + (pickedId) => flightState._icons?.has(pickedId) === true, ); - // Force a fresh rotation pass on the first tick after re-enable - flightState._lastCamPoseSig = ''; if (!flightState._preRenderRemove && viewer?.scene) { flightState._preRenderRemove = viewer.scene.preRender.addEventListener( parts.rendering._fleetTick, @@ -173,18 +170,11 @@ export function createLifecycle({ ); } if (!flightState._moveEndRemove && viewer?.camera) { - // Arrival polish (field test 2026-07-03: "planes look weird when you first - // come to them"): when a camera move SETTLES (voice fly-to, fast pan - // release), force a full rotation pass on the very next frame. The - // pose-signature gate alone can eat the settle — the final easing frames - // of a flight land inside one quantization bucket (10 m / 0.06°), leaving - // every icon wearing its last mid-flight rotation for up to - // ROTATION_REFRESH_MS. Zeroing the tick throttle too means the pass runs - // on the next preRender, not up to FLEET_DR_INTERVAL_MS later. Cost: one - // extra rotation pass per completed camera gesture — nothing per-frame. + // A settled camera move (voice fly-to, fast pan release) runs the fleet + // pass on the very next preRender rather than up to FLEET_DR_INTERVAL_MS + // later, so the model handoff and focus land with the camera. flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener( () => { - flightState._lastCamPoseSig = ''; flightState._lastFleetTickMs = 0; }, ); @@ -193,15 +183,14 @@ export function createLifecycle({ }, /** - * Hide all flight billboards and tear down click/keyboard handlers. + * Hide all flight icons and tear down click/keyboard handlers. * Also clears any active flight tracking so the camera is released. * @param {Cesium.Viewer} viewer */ disable(viewer) { parts.controller._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); - if (flightState._billboardCollection) - flightState._billboardCollection.show = false; + if (flightState._icons) flightState._icons.show = false; releaseContinuousRender('flights'); parts.rendering._releaseModels(); if (flightState._modelCollection) @@ -282,10 +271,12 @@ export function createLifecycle({ flightState._moveEndRemove = null; } parts.rendering._releaseModels(); - if (flightState._billboardCollection) { - viewer.scene.primitives.remove(flightState._billboardCollection); - flightState._billboardCollection = null; + if (flightState._iconPrimitive) { + viewer.scene.primitives.remove(flightState._iconPrimitive); // removing destroys it + flightState._iconPrimitive = null; } + flightState._icons?.clear(); + flightState._icons = null; if (flightState._modelCollection) { viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models flightState._modelCollection = null; @@ -302,8 +293,9 @@ export function createLifecycle({ flightState._preloadModel = null; } flightState._planeModelLoaded = false; - flightState._billboards.clear(); - flightState._cullPositions.clear(); + flightState._grounded.clear(); + flightState._floorPinned.clear(); + flightState._iconFocusActive = 0; flightState._detectionObjects.clear(); flightState.records.data.clear(); flightState._positionHistory.clear(); diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index 309966b9b..37f2febc9 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -35,10 +35,23 @@ import { DISPLAY_CORRIDOR_CELL_BUDGET, DISPLAY_CORRIDOR_FAIR_SHARE, } from './policy.js'; -import { billboardState, setBillboard } from '../../data/billboardMirror.js'; import { modelState } from '../../data/modelMirror.js'; import { readEntityRecord } from '../../data/entityRecords.js'; +/** A fix's own ground speed, else the record's. */ +function fixSpeedMps(fix, info) { + return Number.isFinite(fix.velocity) + ? fix.velocity + : (info && info.velocity) || 0; +} + +/** A fix's own track, else the record's. */ +function fixTrackDeg(fix, info) { + return Number.isFinite(fix.track) + ? fix.track + : (info && info.true_track) || 0; +} + export function createMotion({ flightState, services, @@ -66,6 +79,23 @@ export function createMotion({ // Fixes leave the cache with their history; corrected positions invalidate // the local frame even when the existing Cartesian is mutated in place. const fixFrames = new WeakMap(); + /** `_motionRegime`'s reused result. */ + const regime = { + kind: 'fix', + a: null, + b: null, + span: 0, + speed: 0, + trackFrom: 0, + trackTo: 0, + chordCourse: null, + blend: 0, + fix: null, + heading: 0, + turnRateDps: 0, + minDt: 0, + maxDt: 0, + }; // Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal // warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS @@ -81,7 +111,7 @@ export function createMotion({ * coasting on its final meta — if that fix predated the floor warm, it sat * frozen underground forever. Runs once per poll over the (bounded) flight * map; touches only grounded, feed-absent, demonstrably-below-floor - * contacts, and patches the stored fix + billboard in place (zero-velocity + * contacts, and patches the stored fix + icon in place (zero-velocity * DR renders the patched fix verbatim). * @param {Set} currentIcaos - Contacts present in THIS poll (already * floored by the live path — skipped here). @@ -101,7 +131,6 @@ export function createMotion({ ) continue; info.renderAltitudeM = lifted; - flightState._cullPositions.delete(icao24); // above the ellipsoid now (or floors say otherwise next poll) const position = Cesium.Cartesian3.fromDegrees( info.rawLon, info.rawLat, @@ -111,8 +140,9 @@ export function createMotion({ const newest = history?.[history.length - 1]; if (newest) newest.position = Cesium.Cartesian3.clone(position, newest.position); - const bb = flightState._billboards.get(icao24); - if (bb) setBillboard(bb, 'position', position); + // Above the ellipsoid now (or floors say otherwise next poll). + parts.icons._applyIconPresentation(icao24); + parts.icons._planIcon(icao24); } } @@ -134,9 +164,7 @@ export function createMotion({ function _deadReckon(icao24, result, sampledRenderTime) { const info = flightState.records.data.get(icao24); if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) { - const position = billboardState( - flightState._billboards.get(icao24), - )?.position; + const position = parts.icons._iconPosition(icao24); flightState._drCourseDeg = info?.true_track || 0; flightState._drSpeedMps = info?.velocity || 0; flightState._drCourseHold = @@ -166,100 +194,113 @@ export function createMotion({ flightState._scratchRenderTime, ); - // Bracketing pair: interpolate — no extrapolation error, no snap-back. + const regime = _motionRegime(history, info, renderTime); + if (regime.kind === 'between') { + const { a, b, span } = regime; + const t = + span > 0 + ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span + : 1.0; + const trackCourse = lerpAngleDeg(regime.trackFrom, regime.trackTo, t); + flightState._drCourseDeg = + regime.chordCourse != null + ? lerpAngleDeg(trackCourse, regime.chordCourse, regime.blend) + : trackCourse; + flightState._drSpeedMps = regime.speed; + flightState._drCourseHold = regime.speed < COURSE_HOLD_SPEED_MPS; + flightState._drExtrapolating = false; + return Cesium.Cartesian3.lerp(a.position, b.position, t, out); + } + const dt = Cesium.Math.clamp( + Cesium.JulianDate.secondsDifference(renderTime, regime.fix.time), + regime.minDt, + regime.maxDt, + ); + return _extrapolateFix(regime.fix, info, dt, out, regime.turnRateDps); + } + + /** + * Which motion a contact's history gives at `renderTime` — the one choice + * `_deadReckon` (CPU) and the icon planner (GPU segment) both draw from. + * + * - `between`: interpolate the bracketing pair `a`→`b`, no extrapolation + * error and no snap-back. The course of the DISPLAYED motion blends the + * time-interpolated reported track toward the chord by `blend`: the chord + * is only trustworthy when the segment covers real ground (at hover its + * direction is GPS jitter; on a slow tight turn it STEPS the whole + * per-segment turn at each boundary), and helicopters never use it + * (rotorcraft chords are noise-dominated at their typical speeds). + * - `fix`: extrapolate `fix` by the elapsed time clamped to [minDt, maxDt]. + * Warm-up (renderTime predates ALL history: freshly seen or just tracked, + * before RENDER_DELAY_SEC of history exists) runs the OLDEST fix BACKWARD, + * preserving the 30s-behind invariant, so as history fills the icon glides + * FORWARD into interpolation with no freeze and no backward snap (holding + * the oldest fix froze the icon; extrapolating the newest to wall-clock + * made it jump back a poll interval when interpolation took over). + * Otherwise the newest fix coasts. OpenSky can still be receiving fresh + * contact messages for that aircraft; freezing 60 s after the position + * produced a stop → catch-up → stop cadence, so it coasts through the latest + * real contact plus a bounded grace, with an absolute cap so a stale cached + * feed cannot drift forever. + * + * The returned object is a reused scratch: read it before the next call. + * @param {Array} history Non-empty, oldest first. + * @param {object|undefined} info The contact's record. + * @param {Cesium.JulianDate} renderTime + */ + function _motionRegime(history, info, renderTime) { for (let i = history.length - 1; i >= 1; i--) { const a = history[i - 1]; const b = history[i]; if ( - Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && - Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) - ) { - const span = Cesium.JulianDate.secondsDifference(b.time, a.time); - const t = - span > 0 - ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span - : 1.0; - // Course of the DISPLAYED motion. The chord is only trustworthy when the - // segment covers real ground (at hover its direction is GPS jitter; on a - // slow tight turn it STEPS the whole per-segment turn at each boundary), - // so it is blended against the reported per-fix track by displayed - // ground speed — and the track is TIME-INTERPOLATED between the fixes so - // a slow turner's nose advances continuously through the segment instead - // of snapping once per poll. Helicopters always use the reported track - // (rotorcraft chords are noise-dominated at their typical speeds). - const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); - const segSpeed = - span > 0 ? chordLenM / span : (info && info.velocity) || 0; - const fallbackTrack = (info && info.true_track) || 0; - const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; - const trackTo = Number.isFinite(b.track) ? b.track : trackFrom; - const trackCourse = lerpAngleDeg(trackFrom, trackTo, t); - const w = info && info.klass === 'helicopter' ? 0 : speedRamp(segSpeed); - const chordCourse = - w > 0 ? courseBetweenCartesians(a.position, b.position) : null; - flightState._drCourseDeg = - chordCourse != null - ? lerpAngleDeg(trackCourse, chordCourse, w) - : trackCourse; - flightState._drSpeedMps = segSpeed; - flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS; - flightState._drExtrapolating = false; - return Cesium.Cartesian3.lerp(a.position, b.position, t, out); - } + !Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) || + !Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) + ) + continue; + const span = Cesium.JulianDate.secondsDifference(b.time, a.time); + const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); + const speed = span > 0 ? chordLenM / span : (info && info.velocity) || 0; + const fallbackTrack = (info && info.true_track) || 0; + const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; + const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed); + regime.kind = 'between'; + regime.a = a; + regime.b = b; + regime.span = span; + regime.speed = speed; + regime.trackFrom = trackFrom; + regime.trackTo = Number.isFinite(b.track) ? b.track : trackFrom; + regime.chordCourse = + blend > 0 ? courseBetweenCartesians(a.position, b.position) : null; + regime.blend = blend; + return regime; } - const newest = history[history.length - 1]; - const elapsedSec = Cesium.JulianDate.secondsDifference( - renderTime, - newest.time, - ); - if (elapsedSec <= 0) { - // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking - // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing - // pair exists yet). Render at the DELAYED renderTime — preserving the 30s-behind - // invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history - // fills, renderTime advances toward the first fix and the icon glides FORWARD into - // the bracketing interpolation above with NO freeze and NO backward snap. (Holding - // the oldest fix froze the icon; extrapolating the NEWEST fix to wall-clock now made - // the icon jump back ~one poll interval the instant interpolation took over.) - const oldest = history[0]; - const lookbackSec = Cesium.JulianDate.secondsDifference( - oldest.time, - renderTime, - ); // ≥ 0 - return _extrapolateFix( - oldest, - info, - -Math.min(lookbackSec, 60), - out, - (info && info.turnRateDps) || 0, - ); - } - - // Newest POSITION is older than renderTime. OpenSky can still be receiving - // fresh contact/kinematic messages for that aircraft; freezing at a hard - // 60 s-after-position boundary produced the visible stop → catch-up → stop - // cadence. Coast through the latest real contact plus a bounded grace - // window, with an absolute cap so a stale cached feed cannot drift forever. - const coastLimitSec = staleCoastLimitSeconds({ - // `epochMs` is captured once when the poll is normalized. Avoid allocating - // a Date per aircraft on every 12 Hz fleet tick. - fixEpochMs: Number.isFinite(newest.epochMs) - ? newest.epochMs - : Cesium.JulianDate.toDate(newest.time).getTime(), - lastContactEpochMs: info?.lastContactEpochMs, - // Permit one minute of contact grace but cap any cached-feed drift at - // five minutes. Source backoff is exposed separately as a STALE cue. - minimumSec: 60, - maximumSec: 300, - }); - return _extrapolateFix( - newest, - info, - Math.min(elapsedSec, coastLimitSec), - out, - (info && info.turnRateDps) || 0, - ); + const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time); + regime.kind = 'fix'; + regime.fix = warming ? history[0] : newest; + regime.speed = fixSpeedMps(regime.fix, info); + regime.heading = fixTrackDeg(regime.fix, info); + regime.turnRateDps = (info && info.turnRateDps) || 0; + regime.minDt = -60; + // A warm-up ends when real time reaches the oldest fix; a single fix keeps + // coasting forward on the same arc. + regime.maxDt = + warming && history.length > 1 + ? 0 + : staleCoastLimitSeconds({ + // `epochMs` is captured once when the poll is normalized. Avoid + // allocating a Date per aircraft on every 12 Hz fleet tick. + fixEpochMs: Number.isFinite(newest.epochMs) + ? newest.epochMs + : Cesium.JulianDate.toDate(newest.time).getTime(), + lastContactEpochMs: info?.lastContactEpochMs, + // Permit one minute of contact grace but cap any cached-feed drift + // at five minutes. Source backoff is exposed separately as a STALE cue. + minimumSec: 60, + maximumSec: 300, + }); + return regime; } /** @@ -274,12 +315,8 @@ export function createMotion({ */ function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { - const speed = Number.isFinite(fix.velocity) - ? fix.velocity - : (info && info.velocity) || 0; - const heading = Number.isFinite(fix.track) - ? fix.track - : (info && info.true_track) || 0; + const speed = fixSpeedMps(fix, info); + const heading = fixTrackDeg(fix, info); flightState._drSpeedMps = speed; flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS; flightState._drExtrapolating = true; @@ -1114,6 +1151,7 @@ export function createMotion({ return { _refloorStaleGroundedContacts, _deadReckon, + _motionRegime, _extrapolateFix, _isTrackWarmingUp, _trackedFocusSizePx, diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 16c1a9cc6..7b175bc41 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -59,7 +59,8 @@ test('flight state owns distinct mutable records, scratch objects and floor samp for (const key of [ 'records', 'feed', - '_billboards', + '_grounded', + '_floorPinned', '_positionHistory', '_displayFloorState', '_groundSnap', diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 2ecb0a8a4..e951d6cf5 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -207,6 +207,14 @@ export const COURSE_MAX_DPS = 60; export const COURSE_SLEW_DT_MAX_SEC = 0.25; +/** Grounded contacts this close to the camera are floored every fleet tick. */ + +export const FLOOR_NEAR_M = 50_000; + +/** Farther grounded contacts are floored once per this many fleet ticks (~1 s). */ + +export const FLOOR_FAR_EVERY_TICKS = 12; + // --------------------------------------------------------------------------- // adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4 // concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index e8eb44e8d..23f64b0c1 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -16,7 +16,6 @@ import { FLEET_DR_INTERVAL_MS, } from './policy.js'; import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; -import { billboardState, setBillboard } from '../../data/billboardMirror.js'; import { modelState } from '../../data/modelMirror.js'; export function createQueries({ @@ -99,7 +98,7 @@ export function createQueries({ * airframe itself and cannot go stale the way a leg can. * * This is a DISPLAY string only. Identity everywhere in this layer is `icao24` - * (the `_billboards`/`_flightData` key, the `sourceId` detection declutter hashes, + * (the `_icons`/`_flightData` key, the `sourceId` detection declutter hashes, * the `id` that trackById/Context cohorts resolve) — never the label. * @param {string} icao24 - ICAO 24-bit transponder address (the identity key). * @param {Object|null|undefined} info - `_flightData` record for this aircraft. @@ -134,10 +133,8 @@ export function createQueries({ // every visual consumer reads the floored per-frame cache instead (see // `_trackedDisplayPosition`). If that ever changes, floor this path too. const info = flightState.records.data.get(icao24); - const bb = flightState._billboards.get(icao24); const basePos = - parts.motion._deadReckon(icao24) || - (bb ? billboardState(bb).position : null); + parts.motion._deadReckon(icao24) || parts.icons._iconPosition(icao24); if (!basePos) return null; const displayed = displayedKinematics({ derivedSpeedMps: flightState._drSpeedMps, @@ -334,14 +331,8 @@ export function createQueries({ if (!flightState._models3dEnabled) { parts.rendering._releaseModels(); parts.tracking._syncTracked2dRotation(); - // Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the - // tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it - // here would double-image the tracked plane when 3D is turned off mid-track. - if (flightState._billboardCollection) - for (const [icao, bb] of flightState._billboards) { - if (icao !== flightState._trackedIcao) - setBillboard(bb, 'show', true); - } + // (Releasing the models handed each fleet contact back to its icon, never + // the tracked plane's own — its tracked entity is the visual.) } } if ( @@ -424,42 +415,34 @@ export function createQueries({ * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} */ getDetectableObjects(options = {}) { - if ( - !flightState._billboardCollection || - !flightState._billboardCollection.show - ) - return []; - // Compute a stride that evenly samples the billboard map. + const icons = flightState._icons; + if (!icons || !icons.show) return []; + // Compute a stride that evenly samples the icon rows. // seed shifts the starting offset so successive calls can sample - // different aircraft without shuffling the underlying Map order. + // different aircraft without reordering the rows. const maxCount = Number.isFinite(options.maxCount) ? Math.max(1, Math.floor(options.maxCount)) - : flightState._billboards.size; + : icons.count; const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - const stride = Math.max( - 1, - Math.ceil(flightState._billboards.size / maxCount), - ); + const stride = Math.max(1, Math.ceil(icons.count / maxCount)); const start = seed % stride; + const occluder = parts.icons._occluder(); + const tau = parts.icons._displayTau(); const result = []; - let idx = 0; - for (const [icao24, bb] of flightState._billboards) { - const shouldTake = (idx - start) % stride === 0; - idx++; - if (!shouldTake) continue; + for (let row = start; row < icons.count; row += stride) { + const icao24 = icons.idAt(row); if ( flightState._cockpitContactMode && icao24.toLowerCase() === flightState._cockpitSubjectId ) continue; const isTracked = icao24 === flightState._trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so the detection box - // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. + // Keep planes rendered as a 3D model (icon hidden) so the detection box + // doesn't vanish on the 2D→3D handoff. const model = flightState._models.get(icao24) ?? null; const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model); - if (!isTracked && !billboardState(bb).show && !modelOwnsVisual) - continue; + if (!isTracked && !icons.shown(row) && !modelOwnsVisual) continue; const info = flightState.records.data.get(icao24); let object = flightState._detectionObjects.get(icao24); if (!object) { @@ -467,21 +450,24 @@ export function createQueries({ sourceId: icao24, type: 'AIR', _weldPos: new Cesium.Cartesian3(), + _iconPos: new Cesium.Cartesian3(), }; flightState._detectionObjects.set(icao24, object); } + // WELD: anchor to whatever actually owns the visual. A model-owned contact is // read straight off the translation the fleet tick already wrote into its // modelMatrix, so bracket and label sit on the aircraft you can see instead of - // on the buried billboard position, which for a grounded plane is ~100 m below + // on the buried icon position, which for a grounded plane is ~100 m below // 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. + // no `_modelDisplayPosition` call from postRender. Icon-owned contacts keep + // the drawn icon position — icon and bracket are co-located there. const spec = modelOwnsVisual ? parts.rendering._modelSpec(info?.klass) : null; const pos = isTracked - ? parts.motion._trackedVisualCached() || billboardState(bb).position + ? parts.motion._trackedVisualCached() || + icons.position(row, tau, object._iconPos) : modelOwnsVisual ? modelVisualAnchor( modelState(model).modelMatrix, @@ -491,8 +477,15 @@ export function createQueries({ : spec.scale, object._weldPos || (object._weldPos = new Cesium.Cartesian3()), ) - : billboardState(bb).position; - if (!pos) continue; + : icons.position(row, tau, object._iconPos); + // An icon past the horizon is not drawn, so it is not detectable. + if ( + !pos || + (!isTracked && + !modelOwnsVisual && + !parts.icons._aboveHorizon(row, pos, occluder)) + ) + continue; object.position = pos; object.skipLabel = isTracked; // Card text only — declutter/cohort identity is `object.sourceId` (icao24). @@ -517,13 +510,9 @@ export function createQueries({ result.push(object); if (result.length >= maxCount) break; } - if ( - flightState._detectionObjects.size > - flightState._billboards.size + 512 - ) { + if (flightState._detectionObjects.size > icons.count + 512) { for (const icao24 of flightState._detectionObjects.keys()) { - if (!flightState._billboards.has(icao24)) - flightState._detectionObjects.delete(icao24); + if (!icons.has(icao24)) flightState._detectionObjects.delete(icao24); } } return result; @@ -583,12 +572,11 @@ export function createQueries({ * @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>} */ getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { - if ( - !center || - !flightState._billboardCollection || - !flightState._billboardCollection.show - ) - return []; + const icons = flightState._icons; + if (!center || !icons || !icons.show) return []; + const occluder = parts.icons._occluder(); + const tau = parts.icons._displayTau(); + const scratch = flightState._scratchNearbyPos; const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) @@ -598,28 +586,30 @@ export function createQueries({ const nearby = []; - for (const [icao24, bb] of flightState._billboards) { + for (let row = 0; row < icons.count; row++) { + const icao24 = icons.idAt(row); const isTracked = icao24 === flightState._trackedIcao; - // Keep planes rendered as a 3D model (billboard hidden) so proximity counts - // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. + const trackedPos = isTracked + ? parts.motion._trackedDisplayCached() + : null; // cached, no recompute (anti-jitter) + const drawn = trackedPos || icons.position(row, tau, scratch); + // Keep planes rendered as a 3D model (icon hidden) so proximity counts + // don't drop to zero on the 2D→3D handoff. if ( !aircraftIncludedInNearby({ isTracked, - billboardShown: billboardState(bb).show, + billboardShown: + icons.shown(row) && + parts.icons._aboveHorizon(row, drawn, occluder), modelRendering: parts.rendering._modelOwnsVisual(icao24), includeHidden, }) ) continue; - const trackedPos = isTracked - ? parts.motion._trackedDisplayCached() - : null; // cached, no recompute (anti-jitter) - const pos = trackedPos || billboardState(bb).position; - if (!pos) continue; - - const distance = Cesium.Cartesian3.distance(center, pos); + const distance = Cesium.Cartesian3.distance(center, drawn); if (distance > maxRange) continue; + const pos = trackedPos || Cesium.Cartesian3.clone(drawn); const info = flightState.records.data.get(icao24); const callsign = info?.callsign?.trim() || null; @@ -651,8 +641,8 @@ export function createQueries({ /** * Return id/label/position for up to maxCount currently rendered aircraft. - * Cheap snapshot for voice-tool framing — billboard positions only, no - * dead reckoning and no cloning. + * Cheap snapshot for voice-tool framing — drawn icon positions, no + * extra dead reckoning. * @param {number} [maxCount=500] - Maximum number of entries to return. * @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>} */ @@ -671,34 +661,25 @@ export function createQueries({ * holds no data and therefore cannot answer. */ hasContact(icao24) { - if ( - !flightState._billboardCollection || - !flightState._billboardCollection.show || - flightState._billboards.size === 0 - ) - return null; + const icons = flightState._icons; + if (!icons || !icons.show || icons.count === 0) return null; if (!icao24) return false; const id = String(icao24).trim(); - return ( - flightState._billboards.has(id) || - flightState._billboards.has(id.toLowerCase()) - ); + return icons.has(id) || icons.has(id.toLowerCase()); }, getAllPositions(maxCount = 500) { - if ( - !flightState._billboardCollection || - flightState._billboards.size === 0 - ) - return []; + const icons = flightState._icons; + if (!icons || icons.count === 0) return []; const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 500; + const tau = parts.icons._displayTau(); const result = []; - for (const [icao24, bb] of flightState._billboards) { - const pos = billboardState(bb).position; - if (!pos) continue; + for (let row = 0; row < icons.count; row++) { + const icao24 = icons.idAt(row); + const pos = icons.position(row, tau); const carto = Cesium.Cartographic.fromCartesian( pos, Cesium.Ellipsoid.WGS84, @@ -741,11 +722,7 @@ export function createQueries({ * @returns {Array} See mapAnalystRecord for the record shape. */ getAnalystRecords(maxCount = 2000) { - if ( - !flightState._billboardCollection || - !flightState._billboardCollection.show || - flightState.records.data.size === 0 - ) + if (!flightState._icons?.show || flightState.records.data.size === 0) return []; const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) @@ -773,8 +750,8 @@ export function createQueries({ trackById(icao24, { origin = 'programmatic' } = {}) { if (!icao24) return false; let id = String(icao24).trim(); - if (!flightState._billboards.has(id)) id = id.toLowerCase(); - if (!flightState._billboards.has(id)) return false; + if (!flightState._icons?.has(id)) id = id.toLowerCase(); + if (!flightState._icons?.has(id)) return false; if (parts.tracking._isExplicitTrackingOrigin(origin)) parts.tracking._cancelPendingTrackingRestore(); if (flightState._trackedIcao === id) @@ -844,7 +821,7 @@ export function createQueries({ ) return false; let id = String(icao24).trim(); - if (!flightState._billboards.has(id)) id = id.toLowerCase(); + if (!flightState._icons?.has(id)) id = id.toLowerCase(); if ( id !== flightState._trackedIcao || !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || diff --git a/src/layers/flights/queries.test.mjs b/src/layers/flights/queries.test.mjs index 106bc0170..a2d1fff19 100644 --- a/src/layers/flights/queries.test.mjs +++ b/src/layers/flights/queries.test.mjs @@ -3,11 +3,9 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createQueries } from './queries.js'; import { createRendering } from './rendering.js'; -import { - billboardState, - mirrorBillboard, - setBillboard, -} from '../../data/billboardMirror.js'; +import { createIcons } from './icons.js'; +import { createIconSet } from '../../data/iconSet.js'; +import { writeHoldSegment } from '../../data/iconSegment.js'; import { mirrorModel, setModel } from '../../data/modelMirror.js'; function fixture() { @@ -16,14 +14,11 @@ function fixture() { registration: ' N123 ', altitude: 1000, }; - const bb = mirrorBillboard({ - show: true, - position: new Cesium.Cartesian3(1, 2, 3), - }); + const icons = createIconSet(); const flightState = { feed: {}, - _billboardCollection: { show: true }, - _billboards: new Map([['abc123', bb]]), + _icons: icons, + _scratchIconRenderTime: new Cesium.JulianDate(), _models: new Map(), _detectionObjects: new Map(), records: { data: new Map([['abc123', info]]) }, @@ -48,8 +43,27 @@ function fixture() { motion: { _trackedVisualCached: () => null }, }; parts.rendering = createRendering({ flightState, services, parts }); + parts.icons = createIcons({ flightState, services, parts }); const queries = createQueries({ flightState, services, parts }).methods; - return { flightState, info, bb, parts, queries, formats: () => formats }; + const row = () => icons.row('abc123'); + /** Hold the contact's icon at `position`. */ + const place = (position) => { + writeHoldSegment(icons.segments, row(), position, 0); + icons.segmentWritten(row()); + }; + icons.add('abc123', { id: 'abc123' }); + place(new Cesium.Cartesian3(1, 2, 3)); + icons.setShown(row(), true); + return { + flightState, + info, + parts, + queries, + formats: () => formats, + place, + show: (shown) => icons.setShown(row(), shown), + drawn: () => parts.icons._iconPosition('abc123'), + }; } test('candidate text follows in-place enrichment without freezing identity or positions', () => { @@ -57,9 +71,9 @@ test('candidate text follows in-place enrichment without freezing identity or po const [first] = f.queries.getDetectableObjects(); assert.equal(first.id, 'TEST123'); assert.equal(first.sourceId, 'abc123'); - setBillboard(f.bb, 'position', new Cesium.Cartesian3(4, 5, 6)); + f.place(new Cesium.Cartesian3(4, 5, 6)); assert.equal(f.queries.getDetectableObjects()[0], first); - assert.equal(first.position, billboardState(f.bb).position); + assert.deepEqual(first.position, f.drawn()); assert.deepEqual(first.position, new Cesium.Cartesian3(4, 5, 6)); assert.equal(f.formats(), 1); f.info.callsign = ' NEW456 '; @@ -107,7 +121,7 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => scale: 1, }); f.flightState._models.set('abc123', model); - setBillboard(f.bb, 'show', false); + f.show(false); assert.deepEqual(f.queries.getDetectableObjects(), []); model.ready = true; assert.equal(f.queries.getDetectableObjects()[0], candidate); @@ -121,11 +135,8 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60)); setModel(model, 'show', false); assert.deepEqual(f.queries.getDetectableObjects(), []); - setBillboard(f.bb, 'show', true); - assert.equal( - f.queries.getDetectableObjects()[0].position, - billboardState(f.bb).position, - ); + f.show(true); + assert.deepEqual(f.queries.getDetectableObjects()[0].position, f.drawn()); assert.equal(get.mock.callCount(), 6); setModel(model, 'show', true); assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index c4c2a923e..67eae40e4 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,22 +1,14 @@ import * as Cesium from 'cesium'; -import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; -import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; -import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; -import { - cameraPoseSignature, - horizonOccluder, - screenProjectedRotation, -} from '../../data/iconOrientation.js'; -import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; +import { horizonOccluder } from '../../data/iconOrientation.js'; +import { aircraftTreatmentAlpha } from '../../data/aircraftRecession.js'; import { MIL_TINT, EMERGENCY_TINT, GROUND_SCALE, - COCKPIT_CONTACT_SIZE_PX, COCKPIT_CIVILIAN_COLOR, MODEL_ALT_CEIL_M, MODEL_MAX_ALL, @@ -32,14 +24,9 @@ import { TRACKED_MODEL_MIN_PX, TRACKED_MODEL_MAX_PX, FLEET_DR_INTERVAL_MS, - RENDER_DELAY_SEC, - COURSE_SLEW_DT_MAX_SEC, - ROTATION_REFRESH_MS, - COURSE_MAX_DPS, } from './policy.js'; import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; import { aircraftVisuals } from '../aircraft/visualState.js'; -import { billboardState, setBillboard } from '../../data/billboardMirror.js'; import { modelState, setModel } from '../../data/modelMirror.js'; export function createRendering({ @@ -52,14 +39,8 @@ export function createRendering({ const { isMilitaryIcao } = services.militaryRegistry; const { tr3bIconKind, isTr3b } = services.aircraftPresentation; const { trackedModelScaleForPixelCap } = services.camera; - const { - focusNowMs, - getFocusTarget, - nearFarScalarValueAtDistance, - advanceProjectedSpriteFocus, - } = services.focus; - const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = - services.recession; + const { focusNowMs, getFocusTarget } = services.focus; + const { applyAircraftModelTreatment } = services.recession; /** True while this contact's latest poll broadcast an emergency code. */ @@ -98,22 +79,6 @@ export function createRendering({ ); } - /** Depth-test policy for aircraft billboards. Round 5 (owner directive - * 2026-07-06: "I just want the planes and their lines to ALWAYS be - * visible... evenly applied"): EVERY contact renders depth-test-free at - * every distance — grounded, low, and airborne alike. The photoreal mesh - * writes depth and residual baro/floor error will always leave some sprite - * geometry at or below it; a uniform rule beats the grounded-only / - * low-AGL-only conditions that kept leaving classes of contacts buried - * (2026-07-03 Van Nuys grounded case; 2026-07-06 Austin QNH-below-field - * case). Far-side planes are still removed by the fleet tick's horizon - * occluder, which never depended on depth. Kept as a function so the - * callers' restyle sites stay diff-stable. */ - - function _groundDepthDistance() { - return Number.POSITIVE_INFINITY; - } - function _modelSpec(klass) { const cached = flightState._specCache.get(klass); if (cached) return cached; @@ -141,53 +106,6 @@ export function createRendering({ const _iconKind = (icao24, klass) => tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); - /** Apply the current normal/cockpit visual contract to one owned fleet billboard. */ - - function _applyFleetBillboardPresentation(icao24, bb) { - if (!bb) return; - const limbScale = flightState._billboardLimbScale.get(bb) ?? 1; - const isCockpitContact = - flightState._cockpitContactMode && icao24 !== flightState._trackedIcao; - const isCockpitNear = - isCockpitContact && flightState._cockpitNearContacts.has(icao24); - if (isCockpitContact && !isCockpitNear) { - const freshnessAlpha = cyberSonarBaseAlpha(bb); - bb.image = cockpitContactDotImage(); - setBillboard(bb, 'width', COCKPIT_CONTACT_SIZE_PX); - setBillboard(bb, 'height', COCKPIT_CONTACT_SIZE_PX); - setBillboard(bb, 'scale', limbScale); - setBillboard(bb, 'scaleByDistance', _cockpitBillboardScaleByDistance()); - bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha); - setBillboard(bb, 'rotation', 0); - return; - } - - const meta = flightState.records.data.get(icao24); - bb.image = aircraftIcon( - _iconKind(icao24, meta?.klass), - aircraftVisuals.read(bb)?.iconLarge ? TRACKED_ICON_PX : undefined, - ); - setBillboard(bb, 'width', icao24 === flightState._trackedIcao ? 24 : 20); - setBillboard(bb, 'height', icao24 === flightState._trackedIcao ? 24 : 20); - setBillboard( - bb, - 'scale', - _fleetBillboardScale(icao24, meta?.klass) * limbScale, - ); - setBillboard(bb, 'scaleByDistance', _normalBillboardScaleByDistance()); - bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb)); - } - - /** - * Per-preRender fleet pass at ~12Hz: dead-reckons every untracked billboard, - * horizon-culls billboards beyond the limb (no far-side depth with the globe - * hidden), and refreshes screen-projected icon rotations whenever the camera - * pose changed (plus a 1s drift catch-up while idle). Driven by - * scene.preRender — NOT camera.changed, whose granularity is globally - * degraded by other layers mutating camera.percentageChanged. - * @returns {void} - */ - /** Model tint, mirroring the billboard color rules. */ function _modelColor(icao24) { @@ -272,12 +190,12 @@ export function createRendering({ /** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the * vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked * neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion - * matches picked-object IDs, and every billboard AND model in this layer carries its + * matches picked-object IDs, and every icon AND model in this layer carries its * icao as `id`, so the icao strings cover both; the tracked entity is excluded as the * object itself. Built lazily — only when a sample actually fires (one-shot). */ function _groundSampleExclusions() { - const out = [...flightState._billboards.keys()]; + const out = parts.icons._iconIds(); if (flightState._trackedEntity) out.push(flightState._trackedEntity); return out; } @@ -323,15 +241,23 @@ export function createRendering({ ); } - /** Atomically hand one fleet contact from its billboard to a safely placed, + /** Atomically hand one fleet contact from its icon to a safely placed, * ready 3D model. Missing/loading models and unresolved terrain always leave - * the billboard owning the visual, so no render frame can hide both. + * the icon owning the visual, so no render frame can hide both. * `beforeShow` runs only on the committing path — the per-tick model treatment * belongs to a model that is about to draw, not to one still waiting. */ - function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { + function _driveFleetModelHandoff( + icao24, + model, + row, + pos, + course, + beforeShow, + ) { + const icons = flightState._icons; if (!model) { - setBillboard(bb, 'show', true); + icons.setShown(row, true); return false; } const displayPos = _modelDisplayPosition( @@ -341,7 +267,7 @@ export function createRendering({ ); if (!displayPos) { setModel(model, 'show', false); // no ground evidence → nothing safe to place a depth-tested model at - setBillboard(bb, 'show', true); + icons.setShown(row, true); return false; } // The matrix is written BEFORE the readiness test on purpose: a model can flip @@ -350,12 +276,12 @@ export function createRendering({ setModel(model, 'modelMatrix', _modelMatrix(displayPos, course)); if (!model.ready) { setModel(model, 'show', false); // not loaded yet → keep the 2D icon, no half-model flash - setBillboard(bb, 'show', true); + icons.setShown(row, true); return false; } beforeShow?.(); if (!modelState(model).show) setModel(model, 'show', true); - if (billboardState(bb).show) setBillboard(bb, 'show', false); // hand off ONLY once the model renders + icons.setShown(row, false); // hand off ONLY once the model renders return true; } @@ -443,9 +369,6 @@ export function createRendering({ (current && aircraftVisuals.read(current)?.specKey !== key) || (!current && flightState._modelPending.has(icao24)) ) { - const bb = flightState._billboards.get(icao24); - if (bb && icao24 !== flightState._trackedIcao) - setBillboard(bb, 'show', true); _releaseModel(icao24); } if ( @@ -479,9 +402,6 @@ export function createRendering({ aircraftVisuals.read(model)?.irBoost === flightState._irBoost ) continue; // already right state - const bb = flightState._billboards.get(icao); - if (bb && icao !== flightState._trackedIcao) - setBillboard(bb, 'show', true); _releaseModel(icao); } if (flightState._irReloadQueue.length === 0) @@ -624,6 +544,7 @@ export function createRendering({ } } flightState._models.delete(icao24); + _showReleasedIcon(icao24); } // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so @@ -664,9 +585,20 @@ export function createRendering({ } } } + for (const icao24 of flightState._models.keys()) _showReleasedIcon(icao24); flightState._models.clear(); } + /** A released fleet model hands the visual straight back to its icon (the + * tracked contact's entity keeps its own). */ + + function _showReleasedIcon(icao24) { + const icons = flightState._icons; + const row = icons?.row(icao24) ?? -1; + if (row >= 0 && icao24 !== flightState._trackedIcao) + icons.setShown(row, true); + } + /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ function _releaseTrackedModel() { @@ -709,8 +641,7 @@ export function createRendering({ // readout consumers then reuse that exact cached position. const pos = parts.motion._trackedDisplayPosition(flightState._trackedIcao) || - billboardState(flightState._billboards.get(flightState._trackedIcao)) - ?.position; + parts.icons._iconPosition(flightState._trackedIcao); if (!pos) { if (flightState._trackedModel) setModel(flightState._trackedModel, 'show', false); @@ -789,9 +720,7 @@ export function createRendering({ // default here produces a one-frame jump to the Earth's center. const currentPos = parts.motion._trackedDisplayCached() || - billboardState( - flightState._billboards.get(flightState._trackedIcao), - )?.position; + parts.icons._iconPosition(flightState._trackedIcao); if (currentPos) { const displayPos = _modelDisplayPosition( flightState._trackedIcao, @@ -859,54 +788,40 @@ export function createRendering({ } } + /** + * ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and + * the two-tier glyph swap all run on the GPU from each row's segment, so this + * only re-plans rows whose regime ended, pins grounded contacts to the floor + * under them, advances focus de-emphasis while a target exists, and drives + * the 3D-model handoff inside the model regime. Driven by scene.preRender. + * @returns {void} + */ + function _fleetTick() { - if ( - !flightState._viewer || - !flightState._billboardCollection || - !flightState._billboardCollection.show - ) - return; + const icons = flightState._icons; + if (!flightState._viewer || !icons || !icons.show) return; const scene = flightState._viewer.scene; const camera = flightState._viewer.camera; const nowMs = focusNowMs(Date.now()); - // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz - // primitive rebuild needed here anymore.) - if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return; - const tickDtSec = flightState._lastFleetTickMs - ? Math.min( - COURSE_SLEW_DT_MAX_SEC, - (nowMs - flightState._lastFleetTickMs) / 1000, - ) - : 0.08; flightState._lastFleetTickMs = nowMs; // Every contact in this pass shares the same delayed display instant. - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchFleetRenderTime), - -RENDER_DELAY_SEC, + const renderTime = parts.icons._displayTime( flightState._scratchFleetRenderTime, ); + const tau = icons.seconds(renderTime); _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload if (flightState._cockpitContactMode) parts.tracking._refreshCockpitNearContacts(); - const poseSig = cameraPoseSignature(camera); - // Only the nearby Cockpit silhouettes need projected course; far dots are - // rotation-free. The per-contact gate below keeps the pip path cheap. - const doRotations = - poseSig !== flightState._lastCamPoseSig || - nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS; - if (doRotations) { - flightState._lastCamPoseSig = poseSig; - flightState._lastRotPassMs = nowMs; - } - - const occluder = horizonOccluder(camera); + parts.icons._replanExpired(tau); + parts.icons._floorGroundedIcons(renderTime, tau, nowMs, camera.positionWC); const focusTarget = getFocusTarget(); + parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget); // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. - // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. + // Drop all models the moment we leave it (toggled off / zoomed out) so icons resume. const useModels = _modelRegimeActive(); // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that // resolves after zoom-out could briefly add a model outside the 3D-model regime). @@ -915,6 +830,7 @@ export function createRendering({ (flightState._models.size || flightState._modelPending.size) ) _releaseModels(); + if (!useModels) return; // 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the // cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen @@ -925,273 +841,124 @@ export function createRendering({ // on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap // with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum // filtering dropped near off-screen planes entirely. This does both right. - let modelEligible = null; - if (useModels) { - const cap = _modelCap(); - const camPos = camera.positionWC; - const addM = _modelAddDistM(); - const addDistSq = addM * addM; - const keepM = _modelKeepDistM(); - const keepDistSq = keepM * keepM; - const cull = camera.frustum.computeCullingVolume( - camPos, - camera.directionWC, - camera.upWC, + const cap = _modelCap(); + const camPos = camera.positionWC; + const addM = _modelAddDistM(); + const addDistSq = addM * addM; + const keepM = _modelKeepDistM(); + const keepDistSq = keepM * keepM; + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. + const cand = []; + for (let row = 0; row < icons.count; row++) { + const icao = icons.idAt(row); + if (icao === flightState._trackedIcao) continue; + // A converted TR-3B renders as an icon and can never take a model, so + // it must not occupy a CAP SLOT either — excluded here at selection time, + // not just at the handoff below, or accumulated conversions would starve + // ordinary contacts of 3D models. (The handoff guard stays as defence.) + if (isTr3b(icao)) continue; + // Ground planes compete for model slots like everyone else (owner decision + // 2026-07-03: "3D mode is respected regardless of whether a plane is on the + // ground or in the air — no distinction"). The cap + nearest-first ordering + // below already bound airport clusters; grounded placement is handled by the + // one-shot ground snap in _modelDisplayPosition. + const position = icons.position( + row, + tau, + flightState._scratchModelBS.center, ); - // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. - const cand = []; - for (const [icao, bb] of flightState._billboards) { - if (icao === flightState._trackedIcao) continue; - // A converted TR-3B renders as a billboard and can never take a model, so - // it must not occupy a CAP SLOT either — excluded here at selection time, - // not just at the handoff below, or accumulated conversions would starve - // ordinary contacts of 3D models. (The handoff guard stays as defence.) - if (isTr3b(icao)) continue; - // Ground planes compete for model slots like everyone else (owner decision - // 2026-07-03: "3D mode is respected regardless of whether a plane is on the - // ground or in the air — no distinction"). The cap + nearest-first ordering - // below already bound airport clusters; grounded placement is handled by the - // one-shot ground snap in _modelDisplayPosition. - const { position } = billboardState(bb); - const d2 = Cesium.Cartesian3.distanceSquared(camPos, position); - if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible - Cesium.Cartesian3.clone(position, flightState._scratchModelBS.center); - cand.push([ - icao, - d2, - cull.computeVisibility(flightState._scratchModelBS) !== - Cesium.Intersect.OUTSIDE, - ]); - } - cand.sort((a, b) => a[1] - b[1]); // nearest first - if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { - console.warn( - `[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`, - ); - flightState._lastModelCapWarnMs = nowMs; - } - modelEligible = selectModelEligible(cand, { - cap, - addDistSq, - isModeled: (icao) => flightState._models.has(icao), - }); - const toRelease = []; - for (const icao of flightState._models.keys()) { - if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) - toRelease.push(icao); - } - for (const icao of toRelease) _releaseModel(icao); + const d2 = Cesium.Cartesian3.distanceSquared(camPos, position); + if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible + cand.push([ + icao, + d2, + cull.computeVisibility(flightState._scratchModelBS) !== + Cesium.Intersect.OUTSIDE, + ]); } - - for (const [icao24, bb] of flightState._billboards) { - if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion - const icon = billboardState(bb); - - const info = flightState.records.data.get(icao24); - - const dr = parts.motion._deadReckon( - icao24, - flightState._scratchFleetPos, - renderTime, + cand.sort((a, b) => a[1] - b[1]); // nearest first + if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { + console.warn( + `[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`, ); - // The dead-reckoned point drifts away from the fix whose cell supplied the - // height, so a grounded contact's sprite ends up under the mesh it taxied - // (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and - // never while a 3D model owns the visual (T7). - const display = parts.motion._floorGroundedDisplayPosition( - icao24, - info, - dr, - _modelOwnsVisual(icao24), - nowMs, - ); - // Gate the write — assigning Billboard.position dirties the whole - // collection's vertex buffer, so skip sub-meter moves. - if ( - display && - Cesium.Cartesian3.distanceSquared(display, icon.position) > 1.0 - ) { - setBillboard(bb, 'position', display); - } + flightState._lastModelCapWarnMs = nowMs; + } + const modelEligible = selectModelEligible(cand, { + cap, + addDistSq, + isModeled: (icao) => flightState._models.has(icao), + }); + const toRelease = []; + for (const icao of flightState._models.keys()) { + if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) + toRelease.push(icao); + } + for (const icao of toRelease) _releaseModel(icao); - // Round 6: occlusion-test a LIFTED point for contacts rendering below - // (or within a wingspan of) the ellipsoid — EllipsoidalOccluder judges a - // sub-ellipsoid point near the limb "beyond the horizon" and the fleet - // pass would hide a plane that is really just low over high-N terrain - // waiting for its floor to warm (ATL grounded contacts at geoid −31 m). - const beyondHorizon = !occluder.isPointVisible( - flightState._cullPositions.get(icao24) || icon.position, - ); - // A billboard flipping INTO view (horizon reveal while the camera idles) - // gets its rotation refreshed THIS tick even without a pose change — - // otherwise it reappears wearing its stale (often creation-north) nose for - // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false - // here; harmless — the model branch below `continue`s past the rotation.) - const revealed = !beyondHorizon && !icon.show; - if (icon.show === beyondHorizon) setBillboard(bb, 'show', !beyondHorizon); - if (beyondHorizon) { - // Also hide any 3D model — otherwise a model that crossed the limb would keep - // rendering through the hidden globe at its last matrix. + // 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown + // until the model is actually READY to render, so a plane is never both iconless AND + // modelless (the "planes vanish when 3D turns on" bug). Position the model every tick + // regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on + // purpose: the Easter egg IS the triangle, and there is no GLB for it. + const occluder = horizonOccluder(camera); + for (const icao24 of modelEligible) { + if (isTr3b(icao24)) continue; + const row = icons.row(icao24); + if (row < 0) continue; + const position = icons.position(row, tau, flightState._scratchFleetPos); + if (!parts.icons._aboveHorizon(row, position, occluder)) { + // A model that crossed the limb would keep rendering through the + // hidden globe at its last matrix; the icon's own cull is on the GPU. const m = flightState._models.get(icao24); if (m && modelState(m)?.show) setModel(m, 'show', false); continue; } - - // One order-independent write site composes freshness × focus × limb haze - // for alpha, and base class/ground scale × limb taper for scale. Cesium's - // locked NearFarScalar remains a separate multiplicative stage. This - // narrowly amends always-visible rendering without count culling or zero - // alpha; nearer focus behavior remains tunable rather than universal. - const cameraDistanceM = Cesium.Cartesian3.distance( - camera.positionWC, - icon.position, + _ensureModel(icao24); + const model = flightState._models.get(icao24); + const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position); + _driveFleetModelHandoff( + icao24, + model, + row, + position, + icons.course(row, tau), + () => { + applyAircraftModelTreatment({ + model, + // IR boost must survive the per-tick treatment write — otherwise + // any alpha change would repaint the ordinary tint over the hot + // white. Boosted models also skip the recession fade: hot targets + // stay full-strength at any range (icons keep their normal + // fade — full-opacity glyph walls read as overwhelming). + baseColor: flightState._irBoost + ? Cesium.Color.WHITE + : _modelColor(icao24), + alpha: flightState._irBoost + ? 1 + : aircraftTreatmentAlpha({ + baseAlpha: icons.alpha(row), + focusFactor: icons.focus(row), + cameraDistanceM, + cameraHeightM: camera.positionCartographic?.height, + }), + }); + }, ); - const distanceScale = nearFarScalarValueAtDistance( - icon.scaleByDistance, - cameraDistanceM, - ); - const focus = advanceProjectedSpriteFocus( - bb, - icon.position, - scene, - camera, - nowMs, - focusTarget, - undefined, - (icon.width || 20) * (icon.scale || 1) * distanceScale * 0.5, - (icon.height || 20) * (icon.scale || 1) * distanceScale * 0.5, - ); - const isCockpitNear = - flightState._cockpitContactMode && - flightState._cockpitNearContacts.has(icao24); - const layerBaseColor = - flightState._cockpitContactMode && !isCockpitNear - ? _cockpitContactColor(icao24, info) - : _fleetBillboardColor(icao24, info); - const baseColor = layerBaseColor; - const treatment = applyAircraftBillboardTreatment({ - billboard: bb, - baseScale: - flightState._cockpitContactMode && !isCockpitNear - ? 1 - : _fleetBillboardScale(icao24, info?.klass), - baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1, - baseColor, - focusFactor: focus.factor, - cameraDistanceM, - cameraHeightM: camera.positionCartographic?.height, - }); - flightState._billboardLimbScale.set(bb, treatment.factors.scale); - // Two-tier glyph raster (owner playtest 2026-08-16): the billboard atlas - // has no mipmaps, so no single texture stays crisp across the ~25–150 - // device-px range scaleByDistance produces. Swap between the 64 px fleet - // raster and the 192 px close raster on the billboard's ACTUAL on-screen - // size — post-treatment bb.scale, so focus/limb recession counts — with - // hysteresis so zoom oscillation never thrashes the atlas. - if (!flightState._cockpitContactMode || isCockpitNear) { - const glyphDevPx = - (icon.width || 20) * - (icon.scale || 1) * - distanceScale * - (globalThis.devicePixelRatio || 1); - const visual = aircraftVisuals.write(bb); - const wantLarge = visual.iconLarge ? glyphDevPx > 56 : glyphDevPx > 76; - if (wantLarge !== !!visual.iconLarge) { - visual.iconLarge = wantLarge; - bb.image = aircraftIcon( - _iconKind(icao24, info?.klass), - wantLarge ? TRACKED_ICON_PX : undefined, - ); - } - } - - // Smoothed display course: the path direction _deadReckon just reported - // for THIS aircraft (nothing else calls _deadReckon in between), rate- - // limited so segment-boundary course steps glide instead of snapping. - // The slew cap eases toward COURSE_MIN_DPS at low speed, and a hovering - // aircraft (hold flag) keeps its previous nose direction outright. - const rawCourse = - flightState._drCourseDeg != null - ? flightState._drCourseDeg - : (info && info.true_track) || 0; - const prevCourse = flightState._displayCourse.get(icao24); - const course = - flightState._drCourseHold && prevCourse != null - ? prevCourse - : limitCourseStep( - prevCourse, - rawCourse, - courseSlewCapDps( - flightState._drSpeedMps != null - ? flightState._drSpeedMps - : ((info && info.velocity) ?? NaN), - COURSE_MAX_DPS, - ), - tickDtSec, - ); - flightState._displayCourse.set(icao24, course); - - // 3D model takes over from the billboard for in-view planes (modelEligible). GAP-PROOF: the - // billboard stays shown until the model is actually READY to render, so a plane is never both - // iconless AND modelless (the "planes vanish when 3D turns on" bug). Position the model every - // tick regardless so it's framed the instant it becomes ready. - // Converted TR-3Bs stay 2D on purpose: the Easter egg IS the triangle, and - // there is no GLB for it, so the model handoff is suppressed rather than - // fed a stand-in mesh. The billboard keeps rendering (and keeps satisfying - // the getNearby/getDetectableObjects `bb.show` visibility guards), so a - // converted contact still works in Contacts and Cockpit. - if (useModels && dr && modelEligible.has(icao24) && !isTr3b(icao24)) { - _ensureModel(icao24); - const model = flightState._models.get(icao24); - const ownsVisual = _driveFleetModelHandoff( - icao24, - model, - bb, - dr, - course, - () => { - applyAircraftModelTreatment({ - model, - // IR boost must survive the per-tick treatment write — otherwise - // any alpha change would repaint the ordinary tint over the hot - // white. Boosted models also skip the recession fade: hot targets - // stay full-strength at any range (billboards keep their normal - // fade — full-opacity glyph walls read as overwhelming). - baseColor: flightState._irBoost - ? Cesium.Color.WHITE - : _modelColor(icao24), - alpha: flightState._irBoost ? 1 : treatment.alpha, - }); - }, - ); - if (ownsVisual) continue; // skip billboard rotation - } - - if ( - (!flightState._cockpitContactMode || isCockpitNear) && - (doRotations || revealed) - ) { - const rot = screenProjectedRotation( - scene, - icon.position, - course, - icon.rotation, - ); - if (rot !== null && Math.abs(rot - icon.rotation) > 0.002) { - setBillboard(bb, 'rotation', rot); - } - } } } return { _fleetBillboardColor, _fleetBillboardScale, - _groundDepthDistance, + _cockpitContactColor, _modelSpec, _normalBillboardScaleByDistance, _cockpitBillboardScaleByDistance, _iconKind, - _applyFleetBillboardPresentation, _modelColor, _modelRegimeActive, _modelCap, diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 2fa013a48..ddc8c864a 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -1,13 +1,10 @@ import * as Cesium from 'cesium'; -import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { turnRateFromFixHistory, liftRepeatedGroundFix, synthesizeForwardKinematicsFix, } from '../../data/motionModel.js'; -import { aircraftIcon } from '../../data/aircraftIcons.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; -import { addBillboard } from '../../data/billboardMirror.js'; /** Apply reconciled aircraft records to Cesium resources and follow state. */ export function createFlightSnapshotRenderer({ @@ -19,6 +16,7 @@ export function createFlightSnapshotRenderer({ rendering, tracking, motion, + icons, enrichment, queries, }) { @@ -53,17 +51,8 @@ export function createFlightSnapshotRenderer({ // which hands off on untrack). const isMil = isMilitaryIcao(icao24); if (isMil && tracking._militaryLayerSuppresses(icao24)) { - const dupe = flightState._billboards.get(icao24); - if (dupe) { - flightState._billboardCollection.remove(dupe); - flightState._billboards.delete(icao24); - rendering._releaseModel(icao24); // military-suppression: drop any 3D model too - records.forget(icao24); - flightState._cullPositions.delete(icao24); - flightState._positionHistory.delete(icao24); - flightState._displayCourse.delete(icao24); - flightState._groundSnap.forget(icao24); - } + // Military-suppression: drop the duplicate with any 3D model too. + if (flightState._icons.has(icao24)) icons._forgetContact(icao24); continue; } @@ -84,12 +73,12 @@ export function createFlightSnapshotRenderer({ ); // Either flip retires the previous one-shot ground sample. if (groundFlipped) flightState._groundSnap.forget(icao24); - flightState._cullPositions.set( - icao24, - meta.renderAltitudeM < 10 - ? Cesium.Cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12) - : null, - ); + if (meta.onGround) { + flightState._grounded.add(icao24); + } else if (flightState._grounded.delete(icao24)) { + flightState._floorPinned.delete(icao24); + motion._retireDisplayFloorState(icao24, Date.now()); + } const isTracked = icao24 === flightState._trackedIcao; // Append to position history stamped with the FEED's fix epoch @@ -153,72 +142,18 @@ export function createFlightSnapshotRenderer({ } } - if (flightState._billboards.has(icao24)) { - const bb = flightState._billboards.get(icao24); - // Reclassify if the category resolved/changed (first extended poll); - // a ground flip (landing/takeoff) re-scales the SAME billboard in - // place — the transition is a restyle, never a removal. - // Round 5: depth policy is uniform (always depth-test-free, see - // _groundDepthDistance) — nothing to flip on landing/takeoff. - // Position AND rotation are owned by the fleet pass (_fleetTick); - // course changes land on the next rotation pass (forced below). - if (!isTracked) { - // Refresh affiliation hue without clobbering the tick-owned - // freshness × focus × horizon alpha composition. - bb.color = rendering - ._fleetBillboardColor(icao24) - .withAlpha(cyberSonarBaseAlpha(bb)); - } - if ( - prevMeta?.klass !== meta.klass || - groundFlipped || - flightState._cockpitContactMode - ) { - rendering._applyFleetBillboardPresentation(icao24, bb); - } + if (flightState._icons.has(icao24)) { + // Reclassification, a ground flip (a restyle, never a removal), a + // freshness change and the cockpit contract all land on the row's + // style; motion re-plans from the updated history. + icons._applyIconPresentation(icao24); + icons._planIcon(icao24); // 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); } 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 - // projected back to that delayed time, and a grounded contact is then - // lifted onto the floor under that delayed position. Creating it at - // the raw fix drew it ahead until the next fleet tick snapped it back; - // skipping the floor started a taxiing contact low and then raised it. - const delayed = motion._deadReckon(icao24, new Cesium.Cartesian3()); - const displayed = - (delayed && - motion._floorGroundedDisplayPosition( - icao24, - flightState.records.data.get(icao24) || meta, - delayed, - rendering._modelOwnsVisual(icao24), - )) || - position; - const bb = addBillboard(flightState._billboardCollection, { - position: displayed, - image: aircraftIcon(rendering._iconKind(icao24, meta.klass)), - width: isTracked ? 24 : 20, - height: isTracked ? 24 : 20, - scale: rendering._fleetBillboardScale(icao24, meta.klass), - // Screen-projected rotation lands on the next fleet tick. - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked - ? Cesium.Color.CYAN - : rendering._fleetBillboardColor(icao24), - sizeInMeters: false, - scaleByDistance: rendering._normalBillboardScaleByDistance(), - // Grounded/near-surface planes sit at/below the photoreal tile - // surface — render them depth-test-free so they never vanish up - // close (_groundDepthDistance). - disableDepthTestDistance: rendering._groundDepthDistance(), - id: icao24, - show: !isTracked, // hidden if currently tracked (entity replaces it) - }); - flightState._billboards.set(icao24, bb); - rendering._applyFleetBillboardPresentation(icao24, bb); + // Hidden while tracked: the tracked entity replaces it. + icons._addIcon(icao24); } // Takeoff while TRACKED: ground traffic drew no trail, so start one @@ -240,8 +175,12 @@ export function createFlightSnapshotRenderer({ // EXCEPTION: likely-landed planes (last fix low + slow) get only // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a // feed gap, and the full grace left phantom planes parked at airports. - for (const [icao24, bb] of flightState._billboards) { - if (currentIcaos.has(icao24)) continue; + const absentIds = []; + for (let row = 0; row < flightState._icons.count; row++) { + const icao24 = flightState._icons.idAt(row); + if (!currentIcaos.has(icao24)) absentIds.push(icao24); + } + for (const icao24 of absentIds) { const absence = records.absence(icao24, { complete: snapshot.complete, retainIds: snapshot.retainIds, @@ -249,8 +188,9 @@ export function createFlightSnapshotRenderer({ }); if (absence === 'retain') continue; if (absence === 'stale') { + icons._applyIconPresentation(icao24); // the faded freshness alpha if (icao24 === flightState._trackedIcao && flightState._trackedEntity) { - // Honest readout: the tracked plane has no faded billboard (its + // Honest readout: the tracked plane has no faded icon (its // entity owns the visual), so refresh the label — with icao24 now // in _missingPolls, _trackedLabelText appends the STALE cue so // last-known velocity/altitude aren't presented as live. @@ -269,20 +209,9 @@ export function createFlightSnapshotRenderer({ tracking._clearTracking(false, { evicted: true }); } - flightState._billboardCollection.remove(bb); - flightState._billboards.delete(icao24); - rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) - records.forget(icao24); - flightState._cullPositions.delete(icao24); - flightState._positionHistory.delete(icao24); - flightState._displayCourse.delete(icao24); - flightState._groundSnap.forget(icao24); - flightState._displayFloorState.delete(icao24); + icons._forgetContact(icao24); // aged out: its 3D model goes too (no orphan / cap leak) } - // Fresh courses arrived — force a rotation pass on the next fleet tick - flightState._lastCamPoseSig = ''; - // Ambient type enrichment: give ON-SCREEN planes real types (bounded // sweep — see _sweepAmbientEnrichment; internally fail-silent). enrichment._sweepAmbientEnrichment(); @@ -307,7 +236,7 @@ export function createFlightSnapshotRenderer({ // aircraft instead of the pavement. sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { excludeObjects: [ - ...flightState._billboards.values(), + ...icons._iconIds(), ...flightState._models.values(), flightState._trackedModel, ].filter(Boolean), @@ -327,6 +256,6 @@ export function createFlightSnapshotRenderer({ // in place is safe (the DR extrapolates a zero-velocity fix). motion._refloorStaleGroundedContacts(currentIcaos); - return { count: flightState._billboards.size, ids: acceptedSnapshotIcaos }; + return { count: flightState._icons.count, ids: acceptedSnapshotIcaos }; }; } diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index b297d5a9d..b3ac6ab0d 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -11,7 +11,6 @@ export function createFlightState({ source, services }) { ...services.geoid, ...services.groundFloor, }); - flightState._cullPositions = new Map(); /** 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 @@ -91,22 +90,37 @@ export function createFlightState({ source, services }) { 1.0, ); // frustum-visibility test - /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ - - flightState._billboardLimbScale = new WeakMap(); // keep last N positions per aircraft // --------------------------------------------------------------------------- // Module-level state: billboard collection and per-aircraft lookup maps // --------------------------------------------------------------------------- - /** @type {Cesium.BillboardCollection|null} */ + /** Every contact's icon row (see icons.js); null outside a layer lifetime. */ - flightState._billboardCollection = null; + flightState._icons = null; - /** @type {Map} icao24 -> billboard primitive */ + /** The primitive drawing `_icons`. */ - flightState._billboards = new Map(); + flightState._iconPrimitive = null; + + flightState._scratchIconRenderTime = new Cesium.JulianDate(); + + /** Contacts whose latest poll put them on the ground. */ + + flightState._grounded = new Set(); + + /** Grounded contacts the floor currently lifts; their rows hold CPU positions. */ + + flightState._floorPinned = new Set(); + + /** Fleet ticks since start; far grounded rows take their floor turn by it. */ + + flightState._floorTick = 0; + + /** Rows still away from full focus emphasis after the last focus pass. */ + + flightState._iconFocusActive = 0; /** Stable lightweight records reused by the detection overlay between polls. */ @@ -204,14 +218,6 @@ export function createFlightState({ source, services }) { flightState._lastFleetTickMs = 0; - /** @type {string} Camera pose signature at the last rotation pass */ - - flightState._lastCamPoseSig = ''; - - /** @type {number} Epoch ms of the last full rotation pass */ - - flightState._lastRotPassMs = 0; - /** @type {number} Last computed rotation for the tracked entity (radians) */ flightState._lastTrackedRotation = 0; @@ -250,6 +256,10 @@ export function createFlightState({ source, services }) { flightState._scratchFleetPos = new Cesium.Cartesian3(); + flightState._scratchCockpitPos = new Cesium.Cartesian3(); + + flightState._scratchNearbyPos = new Cesium.Cartesian3(); + flightState._scratchSonarScreen = new Cesium.Cartesian2(); flightState._scratchDrRaw = new Cesium.Cartesian3(); diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index 41fd19533..b5df7a076 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -1,7 +1,8 @@ import * as Cesium from 'cesium'; import { CYAN_TRANSPARENT } from './policy.js'; -import { mirrorBillboard } from '../../data/billboardMirror.js'; import { mirrorModel } from '../../data/modelMirror.js'; +import { createIconSet } from '../../data/iconSet.js'; +import { writeHoldSegment } from '../../data/iconSegment.js'; export function createTesting({ flightState, @@ -38,6 +39,19 @@ export function createTesting({ flightState._displayFloorState.clear(); } + /** Give a test contact an icon row held at `billboard.position`, shown as + * `billboard.show` says (hidden by default while tracked). */ + + function _seedIconForTest(icao24, billboard, tracked) { + const icons = flightState._icons; + const row = icons.add(icao24, { layer: 'flights', id: icao24 }); + if (billboard?.position) + writeHoldSegment(icons.segments, row, billboard.position, 0); + icons.segmentWritten(row); + icons.setShown(row, billboard?.show ?? !tracked); + return row; + } + /** * Seed only the mutable state needed to exercise tracked-card refreshes through * the production poll reconciler. Tests still call `flightsLayer.update()`; @@ -46,13 +60,14 @@ export function createTesting({ * @param {string} state.icao24 * @param {object} state.entity * @param {object} state.meta - * @param {object} state.billboard - * @param {object} state.billboardCollection + * @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.billboard] + * Where the contact's icon is held, and whether it is shown. + * @param {{show?: boolean}} [state.billboardCollection] Layer visibility. * @param {object} state.viewer * @param {Array} [state.history=[]] * @param {boolean} [state.tracked=true] * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed - * by icao24, for the billboard-hidden/model-shown handoff state. + * by icao24, for the icon-hidden/model-shown handoff state. */ function _setTrackedFlightRefreshStateForTest({ @@ -69,9 +84,13 @@ export function createTesting({ }) { flightState._viewer = viewer; flightState._modelCollection = modelCollection; - flightState._billboardCollection = billboardCollection; - flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]); - flightState._cullPositions.clear(); + flightState._icons = createIconSet({ + epoch: parts.icons._displayTime(new Cesium.JulianDate()), + }); + flightState._icons.show = billboardCollection?.show ?? true; + flightState._grounded.clear(); + flightState._floorPinned.clear(); + _seedIconForTest(icao24, billboard, tracked); flightState._models.clear(); for (const [key, model] of models) flightState._models.set(key, mirrorModel(model)); @@ -120,7 +139,7 @@ export function createTesting({ billboard, history = [], }) { - flightState._billboards.set(icao24, mirrorBillboard(billboard)); + _seedIconForTest(icao24, billboard, false); flightState.records.data.set(icao24, meta); flightState._positionHistory.set(icao24, history); } @@ -181,25 +200,64 @@ export function createTesting({ : Cesium.Color.CYAN; } - /** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ + /** Drive the exact fleet icon-to-model handoff used by `_fleetTick`. */ function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) { return parts.rendering._driveFleetModelHandoff( icao24, flightState._models.get(icao24), - flightState._billboards.get(icao24), + flightState._icons.row(icao24), position, course, ); } + /** Where a contact's icon is drawn now and whether the layer shows it. */ + + function _iconForTest(icao24) { + const position = parts.icons._iconPosition(icao24); + return position + ? { + position, + shown: parts.icons._iconShown(icao24), + course: parts.icons._iconCourse(icao24), + } + : null; + } + /** Exercise the exact asynchronous fleet loader and return its admitted model. */ async function _ensureFleetModelForTest(icao24) { await parts.rendering._ensureModel(icao24); return flightState._models.get(icao24) || null; } + /** Show or hide a contact's icon as a handoff would, or hold it somewhere. */ + + function _setIconForTest(icao24, { shown, position } = {}) { + const icons = flightState._icons; + const row = icons?.row(icao24) ?? -1; + if (row < 0) return; + if (shown !== undefined) icons.setShown(row, shown); + if (position) { + writeHoldSegment(icons.segments, row, position, 0); + icons.segmentWritten(row); + } + } + + /** Run the fleet tick's grounded floor pass once, at `renderTime`. */ + + function _floorGroundedIconsForTest(renderTime, cameraPosition) { + const icons = flightState._icons; + parts.icons._floorGroundedIcons( + renderTime, + icons.seconds(renderTime), + Date.now(), + cameraPosition, + ); + } return { + _floorGroundedIconsForTest, + _setIconForTest, _floorGroundedDisplayPositionForTest, _clearDisplayFloorStateForTest, _setTrackedFlightRefreshStateForTest, @@ -214,6 +272,7 @@ export function createTesting({ _updateTrackedModelForTest, _trackedBillboardColorForTest, _driveFleetModelHandoffForTest, + _iconForTest, _ensureFleetModelForTest, }; } diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index c29ff9b20..d110dd2f1 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -27,7 +27,6 @@ import { } from './policy.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; -import { billboardState, setBillboard } from '../../data/billboardMirror.js'; export function createTracking({ flightState, @@ -65,9 +64,9 @@ export function createTracking({ } function _publishTrackedSelection(icao24, origin = 'programmatic') { - const bb = flightState._billboards.get(icao24); + const position = parts.icons._iconPosition(icao24); const info = flightState.records.data.get(icao24); - if (!billboardState(bb)?.position || !info) return false; + if (!position || !info) return false; if (flightState._trackedEntity) entityRecord(flightState._trackedEntity).selectionOrigin = origin; _emitAwarenessEvent('gev:awareness-subject-selected', { @@ -78,7 +77,7 @@ export function createTracking({ // a callsign-less contact reached Context as its raw hex even once adsbdb // had supplied a registration. Identity below stays `icao24`. label: parts.queries._contactLabel(icao24, info), - position: Cesium.Cartesian3.clone(billboardState(bb).position), + position, origin, }); selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); @@ -163,9 +162,12 @@ export function createTracking({ } const previous = flightState._cockpitNearContacts; const distancesSquared = []; - for (const [icao24, bb] of flightState._billboards) { - const position = billboardState(bb)?.position; - if (icao24 === flightState._trackedIcao || !position) continue; + const icons = flightState._icons; + const tau = parts.icons._displayTau(); + for (let row = 0; row < icons.count; row++) { + const icao24 = icons.idAt(row); + if (icao24 === flightState._trackedIcao) continue; + const position = icons.position(row, tau, flightState._scratchCockpitPos); distancesSquared.push([ icao24, Cesium.Cartesian3.distanceSquared( @@ -181,13 +183,10 @@ export function createTracking({ parts.rendering._modelKeepDistM(), ); flightState._cockpitNearContacts = next; - let presentationChanged = false; - for (const [icao24, bb] of flightState._billboards) { - if (previous.has(icao24) === next.has(icao24)) continue; - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - presentationChanged = true; - } - if (presentationChanged) flightState._lastCamPoseSig = ''; + for (const icao24 of previous) + if (!next.has(icao24)) parts.icons._applyIconPresentation(icao24); + for (const icao24 of next) + if (!previous.has(icao24)) parts.icons._applyIconPresentation(icao24); } /** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ @@ -205,9 +204,9 @@ export function createTracking({ if (flightState._modelCollection) flightState._modelCollection.show = true; flightState._trail?.setVisible(!next); if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; - for (const [icao24, bb] of flightState._billboards) - parts.rendering._applyFleetBillboardPresentation(icao24, bb); - flightState._lastCamPoseSig = ''; + const icons = flightState._icons; + for (let row = 0; row < (icons?.count ?? 0); row++) + parts.icons._applyIconPresentation(icons.idAt(row)); flightState._lastFleetTickMs = 0; } @@ -319,8 +318,7 @@ export function createTracking({ if (!flightState._trackedIcao || !flightState._viewer) return; const pos = parts.motion._trackedDisplayCached() || - billboardState(flightState._billboards.get(flightState._trackedIcao)) - ?.position; + parts.icons._iconPosition(flightState._trackedIcao); if (!pos) return; const projected = screenProjectedRotation( flightState._viewer.scene, @@ -639,30 +637,9 @@ export function createTracking({ const clearedIcao = flightState._trackedIcao; clearFocusTarget('flights', clearedIcao); - // Restore the original billboard appearance. The rotation is re-seeded from - // the tracked entity's last rendered rotation and a rotation pass is forced - // (_lastCamPoseSig below): the fleet billboard otherwise reappears with the - // STALE screen rotation it had when tracking began — up to a full - // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. - if (flightState._billboards.has(flightState._trackedIcao)) { - const bb = flightState._billboards.get(flightState._trackedIcao); - setBillboard(bb, 'show', true); - setBillboard(bb, 'width', 20); - setBillboard(bb, 'height', 20); - // Ground/military-aware restore (a plane untracked while taxiing must come - // back muted-gray at ground scale, not white at full scale). - bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao); - setBillboard( - bb, - 'scale', - parts.rendering._fleetBillboardScale( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, - ), - ); - setBillboard(bb, 'rotation', flightState._lastTrackedRotation); - } - flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick + // The nose the icon continues from (the shared _displayCourse entry), so + // release never flips it. + const trackedCourseDeg = flightState._displayCourse.get(clearedIcao); // Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the // follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner @@ -679,10 +656,14 @@ export function createTracking({ parts.rendering._releaseTrackedModel(); _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped flightState._trackedIcao = null; - parts.rendering._applyFleetBillboardPresentation( - clearedIcao, - flightState._billboards.get(clearedIcao), - ); + // Hand the contact back to its icon only now: settling floors a grounded + // contact, and while it still read as tracked its tracked model owned the + // visual, so the floor would have stepped aside. + if (flightState._icons?.has(clearedIcao)) { + flightState._icons.setShown(flightState._icons.row(clearedIcao), true); + parts.icons._settleIcon(clearedIcao, { fromCourseDeg: trackedCourseDeg }); + } + parts.icons._applyIconPresentation(clearedIcao); clearTrackedSubjectContext('flights'); _emitAwarenessEvent('gev:awareness-subject-cleared', { layerId: 'flights', @@ -730,10 +711,7 @@ export function createTracking({ pending.generation !== flightState._trackingIntentGeneration ) return false; - if ( - !flightState._billboardCollection?.show || - !flightState._billboards.has(pending.id) - ) + if (!flightState._icons?.show || !flightState._icons.has(pending.id)) return false; flightState._pendingTrackingRestore = null; _trackFlight(pending.id, { origin: pending.origin }); @@ -837,42 +815,42 @@ export function createTracking({ // an already-loaded model would otherwise linger with the billboard hidden. if (flightState._models.has(id) || flightState._modelPending.has(id)) parts.rendering._releaseModel(id); - const bb = flightState._billboards.get(id); - if (bb && id !== flightState._trackedIcao) setBillboard(bb, 'show', true); // horizon cull re-asserts next tick + const row = flightState._icons?.row(id) ?? -1; + if (row >= 0 && id !== flightState._trackedIcao) + flightState._icons.setShown(row, true); if (id === flightState._trackedIcao) { parts.rendering._releaseTrackedModel(); _syncTracked2dRotation(); } } - const bb = flightState._billboards.get(id); - if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb); + parts.icons._applyIconPresentation(id); if (id === flightState._trackedIcao) { _syncTrackedBillboardImage(); _updateTrackedLabelModel(id); } flightState._viewer?.scene?.requestRender?.(); - return flightState._billboards.has(id) || id === flightState._trackedIcao; + return flightState._icons?.has(id) || id === flightState._trackedIcao; } /** Re-image every converted contact this layer owns (IR style flip). */ function _refreshTr3bForStyle() { for (const id of tr3bConvertedIds()) { - if (flightState._billboards.has(id) || id === flightState._trackedIcao) + if (flightState._icons?.has(id) || id === flightState._trackedIcao) _refreshTr3bContact(id); } } - /** Plausibility check anchored to the plane's billboard position (coarse is + /** Plausibility check anchored to the plane's icon position (coarse is * fine here — this gates a LABEL, and it must not touch the tracked frame * cache). Missing data → true (never hide what we can't judge). */ function _routeIsPlausible(icao24, route) { const info = flightState.records.data.get(icao24); - const bb = flightState._billboards.get(icao24); - if (!info || !bb || !billboardState(bb).position) return true; + const position = parts.icons._iconPosition(icao24); + if (!info || !position) return true; const carto = Cesium.Cartographic.fromCartesian( - billboardState(bb).position, + position, Cesium.Ellipsoid.WGS84, flightState._scratchCarto, ); @@ -907,29 +885,42 @@ export function createTracking({ function _trackFlight(icao24, { origin = 'programmatic' } = {}) { _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over - const bb = flightState._billboards.get(icao24); + const icons = flightState._icons; + const row = icons?.row(icao24) ?? -1; const info = flightState.records.data.get(icao24); - if (!bb || !info) return; + if (row < 0 || !info) return; + // The tracked limiter continues from the nose the icon was drawn with, and + // the entity starts with the rotation the icon had on screen. + const tau = parts.icons._displayTau(); + const iconPosition = icons.position(row, tau); + flightState._displayCourse.set(icao24, icons.course(row, tau)); flightState._trackedIcao = icao24; _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget flightState._cachedDRFrame = -1; - flightState._lastTrackedRotation = billboardState(bb).rotation || 0; + flightState._lastTrackedRotation = + screenProjectedRotation( + flightState._viewer.scene, + iconPosition, + icons.course(row, tau), + 0, + ) ?? 0; // Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover // fleet model would be orphaned + double-rendered. parts.rendering._releaseModel(icao24); - // Hide the billboard — the tracked entity replaces it visually - setBillboard(bb, 'show', false); + // Hide the icon — the tracked entity replaces it visually + icons.setShown(row, false); // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — // one computation shared by the position/alignedAxis/rotation/trail-head callbacks, - // with discontinuity reconciliation). Falls back to the last billboard position when + // with discontinuity reconciliation). Falls back to the icon's position when // the aircraft has no fix. const getTrackedPosition = () => parts.motion._trackedDisplayPosition(icao24) || - billboardState(bb).position; + parts.icons._iconPosition(icao24) || + iconPosition; // Dead-reckoning position property — smooth continuous motion between API updates. const positionProperty = new Cesium.CallbackProperty(() => { @@ -1106,23 +1097,19 @@ export function createTracking({ */ function _onMilitaryActiveChange(active) { - if (!flightState._viewer || !flightState._billboardCollection) return; + const icons = flightState._icons; + if (!flightState._viewer || !icons) return; if (active) { - for (const [icao24, bb] of flightState._billboards) { - if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao) - continue; - flightState._billboardCollection.remove(bb); - flightState._billboards.delete(icao24); - parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too - flightState.records.data.delete(icao24); - flightState._cullPositions.delete(icao24); - flightState._positionHistory.delete(icao24); - flightState._displayCourse.delete(icao24); - flightState._groundSnap.forget(icao24); - flightState.records.missingPolls.delete(icao24); + const suppressed = []; + for (let row = 0; row < icons.count; row++) { + const icao24 = icons.idAt(row); + if (isMilitaryIcao(icao24) && icao24 !== flightState._trackedIcao) + suppressed.push(icao24); } - flightState.feed._count = flightState._billboards.size; - } else if (flightState._billboardCollection.show) { + // Military-suppression: drop each duplicate with any 3D model too. + for (const icao24 of suppressed) parts.icons._forgetContact(icao24); + flightState.feed._count = icons.count; + } else if (icons.show) { // Fire-and-forget refresh; only while the layer is actually enabled. void flightsLayer.update(flightState._viewer); } @@ -1212,25 +1199,12 @@ export function createTracking({ return; } - // For BillboardCollection picks, the billboard may be at picked.primitive or picked.id - const billboard = picked.primitive; - if ( - billboard && - billboard.id && - flightState._billboards.has(billboard.id) - ) { + // Icon picks carry the icao on picked.id; models carry it on the primitive. + const pickedIcao = + typeof picked.id === 'string' ? picked.id : picked.primitive?.id; + if (pickedIcao && flightState._icons?.has(pickedIcao)) { _cancelPendingTrackingRestore(); - _trackFlight(billboard.id, { origin: 'user' }); - return; - } - // Some CesiumJS versions surface the id as a string on picked.id instead - if ( - picked.id && - typeof picked.id === 'string' && - flightState._billboards.has(picked.id) - ) { - _cancelPendingTrackingRestore(); - _trackFlight(picked.id, { origin: 'user' }); + _trackFlight(pickedIcao, { origin: 'user' }); return; } } diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 75746b6e3..605876f73 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -702,7 +702,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () assert.doesNotMatch(detection, /sonarActive \? 1 : 0/); assert.match( civilRendering, - /alpha: flightState\._irBoost \? 1 : treatment\.alpha/, + /alpha: flightState\._irBoost\s*\?\s*1\s*:\s*aircraftTreatmentAlpha\(/, ); assert.match( militaryRendering,