mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
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:
1 parent
5f36eb0e0d
commit
2c4c66c710
33 files changed
+1598
-986
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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\)\);/);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;/,
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) ||
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user