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.
This commit is contained in:
Sameh Khamis committed 2026-11-03 19:40:39 -08:00
1 parent 5f36eb0e0d
commit 2c4c66c710
33 files changed
+1598 -986

No files matched your search

+12
View File
@@ -1315,6 +1315,12 @@
"src/layers/flights/policy.js", "src/layers/flights/policy.js",
"src/layers/flights/queries.js", "src/layers/flights/queries.js",
"src/layers/flights/rendering.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/modelMirror.js",
"src/data/billboardMirror.js", "src/data/billboardMirror.js",
"src/layers/aircraft/visualState.js", "src/layers/aircraft/visualState.js",
@@ -2249,6 +2255,7 @@
"./standalone/catalog" "./standalone/catalog"
], ],
"modules": [ "modules": [
"src/layers/flights/icons.js",
"src/sources/adsbFields.js", "src/sources/adsbFields.js",
"src/data/headingDegrees.js", "src/data/headingDegrees.js",
"src/data/distanceFormat.js", "src/data/distanceFormat.js",
@@ -3248,6 +3255,11 @@
"application-layer-construction": { "application-layer-construction": {
"exports": ["./application/layers"], "exports": ["./application/layers"],
"modules": [ "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/sources/adsbFields.js",
"src/data/headingDegrees.js", "src/data/headingDegrees.js",
"src/data/distanceFormat.js", "src/data/distanceFormat.js",
+2
View File
@@ -252,6 +252,8 @@ const _b64 = (s) =>
* NEAR the display size lets the browser's SVG AA do the work: crisp AND * 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. */ * smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
const FLEET_RASTER_PX = 64; 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 /** 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 * (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
* it stays crisp at its biggest on-screen sizes. */ * it stays crisp at its biggest on-screen sizes. */
+34
View File
@@ -243,6 +243,40 @@ export function applyAircraftBillboardTreatment({
return _treatmentResult; 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<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [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 * Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics. * disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
+6 -6
View File
@@ -83,24 +83,24 @@ for (const layer of LAYERS) {
test(`${layer.name}: near AIR state is independent from model admission`, () => { test(`${layer.name}: near AIR state is independent from model admission`, () => {
assert.match(source, /nextCockpitNearContacts\(/, assert.match(source, /nextCockpitNearContacts\(/,
'Cockpit derives a separate near-contact hysteresis set'); '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'); 'only out-of-range Cockpit contacts become dots');
// `_iconKind` is identity for every unconverted contact (see // `_iconKind` is identity for every unconverted contact (see
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the // tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
// operator explicitly converted into a TR-3B. // 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'); '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'); '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'); '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'); 'the gap-proof billboard-to-model handoff remains intact');
}); });
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => { 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]; 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, /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\)\);/);
}); });
} }
+4
View File
@@ -40,5 +40,9 @@ export const _driveFleetModelHandoffForTest =
flightsLayer.testing._driveFleetModelHandoffForTest; flightsLayer.testing._driveFleetModelHandoffForTest;
export const _ensureFleetModelForTest = export const _ensureFleetModelForTest =
flightsLayer.testing._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 const mapAnalystRecord = flightsLayer.mapAnalystRecord;
export default flightsLayer; export default flightsLayer;
+101 -28
View File
@@ -14,6 +14,8 @@ import flightsLayer, {
_setTrackedFlightRefreshStateForTest, _setTrackedFlightRefreshStateForTest,
_floorGroundedDisplayPositionForTest, _floorGroundedDisplayPositionForTest,
_clearDisplayFloorStateForTest, _clearDisplayFloorStateForTest,
_iconForTest,
_floorGroundedIconsForTest,
mapAnalystRecord, mapAnalystRecord,
} from './flights.js'; } from './flights.js';
import { setMilitaryLayerActive } from './militaryRegistry.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 () => { 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 // 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. // 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. // tick, which then jumped it back about one delay of travel.
const added = []; const billboardCollection = { show: false };
const billboardCollection = {
show: false,
add(options) {
const billboard = { ...options };
added.push(billboard);
return billboard;
},
remove() {},
};
const viewer = { camera: { positionCartographic: null }, scene: {} }; const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({ _setTrackedFlightRefreshStateForTest({
icao24: 'seed00', icao24: 'seed00',
@@ -293,9 +286,9 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a
}; };
try { try {
await flightsLayer.update(viewer); await flightsLayer.update(viewer);
const billboard = added.find((entry) => entry.id === icao24); const icon = _iconForTest(icao24);
assert.ok(billboard, 'the new contact gets a billboard'); assert.ok(icon, 'the new contact gets an icon');
const shown = Cesium.Cartographic.fromCartesian(billboard.position); const shown = Cesium.Cartographic.fromCartesian(icon.position);
const fix = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 10_700); const fix = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 10_700);
const behindM = Cesium.Cartesian3.distance( const behindM = Cesium.Cartesian3.distance(
Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 10_700), 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 () => { 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 // 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. // floor of its reported fix, then the next fleet tick raised it.
_clearDisplayFloorStateForTest(); _clearDisplayFloorStateForTest();
_clearMeshFloorCellsForTest(); _clearMeshFloorCellsForTest();
setMeshFloorPreferred(true); setMeshFloorPreferred(true);
const added = []; const billboardCollection = { show: false };
const billboardCollection = {
show: false,
add(options) {
const billboard = { ...options };
added.push(billboard);
return billboard;
},
remove() {},
};
const viewer = { camera: { positionCartographic: null }, scene: {} }; const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({ _setTrackedFlightRefreshStateForTest({
icao24: 'seed00', icao24: 'seed00',
@@ -1142,9 +1126,9 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi
}; };
try { try {
await flightsLayer.update(viewer); await flightsLayer.update(viewer);
const billboard = added.find((entry) => entry.id === icao24); const icon = _iconForTest(icao24);
assert.ok(billboard, 'the new contact gets a billboard'); assert.ok(icon, 'the new contact gets an icon');
const shown = _floorCarto(billboard.position); const shown = _floorCarto(icon.position);
assert.ok( assert.ok(
Cesium.Math.toDegrees(shown.longitude) < fixLon, Cesium.Math.toDegrees(shown.longitude) < fixLon,
'drawn behind the fix', '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', () => { test('display floor: a grounded contact that drifted onto a higher cell is lifted', () => {
_clearDisplayFloorStateForTest(); _clearDisplayFloorStateForTest();
_clearMeshFloorCellsForTest(); _clearMeshFloorCellsForTest();
+18 -2
View File
@@ -45,7 +45,7 @@ const COURSE0 = 19; // deg
const COURSE_SLOPE = 20; // lerp: deg per unit fraction const COURSE_SLOPE = 20; // lerp: deg per unit fraction
const SLEW_OFFSET = 21; // deg still to glide at SLEW_T0 const SLEW_OFFSET = 21; // deg still to glide at SLEW_T0
const SLEW_T0 = 22; // s 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 DEG = Math.PI / 180;
const STRAIGHT_BELOW_RAD_PER_SEC = 1e-4; 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, * 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). * 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. * Call after writing the segment.
* @param {Float32Array} out * @param {Float32Array} out
* @param {number} row * @param {number} row
@@ -153,7 +155,7 @@ export function writeCourseGlide(out, row, fromCourseDeg, t0, rateDps) {
const target = targetCourse(out, o, t0); const target = targetCourse(out, o, t0);
out[o + SLEW_OFFSET] = norm180(fromCourseDeg - target); out[o + SLEW_OFFSET] = norm180(fromCourseDeg - target);
out[o + SLEW_T0] = t0; 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 o = row * SEGMENT_STRIDE;
const course = targetCourse(seg, o, tau); const course = targetCourse(seg, o, tau);
const offset = seg[o + SLEW_OFFSET]; 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); if (offset === 0) return norm360(course);
// limitCourseStep: the displayed course turns at the rate toward the segment's // limitCourseStep: the displayed course turns at the rate toward the segment's
// own (possibly turning) course until it reaches it, then follows it. The // 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); 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. */ /** The segment's own course at `tau`, before any glide. */
function targetCourse(seg, o, tau) { function targetCourse(seg, o, tau) {
const kind = seg[o + KIND]; 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 iconSegmentCourse(vec4 seg3, vec4 seg4, vec4 seg5, float tau) {
float course = iconTargetCourse(seg3, seg4, seg5, tau); float course = iconTargetCourse(seg3, seg4, seg5, tau);
float offset = seg5.y; float offset = seg5.y;
if (seg5.w < 0.0) return iconTargetCourse(seg3, seg4, seg5, seg5.z) + offset;
if (offset == 0.0) return course; if (offset == 0.0) return course;
float toward = -sign(offset); float toward = -sign(offset);
float start = iconTargetCourse(seg3, seg4, seg5, seg5.z); float start = iconTargetCourse(seg3, seg4, seg5, seg5.z);
+32
View File
@@ -213,3 +213,35 @@ test('the course glide never outruns the rate while its target turns', () => {
'then it follows the turn', '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');
});
+25 -1
View File
@@ -43,8 +43,14 @@ export const ICON_SHOWN = 1;
export const ICON_ROTATES = 2; export const ICON_ROTATES = 2;
/** Fade and shrink toward the globe's limb (aircraft recession). */ /** Fade and shrink toward the globe's limb (aircraft recession). */
export const ICON_RECEDES = 4; 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; 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. */ /** Move the epoch forward once render times pass this many seconds. */
const REBASE_AFTER_SEC = 1800; const REBASE_AFTER_SEC = 1800;
@@ -422,6 +428,24 @@ export function createIconSet({ capacity = 1024, epoch } = {}) {
course(row, tau) { course(row, tau) {
return segmentCourse(segments, 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) { function markRow(row) {
+24
View File
@@ -4,6 +4,8 @@ import * as Cesium from 'cesium';
import { import {
createIconSet, createIconSet,
DirtyRows, DirtyRows,
ICON_CULL_HEIGHT_M,
ICON_CULL_LIFT,
ICON_ROTATES, ICON_ROTATES,
ICON_SHOWN, ICON_SHOWN,
} from './iconSet.js'; } from './iconSet.js';
@@ -225,3 +227,25 @@ test('rebasing moves the epoch without moving any row', () => {
assert.equal(set.expires(row), 1100); assert.equal(set.expires(row), 1100);
assert.deepEqual(set.dirty.segments.runs(set.count), [[0, 0]]); 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',
);
});
+9 -4
View File
@@ -16,7 +16,7 @@
*/ */
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { ICON_SEGMENT_GLSL, SEGMENT_STRIDE } from './iconSegment.js'; 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 { iconAtlas, MAX_ICON_SLOTS } from './iconAtlas.js';
import { import {
cameraLimbDistanceM, cameraLimbDistanceM,
@@ -25,8 +25,6 @@ import {
/** On-screen device pixels above which a row draws its near (large) image. */ /** On-screen device pixels above which a row draws its near (large) image. */
const NEAR_IMAGE_PX = 76; 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({ const ATTRIBUTE_LOCATIONS = Object.freeze({
corner: 0, corner: 0,
@@ -80,6 +78,13 @@ bool iconFlag(float flags, float bit) {
return mod(floor(flags / bit), 2.0) == 1.0; 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) { bool occludedByEllipsoid(vec3 world) {
vec3 cv = u_horizon.xyz; vec3 cv = u_horizon.xyz;
float vh2 = u_horizon.w; float vh2 = u_horizon.w;
@@ -101,7 +106,7 @@ void main() {
vec3 rte = iconSegmentPosition(seg0, seg1, seg2, seg3, seg4, u_tau); vec3 rte = iconSegmentPosition(seg0, seg1, seg2, seg3, seg4, u_tau);
vec3 world = u_cameraPosition + rte; 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; if (occludedByEllipsoid(cullPoint)) return;
vec4 positionEC = czm_modelViewRelativeToEye * vec4(rte, 1.0); vec4 positionEC = czm_modelViewRelativeToEye * vec4(rte, 1.0);
+1 -1
View File
@@ -1660,7 +1660,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
[ [
'flights', 'flights',
readLayerSource(new URL('./flights.js', import.meta.url)), 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', 'militaryFlights',
+10 -4
View File
@@ -10,7 +10,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import flightsLayer, { _setTrackedFlightRefreshStateForTest } from './flights.js'; import flightsLayer, {
_iconForTest,
_setTrackedFlightRefreshStateForTest,
} from './flights.js';
import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js'; import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js';
const ICAO = 'abc123'; const ICAO = 'abc123';
@@ -39,6 +42,8 @@ const LAYERS = [
name: 'commercial', name: 'commercial',
layer: flightsLayer, layer: flightsLayer,
visualCenterM: Cesium.Cartesian3.ZERO, visualCenterM: Cesium.Cartesian3.ZERO,
// Where the icon is drawn: the seeded position as the GPU encodes it.
spritePosition: () => _iconForTest(ICAO).position,
seed({ billboardShow, models = [] }) { seed({ billboardShow, models = [] }) {
_setTrackedFlightRefreshStateForTest({ _setTrackedFlightRefreshStateForTest({
icao24: ICAO, icao24: ICAO,
@@ -61,6 +66,7 @@ const LAYERS = [
name: 'military', name: 'military',
layer: militaryFlightsLayer, layer: militaryFlightsLayer,
visualCenterM: Cesium.Cartesian3.ZERO, visualCenterM: Cesium.Cartesian3.ZERO,
spritePosition: () => contactBillboard(true).position,
seed({ billboardShow, models = [] }) { seed({ billboardShow, models = [] }) {
_setTrackedMilitaryRefreshStateForTest({ _setTrackedMilitaryRefreshStateForTest({
icao24: ICAO, icao24: ICAO,
@@ -149,8 +155,8 @@ for (const fixture of LAYERS) {
const spritePosition = detectionPosition(fixture.layer); const spritePosition = detectionPosition(fixture.layer);
assert.ok(spritePosition, 'the 2D sprite publishes a detection candidate'); assert.ok(spritePosition, 'the 2D sprite publishes a detection candidate');
assert.ok( assert.ok(
Cesium.Cartesian3.equalsEpsilon(spritePosition, contactBillboard(true).position, 0, 1e-6), Cesium.Cartesian3.equalsEpsilon(spritePosition, fixture.spritePosition(), 0, 1e-6),
'the 2D bracket stays welded to the billboard position', 'the 2D bracket stays welded to the drawn sprite position',
); );
const modelMatrix = Cesium.Matrix4.fromRotationTranslation(MODEL_ROTATION, MODEL_POSITION); const modelMatrix = Cesium.Matrix4.fromRotationTranslation(MODEL_ROTATION, MODEL_POSITION);
@@ -195,7 +201,7 @@ for (const fixture of LAYERS) {
const loadingPosition = detectionPosition(fixture.layer); const loadingPosition = detectionPosition(fixture.layer);
assert.ok(loadingPosition, 'the billboard remains detectable while its model loads'); assert.ok(loadingPosition, 'the billboard remains detectable while its model loads');
assert.ok( 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', 'a shown-but-unready zero-scale model cannot steal the detection anchor',
); );
}); });
+9 -17
View File
@@ -3,7 +3,6 @@ import test from 'node:test';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { routePlausible } from './routePlausible.js'; import { routePlausible } from './routePlausible.js';
import { createTracking } from '../layers/flights/tracking.js'; import { createTracking } from '../layers/flights/tracking.js';
import { addBillboard } from './billboardMirror.js';
const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' }; const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' };
const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' }; 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); assert.equal(plausible(beyond, null, null), true);
}); });
test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => { test('the actual native tracked label hides the wrong leg and keeps a healthy route', () => {
const collection = new Cesium.BillboardCollection();
t.after(() => collection.destroy());
const flightState = { const flightState = {
records: { data: new Map(), missingPolls: new Map() }, records: { data: new Map(), missingPolls: new Map() },
feed: { _backoff: false }, feed: { _backoff: false },
_billboards: new Map(),
_scratchCarto: new Cesium.Cartographic(), _scratchCarto: new Cesium.Cartographic(),
}; };
// Where each contact's icon is drawn.
const drawn = new Map();
// Only application host services are supplied. Route admission, WGS84 // Only application host services are supplied. Route admission, WGS84
// billboard conversion and tracked-label formatting are the actual owners. // billboard conversion and tracked-label formatting are the actual owners.
const services = Object.fromEntries( 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, {}]), ].map((name) => [name, {}]),
); );
services.aircraftPresentation = { isTr3b: () => false }; 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 }); const tracking = createTracking({ flightState, services, parts });
for (const [position, expected] of [ for (const [position, expected] of [
[beyond, false], [beyond, false],
@@ -155,18 +156,9 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
verticalRate: 0, verticalRate: 0,
route, route,
}); });
flightState._billboards.set( drawn.set(
id, id,
addBillboard(collection, { Cesium.Cartesian3.fromDegrees(position.lon, position.lat, 10000),
id,
width: 20,
height: 20,
position: Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
10000,
),
}),
); );
assert.equal( assert.equal(
tracking._trackedLabelText(id).includes('SFO → LAX'), tracking._trackedLabelText(id).includes('SFO → LAX'),
+3 -3
View File
@@ -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 // 1. The 3D model handoff is SUPPRESSED for a converted contact — there is
// no TR-3B GLB, so the triangle billboard stays the visual. // 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`); `${name}: fleet model handoff skips converted contacts`);
// The tracked regime became default-on and camera-distance driven // The tracked regime became default-on and camera-distance driven
// (2026-08-19), so the suppression moved from a conjunct on // (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 // 2. The billboard is never hidden by that suppression — it must keep
// satisfying the getNearby/getDetectableObjects visibility guards, so a // satisfying the getNearby/getDetectableObjects visibility guards, so a
// converted contact still works in Contacts and Cockpit. // 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`); `${name}: converting restores the billboard the model handoff had hidden`);
// 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no // 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)); const source = readLayerSource(new URL(`./${name}`, import.meta.url));
// Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated // Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated
// ambient-enrichment candidate loop that also builds a `cand`. // 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.ok(loop, `${name}: the model-eligibility candidate loop is present`);
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`); assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/, assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
+22 -1
View File
@@ -34,6 +34,8 @@ import flightsLayer, {
_trackedBillboardColorForTest as flightsTrackedBillboardColor, _trackedBillboardColorForTest as flightsTrackedBillboardColor,
_driveFleetModelHandoffForTest as flightsDriveFleetModel, _driveFleetModelHandoffForTest as flightsDriveFleetModel,
_ensureFleetModelForTest as flightsEnsureFleetModel, _ensureFleetModelForTest as flightsEnsureFleetModel,
_iconForTest as flightsIcon,
_setIconForTest as flightsSetIcon,
} from './flights.js'; } from './flights.js';
import militaryFlightsLayer, { import militaryFlightsLayer, {
_setTrackedMilitaryRefreshStateForTest, _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 /** Records what the driver hands to the scene, so a test can assert that a
* failing loader never put a model up. */ * failing loader never put a model up. */
function recordingModelCollection() { function recordingModelCollection() {
@@ -216,7 +237,7 @@ const LAYERS = [
modelCollection, modelCollection,
meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround }, meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround },
}); });
return billboard; return flightIconView(icao24);
}, },
}, },
{ {
+12 -10
View File
@@ -9,7 +9,6 @@ import {
ENRICH_AMBIENT_REFILL_WINDOW_MS, ENRICH_AMBIENT_REFILL_WINDOW_MS,
ENRICH_AMBIENT_PER_SWEEP, ENRICH_AMBIENT_PER_SWEEP,
} from './policy.js'; } from './policy.js';
import { billboardState } from '../../data/billboardMirror.js';
export function createEnrichment({ export function createEnrichment({
flightState, flightState,
@@ -94,9 +93,7 @@ export function createEnrichment({
}); });
if (klass !== meta.klass) { if (klass !== meta.klass) {
meta.klass = klass; meta.klass = klass;
const bb = flightState._billboards.get(icao24); parts.icons._applyIconPresentation(icao24);
if (bb)
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
// Hangar fleet: the class's GLB/scale may have changed — resync the // Hangar fleet: the class's GLB/scale may have changed — resync the
// live model, any in-flight load, and the tracked standalone model. // live model, any in-flight load, and the tracked standalone model.
parts.rendering._syncModelToClass(icao24); parts.rendering._syncModelToClass(icao24);
@@ -182,8 +179,7 @@ export function createEnrichment({
if ( if (
flightState._enrichAmbientBudget <= 0 || flightState._enrichAmbientBudget <= 0 ||
!flightState._viewer || !flightState._viewer ||
!flightState._billboardCollection || !flightState._icons?.show
!flightState._billboardCollection.show
) )
return; return;
try { try {
@@ -196,13 +192,19 @@ export function createEnrichment({
camera.upWC, camera.upWC,
); );
const cand = []; 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 (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 (!/^[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) if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
const { position } = billboardState(bb); const position = icons.position(
if (!position || !occluder.isPointVisible(position)) continue; // beyond the limb row,
Cesium.Cartesian3.clone(position, flightState._scratchModelBS.center); tau,
flightState._scratchModelBS.center,
);
if (!occluder.isPointVisible(position)) continue; // beyond the limb
if ( if (
cull.computeVisibility(flightState._scratchModelBS) === cull.computeVisibility(flightState._scratchModelBS) ===
Cesium.Intersect.OUTSIDE Cesium.Intersect.OUTSIDE
+55 -50
View File
@@ -1,11 +1,11 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { aircraftIcon } from '../../data/aircraftIcons.js';
import { FOCUS_EVIDENCE_DEV } from './policy.js'; import { FOCUS_EVIDENCE_DEV } from './policy.js';
import { writeHoldSegment } from '../../data/iconSegment.js';
import { import {
addBillboard, aircraftRecessionFactors,
billboardState, aircraftTreatmentAlpha,
setBillboard, } from '../../data/aircraftRecession.js';
} from '../../data/billboardMirror.js'; import { horizonOccluder } from '../../data/iconOrientation.js';
export function createEvidence({ export function createEvidence({
flightState, flightState,
@@ -44,17 +44,14 @@ export function createEvidence({
/** Replace the real fleet with deterministic explicit-position contacts. */ /** Replace the real fleet with deterministic explicit-position contacts. */
function _setFocusEvidenceAircraft(records = []) { function _setFocusEvidenceAircraft(records = []) {
if ( if (!FOCUS_EVIDENCE_DEV || !flightState._icons || !flightState._viewer)
!FOCUS_EVIDENCE_DEV ||
!flightState._billboardCollection ||
!flightState._viewer
)
return { ok: false, count: 0 }; return { ok: false, count: 0 };
if (flightState._trackedIcao) parts.tracking._clearTracking(); if (flightState._trackedIcao) parts.tracking._clearTracking();
parts.rendering._releaseModels(); parts.rendering._releaseModels();
for (const bb of flightState._billboards.values()) const icons = flightState._icons;
flightState._billboardCollection.remove(bb); icons.clear();
flightState._billboards.clear(); flightState._grounded.clear();
flightState._floorPinned.clear();
flightState.records.data.clear(); flightState.records.data.clear();
flightState._positionHistory.clear(); flightState._positionHistory.clear();
flightState._displayCourse.clear(); flightState._displayCourse.clear();
@@ -86,32 +83,19 @@ export function createEvidence({
rawLon: record.longitude ?? null, rawLon: record.longitude ?? null,
}; };
flightState.records.data.set(id, meta); flightState.records.data.set(id, meta);
flightState._cullPositions.delete(id);
flightState._focusEvidenceIds.add(id); flightState._focusEvidenceIds.add(id);
const bb = addBillboard(flightState._billboardCollection, { parts.icons._addIcon(id);
position, const row = icons.row(id);
image: aircraftIcon(parts.rendering._iconKind(id, klass)), writeHoldSegment(icons.segments, row, position, meta.true_track);
width: 20, icons.segmentWritten(row);
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);
} }
flightState.feed._count = flightState._billboards.size; flightState.feed._count = icons.count;
flightState._lastFleetTickMs = 0; flightState._lastFleetTickMs = 0;
flightState._viewer.scene.requestRender?.(); flightState._viewer.scene.requestRender?.();
return { ok: true, count: flightState.feed._count }; return { ok: true, count: flightState.feed._count };
} }
/** Update explicit evidence positions without rebuilding billboards. */ /** Update explicit evidence positions without rebuilding rows. */
function _moveFocusEvidenceAircraft(records = []) { function _moveFocusEvidenceAircraft(records = []) {
if (!FOCUS_EVIDENCE_DEV) return { ok: false, moved: 0 }; if (!FOCUS_EVIDENCE_DEV) return { ok: false, moved: 0 };
@@ -122,15 +106,21 @@ export function createEvidence({
.toLowerCase(); .toLowerCase();
if (!flightState._focusEvidenceIds.has(id)) continue; if (!flightState._focusEvidenceIds.has(id)) continue;
const position = _focusEvidencePosition(record); const position = _focusEvidencePosition(record);
const bb = flightState._billboards.get(id); const row = flightState._icons?.row(id) ?? -1;
if (!position || !bb) continue; if (!position || row < 0) continue;
setBillboard(bb, 'position', position);
const meta = flightState.records.data.get(id); const meta = flightState.records.data.get(id);
if (meta) { if (meta) {
if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg; if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg;
if (Number.isFinite(record.velocityMps)) if (Number.isFinite(record.velocityMps))
meta.velocity = record.velocityMps; meta.velocity = record.velocityMps;
} }
writeHoldSegment(
flightState._icons.segments,
row,
position,
meta?.true_track ?? 0,
);
flightState._icons.segmentWritten(row);
moved += 1; moved += 1;
} }
flightState._lastFleetTickMs = 0; flightState._lastFleetTickMs = 0;
@@ -142,27 +132,42 @@ export function createEvidence({
function _focusEvidenceSnapshot() { function _focusEvidenceSnapshot() {
if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return []; 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) => { return [...flightState._focusEvidenceIds].map((id) => {
const bb = flightState._billboards.get(id); const row = icons?.row(id) ?? -1;
const screen = billboardState(bb)?.position const position = row < 0 ? null : parts.icons._iconPosition(id);
? Cesium.SceneTransforms.worldToWindowCoordinates( const cameraDistanceM = position
flightState._viewer.scene, ? Cesium.Cartesian3.distance(camera.positionWC, position)
billboardState(bb).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; : null;
return { return {
id, id,
show: billboardState(bb)?.show === true, show:
scale: billboardState(bb)?.scale ?? null, !!position &&
alpha: bb?.color?.alpha ?? null, 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, x: screen?.x ?? null,
y: screen?.y ?? null, y: screen?.y ?? null,
cameraDistanceM: billboardState(bb)?.position cameraDistanceM,
? Cesium.Cartesian3.distance(
flightState._viewer.camera.positionWC,
billboardState(bb).position,
)
: null,
}; };
}); });
} }
+471
View File
@@ -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,
};
}
+120
View File
@@ -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);
});
+3
View File
@@ -1,6 +1,7 @@
import { createFlightSnapshotRenderer } from './snapshotRenderer.js'; import { createFlightSnapshotRenderer } from './snapshotRenderer.js';
import { createFlightState } from './state.js'; import { createFlightState } from './state.js';
import { createRendering } from './rendering.js'; import { createRendering } from './rendering.js';
import { createIcons } from './icons.js';
import { createMotion } from './motion.js'; import { createMotion } from './motion.js';
import { createTracking } from './tracking.js'; import { createTracking } from './tracking.js';
import { createController } from './controller.js'; import { createController } from './controller.js';
@@ -21,6 +22,7 @@ export function createCivilFlightLayer({
const layer = {}; const layer = {};
const context = { flightState, services, parts, layer, resolveAsset }; const context = { flightState, services, parts, layer, resolveAsset };
parts.rendering = createRendering(context); parts.rendering = createRendering(context);
parts.icons = createIcons(context);
parts.motion = createMotion(context); parts.motion = createMotion(context);
parts.tracking = createTracking(context); parts.tracking = createTracking(context);
parts.controller = createController(context); parts.controller = createController(context);
@@ -38,6 +40,7 @@ export function createCivilFlightLayer({
rendering: parts.rendering, rendering: parts.rendering,
tracking: parts.tracking, tracking: parts.tracking,
motion: parts.motion, motion: parts.motion,
icons: parts.icons,
enrichment: parts.enrichment, enrichment: parts.enrichment,
queries: parts.queries, queries: parts.queries,
}); });
+24 -32
View File
@@ -34,7 +34,7 @@ export function createLifecycle({
/** /**
* Initialize the flights layer. * 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. * click-to-track handler on the scene canvas.
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
*/ */
@@ -48,9 +48,7 @@ export function createLifecycle({
clearFocusTarget('flights'); clearFocusTarget('flights');
flightState._focusEvidenceIds.clear(); flightState._focusEvidenceIds.clear();
flightState._viewer = viewer; flightState._viewer = viewer;
flightState._billboardCollection = new Cesium.BillboardCollection(); registerSpriteCollection('flights', parts.icons._createIcons(viewer));
viewer.scene.primitives.add(flightState._billboardCollection);
registerSpriteCollection('flights', flightState._billboardCollection);
flightState._modelCollection = new Cesium.PrimitiveCollection(); flightState._modelCollection = new Cesium.PrimitiveCollection();
viewer.scene.primitives.add(flightState._modelCollection); viewer.scene.primitives.add(flightState._modelCollection);
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed // 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 */ /* tracked plane just stays a billboard a beat longer */
}); });
} }
flightState._billboards = new Map(); flightState._grounded.clear();
flightState._cullPositions.clear(); flightState._floorPinned.clear();
flightState._iconFocusActive = 0;
flightState._detectionObjects = new Map(); flightState._detectionObjects = new Map();
flightState.records.data = new Map(); flightState.records.data = new Map();
flightState._positionHistory = new Map(); flightState._positionHistory = new Map();
@@ -126,16 +125,15 @@ export function createLifecycle({
restoreSpriteOrder(viewer); 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 * @param {Cesium.Viewer} viewer
*/ */
enable(viewer) { enable(viewer) {
if (flightState._billboardCollection) if (flightState._icons) flightState._icons.show = true;
flightState._billboardCollection.show = true;
holdContinuousRender('flights'); // per-frame animator (perf wave 2) holdContinuousRender('flights'); // per-frame animator (perf wave 2)
if (flightState._modelCollection) if (flightState._modelCollection)
flightState._modelCollection.show = true; flightState._modelCollection.show = true;
@@ -156,11 +154,10 @@ export function createLifecycle({
}); });
} }
parts.tracking._installClickHandler(viewer); parts.tracking._installClickHandler(viewer);
registerPickOwner('flights', (pickedId) => registerPickOwner(
flightState._billboards.has(pickedId), '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) { if (!flightState._preRenderRemove && viewer?.scene) {
flightState._preRenderRemove = viewer.scene.preRender.addEventListener( flightState._preRenderRemove = viewer.scene.preRender.addEventListener(
parts.rendering._fleetTick, parts.rendering._fleetTick,
@@ -173,18 +170,11 @@ export function createLifecycle({
); );
} }
if (!flightState._moveEndRemove && viewer?.camera) { if (!flightState._moveEndRemove && viewer?.camera) {
// Arrival polish (field test 2026-07-03: "planes look weird when you first // A settled camera move (voice fly-to, fast pan release) runs the fleet
// come to them"): when a camera move SETTLES (voice fly-to, fast pan // pass on the very next preRender rather than up to FLEET_DR_INTERVAL_MS
// release), force a full rotation pass on the very next frame. The // later, so the model handoff and focus land with the camera.
// 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.
flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener( flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(
() => { () => {
flightState._lastCamPoseSig = '';
flightState._lastFleetTickMs = 0; 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. * Also clears any active flight tracking so the camera is released.
* @param {Cesium.Viewer} viewer * @param {Cesium.Viewer} viewer
*/ */
disable(viewer) { disable(viewer) {
parts.controller._abortActiveUpdates(); parts.controller._abortActiveUpdates();
parts.tracking._cancelPendingTrackingRestore(); parts.tracking._cancelPendingTrackingRestore();
if (flightState._billboardCollection) if (flightState._icons) flightState._icons.show = false;
flightState._billboardCollection.show = false;
releaseContinuousRender('flights'); releaseContinuousRender('flights');
parts.rendering._releaseModels(); parts.rendering._releaseModels();
if (flightState._modelCollection) if (flightState._modelCollection)
@@ -282,10 +271,12 @@ export function createLifecycle({
flightState._moveEndRemove = null; flightState._moveEndRemove = null;
} }
parts.rendering._releaseModels(); parts.rendering._releaseModels();
if (flightState._billboardCollection) { if (flightState._iconPrimitive) {
viewer.scene.primitives.remove(flightState._billboardCollection); viewer.scene.primitives.remove(flightState._iconPrimitive); // removing destroys it
flightState._billboardCollection = null; flightState._iconPrimitive = null;
} }
flightState._icons?.clear();
flightState._icons = null;
if (flightState._modelCollection) { if (flightState._modelCollection) {
viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models
flightState._modelCollection = null; flightState._modelCollection = null;
@@ -302,8 +293,9 @@ export function createLifecycle({
flightState._preloadModel = null; flightState._preloadModel = null;
} }
flightState._planeModelLoaded = false; flightState._planeModelLoaded = false;
flightState._billboards.clear(); flightState._grounded.clear();
flightState._cullPositions.clear(); flightState._floorPinned.clear();
flightState._iconFocusActive = 0;
flightState._detectionObjects.clear(); flightState._detectionObjects.clear();
flightState.records.data.clear(); flightState.records.data.clear();
flightState._positionHistory.clear(); flightState._positionHistory.clear();
+140 -102
View File
@@ -35,10 +35,23 @@ import {
DISPLAY_CORRIDOR_CELL_BUDGET, DISPLAY_CORRIDOR_CELL_BUDGET,
DISPLAY_CORRIDOR_FAIR_SHARE, DISPLAY_CORRIDOR_FAIR_SHARE,
} from './policy.js'; } from './policy.js';
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
import { modelState } from '../../data/modelMirror.js'; import { modelState } from '../../data/modelMirror.js';
import { readEntityRecord } from '../../data/entityRecords.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({ export function createMotion({
flightState, flightState,
services, services,
@@ -66,6 +79,23 @@ export function createMotion({
// Fixes leave the cache with their history; corrected positions invalidate // Fixes leave the cache with their history; corrected positions invalidate
// the local frame even when the existing Cartesian is mutated in place. // the local frame even when the existing Cartesian is mutated in place.
const fixFrames = new WeakMap(); 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 // Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS // 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 * 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 * frozen underground forever. Runs once per poll over the (bounded) flight
* map; touches only grounded, feed-absent, demonstrably-below-floor * 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). * DR renders the patched fix verbatim).
* @param {Set<string>} currentIcaos - Contacts present in THIS poll (already * @param {Set<string>} currentIcaos - Contacts present in THIS poll (already
* floored by the live path — skipped here). * floored by the live path — skipped here).
@@ -101,7 +131,6 @@ export function createMotion({
) )
continue; continue;
info.renderAltitudeM = lifted; info.renderAltitudeM = lifted;
flightState._cullPositions.delete(icao24); // above the ellipsoid now (or floors say otherwise next poll)
const position = Cesium.Cartesian3.fromDegrees( const position = Cesium.Cartesian3.fromDegrees(
info.rawLon, info.rawLon,
info.rawLat, info.rawLat,
@@ -111,8 +140,9 @@ export function createMotion({
const newest = history?.[history.length - 1]; const newest = history?.[history.length - 1];
if (newest) if (newest)
newest.position = Cesium.Cartesian3.clone(position, newest.position); newest.position = Cesium.Cartesian3.clone(position, newest.position);
const bb = flightState._billboards.get(icao24); // Above the ellipsoid now (or floors say otherwise next poll).
if (bb) setBillboard(bb, 'position', position); parts.icons._applyIconPresentation(icao24);
parts.icons._planIcon(icao24);
} }
} }
@@ -134,9 +164,7 @@ export function createMotion({
function _deadReckon(icao24, result, sampledRenderTime) { function _deadReckon(icao24, result, sampledRenderTime) {
const info = flightState.records.data.get(icao24); const info = flightState.records.data.get(icao24);
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) { if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
const position = billboardState( const position = parts.icons._iconPosition(icao24);
flightState._billboards.get(icao24),
)?.position;
flightState._drCourseDeg = info?.true_track || 0; flightState._drCourseDeg = info?.true_track || 0;
flightState._drSpeedMps = info?.velocity || 0; flightState._drSpeedMps = info?.velocity || 0;
flightState._drCourseHold = flightState._drCourseHold =
@@ -166,100 +194,113 @@ export function createMotion({
flightState._scratchRenderTime, 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<object>} 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--) { for (let i = history.length - 1; i >= 1; i--) {
const a = history[i - 1]; const a = history[i - 1];
const b = history[i]; const b = history[i];
if ( if (
Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && !Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) !Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
) { )
const span = Cesium.JulianDate.secondsDifference(b.time, a.time); continue;
const t = const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
span > 0 const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span const speed = span > 0 ? chordLenM / span : (info && info.velocity) || 0;
: 1.0; const fallbackTrack = (info && info.true_track) || 0;
// Course of the DISPLAYED motion. The chord is only trustworthy when the const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
// segment covers real ground (at hover its direction is GPS jitter; on a const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
// slow tight turn it STEPS the whole per-segment turn at each boundary), regime.kind = 'between';
// so it is blended against the reported per-fix track by displayed regime.a = a;
// ground speed — and the track is TIME-INTERPOLATED between the fixes so regime.b = b;
// a slow turner's nose advances continuously through the segment instead regime.span = span;
// of snapping once per poll. Helicopters always use the reported track regime.speed = speed;
// (rotorcraft chords are noise-dominated at their typical speeds). regime.trackFrom = trackFrom;
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position); regime.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
const segSpeed = regime.chordCourse =
span > 0 ? chordLenM / span : (info && info.velocity) || 0; blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
const fallbackTrack = (info && info.true_track) || 0; regime.blend = blend;
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack; return regime;
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);
}
} }
const newest = history[history.length - 1]; const newest = history[history.length - 1];
const elapsedSec = Cesium.JulianDate.secondsDifference( const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
renderTime, regime.kind = 'fix';
newest.time, regime.fix = warming ? history[0] : newest;
); regime.speed = fixSpeedMps(regime.fix, info);
if (elapsedSec <= 0) { regime.heading = fixTrackDeg(regime.fix, info);
// Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking regime.turnRateDps = (info && info.turnRateDps) || 0;
// aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing regime.minDt = -60;
// pair exists yet). Render at the DELAYED renderTime — preserving the 30s-behind // A warm-up ends when real time reaches the oldest fix; a single fix keeps
// invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history // coasting forward on the same arc.
// fills, renderTime advances toward the first fix and the icon glides FORWARD into regime.maxDt =
// the bracketing interpolation above with NO freeze and NO backward snap. (Holding warming && history.length > 1
// the oldest fix froze the icon; extrapolating the NEWEST fix to wall-clock now made ? 0
// the icon jump back ~one poll interval the instant interpolation took over.) : staleCoastLimitSeconds({
const oldest = history[0]; // `epochMs` is captured once when the poll is normalized. Avoid
const lookbackSec = Cesium.JulianDate.secondsDifference( // allocating a Date per aircraft on every 12 Hz fleet tick.
oldest.time, fixEpochMs: Number.isFinite(newest.epochMs)
renderTime, ? newest.epochMs
); // ≥ 0 : Cesium.JulianDate.toDate(newest.time).getTime(),
return _extrapolateFix( lastContactEpochMs: info?.lastContactEpochMs,
oldest, // Permit one minute of contact grace but cap any cached-feed drift
info, // at five minutes. Source backoff is exposed separately as a STALE cue.
-Math.min(lookbackSec, 60), minimumSec: 60,
out, maximumSec: 300,
(info && info.turnRateDps) || 0, });
); return regime;
}
// 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,
);
} }
/** /**
@@ -274,12 +315,8 @@ export function createMotion({
*/ */
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) { function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
const speed = Number.isFinite(fix.velocity) const speed = fixSpeedMps(fix, info);
? fix.velocity const heading = fixTrackDeg(fix, info);
: (info && info.velocity) || 0;
const heading = Number.isFinite(fix.track)
? fix.track
: (info && info.true_track) || 0;
flightState._drSpeedMps = speed; flightState._drSpeedMps = speed;
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS; flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
flightState._drExtrapolating = true; flightState._drExtrapolating = true;
@@ -1114,6 +1151,7 @@ export function createMotion({
return { return {
_refloorStaleGroundedContacts, _refloorStaleGroundedContacts,
_deadReckon, _deadReckon,
_motionRegime,
_extrapolateFix, _extrapolateFix,
_isTrackWarmingUp, _isTrackWarmingUp,
_trackedFocusSizePx, _trackedFocusSizePx,
+2 -1
View File
@@ -59,7 +59,8 @@ test('flight state owns distinct mutable records, scratch objects and floor samp
for (const key of [ for (const key of [
'records', 'records',
'feed', 'feed',
'_billboards', '_grounded',
'_floorPinned',
'_positionHistory', '_positionHistory',
'_displayFloorState', '_displayFloorState',
'_groundSnap', '_groundSnap',
+8
View File
@@ -207,6 +207,14 @@ export const COURSE_MAX_DPS = 60;
export const COURSE_SLEW_DT_MAX_SEC = 0.25; 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 // adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4
// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart // concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart
+68 -91
View File
@@ -16,7 +16,6 @@ import {
FLEET_DR_INTERVAL_MS, FLEET_DR_INTERVAL_MS,
} from './policy.js'; } from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
import { modelState } from '../../data/modelMirror.js'; import { modelState } from '../../data/modelMirror.js';
export function createQueries({ export function createQueries({
@@ -99,7 +98,7 @@ export function createQueries({
* airframe itself and cannot go stale the way a leg can. * airframe itself and cannot go stale the way a leg can.
* *
* This is a DISPLAY string only. Identity everywhere in this layer is `icao24` * 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. * the `id` that trackById/Context cohorts resolve) — never the label.
* @param {string} icao24 - ICAO 24-bit transponder address (the identity key). * @param {string} icao24 - ICAO 24-bit transponder address (the identity key).
* @param {Object|null|undefined} info - `_flightData` record for this aircraft. * @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 // every visual consumer reads the floored per-frame cache instead (see
// `_trackedDisplayPosition`). If that ever changes, floor this path too. // `_trackedDisplayPosition`). If that ever changes, floor this path too.
const info = flightState.records.data.get(icao24); const info = flightState.records.data.get(icao24);
const bb = flightState._billboards.get(icao24);
const basePos = const basePos =
parts.motion._deadReckon(icao24) || parts.motion._deadReckon(icao24) || parts.icons._iconPosition(icao24);
(bb ? billboardState(bb).position : null);
if (!basePos) return null; if (!basePos) return null;
const displayed = displayedKinematics({ const displayed = displayedKinematics({
derivedSpeedMps: flightState._drSpeedMps, derivedSpeedMps: flightState._drSpeedMps,
@@ -334,14 +331,8 @@ export function createQueries({
if (!flightState._models3dEnabled) { if (!flightState._models3dEnabled) {
parts.rendering._releaseModels(); parts.rendering._releaseModels();
parts.tracking._syncTracked2dRotation(); parts.tracking._syncTracked2dRotation();
// Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the // (Releasing the models handed each fleet contact back to its icon, never
// tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it // the tracked plane's own — its tracked entity is the visual.)
// 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);
}
} }
} }
if ( if (
@@ -424,42 +415,34 @@ export function createQueries({
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
*/ */
getDetectableObjects(options = {}) { getDetectableObjects(options = {}) {
if ( const icons = flightState._icons;
!flightState._billboardCollection || if (!icons || !icons.show) return [];
!flightState._billboardCollection.show // Compute a stride that evenly samples the icon rows.
)
return [];
// Compute a stride that evenly samples the billboard map.
// seed shifts the starting offset so successive calls can sample // 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) const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(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 seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
const stride = Math.max( const stride = Math.max(1, Math.ceil(icons.count / maxCount));
1,
Math.ceil(flightState._billboards.size / maxCount),
);
const start = seed % stride; const start = seed % stride;
const occluder = parts.icons._occluder();
const tau = parts.icons._displayTau();
const result = []; const result = [];
let idx = 0; for (let row = start; row < icons.count; row += stride) {
for (const [icao24, bb] of flightState._billboards) { const icao24 = icons.idAt(row);
const shouldTake = (idx - start) % stride === 0;
idx++;
if (!shouldTake) continue;
if ( if (
flightState._cockpitContactMode && flightState._cockpitContactMode &&
icao24.toLowerCase() === flightState._cockpitSubjectId icao24.toLowerCase() === flightState._cockpitSubjectId
) )
continue; continue;
const isTracked = icao24 === flightState._trackedIcao; const isTracked = icao24 === flightState._trackedIcao;
// Keep planes rendered as a 3D model (billboard hidden) so the detection box // Keep planes rendered as a 3D model (icon hidden) so the detection box
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. // doesn't vanish on the 2D→3D handoff.
const model = flightState._models.get(icao24) ?? null; const model = flightState._models.get(icao24) ?? null;
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model); const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model);
if (!isTracked && !billboardState(bb).show && !modelOwnsVisual) if (!isTracked && !icons.shown(row) && !modelOwnsVisual) continue;
continue;
const info = flightState.records.data.get(icao24); const info = flightState.records.data.get(icao24);
let object = flightState._detectionObjects.get(icao24); let object = flightState._detectionObjects.get(icao24);
if (!object) { if (!object) {
@@ -467,21 +450,24 @@ export function createQueries({
sourceId: icao24, sourceId: icao24,
type: 'AIR', type: 'AIR',
_weldPos: new Cesium.Cartesian3(), _weldPos: new Cesium.Cartesian3(),
_iconPos: new Cesium.Cartesian3(),
}; };
flightState._detectionObjects.set(icao24, object); flightState._detectionObjects.set(icao24, object);
} }
// WELD: anchor to whatever actually owns the visual. A model-owned contact is // 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 // 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 // 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 // and rises only as the coarse ground-floor cell warms. Zero extra sampling and
// no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep // no `_modelDisplayPosition` call from postRender. Icon-owned contacts keep
// `bb.position` — sprite and bracket are co-located there, so association holds. // the drawn icon position — icon and bracket are co-located there.
const spec = modelOwnsVisual const spec = modelOwnsVisual
? parts.rendering._modelSpec(info?.klass) ? parts.rendering._modelSpec(info?.klass)
: null; : null;
const pos = isTracked const pos = isTracked
? parts.motion._trackedVisualCached() || billboardState(bb).position ? parts.motion._trackedVisualCached() ||
icons.position(row, tau, object._iconPos)
: modelOwnsVisual : modelOwnsVisual
? modelVisualAnchor( ? modelVisualAnchor(
modelState(model).modelMatrix, modelState(model).modelMatrix,
@@ -491,8 +477,15 @@ export function createQueries({
: spec.scale, : spec.scale,
object._weldPos || (object._weldPos = new Cesium.Cartesian3()), object._weldPos || (object._weldPos = new Cesium.Cartesian3()),
) )
: billboardState(bb).position; : icons.position(row, tau, object._iconPos);
if (!pos) continue; // 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.position = pos;
object.skipLabel = isTracked; object.skipLabel = isTracked;
// Card text only — declutter/cohort identity is `object.sourceId` (icao24). // Card text only — declutter/cohort identity is `object.sourceId` (icao24).
@@ -517,13 +510,9 @@ export function createQueries({
result.push(object); result.push(object);
if (result.length >= maxCount) break; if (result.length >= maxCount) break;
} }
if ( if (flightState._detectionObjects.size > icons.count + 512) {
flightState._detectionObjects.size >
flightState._billboards.size + 512
) {
for (const icao24 of flightState._detectionObjects.keys()) { for (const icao24 of flightState._detectionObjects.keys()) {
if (!flightState._billboards.has(icao24)) if (!icons.has(icao24)) flightState._detectionObjects.delete(icao24);
flightState._detectionObjects.delete(icao24);
} }
} }
return result; 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}>} * @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 } = {}) { getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
if ( const icons = flightState._icons;
!center || if (!center || !icons || !icons.show) return [];
!flightState._billboardCollection || const occluder = parts.icons._occluder();
!flightState._billboardCollection.show const tau = parts.icons._displayTau();
) const scratch = flightState._scratchNearbyPos;
return [];
const limit = Number.isFinite(maxCount) const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount)) ? Math.max(1, Math.floor(maxCount))
@@ -598,28 +586,30 @@ export function createQueries({
const nearby = []; 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; const isTracked = icao24 === flightState._trackedIcao;
// Keep planes rendered as a 3D model (billboard hidden) so proximity counts const trackedPos = isTracked
// don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. ? 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 ( if (
!aircraftIncludedInNearby({ !aircraftIncludedInNearby({
isTracked, isTracked,
billboardShown: billboardState(bb).show, billboardShown:
icons.shown(row) &&
parts.icons._aboveHorizon(row, drawn, occluder),
modelRendering: parts.rendering._modelOwnsVisual(icao24), modelRendering: parts.rendering._modelOwnsVisual(icao24),
includeHidden, includeHidden,
}) })
) )
continue; continue;
const trackedPos = isTracked const distance = Cesium.Cartesian3.distance(center, drawn);
? 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);
if (distance > maxRange) continue; if (distance > maxRange) continue;
const pos = trackedPos || Cesium.Cartesian3.clone(drawn);
const info = flightState.records.data.get(icao24); const info = flightState.records.data.get(icao24);
const callsign = info?.callsign?.trim() || null; const callsign = info?.callsign?.trim() || null;
@@ -651,8 +641,8 @@ export function createQueries({
/** /**
* Return id/label/position for up to maxCount currently rendered aircraft. * Return id/label/position for up to maxCount currently rendered aircraft.
* Cheap snapshot for voice-tool framing — billboard positions only, no * Cheap snapshot for voice-tool framing — drawn icon positions, no
* dead reckoning and no cloning. * extra dead reckoning.
* @param {number} [maxCount=500] - Maximum number of entries to return. * @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}>} * @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. * holds no data and therefore cannot answer.
*/ */
hasContact(icao24) { hasContact(icao24) {
if ( const icons = flightState._icons;
!flightState._billboardCollection || if (!icons || !icons.show || icons.count === 0) return null;
!flightState._billboardCollection.show ||
flightState._billboards.size === 0
)
return null;
if (!icao24) return false; if (!icao24) return false;
const id = String(icao24).trim(); const id = String(icao24).trim();
return ( return icons.has(id) || icons.has(id.toLowerCase());
flightState._billboards.has(id) ||
flightState._billboards.has(id.toLowerCase())
);
}, },
getAllPositions(maxCount = 500) { getAllPositions(maxCount = 500) {
if ( const icons = flightState._icons;
!flightState._billboardCollection || if (!icons || icons.count === 0) return [];
flightState._billboards.size === 0
)
return [];
const limit = Number.isFinite(maxCount) const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount)) ? Math.max(1, Math.floor(maxCount))
: 500; : 500;
const tau = parts.icons._displayTau();
const result = []; const result = [];
for (const [icao24, bb] of flightState._billboards) { for (let row = 0; row < icons.count; row++) {
const pos = billboardState(bb).position; const icao24 = icons.idAt(row);
if (!pos) continue; const pos = icons.position(row, tau);
const carto = Cesium.Cartographic.fromCartesian( const carto = Cesium.Cartographic.fromCartesian(
pos, pos,
Cesium.Ellipsoid.WGS84, Cesium.Ellipsoid.WGS84,
@@ -741,11 +722,7 @@ export function createQueries({
* @returns {Array<Object>} See mapAnalystRecord for the record shape. * @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/ */
getAnalystRecords(maxCount = 2000) { getAnalystRecords(maxCount = 2000) {
if ( if (!flightState._icons?.show || flightState.records.data.size === 0)
!flightState._billboardCollection ||
!flightState._billboardCollection.show ||
flightState.records.data.size === 0
)
return []; return [];
const limit = Number.isFinite(maxCount) const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount)) ? Math.max(1, Math.floor(maxCount))
@@ -773,8 +750,8 @@ export function createQueries({
trackById(icao24, { origin = 'programmatic' } = {}) { trackById(icao24, { origin = 'programmatic' } = {}) {
if (!icao24) return false; if (!icao24) return false;
let id = String(icao24).trim(); let id = String(icao24).trim();
if (!flightState._billboards.has(id)) id = id.toLowerCase(); if (!flightState._icons?.has(id)) id = id.toLowerCase();
if (!flightState._billboards.has(id)) return false; if (!flightState._icons?.has(id)) return false;
if (parts.tracking._isExplicitTrackingOrigin(origin)) if (parts.tracking._isExplicitTrackingOrigin(origin))
parts.tracking._cancelPendingTrackingRestore(); parts.tracking._cancelPendingTrackingRestore();
if (flightState._trackedIcao === id) if (flightState._trackedIcao === id)
@@ -844,7 +821,7 @@ export function createQueries({
) )
return false; return false;
let id = String(icao24).trim(); let id = String(icao24).trim();
if (!flightState._billboards.has(id)) id = id.toLowerCase(); if (!flightState._icons?.has(id)) id = id.toLowerCase();
if ( if (
id !== flightState._trackedIcao || id !== flightState._trackedIcao ||
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) || !flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
+31 -20
View File
@@ -3,11 +3,9 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createQueries } from './queries.js'; import { createQueries } from './queries.js';
import { createRendering } from './rendering.js'; import { createRendering } from './rendering.js';
import { import { createIcons } from './icons.js';
billboardState, import { createIconSet } from '../../data/iconSet.js';
mirrorBillboard, import { writeHoldSegment } from '../../data/iconSegment.js';
setBillboard,
} from '../../data/billboardMirror.js';
import { mirrorModel, setModel } from '../../data/modelMirror.js'; import { mirrorModel, setModel } from '../../data/modelMirror.js';
function fixture() { function fixture() {
@@ -16,14 +14,11 @@ function fixture() {
registration: ' N123 ', registration: ' N123 ',
altitude: 1000, altitude: 1000,
}; };
const bb = mirrorBillboard({ const icons = createIconSet();
show: true,
position: new Cesium.Cartesian3(1, 2, 3),
});
const flightState = { const flightState = {
feed: {}, feed: {},
_billboardCollection: { show: true }, _icons: icons,
_billboards: new Map([['abc123', bb]]), _scratchIconRenderTime: new Cesium.JulianDate(),
_models: new Map(), _models: new Map(),
_detectionObjects: new Map(), _detectionObjects: new Map(),
records: { data: new Map([['abc123', info]]) }, records: { data: new Map([['abc123', info]]) },
@@ -48,8 +43,27 @@ function fixture() {
motion: { _trackedVisualCached: () => null }, motion: { _trackedVisualCached: () => null },
}; };
parts.rendering = createRendering({ flightState, services, parts }); parts.rendering = createRendering({ flightState, services, parts });
parts.icons = createIcons({ flightState, services, parts });
const queries = createQueries({ flightState, services, parts }).methods; 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', () => { 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(); const [first] = f.queries.getDetectableObjects();
assert.equal(first.id, 'TEST123'); assert.equal(first.id, 'TEST123');
assert.equal(first.sourceId, 'abc123'); 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(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.deepEqual(first.position, new Cesium.Cartesian3(4, 5, 6));
assert.equal(f.formats(), 1); assert.equal(f.formats(), 1);
f.info.callsign = ' NEW456 '; f.info.callsign = ' NEW456 ';
@@ -107,7 +121,7 @@ test('candidate collection resolves models once and keeps handoffs live', (t) =>
scale: 1, scale: 1,
}); });
f.flightState._models.set('abc123', model); f.flightState._models.set('abc123', model);
setBillboard(f.bb, 'show', false); f.show(false);
assert.deepEqual(f.queries.getDetectableObjects(), []); assert.deepEqual(f.queries.getDetectableObjects(), []);
model.ready = true; model.ready = true;
assert.equal(f.queries.getDetectableObjects()[0], candidate); 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)); assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60));
setModel(model, 'show', false); setModel(model, 'show', false);
assert.deepEqual(f.queries.getDetectableObjects(), []); assert.deepEqual(f.queries.getDetectableObjects(), []);
setBillboard(f.bb, 'show', true); f.show(true);
assert.equal( assert.deepEqual(f.queries.getDetectableObjects()[0].position, f.drawn());
f.queries.getDetectableObjects()[0].position,
billboardState(f.bb).position,
);
assert.equal(get.mock.callCount(), 6); assert.equal(get.mock.callCount(), 6);
setModel(model, 'show', true); setModel(model, 'show', true);
assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true); assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true);
+153 -386
View File
@@ -1,22 +1,14 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js'; import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js'; import { civilAircraftModelSpec } from './modelSpec.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { isEmergency } from '../../data/aircraftEmergency.js'; import { isEmergency } from '../../data/aircraftEmergency.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { horizonOccluder } from '../../data/iconOrientation.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { aircraftTreatmentAlpha } from '../../data/aircraftRecession.js';
import {
cameraPoseSignature,
horizonOccluder,
screenProjectedRotation,
} from '../../data/iconOrientation.js';
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import { import {
MIL_TINT, MIL_TINT,
EMERGENCY_TINT, EMERGENCY_TINT,
GROUND_SCALE, GROUND_SCALE,
COCKPIT_CONTACT_SIZE_PX,
COCKPIT_CIVILIAN_COLOR, COCKPIT_CIVILIAN_COLOR,
MODEL_ALT_CEIL_M, MODEL_ALT_CEIL_M,
MODEL_MAX_ALL, MODEL_MAX_ALL,
@@ -32,14 +24,9 @@ import {
TRACKED_MODEL_MIN_PX, TRACKED_MODEL_MIN_PX,
TRACKED_MODEL_MAX_PX, TRACKED_MODEL_MAX_PX,
FLEET_DR_INTERVAL_MS, FLEET_DR_INTERVAL_MS,
RENDER_DELAY_SEC,
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
} from './policy.js'; } from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
import { aircraftVisuals } from '../aircraft/visualState.js'; import { aircraftVisuals } from '../aircraft/visualState.js';
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
import { modelState, setModel } from '../../data/modelMirror.js'; import { modelState, setModel } from '../../data/modelMirror.js';
export function createRendering({ export function createRendering({
@@ -52,14 +39,8 @@ export function createRendering({
const { isMilitaryIcao } = services.militaryRegistry; const { isMilitaryIcao } = services.militaryRegistry;
const { tr3bIconKind, isTr3b } = services.aircraftPresentation; const { tr3bIconKind, isTr3b } = services.aircraftPresentation;
const { trackedModelScaleForPixelCap } = services.camera; const { trackedModelScaleForPixelCap } = services.camera;
const { const { focusNowMs, getFocusTarget } = services.focus;
focusNowMs, const { applyAircraftModelTreatment } = services.recession;
getFocusTarget,
nearFarScalarValueAtDistance,
advanceProjectedSpriteFocus,
} = services.focus;
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
services.recession;
/** True while this contact's latest poll broadcast an emergency code. */ /** 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) { function _modelSpec(klass) {
const cached = flightState._specCache.get(klass); const cached = flightState._specCache.get(klass);
if (cached) return cached; if (cached) return cached;
@@ -141,53 +106,6 @@ export function createRendering({
const _iconKind = (icao24, klass) => const _iconKind = (icao24, klass) =>
tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); 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. */ /** Model tint, mirroring the billboard color rules. */
function _modelColor(icao24) { function _modelColor(icao24) {
@@ -272,12 +190,12 @@ export function createRendering({
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the /** 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 * 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 * 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 * 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). */ * object itself. Built lazily — only when a sample actually fires (one-shot). */
function _groundSampleExclusions() { function _groundSampleExclusions() {
const out = [...flightState._billboards.keys()]; const out = parts.icons._iconIds();
if (flightState._trackedEntity) out.push(flightState._trackedEntity); if (flightState._trackedEntity) out.push(flightState._trackedEntity);
return out; 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 * 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 * `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. */ * 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) { if (!model) {
setBillboard(bb, 'show', true); icons.setShown(row, true);
return false; return false;
} }
const displayPos = _modelDisplayPosition( const displayPos = _modelDisplayPosition(
@@ -341,7 +267,7 @@ export function createRendering({
); );
if (!displayPos) { if (!displayPos) {
setModel(model, 'show', false); // no ground evidence → nothing safe to place a depth-tested model at 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; return false;
} }
// The matrix is written BEFORE the readiness test on purpose: a model can flip // 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)); setModel(model, 'modelMatrix', _modelMatrix(displayPos, course));
if (!model.ready) { if (!model.ready) {
setModel(model, 'show', false); // not loaded yet → keep the 2D icon, no half-model flash 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; return false;
} }
beforeShow?.(); beforeShow?.();
if (!modelState(model).show) setModel(model, 'show', true); 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; return true;
} }
@@ -443,9 +369,6 @@ export function createRendering({
(current && aircraftVisuals.read(current)?.specKey !== key) || (current && aircraftVisuals.read(current)?.specKey !== key) ||
(!current && flightState._modelPending.has(icao24)) (!current && flightState._modelPending.has(icao24))
) { ) {
const bb = flightState._billboards.get(icao24);
if (bb && icao24 !== flightState._trackedIcao)
setBillboard(bb, 'show', true);
_releaseModel(icao24); _releaseModel(icao24);
} }
if ( if (
@@ -479,9 +402,6 @@ export function createRendering({
aircraftVisuals.read(model)?.irBoost === flightState._irBoost aircraftVisuals.read(model)?.irBoost === flightState._irBoost
) )
continue; // already right state continue; // already right state
const bb = flightState._billboards.get(icao);
if (bb && icao !== flightState._trackedIcao)
setBillboard(bb, 'show', true);
_releaseModel(icao); _releaseModel(icao);
} }
if (flightState._irReloadQueue.length === 0) if (flightState._irReloadQueue.length === 0)
@@ -624,6 +544,7 @@ export function createRendering({
} }
} }
flightState._models.delete(icao24); flightState._models.delete(icao24);
_showReleasedIcon(icao24);
} }
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping // 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 // 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(); 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. */ /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
function _releaseTrackedModel() { function _releaseTrackedModel() {
@@ -709,8 +641,7 @@ export function createRendering({
// readout consumers then reuse that exact cached position. // readout consumers then reuse that exact cached position.
const pos = const pos =
parts.motion._trackedDisplayPosition(flightState._trackedIcao) || parts.motion._trackedDisplayPosition(flightState._trackedIcao) ||
billboardState(flightState._billboards.get(flightState._trackedIcao)) parts.icons._iconPosition(flightState._trackedIcao);
?.position;
if (!pos) { if (!pos) {
if (flightState._trackedModel) if (flightState._trackedModel)
setModel(flightState._trackedModel, 'show', false); setModel(flightState._trackedModel, 'show', false);
@@ -789,9 +720,7 @@ export function createRendering({
// default here produces a one-frame jump to the Earth's center. // default here produces a one-frame jump to the Earth's center.
const currentPos = const currentPos =
parts.motion._trackedDisplayCached() || parts.motion._trackedDisplayCached() ||
billboardState( parts.icons._iconPosition(flightState._trackedIcao);
flightState._billboards.get(flightState._trackedIcao),
)?.position;
if (currentPos) { if (currentPos) {
const displayPos = _modelDisplayPosition( const displayPos = _modelDisplayPosition(
flightState._trackedIcao, 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() { function _fleetTick() {
if ( const icons = flightState._icons;
!flightState._viewer || if (!flightState._viewer || !icons || !icons.show) return;
!flightState._billboardCollection ||
!flightState._billboardCollection.show
)
return;
const scene = flightState._viewer.scene; const scene = flightState._viewer.scene;
const camera = flightState._viewer.camera; const camera = flightState._viewer.camera;
const nowMs = focusNowMs(Date.now()); 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; 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; flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant. // Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = parts.icons._displayTime(
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime, flightState._scratchFleetRenderTime,
); );
const tau = icons.seconds(renderTime);
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
if (flightState._cockpitContactMode) if (flightState._cockpitContactMode)
parts.tracking._refreshCockpitNearContacts(); parts.tracking._refreshCockpitNearContacts();
const poseSig = cameraPoseSignature(camera); parts.icons._replanExpired(tau);
// Only the nearby Cockpit silhouettes need projected course; far dots are parts.icons._floorGroundedIcons(renderTime, tau, nowMs, camera.positionWC);
// 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);
const focusTarget = getFocusTarget(); 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. // 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(); const useModels = _modelRegimeActive();
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that // 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). // 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) (flightState._models.size || flightState._modelPending.size)
) )
_releaseModels(); _releaseModels();
if (!useModels) return;
// 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the // 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 // 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 // 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 // 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. // filtering dropped near off-screen planes entirely. This does both right.
let modelEligible = null; const cap = _modelCap();
if (useModels) { const camPos = camera.positionWC;
const cap = _modelCap(); const addM = _modelAddDistM();
const camPos = camera.positionWC; const addDistSq = addM * addM;
const addM = _modelAddDistM(); const keepM = _modelKeepDistM();
const addDistSq = addM * addM; const keepDistSq = keepM * keepM;
const keepM = _modelKeepDistM(); const cull = camera.frustum.computeCullingVolume(
const keepDistSq = keepM * keepM; camPos,
const cull = camera.frustum.computeCullingVolume( camera.directionWC,
camPos, camera.upWC,
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 d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
const cand = []; if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
for (const [icao, bb] of flightState._billboards) { cand.push([
if (icao === flightState._trackedIcao) continue; icao,
// A converted TR-3B renders as a billboard and can never take a model, so d2,
// it must not occupy a CAP SLOT either — excluded here at selection time, cull.computeVisibility(flightState._scratchModelBS) !==
// not just at the handoff below, or accumulated conversions would starve Cesium.Intersect.OUTSIDE,
// 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);
} }
cand.sort((a, b) => a[1] - b[1]); // nearest first
for (const [icao24, bb] of flightState._billboards) { if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion console.warn(
const icon = billboardState(bb); `[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`,
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(
icao24,
flightState._scratchFleetPos,
renderTime,
); );
// The dead-reckoned point drifts away from the fix whose cell supplied the flightState._lastModelCapWarnMs = nowMs;
// 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 const modelEligible = selectModelEligible(cand, {
// never while a 3D model owns the visual (T7). cap,
const display = parts.motion._floorGroundedDisplayPosition( addDistSq,
icao24, isModeled: (icao) => flightState._models.has(icao),
info, });
dr, const toRelease = [];
_modelOwnsVisual(icao24), for (const icao of flightState._models.keys()) {
nowMs, if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
); toRelease.push(icao);
// Gate the write — assigning Billboard.position dirties the whole }
// collection's vertex buffer, so skip sub-meter moves. for (const icao of toRelease) _releaseModel(icao);
if (
display &&
Cesium.Cartesian3.distanceSquared(display, icon.position) > 1.0
) {
setBillboard(bb, 'position', display);
}
// Round 6: occlusion-test a LIFTED point for contacts rendering below // 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown
// (or within a wingspan of) the ellipsoid — EllipsoidalOccluder judges a // until the model is actually READY to render, so a plane is never both iconless AND
// sub-ellipsoid point near the limb "beyond the horizon" and the fleet // modelless (the "planes vanish when 3D turns on" bug). Position the model every tick
// pass would hide a plane that is really just low over high-N terrain // regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on
// waiting for its floor to warm (ATL grounded contacts at geoid −31 m). // purpose: the Easter egg IS the triangle, and there is no GLB for it.
const beyondHorizon = !occluder.isPointVisible( const occluder = horizonOccluder(camera);
flightState._cullPositions.get(icao24) || icon.position, for (const icao24 of modelEligible) {
); if (isTr3b(icao24)) continue;
// A billboard flipping INTO view (horizon reveal while the camera idles) const row = icons.row(icao24);
// gets its rotation refreshed THIS tick even without a pose change — if (row < 0) continue;
// otherwise it reappears wearing its stale (often creation-north) nose for const position = icons.position(row, tau, flightState._scratchFleetPos);
// up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false if (!parts.icons._aboveHorizon(row, position, occluder)) {
// here; harmless — the model branch below `continue`s past the rotation.) // A model that crossed the limb would keep rendering through the
const revealed = !beyondHorizon && !icon.show; // hidden globe at its last matrix; the icon's own cull is on the GPU.
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.
const m = flightState._models.get(icao24); const m = flightState._models.get(icao24);
if (m && modelState(m)?.show) setModel(m, 'show', false); if (m && modelState(m)?.show) setModel(m, 'show', false);
continue; continue;
} }
_ensureModel(icao24);
// One order-independent write site composes freshness × focus × limb haze const model = flightState._models.get(icao24);
// for alpha, and base class/ground scale × limb taper for scale. Cesium's const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position);
// locked NearFarScalar remains a separate multiplicative stage. This _driveFleetModelHandoff(
// narrowly amends always-visible rendering without count culling or zero icao24,
// alpha; nearer focus behavior remains tunable rather than universal. model,
const cameraDistanceM = Cesium.Cartesian3.distance( row,
camera.positionWC, position,
icon.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 { return {
_fleetBillboardColor, _fleetBillboardColor,
_fleetBillboardScale, _fleetBillboardScale,
_groundDepthDistance, _cockpitContactColor,
_modelSpec, _modelSpec,
_normalBillboardScaleByDistance, _normalBillboardScaleByDistance,
_cockpitBillboardScaleByDistance, _cockpitBillboardScaleByDistance,
_iconKind, _iconKind,
_applyFleetBillboardPresentation,
_modelColor, _modelColor,
_modelRegimeActive, _modelRegimeActive,
_modelCap, _modelCap,
+28 -99
View File
@@ -1,13 +1,10 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { import {
turnRateFromFixHistory, turnRateFromFixHistory,
liftRepeatedGroundFix, liftRepeatedGroundFix,
synthesizeForwardKinematicsFix, synthesizeForwardKinematicsFix,
} from '../../data/motionModel.js'; } from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
import { addBillboard } from '../../data/billboardMirror.js';
/** Apply reconciled aircraft records to Cesium resources and follow state. */ /** Apply reconciled aircraft records to Cesium resources and follow state. */
export function createFlightSnapshotRenderer({ export function createFlightSnapshotRenderer({
@@ -19,6 +16,7 @@ export function createFlightSnapshotRenderer({
rendering, rendering,
tracking, tracking,
motion, motion,
icons,
enrichment, enrichment,
queries, queries,
}) { }) {
@@ -53,17 +51,8 @@ export function createFlightSnapshotRenderer({
// which hands off on untrack). // which hands off on untrack).
const isMil = isMilitaryIcao(icao24); const isMil = isMilitaryIcao(icao24);
if (isMil && tracking._militaryLayerSuppresses(icao24)) { if (isMil && tracking._militaryLayerSuppresses(icao24)) {
const dupe = flightState._billboards.get(icao24); // Military-suppression: drop the duplicate with any 3D model too.
if (dupe) { if (flightState._icons.has(icao24)) icons._forgetContact(icao24);
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);
}
continue; continue;
} }
@@ -84,12 +73,12 @@ export function createFlightSnapshotRenderer({
); );
// Either flip retires the previous one-shot ground sample. // Either flip retires the previous one-shot ground sample.
if (groundFlipped) flightState._groundSnap.forget(icao24); if (groundFlipped) flightState._groundSnap.forget(icao24);
flightState._cullPositions.set( if (meta.onGround) {
icao24, flightState._grounded.add(icao24);
meta.renderAltitudeM < 10 } else if (flightState._grounded.delete(icao24)) {
? Cesium.Cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12) flightState._floorPinned.delete(icao24);
: null, motion._retireDisplayFloorState(icao24, Date.now());
); }
const isTracked = icao24 === flightState._trackedIcao; const isTracked = icao24 === flightState._trackedIcao;
// Append to position history stamped with the FEED's fix epoch // Append to position history stamped with the FEED's fix epoch
@@ -153,72 +142,18 @@ export function createFlightSnapshotRenderer({
} }
} }
if (flightState._billboards.has(icao24)) { if (flightState._icons.has(icao24)) {
const bb = flightState._billboards.get(icao24); // Reclassification, a ground flip (a restyle, never a removal), a
// Reclassify if the category resolved/changed (first extended poll); // freshness change and the cockpit contract all land on the row's
// a ground flip (landing/takeoff) re-scales the SAME billboard in // style; motion re-plans from the updated history.
// place — the transition is a restyle, never a removal. icons._applyIconPresentation(icao24);
// Round 5: depth policy is uniform (always depth-test-free, see icons._planIcon(icao24);
// _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);
}
// Poll-path class change (category updates): same model resync rule // Poll-path class change (category updates): same model resync rule
// as the enrichment path — the class's GLB/scale may have changed. // as the enrichment path — the class's GLB/scale may have changed.
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
} else { } else {
// Start where the fleet pass will draw it: the fleet renders behind // Hidden while tracked: the tracked entity replaces it.
// real time, so a fresh contact's displayed position is its fix icons._addIcon(icao24);
// 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);
} }
// Takeoff while TRACKED: ground traffic drew no trail, so start one // 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 // EXCEPTION: likely-landed planes (last fix low + slow) get only
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
// feed gap, and the full grace left phantom planes parked at airports. // feed gap, and the full grace left phantom planes parked at airports.
for (const [icao24, bb] of flightState._billboards) { const absentIds = [];
if (currentIcaos.has(icao24)) continue; 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, { const absence = records.absence(icao24, {
complete: snapshot.complete, complete: snapshot.complete,
retainIds: snapshot.retainIds, retainIds: snapshot.retainIds,
@@ -249,8 +188,9 @@ export function createFlightSnapshotRenderer({
}); });
if (absence === 'retain') continue; if (absence === 'retain') continue;
if (absence === 'stale') { if (absence === 'stale') {
icons._applyIconPresentation(icao24); // the faded freshness alpha
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) { 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 // entity owns the visual), so refresh the label — with icao24 now
// in _missingPolls, _trackedLabelText appends the STALE cue so // in _missingPolls, _trackedLabelText appends the STALE cue so
// last-known velocity/altitude aren't presented as live. // last-known velocity/altitude aren't presented as live.
@@ -269,20 +209,9 @@ export function createFlightSnapshotRenderer({
tracking._clearTracking(false, { evicted: true }); tracking._clearTracking(false, { evicted: true });
} }
flightState._billboardCollection.remove(bb); icons._forgetContact(icao24); // aged out: its 3D model goes too (no orphan / cap leak)
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);
} }
// Fresh courses arrived — force a rotation pass on the next fleet tick
flightState._lastCamPoseSig = '';
// Ambient type enrichment: give ON-SCREEN planes real types (bounded // Ambient type enrichment: give ON-SCREEN planes real types (bounded
// sweep — see _sweepAmbientEnrichment; internally fail-silent). // sweep — see _sweepAmbientEnrichment; internally fail-silent).
enrichment._sweepAmbientEnrichment(); enrichment._sweepAmbientEnrichment();
@@ -307,7 +236,7 @@ export function createFlightSnapshotRenderer({
// aircraft instead of the pavement. // aircraft instead of the pavement.
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
excludeObjects: [ excludeObjects: [
...flightState._billboards.values(), ...icons._iconIds(),
...flightState._models.values(), ...flightState._models.values(),
flightState._trackedModel, flightState._trackedModel,
].filter(Boolean), ].filter(Boolean),
@@ -327,6 +256,6 @@ export function createFlightSnapshotRenderer({
// in place is safe (the DR extrapolates a zero-velocity fix). // in place is safe (the DR extrapolates a zero-velocity fix).
motion._refloorStaleGroundedContacts(currentIcaos); motion._refloorStaleGroundedContacts(currentIcaos);
return { count: flightState._billboards.size, ids: acceptedSnapshotIcaos }; return { count: flightState._icons.count, ids: acceptedSnapshotIcaos };
}; };
} }
+26 -16
View File
@@ -11,7 +11,6 @@ export function createFlightState({ source, services }) {
...services.geoid, ...services.geoid,
...services.groundFloor, ...services.groundFloor,
}); });
flightState._cullPositions = new Map();
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs /** 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 * vertex-baked to real-world METERS in the airplane.glb axis convention, so
@@ -91,22 +90,37 @@ export function createFlightState({ source, services }) {
1.0, 1.0,
); );
// frustum-visibility test // frustum-visibility test
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
flightState._billboardLimbScale = new WeakMap();
// keep last N positions per aircraft // keep last N positions per aircraft
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Module-level state: billboard collection and per-aircraft lookup maps // 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<string, Cesium.Billboard>} 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. */ /** Stable lightweight records reused by the detection overlay between polls. */
@@ -204,14 +218,6 @@ export function createFlightState({ source, services }) {
flightState._lastFleetTickMs = 0; 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) */ /** @type {number} Last computed rotation for the tracked entity (radians) */
flightState._lastTrackedRotation = 0; flightState._lastTrackedRotation = 0;
@@ -250,6 +256,10 @@ export function createFlightState({ source, services }) {
flightState._scratchFleetPos = new Cesium.Cartesian3(); flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchCockpitPos = new Cesium.Cartesian3();
flightState._scratchNearbyPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2(); flightState._scratchSonarScreen = new Cesium.Cartesian2();
flightState._scratchDrRaw = new Cesium.Cartesian3(); flightState._scratchDrRaw = new Cesium.Cartesian3();
+69 -10
View File
@@ -1,7 +1,8 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { CYAN_TRANSPARENT } from './policy.js'; import { CYAN_TRANSPARENT } from './policy.js';
import { mirrorBillboard } from '../../data/billboardMirror.js';
import { mirrorModel } from '../../data/modelMirror.js'; import { mirrorModel } from '../../data/modelMirror.js';
import { createIconSet } from '../../data/iconSet.js';
import { writeHoldSegment } from '../../data/iconSegment.js';
export function createTesting({ export function createTesting({
flightState, flightState,
@@ -38,6 +39,19 @@ export function createTesting({
flightState._displayFloorState.clear(); 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 * Seed only the mutable state needed to exercise tracked-card refreshes through
* the production poll reconciler. Tests still call `flightsLayer.update()`; * the production poll reconciler. Tests still call `flightsLayer.update()`;
@@ -46,13 +60,14 @@ export function createTesting({
* @param {string} state.icao24 * @param {string} state.icao24
* @param {object} state.entity * @param {object} state.entity
* @param {object} state.meta * @param {object} state.meta
* @param {object} state.billboard * @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.billboard]
* @param {object} state.billboardCollection * Where the contact's icon is held, and whether it is shown.
* @param {{show?: boolean}} [state.billboardCollection] Layer visibility.
* @param {object} state.viewer * @param {object} state.viewer
* @param {Array<Object>} [state.history=[]] * @param {Array<Object>} [state.history=[]]
* @param {boolean} [state.tracked=true] * @param {boolean} [state.tracked=true]
* @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed * @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({ function _setTrackedFlightRefreshStateForTest({
@@ -69,9 +84,13 @@ export function createTesting({
}) { }) {
flightState._viewer = viewer; flightState._viewer = viewer;
flightState._modelCollection = modelCollection; flightState._modelCollection = modelCollection;
flightState._billboardCollection = billboardCollection; flightState._icons = createIconSet({
flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]); epoch: parts.icons._displayTime(new Cesium.JulianDate()),
flightState._cullPositions.clear(); });
flightState._icons.show = billboardCollection?.show ?? true;
flightState._grounded.clear();
flightState._floorPinned.clear();
_seedIconForTest(icao24, billboard, tracked);
flightState._models.clear(); flightState._models.clear();
for (const [key, model] of models) for (const [key, model] of models)
flightState._models.set(key, mirrorModel(model)); flightState._models.set(key, mirrorModel(model));
@@ -120,7 +139,7 @@ export function createTesting({
billboard, billboard,
history = [], history = [],
}) { }) {
flightState._billboards.set(icao24, mirrorBillboard(billboard)); _seedIconForTest(icao24, billboard, false);
flightState.records.data.set(icao24, meta); flightState.records.data.set(icao24, meta);
flightState._positionHistory.set(icao24, history); flightState._positionHistory.set(icao24, history);
} }
@@ -181,25 +200,64 @@ export function createTesting({
: Cesium.Color.CYAN; : 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 }) { function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) {
return parts.rendering._driveFleetModelHandoff( return parts.rendering._driveFleetModelHandoff(
icao24, icao24,
flightState._models.get(icao24), flightState._models.get(icao24),
flightState._billboards.get(icao24), flightState._icons.row(icao24),
position, position,
course, 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. */ /** Exercise the exact asynchronous fleet loader and return its admitted model. */
async function _ensureFleetModelForTest(icao24) { async function _ensureFleetModelForTest(icao24) {
await parts.rendering._ensureModel(icao24); await parts.rendering._ensureModel(icao24);
return flightState._models.get(icao24) || null; 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 { return {
_floorGroundedIconsForTest,
_setIconForTest,
_floorGroundedDisplayPositionForTest, _floorGroundedDisplayPositionForTest,
_clearDisplayFloorStateForTest, _clearDisplayFloorStateForTest,
_setTrackedFlightRefreshStateForTest, _setTrackedFlightRefreshStateForTest,
@@ -214,6 +272,7 @@ export function createTesting({
_updateTrackedModelForTest, _updateTrackedModelForTest,
_trackedBillboardColorForTest, _trackedBillboardColorForTest,
_driveFleetModelHandoffForTest, _driveFleetModelHandoffForTest,
_iconForTest,
_ensureFleetModelForTest, _ensureFleetModelForTest,
}; };
} }
+75 -101
View File
@@ -27,7 +27,6 @@ import {
} from './policy.js'; } from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
export function createTracking({ export function createTracking({
flightState, flightState,
@@ -65,9 +64,9 @@ export function createTracking({
} }
function _publishTrackedSelection(icao24, origin = 'programmatic') { function _publishTrackedSelection(icao24, origin = 'programmatic') {
const bb = flightState._billboards.get(icao24); const position = parts.icons._iconPosition(icao24);
const info = flightState.records.data.get(icao24); const info = flightState.records.data.get(icao24);
if (!billboardState(bb)?.position || !info) return false; if (!position || !info) return false;
if (flightState._trackedEntity) if (flightState._trackedEntity)
entityRecord(flightState._trackedEntity).selectionOrigin = origin; entityRecord(flightState._trackedEntity).selectionOrigin = origin;
_emitAwarenessEvent('gev:awareness-subject-selected', { _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 // a callsign-less contact reached Context as its raw hex even once adsbdb
// had supplied a registration. Identity below stays `icao24`. // had supplied a registration. Identity below stays `icao24`.
label: parts.queries._contactLabel(icao24, info), label: parts.queries._contactLabel(icao24, info),
position: Cesium.Cartesian3.clone(billboardState(bb).position), position,
origin, origin,
}); });
selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
@@ -163,9 +162,12 @@ export function createTracking({
} }
const previous = flightState._cockpitNearContacts; const previous = flightState._cockpitNearContacts;
const distancesSquared = []; const distancesSquared = [];
for (const [icao24, bb] of flightState._billboards) { const icons = flightState._icons;
const position = billboardState(bb)?.position; const tau = parts.icons._displayTau();
if (icao24 === flightState._trackedIcao || !position) continue; 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([ distancesSquared.push([
icao24, icao24,
Cesium.Cartesian3.distanceSquared( Cesium.Cartesian3.distanceSquared(
@@ -181,13 +183,10 @@ export function createTracking({
parts.rendering._modelKeepDistM(), parts.rendering._modelKeepDistM(),
); );
flightState._cockpitNearContacts = next; flightState._cockpitNearContacts = next;
let presentationChanged = false; for (const icao24 of previous)
for (const [icao24, bb] of flightState._billboards) { if (!next.has(icao24)) parts.icons._applyIconPresentation(icao24);
if (previous.has(icao24) === next.has(icao24)) continue; for (const icao24 of next)
parts.rendering._applyFleetBillboardPresentation(icao24, bb); if (!previous.has(icao24)) parts.icons._applyIconPresentation(icao24);
presentationChanged = true;
}
if (presentationChanged) flightState._lastCamPoseSig = '';
} }
/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */ /** 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; if (flightState._modelCollection) flightState._modelCollection.show = true;
flightState._trail?.setVisible(!next); flightState._trail?.setVisible(!next);
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
for (const [icao24, bb] of flightState._billboards) const icons = flightState._icons;
parts.rendering._applyFleetBillboardPresentation(icao24, bb); for (let row = 0; row < (icons?.count ?? 0); row++)
flightState._lastCamPoseSig = ''; parts.icons._applyIconPresentation(icons.idAt(row));
flightState._lastFleetTickMs = 0; flightState._lastFleetTickMs = 0;
} }
@@ -319,8 +318,7 @@ export function createTracking({
if (!flightState._trackedIcao || !flightState._viewer) return; if (!flightState._trackedIcao || !flightState._viewer) return;
const pos = const pos =
parts.motion._trackedDisplayCached() || parts.motion._trackedDisplayCached() ||
billboardState(flightState._billboards.get(flightState._trackedIcao)) parts.icons._iconPosition(flightState._trackedIcao);
?.position;
if (!pos) return; if (!pos) return;
const projected = screenProjectedRotation( const projected = screenProjectedRotation(
flightState._viewer.scene, flightState._viewer.scene,
@@ -639,30 +637,9 @@ export function createTracking({
const clearedIcao = flightState._trackedIcao; const clearedIcao = flightState._trackedIcao;
clearFocusTarget('flights', clearedIcao); clearFocusTarget('flights', clearedIcao);
// Restore the original billboard appearance. The rotation is re-seeded from // The nose the icon continues from (the shared _displayCourse entry), so
// the tracked entity's last rendered rotation and a rotation pass is forced // release never flips it.
// (_lastCamPoseSig below): the fleet billboard otherwise reappears with the const trackedCourseDeg = flightState._displayCourse.get(clearedIcao);
// 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
// Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the // 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 // 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(); parts.rendering._releaseTrackedModel();
_resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped _resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped
flightState._trackedIcao = null; flightState._trackedIcao = null;
parts.rendering._applyFleetBillboardPresentation( // Hand the contact back to its icon only now: settling floors a grounded
clearedIcao, // contact, and while it still read as tracked its tracked model owned the
flightState._billboards.get(clearedIcao), // 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'); clearTrackedSubjectContext('flights');
_emitAwarenessEvent('gev:awareness-subject-cleared', { _emitAwarenessEvent('gev:awareness-subject-cleared', {
layerId: 'flights', layerId: 'flights',
@@ -730,10 +711,7 @@ export function createTracking({
pending.generation !== flightState._trackingIntentGeneration pending.generation !== flightState._trackingIntentGeneration
) )
return false; return false;
if ( if (!flightState._icons?.show || !flightState._icons.has(pending.id))
!flightState._billboardCollection?.show ||
!flightState._billboards.has(pending.id)
)
return false; return false;
flightState._pendingTrackingRestore = null; flightState._pendingTrackingRestore = null;
_trackFlight(pending.id, { origin: pending.origin }); _trackFlight(pending.id, { origin: pending.origin });
@@ -837,42 +815,42 @@ export function createTracking({
// an already-loaded model would otherwise linger with the billboard hidden. // an already-loaded model would otherwise linger with the billboard hidden.
if (flightState._models.has(id) || flightState._modelPending.has(id)) if (flightState._models.has(id) || flightState._modelPending.has(id))
parts.rendering._releaseModel(id); parts.rendering._releaseModel(id);
const bb = flightState._billboards.get(id); const row = flightState._icons?.row(id) ?? -1;
if (bb && id !== flightState._trackedIcao) setBillboard(bb, 'show', true); // horizon cull re-asserts next tick if (row >= 0 && id !== flightState._trackedIcao)
flightState._icons.setShown(row, true);
if (id === flightState._trackedIcao) { if (id === flightState._trackedIcao) {
parts.rendering._releaseTrackedModel(); parts.rendering._releaseTrackedModel();
_syncTracked2dRotation(); _syncTracked2dRotation();
} }
} }
const bb = flightState._billboards.get(id); parts.icons._applyIconPresentation(id);
if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb);
if (id === flightState._trackedIcao) { if (id === flightState._trackedIcao) {
_syncTrackedBillboardImage(); _syncTrackedBillboardImage();
_updateTrackedLabelModel(id); _updateTrackedLabelModel(id);
} }
flightState._viewer?.scene?.requestRender?.(); 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). */ /** Re-image every converted contact this layer owns (IR style flip). */
function _refreshTr3bForStyle() { function _refreshTr3bForStyle() {
for (const id of tr3bConvertedIds()) { for (const id of tr3bConvertedIds()) {
if (flightState._billboards.has(id) || id === flightState._trackedIcao) if (flightState._icons?.has(id) || id === flightState._trackedIcao)
_refreshTr3bContact(id); _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 * 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). */ * cache). Missing data → true (never hide what we can't judge). */
function _routeIsPlausible(icao24, route) { function _routeIsPlausible(icao24, route) {
const info = flightState.records.data.get(icao24); const info = flightState.records.data.get(icao24);
const bb = flightState._billboards.get(icao24); const position = parts.icons._iconPosition(icao24);
if (!info || !bb || !billboardState(bb).position) return true; if (!info || !position) return true;
const carto = Cesium.Cartographic.fromCartesian( const carto = Cesium.Cartographic.fromCartesian(
billboardState(bb).position, position,
Cesium.Ellipsoid.WGS84, Cesium.Ellipsoid.WGS84,
flightState._scratchCarto, flightState._scratchCarto,
); );
@@ -907,29 +885,42 @@ export function createTracking({
function _trackFlight(icao24, { origin = 'programmatic' } = {}) { function _trackFlight(icao24, { origin = 'programmatic' } = {}) {
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over _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); 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; flightState._trackedIcao = icao24;
_resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget
flightState._cachedDRFrame = -1; 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 // 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 // own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover
// fleet model would be orphaned + double-rendered. // fleet model would be orphaned + double-rendered.
parts.rendering._releaseModel(icao24); parts.rendering._releaseModel(icao24);
// Hide the billboard — the tracked entity replaces it visually // Hide the icon — the tracked entity replaces it visually
setBillboard(bb, 'show', false); icons.setShown(row, false);
// Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition —
// one computation shared by the position/alignedAxis/rotation/trail-head callbacks, // 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. // the aircraft has no fix.
const getTrackedPosition = () => const getTrackedPosition = () =>
parts.motion._trackedDisplayPosition(icao24) || parts.motion._trackedDisplayPosition(icao24) ||
billboardState(bb).position; parts.icons._iconPosition(icao24) ||
iconPosition;
// Dead-reckoning position property — smooth continuous motion between API updates. // Dead-reckoning position property — smooth continuous motion between API updates.
const positionProperty = new Cesium.CallbackProperty(() => { const positionProperty = new Cesium.CallbackProperty(() => {
@@ -1106,23 +1097,19 @@ export function createTracking({
*/ */
function _onMilitaryActiveChange(active) { function _onMilitaryActiveChange(active) {
if (!flightState._viewer || !flightState._billboardCollection) return; const icons = flightState._icons;
if (!flightState._viewer || !icons) return;
if (active) { if (active) {
for (const [icao24, bb] of flightState._billboards) { const suppressed = [];
if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao) for (let row = 0; row < icons.count; row++) {
continue; const icao24 = icons.idAt(row);
flightState._billboardCollection.remove(bb); if (isMilitaryIcao(icao24) && icao24 !== flightState._trackedIcao)
flightState._billboards.delete(icao24); suppressed.push(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);
} }
flightState.feed._count = flightState._billboards.size; // Military-suppression: drop each duplicate with any 3D model too.
} else if (flightState._billboardCollection.show) { 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. // Fire-and-forget refresh; only while the layer is actually enabled.
void flightsLayer.update(flightState._viewer); void flightsLayer.update(flightState._viewer);
} }
@@ -1212,25 +1199,12 @@ export function createTracking({
return; return;
} }
// For BillboardCollection picks, the billboard may be at picked.primitive or picked.id // Icon picks carry the icao on picked.id; models carry it on the primitive.
const billboard = picked.primitive; const pickedIcao =
if ( typeof picked.id === 'string' ? picked.id : picked.primitive?.id;
billboard && if (pickedIcao && flightState._icons?.has(pickedIcao)) {
billboard.id &&
flightState._billboards.has(billboard.id)
) {
_cancelPendingTrackingRestore(); _cancelPendingTrackingRestore();
_trackFlight(billboard.id, { origin: 'user' }); _trackFlight(pickedIcao, { 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' });
return; return;
} }
} }
+1 -1
View File
@@ -702,7 +702,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.doesNotMatch(detection, /sonarActive \? 1 : 0/); assert.doesNotMatch(detection, /sonarActive \? 1 : 0/);
assert.match( assert.match(
civilRendering, civilRendering,
/alpha: flightState\._irBoost \? 1 : treatment\.alpha/, /alpha: flightState\._irBoost\s*\?\s*1\s*:\s*aircraftTreatmentAlpha\(/,
); );
assert.match( assert.match(
militaryRendering, militaryRendering,