diff --git a/scripts/focus-allocation-check.mjs b/scripts/focus-allocation-check.mjs index e15538a2f..f256f7ef7 100644 --- a/scripts/focus-allocation-check.mjs +++ b/scripts/focus-allocation-check.mjs @@ -1,11 +1,8 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { - advanceSpriteFocus, -} from '../src/data/focusDeemphasis.js'; -import { - applyAircraftBillboardTreatment, -} from '../src/data/aircraftRecession.js'; +import { advanceSpriteFocus } from '../src/data/focusDeemphasis.js'; +import { aircraftTreatmentAlpha } from '../src/data/aircraftRecession.js'; +import { createIconSet } from '../src/data/iconSet.js'; if (typeof global.gc !== 'function') { throw new Error('focus-allocation-check.mjs requires node --expose-gc'); @@ -66,17 +63,14 @@ const focusInput = { target, }; -const baseColor = { withAlpha: (alpha) => ({ alpha }) }; -const billboard = { scale: 1, color: { alpha: 1 } }; const aircraftInput = { - billboard, - baseScale: 1, baseAlpha: 1, - baseColor, focusFactor: 1, cameraDistanceM: 100, cameraHeightM: 1000, }; +const icons = createIconSet({ capacity: 4 }); +const row = icons.add('sprite', {}); const baseline = bytesPerCall(() => focusInput.nowMs); const focus = netOfLoopBaseline( @@ -84,30 +78,22 @@ const focus = netOfLoopBaseline( baseline, ); const aircraft = netOfLoopBaseline( - bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha), + bytesPerCall(() => aircraftTreatmentAlpha(aircraftInput)), baseline, ); const productionSpriteTick = netOfLoopBaseline( bytesPerCall(() => { - // Match the hot civilian/military sprite path: the caller constructs fresh - // option literals, consumes the shared focus result immediately, and then - // constructs the composed aircraft-treatment options for the same sprite. + // Match the hot icon-set focus pass: the caller constructs a fresh option + // literal, consumes the shared focus result immediately and writes it to + // the row's focus column (the shader composes it with limb recession). const focusResult = advanceSpriteFocus(sprite, { screenPosition: focusInput.screenPosition, cameraDistance: focusInput.cameraDistance, nowMs: focusInput.nowMs, target, }); - const treatment = applyAircraftBillboardTreatment({ - billboard, - baseScale: aircraftInput.baseScale, - baseAlpha: aircraftInput.baseAlpha, - baseColor, - focusFactor: focusResult.factor, - cameraDistanceM: aircraftInput.cameraDistanceM, - cameraHeightM: aircraftInput.cameraHeightM, - }); - return focusResult.factor + treatment.alpha; + icons.setFocus(row, focusResult.factor); + return focusResult.factor; }), baseline, ); @@ -118,22 +104,24 @@ assert.ok( ); assert.ok( aircraft.roundedMedian <= PURE_CALL_BUDGET, - `applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`, + `aircraftTreatmentAlpha allocated ${aircraft.rawMedian.toFixed(3)} B/call median`, ); assert.ok( productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET, `production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`, ); -console.log(JSON.stringify({ - callsPerTrial: CALLS, - trials: TRIALS, - budgets: { - pureCall: PURE_CALL_BUDGET, - productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET, - }, - loopBaseline: baseline, - advanceSpriteFocus: focus, - applyAircraftBillboardTreatment: aircraft, - productionSpriteTick, -})); +console.log( + JSON.stringify({ + callsPerTrial: CALLS, + trials: TRIALS, + budgets: { + pureCall: PURE_CALL_BUDGET, + productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET, + }, + loopBaseline: baseline, + advanceSpriteFocus: focus, + aircraftTreatmentAlpha: aircraft, + productionSpriteTick, + }), +); diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6c9f600a6..24d68eb72 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1324,9 +1324,7 @@ "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", + "src/data/modelRecord.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", "src/layers/flights/tracking.js", @@ -1395,9 +1393,7 @@ "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", + "src/data/modelRecord.js", "src/layers/military/state.js", "src/layers/military/testing.js", "src/layers/military/tracking.js", @@ -1440,7 +1436,7 @@ "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", - "src/data/modelMirror.js", + "src/data/modelRecord.js", "src/data/entityRecords.js", "src/data/aircraftModelLighting.js", "src/data/motionModel.js", @@ -1470,7 +1466,6 @@ "src/layers/vessels/queries.js", "src/layers/vessels/rendering.js", "src/data/entityRecords.js", - "src/data/billboardMirror.js", "src/data/iconSetPrimitive.js", "src/data/iconSet.js", "src/data/iconSegment.js", @@ -1564,7 +1559,6 @@ "src/data/iconSegment.js", "src/data/iconAtlas.js", "src/data/aircraftRecession.js", - "src/data/billboardMirror.js", "src/data/motionModel.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", @@ -1895,7 +1889,6 @@ "src/layers/cctv/projection.js", "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", - "src/data/billboardMirror.js", "src/data/iconSetPrimitive.js", "src/data/iconSet.js", "src/data/iconSegment.js", @@ -2376,7 +2369,6 @@ "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", @@ -2593,8 +2585,7 @@ "src/layers/flights/policy.js", "src/layers/flights/queries.js", "src/layers/flights/rendering.js", - "src/data/modelMirror.js", - "src/layers/aircraft/visualState.js", + "src/data/modelRecord.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", "src/layers/flights/tracking.js", @@ -3352,7 +3343,6 @@ "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", @@ -3514,8 +3504,7 @@ "src/layers/flights/policy.js", "src/layers/flights/queries.js", "src/layers/flights/rendering.js", - "src/data/modelMirror.js", - "src/layers/aircraft/visualState.js", + "src/data/modelRecord.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", "src/layers/flights/tracking.js", diff --git a/src/data/aircraftIconLayer.js b/src/data/aircraftIconLayer.js index 94040f0c2..b7c92809d 100644 --- a/src/data/aircraftIconLayer.js +++ b/src/data/aircraftIconLayer.js @@ -412,15 +412,14 @@ export function createAircraftIcons({ * @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) { + /** Give a test contact an icon row held at `icon.position`, shown as + * `icon.show` says (hidden by default while tracked). */ + function _seedIconForTest(icao24, icon, tracked) { const set = state._icons; const row = set.add(icao24, { layer: layerId, id: icao24 }); - if (billboard?.position) - writeHoldSegment(set.segments, row, billboard.position, 0); + if (icon?.position) writeHoldSegment(set.segments, row, icon.position, 0); set.segmentWritten(row); - set.setShown(row, billboard?.show ?? !tracked); + set.setShown(row, icon?.show ?? !tracked); return row; } diff --git a/src/data/aircraftModelLayer.js b/src/data/aircraftModelLayer.js index 55f9c4795..ef6160165 100644 --- a/src/data/aircraftModelLayer.js +++ b/src/data/aircraftModelLayer.js @@ -3,8 +3,12 @@ import { selectModelEligible } from './modelEligibility.js'; import { horizonOccluder } from './iconOrientation.js'; import { aircraftTreatmentAlpha } from './aircraftRecession.js'; import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js'; -import { aircraftVisuals } from '../layers/aircraft/visualState.js'; -import { modelState, setModel } from './modelMirror.js'; +import { + createModelRecord, + modelIsRendering, + placeModel, + showModel, +} from './modelRecord.js'; /** * An aircraft layer's 3D models: the fleet regime and its gap-proof handoff @@ -98,8 +102,8 @@ export function createAircraftModels({ } /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into - * `result` (a shared scratch by default) and returns it. Push it with `setModel`, which copies - * it into Core's mirror and the model's own matrix, so models never share one mutable matrix + * `result` (a shared scratch by default) and returns it. Push it with `placeModel`, which copies + * it into the model's record and its own matrix, so models never share one mutable matrix * object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium * clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at * the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the @@ -187,14 +191,14 @@ export function createAircraftModels({ function _driveFleetModelHandoff( icao24, - model, + record, row, pos, course, beforeShow, ) { const icons = state._icons; - if (!model) { + if (!record) { icons.setShown(row, true); return false; } @@ -204,16 +208,15 @@ export function createAircraftModels({ state._scratchGroundPos, ); if (!displayPos) { - setModel(model, 'show', false); // no ground evidence → nothing safe to place a depth-tested model at + showModel(record, false); // no ground evidence → nothing safe to place a depth-tested model at icons.setShown(row, true); return false; } // The matrix is written BEFORE the readiness test on purpose: a model can flip // `ready` during scene update after this tick, and a first rendered frame on a // stale load-start matrix is the one-frame jump this ordering prevents. - setModel( - model, - 'modelMatrix', + placeModel( + record, _modelMatrix( displayPos, course, @@ -221,13 +224,13 @@ export function createAircraftModels({ _headingOffsetDeg(state.records.data.get(icao24)?.klass), ), ); - if (!model.ready) { - setModel(model, 'show', false); // not loaded yet → keep the 2D icon, no half-model flash + if (!record.model.ready) { + showModel(record, false); // not loaded yet → keep the 2D icon, no half-model flash icons.setShown(row, true); return false; } beforeShow?.(); - if (!modelState(model).show) setModel(model, 'show', true); + if (!record.show) showModel(record, true); icons.setShown(row, false); // hand off ONLY once the model renders return true; } @@ -265,33 +268,30 @@ export function createAircraftModels({ * null or still-loading tracked model from claiming a visual it is not * drawing yet. * @param {string} icao24 - * @param {Cesium.Model|null} [model] Resolved fleet model; null means absent. + * @param {import('./modelRecord.js').ModelRecord|null} [record] Resolved + * fleet model record; null means absent. * @returns {boolean} */ - function _modelOwnsVisual(icao24, model) { + function _modelOwnsVisual(icao24, record) { if (icao24 === state._trackedIcao) { return ( parts.tracking._trackedModelRegimeActive() && - _modelIsRendering(state._trackedModel) + modelIsRendering(state._trackedModel) ); } - return _modelIsRendering( - model === undefined ? state._models.get(icao24) : model, + return modelIsRendering( + record === undefined ? state._models.get(icao24) : record, ); } - /** A model is the visual only once it actually draws — loaded AND shown. - * - * `show` is sufficient evidence of a safe placement because only ONE site ever - * sets it true for a fleet model (`_driveFleetModelHandoff`, after the matrix is - * committed) and one for the tracked model (`_updateTrackedModel`, likewise); - * everything else — admission, an unresolved ground, a not-yet-ready glTF, the - * limb cull, a regime exit — only ever clears it. */ - - function _modelIsRendering(model) { - return !!model && model.ready === true && modelState(model)?.show === true; - } + // A model is the visual only once it actually draws, loaded AND shown + // (`modelIsRendering`). `show` is sufficient evidence of a safe placement + // because only ONE site ever sets it true for a fleet model + // (`_driveFleetModelHandoff`, after the matrix is committed) and one for the + // tracked model (`_updateTrackedModel`, likewise); everything else — + // admission, an unresolved ground, a not-yet-ready glTF, the limb cull, a + // regime exit — only ever clears it. /** Spec identity for a LOADED model: URL and scale together (same-URL classes * differ by scale — airliner vs quadjet both ship airplane.glb). */ @@ -313,7 +313,7 @@ export function createAircraftModels({ const key = _specKeyFor(state.records.data.get(icao24)?.klass); const current = state._models.get(icao24); if ( - (current && aircraftVisuals.read(current)?.specKey !== key) || + (current && current.specKey !== key) || (!current && state._modelPending.has(icao24)) ) { _releaseModel(icao24); @@ -321,7 +321,7 @@ export function createAircraftModels({ if ( icao24 === state._trackedIcao && state._trackedModel && - aircraftVisuals.read(state._trackedModel)?.specKey !== key + state._trackedModel.specKey !== key ) { _releaseTrackedModel(); } @@ -340,9 +340,8 @@ export function createAircraftModels({ if (!state._irReloadQueue) return; const batch = state._irReloadQueue.splice(0, policy.IR_RELOAD_BATCH); for (const icao of batch) { - const model = state._models.get(icao); - if (!model || aircraftVisuals.read(model)?.irBoost === state._irBoost) - continue; // already right state + const record = state._models.get(icao); + if (!record || record.irBoost === state._irBoost) continue; // already right state _releaseModel(icao); } if (state._irReloadQueue.length === 0) state._irReloadQueue = null; @@ -435,16 +434,17 @@ export function createAircraftModels({ // Keep the pick identity explicit on the resolved primitive. This also // protects injected/custom loaders that do not copy the creation option. model.id = icao24; - const visual = aircraftVisuals.write(model); - visual.specKey = specKey; // class-change sync compares against this - visual.irBoost = loadIrBoost; // boost-flip reload queue compares against this + const record = createModelRecord(model, { + specKey, // class-change sync compares against this + 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 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); + showModel(record, false); state._modelCollection.add(model); - state._models.set(icao24, model); + state._models.set(icao24, record); state._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard } @@ -452,17 +452,17 @@ export function createAircraftModels({ * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ function _releaseModel(icao24) { - const m = state._models.get(icao24); + const record = state._models.get(icao24); const pending = state._modelPending.has(icao24); // Only bump the generation when there's something to invalidate (an in-flight load or a // live model) — so removing never-modeled aircraft doesn't grow _modelGen. - if (m || pending) { + if (record || pending) { state._modelGen.set(icao24, (state._modelGen.get(icao24) || 0) + 1); } - if (m) { + if (record) { if (state._modelCollection && !state._modelCollection.isDestroyed()) { try { - state._modelCollection.remove(m); + state._modelCollection.remove(record.model); } catch { /* gone */ } @@ -492,9 +492,9 @@ export function createAircraftModels({ for (const icao of state._modelPending) state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1); if (state._modelCollection && !state._modelCollection.isDestroyed()) { - for (const m of state._models.values()) { + for (const record of state._models.values()) { try { - state._modelCollection.remove(m); + state._modelCollection.remove(record.model); } catch { /* gone */ } @@ -521,7 +521,7 @@ export function createAircraftModels({ if (state._trackedModel) { if (state._modelCollection && !state._modelCollection.isDestroyed()) { try { - state._modelCollection.remove(state._trackedModel); + state._modelCollection.remove(state._trackedModel.model); } catch { /* gone */ } @@ -542,7 +542,7 @@ export function createAircraftModels({ state._modelCollection && !state._modelCollection.isDestroyed(); if (!active) { - if (state._trackedModel) setModel(state._trackedModel, 'show', false); + if (state._trackedModel) showModel(state._trackedModel, false); return; } // Ask the frame-cached source directly. If the entity callback already ran, @@ -553,7 +553,7 @@ export function createAircraftModels({ parts.motion._trackedDisplayPosition(state._trackedIcao) || parts.icons._iconPosition(state._trackedIcao); if (!pos) { - if (state._trackedModel) setModel(state._trackedModel, 'show', false); + if (state._trackedModel) showModel(state._trackedModel, false); return; } if ( @@ -621,8 +621,11 @@ export function createAircraftModels({ // Assign after resolution as well as in the creation options so the // standalone primitive always exposes the tracked aircraft pick id. m.id = state._trackedIcao; - aircraftVisuals.write(m).specKey = trackedKey; // class-change sync compares against this - setModel(m, 'show', false); // admitted, not yet the visual — the driver shows it once placed + const record = createModelRecord(m, { + specKey: trackedKey, // class-change sync compares against this + irBoost: trackedIrBoost, + }); + showModel(record, false); // admitted, not yet the visual — the driver shows it once placed // Seed the world transform before the primitive enters the scene. A model // can become ready+shown between render phases; leaving Cesium's identity // default here produces a one-frame jump to the Earth's center. @@ -636,9 +639,8 @@ export function createAircraftModels({ state._scratchGroundPos, ); if (displayPos) - setModel( - m, - 'modelMatrix', + placeModel( + record, _modelMatrix( displayPos, parts.motion._trackedDisplayCourse(), @@ -648,7 +650,7 @@ export function createAircraftModels({ ), ); } - state._trackedModel = m; + state._trackedModel = record; state._trackedModelLoading = false; // A good load retires this selection's failure budget — a contact that // recovers after a transient blip is not one attempt from giving up. @@ -675,15 +677,14 @@ export function createAircraftModels({ state._scratchGroundPos, ); if (!displayPos) { - setModel(state._trackedModel, 'show', false); // no ground evidence → the billboard carries it + showModel(state._trackedModel, false); // no ground evidence → the icon carries it return; } const spec = _modelSpec( state.records.data.get(state._trackedIcao)?.klass, ); - setModel( + placeModel( state._trackedModel, - 'modelMatrix', _modelMatrix( displayPos, parts.motion._trackedDisplayCourse(), @@ -691,8 +692,8 @@ export function createAircraftModels({ spec.headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG, ), ); - if (!state._trackedModel.ready) return; - state._trackedModel.scale = trackedModelScaleForPixelCap({ + if (!state._trackedModel.model.ready) return; + state._trackedModel.model.scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, nativeRadiusM: spec.nativeRadiusM, rangeM: Cesium.Cartesian3.distance( @@ -703,7 +704,7 @@ export function createAircraftModels({ fovyRad: state._viewer.camera.frustum.fovy, maximumPixelSize: policy.TRACKED_MODEL_MAX_PX, }); - setModel(state._trackedModel, 'show', true); + showModel(state._trackedModel, true); } } @@ -824,22 +825,22 @@ export function createAircraftModels({ 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 = state._models.get(icao24); - if (m && modelState(m)?.show) setModel(m, 'show', false); + const hidden = state._models.get(icao24); + if (hidden?.show) showModel(hidden, false); continue; } _ensureModel(icao24); - const model = state._models.get(icao24); + const record = state._models.get(icao24); const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position); _driveFleetModelHandoff( icao24, - model, + record, row, position, icons.course(row, tau), () => { applyAircraftModelTreatment({ - model, + model: record.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 @@ -871,7 +872,6 @@ export function createAircraftModels({ _modelDisplayPosition, _driveFleetModelHandoff, _modelOwnsVisual, - _modelIsRendering, _specKeyFor, _syncModelToClass, _reloadModelsForIrBoost, diff --git a/src/data/aircraftRecession.js b/src/data/aircraftRecession.js index 77c7b160e..4f6b4cd9a 100644 --- a/src/data/aircraftRecession.js +++ b/src/data/aircraftRecession.js @@ -1,11 +1,11 @@ /** - * Limb-relative recession for distant aircraft billboards. + * Limb-relative recession for distant aircraft: the factors icon sets apply in + * their shader (ICON_RECEDES) and models apply through their colour alpha. * - * This multiplies the billboard's ordinary scale, which Cesium then composes - * with the locked NearFarScalar. It never edits that scalar, culls a contact, - * or allows alpha to reach zero. + * It multiplies an icon's ordinary scale, which composes with its 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, @@ -20,13 +20,6 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({ let _params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS }; const _scratchFactors = { scale: 1, alpha: 1, limbRatio: null }; -const _treatmentResult = { - scale: 1, - alpha: 1, - scaleWrites: 0, - alphaWrites: 0, - factors: _scratchFactors, -}; function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); @@ -178,71 +171,6 @@ export function aircraftRecessionFactors( ); } -/** - * Production wire helper: compose base scale/alpha, focus emphasis, and limb - * recession into one deadband-gated billboard write site. - * @param {object} input - * @param {object} input.billboard - * @param {number} input.baseScale - * @param {number} input.baseAlpha - * @param {{withAlpha:(alpha:number)=>object}} input.baseColor - * @param {number} input.focusFactor - * @param {number} input.cameraDistanceM - * @param {number} input.cameraHeightM - * @param {Partial} [input.params] - * - * IMPORTANT: the returned object (including `factors`) is a module-owned - * mutable singleton. Consume every needed property before the next call; - * never retain it as a snapshot. - * @returns {{scale:number,alpha:number,scaleWrites:number,alphaWrites:number,factors:object}} - */ -export function applyAircraftBillboardTreatment({ - billboard, - baseScale, - baseAlpha, - baseColor, - focusFactor, - cameraDistanceM, - cameraHeightM, - params, -}) { - const tuning = resolvedParams(params); - const factors = aircraftRecessionFactorsResolved( - cameraDistanceM, - cameraHeightM, - tuning, - _scratchFactors, - ); - const scale = baseScale * factors.scale; - const composedTreatment = Math.max( - tuning.combinedAlphaFloor, - clamp(focusFactor, 0, 1) * factors.alpha, - ); - const alpha = baseAlpha * composedTreatment; - let scaleWrites = 0; - let alphaWrites = 0; - const pushedScale = billboardState(billboard)?.scale; - if ( - !Number.isFinite(pushedScale) || - Math.abs(pushedScale - scale) > tuning.writeEpsilon - ) { - setBillboard(billboard, 'scale', scale); - scaleWrites = 1; - } - if ( - !Number.isFinite(billboard.color?.alpha) || - Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon - ) { - billboard.color = baseColor.withAlpha(alpha); - alphaWrites = 1; - } - _treatmentResult.scale = scale; - _treatmentResult.alpha = alpha; - _treatmentResult.scaleWrites = scaleWrites; - _treatmentResult.alphaWrites = alphaWrites; - return _treatmentResult; -} - /** * The alpha an aircraft draws with: base × max(combined floor, focus × limb * haze). Icon sets compose the same thing in their shader; models need it here. diff --git a/src/data/aircraftRecession.test.mjs b/src/data/aircraftRecession.test.mjs index 7173a36d2..0d8ab7986 100644 --- a/src/data/aircraftRecession.test.mjs +++ b/src/data/aircraftRecession.test.mjs @@ -3,11 +3,10 @@ import assert from 'node:assert/strict'; import { DEFAULT_AIRCRAFT_RECESSION_PARAMS, aircraftRecessionFactors, - applyAircraftBillboardTreatment, applyAircraftModelTreatment, cameraLimbDistanceM, + aircraftTreatmentAlpha, } from './aircraftRecession.js'; -import { mirrorBillboard } from './billboardMirror.js'; const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS }; const cameraHeightM = 1_000_000; @@ -67,72 +66,15 @@ test('globe-view transition eases to identity without a threshold pop', () => { assert.deepEqual(end, { scale: 1, alpha: 1, limbRatio: null }); }); -test('aircraft billboard wire writes scale and alpha only for the far treatment', () => { - const color = { withAlpha: (alpha) => ({ alpha }) }; - const near = mirrorBillboard({ scale: 1.2, color: { alpha: 0.8 } }); - const nearResult = applyAircraftBillboardTreatment({ - billboard: near, - baseScale: 1.2, - baseAlpha: 0.8, - baseColor: color, - focusFactor: 1, - cameraDistanceM: limb * 0.4, - cameraHeightM, - params, - }); - assert.equal(nearResult.scaleWrites, 0); - assert.equal(nearResult.alphaWrites, 0); - - const far = mirrorBillboard({ scale: 1.2, color: { alpha: 0.8 } }); - const farResult = applyAircraftBillboardTreatment({ - billboard: far, - baseScale: 1.2, - baseAlpha: 0.8, - baseColor: color, - focusFactor: 0.5, - cameraDistanceM: limb, - cameraHeightM, - params, - }); - assert.equal(farResult.scaleWrites, 1); - assert.equal(farResult.alphaWrites, 1); - assert.ok(Math.abs(far.scale - (1.2 * 0.45)) < 1e-12); - assert.ok(Math.abs(far.color.alpha - (0.8 * 0.20)) < 1e-12); -}); - test('combined focus and haze product is clamped at the composed alpha floor', () => { - const color = { withAlpha: (alpha) => ({ alpha }) }; - const billboard = mirrorBillboard({ scale: 0.45, color: { alpha: 1 } }); - const result = applyAircraftBillboardTreatment({ - billboard, - baseScale: 1, + const alpha = aircraftTreatmentAlpha({ baseAlpha: 1, - baseColor: color, focusFactor: 0.25, cameraDistanceM: limb, cameraHeightM, params, }); - assert.equal(result.alpha, 0.20); - assert.equal(billboard.color.alpha, 0.20); -}); - -test('applyAircraftBillboardTreatment returns its documented module singleton', () => { - const color = { withAlpha: (alpha) => ({ alpha }) }; - const billboard = mirrorBillboard({ scale: 1, color: { alpha: 1 } }); - const input = { - billboard, - baseAlpha: 1, - baseColor: color, - focusFactor: 1, - cameraDistanceM: 0, - cameraHeightM, - params, - }; - const first = applyAircraftBillboardTreatment({ ...input, baseScale: 1 }); - const second = applyAircraftBillboardTreatment({ ...input, baseScale: 0.5 }); - assert.strictEqual(first, second); - assert.equal(first.scale, 0.5, 'the prior reference reflects the next call'); + assert.equal(alpha, 0.2); }); test('ambient model presentation receives composed alpha without changing blend semantics', () => { diff --git a/src/data/billboardMirror.js b/src/data/billboardMirror.js deleted file mode 100644 index 138d6a5ad..000000000 --- a/src/data/billboardMirror.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @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. A - // billboard never given one has none here (Cesium would report the origin). - if (!value) return; - record.position = Cesium.Cartesian3.clone(value, 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 deleted file mode 100644 index 892bd04cf..000000000 --- a/src/data/billboardMirror.test.mjs +++ /dev/null @@ -1,74 +0,0 @@ -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.equal(billboardState(bb).position, undefined, 'none was pushed'); - 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/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs index 8421267fc..79a95cb1b 100644 --- a/src/data/cockpitDetectionSubjects.test.mjs +++ b/src/data/cockpitDetectionSubjects.test.mjs @@ -27,8 +27,8 @@ const LAYERS = [ _setTrackedFlightRefreshStateForTest({ icao24, entity: null, - billboard: candidateBillboard(icao24), - billboardCollection: { show: true, remove() {} }, + icon: candidateBillboard(icao24), + iconLayer: { show: true, remove() {} }, viewer: candidateViewer(), tracked: false, meta: { @@ -48,8 +48,8 @@ const LAYERS = [ _setTrackedMilitaryRefreshStateForTest({ icao24, entity: null, - billboard: candidateBillboard(icao24), - billboardCollection: { show: true, remove() {} }, + icon: candidateBillboard(icao24), + iconLayer: { show: true, remove() {} }, viewer: candidateViewer(), tracked: false, meta: { diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 487a9d538..0c911b8fa 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -188,8 +188,8 @@ test('nonempty OpenSky payload with zero usable rows cannot prove share target a _setTrackedFlightRefreshStateForTest({ icao24: 'abc123', entity: withEntityRecord({}, { labelModel: { title: 'WARM', details: [] } }), - billboard: { show: false }, - billboardCollection: { show: true, remove() {} }, + icon: { show: false }, + iconLayer: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, meta: { rawLat: 30.2, rawLon: -97.7, onGround: false }, }); @@ -229,16 +229,16 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a // first displayed position is its fix projected back to that delayed time. // Creating the icon at the raw fix drew it ahead until the next fleet // tick, which then jumped it back about one delay of travel. - const billboardCollection = { show: false }; + const iconLayer = { show: false }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ icao24: 'seed00', entity: null, - billboard: { + icon: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0, 0), }, - billboardCollection, + iconLayer, viewer, meta: { rawLat: 0, rawLon: 0, onGround: false }, tracked: false, @@ -317,13 +317,13 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA color: Cesium.Color.WHITE, show: false, }; - const billboardCollection = { show: false, remove() {} }; + const iconLayer = { show: false, remove() {} }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ icao24, entity, - billboard, - billboardCollection, + icon: billboard, + iconLayer, viewer, meta: { callsign: 'OLD1', @@ -473,8 +473,8 @@ test('real civil track path creates no native label and publishes every cached h _setTrackedFlightRefreshStateForTest({ icao24, entity: null, - billboard, - billboardCollection: { show: true, remove() {} }, + icon: billboard, + iconLayer: { show: true, remove() {} }, viewer, tracked: false, history: [ @@ -597,7 +597,7 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late001', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.5, 30.4, 9_000), }, @@ -621,7 +621,7 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late002', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.4, 30.5, 8_000), }, @@ -644,7 +644,7 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late003', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000), }, @@ -661,7 +661,7 @@ test('real civil track path creates no native label and publishes every cached h assert.equal(_pendingFlightTrackingRestoreForTest(), 'late004'); _addFlightTrackingCandidateForTest({ icao24: 'late004', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000), }, @@ -686,7 +686,7 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late005', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.1, 30.8, 5_000), }, @@ -732,12 +732,12 @@ function seedLabelContact({ _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity: null, - billboard: { + icon: { position: LABEL_POSITION, color: Cesium.Color.WHITE, show: true, }, - billboardCollection: { show: true, remove() {} }, + iconLayer: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, tracked, meta: { @@ -940,12 +940,12 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity, - billboard: { + icon: { position: LABEL_POSITION, color: Cesium.Color.WHITE, show: false, }, - billboardCollection: { show: false, remove() {} }, + iconLayer: { show: false, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, meta: { callsign: ' ', @@ -1064,16 +1064,16 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi _clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); - const billboardCollection = { show: false }; + const iconLayer = { show: false }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ icao24: 'seed00', entity: null, - billboard: { + icon: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0, 0), }, - billboardCollection, + iconLayer, viewer, meta: { rawLat: 0, rawLon: 0, onGround: false }, tracked: false, @@ -1154,11 +1154,11 @@ test('far grounded contacts take turns on the floor; near ones refloor every tic _setTrackedFlightRefreshStateForTest({ icao24: 'seed00', entity: null, - billboard: { + icon: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0, 0), }, - billboardCollection: { show: false }, + iconLayer: { show: false }, viewer, meta: { rawLat: 0, rawLon: 0, onGround: false }, tracked: false, diff --git a/src/data/focusAllocations.test.mjs b/src/data/focusAllocations.test.mjs index 86f28db18..a76caf494 100644 --- a/src/data/focusAllocations.test.mjs +++ b/src/data/focusAllocations.test.mjs @@ -23,6 +23,6 @@ test('converged focus treatment stays within the GC-bracketed allocation budget' assert.equal(result.status, 0, result.stderr || result.stdout); const report = JSON.parse(result.stdout.trim()); assert.ok(report.advanceSpriteFocus.roundedMedian <= 16, result.stdout); - assert.ok(report.applyAircraftBillboardTreatment.roundedMedian <= 16, result.stdout); + assert.ok(report.aircraftTreatmentAlpha.roundedMedian <= 16, result.stdout); assert.ok(report.productionSpriteTick.roundedMedian <= 212, result.stdout); }); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 606dc8440..f159f0655 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -889,8 +889,8 @@ test('Contacts activation adopts the production military tracked-subject descrip _setTrackedMilitaryRefreshStateForTest({ icao24: id, entity: null, - billboard: { position, show: true }, - billboardCollection: { show: true, remove() {} }, + icon: { position, show: true }, + iconLayer: { show: true, remove() {} }, viewer: sourceViewer, meta: { callsign: 'RCH451 ', @@ -971,8 +971,8 @@ test('Contacts activation adopts the production military tracked-subject descrip _setTrackedMilitaryRefreshStateForTest({ icao24: id, entity: null, - billboard: { position, show: true }, - billboardCollection: { show: true, remove() {} }, + icon: { position, show: true }, + iconLayer: { show: true, remove() {} }, viewer: sourceViewer, meta: {}, tracked: false, @@ -998,8 +998,8 @@ test('production military tracked-subject label falls back to registration befor _setTrackedMilitaryRefreshStateForTest({ icao24: id, entity: null, - billboard: { position, show: true }, - billboardCollection: { show: true, remove() {} }, + icon: { position, show: true }, + iconLayer: { show: true, remove() {} }, viewer: sourceViewer, meta: { callsign: ' ', @@ -1051,8 +1051,8 @@ test('production military tracked-subject label falls back to registration befor _setTrackedMilitaryRefreshStateForTest({ icao24: id, entity: null, - billboard: { position, show: true }, - billboardCollection: { show: true, remove() {} }, + icon: { position, show: true }, + iconLayer: { show: true, remove() {} }, viewer: sourceViewer, meta: {}, tracked: false, @@ -1097,8 +1097,8 @@ test('a cached Context subject re-reads its label when enrichment lands after se _setTrackedFlightRefreshStateForTest({ icao24: id, entity: null, - billboard: { position, color: Cesium.Color.WHITE, show: true }, - billboardCollection: { show: true, remove() {} }, + icon: { position, color: Cesium.Color.WHITE, show: true }, + iconLayer: { show: true, remove() {} }, viewer: sourceViewer, meta, }); @@ -1185,8 +1185,8 @@ test('a cached Context subject re-reads its label when enrichment lands after se _setTrackedFlightRefreshStateForTest({ icao24: id, entity: null, - billboard: { position, show: true }, - billboardCollection: { show: false, remove() {} }, + icon: { position, show: true }, + iconLayer: { show: false, remove() {} }, viewer: sourceViewer, meta: {}, tracked: false, @@ -1207,8 +1207,8 @@ test('Contacts activation adopts the production civilian tracked-subject descrip _setTrackedFlightRefreshStateForTest({ icao24: id, entity: null, - billboard: { position, show: true }, - billboardCollection: { show: true, remove() {} }, + icon: { position, show: true }, + iconLayer: { show: true, remove() {} }, viewer: sourceViewer, meta: { callsign: 'DAL123 ', @@ -1296,8 +1296,8 @@ test('Contacts activation adopts the production civilian tracked-subject descrip _setTrackedFlightRefreshStateForTest({ icao24: id, entity: null, - billboard: { position, show: true }, - billboardCollection: { show: true, remove() {} }, + icon: { position, show: true }, + iconLayer: { show: true, remove() {} }, viewer: sourceViewer, meta: {}, tracked: false, diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 3ea1c5f69..ee840f70d 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -149,8 +149,8 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target _setTrackedMilitaryRefreshStateForTest({ icao24: 'ae1234', entity: withEntityRecord({}, { labelModel: { title: 'WARM', details: [] } }), - billboard: { show: false }, - billboardCollection: { show: true, remove() {} }, + icon: { show: false }, + iconLayer: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, meta: { rawLat: 31, rawLon: -97, onGround: false }, }); @@ -190,13 +190,13 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p color: Cesium.Color.WHITE, show: false, }; - const billboardCollection = { show: false, remove() {} }; + const iconLayer = { show: false, remove() {} }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedMilitaryRefreshStateForTest({ icao24, entity, - billboard, - billboardCollection, + icon: billboard, + iconLayer, viewer, meta: { callsign: 'OLD2', @@ -266,11 +266,11 @@ test('cached military snapshots retain their observation time and expose stale f _setTrackedMilitaryRefreshStateForTest({ icao24: 'ae01ce', entity: withEntityRecord({}, { labelModel: { title: 'RCH451', details: [] } }), - billboard: { + icon: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false, }, - billboardCollection: { show: false, remove() {} }, + iconLayer: { show: false, remove() {} }, viewer, history, meta: { rawLat: 31, rawLon: -97, onGround: false }, @@ -408,8 +408,8 @@ test('real military track path creates no native label and publishes every cache _setTrackedMilitaryRefreshStateForTest({ icao24, entity: null, - billboard, - billboardCollection: { show: true, remove() {} }, + icon: billboard, + iconLayer: { show: true, remove() {} }, viewer, tracked: false, history: [ @@ -528,7 +528,7 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late001', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000), }, @@ -555,7 +555,7 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late002', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000), }, @@ -581,7 +581,7 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late003', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000), }, @@ -601,7 +601,7 @@ test('real military track path creates no native label and publishes every cache assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004'); _addMilitaryTrackingCandidateForTest({ icao24: 'late004', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000), }, @@ -629,7 +629,7 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late005', - billboard: { + icon: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000), }, @@ -676,7 +676,7 @@ test('a callsign-less contact is findable by the tail number the app displays', rawLat: 40.71, rawLon: -74.01, }, - billboard: { + icon: { position: Cesium.Cartesian3.fromDegrees(-74.01, 40.71, 167), color: Cesium.Color.WHITE, show: true, @@ -756,7 +756,7 @@ test('a registration never outranks another contact’s exact callsign', () => { rawLat: 31.0, rawLon: -97.0, }, - billboard: billboardAt(-97.0, 31.0), + icon: billboardAt(-97.0, 31.0), }); _addMilitaryTrackingCandidateForTest({ icao24: 'ae0bb2', @@ -767,7 +767,7 @@ test('a registration never outranks another contact’s exact callsign', () => { rawLat: 31.2, rawLon: -97.2, }, - billboard: billboardAt(-97.2, 31.2), + icon: billboardAt(-97.2, 31.2), }); assert.equal( @@ -808,7 +808,7 @@ test('two contacts matching at the same strength resolve deterministically', () rawLat: 32.0, rawLon: -98.0, }, - billboard: billboardAt(-98.0, 32.0), + icon: billboardAt(-98.0, 32.0), }); _addMilitaryTrackingCandidateForTest({ icao24: 'aef001', @@ -819,7 +819,7 @@ test('two contacts matching at the same strength resolve deterministically', () rawLat: 32.1, rawLon: -98.1, }, - billboard: billboardAt(-98.1, 32.1), + icon: billboardAt(-98.1, 32.1), }); const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24; @@ -847,12 +847,12 @@ test('the military tracked descriptor publishes the reported vertical rate', () _setTrackedMilitaryRefreshStateForTest({ icao24: 'ae7f01', entity: withEntityRecord({}, { labelModel: { title: 'TEST', details: [] } }), - billboard: { + icon: { position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000), color: Cesium.Color.WHITE, show: true, }, - billboardCollection: { show: true, remove() {} }, + iconLayer: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, meta: { callsign: 'RCH512', diff --git a/src/data/modelMirror.js b/src/data/modelMirror.js deleted file mode 100644 index 8c318d82f..000000000 --- a/src/data/modelMirror.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @module modelMirror - * @description Core's copy of what a layer pushed to its 3D models, so the layer - * reads its own placement back from Core instead of from the renderer's objects. - * - * A layer writes the mirrored fields with {@link setModel} and reads them with - * {@link modelState}. What the renderer measures (`ready`, `computedScale`) is - * still read from the model. Colour stays out for the same reason as on - * billboards: the cyber-sonar theme writes it too and reads the live value. - */ -import * as Cesium from 'cesium'; -import { createEntityStore } from './entityRecords.js'; - -const mirrors = createEntityStore(); - -/** - * Core's record for `model`, made on first write with Cesium's defaults for a - * model loaded without `show` or `modelMatrix` options (shown, at the identity). - */ -function recordFor(model) { - const record = mirrors.write(model); - if (!record.modelMatrix) { - record.show = true; - record.modelMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); - } - return record; -} - -/** - * Start mirroring a model Core did not write itself (test seams), from its - * current fields. - * @param {Object} model - * @returns {Object} The model. - */ -export function mirrorModel(model) { - if (!model || typeof model !== 'object') return model; - const record = recordFor(model); - record.show = model.show ?? true; - if (model.modelMatrix) - Cesium.Matrix4.clone(model.modelMatrix, record.modelMatrix); - return model; -} - -/** - * Write one mirrored field to the model and to Core's copy. A matrix is copied - * into both, so a shared scratch can be passed and reused straight after. - * @param {Object} model - * @param {'show'|'modelMatrix'} field - * @param {*} value - */ -export function setModel(model, field, value) { - if (field !== 'show' && field !== 'modelMatrix') - throw new TypeError(`The model mirror does not hold ${field}`); - const record = recordFor(model); - if (field === 'modelMatrix') { - Cesium.Matrix4.clone(value, record.modelMatrix); - // Cesium clones the model's own matrix each frame, so write into it in place. - if (model.modelMatrix) Cesium.Matrix4.clone(value, model.modelMatrix); - else model.modelMatrix = Cesium.Matrix4.clone(value); - } else { - record[field] = value; - model[field] = value; - } -} - -/** - * Core's copy of a model's mirrored fields, or null when nothing was written to - * it yet (or there is no model). - * @param {Object|null|undefined} model - * @returns {{ show: boolean, modelMatrix: Cesium.Matrix4 }|null} - */ -export function modelState(model) { - return mirrors.read(model); -} diff --git a/src/data/modelMirror.test.mjs b/src/data/modelMirror.test.mjs deleted file mode 100644 index 611980032..000000000 --- a/src/data/modelMirror.test.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { mirrorModel, modelState, setModel } from './modelMirror.js'; - -function fakeModel() { - return { show: true, modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY) }; -} - -test('a model has no mirror until Core writes to it', () => { - assert.equal(modelState(fakeModel()), null); - assert.equal(modelState(null), null); -}); - -test('the first write starts from Cesium defaults for the other fields', () => { - const model = fakeModel(); - setModel(model, 'show', false); - assert.equal(model.show, false); - assert.equal(modelState(model).show, false); - assert.ok(Cesium.Matrix4.equals(modelState(model).modelMatrix, Cesium.Matrix4.IDENTITY)); -}); - -test('a matrix is copied into both the model and the mirror, never shared', () => { - const model = fakeModel(); - const own = model.modelMatrix; - const scratch = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(1, 2, 3)); - setModel(model, 'modelMatrix', scratch); - assert.equal(model.modelMatrix, own, "written into the model's own matrix"); - assert.ok(Cesium.Matrix4.equals(model.modelMatrix, scratch)); - assert.notEqual(modelState(model).modelMatrix, scratch); - Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(9, 9, 9), scratch); - assert.deepEqual( - Cesium.Matrix4.getTranslation(modelState(model).modelMatrix, new Cesium.Cartesian3()), - new Cesium.Cartesian3(1, 2, 3), - 'reusing the scratch moves neither copy', - ); - assert.ok(!Cesium.Matrix4.equals(model.modelMatrix, scratch)); -}); - -test('the mirror answers from Core, not from the model', () => { - const model = fakeModel(); - setModel(model, 'show', true); - model.show = false; // a write that bypassed the mirror - assert.equal(modelState(model).show, true); -}); - -test("a model without its own matrix yet gets a copy, not the caller's", () => { - const model = { show: true }; - const matrix = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(1, 2, 3)); - setModel(model, 'modelMatrix', matrix); - assert.notEqual(model.modelMatrix, matrix); - assert.ok(Cesium.Matrix4.equals(model.modelMatrix, matrix)); -}); - -test('a model Core did not write can be mirrored from its fields', () => { - const matrix = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(4, 5, 6)); - const model = mirrorModel({ show: false, modelMatrix: matrix }); - assert.equal(modelState(model).show, false); - assert.ok(Cesium.Matrix4.equals(modelState(model).modelMatrix, matrix)); - assert.notEqual(modelState(model).modelMatrix, matrix); - assert.equal(modelState(mirrorModel({})).show, true, 'Cesium shows by default'); - assert.equal(mirrorModel(null), null); -}); - -test('fields outside the mirror are refused', () => { - assert.throws(() => setModel({}, 'scale', 2), TypeError); -}); diff --git a/src/data/modelRecord.js b/src/data/modelRecord.js new file mode 100644 index 000000000..c10fc524f --- /dev/null +++ b/src/data/modelRecord.js @@ -0,0 +1,79 @@ +/** + * @module modelRecord + * @description A layer's own record of one 3D model it draws, held in the + * layer's maps under the contact's id, so the layer reads what it pushed back + * from itself instead of from the renderer's object. + * + * Write `show` and the placement with {@link showModel} and {@link placeModel}; + * read them from the record. What the renderer measures (`ready`, + * `computedScale`) is still read from `record.model`. Colour stays out: the + * cyber-sonar theme writes it too and reads the live value. + */ +import * as Cesium from 'cesium'; + +/** + * @typedef {object} ModelRecord + * @property {Cesium.Model} model + * @property {boolean} show Whether the layer last showed the model. + * @property {Cesium.Matrix4} matrix Where the layer last placed it. + * @property {string|null} specKey The model spec it was loaded for; a class + * change reloads. + * @property {boolean|null} irBoost The IR boost it was loaded with; a flip + * reloads. + * @property {number} bellyM Metres from the model origin to its belly, to sit + * it on the ground. + */ + +/** + * A record for a model the layer just made, from the options it made it with + * (Cesium's defaults when none: shown, at the identity). + * @param {Cesium.Model} model + * @param {{specKey?: string, irBoost?: boolean, bellyM?: number}} [fields] + * @returns {ModelRecord} + */ +export function createModelRecord( + model, + { specKey = null, irBoost = null, bellyM = 0 } = {}, +) { + return { + model, + show: model.show ?? true, + matrix: Cesium.Matrix4.clone(model.modelMatrix ?? Cesium.Matrix4.IDENTITY), + specKey, + irBoost, + bellyM, + }; +} + +/** + * Show or hide a model and record it. + * @param {ModelRecord} record + * @param {boolean} show + */ +export function showModel(record, show) { + record.show = show; + record.model.show = show; +} + +/** + * Place a model and record where. The matrix is copied into both, so a shared + * scratch can be passed and reused straight after. + * @param {ModelRecord} record + * @param {Cesium.Matrix4} matrix + */ +export function placeModel(record, matrix) { + Cesium.Matrix4.clone(matrix, record.matrix); + const model = record.model; + // Cesium clones the model's own matrix each frame, so write into it in place. + if (model.modelMatrix) Cesium.Matrix4.clone(matrix, model.modelMatrix); + else model.modelMatrix = Cesium.Matrix4.clone(matrix); +} + +/** + * Whether a record's model is what the user sees: loaded and shown. + * @param {ModelRecord|null|undefined} record + * @returns {boolean} + */ +export function modelIsRendering(record) { + return !!record && record.model.ready === true && record.show === true; +} diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index e4f404ca8..39809abcf 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -32,7 +32,7 @@ import { trailHeadStart, modelAnchorWorld, } from './modelVisualAnchor.js'; -import { mirrorModel } from './modelMirror.js'; +import { createModelRecord } from './modelRecord.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -798,13 +798,13 @@ test('the trail anchor rides the rendered longitudinal axis at every heading', ( position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4(), ); for (const [url, anchor] of Object.entries(MODEL_TRAIL_ANCHOR_NATIVE)) { - const model = mirrorModel({ + const record = createModelRecord({ modelMatrix, computedScale: 1, sceneGraph: { components: { transform: rootTransform } }, }); const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()); - const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); + const world = modelAnchorWorld(record, anchor, new Cesium.Cartesian3()); // Reference axes derived INDEPENDENTLY, from the ENU frame and the same // heading the matrix was built with — deliberately NOT through // modelAnchorWorld. Taking them through the function under test makes the @@ -861,13 +861,13 @@ test('a hovering rotorcraft anchors aft of its own hull, whatever its heading', const anchor = MODEL_TRAIL_ANCHOR_NATIVE['/models/bell206.glb']; for (const headingDeg of [0, 137.5, 271.9]) { const hpr = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(headingDeg), 0, 0); - const model = mirrorModel({ + const record = createModelRecord({ modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame( position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4()), computedScale: 1, }); - const origin = Cesium.Matrix4.getTranslation(model.modelMatrix, new Cesium.Cartesian3()); - const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); + const origin = Cesium.Matrix4.getTranslation(record.matrix, new Cesium.Cartesian3()); + const world = modelAnchorWorld(record, anchor, new Cesium.Cartesian3()); const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); const ax = (i) => { const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); diff --git a/src/data/modelVisualAnchor.js b/src/data/modelVisualAnchor.js index a3bd92b64..c9fdf782f 100644 --- a/src/data/modelVisualAnchor.js +++ b/src/data/modelVisualAnchor.js @@ -1,5 +1,4 @@ import * as Cesium from 'cesium'; -import { modelState } from './modelMirror.js'; /** * Model-space visual centres measured from the shipped GLBs' scene-space AABBs. * @@ -107,13 +106,16 @@ const _chainScratch = new Cesium.Matrix4(); * here, so exactly one transform — Cesium's own — stands between the table and * the world, and there is no second convention to drift. * - * @param {object} model Cesium Model (needs modelMatrix, computedScale, sceneGraph). + * @param {import('./modelRecord.js').ModelRecord} record The layer's record of + * the model: where it placed it, and the Cesium Model (computedScale, + * sceneGraph). * @param {readonly number[]} nativeAnchor Raw glTF `[x, y, z]` native-unit offset. * @param {{x:number,y:number,z:number}} result Mutable Cartesian-like result. * @returns {{x:number,y:number,z:number}|null} World position of the anchor. */ -export function modelAnchorWorld(model, nativeAnchor, result) { - const modelMatrix = modelState(model)?.modelMatrix; +export function modelAnchorWorld(record, nativeAnchor, result) { + const modelMatrix = record?.matrix; + const model = record?.model; if (!modelMatrix || !nativeAnchor || !result) return null; const scale = Number.isFinite(model.computedScale) ? model.computedScale : 1; _anchorScratch.x = (nativeAnchor[0] || 0) * scale; diff --git a/src/data/nearbyModelHandoff.test.mjs b/src/data/nearbyModelHandoff.test.mjs index e540be618..ce3a6a2bc 100644 --- a/src/data/nearbyModelHandoff.test.mjs +++ b/src/data/nearbyModelHandoff.test.mjs @@ -51,8 +51,8 @@ const LAYERS = [ _setTrackedFlightRefreshStateForTest({ icao24: ICAO, entity: null, - billboard: contactBillboard(billboardShow), - billboardCollection: { show: true, remove() {} }, + icon: contactBillboard(billboardShow), + iconLayer: { show: true, remove() {} }, viewer: contactViewer(), tracked: false, models, @@ -74,8 +74,8 @@ const LAYERS = [ _setTrackedMilitaryRefreshStateForTest({ icao24: ICAO, entity: null, - billboard: contactBillboard(billboardShow), - billboardCollection: { show: true, remove() {} }, + icon: contactBillboard(billboardShow), + iconLayer: { show: true, remove() {} }, viewer: contactViewer(), tracked: false, models, diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index 89b64c733..f30b578fe 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -164,8 +164,8 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke _setTrackedFlightRefreshStateForTest({ icao24, entity, - billboard, - billboardCollection: { show: false, remove() {} }, + icon: billboard, + iconLayer: { show: false, remove() {} }, viewer, meta: { callsign: 'OLD1', @@ -298,13 +298,13 @@ test('conversions are session-scoped and no lifecycle path clears them', async ( _setTrackedFlightRefreshStateForTest({ icao24, entity: null, - billboard: { + icon: { position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000), color: Cesium.Color.WHITE, image: aircraftIcon('tr3b'), show: true, }, - billboardCollection: { show: false, remove() {} }, + iconLayer: { show: false, remove() {} }, viewer, tracked: false, meta: { callsign: 'OLD1', altitude: 9_000, klass: 'airliner', rawLat: 30.2, rawLon: -97.7 }, @@ -462,12 +462,12 @@ test('cockpit class filter matches a converted contact end to end', async () => const seed = () => _setTrackedFlightRefreshStateForTest({ icao24, entity: null, - billboard: { + icon: { position: Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668), color: Cesium.Color.WHITE, show: true, }, - billboardCollection: { show: true, remove() {} }, + iconLayer: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, tracked: false, meta: { callsign: 'SWA696 ', altitude: 10_668, klass: 'airliner', onGround: false }, @@ -508,12 +508,12 @@ test('military records and detection cards agree with the conversion', () => { const seed = () => _setTrackedMilitaryRefreshStateForTest({ icao24, entity: null, - billboard: { + icon: { position: Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668), color: Cesium.Color.WHITE, show: true, }, - billboardCollection: { show: true, remove() {} }, + iconLayer: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, tracked: false, meta: { callsign: 'RCH451', altitudeFt: 35_000, klass: 'quadjet', type: 'C-17A', onGround: false }, diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 09fd23e36..ba7c97681 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -49,7 +49,6 @@ 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 @@ -231,8 +230,8 @@ const LAYERS = [ _setTrackedFlightRefreshStateForTest({ icao24, entity: null, - billboard, - billboardCollection: { show: true, remove() {} }, + icon: billboard, + iconLayer: { show: true, remove() {} }, viewer, tracked, models, @@ -256,8 +255,8 @@ const LAYERS = [ _setTrackedMilitaryRefreshStateForTest({ icao24, entity: null, - billboard, - billboardCollection: { show: true, remove() {} }, + icon: billboard, + iconLayer: { show: true, remove() {} }, viewer, tracked, models, @@ -375,7 +374,7 @@ for (const fixture of LAYERS) { onGround: true, models: [[icao24, model]], }); - setBillboard(billboard, 'show', false); + billboard.show = false; let ownsVisual = fixture.driveFleetModel({ icao24, position, course: 92 }); assert.equal(ownsVisual, false, 'cold terrain cannot transfer ownership to 3D'); @@ -511,7 +510,7 @@ for (const fixture of LAYERS) { onGround: true, models: [[icao24, model]], }); - setBillboard(billboard, 'show', false); + billboard.show = false; const atA = Cesium.Cartesian3.fromDegrees(A_LON, LAT, 0); const atB = Cesium.Cartesian3.fromDegrees(B_LON, LAT, 0); @@ -646,7 +645,7 @@ for (const fixture of LAYERS) { 0, 1e-6, ), 'a hidden tracked model cannot steal the detection anchor'); - setBillboard(billboard, 'show', false); // tracking hides the fleet sprite; the tracked entity remains the 2D owner + 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'); @@ -685,7 +684,7 @@ for (const fixture of LAYERS) { viewer.tileset.tilesLoaded = false; sampledHeight = undefined; - setBillboard(billboard, 'position', Cesium.Cartesian3.fromDegrees(-97.71, 30.21, -500)); + 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'); @@ -695,7 +694,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. - setBillboard(billboard, 'position', Cesium.Cartesian3.fromDegrees(-97.709, 30.21, -500)); + 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'); @@ -704,7 +703,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. - setBillboard(billboard, 'position', Cesium.Cartesian3.fromDegrees(-97.707, 30.21, -500)); + 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/aircraft/visualState.js b/src/layers/aircraft/visualState.js deleted file mode 100644 index b9b1f191c..000000000 --- a/src/layers/aircraft/visualState.js +++ /dev/null @@ -1,11 +0,0 @@ -import { createEntityStore } from '../../data/entityRecords.js'; - -/** - * What the aircraft layers (flights, military, local ADS-B) remember about the - * models and billboards they draw, kept beside them rather than on them. - * - `specKey`: the model spec a model was loaded for; a class change reloads. - * - `irBoost`: the IR boost a model was loaded with; a flip reloads. - * - `bellyM`: metres from the model origin to its belly, to sit it on the ground. - * - `iconLarge`: whether a billboard shows the large icon raster. - */ -export const aircraftVisuals = createEntityStore(); diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index f8d76eb6c..88e2d8240 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -32,7 +32,6 @@ import { DISPLAY_CORRIDOR_CELL_BUDGET, DISPLAY_CORRIDOR_FAIR_SHARE, } from './policy.js'; -import { modelState } from '../../data/modelMirror.js'; import { readEntityRecord } from '../../data/entityRecords.js'; import { aircraftMotionRegime, @@ -476,10 +475,10 @@ export function createMotion({ flightState.records.data.get(flightState._trackedIcao)?.klass, ); return modelVisualAnchor( - modelState(flightState._trackedModel).modelMatrix, + flightState._trackedModel.matrix, spec.visualCenterNative, - Number.isFinite(flightState._trackedModel.computedScale) - ? flightState._trackedModel.computedScale + Number.isFinite(flightState._trackedModel.model.computedScale) + ? flightState._trackedModel.model.computedScale : spec.scale, flightState._trackedVisualPos, ); @@ -505,7 +504,7 @@ export function createMotion({ ) return null; return Cesium.Matrix4.getTranslation( - modelState(flightState._trackedModel).modelMatrix, + flightState._trackedModel.matrix, flightState._scratchTrailClip, ); } @@ -520,8 +519,8 @@ export function createMotion({ const spec = parts.rendering._modelSpec( flightState.records.data.get(flightState._trackedIcao)?.klass, ); - const scale = Number.isFinite(flightState._trackedModel.computedScale) - ? flightState._trackedModel.computedScale + const scale = Number.isFinite(flightState._trackedModel.model.computedScale) + ? flightState._trackedModel.model.computedScale : spec.scale; return spec.nativeRadiusM * scale; } diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 23f64b0c1..e74577ac6 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -16,7 +16,6 @@ import { FLEET_DR_INTERVAL_MS, } from './policy.js'; import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; -import { modelState } from '../../data/modelMirror.js'; export function createQueries({ flightState, @@ -440,8 +439,11 @@ export function createQueries({ const isTracked = icao24 === flightState._trackedIcao; // 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) ?? null; - const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model); + const record = flightState._models.get(icao24) ?? null; + const modelOwnsVisual = parts.rendering._modelOwnsVisual( + icao24, + record, + ); if (!isTracked && !icons.shown(row) && !modelOwnsVisual) continue; const info = flightState.records.data.get(icao24); let object = flightState._detectionObjects.get(icao24); @@ -470,10 +472,10 @@ export function createQueries({ icons.position(row, tau, object._iconPos) : modelOwnsVisual ? modelVisualAnchor( - modelState(model).modelMatrix, + record.matrix, spec.visualCenterNative, - Number.isFinite(model.computedScale) - ? model.computedScale + Number.isFinite(record.model.computedScale) + ? record.model.computedScale : spec.scale, object._weldPos || (object._weldPos = new Cesium.Cartesian3()), ) diff --git a/src/layers/flights/queries.test.mjs b/src/layers/flights/queries.test.mjs index a2d1fff19..a0ab8c132 100644 --- a/src/layers/flights/queries.test.mjs +++ b/src/layers/flights/queries.test.mjs @@ -6,7 +6,11 @@ import { createRendering } from './rendering.js'; import { createIcons } from './icons.js'; import { createIconSet } from '../../data/iconSet.js'; import { writeHoldSegment } from '../../data/iconSegment.js'; -import { mirrorModel, setModel } from '../../data/modelMirror.js'; +import { + createModelRecord, + placeModel, + showModel, +} from '../../data/modelRecord.js'; function fixture() { const info = { @@ -109,7 +113,7 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => const get = t.mock.method(f.flightState._models, 'get'); const [candidate] = f.queries.getDetectableObjects(); assert.equal(get.mock.callCount(), 1); - const model = mirrorModel({ + const record = createModelRecord({ ready: false, show: true, modelMatrix: Cesium.Matrix4.fromTranslation( @@ -120,25 +124,24 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => visualCenterNative: Cesium.Cartesian3.ZERO, scale: 1, }); - f.flightState._models.set('abc123', model); + f.flightState._models.set('abc123', record); f.show(false); assert.deepEqual(f.queries.getDetectableObjects(), []); - model.ready = true; + record.model.ready = true; assert.equal(f.queries.getDetectableObjects()[0], candidate); assert.deepEqual(candidate.position, new Cesium.Cartesian3(10, 20, 30)); - setModel( - model, - 'modelMatrix', + placeModel( + record, Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(40, 50, 60)), ); f.queries.getDetectableObjects(); assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60)); - setModel(model, 'show', false); + showModel(record, false); assert.deepEqual(f.queries.getDetectableObjects(), []); f.show(true); assert.deepEqual(f.queries.getDetectableObjects()[0].position, f.drawn()); assert.equal(get.mock.callCount(), 6); - setModel(model, 'show', true); + showModel(record, true); assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true); assert.equal(get.mock.callCount(), 7); }); @@ -146,7 +149,7 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => test('tracked ownership and live anchors remain independent of fleet models', () => { const f = fixture(); f.flightState._trackedIcao = 'abc123'; - f.flightState._trackedModel = mirrorModel({ ready: true, show: true }); + f.flightState._trackedModel = createModelRecord({ ready: true, show: true }); f.parts.rendering._modelSpec = () => ({}); let anchor = new Cesium.Cartesian3(10, 20, 30); f.parts.motion._trackedVisualCached = () => anchor; @@ -158,16 +161,16 @@ test('tracked ownership and live anchors remain independent of fleet models', () assert.equal(f.queries.getDetectableObjects()[0].position, anchor); for (const tracked of [ null, - mirrorModel({ ready: false, show: true }), - mirrorModel({ ready: true, show: false }), + createModelRecord({ ready: false, show: true }), + createModelRecord({ ready: true, show: false }), ]) { f.flightState._trackedModel = tracked; assert.equal( - f.parts.rendering._modelOwnsVisual('abc123', mirrorModel({ ready: true, show: true })), + f.parts.rendering._modelOwnsVisual('abc123', createModelRecord({ ready: true, show: true })), false, ); } - f.flightState._trackedModel = mirrorModel({ ready: true, show: true }); + f.flightState._trackedModel = createModelRecord({ ready: true, show: true }); f.parts.tracking._trackedModelRegimeActive = () => false; assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), false); }); diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 17260ec93..4e8d9f751 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -239,8 +239,8 @@ export function createFlightSnapshotRenderer({ sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { excludeObjects: [ ...icons._iconIds(), - ...flightState._models.values(), - flightState._trackedModel, + ...[...flightState._models.values()].map((record) => record.model), + flightState._trackedModel?.model, ].filter(Boolean), viewerLat: viewerLatDeg, viewerLon: viewerLonDeg, diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index 06a1b9061..efbdd1ce1 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { CYAN_TRANSPARENT } from './policy.js'; -import { mirrorModel } from '../../data/modelMirror.js'; +import { createModelRecord } from '../../data/modelRecord.js'; import { createIconSet } from '../../data/iconSet.js'; import { aircraftIconTestSeams } from '../../data/aircraftIconLayer.js'; @@ -49,9 +49,9 @@ export function createTesting({ * @param {string} state.icao24 * @param {object} state.entity * @param {object} state.meta - * @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.billboard] + * @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.icon] * Where the contact's icon is held, and whether it is shown. - * @param {{show?: boolean}} [state.billboardCollection] Layer visibility. + * @param {{show?: boolean}} [state.iconLayer] Layer visibility. * @param {object} state.viewer * @param {Array} [state.history=[]] * @param {boolean} [state.tracked=true] @@ -63,8 +63,8 @@ export function createTesting({ icao24, entity, meta, - billboard, - billboardCollection, + icon, + iconLayer, viewer, history = [], tracked = true, @@ -76,13 +76,13 @@ export function createTesting({ flightState._icons = createIconSet({ epoch: parts.icons._displayTime(new Cesium.JulianDate()), }); - flightState._icons.show = billboardCollection?.show ?? true; + flightState._icons.show = iconLayer?.show ?? true; flightState._grounded.clear(); flightState._floorPinned.clear(); - _seedIconForTest(icao24, billboard, tracked); + _seedIconForTest(icao24, icon, tracked); flightState._models.clear(); for (const [key, model] of models) - flightState._models.set(key, mirrorModel(model)); + flightState._models.set(key, createModelRecord(model)); flightState._detectionObjects = new Map(); flightState.records.data = new Map([[icao24, meta]]); flightState._positionHistory = new Map([[icao24, history]]); @@ -125,10 +125,10 @@ export function createTesting({ function _addFlightTrackingCandidateForTest({ icao24, meta, - billboard, + icon, history = [], }) { - _seedIconForTest(icao24, billboard, false); + _seedIconForTest(icao24, icon, false); flightState.records.data.set(icao24, meta); flightState._positionHistory.set(icao24, history); } @@ -205,7 +205,7 @@ export function createTesting({ async function _ensureFleetModelForTest(icao24) { await parts.rendering._ensureModel(icao24); - return flightState._models.get(icao24) || null; + return flightState._models.get(icao24)?.model ?? null; } /** Run the fleet tick's grounded floor pass once, at `renderTime`. */ diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 99c2ab7e2..d1ad04150 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1193,7 +1193,8 @@ export function createTracking({ const rawPick = typeof picked.id === 'string' ? picked.id : picked.primitive?.id; if ( - picked.primitive === flightState._trackedModel || + (flightState._trackedModel && + picked.primitive === flightState._trackedModel.model) || rawPick === flightState._trackedIcao ) return; diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 84ae5677b..c89a052f0 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -15,8 +15,11 @@ import { MODEL_PROX_KEEP_M, } from '../flights/policy.js'; import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; -import { aircraftVisuals } from '../aircraft/visualState.js'; -import { setModel } from '../../data/modelMirror.js'; +import { + createModelRecord, + placeModel, + showModel, +} from '../../data/modelRecord.js'; const specKey = (spec) => `${spec.url}@${spec.scale}`; @@ -48,7 +51,7 @@ export function createLocalAdsbModels({ }) { const collection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(collection); - /** @type {Map} id -> admitted model */ + /** @type {Map} id -> admitted model */ const models = new Map(); /** @type {Map} id -> generation of an in-flight load */ const pending = new Map(); @@ -64,10 +67,10 @@ export function createLocalAdsbModels({ function release(id) { pending.delete(id); - const model = models.get(id); - if (!model) return; + const record = models.get(id); + if (!record) return; models.delete(id); - if (!collection.isDestroyed()) collection.remove(model); + if (!collection.isDestroyed()) collection.remove(record.model); groundSnap?.forget?.(id); } @@ -81,7 +84,7 @@ export function createLocalAdsbModels({ const spec = civilAircraftModelSpec(marker.klass); const key = specKey(spec); const current = models.get(id); - if (current && aircraftVisuals.read(current)?.specKey === key) return; + if (current?.specKey === key) return; if (current) release(id); if (pending.has(id) || models.size + pending.size >= cap) return; const loadGeneration = generation; @@ -116,13 +119,14 @@ export function createLocalAdsbModels({ return; } model.id = id; - const visual = aircraftVisuals.write(model); - visual.specKey = key; - visual.bellyM = spec.bellyM; + const record = createModelRecord(model, { + specKey: key, + bellyM: spec.bellyM, + }); // Admitted, not yet the visual: placement turns it on. - setModel(model, 'show', false); + showModel(record, false); collection.add(model); - models.set(id, model); + models.set(id, record); } function regime(preferences) { @@ -171,11 +175,11 @@ export function createLocalAdsbModels({ function placement(id, marker) { if (!marker.onGround) return marker.position; - const model = models.get(id); + const record = models.get(id); // The vertical sample must hit the tile skin, not this aircraft's own // billboard or model. const height = groundSnap?.heightFor?.(viewer, id, marker.position, () => - [marker.entity, model].filter(Boolean), + [marker.entity, record?.model].filter(Boolean), ); if (!Number.isFinite(height)) return null; const carto = Cesium.Cartographic.fromCartesian( @@ -186,7 +190,7 @@ export function createLocalAdsbModels({ return Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, - height + (aircraftVisuals.read(model)?.bellyM || 0), + height + (record?.bellyM || 0), Cesium.Ellipsoid.WGS84, scratchGround, ); @@ -221,12 +225,12 @@ export function createLocalAdsbModels({ } const occluder = horizonOccluder(viewer.camera); for (const [id, marker] of markers) { - const model = models.get(id); + const record = models.get(id); marker.modelOwnsVisual = false; - if (!model || !marker.position) continue; + if (!record || !marker.position) continue; const position = placement(id, marker); if (!position || !occluder.isPointVisible(marker.position)) { - setModel(model, 'show', false); + showModel(record, false); continue; } scratchHpr.heading = Cesium.Math.toRadians( @@ -234,9 +238,8 @@ export function createLocalAdsbModels({ ); scratchHpr.pitch = 0; scratchHpr.roll = 0; - setModel( - model, - 'modelMatrix', + placeModel( + record, Cesium.Transforms.headingPitchRollToFixedFrame( position, scratchHpr, @@ -245,11 +248,11 @@ export function createLocalAdsbModels({ scratchMatrix, ), ); - if (!model.ready) { - setModel(model, 'show', false); + if (!record.model.ready) { + showModel(record, false); continue; } - setModel(model, 'show', true); + showModel(record, true); marker.modelOwnsVisual = true; } for (const id of [...models.keys()]) if (!markers.has(id)) release(id); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 36c068531..5f48967bc 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -21,7 +21,6 @@ import { COURSE_SLEW_DT_MAX_SEC, COURSE_MAX_DPS, } from './policy.js'; -import { modelState } from '../../data/modelMirror.js'; import { readEntityRecord } from '../../data/entityRecords.js'; import { aircraftMotionRegime, @@ -340,10 +339,10 @@ export function createMotion({ flightState.records.data.get(flightState._trackedIcao)?.klass, ); return modelVisualAnchor( - modelState(flightState._trackedModel).modelMatrix, + flightState._trackedModel.matrix, spec.visualCenterNative, - Number.isFinite(flightState._trackedModel.computedScale) - ? flightState._trackedModel.computedScale + Number.isFinite(flightState._trackedModel.model.computedScale) + ? flightState._trackedModel.model.computedScale : spec.scale, flightState._trackedVisualPos, ); @@ -367,7 +366,7 @@ export function createMotion({ ) return null; return Cesium.Matrix4.getTranslation( - modelState(flightState._trackedModel).modelMatrix, + flightState._trackedModel.matrix, flightState._scratchTrailClip, ); } @@ -382,8 +381,8 @@ export function createMotion({ const spec = parts.rendering._modelSpec( flightState.records.data.get(flightState._trackedIcao)?.klass, ); - const scale = Number.isFinite(flightState._trackedModel.computedScale) - ? flightState._trackedModel.computedScale + const scale = Number.isFinite(flightState._trackedModel.model.computedScale) + ? flightState._trackedModel.model.computedScale : spec.scale; return spec.nativeRadiusM * scale; } diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 2741ea83d..90379cdb0 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -181,11 +181,11 @@ test('a newly seen military contact appears at its delayed position, not ahead o icao24: 'seed00', entity: null, meta: { rawLat: 0, rawLon: 0, onGround: false }, - billboard: { + icon: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0, 0), }, - billboardCollection: { show: false }, + iconLayer: { show: false }, viewer: { camera: { positionCartographic: null }, scene: {} }, tracked: false, }); @@ -246,8 +246,8 @@ test('a vanished military contact fades while position-less rows keep theirs', a icao24: 'seed00', entity: null, meta: { rawLat: 0, rawLon: 0, onGround: false }, - billboard: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0) }, - billboardCollection: { show: true, add: (options) => ({ ...options }) }, + icon: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0) }, + iconLayer: { show: true, add: (options) => ({ ...options }) }, viewer, tracked: false, }); diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index e2908fbb9..8e4710a6e 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 { modelState } from '../../data/modelMirror.js'; export function createQueries({ flightState, @@ -450,7 +449,7 @@ export function createQueries({ const isTracked = icao24 === flightState._trackedIcao; // 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 record = flightState._models.get(icao24); const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24); if (!isTracked && !icons.shown(row) && !modelOwnsVisual) continue; const info = flightState.records.data.get(icao24); @@ -479,10 +478,10 @@ export function createQueries({ icons.position(row, tau, object._iconPos) : modelOwnsVisual ? modelVisualAnchor( - modelState(model).modelMatrix, + record.matrix, spec.visualCenterNative, - Number.isFinite(model.computedScale) - ? model.computedScale + Number.isFinite(record.model.computedScale) + ? record.model.computedScale : spec.scale, object._weldPos || (object._weldPos = new Cesium.Cartesian3()), ) diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index f1ad8f182..442b4794e 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -186,8 +186,8 @@ export function createMilitarySnapshotRenderer({ sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, { excludeObjects: [ ...icons._iconIds(), - ...flightState._models.values(), - flightState._trackedModel, + ...[...flightState._models.values()].map((record) => record.model), + flightState._trackedModel?.model, ].filter(Boolean), viewerLat: viewerCarto ? Cesium.Math.toDegrees(viewerCarto.latitude) diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index b67ab7299..02268cccb 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; -import { mirrorModel } from '../../data/modelMirror.js'; +import { createModelRecord } from '../../data/modelRecord.js'; import { createIconSet } from '../../data/iconSet.js'; import { aircraftIconTestSeams } from '../../data/aircraftIconLayer.js'; @@ -21,9 +21,9 @@ export function createTesting({ * @param {string} state.icao24 * @param {object} state.entity * @param {object} state.meta - * @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.billboard] + * @param {{position?: Cesium.Cartesian3, show?: boolean}} [state.icon] * Where the contact's icon is held, and whether it is shown. - * @param {{show?: boolean}} [state.billboardCollection] Layer visibility. + * @param {{show?: boolean}} [state.iconLayer] Layer visibility. * @param {object} state.viewer * @param {Array} [state.history=[]] * @param {boolean} [state.tracked=true] @@ -35,8 +35,8 @@ export function createTesting({ icao24, entity, meta, - billboard, - billboardCollection, + icon, + iconLayer, viewer, history = [], tracked = true, @@ -48,11 +48,11 @@ export function createTesting({ flightState._icons = createIconSet({ epoch: parts.icons._displayTime(new Cesium.JulianDate()), }); - flightState._icons.show = billboardCollection?.show ?? true; - _seedIconForTest(icao24, billboard, tracked); + flightState._icons.show = iconLayer?.show ?? true; + _seedIconForTest(icao24, icon, tracked); flightState._models.clear(); for (const [key, model] of models) - flightState._models.set(key, mirrorModel(model)); + flightState._models.set(key, createModelRecord(model)); flightState._detectionObjects = new Map(); flightState.records.data = new Map([[icao24, meta]]); flightState._positionHistory = new Map([[icao24, history]]); @@ -93,10 +93,10 @@ export function createTesting({ function _addMilitaryTrackingCandidateForTest({ icao24, meta, - billboard, + icon, history = [], }) { - _seedIconForTest(icao24, billboard, false); + _seedIconForTest(icao24, icon, false); flightState.records.data.set(icao24, meta); flightState._positionHistory.set(icao24, history); } @@ -158,7 +158,7 @@ export function createTesting({ async function _ensureFleetModelForTest(icao24) { await parts.rendering._ensureModel(icao24); - return flightState._models.get(icao24) || null; + return flightState._models.get(icao24)?.model ?? null; } return { diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 99a8de205..b61b03f71 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1098,7 +1098,8 @@ export function createTracking({ const rawPick = typeof picked.id === 'string' ? picked.id : picked.primitive?.id; if ( - picked.primitive === flightState._trackedModel || + (flightState._trackedModel && + picked.primitive === flightState._trackedModel.model) || rawPick === flightState._trackedIcao ) return;