mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Draw military flights from the shared GPU icon set
The flights icon planning and presentation move into `createAircraftIcons` (src/data/aircraftIconLayer.js), and the motion regime both layers dead-reckon from into src/data/aircraftMotionRegime.js. Flights keeps only its grounded-floor pass on top. Military now draws every contact as a row of that icon set, like flights: the billboards, the cull positions and the rotation-pass signature go. Tracking hides the row and untracking re-plans it from the tracked course. Queries read the drawn row position and judge the horizon as the shader does. Snapshots that arrive before init or after destroy are ignored, and per-contact lookups find no contact then instead of throwing. With about 120 live military contacts, preRender p95 drops from 0.7-0.8 ms to 0.2 ms on the same views. Render time and frame gaps are unchanged or slightly lower.
This commit is contained in:
1 parent
2c4c66c710
commit
9fde0a2a59
33 files changed
+1256
-1376
No files matched your search
@@ -1316,6 +1316,8 @@
|
||||
"src/layers/flights/queries.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/icons.js",
|
||||
"src/data/aircraftIconLayer.js",
|
||||
"src/data/aircraftMotionRegime.js",
|
||||
"src/data/iconSetPrimitive.js",
|
||||
"src/data/iconSet.js",
|
||||
"src/data/iconSegment.js",
|
||||
@@ -1383,6 +1385,14 @@
|
||||
"src/layers/military/policy.js",
|
||||
"src/layers/military/queries.js",
|
||||
"src/layers/military/rendering.js",
|
||||
"src/layers/military/icons.js",
|
||||
"src/data/aircraftIconLayer.js",
|
||||
"src/data/aircraftMotionRegime.js",
|
||||
"src/data/iconSetPrimitive.js",
|
||||
"src/data/iconSet.js",
|
||||
"src/data/iconSegment.js",
|
||||
"src/data/iconAtlas.js",
|
||||
"src/data/aircraftRecession.js",
|
||||
"src/data/modelMirror.js",
|
||||
"src/data/billboardMirror.js",
|
||||
"src/layers/aircraft/visualState.js",
|
||||
@@ -2256,6 +2266,8 @@
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/flights/icons.js",
|
||||
"src/data/aircraftIconLayer.js",
|
||||
"src/data/aircraftMotionRegime.js",
|
||||
"src/sources/adsbFields.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
@@ -2604,6 +2616,7 @@
|
||||
"src/layers/military/policy.js",
|
||||
"src/layers/military/queries.js",
|
||||
"src/layers/military/rendering.js",
|
||||
"src/layers/military/icons.js",
|
||||
"src/layers/military/state.js",
|
||||
"src/layers/military/testing.js",
|
||||
"src/layers/military/tracking.js",
|
||||
@@ -3256,6 +3269,8 @@
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/layers/flights/icons.js",
|
||||
"src/data/aircraftIconLayer.js",
|
||||
"src/data/aircraftMotionRegime.js",
|
||||
"src/data/iconSetPrimitive.js",
|
||||
"src/data/iconSet.js",
|
||||
"src/data/iconSegment.js",
|
||||
@@ -3521,6 +3536,7 @@
|
||||
"src/layers/military/policy.js",
|
||||
"src/layers/military/queries.js",
|
||||
"src/layers/military/rendering.js",
|
||||
"src/layers/military/icons.js",
|
||||
"src/layers/military/state.js",
|
||||
"src/layers/military/testing.js",
|
||||
"src/layers/military/tracking.js",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
|
||||
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
||||
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
||||
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
||||
import { createIcons as createMilitaryIcons } from '../layers/military/icons.js';
|
||||
import { createIconSet } from './iconSet.js';
|
||||
import {
|
||||
aircraftMotionRegime,
|
||||
createMotionRegime,
|
||||
} from './aircraftMotionRegime.js';
|
||||
import { aircraftEmergency } from './aircraftEmergency.js';
|
||||
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
||||
// and civil/military billboard, Cockpit and model tint regressions.
|
||||
@@ -462,18 +468,16 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
|
||||
}
|
||||
});
|
||||
|
||||
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
|
||||
test('GERMAN: an existing untracked military icon repaints each poll without losing its fade', () => {
|
||||
const { flightState, rendering } = renderHarness(militaryRendering);
|
||||
const bb = {
|
||||
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
|
||||
};
|
||||
Object.assign(flightState, {
|
||||
_billboards: new Map([['abc123', bb]]),
|
||||
_icons: createIconSet(),
|
||||
_models: new Map(),
|
||||
_positionHistory: new Map(),
|
||||
_cullPositions: new Map(),
|
||||
_displayCourse: new Map(),
|
||||
_groundSnap: { forget() {} },
|
||||
_cockpitContactMode: false,
|
||||
_cockpitNearContacts: new Set(),
|
||||
});
|
||||
const records = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
@@ -481,18 +485,42 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
|
||||
floorAltitudeM: (value) => value,
|
||||
});
|
||||
flightState.records = records;
|
||||
const parts = {
|
||||
rendering: {
|
||||
...rendering,
|
||||
_iconKind: () => 'jet',
|
||||
_syncModelToClass() {},
|
||||
},
|
||||
motion: {
|
||||
_motionRegime: (history, info, renderTime) =>
|
||||
aircraftMotionRegime(
|
||||
history,
|
||||
info,
|
||||
renderTime,
|
||||
{ speed: 'speedMps', track: 'track' },
|
||||
createMotionRegime(),
|
||||
),
|
||||
},
|
||||
};
|
||||
const icons = createMilitaryIcons({
|
||||
flightState,
|
||||
services: { focus: { focusPassIsNeeded: () => false } },
|
||||
parts,
|
||||
});
|
||||
const row = flightState._icons.add('abc123', {
|
||||
layer: 'military',
|
||||
id: 'abc123',
|
||||
});
|
||||
// The focus fade the fleet tick composes lives in its own column.
|
||||
flightState._icons.setFocus(row, 0.65);
|
||||
const apply = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records,
|
||||
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
||||
meshFloor: { sampleMeshFloorCells() {} },
|
||||
militaryRegistry: { registerMilitaryIcaos() {} },
|
||||
rendering: {
|
||||
...rendering,
|
||||
_syncModelToClass() {},
|
||||
_applyFleetBillboardPresentation() {},
|
||||
},
|
||||
motion: {},
|
||||
rendering: parts.rendering,
|
||||
icons,
|
||||
tracking: {},
|
||||
queries: {},
|
||||
});
|
||||
@@ -518,7 +546,7 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
|
||||
{ observedAtMs: NOW_MS },
|
||||
);
|
||||
apply(snapshot, {});
|
||||
assert.equal(bb.color.green < 0.5, red);
|
||||
assert.equal(bb.color.alpha, 0.65);
|
||||
assert.equal(flightState._icons.color(row).green < 0.5, red);
|
||||
assert.ok(Math.abs(flightState._icons.focus(row) - 0.65) < 1e-6);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
courseSlewCapDps,
|
||||
norm180,
|
||||
} from './motionModel.js';
|
||||
import {
|
||||
createIconSet,
|
||||
ICON_CULL_LIFT,
|
||||
ICON_RECEDES,
|
||||
ICON_ROTATES,
|
||||
} from './iconSet.js';
|
||||
import {
|
||||
segmentCourse,
|
||||
writeArcSegment,
|
||||
writeCourseGlide,
|
||||
writeHoldSegment,
|
||||
writeLerpSegment,
|
||||
} from './iconSegment.js';
|
||||
import { IconSetPrimitive } from './iconSetPrimitive.js';
|
||||
import { iconAtlas } from './iconAtlas.js';
|
||||
import {
|
||||
aircraftIcon,
|
||||
FLEET_ICON_PX,
|
||||
TRACKED_ICON_PX,
|
||||
} from './aircraftIcons.js';
|
||||
import { cockpitContactDotImage } from './cockpitContactDot.js';
|
||||
import { horizonOccluder } from './iconOrientation.js';
|
||||
|
||||
/** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */
|
||||
const COCKPIT_DOT_PX = 16;
|
||||
/** Contacts drawn below this height (m) are judged for the horizon a little
|
||||
* above the ellipsoid (ICON_CULL_LIFT). */
|
||||
const CULL_LIFT_BELOW_M = 10;
|
||||
|
||||
/**
|
||||
* An aircraft layer's contacts as one icon set: each row's motion segment
|
||||
* mirrors the layer's dead reckoning for the delayed display time, so the GPU
|
||||
* moves every untracked contact between polls with no per-frame CPU pass.
|
||||
*
|
||||
* The layer's state supplies `_icons`, `_iconPrimitive`, `_scratchIconRenderTime`,
|
||||
* `_iconFocusActive`, `_positionHistory`, `_displayCourse`, `_groundSnap`,
|
||||
* `_models`, `_trackedIcao`, `_viewer`, `_cockpitContactMode`,
|
||||
* `_cockpitNearContacts` and `records` (`data`, `missingPolls`, `forget`).
|
||||
* `parts.motion._motionRegime` picks the motion; `parts.rendering` supplies
|
||||
* the icon kind, scale, colours and distance scalars, and `_releaseModel`.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.state The layer's mutable state.
|
||||
* @param {object} options.services The layer's services (`focus`).
|
||||
* @param {object} options.parts The layer's parts (`motion`, `rendering`).
|
||||
* @param {string} options.layerId Pick and focus owner id.
|
||||
* @param {number} options.renderDelaySec How far behind real time contacts draw.
|
||||
* @param {number} options.courseMaxDps The fleet's course slew cap.
|
||||
* @param {number} options.cockpitContactSizePx Cockpit far-contact pip size.
|
||||
* @param {(icao24: string) => boolean} [options.holdsPlan] True while something
|
||||
* else writes this row's segment (it is not planned).
|
||||
* @param {(icao24: string) => void} [options.beforeSettle] Runs before a row is
|
||||
* planned afresh.
|
||||
* @param {(icao24: string) => void} [options.afterSettle] Runs after it.
|
||||
* @param {(icao24: string) => void} [options.onForget] Drops the layer's own
|
||||
* per-contact state when a contact goes.
|
||||
*/
|
||||
export function createAircraftIcons({
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
layerId,
|
||||
renderDelaySec,
|
||||
courseMaxDps,
|
||||
cockpitContactSizePx,
|
||||
holdsPlan = () => false,
|
||||
beforeSettle = () => {},
|
||||
afterSettle = () => {},
|
||||
onForget = () => {},
|
||||
}) {
|
||||
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),
|
||||
-renderDelaySec,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function _displayTau() {
|
||||
return state._icons.seconds(_displayTime());
|
||||
}
|
||||
|
||||
/** Create the set and its primitive for one layer lifetime. */
|
||||
function _createIcons(viewer) {
|
||||
state._icons = createIconSet({ epoch: _displayTime() });
|
||||
state._iconPrimitive = new IconSetPrimitive({
|
||||
set: state._icons,
|
||||
renderTime: () => _displayTime(state._scratchIconRenderTime),
|
||||
});
|
||||
viewer.scene.primitives.add(state._iconPrimitive);
|
||||
return state._iconPrimitive;
|
||||
}
|
||||
|
||||
/** Where a contact is drawn right now, or null when it has no row. */
|
||||
function _iconPosition(icao24, result = new Cesium.Cartesian3()) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
return row < 0 ? null : set.position(row, _displayTau(), result);
|
||||
}
|
||||
|
||||
/** The nose direction a contact is drawn with right now. */
|
||||
function _iconCourse(icao24) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
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 = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
return row >= 0 && set.shown(row);
|
||||
}
|
||||
|
||||
/** The camera's horizon test, or null when there is no placed camera. */
|
||||
function _occluder() {
|
||||
const camera = state._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(
|
||||
state._icons.cullPoint(row, position, scratchCull),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the segment the layer's dead reckoning 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 = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
if (row < 0 || holdsPlan(icao24)) return;
|
||||
const history = state._positionHistory.get(icao24);
|
||||
if (!history?.length) return;
|
||||
const info = state.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;
|
||||
if (span > 0) {
|
||||
writeLerpSegment(seg, row, {
|
||||
from: a.position,
|
||||
to: b.position,
|
||||
t0: set.seconds(a.time),
|
||||
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, courseMaxDps),
|
||||
);
|
||||
}
|
||||
set.segmentWritten(row, expiresAt);
|
||||
}
|
||||
|
||||
/** Re-plan every row whose regime ended before `tau`. */
|
||||
function _replanExpired(tau) {
|
||||
const set = state._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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, state._iconFocusActive)) return;
|
||||
const { advanceProjectedSpriteFocus, nearFarScalarValueAtDistance } =
|
||||
services.focus;
|
||||
const set = state._icons;
|
||||
let active = 0;
|
||||
for (let row = 0; row < set.count; row++) {
|
||||
if (!set.shown(row) && !state._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++;
|
||||
}
|
||||
state._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 = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
if (row < 0) return;
|
||||
const rendering = parts.rendering;
|
||||
const info = state.records.data.get(icao24);
|
||||
const tracked = icao24 === state._trackedIcao;
|
||||
const cockpitDot =
|
||||
state._cockpitContactMode &&
|
||||
!tracked &&
|
||||
!state._cockpitNearContacts.has(icao24);
|
||||
if (cockpitDot) {
|
||||
const dot = iconAtlas.slot(cockpitContactDotImage(), COCKPIT_DOT_PX);
|
||||
set.setImage(row, dot, dot);
|
||||
set.setSize(row, cockpitContactSizePx, cockpitContactSizePx);
|
||||
set.setScale(row, 1);
|
||||
cockpitScaleByDistance ??= rendering._cockpitBillboardScaleByDistance();
|
||||
set.setScaleByDistance(row, cockpitScaleByDistance);
|
||||
set.setColor(row, rendering._cockpitContactColor(icao24, info));
|
||||
set.setFlag(row, ICON_ROTATES, false);
|
||||
} else {
|
||||
set.setImage(
|
||||
row,
|
||||
iconAtlas.slot(
|
||||
aircraftIcon(rendering._iconKind(icao24, info?.klass)),
|
||||
FLEET_ICON_PX,
|
||||
),
|
||||
iconAtlas.slot(
|
||||
aircraftIcon(
|
||||
rendering._iconKind(icao24, info?.klass),
|
||||
TRACKED_ICON_PX,
|
||||
),
|
||||
TRACKED_ICON_PX,
|
||||
),
|
||||
);
|
||||
const size = tracked ? 24 : 20;
|
||||
set.setSize(row, size, size);
|
||||
set.setScale(row, rendering._fleetBillboardScale(icao24, info?.klass));
|
||||
normalScaleByDistance ??= rendering._normalBillboardScaleByDistance();
|
||||
set.setScaleByDistance(row, normalScaleByDistance);
|
||||
set.setColor(row, 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 < CULL_LIFT_BELOW_M,
|
||||
);
|
||||
set.setAlpha(row, state.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 = state._icons;
|
||||
const row = set.add(icao24, { layer: layerId, id: icao24 });
|
||||
_applyIconPresentation(icao24);
|
||||
_settleIcon(icao24);
|
||||
set.setShown(row, icao24 !== state._trackedIcao);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a row afresh, with the layer's own settle work on either side, and
|
||||
* let a dimmed row relax: one that sat out the focus pass (tracked) may come
|
||||
* back dimmed after the pass went idle, so it is counted to run the pass.
|
||||
* @param {string} icao24
|
||||
* @param {{fromCourseDeg?: number}} [options] The course on screen to glide from.
|
||||
*/
|
||||
function _settleIcon(icao24, options) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
if (row < 0) return;
|
||||
beforeSettle(icao24);
|
||||
if (set.focus(row) < 1) state._iconFocusActive += 1;
|
||||
_planIcon(icao24, options);
|
||||
afterSettle(icao24);
|
||||
}
|
||||
|
||||
/** Drop a contact and everything the layer keeps for it. */
|
||||
function _forgetContact(icao24) {
|
||||
state._icons?.remove(icao24);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
state.records.forget(icao24);
|
||||
state._positionHistory.delete(icao24);
|
||||
state._displayCourse.delete(icao24);
|
||||
state._groundSnap.forget(icao24);
|
||||
onForget(icao24);
|
||||
}
|
||||
|
||||
/** Icon ids, so ray samples never land on an aircraft instead of the ground. */
|
||||
function _iconIds() {
|
||||
const set = state._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,
|
||||
_focusIcons,
|
||||
_applyIconPresentation,
|
||||
_addIcon,
|
||||
_forgetContact,
|
||||
_iconIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seams over an aircraft layer's icon rows, shared by the flights and
|
||||
* military `testing` parts.
|
||||
* @param {object} state The layer state holding `_icons`.
|
||||
* @param {{icons: ReturnType<typeof createAircraftIcons>}} parts Read at call time.
|
||||
* @param {string} layerId
|
||||
*/
|
||||
export function aircraftIconTestSeams(state, parts, layerId) {
|
||||
/** 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 set = state._icons;
|
||||
const row = set.add(icao24, { layer: layerId, id: icao24 });
|
||||
if (billboard?.position)
|
||||
writeHoldSegment(set.segments, row, billboard.position, 0);
|
||||
set.segmentWritten(row);
|
||||
set.setShown(row, billboard?.show ?? !tracked);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Show or hide a contact's icon as a handoff would, or hold it somewhere. */
|
||||
function _setIconForTest(icao24, { shown, position } = {}) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
if (row < 0) return;
|
||||
if (shown !== undefined) set.setShown(row, shown);
|
||||
if (position) {
|
||||
writeHoldSegment(set.segments, row, position, 0);
|
||||
set.segmentWritten(row);
|
||||
}
|
||||
}
|
||||
|
||||
return { _seedIconForTest, _iconForTest, _setIconForTest };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
courseBetweenCartesians,
|
||||
speedRamp,
|
||||
staleCoastLimitSeconds,
|
||||
} from './motionModel.js';
|
||||
|
||||
/**
|
||||
* Which record fields carry an aircraft layer's reported ground speed (m/s)
|
||||
* and track (degrees clockwise from north).
|
||||
* @typedef {{speed: string, track: string}} AircraftRecordFields
|
||||
*/
|
||||
|
||||
/** A fix's own ground speed, else the record's. */
|
||||
export function fixSpeedMps(fix, info, fields) {
|
||||
return Number.isFinite(fix.velocity)
|
||||
? fix.velocity
|
||||
: (info && info[fields.speed]) || 0;
|
||||
}
|
||||
|
||||
/** A fix's own track, else the record's. */
|
||||
export function fixTrackDeg(fix, info, fields) {
|
||||
return Number.isFinite(fix.track)
|
||||
? fix.track
|
||||
: (info && info[fields.track]) || 0;
|
||||
}
|
||||
|
||||
/** A reusable result for `aircraftMotionRegime`. */
|
||||
export function createMotionRegime() {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which motion an aircraft's history gives at `renderTime` — the one choice
|
||||
* the CPU dead reckoning 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 a poll interval of history exists) runs the OLDEST fix BACKWARD,
|
||||
* preserving the behind-real-time 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. A feed 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.
|
||||
*
|
||||
* @param {Array<object>} history Non-empty, oldest first.
|
||||
* @param {object|undefined} info The contact's record.
|
||||
* @param {Cesium.JulianDate} renderTime
|
||||
* @param {AircraftRecordFields} fields
|
||||
* @param {object} result From `createMotionRegime`; returned filled in.
|
||||
*/
|
||||
export function aircraftMotionRegime(
|
||||
history,
|
||||
info,
|
||||
renderTime,
|
||||
fields,
|
||||
result,
|
||||
) {
|
||||
for (let i = history.length - 1; i >= 1; i--) {
|
||||
const a = history[i - 1];
|
||||
const b = history[i];
|
||||
if (
|
||||
!Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
|
||||
!Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
||||
)
|
||||
continue;
|
||||
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
|
||||
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
|
||||
const speed =
|
||||
span > 0 ? chordLenM / span : (info && info[fields.speed]) || 0;
|
||||
const fallbackTrack = (info && info[fields.track]) || 0;
|
||||
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
|
||||
const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
|
||||
result.kind = 'between';
|
||||
result.a = a;
|
||||
result.b = b;
|
||||
result.span = span;
|
||||
result.speed = speed;
|
||||
result.trackFrom = trackFrom;
|
||||
result.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
|
||||
result.chordCourse =
|
||||
blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
|
||||
result.blend = blend;
|
||||
return result;
|
||||
}
|
||||
const newest = history[history.length - 1];
|
||||
const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
result.kind = 'fix';
|
||||
result.fix = warming ? history[0] : newest;
|
||||
result.speed = fixSpeedMps(result.fix, info, fields);
|
||||
result.heading = fixTrackDeg(result.fix, info, fields);
|
||||
result.turnRateDps = (info && info.turnRateDps) || 0;
|
||||
result.minDt = -60;
|
||||
// A warm-up ends when real time reaches the oldest fix; a single fix keeps
|
||||
// coasting forward on the same arc.
|
||||
result.maxDt =
|
||||
warming && history.length > 1
|
||||
? 0
|
||||
: staleCoastLimitSeconds({
|
||||
// `epochMs` is captured once when the poll is normalized. Avoid
|
||||
// allocating a Date per aircraft on every 12 Hz fleet tick.
|
||||
fixEpochMs: Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
||||
lastContactEpochMs: info?.lastContactEpochMs,
|
||||
// Permit one minute of contact grace but cap any cached-feed drift
|
||||
// at five minutes. Source backoff is exposed separately as a STALE cue.
|
||||
minimumSec: 60,
|
||||
maximumSec: 300,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -83,24 +83,24 @@ for (const layer of LAYERS) {
|
||||
test(`${layer.name}: near AIR state is independent from model admission`, () => {
|
||||
assert.match(source, /nextCockpitNearContacts\(/,
|
||||
'Cockpit derives a separate near-contact hysteresis set');
|
||||
assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)|!flightState\._cockpitNearContacts\.has\(icao24\);\s*if \(cockpitDot\) \{\s*const dot = iconAtlas\.slot\(cockpitContactDotImage\(\)/,
|
||||
assert.match(source, /!state\._cockpitNearContacts\.has\(icao24\);\s*if \(cockpitDot\) \{\s*const dot = iconAtlas\.slot\(cockpitContactDotImage\(\)/,
|
||||
'only out-of-range Cockpit contacts become dots');
|
||||
// `_iconKind` is identity for every unconverted contact (see
|
||||
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
|
||||
// operator explicitly converted into a TR-3B.
|
||||
assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*aircraftVisuals\.read\(bb\)\?\.iconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)|aircraftIcon\(\s*parts\.rendering\._iconKind\(icao24, info\?\.klass\),?\s*\)/,
|
||||
assert.match(source, /aircraftIcon\(\s*(?:parts\.)?rendering\._iconKind\(icao24, info\?\.klass\),?\s*\)/,
|
||||
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
||||
assert.match(source, /setBillboard\(\s*bb,\s*'rotation',\s*0\s*\);|set\.setFlag\(row, ICON_ROTATES, false\);/,
|
||||
assert.match(source, /set\.setFlag\(row, ICON_ROTATES, false\);/,
|
||||
'far dots are reset to a rotation-free presentation');
|
||||
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)|set\.setFlag\(row, ICON_ROTATES, true\);/,
|
||||
assert.match(source, /set\.setFlag\(row, ICON_ROTATES, true\);/,
|
||||
'near 2D silhouettes continue to receive projected course');
|
||||
assert.match(source, /if\s*\(\s*billboardState\(bb\)\.show,?\s*\)\s*setBillboard\(\s*bb,\s*'show',\s*false\s*\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders|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');
|
||||
assert.match(source, /icons\.setShown\(row, false\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
|
||||
'the gap-proof icon-to-model handoff remains intact');
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
|
||||
const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/);
|
||||
assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);|for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/);
|
||||
assert.match(setMode, /for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/);
|
||||
});
|
||||
}
|
||||
@@ -1665,7 +1665,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
||||
[
|
||||
'militaryFlights',
|
||||
readLayerSource(new URL('./militaryFlights.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;/,
|
||||
],
|
||||
[
|
||||
'aisLiveVessels',
|
||||
|
||||
@@ -32,5 +32,7 @@ export const _driveFleetModelHandoffForTest =
|
||||
militaryFlightsLayer.testing._driveFleetModelHandoffForTest;
|
||||
export const _ensureFleetModelForTest =
|
||||
militaryFlightsLayer.testing._ensureFleetModelForTest;
|
||||
export const _iconForTest = militaryFlightsLayer.testing._iconForTest;
|
||||
export const _setIconForTest = militaryFlightsLayer.testing._setIconForTest;
|
||||
export const mapAnalystRecord = militaryFlightsLayer.mapAnalystRecord;
|
||||
export default militaryFlightsLayer;
|
||||
@@ -14,7 +14,10 @@ import flightsLayer, {
|
||||
_iconForTest,
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
} from './flights.js';
|
||||
import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js';
|
||||
import militaryFlightsLayer, {
|
||||
_iconForTest as _militaryIconForTest,
|
||||
_setTrackedMilitaryRefreshStateForTest,
|
||||
} from './militaryFlights.js';
|
||||
|
||||
const ICAO = 'abc123';
|
||||
const OTHER = 'def456';
|
||||
@@ -66,7 +69,7 @@ const LAYERS = [
|
||||
name: 'military',
|
||||
layer: militaryFlightsLayer,
|
||||
visualCenterM: Cesium.Cartesian3.ZERO,
|
||||
spritePosition: () => contactBillboard(true).position,
|
||||
spritePosition: () => _militaryIconForTest(ICAO).position,
|
||||
seed({ billboardShow, models = [] }) {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: ICAO,
|
||||
|
||||
@@ -232,8 +232,8 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
|
||||
// 1. The 3D model handoff is SUPPRESSED for a converted contact — there is
|
||||
// no TR-3B GLB, so the triangle billboard stays the visual.
|
||||
assert.match(source, /modelEligible\.has\(icao24\) && !isTr3b\(icao24\)|for \(const icao24 of modelEligible\) \{\s*if \(isTr3b\(icao24\)\) continue;/,
|
||||
// no TR-3B GLB, so the triangle icon stays the visual.
|
||||
assert.match(source, /for \(const icao24 of modelEligible\) \{\s*if \(isTr3b\(icao24\)\) continue;/,
|
||||
`${name}: fleet model handoff skips converted contacts`);
|
||||
// The tracked regime became default-on and camera-distance driven
|
||||
// (2026-08-19), so the suppression moved from a conjunct on
|
||||
@@ -242,11 +242,11 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
||||
assert.match(source, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isTr3b\(\s*(?:flightState\.)?_trackedIcao,?\s*\),?\s*\)\s*\{/,
|
||||
`${name}: the standalone tracked model is suppressed for a converted contact`);
|
||||
|
||||
// 2. The billboard is never hidden by that suppression — it must keep
|
||||
// 2. The icon is never hidden by that suppression — it must keep
|
||||
// satisfying the getNearby/getDetectableObjects visibility guards, so a
|
||||
// converted contact still works in Contacts and Cockpit.
|
||||
assert.match(source, /if\s*\(\s*(bb|modelled)\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*setBillboard\(\s*\1,\s*'show',\s*true,?\s*\);|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`);
|
||||
assert.match(source, /if\s*\(\s*row >= 0\s*&&\s*id\s*!==\s*flightState\._trackedIcao\s*\)\s*flightState\._icons\.setShown\(row, true\);/,
|
||||
`${name}: converting restores the icon the model handoff had hidden`);
|
||||
|
||||
// 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no
|
||||
// refresh path (poll reconciler, raster swap, presentation, tracked
|
||||
@@ -411,7 +411,7 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => {
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
// Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated
|
||||
// ambient-enrichment candidate loop that also builds a `cand`.
|
||||
const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\((?:\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*|let row = 0; row < icons\.count; row\+\+)\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0];
|
||||
const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\(let row = 0; row < icons\.count; row\+\+\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0];
|
||||
assert.ok(loop, `${name}: the model-eligibility candidate loop is present`);
|
||||
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
|
||||
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
|
||||
|
||||
@@ -44,6 +44,8 @@ import militaryFlightsLayer, {
|
||||
_trackedBillboardColorForTest as militaryTrackedBillboardColor,
|
||||
_driveFleetModelHandoffForTest as militaryDriveFleetModel,
|
||||
_ensureFleetModelForTest as militaryEnsureFleetModel,
|
||||
_iconForTest as militaryIcon,
|
||||
_setIconForTest as militarySetIcon,
|
||||
} from './militaryFlights.js';
|
||||
import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js';
|
||||
import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js';
|
||||
@@ -188,21 +190,21 @@ function contactBillboard() {
|
||||
};
|
||||
}
|
||||
|
||||
/** A flights contact's icon row, read and written the way these tests read a
|
||||
/** A 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) {
|
||||
function iconView(icao24, readIcon, setIcon) {
|
||||
return {
|
||||
get position() {
|
||||
return flightsIcon(icao24)?.position;
|
||||
return readIcon(icao24)?.position;
|
||||
},
|
||||
set position(position) {
|
||||
flightsSetIcon(icao24, { position });
|
||||
setIcon(icao24, { position });
|
||||
},
|
||||
get show() {
|
||||
return flightsIcon(icao24)?.shown === true;
|
||||
return readIcon(icao24)?.shown === true;
|
||||
},
|
||||
set show(shown) {
|
||||
flightsSetIcon(icao24, { shown });
|
||||
setIcon(icao24, { shown });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -237,7 +239,7 @@ const LAYERS = [
|
||||
modelCollection,
|
||||
meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround },
|
||||
});
|
||||
return flightIconView(icao24);
|
||||
return iconView(icao24, flightsIcon, flightsSetIcon);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -262,7 +264,7 @@ const LAYERS = [
|
||||
modelCollection,
|
||||
meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet', onGround },
|
||||
});
|
||||
return billboard;
|
||||
return iconView(icao24, militaryIcon, militarySetIcon);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+37
-361
@@ -1,35 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { courseSlewCapDps, norm180 } from '../../data/motionModel.js';
|
||||
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 { createAircraftIcons } from '../../data/aircraftIconLayer.js';
|
||||
import {
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COURSE_MAX_DPS,
|
||||
@@ -38,184 +14,48 @@ import {
|
||||
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.
|
||||
* The flights layer's contacts as one icon set (`createAircraftIcons`), plus
|
||||
* the grounded floor: a floor-pinned row holds a CPU position, so it is not
|
||||
* planned, and settling a grounded contact puts it straight onto the floor.
|
||||
*/
|
||||
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),
|
||||
const icons = createAircraftIcons({
|
||||
state: flightState,
|
||||
services,
|
||||
parts,
|
||||
layerId: 'flights',
|
||||
renderDelaySec: RENDER_DELAY_SEC,
|
||||
courseMaxDps: COURSE_MAX_DPS,
|
||||
cockpitContactSizePx: COCKPIT_CONTACT_SIZE_PX,
|
||||
holdsPlan: (icao24) =>
|
||||
flightState._focusEvidenceIds.has(icao24) ||
|
||||
flightState._floorPinned.has(icao24),
|
||||
// A pin stops at the last floor pass, which skips a tracked contact.
|
||||
beforeSettle: (icao24) => flightState._floorPinned.delete(icao24),
|
||||
// Straight onto the floor under where it is drawn, not its fix's floor
|
||||
// until the next fleet tick raises it.
|
||||
afterSettle(icao24) {
|
||||
if (!flightState._grounded.has(icao24)) return;
|
||||
const renderTime = icons._displayTime();
|
||||
_floorIcon(
|
||||
icao24,
|
||||
renderTime,
|
||||
flightState._icons.seconds(renderTime),
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
},
|
||||
onForget(icao24) {
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
flightState._grounded.delete(icao24);
|
||||
flightState._floorPinned.delete(icao24);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Grounded contacts stand on the floor under where they are DRAWN, which the
|
||||
@@ -272,7 +112,7 @@ export function createIcons({ flightState, services, parts }) {
|
||||
);
|
||||
if (!display || display === raw) {
|
||||
if (flightState._floorPinned.delete(icao24))
|
||||
_planIcon(icao24, { fromCourseDeg: set.course(row, tau) });
|
||||
icons._planIcon(icao24, { fromCourseDeg: set.course(row, tau) });
|
||||
return;
|
||||
}
|
||||
const planned = set.expires(row) !== -Infinity;
|
||||
@@ -303,169 +143,5 @@ export function createIcons({ flightState, services, parts }) {
|
||||
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,
|
||||
};
|
||||
return { ...icons, _floorGroundedIcons };
|
||||
}
|
||||
+22
-113
@@ -2,9 +2,6 @@ import * as Cesium from 'cesium';
|
||||
import {
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
lerpAngleDeg,
|
||||
speedRamp,
|
||||
courseBetweenCartesians,
|
||||
staleCoastLimitSeconds,
|
||||
arcOffsetEnu,
|
||||
courseSlewCapDps,
|
||||
limitCourseStep,
|
||||
@@ -37,20 +34,15 @@ import {
|
||||
} from './policy.js';
|
||||
import { modelState } from '../../data/modelMirror.js';
|
||||
import { readEntityRecord } from '../../data/entityRecords.js';
|
||||
import {
|
||||
aircraftMotionRegime,
|
||||
createMotionRegime,
|
||||
fixSpeedMps,
|
||||
fixTrackDeg,
|
||||
} from '../../data/aircraftMotionRegime.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;
|
||||
}
|
||||
/** Where a flights record carries its reported speed and track. */
|
||||
const RECORD_FIELDS = { speed: 'velocity', track: 'true_track' };
|
||||
|
||||
export function createMotion({
|
||||
flightState,
|
||||
@@ -80,22 +72,7 @@ export function createMotion({
|
||||
// the local frame even when the existing Cartesian is mutated in place.
|
||||
const fixFrames = new WeakMap();
|
||||
/** `_motionRegime`'s reused result. */
|
||||
const regime = {
|
||||
kind: 'fix',
|
||||
a: null,
|
||||
b: null,
|
||||
span: 0,
|
||||
speed: 0,
|
||||
trackFrom: 0,
|
||||
trackTo: 0,
|
||||
chordCourse: null,
|
||||
blend: 0,
|
||||
fix: null,
|
||||
heading: 0,
|
||||
turnRateDps: 0,
|
||||
minDt: 0,
|
||||
maxDt: 0,
|
||||
};
|
||||
const regime = createMotionRegime();
|
||||
|
||||
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
|
||||
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
|
||||
@@ -221,86 +198,18 @@ export function createMotion({
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `_deadReckon` (CPU) and the icon planner (GPU segment) both draw from
|
||||
* (`aircraftMotionRegime`). The result is a reused scratch: read it before
|
||||
* the next call.
|
||||
*/
|
||||
function _motionRegime(history, info, renderTime) {
|
||||
for (let i = history.length - 1; i >= 1; i--) {
|
||||
const a = history[i - 1];
|
||||
const b = history[i];
|
||||
if (
|
||||
!Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
|
||||
!Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
||||
)
|
||||
continue;
|
||||
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
|
||||
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
|
||||
const speed = span > 0 ? chordLenM / span : (info && info.velocity) || 0;
|
||||
const fallbackTrack = (info && info.true_track) || 0;
|
||||
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
|
||||
const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
|
||||
regime.kind = 'between';
|
||||
regime.a = a;
|
||||
regime.b = b;
|
||||
regime.span = span;
|
||||
regime.speed = speed;
|
||||
regime.trackFrom = trackFrom;
|
||||
regime.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
|
||||
regime.chordCourse =
|
||||
blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
|
||||
regime.blend = blend;
|
||||
return regime;
|
||||
}
|
||||
const newest = history[history.length - 1];
|
||||
const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
regime.kind = 'fix';
|
||||
regime.fix = warming ? history[0] : newest;
|
||||
regime.speed = fixSpeedMps(regime.fix, info);
|
||||
regime.heading = fixTrackDeg(regime.fix, info);
|
||||
regime.turnRateDps = (info && info.turnRateDps) || 0;
|
||||
regime.minDt = -60;
|
||||
// A warm-up ends when real time reaches the oldest fix; a single fix keeps
|
||||
// coasting forward on the same arc.
|
||||
regime.maxDt =
|
||||
warming && history.length > 1
|
||||
? 0
|
||||
: staleCoastLimitSeconds({
|
||||
// `epochMs` is captured once when the poll is normalized. Avoid
|
||||
// allocating a Date per aircraft on every 12 Hz fleet tick.
|
||||
fixEpochMs: Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
||||
lastContactEpochMs: info?.lastContactEpochMs,
|
||||
// Permit one minute of contact grace but cap any cached-feed drift
|
||||
// at five minutes. Source backoff is exposed separately as a STALE cue.
|
||||
minimumSec: 60,
|
||||
maximumSec: 300,
|
||||
});
|
||||
return regime;
|
||||
return aircraftMotionRegime(
|
||||
history,
|
||||
info,
|
||||
renderTime,
|
||||
RECORD_FIELDS,
|
||||
regime,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,8 +224,8 @@ export function createMotion({
|
||||
*/
|
||||
|
||||
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
|
||||
const speed = fixSpeedMps(fix, info);
|
||||
const heading = fixTrackDeg(fix, info);
|
||||
const speed = fixSpeedMps(fix, info, RECORD_FIELDS);
|
||||
const heading = fixTrackDeg(fix, info, RECORD_FIELDS);
|
||||
flightState._drSpeedMps = speed;
|
||||
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
|
||||
flightState._drExtrapolating = true;
|
||||
@@ -533,7 +442,7 @@ export function createMotion({
|
||||
* readout run in postRender at a LATER frameNumber, so calling _trackedDisplayPosition there would
|
||||
* re-run the dead-reckon on a fresh sample and double-advance the reconciliation → the label jitters
|
||||
* against the now-stable plane (the model's jitter fix, resurfacing in the labels). Returns null when
|
||||
* there's no valid fix for the tracked aircraft, so callers fall back to the billboard position. */
|
||||
* there's no valid fix for the tracked aircraft, so callers fall back to the icon position. */
|
||||
|
||||
function _trackedDisplayCached() {
|
||||
return flightState._drReconcileValid &&
|
||||
|
||||
@@ -147,3 +147,17 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
|
||||
'0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('a flight layer without an icon set declines contact refreshes', () => {
|
||||
// Before init and after destroy there are no icon rows: a TR-3B refresh
|
||||
// reports that the layer owns no such contact instead of throwing.
|
||||
const layer = createCivilFlightLayer({
|
||||
source: { label: 'Fixture aircraft', getSnapshot() {} },
|
||||
services: {
|
||||
...services(),
|
||||
aircraftPresentation: { isTr3b: () => true, tr3bConvertedIds: () => [] },
|
||||
},
|
||||
});
|
||||
assert.equal(layer.refreshTr3b('abc123'), false);
|
||||
assert.equal(layer.trackById('abc123'), false);
|
||||
});
|
||||
@@ -181,23 +181,17 @@ export const LANDED_SPEED_MAX_MPS = 23;
|
||||
/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon orientation (2026-06-10 playtest fix): rotation is computed by
|
||||
// projecting each aircraft's course vector into WINDOW coordinates
|
||||
// (iconOrientation.js) with alignedAxis always ZERO — exact at every camera
|
||||
// pitch/heading, including tracked-entity orbit mode. Rotation passes run on
|
||||
// the fleet tick only when the camera pose changed (or 1s drift catch-up).
|
||||
// The same tick horizon-culls billboards: with the Cesium globe hidden there
|
||||
// is no far-side depth, so planes otherwise show through the planet.
|
||||
// Fleet icons are one GPU icon set (iconSet.js): the shader dead-reckons each
|
||||
// row's motion segment, projects its course into window coordinates and
|
||||
// horizon-culls it (with the Cesium globe hidden there is no far-side depth,
|
||||
// so planes otherwise show through the planet). The fleet tick only re-plans
|
||||
// expired rows and drives the model handoff.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
|
||||
|
||||
export const FLEET_DR_INTERVAL_MS = 80;
|
||||
|
||||
/** @constant {number} Max ms between rotation passes while the camera is idle. */
|
||||
|
||||
export const ROTATION_REFRESH_MS = 1000;
|
||||
|
||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||
|
||||
|
||||
@@ -456,7 +456,7 @@ export function createRendering({
|
||||
id: icao24, // so scene.pick returns the icao for click-to-track
|
||||
});
|
||||
} catch {
|
||||
// asset/decode fail — stay billboard. Only touch this lifecycle's state if still current
|
||||
// asset/decode fail — stay an icon. Only touch this lifecycle's state if still current
|
||||
// (a destroy/re-init may have swapped the globals while this load was in flight).
|
||||
if (epoch === flightState._modelEpoch) {
|
||||
flightState._modelPending.delete(icao24);
|
||||
@@ -509,7 +509,7 @@ export function createRendering({
|
||||
visual.specKey = specKey; // class-change sync compares against this
|
||||
visual.irBoost = loadIrBoost; // boost-flip reload queue compares against this
|
||||
// Admitted, not yet the visual. Cesium's default is show=true, which would let
|
||||
// an unplaced primitive claim ownership from the billboard for the frames
|
||||
// an unplaced primitive claim ownership from the icon for the frames
|
||||
// between admission and the next fleet tick (and draw at the identity matrix,
|
||||
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
||||
setModel(model, 'show', false);
|
||||
|
||||
@@ -24,6 +24,8 @@ export function createFlightSnapshotRenderer({
|
||||
const { warmGroundFloor } = groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
// Before init (or after destroy) there is no icon set to draw into.
|
||||
if (!flightState._icons) return { count: 0, ids: new Set() };
|
||||
const currentIcaos = new Set();
|
||||
const acceptedSnapshotIcaos = new Set();
|
||||
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as Cesium from 'cesium';
|
||||
import { CYAN_TRANSPARENT } from './policy.js';
|
||||
import { mirrorModel } from '../../data/modelMirror.js';
|
||||
import { createIconSet } from '../../data/iconSet.js';
|
||||
import { writeHoldSegment } from '../../data/iconSegment.js';
|
||||
import { aircraftIconTestSeams } from '../../data/aircraftIconLayer.js';
|
||||
|
||||
export function createTesting({
|
||||
flightState,
|
||||
@@ -11,6 +11,8 @@ export function createTesting({
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
const { _seedIconForTest, _iconForTest, _setIconForTest } =
|
||||
aircraftIconTestSeams(flightState, parts, 'flights');
|
||||
/** Test seam for the display-floor clamp (unit-tested against real Cesium math
|
||||
* with seeded mesh cells — the drift mechanism is otherwise only reachable
|
||||
* through a live poll + render loop). */
|
||||
@@ -39,19 +41,6 @@ export function createTesting({
|
||||
flightState._displayFloorState.clear();
|
||||
}
|
||||
|
||||
/** Give a test contact an icon row held at `billboard.position`, shown as
|
||||
* `billboard.show` says (hidden by default while tracked). */
|
||||
|
||||
function _seedIconForTest(icao24, billboard, tracked) {
|
||||
const icons = flightState._icons;
|
||||
const row = icons.add(icao24, { layer: 'flights', id: icao24 });
|
||||
if (billboard?.position)
|
||||
writeHoldSegment(icons.segments, row, billboard.position, 0);
|
||||
icons.segmentWritten(row);
|
||||
icons.setShown(row, billboard?.show ?? !tracked);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed only the mutable state needed to exercise tracked-card refreshes through
|
||||
* the production poll reconciler. Tests still call `flightsLayer.update()`;
|
||||
@@ -212,38 +201,12 @@ export function createTesting({
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a contact's icon is drawn now and whether the layer shows it. */
|
||||
|
||||
function _iconForTest(icao24) {
|
||||
const position = parts.icons._iconPosition(icao24);
|
||||
return position
|
||||
? {
|
||||
position,
|
||||
shown: parts.icons._iconShown(icao24),
|
||||
course: parts.icons._iconCourse(icao24),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Exercise the exact asynchronous fleet loader and return its admitted model. */
|
||||
|
||||
async function _ensureFleetModelForTest(icao24) {
|
||||
await parts.rendering._ensureModel(icao24);
|
||||
return flightState._models.get(icao24) || null;
|
||||
}
|
||||
/** Show or hide a contact's icon as a handoff would, or hold it somewhere. */
|
||||
|
||||
function _setIconForTest(icao24, { shown, position } = {}) {
|
||||
const icons = flightState._icons;
|
||||
const row = icons?.row(icao24) ?? -1;
|
||||
if (row < 0) return;
|
||||
if (shown !== undefined) icons.setShown(row, shown);
|
||||
if (position) {
|
||||
writeHoldSegment(icons.segments, row, position, 0);
|
||||
icons.segmentWritten(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the fleet tick's grounded floor pass once, at `renderTime`. */
|
||||
|
||||
function _floorGroundedIconsForTest(renderTime, cameraPosition) {
|
||||
|
||||
@@ -164,7 +164,7 @@ export function createTracking({
|
||||
const distancesSquared = [];
|
||||
const icons = flightState._icons;
|
||||
const tau = parts.icons._displayTau();
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
for (let row = 0; row < (icons?.count ?? 0); row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
if (icao24 === flightState._trackedIcao) continue;
|
||||
const position = icons.position(row, tau, flightState._scratchCockpitPos);
|
||||
@@ -987,7 +987,7 @@ export function createTracking({
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
// The tracked target must never vanish into tile geometry — tracking a
|
||||
// taxiing plane at street level would otherwise bury the cyan icon inside
|
||||
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
|
||||
// the runway skin, so it draws depth-test-free like the fleet icon set;
|
||||
// its shared-host tracked card is top-composited separately.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createAircraftIcons } from '../../data/aircraftIconLayer.js';
|
||||
import {
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COURSE_MAX_DPS,
|
||||
RENDER_DELAY_SEC,
|
||||
} from './policy.js';
|
||||
|
||||
/** The military layer's contacts as one icon set (`createAircraftIcons`). */
|
||||
export function createIcons({ flightState, services, parts }) {
|
||||
return createAircraftIcons({
|
||||
state: flightState,
|
||||
services,
|
||||
parts,
|
||||
layerId: 'military',
|
||||
renderDelaySec: RENDER_DELAY_SEC,
|
||||
courseMaxDps: COURSE_MAX_DPS,
|
||||
cockpitContactSizePx: COCKPIT_CONTACT_SIZE_PX,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createMilitarySnapshotRenderer } from './snapshotRenderer.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createIcons } from './icons.js';
|
||||
import { createMotion } from './motion.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
import { createController } from './controller.js';
|
||||
@@ -19,6 +20,7 @@ export function createMilitaryFlightLayer({
|
||||
const layer = {};
|
||||
const context = { flightState, services, parts, layer, resolveAsset };
|
||||
parts.rendering = createRendering(context);
|
||||
parts.icons = createIcons(context);
|
||||
parts.motion = createMotion(context);
|
||||
parts.tracking = createTracking(context);
|
||||
parts.controller = createController(context);
|
||||
@@ -33,7 +35,7 @@ export function createMilitaryFlightLayer({
|
||||
militaryRegistry: services.militaryRegistry,
|
||||
rendering: parts.rendering,
|
||||
tracking: parts.tracking,
|
||||
motion: parts.motion,
|
||||
icons: parts.icons,
|
||||
queries: parts.queries,
|
||||
});
|
||||
parts.ingestion = createIngestion({
|
||||
|
||||
@@ -29,7 +29,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize the layer: create the billboard collection, reset all state,
|
||||
* Initialize the layer: create the icon set and its primitive, reset all state,
|
||||
* and install the click-to-track handler.
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
@@ -42,9 +42,7 @@ export function createLifecycle({
|
||||
flightState.lifetime = new AbortController();
|
||||
clearFocusTarget('militaryFlights');
|
||||
flightState._viewer = viewer;
|
||||
flightState._billboardCollection = new Cesium.BillboardCollection();
|
||||
viewer.scene.primitives.add(flightState._billboardCollection);
|
||||
registerSpriteCollection('military', flightState._billboardCollection);
|
||||
registerSpriteCollection('military', parts.icons._createIcons(viewer));
|
||||
flightState._modelCollection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(flightState._modelCollection);
|
||||
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
|
||||
@@ -69,11 +67,10 @@ export function createLifecycle({
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* tracked plane just stays a billboard a beat longer */
|
||||
/* tracked plane just stays an icon a beat longer */
|
||||
});
|
||||
}
|
||||
flightState._billboards = new Map();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._iconFocusActive = 0;
|
||||
flightState._detectionObjects = new Map();
|
||||
flightState.records.data = new Map();
|
||||
flightState._positionHistory = new Map();
|
||||
@@ -105,7 +102,7 @@ export function createLifecycle({
|
||||
|
||||
restoreSpriteOrder(viewer);
|
||||
|
||||
console.log('[Data:Military] Initialized with billboard icons');
|
||||
console.log('[Data:Military] Initialized with GPU icons');
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -113,8 +110,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
enable(viewer) {
|
||||
if (flightState._billboardCollection)
|
||||
flightState._billboardCollection.show = true;
|
||||
if (flightState._icons) flightState._icons.show = true;
|
||||
holdContinuousRender('military'); // per-frame animator (perf wave 2)
|
||||
if (flightState._modelCollection)
|
||||
flightState._modelCollection.show = true;
|
||||
@@ -135,13 +131,12 @@ export function createLifecycle({
|
||||
});
|
||||
}
|
||||
parts.tracking._installClickHandler(viewer);
|
||||
registerPickOwner('military', (pickedId) =>
|
||||
flightState._billboards.has(pickedId),
|
||||
registerPickOwner(
|
||||
'military',
|
||||
(pickedId) => flightState._icons?.has(pickedId) === true,
|
||||
);
|
||||
// The flights layer suppresses its military duplicates while we render them
|
||||
setMilitaryLayerActive(true);
|
||||
// Force a fresh rotation pass on the first tick after re-enable
|
||||
flightState._lastCamPoseSig = '';
|
||||
if (!flightState._preRenderRemove && viewer?.scene) {
|
||||
flightState._preRenderRemove = viewer.scene.preRender.addEventListener(
|
||||
parts.rendering._fleetTick,
|
||||
@@ -154,14 +149,11 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
if (!flightState._moveEndRemove && viewer?.camera) {
|
||||
// Arrival polish (mirror of flights.js — see the comment there): a settled
|
||||
// camera move forces a full rotation pass on the very next frame, because
|
||||
// the pose-signature gate can eat the settle (final easing frames land
|
||||
// inside one quantization bucket) and leave stale noses for up to
|
||||
// ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame.
|
||||
// A settled camera move (voice fly-to, fast pan release) runs the fleet
|
||||
// pass on the very next preRender rather than up to FLEET_DR_INTERVAL_MS
|
||||
// later, so the model handoff and focus land with the camera.
|
||||
flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||
() => {
|
||||
flightState._lastCamPoseSig = '';
|
||||
flightState._lastFleetTickMs = 0;
|
||||
},
|
||||
);
|
||||
@@ -177,8 +169,7 @@ export function createLifecycle({
|
||||
disable(viewer) {
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
flightState._billboardCollection.show = false;
|
||||
if (flightState._icons) flightState._icons.show = false;
|
||||
releaseContinuousRender('military');
|
||||
parts.rendering._releaseModels();
|
||||
if (flightState._modelCollection)
|
||||
@@ -214,8 +205,8 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
/**
|
||||
* Tear down the layer: clear tracking, remove handlers, remove the billboard
|
||||
* collection from the scene, and release all state.
|
||||
* Tear down the layer: clear tracking, remove handlers, remove the icon
|
||||
* primitive from the scene, and release all state.
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
destroy(viewer) {
|
||||
@@ -255,10 +246,12 @@ export function createLifecycle({
|
||||
flightState._moveEndRemove = null;
|
||||
}
|
||||
parts.rendering._releaseModels();
|
||||
if (flightState._billboardCollection) {
|
||||
viewer.scene.primitives.remove(flightState._billboardCollection);
|
||||
flightState._billboardCollection = null;
|
||||
if (flightState._iconPrimitive) {
|
||||
viewer.scene.primitives.remove(flightState._iconPrimitive); // removing destroys it
|
||||
flightState._iconPrimitive = null;
|
||||
}
|
||||
flightState._icons?.clear();
|
||||
flightState._icons = null;
|
||||
if (flightState._modelCollection) {
|
||||
viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models
|
||||
flightState._modelCollection = null;
|
||||
@@ -275,8 +268,7 @@ export function createLifecycle({
|
||||
flightState._preloadModel = null;
|
||||
}
|
||||
flightState._planeModelLoaded = false;
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._iconFocusActive = 0;
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.records.data.clear();
|
||||
flightState._positionHistory.clear();
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
lerpAngleDeg,
|
||||
speedRamp,
|
||||
courseBetweenCartesians,
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
staleCoastLimitSeconds,
|
||||
arcOffsetEnu,
|
||||
courseSlewCapDps,
|
||||
limitCourseStep,
|
||||
@@ -26,6 +23,15 @@ import {
|
||||
} from './policy.js';
|
||||
import { modelState } from '../../data/modelMirror.js';
|
||||
import { readEntityRecord } from '../../data/entityRecords.js';
|
||||
import {
|
||||
aircraftMotionRegime,
|
||||
createMotionRegime,
|
||||
fixSpeedMps,
|
||||
fixTrackDeg,
|
||||
} from '../../data/aircraftMotionRegime.js';
|
||||
|
||||
/** Where a military record carries its reported speed and track. */
|
||||
const RECORD_FIELDS = { speed: 'speedMps', track: 'track' };
|
||||
|
||||
export function createMotion({
|
||||
flightState,
|
||||
@@ -40,6 +46,8 @@ export function createMotion({
|
||||
clearFocusTarget,
|
||||
publishFocusTargetFromCachedPosition,
|
||||
} = services.focus;
|
||||
/** `_motionRegime`'s reused result. */
|
||||
const regime = createMotionRegime();
|
||||
|
||||
/**
|
||||
* Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math.
|
||||
@@ -74,90 +82,43 @@ export function createMotion({
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
|
||||
for (let i = history.length - 1; i >= 1; i--) {
|
||||
const a = history[i - 1];
|
||||
const b = history[i];
|
||||
if (
|
||||
Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) &&
|
||||
Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
||||
) {
|
||||
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
|
||||
const t =
|
||||
span > 0
|
||||
? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span
|
||||
: 1.0;
|
||||
// Course of the DISPLAYED motion. The chord is only trustworthy when the
|
||||
// segment covers real ground (at hover its direction is GPS jitter; on a
|
||||
// slow tight turn it STEPS the whole per-segment turn at each boundary),
|
||||
// so it is blended against the reported per-fix track by displayed
|
||||
// ground speed — and the track is TIME-INTERPOLATED between the fixes so
|
||||
// a slow turner's nose advances continuously through the segment instead
|
||||
// of snapping once per poll. Helicopters always use the reported track
|
||||
// (rotorcraft chords are noise-dominated at their typical speeds).
|
||||
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
|
||||
const segSpeed =
|
||||
span > 0 ? chordLenM / span : (info && info.speedMps) || 0;
|
||||
const fallbackTrack = (info && info.track) || 0;
|
||||
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
|
||||
const trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
|
||||
const trackCourse = lerpAngleDeg(trackFrom, trackTo, t);
|
||||
const w = info && info.klass === 'helicopter' ? 0 : speedRamp(segSpeed);
|
||||
const chordCourse =
|
||||
w > 0 ? courseBetweenCartesians(a.position, b.position) : null;
|
||||
flightState._drCourseDeg =
|
||||
chordCourse != null
|
||||
? lerpAngleDeg(trackCourse, chordCourse, w)
|
||||
: trackCourse;
|
||||
flightState._drSpeedMps = segSpeed;
|
||||
flightState._drCourseHold = segSpeed < COURSE_HOLD_SPEED_MPS;
|
||||
return Cesium.Cartesian3.lerp(a.position, b.position, t, out);
|
||||
}
|
||||
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;
|
||||
return Cesium.Cartesian3.lerp(a.position, b.position, t, out);
|
||||
}
|
||||
|
||||
const newest = history[history.length - 1];
|
||||
const elapsedSec = Cesium.JulianDate.secondsDifference(
|
||||
renderTime,
|
||||
newest.time,
|
||||
const dt = Cesium.Math.clamp(
|
||||
Cesium.JulianDate.secondsDifference(renderTime, regime.fix.time),
|
||||
regime.minDt,
|
||||
regime.maxDt,
|
||||
);
|
||||
if (elapsedSec <= 0) {
|
||||
// Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking
|
||||
// aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing
|
||||
// pair exists yet). Render at the DELAYED renderTime — preserving the behind-real-time
|
||||
// invariant — by extrapolating the OLDEST fix BACKWARD to renderTime. As history
|
||||
// fills, renderTime advances toward the first fix and the icon glides FORWARD into
|
||||
// the bracketing interpolation above with NO freeze and NO backward snap. (Holding
|
||||
// the oldest fix froze the icon until enough history accrued, then jumped.)
|
||||
const oldest = history[0];
|
||||
const lookbackSec = Cesium.JulianDate.secondsDifference(
|
||||
oldest.time,
|
||||
renderTime,
|
||||
); // ≥ 0
|
||||
return _extrapolateFix(
|
||||
oldest,
|
||||
info,
|
||||
-Math.min(lookbackSec, 60),
|
||||
out,
|
||||
(info && info.turnRateDps) || 0,
|
||||
);
|
||||
}
|
||||
return _extrapolateFix(regime.fix, info, dt, out, regime.turnRateDps);
|
||||
}
|
||||
|
||||
// Continue through fresh adsb.lol contacts even when the position epoch has
|
||||
// not advanced, but keep an absolute stale-feed drift ceiling.
|
||||
const coastLimitSec = staleCoastLimitSeconds({
|
||||
// Captured at poll normalization so the fleet tick does not allocate a
|
||||
// Date for every aircraft twelve times per second.
|
||||
fixEpochMs: Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
||||
lastContactEpochMs: info?.lastContactEpochMs,
|
||||
});
|
||||
return _extrapolateFix(
|
||||
newest,
|
||||
/**
|
||||
* Which motion a contact's history gives at `renderTime` — the one choice
|
||||
* `_deadReckon` (CPU) and the icon planner (GPU segment) both draw from
|
||||
* (`aircraftMotionRegime`). The result is a reused scratch: read it before
|
||||
* the next call.
|
||||
*/
|
||||
function _motionRegime(history, info, renderTime) {
|
||||
return aircraftMotionRegime(
|
||||
history,
|
||||
info,
|
||||
Math.min(elapsedSec, coastLimitSec),
|
||||
out,
|
||||
(info && info.turnRateDps) || 0,
|
||||
renderTime,
|
||||
RECORD_FIELDS,
|
||||
regime,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,12 +134,8 @@ export function createMotion({
|
||||
*/
|
||||
|
||||
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
|
||||
const speed = Number.isFinite(fix.velocity)
|
||||
? fix.velocity
|
||||
: (info && info.speedMps) || 0;
|
||||
const heading = Number.isFinite(fix.track)
|
||||
? fix.track
|
||||
: (info && info.track) || 0;
|
||||
const speed = fixSpeedMps(fix, info, RECORD_FIELDS);
|
||||
const heading = fixTrackDeg(fix, info, RECORD_FIELDS);
|
||||
flightState._drSpeedMps = speed;
|
||||
flightState._drCourseHold = speed < COURSE_HOLD_SPEED_MPS;
|
||||
if (speed === 0 || dt === 0) {
|
||||
@@ -356,7 +313,7 @@ export function createMotion({
|
||||
/** The tracked plane's display position WITHOUT recomputing — the value the follow-camera already
|
||||
* settled on this frame. Mirror of flights.js: getDetectableObjects + the readout run in postRender at
|
||||
* a later frameNumber, so recomputing _trackedDisplayPosition there jitters the label against the
|
||||
* now-stable plane. Null when no valid fix (callers fall back to the billboard position). */
|
||||
* now-stable plane. Null when no valid fix (callers fall back to the icon position). */
|
||||
|
||||
function _trackedDisplayCached() {
|
||||
return flightState._drReconcileValid &&
|
||||
@@ -513,6 +470,7 @@ export function createMotion({
|
||||
}
|
||||
return {
|
||||
_deadReckon,
|
||||
_motionRegime,
|
||||
_extrapolateFix,
|
||||
_isTrackWarmingUp,
|
||||
_trackedFocusSizePx,
|
||||
|
||||
@@ -87,7 +87,7 @@ test('military state owns separate contact maps, motion scratch and ground sampl
|
||||
for (const key of [
|
||||
'records',
|
||||
'feed',
|
||||
'_billboards',
|
||||
'_displayCourse',
|
||||
'_positionHistory',
|
||||
'_groundSnap',
|
||||
'_scratchCarto',
|
||||
@@ -141,7 +141,7 @@ test('a normalized source can retain its stale reason without changing standalon
|
||||
test('a newly seen military contact appears at its delayed position, not ahead of it', async () => {
|
||||
// The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh
|
||||
// contact's first displayed position is its fix projected back to that
|
||||
// delayed time. Creating the billboard at the raw fix drew it ahead until
|
||||
// delayed time. Creating the icon at the raw fix drew it ahead until
|
||||
// the next fleet tick jumped it back.
|
||||
const Cesium = await import('cesium');
|
||||
const { RENDER_DELAY_SEC } = await import('./policy.js');
|
||||
@@ -177,7 +177,6 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
||||
},
|
||||
},
|
||||
});
|
||||
const added = [];
|
||||
layer.testing._setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
@@ -186,22 +185,14 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
||||
show: false,
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
|
||||
},
|
||||
billboardCollection: {
|
||||
show: false,
|
||||
add(options) {
|
||||
const billboard = { ...options };
|
||||
added.push(billboard);
|
||||
return billboard;
|
||||
},
|
||||
remove() {},
|
||||
},
|
||||
billboardCollection: { show: false },
|
||||
viewer: { camera: { positionCartographic: null }, scene: {} },
|
||||
tracked: false,
|
||||
});
|
||||
await layer.update({ camera: { positionCartographic: null }, scene: {} });
|
||||
const billboard = added.find((entry) => entry.id === 'ae0001');
|
||||
assert.ok(billboard, 'the new contact gets a billboard');
|
||||
const shown = Cesium.Cartographic.fromCartesian(billboard.position);
|
||||
const icon = layer.testing._iconForTest('ae0001');
|
||||
assert.ok(icon, 'the new contact gets an icon');
|
||||
const shown = Cesium.Cartographic.fromCartesian(icon.position);
|
||||
const behindM = Cesium.Cartesian3.distance(
|
||||
Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 0),
|
||||
Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 0),
|
||||
@@ -289,3 +280,17 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
|
||||
'0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('a military layer without an icon set declines contact refreshes', () => {
|
||||
// Before init and after destroy there are no icon rows: a TR-3B refresh
|
||||
// reports that the layer owns no such contact instead of throwing.
|
||||
const layer = createMilitaryFlightLayer({
|
||||
source: { label: 'Fixture aircraft', getSnapshot() {} },
|
||||
services: {
|
||||
...services(),
|
||||
aircraftPresentation: { isTr3b: () => true, tr3bConvertedIds: () => [] },
|
||||
},
|
||||
});
|
||||
assert.equal(layer.refreshTr3b('abc123'), false);
|
||||
assert.equal(layer.trackById('abc123'), false);
|
||||
});
|
||||
@@ -5,28 +5,23 @@ import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||
* @module militaryFlights
|
||||
* @description Real-time military flight tracking layer powered by the adsb.lol API.
|
||||
*
|
||||
* Renders aircraft as amber chevron billboards in a single BillboardCollection
|
||||
* for GPU-efficient batch drawing. Supports click-to-track: selecting an aircraft
|
||||
* Renders aircraft as amber chevrons in one GPU icon set (iconSet.js), whose
|
||||
* shader dead-reckons and orients every row. Supports click-to-track: selecting an aircraft
|
||||
* spawns an Entity the camera follows, whose position is driven by a
|
||||
* dead-reckoning CallbackProperty. The whole fleet renders at
|
||||
* now - RENDER_DELAY_SEC so positions interpolate BETWEEN two known fixes
|
||||
* instead of extrapolating ahead and snapping back when the next poll lands.
|
||||
*
|
||||
* Billboard orientation uses alignedAxis set to the WGS84 surface normal at each
|
||||
* position so that the rotation angle (heading/track) operates in the local tangent
|
||||
* plane where 0 deg = north; near nadir the icons switch to screen-aligned
|
||||
* rotation (camera.heading - track) with hysteresis.
|
||||
*/
|
||||
|
||||
/** @constant {number} Milliseconds to wait before retrying after a transient error */
|
||||
|
||||
/** @constant {number} Max position samples retained per aircraft for dead reckoning */
|
||||
|
||||
/** @constant {number} Base billboard display scale */
|
||||
/** @constant {number} Base icon display scale */
|
||||
|
||||
export const BILLBOARD_SCALE = 0.7;
|
||||
|
||||
/** @constant {Cesium.Color} Default amber tint for untracked military billboards */
|
||||
/** @constant {Cesium.Color} Default amber tint for untracked military icons */
|
||||
|
||||
export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800');
|
||||
|
||||
@@ -44,7 +39,7 @@ export const EMERGENCY_ICON_COLOR =
|
||||
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
|
||||
// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable,
|
||||
// sticky metadata updating normally. The on-ground flip restyles the existing
|
||||
// billboard IN PLACE (landing/takeoff transition, never a removal); ground planes
|
||||
// icon IN PLACE (landing/takeoff transition, never a removal); ground planes
|
||||
// draw no trails. In 3D mode they take model slots like airborne planes (owner
|
||||
// decision 2026-07-03 — no air/ground distinction), placed by the one-shot
|
||||
// ground snap (see _modelDisplayPosition).
|
||||
@@ -53,7 +48,7 @@ export const EMERGENCY_ICON_COLOR =
|
||||
// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can
|
||||
// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail;
|
||||
// "feed-dropped, coasting" stays the 45%-alpha stale fade.
|
||||
/** Ground billboards render slightly smaller so airport clutter stays visually minor. */
|
||||
/** Ground icons render slightly smaller so airport clutter stays visually minor. */
|
||||
|
||||
export const GROUND_SCALE = 0.8;
|
||||
|
||||
@@ -215,25 +210,10 @@ export const LANDED_SPEED_MAX_MPS = 23;
|
||||
// gets the floor.
|
||||
/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nadir-stable icon orientation (mirrors flights.js): surface-normal alignment
|
||||
// degenerates when the camera looks straight down (the normal is parallel to
|
||||
// the view direction and the shader's screen-projected angle is 0/0). Near
|
||||
// nadir we switch to screen-aligned billboards with
|
||||
// rotation = camera.heading - track (screen-up points at azimuth
|
||||
// camera.heading; a CW track t appears t-h from screen-up; billboard rotation
|
||||
// is CCW-positive => r = h - t). Discrete switch with hysteresis; rotations
|
||||
// are continuous at the boundary with this formula.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @constant {number} Fleet dead-reckoning tick interval (ms) — ~12Hz, not per-frame. */
|
||||
|
||||
export const FLEET_DR_INTERVAL_MS = 80;
|
||||
|
||||
/** @constant {number} Max ms between rotation passes while the camera is idle. */
|
||||
|
||||
export const ROTATION_REFRESH_MS = 1000;
|
||||
|
||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from '../../data/contactMatch.js';
|
||||
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
||||
import { modelState } from '../../data/modelMirror.js';
|
||||
|
||||
export function createQueries({
|
||||
@@ -88,7 +87,7 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Build a public descriptor for one aircraft using its best current position
|
||||
* (dead-reckoned when history exists, billboard position otherwise).
|
||||
* (dead-reckoned when history exists, icon position otherwise).
|
||||
* @param {string} icao24 - ICAO hex identifier of the aircraft.
|
||||
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
|
||||
* Descriptor with a cloned position, or null if the aircraft is unknown.
|
||||
@@ -96,10 +95,8 @@ export function createQueries({
|
||||
|
||||
function _describeFlight(icao24) {
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
const basePos =
|
||||
parts.motion._deadReckon(icao24) ||
|
||||
(bb ? billboardState(bb).position : null);
|
||||
parts.motion._deadReckon(icao24) || parts.icons._iconPosition(icao24);
|
||||
if (!basePos) return null;
|
||||
const displayed = displayedKinematics({
|
||||
derivedSpeedMps: flightState._drSpeedMps,
|
||||
@@ -271,13 +268,8 @@ export function createQueries({
|
||||
if (!flightState._models3dEnabled) {
|
||||
parts.rendering._releaseModels();
|
||||
parts.tracking._syncTracked2dRotation();
|
||||
// Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked
|
||||
// plane's own fleet billboard — its tracked entity is the visual (avoids a double-image).
|
||||
if (flightState._billboardCollection)
|
||||
for (const [icao, bb] of flightState._billboards) {
|
||||
if (icao !== flightState._trackedIcao)
|
||||
setBillboard(bb, 'show', true);
|
||||
}
|
||||
// (Releasing the models handed each fleet contact back to its icon, never
|
||||
// the tracked plane's own — its tracked entity is the visual.)
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -357,12 +349,11 @@ export function createQueries({
|
||||
* @returns {Array<Object>} Sorted array of nearby aircraft descriptors
|
||||
*/
|
||||
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
|
||||
if (
|
||||
!center ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return [];
|
||||
const icons = flightState._icons;
|
||||
if (!center || !icons || !icons.show) return [];
|
||||
const occluder = parts.icons._occluder();
|
||||
const tau = parts.icons._displayTau();
|
||||
const scratch = flightState._scratchNearbyPos;
|
||||
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
@@ -372,28 +363,30 @@ export function createQueries({
|
||||
|
||||
const nearby = [];
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
// Keep planes rendered as a 3D model (billboard hidden) so proximity counts
|
||||
// don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden.
|
||||
const trackedPos = isTracked
|
||||
? parts.motion._trackedDisplayCached()
|
||||
: null; // cached, no recompute (anti-jitter)
|
||||
const drawn = trackedPos || icons.position(row, tau, scratch);
|
||||
// Keep planes rendered as a 3D model (icon hidden) so proximity counts
|
||||
// don't drop to zero on the 2D→3D handoff.
|
||||
if (
|
||||
!aircraftIncludedInNearby({
|
||||
isTracked,
|
||||
billboardShown: billboardState(bb).show,
|
||||
billboardShown:
|
||||
icons.shown(row) &&
|
||||
parts.icons._aboveHorizon(row, drawn, occluder),
|
||||
modelRendering: parts.rendering._modelOwnsVisual(icao24),
|
||||
includeHidden,
|
||||
})
|
||||
)
|
||||
continue;
|
||||
|
||||
const trackedPos = isTracked
|
||||
? parts.motion._trackedDisplayCached()
|
||||
: null; // cached, no recompute (anti-jitter)
|
||||
const pos = trackedPos || billboardState(bb).position;
|
||||
if (!pos) continue;
|
||||
|
||||
const distance = Cesium.Cartesian3.distance(center, pos);
|
||||
const distance = Cesium.Cartesian3.distance(center, drawn);
|
||||
if (distance > maxRange) continue;
|
||||
const pos = trackedPos || Cesium.Cartesian3.clone(drawn);
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
nearby.push({
|
||||
@@ -434,40 +427,32 @@ export function createQueries({
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return [];
|
||||
const icons = flightState._icons;
|
||||
if (!icons || !icons.show) return [];
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: flightState._billboards.size;
|
||||
: icons.count;
|
||||
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||
// Deterministic stride: evenly space selections across the billboard map
|
||||
const stride = Math.max(
|
||||
1,
|
||||
Math.ceil(flightState._billboards.size / maxCount),
|
||||
);
|
||||
// Deterministic stride: evenly space selections across the icon rows
|
||||
const stride = Math.max(1, Math.ceil(icons.count / maxCount));
|
||||
const start = seed % stride;
|
||||
const occluder = parts.icons._occluder();
|
||||
const tau = parts.icons._displayTau();
|
||||
|
||||
const result = [];
|
||||
let idx = 0;
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const shouldTake = (idx - start) % stride === 0;
|
||||
idx++;
|
||||
if (!shouldTake) continue;
|
||||
for (let row = start; row < icons.count; row += stride) {
|
||||
const icao24 = icons.idAt(row);
|
||||
if (
|
||||
flightState._cockpitContactMode &&
|
||||
icao24.toLowerCase() === flightState._cockpitSubjectId
|
||||
)
|
||||
continue;
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
|
||||
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
|
||||
// Keep planes rendered as a 3D model (icon hidden) so the detection box
|
||||
// doesn't vanish on the 2D→3D handoff.
|
||||
const model = flightState._models.get(icao24);
|
||||
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
|
||||
if (!isTracked && !billboardState(bb).show && !modelOwnsVisual)
|
||||
continue;
|
||||
if (!isTracked && !icons.shown(row) && !modelOwnsVisual) continue;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const callsign = info?.callsign?.trim();
|
||||
const registration = info?.registration?.trim();
|
||||
@@ -478,18 +463,20 @@ export function createQueries({
|
||||
type: 'AIR',
|
||||
tier: 'military',
|
||||
_weldPos: new Cesium.Cartesian3(),
|
||||
_iconPos: new Cesium.Cartesian3(),
|
||||
};
|
||||
flightState._detectionObjects.set(icao24, object);
|
||||
}
|
||||
// WELD (mirror of flights.js): anchor to whatever owns the visual. Model-owned
|
||||
// contacts read the translation the fleet tick already wrote into their
|
||||
// modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket
|
||||
// are co-located anyway.
|
||||
// modelMatrix; icon-owned contacts keep the drawn icon position, where icon and
|
||||
// bracket are co-located anyway.
|
||||
const spec = modelOwnsVisual
|
||||
? parts.rendering._modelSpec(info?.klass)
|
||||
: null;
|
||||
const pos = isTracked
|
||||
? parts.motion._trackedVisualCached() || billboardState(bb).position
|
||||
? parts.motion._trackedVisualCached() ||
|
||||
icons.position(row, tau, object._iconPos)
|
||||
: modelOwnsVisual
|
||||
? modelVisualAnchor(
|
||||
modelState(model).modelMatrix,
|
||||
@@ -499,8 +486,15 @@ export function createQueries({
|
||||
: spec.scale,
|
||||
object._weldPos || (object._weldPos = new Cesium.Cartesian3()),
|
||||
)
|
||||
: billboardState(bb).position;
|
||||
if (!pos) continue;
|
||||
: icons.position(row, tau, object._iconPos);
|
||||
// An icon past the horizon is not drawn, so it is not detectable.
|
||||
if (
|
||||
!pos ||
|
||||
(!isTracked &&
|
||||
!modelOwnsVisual &&
|
||||
!parts.icons._aboveHorizon(row, pos, occluder))
|
||||
)
|
||||
continue;
|
||||
object.position = pos;
|
||||
object.skipLabel = isTracked;
|
||||
const id = callsign || registration || icao24;
|
||||
@@ -519,13 +513,9 @@ export function createQueries({
|
||||
result.push(object);
|
||||
if (result.length >= maxCount) break;
|
||||
}
|
||||
if (
|
||||
flightState._detectionObjects.size >
|
||||
flightState._billboards.size + 512
|
||||
) {
|
||||
if (flightState._detectionObjects.size > icons.count + 512) {
|
||||
for (const icao24 of flightState._detectionObjects.keys()) {
|
||||
if (!flightState._billboards.has(icao24))
|
||||
flightState._detectionObjects.delete(icao24);
|
||||
if (!icons.has(icao24)) flightState._detectionObjects.delete(icao24);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -577,8 +567,8 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Return id/label/position for up to maxCount currently rendered aircraft.
|
||||
* Cheap snapshot for voice-tool framing — billboard positions only, no
|
||||
* dead reckoning and no cloning.
|
||||
* Cheap snapshot for voice-tool framing — drawn icon positions, no
|
||||
* extra dead reckoning.
|
||||
* @param {number} [maxCount=500] - Maximum number of entries to return.
|
||||
* @returns {Array<{id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number}>}
|
||||
*/
|
||||
@@ -595,34 +585,25 @@ export function createQueries({
|
||||
* holds no data and therefore cannot answer.
|
||||
*/
|
||||
hasContact(icao24) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
flightState._billboards.size === 0
|
||||
)
|
||||
return null;
|
||||
const icons = flightState._icons;
|
||||
if (!icons || !icons.show || icons.count === 0) return null;
|
||||
if (!icao24) return false;
|
||||
const id = String(icao24).trim();
|
||||
return (
|
||||
flightState._billboards.has(id) ||
|
||||
flightState._billboards.has(id.toLowerCase())
|
||||
);
|
||||
return icons.has(id) || icons.has(id.toLowerCase());
|
||||
},
|
||||
|
||||
getAllPositions(maxCount = 500) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
flightState._billboards.size === 0
|
||||
)
|
||||
return [];
|
||||
const icons = flightState._icons;
|
||||
if (!icons || icons.count === 0) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 500;
|
||||
const tau = parts.icons._displayTau();
|
||||
|
||||
const result = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const pos = billboardState(bb).position;
|
||||
if (!pos) continue;
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
const pos = icons.position(row, tau);
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
pos,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
@@ -657,11 +638,7 @@ export function createQueries({
|
||||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||||
*/
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
flightState.records.data.size === 0
|
||||
)
|
||||
if (!flightState._icons?.show || flightState.records.data.size === 0)
|
||||
return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
@@ -682,8 +659,8 @@ export function createQueries({
|
||||
trackById(icao24, { origin = 'programmatic' } = {}) {
|
||||
if (!icao24) return false;
|
||||
let id = String(icao24).trim();
|
||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
||||
if (!flightState._billboards.has(id)) return false;
|
||||
if (!flightState._icons?.has(id)) id = id.toLowerCase();
|
||||
if (!flightState._icons?.has(id)) return false;
|
||||
if (parts.tracking._isExplicitTrackingOrigin(origin))
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._trackedIcao === id)
|
||||
@@ -752,7 +729,7 @@ export function createQueries({
|
||||
)
|
||||
return false;
|
||||
let id = String(icao24).trim();
|
||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
||||
if (!flightState._icons?.has(id)) id = id.toLowerCase();
|
||||
if (
|
||||
id !== flightState._trackedIcao ||
|
||||
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
|
||||
|
||||
@@ -59,14 +59,14 @@ export class MilitaryFlightRecords {
|
||||
// GROUND-SNAP INTERPLAY (brief item 3 — "don't double-correct"): a
|
||||
// grounded plane's MODEL already rides groundSnap.js's one-shot tileset
|
||||
// sample (_modelDisplayPosition), which is the visual on the ground, and
|
||||
// its billboard is depth-test-free (_groundDepthDistance) so its exact
|
||||
// its icon is depth-test-free (the icon set draws on the near plane) so its exact
|
||||
// height is cosmetic. Deliberately pass surfaceM=null so
|
||||
// pickRenderAltitudeM's on-ground surface branch never fires here:
|
||||
// 1. It would be the SECOND correction of the same grounded plane
|
||||
// (model tileset-snap is the first) — the exact double-correct the
|
||||
// brief forbids.
|
||||
// 2. Military ground rows carry NO baro ("alt_baro":"ground"), so the
|
||||
// grounded billboard sits at 0 m until a surface value warms; letting
|
||||
// grounded icon sits at 0 m until a surface value warms; letting
|
||||
// surfaceM then jump it 0 -> ~surface (often ~100 m) BETWEEN polls
|
||||
// drags the model's ground-snap input past groundSnap's 50 m
|
||||
// move-invalidation threshold and forces a re-sample every time the
|
||||
@@ -95,7 +95,7 @@ export class MilitaryFlightRecords {
|
||||
// ellipsoidal ground (warm-cache read only — the batch warm below
|
||||
// fills cells for later polls). Grounded contacts are deliberately
|
||||
// NOT touched: their model rides groundSnap's tileset sample and
|
||||
// their billboard is depth-test-free (see the surfaceM:null block
|
||||
// their icon is depth-test-free (see the surfaceM:null block
|
||||
// above — same one-shot-invariant reasoning).
|
||||
if (!onGround && renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M) {
|
||||
renderAltitudeM = floorAltitudeM(
|
||||
@@ -105,10 +105,10 @@ export class MilitaryFlightRecords {
|
||||
_floorWarmPoints.push({ lat, lon });
|
||||
} else if (onGround) {
|
||||
// Grounded contacts: warm the floor cell, and — round 4 — when NO
|
||||
// 3D model owns this contact's visual, lift the billboard itself
|
||||
// 3D model owns this contact's visual, lift the icon itself
|
||||
// onto the floor (mesh-first): the R20053 heli sat "straight up in
|
||||
// the ground" because grounded rows render at the legacy ~0 m.
|
||||
// With a model present the billboard stays put (it hides behind
|
||||
// With a model present the icon stays put (it hides behind
|
||||
// the tileset-snapped model, and moving it would drag groundSnap's
|
||||
// input past its move-invalidation threshold — the T7 one-shot
|
||||
// invariant the track regression locks).
|
||||
|
||||
+177
-348
@@ -9,15 +9,8 @@ import {
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
cameraPoseSignature,
|
||||
horizonOccluder,
|
||||
screenProjectedRotation,
|
||||
} from '../../data/iconOrientation.js';
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { aircraftTreatmentAlpha } from '../../data/aircraftRecession.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
PLANE_MODEL_SCALE,
|
||||
@@ -31,7 +24,6 @@ import {
|
||||
MODEL_HEADING_OFFSET_DEG,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
MIL_ICON_COLOR,
|
||||
TRACKED_ICON_COLOR,
|
||||
EMERGENCY_ICON_COLOR,
|
||||
@@ -49,13 +41,9 @@ import {
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
import { aircraftVisuals } from '../aircraft/visualState.js';
|
||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
||||
import { modelState, setModel } from '../../data/modelMirror.js';
|
||||
|
||||
export function createRendering({
|
||||
@@ -67,23 +55,8 @@ export function createRendering({
|
||||
}) {
|
||||
const { tr3bIconKind, isTr3b } = services.aircraftPresentation;
|
||||
const { trackedModelScaleForPixelCap } = services.camera;
|
||||
const {
|
||||
focusNowMs,
|
||||
getFocusTarget,
|
||||
nearFarScalarValueAtDistance,
|
||||
advanceProjectedSpriteFocus,
|
||||
} = services.focus;
|
||||
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
|
||||
services.recession;
|
||||
|
||||
/** Depth-test policy for aircraft billboards (mirror of flights.js — see the
|
||||
* full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact
|
||||
* renders depth-test-free at every distance — a uniform always-visible rule;
|
||||
* the fleet tick's horizon occluder still removes far-side contacts. */
|
||||
|
||||
function _groundDepthDistance() {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
const { focusNowMs, getFocusTarget } = services.focus;
|
||||
const { applyAircraftModelTreatment } = services.recession;
|
||||
|
||||
function _modelSpec(klass) {
|
||||
let spec = flightState._specCache.get(klass);
|
||||
@@ -140,7 +113,9 @@ export function createRendering({
|
||||
return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65);
|
||||
}
|
||||
|
||||
function _militaryBillboardScale(icao24) {
|
||||
/** Fleet icon scale: per-class scale, ×GROUND_SCALE while grounded. */
|
||||
|
||||
function _fleetBillboardScale(icao24) {
|
||||
const meta = flightState.records.data.get(icao24);
|
||||
return (
|
||||
BILLBOARD_SCALE *
|
||||
@@ -149,42 +124,7 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 = _fleetBillboardColor(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', _militaryBillboardScale(icao24) * limbScale);
|
||||
setBillboard(bb, 'scaleByDistance', _normalBillboardScaleByDistance());
|
||||
bb.color = _fleetBillboardColor(icao24, meta).withAlpha(
|
||||
cyberSonarBaseAlpha(bb),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
||||
/** Sprite kind for one contact's icon. Identity for every aircraft except
|
||||
* the ones the operator converted into a TR-3B (Easter egg), which draw the
|
||||
* black-triangle glyph — its thermal-reactive variant while an IR style owns
|
||||
* the scene. Routing EVERY `aircraftIcon()` call through this is what makes a
|
||||
@@ -210,6 +150,10 @@ export function createRendering({
|
||||
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
|
||||
}
|
||||
|
||||
/** Cockpit far-contact pips wear the fleet tint. */
|
||||
|
||||
const _cockpitContactColor = _fleetBillboardColor;
|
||||
|
||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
|
||||
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
|
||||
@@ -280,12 +224,12 @@ export function createRendering({
|
||||
|
||||
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of
|
||||
* flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its
|
||||
* (or a parked neighbor's) billboard/model instead of the tile skin. Exclusion matches
|
||||
* picked-object IDs — every billboard AND model here carries its icao as `id` — plus
|
||||
* (or a parked neighbor's) icon/model instead of the tile skin. Exclusion matches
|
||||
* picked-object IDs — every icon AND model here carries its icao as `id` — plus
|
||||
* the tracked entity object itself. Built lazily, only when a sample actually fires. */
|
||||
|
||||
function _groundSampleExclusions() {
|
||||
const out = [...flightState._billboards.keys()];
|
||||
const out = parts.icons._iconIds();
|
||||
if (flightState._trackedEntity) out.push(flightState._trackedEntity);
|
||||
return out;
|
||||
}
|
||||
@@ -295,7 +239,7 @@ export function createRendering({
|
||||
* (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly
|
||||
* offset so they sit on their
|
||||
* gear; airborne planes pass through. Until the FIRST sample lands, callers keep the
|
||||
* depth-test-free billboard visible and the model hidden while this returns null; a
|
||||
* icon visible and the model hidden while this returns null; a
|
||||
* contact that has already resolved once holds that measurement through a later
|
||||
* outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */
|
||||
|
||||
@@ -324,15 +268,23 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
/** Atomically hand one fleet contact from its billboard to a safely placed,
|
||||
/** Atomically hand one fleet contact from its icon to a safely placed,
|
||||
* ready 3D model. Missing/loading models and unresolved terrain always leave
|
||||
* the billboard owning the visual, so no render frame can hide both.
|
||||
* the icon owning the visual, so no render frame can hide both.
|
||||
* `beforeShow` runs only on the committing path — the per-tick model treatment
|
||||
* belongs to a model that is about to draw, not to one still waiting. */
|
||||
|
||||
function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) {
|
||||
function _driveFleetModelHandoff(
|
||||
icao24,
|
||||
model,
|
||||
row,
|
||||
pos,
|
||||
course,
|
||||
beforeShow,
|
||||
) {
|
||||
const icons = flightState._icons;
|
||||
if (!model) {
|
||||
setBillboard(bb, 'show', true);
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
const displayPos = _modelDisplayPosition(
|
||||
@@ -342,7 +294,7 @@ export function createRendering({
|
||||
);
|
||||
if (!displayPos) {
|
||||
setModel(model, 'show', false); // no ground evidence → nothing safe to place a depth-tested model at
|
||||
setBillboard(bb, 'show', true);
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
// The matrix is written BEFORE the readiness test on purpose: a model can flip
|
||||
@@ -361,12 +313,12 @@ export function createRendering({
|
||||
);
|
||||
if (!model.ready) {
|
||||
setModel(model, 'show', false); // not loaded yet → keep the 2D icon, no half-model flash
|
||||
setBillboard(bb, 'show', true);
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
beforeShow?.();
|
||||
if (!modelState(model).show) setModel(model, 'show', true);
|
||||
if (billboardState(bb).show) setBillboard(bb, 'show', false); // hand off ONLY once the model renders
|
||||
icons.setShown(row, false); // hand off ONLY once the model renders
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -402,7 +354,7 @@ export function createRendering({
|
||||
|
||||
/** Class-change model sync — mirror of flights.js: drop a live model or
|
||||
* in-flight load whose spec no longer matches the class, re-showing the
|
||||
* fleet billboard FIRST (gap-proof) and covering the tracked standalone. */
|
||||
* fleet icon (on release, gap-proof) and covering the tracked standalone. */
|
||||
|
||||
function _syncModelToClass(icao24) {
|
||||
const key = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
||||
@@ -411,9 +363,6 @@ export function createRendering({
|
||||
(current && aircraftVisuals.read(current)?.specKey !== key) ||
|
||||
(!current && flightState._modelPending.has(icao24))
|
||||
) {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (bb && icao24 !== flightState._trackedIcao)
|
||||
setBillboard(bb, 'show', true);
|
||||
_releaseModel(icao24);
|
||||
}
|
||||
if (
|
||||
@@ -447,16 +396,13 @@ export function createRendering({
|
||||
aircraftVisuals.read(model)?.irBoost === flightState._irBoost
|
||||
)
|
||||
continue; // already right state
|
||||
const bb = flightState._billboards.get(icao);
|
||||
if (bb && icao !== flightState._trackedIcao)
|
||||
setBillboard(bb, 'show', true);
|
||||
_releaseModel(icao);
|
||||
}
|
||||
if (flightState._irReloadQueue.length === 0)
|
||||
flightState._irReloadQueue = null;
|
||||
}
|
||||
|
||||
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
|
||||
/** Lazily create the glTF model for an aircraft (fire-and-forget; its icon shows until ready). */
|
||||
|
||||
async function _ensureModel(icao24) {
|
||||
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
|
||||
@@ -501,7 +447,7 @@ export function createRendering({
|
||||
id: icao24, // so scene.pick returns the icao for click-to-track
|
||||
});
|
||||
} catch {
|
||||
// asset/decode fail — stay billboard. Only touch this lifecycle's state if still current
|
||||
// asset/decode fail — stay an icon. Only touch this lifecycle's state if still current
|
||||
// (a destroy/re-init may have swapped the globals while this load was in flight).
|
||||
if (epoch === flightState._modelEpoch) {
|
||||
flightState._modelPending.delete(icao24);
|
||||
@@ -554,7 +500,7 @@ export function createRendering({
|
||||
visual.specKey = specKey; // class-change sync compares against this
|
||||
visual.irBoost = loadIrBoost; // boost-flip reload queue compares against this
|
||||
// Admitted, not yet the visual. Cesium's default is show=true, which would let
|
||||
// an unplaced primitive claim ownership from the billboard for the frames
|
||||
// an unplaced primitive claim ownership from the icon for the frames
|
||||
// between admission and the next fleet tick (and draw at the identity matrix,
|
||||
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
||||
setModel(model, 'show', false);
|
||||
@@ -589,6 +535,7 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
flightState._models.delete(icao24);
|
||||
_showReleasedIcon(icao24);
|
||||
}
|
||||
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
|
||||
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
|
||||
@@ -607,7 +554,7 @@ export function createRendering({
|
||||
flightState._modelGen.delete(icao24);
|
||||
}
|
||||
|
||||
/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */
|
||||
/** Remove all live models (toggle-off / zoom-out); icons take back over at once. */
|
||||
|
||||
function _releaseModels() {
|
||||
flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
|
||||
@@ -629,9 +576,20 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const icao24 of flightState._models.keys()) _showReleasedIcon(icao24);
|
||||
flightState._models.clear();
|
||||
}
|
||||
|
||||
/** A released fleet model hands the visual straight back to its icon (the
|
||||
* tracked contact's entity keeps its own). */
|
||||
|
||||
function _showReleasedIcon(icao24) {
|
||||
const icons = flightState._icons;
|
||||
const row = icons?.row(icao24) ?? -1;
|
||||
if (row >= 0 && icao24 !== flightState._trackedIcao)
|
||||
icons.setShown(row, true);
|
||||
}
|
||||
|
||||
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
|
||||
|
||||
function _releaseTrackedModel() {
|
||||
@@ -672,8 +630,7 @@ export function createRendering({
|
||||
// readout consumers then reuse that exact cached position.
|
||||
const pos =
|
||||
parts.motion._trackedDisplayPosition(flightState._trackedIcao) ||
|
||||
billboardState(flightState._billboards.get(flightState._trackedIcao))
|
||||
?.position;
|
||||
parts.icons._iconPosition(flightState._trackedIcao);
|
||||
if (!pos) {
|
||||
if (flightState._trackedModel)
|
||||
setModel(flightState._trackedModel, 'show', false);
|
||||
@@ -750,9 +707,7 @@ export function createRendering({
|
||||
// default here produces a one-frame jump to the Earth's center.
|
||||
const currentPos =
|
||||
parts.motion._trackedDisplayCached() ||
|
||||
billboardState(
|
||||
flightState._billboards.get(flightState._trackedIcao),
|
||||
)?.position;
|
||||
parts.icons._iconPosition(flightState._trackedIcao);
|
||||
if (currentPos) {
|
||||
const displayPos = _modelDisplayPosition(
|
||||
flightState._trackedIcao,
|
||||
@@ -832,57 +787,38 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* ~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, advances focus de-emphasis while a
|
||||
* target exists, and drives the 3D-model handoff inside the model regime.
|
||||
* Driven by scene.preRender.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function _fleetTick() {
|
||||
if (
|
||||
!flightState._viewer ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return;
|
||||
const icons = flightState._icons;
|
||||
if (!flightState._viewer || !icons || !icons.show) return;
|
||||
const scene = flightState._viewer.scene;
|
||||
const camera = flightState._viewer.camera;
|
||||
const nowMs = focusNowMs(Date.now());
|
||||
|
||||
// (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz
|
||||
// primitive rebuild here. The body rebuilds only when a real fix arrives.)
|
||||
|
||||
if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return;
|
||||
const tickDtSec = flightState._lastFleetTickMs
|
||||
? Math.min(
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
(nowMs - flightState._lastFleetTickMs) / 1000,
|
||||
)
|
||||
: 0.08;
|
||||
flightState._lastFleetTickMs = nowMs;
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = parts.icons._displayTime(
|
||||
flightState._scratchFleetRenderTime,
|
||||
);
|
||||
const tau = icons.seconds(renderTime);
|
||||
|
||||
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
||||
if (flightState._cockpitContactMode)
|
||||
parts.tracking._refreshCockpitNearContacts();
|
||||
const poseSig = cameraPoseSignature(camera);
|
||||
// Only nearby Cockpit silhouettes need projected course; far dots remain
|
||||
// rotation-free through the per-contact gate below.
|
||||
const doRotations =
|
||||
poseSig !== flightState._lastCamPoseSig ||
|
||||
nowMs - flightState._lastRotPassMs >= ROTATION_REFRESH_MS;
|
||||
if (doRotations) {
|
||||
flightState._lastCamPoseSig = poseSig;
|
||||
flightState._lastRotPassMs = nowMs;
|
||||
}
|
||||
|
||||
const occluder = horizonOccluder(camera);
|
||||
parts.icons._replanExpired(tau);
|
||||
const focusTarget = getFocusTarget();
|
||||
parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget);
|
||||
|
||||
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
|
||||
// Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume.
|
||||
// Drop all models the moment we leave it (toggled off / zoomed out) so icons resume.
|
||||
const useModels = _modelRegimeActive();
|
||||
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
|
||||
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
|
||||
@@ -891,240 +827,133 @@ export function createRendering({
|
||||
(flightState._models.size || flightState._modelPending.size)
|
||||
)
|
||||
_releaseModels();
|
||||
if (!useModels) return;
|
||||
|
||||
// 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap —
|
||||
// mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen
|
||||
// new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP
|
||||
// is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding).
|
||||
let modelEligible = null;
|
||||
if (useModels) {
|
||||
const cap = _modelCap();
|
||||
const camPos = camera.positionWC;
|
||||
const addM = _modelAddDistM();
|
||||
const addDistSq = addM * addM;
|
||||
const keepM = _modelKeepDistM();
|
||||
const keepDistSq = keepM * keepM;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
camPos,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
// 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
|
||||
// already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius,
|
||||
// nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots
|
||||
// (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint).
|
||||
// Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an
|
||||
// on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap
|
||||
// with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum
|
||||
// filtering dropped near off-screen planes entirely. This does both right.
|
||||
const cap = _modelCap();
|
||||
const camPos = camera.positionWC;
|
||||
const addM = _modelAddDistM();
|
||||
const addDistSq = addM * addM;
|
||||
const keepM = _modelKeepDistM();
|
||||
const keepDistSq = keepM * keepM;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
camPos,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
|
||||
const cand = [];
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao = icons.idAt(row);
|
||||
if (icao === flightState._trackedIcao) continue;
|
||||
// A converted TR-3B renders as an icon and can never take a model, so
|
||||
// it must not occupy a CAP SLOT either — excluded here at selection time,
|
||||
// not just at the handoff below, or accumulated conversions would starve
|
||||
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
|
||||
if (isTr3b(icao)) continue;
|
||||
// Ground planes compete for model slots like everyone else (owner decision
|
||||
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
|
||||
// ground or in the air — no distinction"). The cap + nearest-first ordering
|
||||
// below already bound airport clusters; grounded placement is handled by the
|
||||
// one-shot ground snap in _modelDisplayPosition.
|
||||
const position = icons.position(
|
||||
row,
|
||||
tau,
|
||||
flightState._scratchModelBS.center,
|
||||
);
|
||||
const cand = [];
|
||||
for (const [icao, bb] of flightState._billboards) {
|
||||
if (icao === flightState._trackedIcao) continue;
|
||||
// A converted TR-3B renders as a billboard and can never take a model, so
|
||||
// it must not occupy a CAP SLOT either (mirror of flights.js) — excluded
|
||||
// at selection time, not just at the handoff below.
|
||||
if (isTr3b(icao)) continue;
|
||||
// Ground planes compete for model slots like everyone else (owner decision
|
||||
// 2026-07-03, mirror of flights.js — no air/ground distinction; 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 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]);
|
||||
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
||||
console.warn(
|
||||
`[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen prioritized.`,
|
||||
);
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
toRelease.push(icao);
|
||||
}
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
const d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
|
||||
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
|
||||
cand.push([
|
||||
icao,
|
||||
d2,
|
||||
cull.computeVisibility(flightState._scratchModelBS) !==
|
||||
Cesium.Intersect.OUTSIDE,
|
||||
]);
|
||||
}
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
||||
const icon = billboardState(bb);
|
||||
|
||||
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
|
||||
// Gate the write — assigning Billboard.position dirties the whole
|
||||
// collection's vertex buffer, so skip sub-meter moves.
|
||||
if (dr && Cesium.Cartesian3.distanceSquared(dr, icon.position) > 1.0) {
|
||||
setBillboard(bb, 'position', dr);
|
||||
}
|
||||
|
||||
// Round 6: occlusion-test a LIFTED point for contacts at/below the
|
||||
// ellipsoid (mirror of flights.js — sub-ellipsoid points near the limb
|
||||
// read "beyond the horizon" and would hide low contacts awaiting floors).
|
||||
const beyondHorizon = !occluder.isPointVisible(
|
||||
flightState.records.data.get(icao24)?.cullPosition || icon.position,
|
||||
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
||||
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
||||
console.warn(
|
||||
`[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`,
|
||||
);
|
||||
// A billboard flipping INTO view (horizon reveal while the camera idles)
|
||||
// gets its rotation refreshed THIS tick even without a pose change —
|
||||
// otherwise it reappears wearing its stale (often creation-north) nose for
|
||||
// up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false
|
||||
// here; harmless — the model branch below `continue`s past the rotation.)
|
||||
const revealed = !beyondHorizon && !icon.show;
|
||||
if (icon.show === beyondHorizon) setBillboard(bb, 'show', !beyondHorizon);
|
||||
if (beyondHorizon) {
|
||||
// Also hide any 3D model — otherwise a model that crossed the limb would keep
|
||||
// rendering through the hidden globe at its last matrix.
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
const modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
toRelease.push(icao);
|
||||
}
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
|
||||
// 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown
|
||||
// until the model is actually READY to render, so a plane is never both iconless AND
|
||||
// modelless (the "planes vanish when 3D turns on" bug). Position the model every tick
|
||||
// regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on
|
||||
// purpose: the Easter egg IS the triangle, and there is no GLB for it.
|
||||
const occluder = horizonOccluder(camera);
|
||||
for (const icao24 of modelEligible) {
|
||||
if (isTr3b(icao24)) continue;
|
||||
const row = icons.row(icao24);
|
||||
if (row < 0) continue;
|
||||
const position = icons.position(row, tau, flightState._scratchFleetPos);
|
||||
if (!parts.icons._aboveHorizon(row, position, occluder)) {
|
||||
// A model that crossed the limb would keep rendering through the
|
||||
// hidden globe at its last matrix; the icon's own cull is on the GPU.
|
||||
const m = flightState._models.get(icao24);
|
||||
if (m && modelState(m)?.show) setModel(m, 'show', false);
|
||||
continue;
|
||||
}
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
|
||||
// One sprite-owned write site composes freshness × focus × limb haze and
|
||||
// base class/ground scale × limb taper. The locked NearFarScalar remains
|
||||
// untouched and multiplicative; there is no cull or zero-alpha path.
|
||||
const cameraDistanceM = Cesium.Cartesian3.distance(
|
||||
camera.positionWC,
|
||||
icon.position,
|
||||
_ensureModel(icao24);
|
||||
const model = flightState._models.get(icao24);
|
||||
const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position);
|
||||
_driveFleetModelHandoff(
|
||||
icao24,
|
||||
model,
|
||||
row,
|
||||
position,
|
||||
icons.course(row, tau),
|
||||
() => {
|
||||
applyAircraftModelTreatment({
|
||||
model,
|
||||
// IR boost must survive the per-tick treatment write — otherwise
|
||||
// any alpha change would repaint the ordinary tint over the hot
|
||||
// white. Boosted models also skip the recession fade: hot targets
|
||||
// stay full-strength at any range (icons keep their normal
|
||||
// fade — full-opacity glyph walls read as overwhelming).
|
||||
baseColor: flightState._irBoost
|
||||
? Cesium.Color.WHITE
|
||||
: _modelColor(icao24),
|
||||
alpha: flightState._irBoost
|
||||
? 1
|
||||
: aircraftTreatmentAlpha({
|
||||
baseAlpha: icons.alpha(row),
|
||||
focusFactor: icons.focus(row),
|
||||
cameraDistanceM,
|
||||
cameraHeightM: camera.positionCartographic?.height,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
const distanceScale = nearFarScalarValueAtDistance(
|
||||
icon.scaleByDistance,
|
||||
cameraDistanceM,
|
||||
);
|
||||
const focus = advanceProjectedSpriteFocus(
|
||||
bb,
|
||||
icon.position,
|
||||
scene,
|
||||
camera,
|
||||
nowMs,
|
||||
focusTarget,
|
||||
undefined,
|
||||
(icon.width || 20) * (icon.scale || 1) * distanceScale * 0.5,
|
||||
(icon.height || 20) * (icon.scale || 1) * distanceScale * 0.5,
|
||||
);
|
||||
const isCockpitNear =
|
||||
flightState._cockpitContactMode &&
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const baseColor = _fleetBillboardColor(icao24, info);
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
baseScale:
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? 1
|
||||
: _militaryBillboardScale(icao24),
|
||||
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 — mirror of flights.js: swap 64/192 px rasters on
|
||||
// the billboard's ACTUAL on-screen size (post-treatment bb.scale, so
|
||||
// focus/limb recession counts) with hysteresis (atlas has no mips).
|
||||
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, flightState.records.data.get(icao24)?.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.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.speedMps) ?? 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 (mirror of flights.js): the Easter
|
||||
// egg IS the triangle and there is no GLB for it, so the model handoff is
|
||||
// suppressed. The billboard keeps rendering, so the contact still satisfies
|
||||
// the getNearby/getDetectableObjects visibility guards.
|
||||
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; boosted
|
||||
// models also skip the recession fade (mirror of flights.js —
|
||||
// billboards keep their normal fade, hot MODELS stay full-strength).
|
||||
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 {
|
||||
_groundDepthDistance,
|
||||
_modelSpec,
|
||||
_normalBillboardScaleByDistance,
|
||||
_cockpitBillboardScaleByDistance,
|
||||
_militaryBillboardScale,
|
||||
_applyFleetBillboardPresentation,
|
||||
_fleetBillboardScale,
|
||||
_cockpitContactColor,
|
||||
_iconKind,
|
||||
_modelColor,
|
||||
_fleetBillboardColor,
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
liftRepeatedGroundFix,
|
||||
synthesizeForwardKinematicsFix,
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
|
||||
import { addBillboard, setBillboard } from '../../data/billboardMirror.js';
|
||||
|
||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||
export function createMilitarySnapshotRenderer({
|
||||
@@ -20,7 +15,7 @@ export function createMilitarySnapshotRenderer({
|
||||
militaryRegistry,
|
||||
rendering,
|
||||
tracking,
|
||||
motion,
|
||||
icons,
|
||||
queries,
|
||||
}) {
|
||||
const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } =
|
||||
@@ -28,6 +23,8 @@ export function createMilitarySnapshotRenderer({
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
const { registerMilitaryIcaos } = militaryRegistry;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
// Before init (or after destroy) there is no icon set to draw into.
|
||||
if (!flightState._icons) return { count: 0, ids: new Set() };
|
||||
const currentIcaos = new Set();
|
||||
const receiptNowMs = snapshot.observedAtMs;
|
||||
// Field-test fix (RS46): coarse floor cells to warm for the below-ground
|
||||
@@ -55,12 +52,6 @@ export function createMilitarySnapshotRenderer({
|
||||
meta.renderAltitudeM,
|
||||
);
|
||||
if (groundFlipped) flightState._groundSnap.forget(icao24);
|
||||
flightState._cullPositions.set(
|
||||
icao24,
|
||||
meta.renderAltitudeM < 10
|
||||
? Cesium.Cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12)
|
||||
: null,
|
||||
);
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
|
||||
// Append to position history stamped with the FEED's fix epoch.
|
||||
@@ -120,63 +111,19 @@ export function createMilitarySnapshotRenderer({
|
||||
}
|
||||
}
|
||||
|
||||
if (flightState._billboards.has(icao24)) {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (!isTracked) {
|
||||
bb.color = rendering
|
||||
._fleetBillboardColor(icao24, meta)
|
||||
.withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
// Position AND rotation are owned by the fleet pass (_fleetTick);
|
||||
// course changes land on the next rotation pass (forced below).
|
||||
// Ground flips (landing/takeoff) restyle this SAME billboard in
|
||||
// place — the transition is never a removal.
|
||||
if (
|
||||
prevMeta?.klass !== meta.klass ||
|
||||
groundFlipped ||
|
||||
flightState._cockpitContactMode
|
||||
) {
|
||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
if (flightState._icons.has(icao24)) {
|
||||
// Reclassification, a ground flip (a restyle, never a removal), a
|
||||
// freshness change and the cockpit contract all land on the row's
|
||||
// style; motion re-plans from the updated history.
|
||||
icons._applyIconPresentation(icao24);
|
||||
icons._planIcon(icao24);
|
||||
// Per-class GLBs: a class change can mean a different asset OR scale —
|
||||
// resync the live model, any in-flight load, and the tracked
|
||||
// standalone (mirror of flights.js).
|
||||
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
|
||||
// Round 5: depth policy is uniform (always depth-test-free, see
|
||||
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
||||
} else {
|
||||
// Start where the fleet pass will draw it: the fleet renders behind
|
||||
// real time, so a fresh contact's displayed position is its fix
|
||||
// projected back to that delayed time. Creating it at the raw fix drew
|
||||
// it ahead until the next fleet tick snapped it back.
|
||||
const displayed =
|
||||
motion._deadReckon(icao24, new Cesium.Cartesian3()) || position;
|
||||
const bb = addBillboard(flightState._billboardCollection, {
|
||||
position: displayed,
|
||||
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
||||
width: isTracked ? 24 : 20,
|
||||
height: isTracked ? 24 : 20,
|
||||
scale:
|
||||
BILLBOARD_SCALE *
|
||||
(CLASS_SCALE_2D[meta.klass] || 1) *
|
||||
(meta.onGround ? GROUND_SCALE : 1),
|
||||
// Screen-projected rotation lands on the next fleet tick.
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
color: isTracked
|
||||
? TRACKED_ICON_COLOR
|
||||
: rendering._fleetBillboardColor(icao24, meta),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||
// surface — render them depth-test-free so they never vanish up
|
||||
// close (_groundDepthDistance).
|
||||
disableDepthTestDistance: rendering._groundDepthDistance(),
|
||||
id: icao24,
|
||||
show: !isTracked, // hidden if currently tracked (entity replaces it)
|
||||
});
|
||||
flightState._billboards.set(icao24, bb);
|
||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
// Hidden while tracked: the tracked entity replaces it.
|
||||
icons._addIcon(icao24);
|
||||
}
|
||||
|
||||
// Takeoff while TRACKED: ground traffic drew no trail, so start one
|
||||
@@ -200,7 +147,7 @@ export function createMilitarySnapshotRenderer({
|
||||
// Round 6: re-floor STALE grounded contacts (mirror of flights.js) —
|
||||
// a parked contact whose feed went quiet froze at its pre-warm height.
|
||||
// The model-ownership gate matches the live grounded-clamp path (a
|
||||
// tileset-snapped model owns its visual; moving its billboard would
|
||||
// tileset-snapped model owns its visual; moving its icon would
|
||||
// disturb groundSnap's input — the T7 one-shot invariant).
|
||||
for (const [icao24, info] of flightState.records.data) {
|
||||
if (!info?.onGround || currentIcaos.has(icao24)) continue;
|
||||
@@ -216,7 +163,6 @@ export function createMilitarySnapshotRenderer({
|
||||
)
|
||||
continue;
|
||||
info.renderAltitudeM = lifted;
|
||||
flightState._cullPositions.delete(icao24);
|
||||
const liftedPos = Cesium.Cartesian3.fromDegrees(
|
||||
info.rawLon,
|
||||
info.rawLat,
|
||||
@@ -226,18 +172,20 @@ export function createMilitarySnapshotRenderer({
|
||||
const newest = hist?.[hist.length - 1];
|
||||
if (newest)
|
||||
newest.position = Cesium.Cartesian3.clone(liftedPos, newest.position);
|
||||
const bbStale = flightState._billboards.get(icao24);
|
||||
if (bbStale) setBillboard(bbStale, 'position', liftedPos);
|
||||
if (flightState._icons.has(icao24)) {
|
||||
icons._applyIconPresentation(icao24);
|
||||
icons._planIcon(icao24);
|
||||
}
|
||||
}
|
||||
// Round 4: sample the RENDERED mesh for those cells (one-shot per cell,
|
||||
// budget-capped, viewer-proximate, google-3d regime only), excluding
|
||||
// this layer's own billboards/models from the probe.
|
||||
// this layer's own icons/models from the probe.
|
||||
{
|
||||
const viewerCarto =
|
||||
flightState._viewer?.camera?.positionCartographic || null;
|
||||
sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, {
|
||||
excludeObjects: [
|
||||
...flightState._billboards.values(),
|
||||
...icons._iconIds(),
|
||||
...flightState._models.values(),
|
||||
flightState._trackedModel,
|
||||
].filter(Boolean),
|
||||
@@ -256,8 +204,12 @@ export function createMilitarySnapshotRenderer({
|
||||
// EXCEPTION: likely-landed planes (last fix low + slow) get only
|
||||
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absentIds = [];
|
||||
for (let row = 0; row < flightState._icons.count; row++) {
|
||||
const icao24 = flightState._icons.idAt(row);
|
||||
if (!currentIcaos.has(icao24)) absentIds.push(icao24);
|
||||
}
|
||||
for (const icao24 of absentIds) {
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
@@ -265,8 +217,9 @@ export function createMilitarySnapshotRenderer({
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
icons._applyIconPresentation(icao24); // the faded freshness alpha
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
// Honest readout: the tracked plane has no faded billboard (its
|
||||
// Honest readout: the tracked plane has no faded icon (its
|
||||
// entity owns the visual), so refresh the label — with icao24 now
|
||||
// in _missingPolls, _buildTrackedLabel appends the STALE cue so
|
||||
// last-known altitude/speed aren't presented as live.
|
||||
@@ -277,7 +230,7 @@ export function createMilitarySnapshotRenderer({
|
||||
|
||||
// If the tracked flight is truly gone, clear tracking BEFORE deleting
|
||||
// its state (M3 ordering, keep it): teardown reads the maps this loop
|
||||
// is about to delete (billboard restore, DR cache reset), and we must
|
||||
// is about to delete (icon restore, DR cache reset), and we must
|
||||
// never leave the camera mid-follow with stale tracking state. The
|
||||
// camera is then RELEASED IN PLACE — it stays where the follow left
|
||||
// it, fully free (owner decision 2026-07-02: no overview flyTo).
|
||||
@@ -285,22 +238,12 @@ export function createMilitarySnapshotRenderer({
|
||||
tracking._clearTracking(false, { evicted: true });
|
||||
}
|
||||
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._billboards.delete(icao24);
|
||||
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||
records.forget(icao24);
|
||||
flightState._cullPositions.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
icons._forgetContact(icao24); // aged out: its 3D model goes too (no orphan / cap leak)
|
||||
}
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
// Feed the shared registry so the flights layer can classify/suppress
|
||||
registerMilitaryIcaos(currentIcaos);
|
||||
|
||||
return { count: flightState._billboards.size, ids: currentIcaos };
|
||||
return { count: flightState._icons.count, ids: currentIcaos };
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,6 @@ export function createFlightState({ source, services }) {
|
||||
...services.geoid,
|
||||
...services.groundFloor,
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
|
||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||
@@ -87,22 +86,24 @@ export function createFlightState({ source, services }) {
|
||||
new Cesium.Cartesian3(),
|
||||
1.0,
|
||||
);
|
||||
// frustum-visibility test
|
||||
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
|
||||
|
||||
flightState._billboardLimbScale = new WeakMap();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */
|
||||
/** Every contact's icon row (see icons.js); null outside a layer lifetime. */
|
||||
|
||||
flightState._billboardCollection = null;
|
||||
flightState._icons = null;
|
||||
|
||||
/** @type {Map<string, Cesium.Billboard>} ICAO hex -> billboard primitive */
|
||||
/** The primitive drawing `_icons`. */
|
||||
|
||||
flightState._billboards = new Map();
|
||||
flightState._iconPrimitive = null;
|
||||
|
||||
flightState._scratchIconRenderTime = new Cesium.JulianDate();
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -187,14 +188,6 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._lastFleetTickMs = 0;
|
||||
|
||||
/** @type {string} Camera pose signature at the last rotation pass */
|
||||
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
/** @type {number} Epoch ms of the last full rotation pass */
|
||||
|
||||
flightState._lastRotPassMs = 0;
|
||||
|
||||
/** @type {number} Last computed rotation for the tracked entity (radians) */
|
||||
|
||||
flightState._lastTrackedRotation = 0;
|
||||
@@ -225,8 +218,14 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._scratchRenderTime = new Cesium.JulianDate();
|
||||
|
||||
flightState._scratchFleetRenderTime = new Cesium.JulianDate();
|
||||
|
||||
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchCockpitPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchNearbyPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
||||
|
||||
flightState._scratchDrRaw = new Cesium.Cartesian3();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js';
|
||||
import { mirrorBillboard } from '../../data/billboardMirror.js';
|
||||
import { mirrorModel } from '../../data/modelMirror.js';
|
||||
import { createIconSet } from '../../data/iconSet.js';
|
||||
import { aircraftIconTestSeams } from '../../data/aircraftIconLayer.js';
|
||||
|
||||
export function createTesting({
|
||||
flightState,
|
||||
@@ -9,6 +11,8 @@ export function createTesting({
|
||||
layer,
|
||||
resolveAsset,
|
||||
}) {
|
||||
const { _seedIconForTest, _iconForTest, _setIconForTest } =
|
||||
aircraftIconTestSeams(flightState, parts, 'military');
|
||||
/**
|
||||
* Seed only the mutable state needed to exercise tracked-card refreshes through
|
||||
* the production military poll reconciler. Tests still call
|
||||
@@ -17,13 +21,14 @@ export function createTesting({
|
||||
* @param {string} state.icao24
|
||||
* @param {object} state.entity
|
||||
* @param {object} state.meta
|
||||
* @param {object} state.billboard
|
||||
* @param {object} state.billboardCollection
|
||||
* @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.billboard]
|
||||
* Where the contact's icon is held, and whether it is shown.
|
||||
* @param {{show?: boolean}} [state.billboardCollection] Layer visibility.
|
||||
* @param {object} state.viewer
|
||||
* @param {Array<Object>} [state.history=[]]
|
||||
* @param {boolean} [state.tracked=true]
|
||||
* @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed
|
||||
* by icao24, for the billboard-hidden/model-shown handoff state.
|
||||
* by icao24, for the icon-hidden/model-shown handoff state.
|
||||
*/
|
||||
|
||||
function _setTrackedMilitaryRefreshStateForTest({
|
||||
@@ -40,9 +45,11 @@ export function createTesting({
|
||||
}) {
|
||||
flightState._viewer = viewer;
|
||||
flightState._modelCollection = modelCollection;
|
||||
flightState._billboardCollection = billboardCollection;
|
||||
flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]);
|
||||
flightState._cullPositions.clear();
|
||||
flightState._icons = createIconSet({
|
||||
epoch: parts.icons._displayTime(new Cesium.JulianDate()),
|
||||
});
|
||||
flightState._icons.show = billboardCollection?.show ?? true;
|
||||
_seedIconForTest(icao24, billboard, tracked);
|
||||
flightState._models.clear();
|
||||
for (const [key, model] of models)
|
||||
flightState._models.set(key, mirrorModel(model));
|
||||
@@ -89,7 +96,7 @@ export function createTesting({
|
||||
billboard,
|
||||
history = [],
|
||||
}) {
|
||||
flightState._billboards.set(icao24, mirrorBillboard(billboard));
|
||||
_seedIconForTest(icao24, billboard, false);
|
||||
flightState.records.data.set(icao24, meta);
|
||||
flightState._positionHistory.set(icao24, history);
|
||||
}
|
||||
@@ -135,13 +142,13 @@ export function createTesting({
|
||||
: TRACKED_ICON_COLOR;
|
||||
}
|
||||
|
||||
/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */
|
||||
/** Drive the exact fleet icon-to-model handoff used by `_fleetTick`. */
|
||||
|
||||
function _driveFleetModelHandoffForTest({ icao24, position, course = 0 }) {
|
||||
return parts.rendering._driveFleetModelHandoff(
|
||||
icao24,
|
||||
flightState._models.get(icao24),
|
||||
flightState._billboards.get(icao24),
|
||||
flightState._icons.row(icao24),
|
||||
position,
|
||||
course,
|
||||
);
|
||||
@@ -153,6 +160,7 @@ export function createTesting({
|
||||
await parts.rendering._ensureModel(icao24);
|
||||
return flightState._models.get(icao24) || null;
|
||||
}
|
||||
|
||||
return {
|
||||
_setTrackedMilitaryRefreshStateForTest,
|
||||
_setMilitaryTrackingRefreshOutcomeForTest,
|
||||
@@ -164,6 +172,8 @@ export function createTesting({
|
||||
_updateTrackedModelForTest,
|
||||
_trackedBillboardColorForTest,
|
||||
_driveFleetModelHandoffForTest,
|
||||
_iconForTest,
|
||||
_setIconForTest,
|
||||
_ensureFleetModelForTest,
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,12 @@ import {
|
||||
RENDER_DELAY_SEC,
|
||||
TRAIL_COLOR,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
AMBER_TRANSPARENT,
|
||||
TRACKED_ICON_COLOR,
|
||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
|
||||
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -64,9 +62,9 @@ export function createTracking({
|
||||
}
|
||||
|
||||
function _publishTrackedSelection(icao24, origin = 'programmatic') {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
const position = parts.icons._iconPosition(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (!billboardState(bb)?.position || !info) return false;
|
||||
if (!position || !info) return false;
|
||||
if (flightState._trackedEntity)
|
||||
entityRecord(flightState._trackedEntity).selectionOrigin = origin;
|
||||
_emitAwarenessEvent('gev:awareness-subject-selected', {
|
||||
@@ -76,7 +74,7 @@ export function createTracking({
|
||||
parts.queries._toCleanText(info.callsign) ||
|
||||
parts.queries._toCleanText(info.registration) ||
|
||||
icao24,
|
||||
position: Cesium.Cartesian3.clone(billboardState(bb).position),
|
||||
position,
|
||||
origin,
|
||||
});
|
||||
selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
|
||||
@@ -152,9 +150,12 @@ export function createTracking({
|
||||
}
|
||||
const previous = flightState._cockpitNearContacts;
|
||||
const distancesSquared = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
const position = billboardState(bb)?.position;
|
||||
if (icao24 === flightState._trackedIcao || !position) continue;
|
||||
const icons = flightState._icons;
|
||||
const tau = parts.icons._displayTau();
|
||||
for (let row = 0; row < (icons?.count ?? 0); row++) {
|
||||
const icao24 = icons.idAt(row);
|
||||
if (icao24 === flightState._trackedIcao) continue;
|
||||
const position = icons.position(row, tau, flightState._scratchCockpitPos);
|
||||
distancesSquared.push([
|
||||
icao24,
|
||||
Cesium.Cartesian3.distanceSquared(
|
||||
@@ -170,13 +171,10 @@ export function createTracking({
|
||||
parts.rendering._modelKeepDistM(),
|
||||
);
|
||||
flightState._cockpitNearContacts = next;
|
||||
let presentationChanged = false;
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (previous.has(icao24) === next.has(icao24)) continue;
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
presentationChanged = true;
|
||||
}
|
||||
if (presentationChanged) flightState._lastCamPoseSig = '';
|
||||
for (const icao24 of previous)
|
||||
if (!next.has(icao24)) parts.icons._applyIconPresentation(icao24);
|
||||
for (const icao24 of next)
|
||||
if (!previous.has(icao24)) parts.icons._applyIconPresentation(icao24);
|
||||
}
|
||||
|
||||
/** Switch all current and future ambient contacts between silhouettes and cockpit pips. */
|
||||
@@ -193,9 +191,9 @@ export function createTracking({
|
||||
if (flightState._modelCollection) flightState._modelCollection.show = true;
|
||||
flightState._trail?.setVisible(!next);
|
||||
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
|
||||
for (const [icao24, bb] of flightState._billboards)
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
flightState._lastCamPoseSig = '';
|
||||
const icons = flightState._icons;
|
||||
for (let row = 0; row < (icons?.count ?? 0); row++)
|
||||
parts.icons._applyIconPresentation(icons.idAt(row));
|
||||
flightState._lastFleetTickMs = 0;
|
||||
}
|
||||
|
||||
@@ -297,29 +295,28 @@ export function createTracking({
|
||||
if (isTr3b(id)) {
|
||||
if (flightState._models.has(id) || flightState._modelPending.has(id))
|
||||
parts.rendering._releaseModel(id);
|
||||
const modelled = flightState._billboards.get(id);
|
||||
if (modelled && id !== flightState._trackedIcao)
|
||||
setBillboard(modelled, 'show', true); // horizon cull re-asserts next tick
|
||||
const row = flightState._icons?.row(id) ?? -1;
|
||||
if (row >= 0 && id !== flightState._trackedIcao)
|
||||
flightState._icons.setShown(row, true);
|
||||
if (id === flightState._trackedIcao) {
|
||||
parts.rendering._releaseTrackedModel();
|
||||
_syncTracked2dRotation();
|
||||
}
|
||||
}
|
||||
const bb = flightState._billboards.get(id);
|
||||
if (bb) parts.rendering._applyFleetBillboardPresentation(id, bb);
|
||||
parts.icons._applyIconPresentation(id);
|
||||
if (id === flightState._trackedIcao) {
|
||||
_syncTrackedBillboardImage();
|
||||
_updateTrackedLabelModel(id);
|
||||
}
|
||||
flightState._viewer?.scene?.requestRender?.();
|
||||
return flightState._billboards.has(id) || id === flightState._trackedIcao;
|
||||
return flightState._icons?.has(id) || id === flightState._trackedIcao;
|
||||
}
|
||||
|
||||
/** Re-image every converted contact this layer owns (IR style flip). */
|
||||
|
||||
function _refreshTr3bForStyle() {
|
||||
for (const id of tr3bConvertedIds()) {
|
||||
if (flightState._billboards.has(id) || id === flightState._trackedIcao)
|
||||
if (flightState._icons?.has(id) || id === flightState._trackedIcao)
|
||||
_refreshTr3bContact(id);
|
||||
}
|
||||
}
|
||||
@@ -415,8 +412,7 @@ export function createTracking({
|
||||
if (!flightState._trackedIcao || !flightState._viewer) return;
|
||||
const pos =
|
||||
parts.motion._trackedDisplayCached() ||
|
||||
billboardState(flightState._billboards.get(flightState._trackedIcao))
|
||||
?.position;
|
||||
parts.icons._iconPosition(flightState._trackedIcao);
|
||||
if (!pos) return;
|
||||
const projected = screenProjectedRotation(
|
||||
flightState._viewer.scene,
|
||||
@@ -745,7 +741,7 @@ export function createTracking({
|
||||
|
||||
/**
|
||||
* Stop tracking the current aircraft and clean up all tracking state.
|
||||
* Restores the original billboard, removes the tracked Entity, invalidates
|
||||
* Hands the contact back to its icon, removes the tracked Entity, invalidates
|
||||
* the per-frame DR cache, and RELEASES the camera IN PLACE — no flyTo
|
||||
* (mirror of flights.js). Deselect used to fly an ~80 km pulled-back
|
||||
* overview; the owner field-ruled that wrong (2026-07-02: "it randomly zooms
|
||||
@@ -777,34 +773,9 @@ export function createTracking({
|
||||
const clearedIcao = flightState._trackedIcao;
|
||||
clearFocusTarget('militaryFlights', clearedIcao);
|
||||
|
||||
// Restore the original billboard appearance. The rotation is re-seeded from
|
||||
// the tracked entity's last rendered rotation and a rotation pass is forced
|
||||
// (_lastCamPoseSig below): the fleet billboard otherwise reappears with the
|
||||
// STALE screen rotation it had when tracking began — up to a full
|
||||
// ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release.
|
||||
if (flightState._billboards.has(flightState._trackedIcao)) {
|
||||
const bb = flightState._billboards.get(flightState._trackedIcao);
|
||||
const meta = flightState.records.data.get(flightState._trackedIcao);
|
||||
setBillboard(bb, 'show', true);
|
||||
setBillboard(bb, 'width', 20);
|
||||
setBillboard(bb, 'height', 20);
|
||||
// Ground-aware restore (a plane untracked while taxiing comes back at
|
||||
// ground scale; the fleet tint is amber, or red while broadcasting an
|
||||
// emergency, on the ground and in the air).
|
||||
setBillboard(
|
||||
bb,
|
||||
'scale',
|
||||
BILLBOARD_SCALE *
|
||||
(CLASS_SCALE_2D[meta?.klass] || 1) *
|
||||
(meta?.onGround ? GROUND_SCALE : 1),
|
||||
);
|
||||
bb.color = parts.rendering._fleetBillboardColor(
|
||||
flightState._trackedIcao,
|
||||
meta,
|
||||
);
|
||||
setBillboard(bb, 'rotation', flightState._lastTrackedRotation);
|
||||
}
|
||||
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
|
||||
// The nose the icon continues from (the shared _displayCourse entry), so
|
||||
// release never flips it.
|
||||
const trackedCourseDeg = flightState._displayCourse.get(clearedIcao);
|
||||
|
||||
// Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the
|
||||
// follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now).
|
||||
@@ -820,10 +791,13 @@ export function createTracking({
|
||||
parts.rendering._releaseTrackedModel();
|
||||
_resetTrackedSelectionState(); // the zoom band + load-failure budget belong to the selection we just dropped
|
||||
flightState._trackedIcao = null;
|
||||
parts.rendering._applyFleetBillboardPresentation(
|
||||
clearedIcao,
|
||||
flightState._billboards.get(clearedIcao),
|
||||
);
|
||||
// Hand the contact back to its icon only now, once it no longer reads as
|
||||
// tracked (the settle plans it as a fleet contact).
|
||||
if (flightState._icons?.has(clearedIcao)) {
|
||||
flightState._icons.setShown(flightState._icons.row(clearedIcao), true);
|
||||
parts.icons._settleIcon(clearedIcao, { fromCourseDeg: trackedCourseDeg });
|
||||
}
|
||||
parts.icons._applyIconPresentation(clearedIcao);
|
||||
clearTrackedSubjectContext('military');
|
||||
_emitAwarenessEvent('gev:awareness-subject-cleared', {
|
||||
layerId: 'military',
|
||||
@@ -844,10 +818,7 @@ export function createTracking({
|
||||
pending.generation !== flightState._trackingIntentGeneration
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
!flightState._billboardCollection?.show ||
|
||||
!flightState._billboards.has(pending.id)
|
||||
)
|
||||
if (!flightState._icons?.show || !flightState._icons.has(pending.id))
|
||||
return false;
|
||||
flightState._pendingTrackingRestore = null;
|
||||
_trackFlight(pending.id, { origin: pending.origin });
|
||||
@@ -861,7 +832,7 @@ export function createTracking({
|
||||
|
||||
/**
|
||||
* Begin tracking a specific aircraft. Clears any previous tracking, hides the
|
||||
* billboard, creates a new Entity with dead-reckoning CallbackProperties for
|
||||
* icon, creates a new Entity with dead-reckoning CallbackProperties for
|
||||
* smooth continuous motion, and sets the viewer's trackedEntity so the camera
|
||||
* follows.
|
||||
* @param {string} icao24 - ICAO hex identifier of the aircraft to track
|
||||
@@ -870,29 +841,42 @@ export function createTracking({
|
||||
function _trackFlight(icao24, { origin = 'programmatic' } = {}) {
|
||||
_clearTracking(false, { origin }); // switching planes — the new follow-camera takes over
|
||||
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
const icons = flightState._icons;
|
||||
const row = icons?.row(icao24) ?? -1;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (!bb || !info) return;
|
||||
if (row < 0 || !info) return;
|
||||
|
||||
// The tracked limiter continues from the nose the icon was drawn with, and
|
||||
// the entity starts with the rotation the icon had on screen.
|
||||
const tau = parts.icons._displayTau();
|
||||
const iconPosition = icons.position(row, tau);
|
||||
flightState._displayCourse.set(icao24, icons.course(row, tau));
|
||||
flightState._trackedIcao = icao24;
|
||||
_resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._lastTrackedRotation = billboardState(bb).rotation || 0;
|
||||
flightState._lastTrackedRotation =
|
||||
screenProjectedRotation(
|
||||
flightState._viewer.scene,
|
||||
iconPosition,
|
||||
icons.course(row, tau),
|
||||
0,
|
||||
) ?? 0;
|
||||
// Drop any fleet 3D model for this aircraft — the tracked entity now owns its visual (its
|
||||
// own billboard + model graphic), and the fleet tick skips the tracked icao, so a leftover
|
||||
// fleet model would be orphaned + double-rendered.
|
||||
parts.rendering._releaseModel(icao24);
|
||||
|
||||
// Hide the billboard -- the tracked entity replaces it visually
|
||||
setBillboard(bb, 'show', false);
|
||||
// Hide the icon -- the tracked entity replaces it visually
|
||||
icons.setShown(row, false);
|
||||
|
||||
// Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition —
|
||||
// one computation shared by the position/rotation/trail-head callbacks, with
|
||||
// discontinuity reconciliation). Falls back to the last billboard position when the
|
||||
// discontinuity reconciliation). Falls back to the icon's position when the
|
||||
// aircraft has no fix.
|
||||
const getTrackedPosition = () =>
|
||||
parts.motion._trackedDisplayPosition(icao24) ||
|
||||
billboardState(bb).position;
|
||||
parts.icons._iconPosition(icao24) ||
|
||||
iconPosition;
|
||||
|
||||
// Dead-reckoning position property evaluated every render frame.
|
||||
// isConstant = false so CesiumJS re-evaluates each frame.
|
||||
@@ -958,7 +942,7 @@ export function createTracking({
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
// The tracked target must never vanish into tile geometry — tracking a
|
||||
// taxiing plane at street level would otherwise bury the amber icon inside
|
||||
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
|
||||
// the runway skin, so it draws depth-test-free like the fleet icon set;
|
||||
// its shared-host tracked card is top-composited separately.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
||||
@@ -1120,26 +1104,12 @@ export function createTracking({
|
||||
return;
|
||||
}
|
||||
|
||||
// BillboardCollection picks: CesiumJS may expose the billboard as
|
||||
// picked.primitive (with .id = icao24) or directly as picked.id
|
||||
const billboard = picked.primitive;
|
||||
if (
|
||||
billboard &&
|
||||
billboard.id &&
|
||||
flightState._billboards.has(billboard.id)
|
||||
) {
|
||||
// Icon picks carry the icao on picked.id; models carry it on the primitive.
|
||||
const pickedIcao =
|
||||
typeof picked.id === 'string' ? picked.id : picked.primitive?.id;
|
||||
if (pickedIcao && flightState._icons?.has(pickedIcao)) {
|
||||
_cancelPendingTrackingRestore();
|
||||
_trackFlight(billboard.id, { origin: 'user' });
|
||||
return;
|
||||
}
|
||||
// Fallback: some CesiumJS versions surface the id string at picked.id
|
||||
if (
|
||||
picked.id &&
|
||||
typeof picked.id === 'string' &&
|
||||
flightState._billboards.has(picked.id)
|
||||
) {
|
||||
_cancelPendingTrackingRestore();
|
||||
_trackFlight(picked.id, { origin: 'user' });
|
||||
_trackFlight(pickedIcao, { origin: 'user' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,15 @@ export function readLayerSource(file) {
|
||||
? '../layers/cctv'
|
||||
: '../layers/vessels',
|
||||
);
|
||||
// The aircraft layers draw through one shared icon layer; it is their code too.
|
||||
const shared = ['flights.js', 'militaryFlights.js'].includes(basename(path))
|
||||
? [join(dirname(path), 'aircraftIconLayer.js')]
|
||||
: [];
|
||||
return readdirSync(directory)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(directory, name), 'utf8'))
|
||||
.map((name) => join(directory, name))
|
||||
.concat(shared)
|
||||
.map((file) => readFileSync(file, 'utf8'))
|
||||
.join('\n');
|
||||
}
|
||||
@@ -706,7 +706,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
);
|
||||
assert.match(
|
||||
militaryRendering,
|
||||
/baseAlpha:[\s\S]*?alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
|
||||
/alpha: flightState\._irBoost\s*\?\s*1\s*:\s*aircraftTreatmentAlpha\(/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
|
||||
Reference in new issue
Block a user