diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ae9d2e2dd..f469c92da 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1315,6 +1315,7 @@ "src/layers/flights/policy.js", "src/layers/flights/queries.js", "src/layers/flights/rendering.js", + "src/data/billboardMirror.js", "src/layers/aircraft/visualState.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", @@ -1375,6 +1376,7 @@ "src/layers/military/policy.js", "src/layers/military/queries.js", "src/layers/military/rendering.js", + "src/data/billboardMirror.js", "src/layers/aircraft/visualState.js", "src/layers/military/state.js", "src/layers/military/testing.js", @@ -2320,6 +2322,7 @@ "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", "src/data/aircraftRecession.js", + "src/data/billboardMirror.js", "src/data/aisLiveVessels.js", "src/data/alprCameras.js", "src/data/analystEngine.js", @@ -3284,6 +3287,7 @@ "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", "src/data/aircraftRecession.js", + "src/data/billboardMirror.js", "src/data/cctvCards.js", "src/data/cctvFootprint.js", "src/data/cctvGizmo.js", diff --git a/src/data/aircraftRecession.js b/src/data/aircraftRecession.js index 04c41e604..984038d16 100644 --- a/src/data/aircraftRecession.js +++ b/src/data/aircraftRecession.js @@ -5,6 +5,7 @@ * with the locked NearFarScalar. It never edits that scalar, culls a contact, * or allows alpha to reach zero. */ +import { billboardState, setBillboard } from './billboardMirror.js'; export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({ startLimbRatio: 0.5, @@ -220,11 +221,12 @@ export function applyAircraftBillboardTreatment({ const alpha = baseAlpha * composedTreatment; let scaleWrites = 0; let alphaWrites = 0; + const pushedScale = billboardState(billboard)?.scale; if ( - !Number.isFinite(billboard.scale) || - Math.abs(billboard.scale - scale) > tuning.writeEpsilon + !Number.isFinite(pushedScale) || + Math.abs(pushedScale - scale) > tuning.writeEpsilon ) { - billboard.scale = scale; + setBillboard(billboard, 'scale', scale); scaleWrites = 1; } if ( diff --git a/src/data/aircraftRecession.test.mjs b/src/data/aircraftRecession.test.mjs index 23b8ceffb..7173a36d2 100644 --- a/src/data/aircraftRecession.test.mjs +++ b/src/data/aircraftRecession.test.mjs @@ -7,6 +7,7 @@ import { applyAircraftModelTreatment, cameraLimbDistanceM, } from './aircraftRecession.js'; +import { mirrorBillboard } from './billboardMirror.js'; const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS }; const cameraHeightM = 1_000_000; @@ -68,7 +69,7 @@ test('globe-view transition eases to identity without a threshold pop', () => { test('aircraft billboard wire writes scale and alpha only for the far treatment', () => { const color = { withAlpha: (alpha) => ({ alpha }) }; - const near = { scale: 1.2, color: { alpha: 0.8 } }; + const near = mirrorBillboard({ scale: 1.2, color: { alpha: 0.8 } }); const nearResult = applyAircraftBillboardTreatment({ billboard: near, baseScale: 1.2, @@ -82,7 +83,7 @@ test('aircraft billboard wire writes scale and alpha only for the far treatment' assert.equal(nearResult.scaleWrites, 0); assert.equal(nearResult.alphaWrites, 0); - const far = { scale: 1.2, color: { alpha: 0.8 } }; + const far = mirrorBillboard({ scale: 1.2, color: { alpha: 0.8 } }); const farResult = applyAircraftBillboardTreatment({ billboard: far, baseScale: 1.2, @@ -101,7 +102,7 @@ test('aircraft billboard wire writes scale and alpha only for the far treatment' test('combined focus and haze product is clamped at the composed alpha floor', () => { const color = { withAlpha: (alpha) => ({ alpha }) }; - const billboard = { scale: 0.45, color: { alpha: 1 } }; + const billboard = mirrorBillboard({ scale: 0.45, color: { alpha: 1 } }); const result = applyAircraftBillboardTreatment({ billboard, baseScale: 1, @@ -118,7 +119,7 @@ test('combined focus and haze product is clamped at the composed alpha floor', ( test('applyAircraftBillboardTreatment returns its documented module singleton', () => { const color = { withAlpha: (alpha) => ({ alpha }) }; - const billboard = { scale: 1, color: { alpha: 1 } }; + const billboard = mirrorBillboard({ scale: 1, color: { alpha: 1 } }); const input = { billboard, baseAlpha: 1, diff --git a/src/data/billboardMirror.js b/src/data/billboardMirror.js new file mode 100644 index 000000000..4084791e7 --- /dev/null +++ b/src/data/billboardMirror.js @@ -0,0 +1,106 @@ +/** + * @module billboardMirror + * @description Core's copy of what a layer pushed to its billboards, so the layer + * reads its own state back from Core instead of from the renderer's objects. + * + * A layer adds billboards with {@link addBillboard}, writes the mirrored fields + * with {@link setBillboard}, and reads them with {@link billboardState}. Fields + * outside the mirror (image, colour, eye offset, …) are written to the billboard + * directly. Colour stays out because the cyber-sonar theme writes it too and + * tells the two writers apart by reading the live value. + */ +import * as Cesium from 'cesium'; +import { createEntityStore } from './entityRecords.js'; + +const mirrors = createEntityStore(); + +/** + * Mirrored fields with Cesium's defaults for a billboard added without them. + * Width and height have none: unset, Cesium reports the loaded image's size, + * which Core cannot know, so {@link addBillboard} requires them. + */ +const DEFAULTS = Object.freeze({ + show: true, + scale: 1, + rotation: 0, + width: undefined, + height: undefined, + scaleByDistance: undefined, +}); + +/** Cesium copies positions and scalers on write; the mirror keeps its own copy too. */ +function copyInto(record, field, value) { + if (field === 'position') { + // Cesium keeps its old position when handed none; so does the mirror. + if (!value && record.position) return; + record.position = Cesium.Cartesian3.clone( + value ?? Cesium.Cartesian3.ZERO, + record.position, + ); + } else if (field === 'scaleByDistance') { + record.scaleByDistance = value + ? Cesium.NearFarScalar.clone(value, record.scaleByDistance) + : undefined; + } else { + record[field] = value; + } +} + +function seed(billboard, source) { + const record = mirrors.write(billboard); + copyInto(record, 'position', source.position); + for (const [field, fallback] of Object.entries(DEFAULTS)) + copyInto(record, field, source[field] ?? fallback); + return record; +} + +/** + * Add a billboard and remember what was pushed. + * @param {Cesium.BillboardCollection} collection + * @param {Object} options Billboard options, as for `collection.add`, with an + * explicit `width` and `height`. + * @returns {Cesium.Billboard} + */ +export function addBillboard(collection, options) { + if (!Number.isFinite(options?.width) || !Number.isFinite(options?.height)) + throw new TypeError( + 'A mirrored billboard needs an explicit width and height', + ); + const billboard = collection.add(options); + seed(billboard, options); + return billboard; +} + +/** + * Start mirroring a billboard Core did not add itself (test seams), from its + * current fields. + * @param {Object} billboard + * @returns {Object} The billboard. + */ +export function mirrorBillboard(billboard) { + if (billboard && typeof billboard === 'object') seed(billboard, billboard); + return billboard; +} + +/** + * Write one mirrored field to the billboard and to Core's copy. + * @param {Object} billboard + * @param {'show'|'position'|'scale'|'rotation'|'width'|'height'|'scaleByDistance'} field + * @param {*} value + */ +export function setBillboard(billboard, field, value) { + copyInto(mirrors.write(billboard), field, value); + billboard[field] = value; +} + +/** + * Core's copy of a billboard's mirrored fields, or null when it has none (or + * there is no billboard). + * @param {Object|null|undefined} billboard + * @returns {{ show: boolean, position: Cesium.Cartesian3, scale: number, + * rotation: number, width?: number, height?: number, + * scaleByDistance?: Cesium.NearFarScalar }|null} + */ +export function billboardState(billboard) { + return mirrors.read(billboard); +} diff --git a/src/data/billboardMirror.test.mjs b/src/data/billboardMirror.test.mjs new file mode 100644 index 000000000..b53b489ae --- /dev/null +++ b/src/data/billboardMirror.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + addBillboard, + billboardState, + mirrorBillboard, + setBillboard, +} from './billboardMirror.js'; + +const SIZE = Object.freeze({ width: 20, height: 20 }); + +function fakeCollection() { + return { add: (options) => ({ ...options }) }; +} + +test('an added billboard is mirrored with Cesium defaults for unset fields', () => { + const position = Cesium.Cartesian3.fromDegrees(10, 20, 30); + const bb = addBillboard(fakeCollection(), { position, width: 20, height: 24 }); + const state = billboardState(bb); + assert.equal(state.show, true); + assert.equal(state.scale, 1); + assert.equal(state.rotation, 0); + assert.equal(state.width, 20); + assert.equal(state.height, 24); + assert.ok(Cesium.Cartesian3.equals(state.position, position)); + assert.notEqual(state.position, position, 'the mirror keeps its own copy'); +}); + +test('writes reach both the billboard and the mirror', () => { + const bb = addBillboard(fakeCollection(), SIZE); + setBillboard(bb, 'show', false); + setBillboard(bb, 'scale', 0.5); + assert.equal(bb.show, false); + assert.equal(billboardState(bb).show, false); + assert.equal(billboardState(bb).scale, 0.5); +}); + +test('a reused scratch position does not move the mirror', () => { + const bb = addBillboard(fakeCollection(), SIZE); + const scratch = Cesium.Cartesian3.fromDegrees(1, 2, 3); + setBillboard(bb, 'position', scratch); + const written = Cesium.Cartesian3.clone(scratch); + Cesium.Cartesian3.fromDegrees(50, 50, 50, undefined, scratch); + assert.ok(Cesium.Cartesian3.equals(billboardState(bb).position, written)); +}); + +test('writing no position keeps the last one, as Cesium does', () => { + const position = Cesium.Cartesian3.fromDegrees(1, 2, 3); + const bb = addBillboard(fakeCollection(), { ...SIZE, position }); + setBillboard(bb, 'position', undefined); + assert.ok(Cesium.Cartesian3.equals(billboardState(bb).position, position)); +}); + +test('the mirror answers from Core, not from the billboard', () => { + const bb = addBillboard(fakeCollection(), { ...SIZE, show: true }); + bb.show = false; // a write that bypassed the mirror + assert.equal(billboardState(bb).show, true); +}); + +test('a billboard Core did not add can be mirrored from its fields', () => { + const bb = mirrorBillboard({ show: false, scale: 2 }); + assert.equal(billboardState(bb).show, false); + assert.equal(billboardState(bb).scale, 2); + assert.ok(Cesium.Cartesian3.equals(billboardState(bb).position, Cesium.Cartesian3.ZERO)); + assert.equal(billboardState(null), null); + assert.equal(mirrorBillboard(null), null); +}); + +test('a billboard without explicit dimensions is refused', () => { + // Unset, Cesium reports the image's size, which the mirror cannot know. + assert.throws(() => addBillboard(fakeCollection(), {}), TypeError); + assert.throws(() => addBillboard(fakeCollection(), { width: 20 }), TypeError); +}); diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index fcadcfa51..2f4930985 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -90,11 +90,11 @@ for (const layer of LAYERS) { // 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*\)/, 'near contacts and model fallbacks retain the class-derived aircraft silhouette'); - assert.match(source, /bb\.rotation\s*=\s*0;/, + assert.match(source, /setBillboard\(\s*bb,\s*'rotation',\s*0\s*\);/, '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*\)/, 'near 2D silhouettes continue to receive projected course'); - assert.match(source, /if\s*\(\s*bb\.show,?\s*\)\s*bb\.show\s*=\s*false;\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/, + assert.match(source, /if\s*\(\s*billboardState\(bb\)\.show,?\s*\)\s*setBillboard\(\s*bb,\s*'show',\s*false\s*\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/, 'the gap-proof billboard-to-model handoff remains intact'); }); diff --git a/src/data/routeSegment.test.mjs b/src/data/routeSegment.test.mjs index caa02c4ea..e48d4645d 100644 --- a/src/data/routeSegment.test.mjs +++ b/src/data/routeSegment.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { routePlausible } from './routePlausible.js'; import { createTracking } from '../layers/flights/tracking.js'; +import { addBillboard } from './billboardMirror.js'; const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' }; const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' }; @@ -156,8 +157,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro }); flightState._billboards.set( id, - collection.add({ + addBillboard(collection, { id, + width: 20, + height: 20, position: Cesium.Cartesian3.fromDegrees( position.lon, position.lat, diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index 9ba8c25c2..654dce572 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -245,7 +245,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria // 2. The billboard is never hidden by that suppression — it must keep // satisfying the getNearby/getDetectableObjects visibility guards, so a // converted contact still works in Contacts and Cockpit. - assert.match(source, /if\s*\(\s*bb\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*bb\.show\s*=\s*true;|if\s*\(\s*modelled\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*modelled\.show\s*=\s*true;/, + 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*\);/, `${name}: converting restores the billboard the model handoff had hidden`); // 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 17fec219d..d30adb4e8 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -45,6 +45,7 @@ import militaryFlightsLayer, { } from './militaryFlights.js'; import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js'; import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js'; +import { setBillboard } from './billboardMirror.js'; // Sample altitudes are expressed RELATIVE to the band rather than as absolute // offsets. The thresholds have already been retuned once (1_000_000 → 150_000 @@ -351,7 +352,7 @@ for (const fixture of LAYERS) { onGround: true, models: [[icao24, model]], }); - billboard.show = false; + setBillboard(billboard, 'show', false); let ownsVisual = fixture.driveFleetModel({ icao24, position, course: 92 }); assert.equal(ownsVisual, false, 'cold terrain cannot transfer ownership to 3D'); @@ -487,7 +488,7 @@ for (const fixture of LAYERS) { onGround: true, models: [[icao24, model]], }); - billboard.show = false; + setBillboard(billboard, 'show', false); const atA = Cesium.Cartesian3.fromDegrees(A_LON, LAT, 0); const atB = Cesium.Cartesian3.fromDegrees(B_LON, LAT, 0); @@ -622,7 +623,7 @@ for (const fixture of LAYERS) { 0, 1e-6, ), 'a hidden tracked model cannot steal the detection anchor'); - billboard.show = false; // tracking hides the fleet sprite; the tracked entity remains the 2D owner + setBillboard(billboard, 'show', false); // tracking hides the fleet sprite; the tracked entity remains the 2D owner const loadingNearby = fixture.layer.getNearby(billboard.position, 1_000, 50) .find((object) => object.icao24 === (fixture.name === 'flights' ? 'a0f001' : 'b0f001')); assert.ok(loadingNearby, 'the tracked contact remains in proximity while its model loads'); @@ -661,7 +662,7 @@ for (const fixture of LAYERS) { viewer.tileset.tilesLoaded = false; sampledHeight = undefined; - billboard.position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, -500); + setBillboard(billboard, 'position', Cesium.Cartesian3.fromDegrees(-97.71, 30.21, -500)); fixture.driveTrackedModel(); assert.equal(model.show, true, 'a successful cached snap survives a poll-time vertical datum change'); @@ -671,7 +672,7 @@ for (const fixture of LAYERS) { // Taxi past the resample threshold with the tiles gone: the tracked model // holds its last measured floor rather than handing the operator's own // selection back to a 2D glyph for a retry backoff. - billboard.position = Cesium.Cartesian3.fromDegrees(-97.709, 30.21, -500); + setBillboard(billboard, 'position', Cesium.Cartesian3.fromDegrees(-97.709, 30.21, -500)); fixture.driveTrackedModel(); assert.equal(model.show, true, 'a taxi-invalidated tracked snap is HELD through the resample outage'); @@ -680,7 +681,7 @@ for (const fixture of LAYERS) { assert.equal(sampleCalls, 1, 'the hold costs no extra synchronous sample'); // Past the drift bound the memory is released and the gate takes over. - billboard.position = Cesium.Cartesian3.fromDegrees(-97.707, 30.21, -500); + setBillboard(billboard, 'position', Cesium.Cartesian3.fromDegrees(-97.707, 30.21, -500)); fixture.driveTrackedModel(); assert.equal(model.show, false, 'past HELD_SNAP_MAX_DRIFT_M the hold is released and the model is withheld'); diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js index aaf8ceb38..a3b2984a4 100644 --- a/src/layers/flights/enrichment.js +++ b/src/layers/flights/enrichment.js @@ -9,6 +9,7 @@ import { ENRICH_AMBIENT_REFILL_WINDOW_MS, ENRICH_AMBIENT_PER_SWEEP, } from './policy.js'; +import { billboardState } from '../../data/billboardMirror.js'; export function createEnrichment({ flightState, @@ -199,11 +200,9 @@ export function createEnrichment({ if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) - if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb - Cesium.Cartesian3.clone( - bb.position, - flightState._scratchModelBS.center, - ); + const { position } = billboardState(bb); + if (!position || !occluder.isPointVisible(position)) continue; // beyond the limb + Cesium.Cartesian3.clone(position, flightState._scratchModelBS.center); if ( cull.computeVisibility(flightState._scratchModelBS) === Cesium.Intersect.OUTSIDE @@ -211,7 +210,7 @@ export function createEnrichment({ continue; // off-screen cand.push([ icao24, - Cesium.Cartesian3.distanceSquared(camPos, bb.position), + Cesium.Cartesian3.distanceSquared(camPos, position), ]); } cand.sort((a, b) => a[1] - b[1]); // nearest first — what the user is looking at resolves first diff --git a/src/layers/flights/evidence.js b/src/layers/flights/evidence.js index 243fe9b9a..49407daff 100644 --- a/src/layers/flights/evidence.js +++ b/src/layers/flights/evidence.js @@ -1,6 +1,11 @@ import * as Cesium from 'cesium'; import { aircraftIcon } from '../../data/aircraftIcons.js'; import { FOCUS_EVIDENCE_DEV } from './policy.js'; +import { + addBillboard, + billboardState, + setBillboard, +} from '../../data/billboardMirror.js'; export function createEvidence({ flightState, @@ -83,7 +88,7 @@ export function createEvidence({ flightState.records.data.set(id, meta); flightState._cullPositions.delete(id); flightState._focusEvidenceIds.add(id); - const bb = flightState._billboardCollection.add({ + const bb = addBillboard(flightState._billboardCollection, { position, image: aircraftIcon(parts.rendering._iconKind(id, klass)), width: 20, @@ -119,7 +124,7 @@ export function createEvidence({ const position = _focusEvidencePosition(record); const bb = flightState._billboards.get(id); if (!position || !bb) continue; - bb.position = position; + setBillboard(bb, 'position', position); const meta = flightState.records.data.get(id); if (meta) { if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg; @@ -139,23 +144,23 @@ export function createEvidence({ if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return []; return [...flightState._focusEvidenceIds].map((id) => { const bb = flightState._billboards.get(id); - const screen = bb?.position + const screen = billboardState(bb)?.position ? Cesium.SceneTransforms.worldToWindowCoordinates( flightState._viewer.scene, - bb.position, + billboardState(bb).position, ) : null; return { id, - show: bb?.show === true, - scale: bb?.scale ?? null, + show: billboardState(bb)?.show === true, + scale: billboardState(bb)?.scale ?? null, alpha: bb?.color?.alpha ?? null, x: screen?.x ?? null, y: screen?.y ?? null, - cameraDistanceM: bb?.position + cameraDistanceM: billboardState(bb)?.position ? Cesium.Cartesian3.distance( flightState._viewer.camera.positionWC, - bb.position, + billboardState(bb).position, ) : null, }; diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index 87a0a402b..d4ae59845 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -35,6 +35,7 @@ import { DISPLAY_CORRIDOR_CELL_BUDGET, DISPLAY_CORRIDOR_FAIR_SHARE, } from './policy.js'; +import { billboardState, setBillboard } from '../../data/billboardMirror.js'; export function createMotion({ flightState, @@ -109,7 +110,7 @@ export function createMotion({ if (newest) newest.position = Cesium.Cartesian3.clone(position, newest.position); const bb = flightState._billboards.get(icao24); - if (bb) bb.position = position; + if (bb) setBillboard(bb, 'position', position); } } @@ -131,7 +132,9 @@ export function createMotion({ function _deadReckon(icao24, result, sampledRenderTime) { const info = flightState.records.data.get(icao24); if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) { - const position = flightState._billboards.get(icao24)?.position; + const position = billboardState( + flightState._billboards.get(icao24), + )?.position; flightState._drCourseDeg = info?.true_track || 0; flightState._drSpeedMps = info?.velocity || 0; flightState._drCourseHold = diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 89b91f125..0cfad575e 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -16,6 +16,7 @@ import { FLEET_DR_INTERVAL_MS, } from './policy.js'; import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; +import { billboardState, setBillboard } from '../../data/billboardMirror.js'; export function createQueries({ flightState, @@ -134,7 +135,8 @@ export function createQueries({ const info = flightState.records.data.get(icao24); const bb = flightState._billboards.get(icao24); const basePos = - parts.motion._deadReckon(icao24) || (bb ? bb.position : null); + parts.motion._deadReckon(icao24) || + (bb ? billboardState(bb).position : null); if (!basePos) return null; const displayed = displayedKinematics({ derivedSpeedMps: flightState._drSpeedMps, @@ -336,7 +338,8 @@ export function createQueries({ // here would double-image the tracked plane when 3D is turned off mid-track. if (flightState._billboardCollection) for (const [icao, bb] of flightState._billboards) { - if (icao !== flightState._trackedIcao) bb.show = true; + if (icao !== flightState._trackedIcao) + setBillboard(bb, 'show', true); } } } @@ -454,7 +457,8 @@ export function createQueries({ // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. const model = flightState._models.get(icao24) ?? null; const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model); - if (!isTracked && !bb.show && !modelOwnsVisual) continue; + if (!isTracked && !billboardState(bb).show && !modelOwnsVisual) + continue; const info = flightState.records.data.get(icao24); let object = flightState._detectionObjects.get(icao24); if (!object) { @@ -476,7 +480,7 @@ export function createQueries({ ? parts.rendering._modelSpec(info?.klass) : null; const pos = isTracked - ? parts.motion._trackedVisualCached() || bb.position + ? parts.motion._trackedVisualCached() || billboardState(bb).position : modelOwnsVisual ? modelVisualAnchor( model.modelMatrix, @@ -486,7 +490,7 @@ export function createQueries({ : spec.scale, object._weldPos || (object._weldPos = new Cesium.Cartesian3()), ) - : bb.position; + : billboardState(bb).position; if (!pos) continue; object.position = pos; object.skipLabel = isTracked; @@ -600,7 +604,7 @@ export function createQueries({ if ( !aircraftIncludedInNearby({ isTracked, - billboardShown: bb.show, + billboardShown: billboardState(bb).show, modelRendering: parts.rendering._modelOwnsVisual(icao24), includeHidden, }) @@ -610,7 +614,7 @@ export function createQueries({ const trackedPos = isTracked ? parts.motion._trackedDisplayCached() : null; // cached, no recompute (anti-jitter) - const pos = trackedPos || bb.position; + const pos = trackedPos || billboardState(bb).position; if (!pos) continue; const distance = Cesium.Cartesian3.distance(center, pos); @@ -692,7 +696,7 @@ export function createQueries({ const result = []; for (const [icao24, bb] of flightState._billboards) { - const pos = bb.position; + const pos = billboardState(bb).position; if (!pos) continue; const carto = Cesium.Cartographic.fromCartesian( pos, diff --git a/src/layers/flights/queries.test.mjs b/src/layers/flights/queries.test.mjs index 13957d6cc..33eec2ed9 100644 --- a/src/layers/flights/queries.test.mjs +++ b/src/layers/flights/queries.test.mjs @@ -3,6 +3,11 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createQueries } from './queries.js'; import { createRendering } from './rendering.js'; +import { + billboardState, + mirrorBillboard, + setBillboard, +} from '../../data/billboardMirror.js'; function fixture() { const info = { @@ -10,7 +15,10 @@ function fixture() { registration: ' N123 ', altitude: 1000, }; - const bb = { show: true, position: new Cesium.Cartesian3(1, 2, 3) }; + const bb = mirrorBillboard({ + show: true, + position: new Cesium.Cartesian3(1, 2, 3), + }); const flightState = { feed: {}, _billboardCollection: { show: true }, @@ -48,9 +56,10 @@ test('candidate text follows in-place enrichment without freezing identity or po const [first] = f.queries.getDetectableObjects(); assert.equal(first.id, 'TEST123'); assert.equal(first.sourceId, 'abc123'); - f.bb.position = new Cesium.Cartesian3(4, 5, 6); + setBillboard(f.bb, 'position', new Cesium.Cartesian3(4, 5, 6)); assert.equal(f.queries.getDetectableObjects()[0], first); - assert.equal(first.position, f.bb.position); + assert.equal(first.position, billboardState(f.bb).position); + assert.deepEqual(first.position, new Cesium.Cartesian3(4, 5, 6)); assert.equal(f.formats(), 1); f.info.callsign = ' NEW456 '; assert.equal(f.queries.getDetectableObjects()[0].id, 'NEW456'); @@ -97,7 +106,7 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => scale: 1, }); f.flightState._models.set('abc123', model); - f.bb.show = false; + setBillboard(f.bb, 'show', false); assert.deepEqual(f.queries.getDetectableObjects(), []); model.ready = true; assert.equal(f.queries.getDetectableObjects()[0], candidate); @@ -111,8 +120,11 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60)); model.show = false; assert.deepEqual(f.queries.getDetectableObjects(), []); - f.bb.show = true; - assert.equal(f.queries.getDetectableObjects()[0].position, f.bb.position); + setBillboard(f.bb, 'show', true); + assert.equal( + f.queries.getDetectableObjects()[0].position, + billboardState(f.bb).position, + ); assert.equal(get.mock.callCount(), 6); model.show = true; assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 147d730ad..a11060071 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -39,6 +39,7 @@ import { } 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'; export function createRendering({ flightState, @@ -151,12 +152,12 @@ export function createRendering({ if (isCockpitContact && !isCockpitNear) { const freshnessAlpha = cyberSonarBaseAlpha(bb); bb.image = cockpitContactDotImage(); - bb.width = COCKPIT_CONTACT_SIZE_PX; - bb.height = COCKPIT_CONTACT_SIZE_PX; - bb.scale = limbScale; - bb.scaleByDistance = _cockpitBillboardScaleByDistance(); + setBillboard(bb, 'width', COCKPIT_CONTACT_SIZE_PX); + setBillboard(bb, 'height', COCKPIT_CONTACT_SIZE_PX); + setBillboard(bb, 'scale', limbScale); + setBillboard(bb, 'scaleByDistance', _cockpitBillboardScaleByDistance()); bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha); - bb.rotation = 0; + setBillboard(bb, 'rotation', 0); return; } @@ -165,10 +166,14 @@ export function createRendering({ _iconKind(icao24, meta?.klass), aircraftVisuals.read(bb)?.iconLarge ? TRACKED_ICON_PX : undefined, ); - bb.width = icao24 === flightState._trackedIcao ? 24 : 20; - bb.height = icao24 === flightState._trackedIcao ? 24 : 20; - bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale; - bb.scaleByDistance = _normalBillboardScaleByDistance(); + setBillboard(bb, 'width', icao24 === flightState._trackedIcao ? 24 : 20); + setBillboard(bb, 'height', icao24 === flightState._trackedIcao ? 24 : 20); + setBillboard( + bb, + 'scale', + _fleetBillboardScale(icao24, meta?.klass) * limbScale, + ); + setBillboard(bb, 'scaleByDistance', _normalBillboardScaleByDistance()); bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb)); } @@ -324,7 +329,7 @@ export function createRendering({ function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { if (!model) { - bb.show = true; + setBillboard(bb, 'show', true); return false; } const displayPos = _modelDisplayPosition( @@ -334,7 +339,7 @@ export function createRendering({ ); if (!displayPos) { model.show = false; // no ground evidence → nothing safe to place a depth-tested model at - bb.show = true; + setBillboard(bb, 'show', true); return false; } // The matrix is written BEFORE the readiness test on purpose: a model can flip @@ -343,12 +348,12 @@ export function createRendering({ _modelMatrix(displayPos, course, model.modelMatrix); if (!model.ready) { model.show = false; // not loaded yet → keep the 2D icon, no half-model flash - bb.show = true; + setBillboard(bb, 'show', true); return false; } beforeShow?.(); if (!model.show) model.show = true; - if (bb.show) bb.show = false; // hand off ONLY once the model renders + if (billboardState(bb).show) setBillboard(bb, 'show', false); // hand off ONLY once the model renders return true; } @@ -437,7 +442,8 @@ export function createRendering({ (!current && flightState._modelPending.has(icao24)) ) { const bb = flightState._billboards.get(icao24); - if (bb && icao24 !== flightState._trackedIcao) bb.show = true; + if (bb && icao24 !== flightState._trackedIcao) + setBillboard(bb, 'show', true); _releaseModel(icao24); } if ( @@ -472,7 +478,8 @@ export function createRendering({ ) continue; // already right state const bb = flightState._billboards.get(icao); - if (bb && icao !== flightState._trackedIcao) bb.show = true; + if (bb && icao !== flightState._trackedIcao) + setBillboard(bb, 'show', true); _releaseModel(icao); } if (flightState._irReloadQueue.length === 0) @@ -699,7 +706,8 @@ export function createRendering({ // readout consumers then reuse that exact cached position. const pos = parts.motion._trackedDisplayPosition(flightState._trackedIcao) || - flightState._billboards.get(flightState._trackedIcao)?.position; + billboardState(flightState._billboards.get(flightState._trackedIcao)) + ?.position; if (!pos) { if (flightState._trackedModel) flightState._trackedModel.show = false; return; @@ -777,7 +785,9 @@ export function createRendering({ // default here produces a one-frame jump to the Earth's center. const currentPos = parts.motion._trackedDisplayCached() || - flightState._billboards.get(flightState._trackedIcao)?.position; + billboardState( + flightState._billboards.get(flightState._trackedIcao), + )?.position; if (currentPos) { const displayPos = _modelDisplayPosition( flightState._trackedIcao, @@ -938,12 +948,10 @@ export function createRendering({ // 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 d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); + const { position } = billboardState(bb); + const d2 = Cesium.Cartesian3.distanceSquared(camPos, position); if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible - Cesium.Cartesian3.clone( - bb.position, - flightState._scratchModelBS.center, - ); + Cesium.Cartesian3.clone(position, flightState._scratchModelBS.center); cand.push([ icao, d2, @@ -973,6 +981,7 @@ export function createRendering({ for (const [icao24, bb] of flightState._billboards) { if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + const icon = billboardState(bb); const info = flightState.records.data.get(icao24); @@ -996,9 +1005,9 @@ export function createRendering({ // collection's vertex buffer, so skip sub-meter moves. if ( display && - Cesium.Cartesian3.distanceSquared(display, bb.position) > 1.0 + Cesium.Cartesian3.distanceSquared(display, icon.position) > 1.0 ) { - bb.position = display; + setBillboard(bb, 'position', display); } // Round 6: occlusion-test a LIFTED point for contacts rendering below @@ -1007,15 +1016,15 @@ export function createRendering({ // pass would hide a plane that is really just low over high-N terrain // waiting for its floor to warm (ATL grounded contacts at geoid −31 m). const beyondHorizon = !occluder.isPointVisible( - flightState._cullPositions.get(icao24) || bb.position, + flightState._cullPositions.get(icao24) || icon.position, ); // A billboard flipping INTO view (horizon reveal while the camera idles) // gets its rotation refreshed THIS tick even without a pose change — // otherwise it reappears wearing its stale (often creation-north) nose for // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false // here; harmless — the model branch below `continue`s past the rotation.) - const revealed = !beyondHorizon && !bb.show; - if (bb.show === beyondHorizon) bb.show = !beyondHorizon; + 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. @@ -1031,22 +1040,22 @@ export function createRendering({ // alpha; nearer focus behavior remains tunable rather than universal. const cameraDistanceM = Cesium.Cartesian3.distance( camera.positionWC, - bb.position, + icon.position, ); const distanceScale = nearFarScalarValueAtDistance( - bb.scaleByDistance, + icon.scaleByDistance, cameraDistanceM, ); const focus = advanceProjectedSpriteFocus( bb, - bb.position, + icon.position, scene, camera, nowMs, focusTarget, undefined, - (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, - (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, + (icon.width || 20) * (icon.scale || 1) * distanceScale * 0.5, + (icon.height || 20) * (icon.scale || 1) * distanceScale * 0.5, ); const isCockpitNear = flightState._cockpitContactMode && @@ -1077,8 +1086,8 @@ export function createRendering({ // hysteresis so zoom oscillation never thrashes the atlas. if (!flightState._cockpitContactMode || isCockpitNear) { const glyphDevPx = - (bb.width || 20) * - (bb.scale || 1) * + (icon.width || 20) * + (icon.scale || 1) * distanceScale * (globalThis.devicePixelRatio || 1); const visual = aircraftVisuals.write(bb); @@ -1160,12 +1169,12 @@ export function createRendering({ ) { const rot = screenProjectedRotation( scene, - bb.position, + icon.position, course, - bb.rotation, + icon.rotation, ); - if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { - bb.rotation = rot; + if (rot !== null && Math.abs(rot - icon.rotation) > 0.002) { + setBillboard(bb, 'rotation', rot); } } } diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index ec6c2933b..2fa013a48 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -7,6 +7,7 @@ import { } from '../../data/motionModel.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; +import { addBillboard } from '../../data/billboardMirror.js'; /** Apply reconciled aircraft records to Cesium resources and follow state. */ export function createFlightSnapshotRenderer({ @@ -195,7 +196,7 @@ export function createFlightSnapshotRenderer({ rendering._modelOwnsVisual(icao24), )) || position; - const bb = flightState._billboardCollection.add({ + const bb = addBillboard(flightState._billboardCollection, { position: displayed, image: aircraftIcon(rendering._iconKind(icao24, meta.klass)), width: isTracked ? 24 : 20, diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index ccb109ae1..df54b7c9d 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { CYAN_TRANSPARENT } from './policy.js'; +import { mirrorBillboard } from '../../data/billboardMirror.js'; export function createTesting({ flightState, @@ -68,7 +69,7 @@ export function createTesting({ flightState._viewer = viewer; flightState._modelCollection = modelCollection; flightState._billboardCollection = billboardCollection; - flightState._billboards = new Map([[icao24, billboard]]); + flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]); flightState._cullPositions.clear(); flightState._models.clear(); for (const [key, model] of models) flightState._models.set(key, model); @@ -117,7 +118,7 @@ export function createTesting({ billboard, history = [], }) { - flightState._billboards.set(icao24, billboard); + flightState._billboards.set(icao24, mirrorBillboard(billboard)); flightState.records.data.set(icao24, meta); flightState._positionHistory.set(icao24, history); } diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 064fce011..9969ad88e 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -26,6 +26,7 @@ import { } from './policy.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; +import { billboardState, setBillboard } from '../../data/billboardMirror.js'; export function createTracking({ flightState, @@ -65,7 +66,7 @@ export function createTracking({ function _publishTrackedSelection(icao24, origin = 'programmatic') { const bb = flightState._billboards.get(icao24); const info = flightState.records.data.get(icao24); - if (!bb?.position || !info) return false; + if (!billboardState(bb)?.position || !info) return false; if (flightState._trackedEntity) entityRecord(flightState._trackedEntity).selectionOrigin = origin; _emitAwarenessEvent('gev:awareness-subject-selected', { @@ -76,7 +77,7 @@ export function createTracking({ // a callsign-less contact reached Context as its raw hex even once adsbdb // had supplied a registration. Identity below stays `icao24`. label: parts.queries._contactLabel(icao24, info), - position: Cesium.Cartesian3.clone(bb.position), + position: Cesium.Cartesian3.clone(billboardState(bb).position), origin, }); selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); @@ -162,12 +163,13 @@ export function createTracking({ const previous = flightState._cockpitNearContacts; const distancesSquared = []; for (const [icao24, bb] of flightState._billboards) { - if (icao24 === flightState._trackedIcao || !bb?.position) continue; + const position = billboardState(bb)?.position; + if (icao24 === flightState._trackedIcao || !position) continue; distancesSquared.push([ icao24, Cesium.Cartesian3.distanceSquared( flightState._viewer.camera.positionWC, - bb.position, + position, ), ]); } @@ -316,7 +318,8 @@ export function createTracking({ if (!flightState._trackedIcao || !flightState._viewer) return; const pos = parts.motion._trackedDisplayCached() || - flightState._billboards.get(flightState._trackedIcao)?.position; + billboardState(flightState._billboards.get(flightState._trackedIcao)) + ?.position; if (!pos) return; const projected = screenProjectedRotation( flightState._viewer.scene, @@ -642,17 +645,21 @@ export function createTracking({ // ROTATION_REFRESH_MS of a wrong (possibly reversed) nose on release. if (flightState._billboards.has(flightState._trackedIcao)) { const bb = flightState._billboards.get(flightState._trackedIcao); - bb.show = true; - bb.width = 20; - bb.height = 20; + setBillboard(bb, 'show', true); + setBillboard(bb, 'width', 20); + setBillboard(bb, 'height', 20); // Ground/military-aware restore (a plane untracked while taxiing must come // back muted-gray at ground scale, not white at full scale). bb.color = parts.rendering._fleetBillboardColor(flightState._trackedIcao); - bb.scale = parts.rendering._fleetBillboardScale( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, + setBillboard( + bb, + 'scale', + parts.rendering._fleetBillboardScale( + flightState._trackedIcao, + flightState.records.data.get(flightState._trackedIcao)?.klass, + ), ); - bb.rotation = flightState._lastTrackedRotation; + setBillboard(bb, 'rotation', flightState._lastTrackedRotation); } flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick @@ -830,7 +837,7 @@ export function createTracking({ if (flightState._models.has(id) || flightState._modelPending.has(id)) parts.rendering._releaseModel(id); const bb = flightState._billboards.get(id); - if (bb && id !== flightState._trackedIcao) bb.show = true; // horizon cull re-asserts next tick + if (bb && id !== flightState._trackedIcao) setBillboard(bb, 'show', true); // horizon cull re-asserts next tick if (id === flightState._trackedIcao) { parts.rendering._releaseTrackedModel(); _syncTracked2dRotation(); @@ -862,9 +869,9 @@ export function createTracking({ function _routeIsPlausible(icao24, route) { const info = flightState.records.data.get(icao24); const bb = flightState._billboards.get(icao24); - if (!info || !bb || !bb.position) return true; + if (!info || !bb || !billboardState(bb).position) return true; const carto = Cesium.Cartographic.fromCartesian( - bb.position, + billboardState(bb).position, Cesium.Ellipsoid.WGS84, flightState._scratchCarto, ); @@ -906,21 +913,22 @@ export function createTracking({ flightState._trackedIcao = icao24; _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget flightState._cachedDRFrame = -1; - flightState._lastTrackedRotation = bb.rotation || 0; + flightState._lastTrackedRotation = billboardState(bb).rotation || 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 - bb.show = false; + setBillboard(bb, 'show', false); // Helper: smoothed, per-frame-cached tracked position (see _trackedDisplayPosition — // one computation shared by the position/alignedAxis/rotation/trail-head callbacks, // with discontinuity reconciliation). Falls back to the last billboard position when // the aircraft has no fix. const getTrackedPosition = () => - parts.motion._trackedDisplayPosition(icao24) || bb.position; + parts.motion._trackedDisplayPosition(icao24) || + billboardState(bb).position; // Dead-reckoning position property — smooth continuous motion between API updates. const positionProperty = new Cesium.CallbackProperty(() => { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index d23ad621a..db357a87f 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -11,6 +11,7 @@ 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'; export function createQueries({ flightState, @@ -96,7 +97,8 @@ export function createQueries({ const info = flightState.records.data.get(icao24); const bb = flightState._billboards.get(icao24); const basePos = - parts.motion._deadReckon(icao24) || (bb ? bb.position : null); + parts.motion._deadReckon(icao24) || + (bb ? billboardState(bb).position : null); if (!basePos) return null; const displayed = displayedKinematics({ derivedSpeedMps: flightState._drSpeedMps, @@ -272,7 +274,8 @@ export function createQueries({ // 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) bb.show = true; + if (icao !== flightState._trackedIcao) + setBillboard(bb, 'show', true); } } } @@ -375,7 +378,7 @@ export function createQueries({ if ( !aircraftIncludedInNearby({ isTracked, - billboardShown: bb.show, + billboardShown: billboardState(bb).show, modelRendering: parts.rendering._modelOwnsVisual(icao24), includeHidden, }) @@ -385,7 +388,7 @@ export function createQueries({ const trackedPos = isTracked ? parts.motion._trackedDisplayCached() : null; // cached, no recompute (anti-jitter) - const pos = trackedPos || bb.position; + const pos = trackedPos || billboardState(bb).position; if (!pos) continue; const distance = Cesium.Cartesian3.distance(center, pos); @@ -462,7 +465,8 @@ export function createQueries({ // doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden. const model = flightState._models.get(icao24); const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); - if (!isTracked && !bb.show && !modelOwnsVisual) continue; + if (!isTracked && !billboardState(bb).show && !modelOwnsVisual) + continue; const info = flightState.records.data.get(icao24); const callsign = info?.callsign?.trim(); const registration = info?.registration?.trim(); @@ -484,7 +488,7 @@ export function createQueries({ ? parts.rendering._modelSpec(info?.klass) : null; const pos = isTracked - ? parts.motion._trackedVisualCached() || bb.position + ? parts.motion._trackedVisualCached() || billboardState(bb).position : modelOwnsVisual ? modelVisualAnchor( model.modelMatrix, @@ -494,7 +498,7 @@ export function createQueries({ : spec.scale, object._weldPos || (object._weldPos = new Cesium.Cartesian3()), ) - : bb.position; + : billboardState(bb).position; if (!pos) continue; object.position = pos; object.skipLabel = isTracked; @@ -616,7 +620,7 @@ export function createQueries({ const result = []; for (const [icao24, bb] of flightState._billboards) { - const pos = bb.position; + const pos = billboardState(bb).position; if (!pos) continue; const carto = Cesium.Cartographic.fromCartesian( pos, diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index f65103887..128719630 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -55,6 +55,7 @@ import { } 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'; export function createRendering({ flightState, @@ -159,12 +160,12 @@ export function createRendering({ if (isCockpitContact && !isCockpitNear) { const freshnessAlpha = cyberSonarBaseAlpha(bb); bb.image = cockpitContactDotImage(); - bb.width = COCKPIT_CONTACT_SIZE_PX; - bb.height = COCKPIT_CONTACT_SIZE_PX; - bb.scale = limbScale; - bb.scaleByDistance = _cockpitBillboardScaleByDistance(); + 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); - bb.rotation = 0; + setBillboard(bb, 'rotation', 0); return; } @@ -173,10 +174,10 @@ export function createRendering({ _iconKind(icao24, meta?.klass), aircraftVisuals.read(bb)?.iconLarge ? TRACKED_ICON_PX : undefined, ); - bb.width = icao24 === flightState._trackedIcao ? 24 : 20; - bb.height = icao24 === flightState._trackedIcao ? 24 : 20; - bb.scale = _militaryBillboardScale(icao24) * limbScale; - bb.scaleByDistance = _normalBillboardScaleByDistance(); + 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), ); @@ -330,7 +331,7 @@ export function createRendering({ function _driveFleetModelHandoff(icao24, model, bb, pos, course, beforeShow) { if (!model) { - bb.show = true; + setBillboard(bb, 'show', true); return false; } const displayPos = _modelDisplayPosition( @@ -340,7 +341,7 @@ export function createRendering({ ); if (!displayPos) { model.show = false; // no ground evidence → nothing safe to place a depth-tested model at - bb.show = true; + setBillboard(bb, 'show', true); return false; } // The matrix is written BEFORE the readiness test on purpose: a model can flip @@ -354,12 +355,12 @@ export function createRendering({ ); if (!model.ready) { model.show = false; // not loaded yet → keep the 2D icon, no half-model flash - bb.show = true; + setBillboard(bb, 'show', true); return false; } beforeShow?.(); if (!model.show) model.show = true; - if (bb.show) bb.show = false; // hand off ONLY once the model renders + if (billboardState(bb).show) setBillboard(bb, 'show', false); // hand off ONLY once the model renders return true; } @@ -405,7 +406,8 @@ export function createRendering({ (!current && flightState._modelPending.has(icao24)) ) { const bb = flightState._billboards.get(icao24); - if (bb && icao24 !== flightState._trackedIcao) bb.show = true; + if (bb && icao24 !== flightState._trackedIcao) + setBillboard(bb, 'show', true); _releaseModel(icao24); } if ( @@ -440,7 +442,8 @@ export function createRendering({ ) continue; // already right state const bb = flightState._billboards.get(icao); - if (bb && icao !== flightState._trackedIcao) bb.show = true; + if (bb && icao !== flightState._trackedIcao) + setBillboard(bb, 'show', true); _releaseModel(icao); } if (flightState._irReloadQueue.length === 0) @@ -662,7 +665,8 @@ export function createRendering({ // readout consumers then reuse that exact cached position. const pos = parts.motion._trackedDisplayPosition(flightState._trackedIcao) || - flightState._billboards.get(flightState._trackedIcao)?.position; + billboardState(flightState._billboards.get(flightState._trackedIcao)) + ?.position; if (!pos) { if (flightState._trackedModel) flightState._trackedModel.show = false; return; @@ -738,7 +742,9 @@ export function createRendering({ // default here produces a one-frame jump to the Earth's center. const currentPos = parts.motion._trackedDisplayCached() || - flightState._billboards.get(flightState._trackedIcao)?.position; + billboardState( + flightState._billboards.get(flightState._trackedIcao), + )?.position; if (currentPos) { const displayPos = _modelDisplayPosition( flightState._trackedIcao, @@ -897,12 +903,10 @@ export function createRendering({ // 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 d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); + const { position } = billboardState(bb); + const d2 = Cesium.Cartesian3.distanceSquared(camPos, position); if (d2 > keepDistSq) continue; // beyond keep radius → never eligible - Cesium.Cartesian3.clone( - bb.position, - flightState._scratchModelBS.center, - ); + Cesium.Cartesian3.clone(position, flightState._scratchModelBS.center); cand.push([ icao, d2, @@ -932,27 +936,28 @@ export function createRendering({ 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, bb.position) > 1.0) { - bb.position = dr; + 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 || bb.position, + flightState.records.data.get(icao24)?.cullPosition || icon.position, ); // A billboard flipping INTO view (horizon reveal while the camera idles) // gets its rotation refreshed THIS tick even without a pose change — // otherwise it reappears wearing its stale (often creation-north) nose for // up to ROTATION_REFRESH_MS. (Model-handed-off planes also read show=false // here; harmless — the model branch below `continue`s past the rotation.) - const revealed = !beyondHorizon && !bb.show; - if (bb.show === beyondHorizon) bb.show = !beyondHorizon; + 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. @@ -968,22 +973,22 @@ export function createRendering({ // untouched and multiplicative; there is no cull or zero-alpha path. const cameraDistanceM = Cesium.Cartesian3.distance( camera.positionWC, - bb.position, + icon.position, ); const distanceScale = nearFarScalarValueAtDistance( - bb.scaleByDistance, + icon.scaleByDistance, cameraDistanceM, ); const focus = advanceProjectedSpriteFocus( bb, - bb.position, + icon.position, scene, camera, nowMs, focusTarget, undefined, - (bb.width || 20) * (bb.scale || 1) * distanceScale * 0.5, - (bb.height || 20) * (bb.scale || 1) * distanceScale * 0.5, + (icon.width || 20) * (icon.scale || 1) * distanceScale * 0.5, + (icon.height || 20) * (icon.scale || 1) * distanceScale * 0.5, ); const isCockpitNear = flightState._cockpitContactMode && @@ -1007,8 +1012,8 @@ export function createRendering({ // focus/limb recession counts) with hysteresis (atlas has no mips). if (!flightState._cockpitContactMode || isCockpitNear) { const glyphDevPx = - (bb.width || 20) * - (bb.scale || 1) * + (icon.width || 20) * + (icon.scale || 1) * distanceScale * (globalThis.devicePixelRatio || 1); const visual = aircraftVisuals.write(bb); @@ -1087,12 +1092,12 @@ export function createRendering({ ) { const rot = screenProjectedRotation( scene, - bb.position, + icon.position, course, - bb.rotation, + icon.rotation, ); - if (rot !== null && Math.abs(rot - bb.rotation) > 0.002) { - bb.rotation = rot; + if (rot !== null && Math.abs(rot - icon.rotation) > 0.002) { + setBillboard(bb, 'rotation', rot); } } } diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 7ed2b1c1f..547359559 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -9,6 +9,7 @@ import { 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({ @@ -150,7 +151,7 @@ export function createMilitarySnapshotRenderer({ // it ahead until the next fleet tick snapped it back. const displayed = motion._deadReckon(icao24, new Cesium.Cartesian3()) || position; - const bb = flightState._billboardCollection.add({ + const bb = addBillboard(flightState._billboardCollection, { position: displayed, image: aircraftIcon(rendering._iconKind(icao24, meta.klass)), width: isTracked ? 24 : 20, @@ -226,7 +227,7 @@ export function createMilitarySnapshotRenderer({ if (newest) newest.position = Cesium.Cartesian3.clone(liftedPos, newest.position); const bbStale = flightState._billboards.get(icao24); - if (bbStale) bbStale.position = liftedPos; + if (bbStale) setBillboard(bbStale, 'position', liftedPos); } // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, // budget-capped, viewer-proximate, google-3d regime only), excluding diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index 15ab14050..fa43ed0fc 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -1,4 +1,5 @@ import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; +import { mirrorBillboard } from '../../data/billboardMirror.js'; export function createTesting({ flightState, @@ -39,7 +40,7 @@ export function createTesting({ flightState._viewer = viewer; flightState._modelCollection = modelCollection; flightState._billboardCollection = billboardCollection; - flightState._billboards = new Map([[icao24, billboard]]); + flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]); flightState._cullPositions.clear(); flightState._models.clear(); for (const [key, model] of models) flightState._models.set(key, model); @@ -86,7 +87,7 @@ export function createTesting({ billboard, history = [], }) { - flightState._billboards.set(icao24, billboard); + flightState._billboards.set(icao24, mirrorBillboard(billboard)); flightState.records.data.set(icao24, meta); flightState._positionHistory.set(icao24, history); } diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 16252caca..7000950ad 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -27,6 +27,7 @@ import { } from './policy.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; +import { billboardState, setBillboard } from '../../data/billboardMirror.js'; export function createTracking({ flightState, @@ -64,7 +65,7 @@ export function createTracking({ function _publishTrackedSelection(icao24, origin = 'programmatic') { const bb = flightState._billboards.get(icao24); const info = flightState.records.data.get(icao24); - if (!bb?.position || !info) return false; + if (!billboardState(bb)?.position || !info) return false; if (flightState._trackedEntity) entityRecord(flightState._trackedEntity).selectionOrigin = origin; _emitAwarenessEvent('gev:awareness-subject-selected', { @@ -74,7 +75,7 @@ export function createTracking({ parts.queries._toCleanText(info.callsign) || parts.queries._toCleanText(info.registration) || icao24, - position: Cesium.Cartesian3.clone(bb.position), + position: Cesium.Cartesian3.clone(billboardState(bb).position), origin, }); selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); @@ -151,12 +152,13 @@ export function createTracking({ const previous = flightState._cockpitNearContacts; const distancesSquared = []; for (const [icao24, bb] of flightState._billboards) { - if (icao24 === flightState._trackedIcao || !bb?.position) continue; + const position = billboardState(bb)?.position; + if (icao24 === flightState._trackedIcao || !position) continue; distancesSquared.push([ icao24, Cesium.Cartesian3.distanceSquared( flightState._viewer.camera.positionWC, - bb.position, + position, ), ]); } @@ -295,7 +297,8 @@ export function createTracking({ if (flightState._models.has(id) || flightState._modelPending.has(id)) parts.rendering._releaseModel(id); const modelled = flightState._billboards.get(id); - if (modelled && id !== flightState._trackedIcao) modelled.show = true; // horizon cull re-asserts next tick + if (modelled && id !== flightState._trackedIcao) + setBillboard(modelled, 'show', true); // horizon cull re-asserts next tick if (id === flightState._trackedIcao) { parts.rendering._releaseTrackedModel(); _syncTracked2dRotation(); @@ -411,7 +414,8 @@ export function createTracking({ if (!flightState._trackedIcao || !flightState._viewer) return; const pos = parts.motion._trackedDisplayCached() || - flightState._billboards.get(flightState._trackedIcao)?.position; + billboardState(flightState._billboards.get(flightState._trackedIcao)) + ?.position; if (!pos) return; const projected = screenProjectedRotation( flightState._viewer.scene, @@ -780,21 +784,24 @@ export function createTracking({ if (flightState._billboards.has(flightState._trackedIcao)) { const bb = flightState._billboards.get(flightState._trackedIcao); const meta = flightState.records.data.get(flightState._trackedIcao); - bb.show = true; - bb.width = 20; - bb.height = 20; + 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). - bb.scale = + setBillboard( + bb, + 'scale', BILLBOARD_SCALE * - (CLASS_SCALE_2D[meta?.klass] || 1) * - (meta?.onGround ? GROUND_SCALE : 1); + (CLASS_SCALE_2D[meta?.klass] || 1) * + (meta?.onGround ? GROUND_SCALE : 1), + ); bb.color = parts.rendering._fleetBillboardColor( flightState._trackedIcao, meta, ); - bb.rotation = flightState._lastTrackedRotation; + setBillboard(bb, 'rotation', flightState._lastTrackedRotation); } flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick @@ -869,21 +876,22 @@ export function createTracking({ flightState._trackedIcao = icao24; _resetTrackedSelectionState(); // fresh selection: enter at the ENTER ceiling, full load-retry budget flightState._cachedDRFrame = -1; - flightState._lastTrackedRotation = bb.rotation || 0; + flightState._lastTrackedRotation = billboardState(bb).rotation || 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 - bb.show = false; + setBillboard(bb, 'show', 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 // aircraft has no fix. const getTrackedPosition = () => - parts.motion._trackedDisplayPosition(icao24) || bb.position; + parts.motion._trackedDisplayPosition(icao24) || + billboardState(bb).position; // Dead-reckoning position property evaluated every render frame. // isConstant = false so CesiumJS re-evaluates each frame.