From 66869b09ef5a1a01206d84de494944c958c01933 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 12:58:50 -0800 Subject: [PATCH] Read aircraft model placement from Core's mirror Flights, military and local ADS-B now write their models' show and world matrix through setModel in src/data/modelMirror.js, and read them back through modelState: the fleet and tracked handoffs, the tracked visual and trail anchors, detection anchors and modelAnchorWorld. A matrix is copied into Core's record and into the model's own matrix, so the shared scratch can be passed directly and models still never share one matrix object. What Cesium measures (ready, computedScale) is still read from the model, and colour stays live as on billboards, because cyber sonar writes it too. Test seams mirror the models they install, as they do billboards. --- scripts/package-boundaries.json | 6 +++ src/data/modelMirror.js | 74 +++++++++++++++++++++++++++++ src/data/modelMirror.test.mjs | 67 ++++++++++++++++++++++++++ src/data/modelScale.test.mjs | 9 ++-- src/data/modelVisualAnchor.js | 6 ++- src/layers/flights/motion.js | 5 +- src/layers/flights/queries.js | 3 +- src/layers/flights/queries.test.mjs | 27 ++++++----- src/layers/flights/rendering.js | 48 ++++++++++--------- src/layers/flights/testing.js | 4 +- src/layers/localAdsb/models.js | 26 ++++++---- src/layers/military/motion.js | 5 +- src/layers/military/queries.js | 3 +- src/layers/military/rendering.js | 74 ++++++++++++++++++----------- src/layers/military/testing.js | 4 +- 15 files changed, 273 insertions(+), 88 deletions(-) create mode 100644 src/data/modelMirror.js create mode 100644 src/data/modelMirror.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ee13c0f9d..e759dd493 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1315,6 +1315,7 @@ "src/layers/flights/policy.js", "src/layers/flights/queries.js", "src/layers/flights/rendering.js", + "src/data/modelMirror.js", "src/data/billboardMirror.js", "src/layers/aircraft/visualState.js", "src/layers/flights/state.js", @@ -1376,6 +1377,7 @@ "src/layers/military/policy.js", "src/layers/military/queries.js", "src/layers/military/rendering.js", + "src/data/modelMirror.js", "src/data/billboardMirror.js", "src/layers/aircraft/visualState.js", "src/layers/military/state.js", @@ -1420,6 +1422,8 @@ "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", + "src/data/modelMirror.js", + "src/data/entityRecords.js", "src/data/aircraftModelLighting.js", "src/data/motionModel.js", "src/data/renderAltitude.js", @@ -2543,6 +2547,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/layers/flights/state.js", "src/layers/flights/testing.js", @@ -3453,6 +3458,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/layers/flights/state.js", "src/layers/flights/testing.js", diff --git a/src/data/modelMirror.js b/src/data/modelMirror.js new file mode 100644 index 000000000..8c318d82f --- /dev/null +++ b/src/data/modelMirror.js @@ -0,0 +1,74 @@ +/** + * @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 new file mode 100644 index 000000000..611980032 --- /dev/null +++ b/src/data/modelMirror.test.mjs @@ -0,0 +1,67 @@ +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/modelScale.test.mjs b/src/data/modelScale.test.mjs index d91914805..37cb1ca31 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -32,6 +32,7 @@ import { trailHeadStart, modelAnchorWorld, } from './modelVisualAnchor.js'; +import { mirrorModel } from './modelMirror.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -797,11 +798,11 @@ 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 = { + const model = mirrorModel({ modelMatrix, computedScale: 1, sceneGraph: { components: { transform: rootTransform } }, - }; + }); const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()); const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); // Reference axes derived INDEPENDENTLY, from the ENU frame and the same @@ -860,11 +861,11 @@ 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 = { + const model = mirrorModel({ 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 enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); diff --git a/src/data/modelVisualAnchor.js b/src/data/modelVisualAnchor.js index 733cd5049..a3bd92b64 100644 --- a/src/data/modelVisualAnchor.js +++ b/src/data/modelVisualAnchor.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { modelState } from './modelMirror.js'; /** * Model-space visual centres measured from the shipped GLBs' scene-space AABBs. * @@ -112,7 +113,8 @@ const _chainScratch = new Cesium.Matrix4(); * @returns {{x:number,y:number,z:number}|null} World position of the anchor. */ export function modelAnchorWorld(model, nativeAnchor, result) { - if (!model || !model.modelMatrix || !nativeAnchor || !result) return null; + const modelMatrix = modelState(model)?.modelMatrix; + if (!modelMatrix || !nativeAnchor || !result) return null; const scale = Number.isFinite(model.computedScale) ? model.computedScale : 1; _anchorScratch.x = (nativeAnchor[0] || 0) * scale; _anchorScratch.y = (nativeAnchor[1] || 0) * scale; @@ -122,7 +124,7 @@ export function modelAnchorWorld(model, nativeAnchor, result) { // baked root transform is carried too. const components = model?.sceneGraph?.components?.transform; Cesium.Matrix4.multiplyTransformation( - model.modelMatrix, + modelMatrix, components || Cesium.Matrix4.IDENTITY, _chainScratch, ); diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index d4ae59845..6fe988f12 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -36,6 +36,7 @@ import { DISPLAY_CORRIDOR_FAIR_SHARE, } from './policy.js'; import { billboardState, setBillboard } from '../../data/billboardMirror.js'; +import { modelState } from '../../data/modelMirror.js'; export function createMotion({ flightState, @@ -530,7 +531,7 @@ export function createMotion({ flightState.records.data.get(flightState._trackedIcao)?.klass, ); return modelVisualAnchor( - flightState._trackedModel.modelMatrix, + modelState(flightState._trackedModel).modelMatrix, spec.visualCenterNative, Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -559,7 +560,7 @@ export function createMotion({ ) return null; return Cesium.Matrix4.getTranslation( - flightState._trackedModel.modelMatrix, + modelState(flightState._trackedModel).modelMatrix, flightState._scratchTrailClip, ); } diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 0cfad575e..e8eb44e8d 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -17,6 +17,7 @@ import { } from './policy.js'; import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; import { billboardState, setBillboard } from '../../data/billboardMirror.js'; +import { modelState } from '../../data/modelMirror.js'; export function createQueries({ flightState, @@ -483,7 +484,7 @@ export function createQueries({ ? parts.motion._trackedVisualCached() || billboardState(bb).position : modelOwnsVisual ? modelVisualAnchor( - model.modelMatrix, + modelState(model).modelMatrix, spec.visualCenterNative, Number.isFinite(model.computedScale) ? model.computedScale diff --git a/src/layers/flights/queries.test.mjs b/src/layers/flights/queries.test.mjs index 33eec2ed9..106bc0170 100644 --- a/src/layers/flights/queries.test.mjs +++ b/src/layers/flights/queries.test.mjs @@ -8,6 +8,7 @@ import { mirrorBillboard, setBillboard, } from '../../data/billboardMirror.js'; +import { mirrorModel, setModel } from '../../data/modelMirror.js'; function fixture() { const info = { @@ -94,13 +95,13 @@ 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 = { + const model = mirrorModel({ ready: false, show: true, modelMatrix: Cesium.Matrix4.fromTranslation( new Cesium.Cartesian3(10, 20, 30), ), - }; + }); f.parts.rendering._modelSpec = () => ({ visualCenterNative: Cesium.Cartesian3.ZERO, scale: 1, @@ -111,14 +112,14 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => model.ready = true; assert.equal(f.queries.getDetectableObjects()[0], candidate); assert.deepEqual(candidate.position, new Cesium.Cartesian3(10, 20, 30)); - Cesium.Matrix4.setTranslation( - model.modelMatrix, - new Cesium.Cartesian3(40, 50, 60), - model.modelMatrix, + setModel( + model, + 'modelMatrix', + Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(40, 50, 60)), ); f.queries.getDetectableObjects(); assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60)); - model.show = false; + setModel(model, 'show', false); assert.deepEqual(f.queries.getDetectableObjects(), []); setBillboard(f.bb, 'show', true); assert.equal( @@ -126,7 +127,7 @@ test('candidate collection resolves models once and keeps handoffs live', (t) => billboardState(f.bb).position, ); assert.equal(get.mock.callCount(), 6); - model.show = true; + setModel(model, 'show', true); assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true); assert.equal(get.mock.callCount(), 7); }); @@ -134,7 +135,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 = { ready: true, show: true }; + f.flightState._trackedModel = mirrorModel({ ready: true, show: true }); f.parts.rendering._modelSpec = () => ({}); let anchor = new Cesium.Cartesian3(10, 20, 30); f.parts.motion._trackedVisualCached = () => anchor; @@ -146,16 +147,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, - { ready: false, show: true }, - { ready: true, show: false }, + mirrorModel({ ready: false, show: true }), + mirrorModel({ ready: true, show: false }), ]) { f.flightState._trackedModel = tracked; assert.equal( - f.parts.rendering._modelOwnsVisual('abc123', { ready: true, show: true }), + f.parts.rendering._modelOwnsVisual('abc123', mirrorModel({ ready: true, show: true })), false, ); } - f.flightState._trackedModel = { ready: true, show: true }; + f.flightState._trackedModel = mirrorModel({ ready: true, show: true }); f.parts.tracking._trackedModelRegimeActive = () => false; assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), false); }); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index a11060071..c4c2a923e 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -40,6 +40,7 @@ import { import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; import { aircraftVisuals } from '../aircraft/visualState.js'; import { billboardState, setBillboard } from '../../data/billboardMirror.js'; +import { modelState, setModel } from '../../data/modelMirror.js'; export function createRendering({ flightState, @@ -242,8 +243,9 @@ export function createRendering({ } /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into - * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one - * mutable matrix object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium + * `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 + * 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 * scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */ @@ -338,21 +340,21 @@ export function createRendering({ flightState._scratchGroundPos, ); if (!displayPos) { - model.show = false; // no ground evidence → nothing safe to place a depth-tested model at + setModel(model, 'show', false); // no ground evidence → nothing safe to place a depth-tested model at setBillboard(bb, 'show', true); 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. - _modelMatrix(displayPos, course, model.modelMatrix); + setModel(model, 'modelMatrix', _modelMatrix(displayPos, course)); if (!model.ready) { - model.show = false; // not loaded yet → keep the 2D icon, no half-model flash + setModel(model, 'show', false); // not loaded yet → keep the 2D icon, no half-model flash setBillboard(bb, 'show', true); return false; } beforeShow?.(); - if (!model.show) model.show = true; + if (!modelState(model).show) setModel(model, 'show', true); if (billboardState(bb).show) setBillboard(bb, 'show', false); // hand off ONLY once the model renders return true; } @@ -415,7 +417,7 @@ export function createRendering({ * limb cull, a regime exit — only ever clears it. */ function _modelIsRendering(model) { - return !!model && model.ready === true && model.show === true; + return !!model && model.ready === true && modelState(model)?.show === true; } /** Spec identity for a LOADED model: URL and scale together (same-URL classes @@ -590,7 +592,7 @@ export function createRendering({ // an unplaced primitive claim ownership from the billboard 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. - model.show = false; + setModel(model, 'show', false); flightState._modelCollection.add(model); flightState._models.set(icao24, model); flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard @@ -697,7 +699,8 @@ export function createRendering({ flightState._modelCollection && !flightState._modelCollection.isDestroyed(); if (!active) { - if (flightState._trackedModel) flightState._trackedModel.show = false; + if (flightState._trackedModel) + setModel(flightState._trackedModel, 'show', false); return; } // Ask the frame-cached source directly. If the entity callback already ran, @@ -709,7 +712,8 @@ export function createRendering({ billboardState(flightState._billboards.get(flightState._trackedIcao)) ?.position; if (!pos) { - if (flightState._trackedModel) flightState._trackedModel.show = false; + if (flightState._trackedModel) + setModel(flightState._trackedModel, 'show', false); return; } if ( @@ -779,7 +783,7 @@ export function createRendering({ // standalone primitive always exposes the tracked aircraft pick id. m.id = flightState._trackedIcao; aircraftVisuals.write(m).specKey = trackedKey; // class-change sync compares against this - m.show = false; // admitted, not yet the visual — the driver shows it once placed + setModel(m, 'show', 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. @@ -795,10 +799,10 @@ export function createRendering({ flightState._scratchGroundPos, ); if (displayPos) - _modelMatrix( - displayPos, - parts.motion._trackedDisplayCourse(), - m.modelMatrix, + setModel( + m, + 'modelMatrix', + _modelMatrix(displayPos, parts.motion._trackedDisplayCourse()), ); } flightState._trackedModel = m; @@ -828,13 +832,13 @@ export function createRendering({ flightState._scratchGroundPos, ); if (!displayPos) { - flightState._trackedModel.show = false; // no ground evidence → the billboard carries it + setModel(flightState._trackedModel, 'show', false); // no ground evidence → the billboard carries it return; } - _modelMatrix( - displayPos, - parts.motion._trackedDisplayCourse(), - flightState._trackedModel.modelMatrix, + setModel( + flightState._trackedModel, + 'modelMatrix', + _modelMatrix(displayPos, parts.motion._trackedDisplayCourse()), ); if (!flightState._trackedModel.ready) return; const spec = _modelSpec( @@ -851,7 +855,7 @@ export function createRendering({ fovyRad: flightState._viewer.camera.frustum.fovy, maximumPixelSize: TRACKED_MODEL_MAX_PX, }); - flightState._trackedModel.show = true; + setModel(flightState._trackedModel, 'show', true); } } @@ -1029,7 +1033,7 @@ export function createRendering({ // Also hide any 3D model — otherwise a model that crossed the limb would keep // rendering through the hidden globe at its last matrix. const m = flightState._models.get(icao24); - if (m && m.show) m.show = false; + if (m && modelState(m)?.show) setModel(m, 'show', false); continue; } diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index df54b7c9d..41fd19533 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { CYAN_TRANSPARENT } from './policy.js'; import { mirrorBillboard } from '../../data/billboardMirror.js'; +import { mirrorModel } from '../../data/modelMirror.js'; export function createTesting({ flightState, @@ -72,7 +73,8 @@ export function createTesting({ flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]); flightState._cullPositions.clear(); flightState._models.clear(); - for (const [key, model] of models) flightState._models.set(key, model); + for (const [key, model] of models) + flightState._models.set(key, mirrorModel(model)); flightState._detectionObjects = new Map(); flightState.records.data = new Map([[icao24, meta]]); flightState._positionHistory = new Map([[icao24, history]]); diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index c9b9a03ae..84ae5677b 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -16,6 +16,7 @@ import { } 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'; const specKey = (spec) => `${spec.url}@${spec.scale}`; @@ -56,6 +57,7 @@ export function createLocalAdsbModels({ let lastEligibilityAt = 0; let eligible = new Set(); const scratchHpr = new Cesium.HeadingPitchRoll(); + const scratchMatrix = new Cesium.Matrix4(); const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1); const scratchCarto = new Cesium.Cartographic(); const scratchGround = new Cesium.Cartesian3(); @@ -118,7 +120,7 @@ export function createLocalAdsbModels({ visual.specKey = key; visual.bellyM = spec.bellyM; // Admitted, not yet the visual: placement turns it on. - model.show = false; + setModel(model, 'show', false); collection.add(model); models.set(id, model); } @@ -224,7 +226,7 @@ export function createLocalAdsbModels({ if (!model || !marker.position) continue; const position = placement(id, marker); if (!position || !occluder.isPointVisible(marker.position)) { - model.show = false; + setModel(model, 'show', false); continue; } scratchHpr.heading = Cesium.Math.toRadians( @@ -232,18 +234,22 @@ export function createLocalAdsbModels({ ); scratchHpr.pitch = 0; scratchHpr.roll = 0; - Cesium.Transforms.headingPitchRollToFixedFrame( - position, - scratchHpr, - Cesium.Ellipsoid.WGS84, - undefined, - model.modelMatrix, + setModel( + model, + 'modelMatrix', + Cesium.Transforms.headingPitchRollToFixedFrame( + position, + scratchHpr, + Cesium.Ellipsoid.WGS84, + undefined, + scratchMatrix, + ), ); if (!model.ready) { - model.show = false; + setModel(model, 'show', false); continue; } - model.show = true; + setModel(model, 'show', 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 06dce1544..eb7ef13b9 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -24,6 +24,7 @@ import { COURSE_SLEW_DT_MAX_SEC, COURSE_MAX_DPS, } from './policy.js'; +import { modelState } from '../../data/modelMirror.js'; export function createMotion({ flightState, @@ -383,7 +384,7 @@ export function createMotion({ flightState.records.data.get(flightState._trackedIcao)?.klass, ); return modelVisualAnchor( - flightState._trackedModel.modelMatrix, + modelState(flightState._trackedModel).modelMatrix, spec.visualCenterNative, Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -410,7 +411,7 @@ export function createMotion({ ) return null; return Cesium.Matrix4.getTranslation( - flightState._trackedModel.modelMatrix, + modelState(flightState._trackedModel).modelMatrix, flightState._scratchTrailClip, ); } diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index db357a87f..205284c7e 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -12,6 +12,7 @@ import { import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js'; import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; import { billboardState, setBillboard } from '../../data/billboardMirror.js'; +import { modelState } from '../../data/modelMirror.js'; export function createQueries({ flightState, @@ -491,7 +492,7 @@ export function createQueries({ ? parts.motion._trackedVisualCached() || billboardState(bb).position : modelOwnsVisual ? modelVisualAnchor( - model.modelMatrix, + modelState(model).modelMatrix, spec.visualCenterNative, Number.isFinite(model.computedScale) ? model.computedScale diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 128719630..6debe84a3 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -56,6 +56,7 @@ import { import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; import { aircraftVisuals } from '../aircraft/visualState.js'; import { billboardState, setBillboard } from '../../data/billboardMirror.js'; +import { modelState, setModel } from '../../data/modelMirror.js'; export function createRendering({ flightState, @@ -253,9 +254,9 @@ export function createRendering({ } /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into - * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable - * matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on - * the last-written transform, and the per-frame tracked write made it flicker). */ + * `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 + * object (see flights.js for the full rationale). */ function _modelMatrix( pos, @@ -340,26 +341,31 @@ export function createRendering({ flightState._scratchGroundPos, ); if (!displayPos) { - model.show = false; // no ground evidence → nothing safe to place a depth-tested model at + setModel(model, 'show', false); // no ground evidence → nothing safe to place a depth-tested model at setBillboard(bb, 'show', true); 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. - _modelMatrix( - displayPos, - course, - model.modelMatrix, - _modelSpec(flightState.records.data.get(icao24)?.klass).headingOffsetDeg, + setModel( + model, + 'modelMatrix', + _modelMatrix( + displayPos, + course, + undefined, + _modelSpec(flightState.records.data.get(icao24)?.klass) + .headingOffsetDeg, + ), ); if (!model.ready) { - model.show = false; // not loaded yet → keep the 2D icon, no half-model flash + setModel(model, 'show', false); // not loaded yet → keep the 2D icon, no half-model flash setBillboard(bb, 'show', true); return false; } beforeShow?.(); - if (!model.show) model.show = true; + if (!modelState(model).show) setModel(model, 'show', true); if (billboardState(bb).show) setBillboard(bb, 'show', false); // hand off ONLY once the model renders return true; } @@ -383,7 +389,7 @@ export function createRendering({ * a regime exit — only ever clears it. Mirror of flights.js. */ function _modelIsRendering(model) { - return !!model && model.ready === true && model.show === true; + return !!model && model.ready === true && modelState(model)?.show === true; } /** Spec identity for a LOADED model — mirror of flights.js: URL and scale @@ -551,7 +557,7 @@ export function createRendering({ // an unplaced primitive claim ownership from the billboard 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. - model.show = false; + setModel(model, 'show', false); flightState._modelCollection.add(model); flightState._models.set(icao24, model); flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard @@ -656,7 +662,8 @@ export function createRendering({ flightState._modelCollection && !flightState._modelCollection.isDestroyed(); if (!active) { - if (flightState._trackedModel) flightState._trackedModel.show = false; + if (flightState._trackedModel) + setModel(flightState._trackedModel, 'show', false); return; } // Ask the frame-cached source directly. If the entity callback already ran, @@ -668,7 +675,8 @@ export function createRendering({ billboardState(flightState._billboards.get(flightState._trackedIcao)) ?.position; if (!pos) { - if (flightState._trackedModel) flightState._trackedModel.show = false; + if (flightState._trackedModel) + setModel(flightState._trackedModel, 'show', false); return; } if ( @@ -736,7 +744,7 @@ export function createRendering({ // standalone primitive always exposes the tracked aircraft pick id. m.id = flightState._trackedIcao; aircraftVisuals.write(m).specKey = trackedKey; // class-change sync compares against this - m.show = false; // admitted, not yet the visual — the driver shows it once placed + setModel(m, 'show', 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. @@ -752,11 +760,15 @@ export function createRendering({ flightState._scratchGroundPos, ); if (displayPos) { - _modelMatrix( - displayPos, - parts.motion._trackedDisplayCourse(), - m.modelMatrix, - trackedSpec.headingOffsetDeg, + setModel( + m, + 'modelMatrix', + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + undefined, + trackedSpec.headingOffsetDeg, + ), ); } } @@ -787,17 +799,21 @@ export function createRendering({ flightState._scratchGroundPos, ); if (!displayPos) { - flightState._trackedModel.show = false; // no ground evidence → the billboard carries it + setModel(flightState._trackedModel, 'show', false); // no ground evidence → the billboard carries it return; } const spec = _modelSpec( flightState.records.data.get(flightState._trackedIcao)?.klass, ); - _modelMatrix( - displayPos, - parts.motion._trackedDisplayCourse(), - flightState._trackedModel.modelMatrix, - spec.headingOffsetDeg, + setModel( + flightState._trackedModel, + 'modelMatrix', + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + undefined, + spec.headingOffsetDeg, + ), ); if (!flightState._trackedModel.ready) return; flightState._trackedModel.scale = trackedModelScaleForPixelCap({ @@ -811,7 +827,7 @@ export function createRendering({ fovyRad: flightState._viewer.camera.frustum.fovy, maximumPixelSize: TRACKED_MODEL_MAX_PX, }); - flightState._trackedModel.show = true; + setModel(flightState._trackedModel, 'show', true); } } @@ -962,7 +978,7 @@ export function createRendering({ // Also hide any 3D model — otherwise a model that crossed the limb would keep // rendering through the hidden globe at its last matrix. const m = flightState._models.get(icao24); - if (m && m.show) m.show = false; + if (m && modelState(m)?.show) setModel(m, 'show', false); continue; } diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index fa43ed0fc..5af1140df 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -1,5 +1,6 @@ import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; import { mirrorBillboard } from '../../data/billboardMirror.js'; +import { mirrorModel } from '../../data/modelMirror.js'; export function createTesting({ flightState, @@ -43,7 +44,8 @@ export function createTesting({ flightState._billboards = new Map([[icao24, mirrorBillboard(billboard)]]); flightState._cullPositions.clear(); flightState._models.clear(); - for (const [key, model] of models) flightState._models.set(key, model); + for (const [key, model] of models) + flightState._models.set(key, mirrorModel(model)); flightState._detectionObjects = new Map(); flightState.records.data = new Map([[icao24, meta]]); flightState._positionHistory = new Map([[icao24, history]]);