diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 731b9da19..631fb2f6d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs index 27deb2b46..59516787a 100644 --- a/src/data/aircraftEmergencyAnalyst.test.mjs +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -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); } }); diff --git a/src/data/aircraftIconLayer.js b/src/data/aircraftIconLayer.js new file mode 100644 index 000000000..e7ea0db82 --- /dev/null +++ b/src/data/aircraftIconLayer.js @@ -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}} 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 }; +} diff --git a/src/data/aircraftMotionRegime.js b/src/data/aircraftMotionRegime.js new file mode 100644 index 000000000..dcbe36e26 --- /dev/null +++ b/src/data/aircraftMotionRegime.js @@ -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} 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; +} diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 2c8bc5f28..0afc0fb96 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -83,24 +83,24 @@ for (const layer of LAYERS) { test(`${layer.name}: near AIR state is independent from model admission`, () => { assert.match(source, /nextCockpitNearContacts\(/, 'Cockpit derives a separate near-contact hysteresis set'); - assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)|!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\)\);/); }); } diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 529544d2d..606dc8440 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -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', diff --git a/src/data/militaryFlights.js b/src/data/militaryFlights.js index db8276b02..dd7d8c902 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -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; diff --git a/src/data/nearbyModelHandoff.test.mjs b/src/data/nearbyModelHandoff.test.mjs index 449ec6a80..e540be618 100644 --- a/src/data/nearbyModelHandoff.test.mjs +++ b/src/data/nearbyModelHandoff.test.mjs @@ -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, diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index d2053832a..89b64c733 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -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;/, diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 072aab15f..fa0e705b2 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -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); }, }, ]; diff --git a/src/layers/flights/icons.js b/src/layers/flights/icons.js index 8bf4df65f..10967dc73 100644 --- a/src/layers/flights/icons.js +++ b/src/layers/flights/icons.js @@ -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 }; } diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index 37f2febc9..f8d76eb6c 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -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} 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 && diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 7b175bc41..1a6bf7b66 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -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); +}); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index e951d6cf5..dbe001624 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -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). */ diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 67eae40e4..67b9d14ef 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -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); diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index ddc8c864a..17260ec93 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -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 diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index b5df7a076..06a1b9061 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -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) { diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index d110dd2f1..99c2ab7e2 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -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 diff --git a/src/layers/military/icons.js b/src/layers/military/icons.js new file mode 100644 index 000000000..4d9a942ee --- /dev/null +++ b/src/layers/military/icons.js @@ -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, + }); +} diff --git a/src/layers/military/index.js b/src/layers/military/index.js index 53a1a25b4..f5c9a6ac7 100644 --- a/src/layers/military/index.js +++ b/src/layers/military/index.js @@ -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({ diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index c9e658e98..31eabd2e4 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -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(); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 9309d4378..36c068531 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -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, diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 3b77643d6..2741ea83d 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -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); +}); diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index bb1d89127..3dba916a3 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -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). */ diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 205284c7e..e2908fbb9 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -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} 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} 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) || diff --git a/src/layers/military/records.js b/src/layers/military/records.js index 7198d62da..b92542f5d 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -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). diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 6debe84a3..7cb1b2297 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -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, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 547359559..f1ad8f182 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -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 }; }; } diff --git a/src/layers/military/state.js b/src/layers/military/state.js index f1a2d691f..8b71c2299 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -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} 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(); diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index 5af1140df..b67ab7299 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -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} [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, }; } diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 24db00d62..99a8de205 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -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; } } diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index 4d07e0407..07a871241 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -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'); } diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 605876f73..da84f29d5 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -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,