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]]);