diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 631fb2f6d..c94739ced 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1317,6 +1317,7 @@ "src/layers/flights/rendering.js", "src/layers/flights/icons.js", "src/data/aircraftIconLayer.js", + "src/data/aircraftModelLayer.js", "src/data/aircraftMotionRegime.js", "src/data/iconSetPrimitive.js", "src/data/iconSet.js", @@ -1387,6 +1388,7 @@ "src/layers/military/rendering.js", "src/layers/military/icons.js", "src/data/aircraftIconLayer.js", + "src/data/aircraftModelLayer.js", "src/data/aircraftMotionRegime.js", "src/data/iconSetPrimitive.js", "src/data/iconSet.js", @@ -2267,6 +2269,7 @@ "modules": [ "src/layers/flights/icons.js", "src/data/aircraftIconLayer.js", + "src/data/aircraftModelLayer.js", "src/data/aircraftMotionRegime.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", @@ -3270,6 +3273,7 @@ "modules": [ "src/layers/flights/icons.js", "src/data/aircraftIconLayer.js", + "src/data/aircraftModelLayer.js", "src/data/aircraftMotionRegime.js", "src/data/iconSetPrimitive.js", "src/data/iconSet.js", diff --git a/src/data/aircraftIconLayer.js b/src/data/aircraftIconLayer.js index e7ea0db82..94040f0c2 100644 --- a/src/data/aircraftIconLayer.js +++ b/src/data/aircraftIconLayer.js @@ -29,6 +29,19 @@ import { horizonOccluder } from './iconOrientation.js'; /** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */ const COCKPIT_DOT_PX = 16; +/** Fleet glyphs keep the established close-range 3× scale. */ +const NORMAL_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( + 1000, + 3.0, + 8000000, + 0.5, +); +const COCKPIT_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( + 1000, + 1.15, + 8000000, + 0.65, +); /** Contacts drawn below this height (m) are judged for the horizon a little * above the ellipsoid (ICON_CULL_LIFT). */ const CULL_LIFT_BELOW_M = 10; @@ -79,8 +92,6 @@ export function createAircraftIcons({ const scratchPosition = new Cesium.Cartesian3(); const scratchCull = new Cesium.Cartesian3(); const scratchScaleByDistance = new Cesium.NearFarScalar(); - let normalScaleByDistance = null; - let cockpitScaleByDistance = null; /** The delayed instant every untracked contact is drawn at. */ function _displayTime(result = scratchTime) { @@ -292,8 +303,7 @@ export function createAircraftIcons({ set.setImage(row, dot, dot); set.setSize(row, cockpitContactSizePx, cockpitContactSizePx); set.setScale(row, 1); - cockpitScaleByDistance ??= rendering._cockpitBillboardScaleByDistance(); - set.setScaleByDistance(row, cockpitScaleByDistance); + set.setScaleByDistance(row, COCKPIT_SCALE_BY_DISTANCE); set.setColor(row, rendering._cockpitContactColor(icao24, info)); set.setFlag(row, ICON_ROTATES, false); } else { @@ -314,8 +324,7 @@ export function createAircraftIcons({ const size = tracked ? 24 : 20; set.setSize(row, size, size); set.setScale(row, rendering._fleetBillboardScale(icao24, info?.klass)); - normalScaleByDistance ??= rendering._normalBillboardScaleByDistance(); - set.setScaleByDistance(row, normalScaleByDistance); + set.setScaleByDistance(row, NORMAL_SCALE_BY_DISTANCE); set.setColor(row, rendering._fleetBillboardColor(icao24, info)); set.setFlag(row, ICON_ROTATES, true); } diff --git a/src/data/aircraftModelLayer.js b/src/data/aircraftModelLayer.js new file mode 100644 index 000000000..55f9c4795 --- /dev/null +++ b/src/data/aircraftModelLayer.js @@ -0,0 +1,888 @@ +import * as Cesium from 'cesium'; +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'; + +/** + * An aircraft layer's 3D models: the fleet regime and its gap-proof handoff + * from the icon set (`createAircraftIcons`), the standalone tracked model, and + * the ~12 Hz fleet tick that drives both. Shared by the flights and military + * layers, which differ only in the options below. + * @param {object} options + * @param {object} options.state The layer state. + * @param {object} options.services + * @param {object} options.parts The layer's parts; `icons`, `motion` and + * `tracking` are read at call time. + * @param {(url: string) => string} options.resolveAsset + * @param {string} options.logLabel Console prefix, e.g. `Data:Flights`. + * @param {object} options.policy The layer's model budgets, radii, pixel + * bounds, heading offset, IR reload batch and fleet tick interval. + * @param {(klass: string) => object} options.modelSpec The class's GLB, scale, + * geometry and tint blend; `headingOffsetDeg` overrides the policy's. + * @param {(icao24: string) => Cesium.Color} options.modelColor Model tint, + * including the tracked contact's. + * @param {(icao24: string) => void} [options.onModelEligible] Runs when a + * contact is about to render in 3D. + * @param {(renderTime: Cesium.JulianDate, tau: number, nowMs: number, + * camera: Cesium.Camera) => void} [options.afterReplan] The layer's own pass + * in the fleet tick, after expired rows are re-planned. + */ +export function createAircraftModels({ + state, + services, + parts, + resolveAsset, + logLabel, + policy, + modelSpec: _modelSpec, + modelColor: _modelColor, + onModelEligible = () => {}, + afterReplan = () => {}, +}) { + const { isTr3b } = services.aircraftPresentation; + const { trackedModelScaleForPixelCap } = services.camera; + const { focusNowMs, getFocusTarget } = services.focus; + const { applyAircraftModelTreatment } = services.recession; + + /** The model heading offset a class's GLB needs. */ + const _headingOffsetDeg = (klass) => + _modelSpec(klass).headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG; + + /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude + * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the + * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in + * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms + * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED + * contact does not route through here: it is one model, it is what the camera is aimed at, + * and it takes its own default-on, hysteretic zoom regime + * (`_trackedModelRegimeActive`). */ + + function _modelRegimeActive() { + if (!state._models3dEnabled) return false; + const h = state._viewer?.camera?.positionCartographic?.height ?? Infinity; + return h < policy.MODEL_ALT_CEIL_M; + } + + /** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the + * SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses + * at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ + + function _modelCap() { + const mapCap = + state._models3dMode === 'all' ? policy.MODEL_MAX_ALL : policy.MODEL_MAX; + // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is + // already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal + // retraversal, the cloud pass) and every model is its own draw call. + return state._cockpitContactMode + ? Math.min(policy.COCKPIT_MODEL_MAX, mapCap) + : mapCap; + } + + /** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */ + + function _modelAddDistM() { + return state._models3dMode === 'all' + ? policy.MODEL_ALL_ADD_M + : policy.MODEL_PROX_ADD_M; + } + + /** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */ + + function _modelKeepDistM() { + return state._models3dMode === 'all' + ? policy.MODEL_ALL_KEEP_M + : policy.MODEL_PROX_KEEP_M; + } + + /** 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 + * 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. */ + + function _modelMatrix( + pos, + headingDeg, + result = state._scratchModelMtx, + offsetDeg = policy.MODEL_HEADING_OFFSET_DEG, + ) { + state._scratchModelHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + offsetDeg, + ); + state._scratchModelHpr.pitch = 0; + state._scratchModelHpr.roll = 0; + return Cesium.Transforms.headingPitchRollToFixedFrame( + pos, + state._scratchModelHpr, + Cesium.Ellipsoid.WGS84, + undefined, + result, + ); + } + + /** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the + * vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked + * neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion + * matches picked-object IDs, and every icon AND model in this layer carries its + * icao as `id`, so the icao strings cover both; the tracked entity is excluded as the + * object itself. Built lazily — only when a sample actually fires (one-shot). */ + + function _groundSampleExclusions() { + const out = parts.icons._iconIds(); + if (state._trackedEntity) out.push(state._trackedEntity); + return out; + } + + /** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position + * verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near + * the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports, + * hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested + * model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides + * a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js, + * CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a + * bounded last-known and resamples), plus + * the belly offset so it sits on its gear rather than sinking to the model-origin + * fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss) + * there is no safe depth-tested placement at all: this returns null, the caller + * keeps the 2D billboard visible and the model hidden, and it retries later. Once + * a contact has resolved once, a later outage holds that measurement inside + * groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D + * because a resample is mid-backoff. */ + + function _modelDisplayPosition(icao24, pos, result) { + const meta = state.records.data.get(icao24); + if (!meta || !meta.onGround) return pos; + const h = state._groundSnap.heightFor( + state._viewer, + icao24, + pos, + _groundSampleExclusions, + ); + if (h == null) return null; + const carto = Cesium.Cartographic.fromCartesian( + pos, + Cesium.Ellipsoid.WGS84, + state._scratchGroundCarto, + ); + carto.height = h + _modelSpec(meta.klass).bellyM; + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + carto.height, + Cesium.Ellipsoid.WGS84, + result, + ); + } + + /** Atomically hand one fleet contact from its icon to a safely placed, + * ready 3D model. Missing/loading models and unresolved terrain always leave + * the icon owning the visual, so no render frame can hide both. + * `beforeShow` runs only on the committing path — the per-tick model treatment + * belongs to a model that is about to draw, not to one still waiting. */ + + function _driveFleetModelHandoff( + icao24, + model, + row, + pos, + course, + beforeShow, + ) { + const icons = state._icons; + if (!model) { + icons.setShown(row, true); + return false; + } + const displayPos = _modelDisplayPosition( + icao24, + pos, + state._scratchGroundPos, + ); + if (!displayPos) { + setModel(model, 'show', 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', + _modelMatrix( + displayPos, + course, + undefined, + _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 + icons.setShown(row, true); + return false; + } + beforeShow?.(); + if (!modelState(model).show) setModel(model, 'show', true); + icons.setShown(row, false); // hand off ONLY once the model renders + return true; + } + + /** + * Whether a 3D model — not the billboard — is what the user actually SEES for + * this contact, and therefore whether the display floor should stand aside. + * + * Model EXISTENCE is not ownership. A fleet model that is still loading, or has + * no resolved ground to stand on, stays hidden while `bb.show` stays true (the + * gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model + * is retained but hidden whenever the model regime is off — 3D disabled, camera + * zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore + * suppressed the clamp in exactly the states where the BILLBOARD is the visual, + * putting the burial straight back. + * + * Cesium's own default `show === true` is the reason admission clears it + * explicitly: a fleet model registered in `_models` the instant its glTF resolves + * would otherwise claim ownership — at the identity matrix — for the frames + * between admission and the next fleet tick, while the BILLBOARD was still the + * visual. Ownership means actually rendering, and every site that sets `show` + * true does so only after committing a matrix. + * + * The two halves: + * - Fleet: the rendering test alone. Safe for the ground snap either way, + * because a fleet model is positioned from the RAW dead-reckon, not from + * the billboard. + * - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked + * model is fed from `_trackedDisplayPosition`, so once it is live the clamp + * must stand aside: two different chains would otherwise be deciding one + * contact's ground, and the billboard's is the one the operator is not + * looking at (T7). The regime check is what makes a regime + * flip take effect on the same frame rather than waiting for + * `_updateTrackedModel` to clear `show`; the rendering test is what keeps a + * 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. + * @returns {boolean} + */ + + function _modelOwnsVisual(icao24, model) { + if (icao24 === state._trackedIcao) { + return ( + parts.tracking._trackedModelRegimeActive() && + _modelIsRendering(state._trackedModel) + ); + } + return _modelIsRendering( + model === undefined ? state._models.get(icao24) : model, + ); + } + + /** 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; + } + + /** Spec identity for a LOADED model: URL and scale together (same-URL classes + * differ by scale — airliner vs quadjet both ship airplane.glb). */ + + const _specKeyFor = (klass) => { + const spec = _modelSpec(klass); + return `${spec.url}@${spec.scale}`; + }; + + /** Class-change model sync (enrichment AND poll-path klass updates): when the + * aircraft's live model or in-flight load no longer matches its class's spec, + * drop it so the eligibility pass reloads the right asset at the right scale. + * Gap-proof: the fleet billboard is re-shown BEFORE the release so the + * contact never goes invisible for the tick gap; _releaseModel's generation + * bump also invalidates any pending load. The tracked standalone model gets + * the same rule (its billboard entity is always the fallback visual). */ + + function _syncModelToClass(icao24) { + const key = _specKeyFor(state.records.data.get(icao24)?.klass); + const current = state._models.get(icao24); + if ( + (current && aircraftVisuals.read(current)?.specKey !== key) || + (!current && state._modelPending.has(icao24)) + ) { + _releaseModel(icao24); + } + if ( + icao24 === state._trackedIcao && + state._trackedModel && + aircraftVisuals.read(state._trackedModel)?.specKey !== key + ) { + _releaseTrackedModel(); + } + } + + function _reloadModelsForIrBoost() { + state._irReloadQueue = [...state._models.keys()]; + for (const icao of state._modelPending) { + if (!state._models.has(icao)) + state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1); + } + _releaseTrackedModel(); + } + + function _drainIrReloadQueue() { + 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 + _releaseModel(icao); + } + if (state._irReloadQueue.length === 0) state._irReloadQueue = null; + } + + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + + async function _ensureModel(icao24) { + // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet + // tick skips it, so a model here would be orphaned + double-rendered. + if (icao24 === state._trackedIcao) return; + onModelEligible(icao24); + if (state._models.has(icao24) || state._modelPending.has(icao24)) return; + // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads + // (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL. + if (state._models.size + state._modelPending.size >= _modelCap()) return; + const epoch = state._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead + const gen = state._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale + state._modelPending.add(icao24); + let model = null; + // Spec identity captured at load START — if enrichment reclassifies the + // aircraft mid-load, the post-await admission below rejects the stale asset. + // Boost state likewise: the creation options bake it in, so a mid-load + // toggle must reject too (the reload queue only covers ADMITTED models). + const specKey = _specKeyFor(state.records.data.get(icao24)?.klass); + const loadIrBoost = state._irBoost; + try { + const spec = _modelSpec(state.records.data.get(icao24)?.klass); + model = await Cesium.Model.fromGltfAsync({ + url: resolveAsset(spec.url), + asynchronous: false, + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + minimumPixelSize: policy.MODEL_MIN_PX, + scale: spec.scale, + color: state._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), + colorBlendMode: Cesium.ColorBlendMode.MIX, + // Launch presentation keeps the code-side tint dominant for every approved + // model; IR boost removes the remaining diffuse hint with flat UNLIT white. + colorBlendAmount: state._irBoost ? 1.0 : spec.blendAmount, + customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined, + id: icao24, // so scene.pick returns the icao for click-to-track + }); + } catch { + // asset/decode fail — stay an icon. Only touch this lifecycle's state if still current + // (a destroy/re-init may have swapped the globals while this load was in flight). + if (epoch === state._modelEpoch) { + state._modelPending.delete(icao24); + _cleanupModelGen(icao24); + } + return; + } + // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new + // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. + if (epoch !== state._modelEpoch) { + try { + model.destroy(); + } catch { + /* gone */ + } + return; + } + state._modelPending.delete(icao24); + // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the + // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was + // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. + // Recheck the shared Display 3D toggle and altitude ceiling after the async + // load. Cockpit uses the same OFF / Proximity / All contract as map Display. + const stale = + (state._modelGen.get(icao24) || 0) !== gen || + !_modelRegimeActive() || + !state._modelCollection || + state._modelCollection.isDestroyed() || + !state.records.data.has(icao24) || + icao24 === state._trackedIcao || + state._models.has(icao24) || + state._models.size >= _modelCap() || + // Class reclassified mid-load → this GLB/scale is for the OLD class. + _specKeyFor(state.records.data.get(icao24)?.klass) !== specKey || + // IR boost flipped mid-load → this model baked the wrong shader/tint. + state._irBoost !== loadIrBoost; + if (stale) { + try { + model.destroy(); + } catch { + /* already gone */ + } + _cleanupModelGen(icao24); // bound the map + return; + } + // 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 + // 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); + state._modelCollection.add(model); + state._models.set(icao24, model); + state._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard + } + + /** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff). + * 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 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) { + state._modelGen.set(icao24, (state._modelGen.get(icao24) || 0) + 1); + } + if (m) { + if (state._modelCollection && !state._modelCollection.isDestroyed()) { + try { + state._modelCollection.remove(m); + } catch { + /* gone */ + } + } + state._models.delete(icao24); + _showReleasedIcon(icao24); + } + // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping + // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so + // the resolving load's gen-check still rejects it. + _cleanupModelGen(icao24); + } + + /** Drop an icao's generation entry once nothing references it (no live model, no in-flight + * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ + + function _cleanupModelGen(icao24) { + if (!state._modelPending.has(icao24) && !state._models.has(icao24)) + state._modelGen.delete(icao24); + } + + /** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ + + function _releaseModels() { + state._irReloadQueue = null; // a full release supersedes any pending boost-flip drain + // Invalidate in-flight loads so a completion after this bulk release can't add a model. + 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()) { + try { + state._modelCollection.remove(m); + } catch { + /* gone */ + } + } + } + for (const icao24 of state._models.keys()) _showReleasedIcon(icao24); + state._models.clear(); + } + + /** A released fleet model hands the visual straight back to its icon (the + * tracked contact's entity keeps its own). */ + + function _showReleasedIcon(icao24) { + const icons = state._icons; + const row = icons?.row(icao24) ?? -1; + if (row >= 0 && icao24 !== state._trackedIcao) icons.setShown(row, true); + } + + /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ + + function _releaseTrackedModel() { + state._trackedModelGen++; + state._trackedModelLoading = false; + if (state._trackedModel) { + if (state._modelCollection && !state._modelCollection.isDestroyed()) { + try { + state._modelCollection.remove(state._trackedModel); + } catch { + /* gone */ + } + } + state._trackedModel = null; + } + } + + /** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet + * cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the + * follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed + * in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */ + + function _updateTrackedModel() { + const active = + state._trackedIcao && + parts.tracking._trackedModelRegimeActive() && + state._modelCollection && + !state._modelCollection.isDestroyed(); + if (!active) { + if (state._trackedModel) setModel(state._trackedModel, 'show', false); + return; + } + // Ask the frame-cached source directly. If the entity callback already ran, + // this is a no-op; if model loading completed between phases, it establishes + // this frame's single sample before the model renders. Camera, detection, and + // readout consumers then reuse that exact cached position. + const pos = + parts.motion._trackedDisplayPosition(state._trackedIcao) || + parts.icons._iconPosition(state._trackedIcao); + if (!pos) { + if (state._trackedModel) setModel(state._trackedModel, 'show', false); + return; + } + if ( + !state._trackedModel && + !state._trackedModelLoading && + parts.tracking._trackedModelLoadAllowed() + ) { + state._trackedModelLoading = true; + const gen = state._trackedModelGen; + const trackedSpec = _modelSpec( + state.records.data.get(state._trackedIcao)?.klass, + ); + const trackedKey = _specKeyFor( + state.records.data.get(state._trackedIcao)?.klass, + ); + const trackedIrBoost = state._irBoost; + Cesium.Model.fromGltfAsync({ + url: resolveAsset(trackedSpec.url), + asynchronous: false, + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + minimumPixelSize: policy.TRACKED_MODEL_MIN_PX, + scale: trackedSpec.scale, + color: state._irBoost + ? Cesium.Color.WHITE + : _modelColor(state._trackedIcao), + colorBlendMode: Cesium.ColorBlendMode.MIX, + // The tracked aircraft uses the same dominant light tint as the fleet; + // IR boost removes the remaining diffuse hint with flat UNLIT white. + colorBlendAmount: state._irBoost ? 1.0 : trackedSpec.blendAmount, + customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined, + // Pick id (H1): without it, clicking the very plane being tracked read as + // EMPTY SPACE (scene.pick → primitive with no id) → an unintended + // deselect. With the icao, the click handler recognizes it as ours. + id: state._trackedIcao, + }) + .then((m) => { + // Untracked / re-tracked / torn down during the load → drop it. + if ( + gen !== state._trackedModelGen || + !state._modelCollection || + state._modelCollection.isDestroyed() + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + return; + } + // Class reclassified OR boost flipped mid-load (no release ran — + // _trackedModel was still null): drop the stale asset; driver reloads. + if ( + _specKeyFor(state.records.data.get(state._trackedIcao)?.klass) !== + trackedKey || + state._irBoost !== trackedIrBoost + ) { + try { + m.destroy(); + } catch { + /* gone */ + } + state._trackedModelLoading = false; + return; + } + // 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 + // 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. + const currentPos = + parts.motion._trackedDisplayCached() || + parts.icons._iconPosition(state._trackedIcao); + if (currentPos) { + const displayPos = _modelDisplayPosition( + state._trackedIcao, + currentPos, + state._scratchGroundPos, + ); + if (displayPos) + setModel( + m, + 'modelMatrix', + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + undefined, + trackedSpec.headingOffsetDeg ?? + policy.MODEL_HEADING_OFFSET_DEG, + ), + ); + } + state._trackedModel = m; + 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. + state._trackedModelFailIcao = null; + state._trackedModelFailCount = 0; + state._trackedModelRetryAtMs = 0; + state._modelCollection.add(m); + state._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up + }) + .catch((err) => { + if (gen !== state._trackedModelGen) return; // superseded load — not this selection's failure + state._trackedModelLoading = false; + parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); + }); + return; // billboard carries the visual until the model is ready + } + if (state._trackedModel) { + // Keep the transform current while GPU resources are still loading. Cesium + // can flip ready during scene update after this callback; waiting for ready + // here would let that first rendered frame use a stale load-start matrix. + const displayPos = _modelDisplayPosition( + state._trackedIcao, + pos, + state._scratchGroundPos, + ); + if (!displayPos) { + setModel(state._trackedModel, 'show', false); // no ground evidence → the billboard carries it + return; + } + const spec = _modelSpec( + state.records.data.get(state._trackedIcao)?.klass, + ); + setModel( + state._trackedModel, + 'modelMatrix', + _modelMatrix( + displayPos, + parts.motion._trackedDisplayCourse(), + undefined, + spec.headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG, + ), + ); + if (!state._trackedModel.ready) return; + state._trackedModel.scale = trackedModelScaleForPixelCap({ + baseScale: spec.scale, + nativeRadiusM: spec.nativeRadiusM, + rangeM: Cesium.Cartesian3.distance( + state._viewer.camera.positionWC, + displayPos, + ), + viewportHeightPx: state._viewer.scene.canvas.clientHeight, + fovyRad: state._viewer.camera.frustum.fovy, + maximumPixelSize: policy.TRACKED_MODEL_MAX_PX, + }); + setModel(state._trackedModel, 'show', true); + } + } + + /** + * ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and + * the two-tier glyph swap all run on the GPU from each row's segment, so this + * only re-plans rows whose regime ended, runs the layer's own pass after + * that (flights floors grounded contacts), advances focus de-emphasis while + * a target exists, and drives the 3D-model handoff inside the model regime. + * Driven by scene.preRender. + * @returns {void} + */ + + function _fleetTick() { + const icons = state._icons; + if (!state._viewer || !icons || !icons.show) return; + const scene = state._viewer.scene; + const camera = state._viewer.camera; + const nowMs = focusNowMs(Date.now()); + + if (nowMs - state._lastFleetTickMs < policy.FLEET_DR_INTERVAL_MS) return; + state._lastFleetTickMs = nowMs; + // Every contact in this pass shares the same delayed display instant. + const renderTime = parts.icons._displayTime(state._scratchFleetRenderTime); + const tau = icons.seconds(renderTime); + + _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload + if (state._cockpitContactMode) parts.tracking._refreshCockpitNearContacts(); + parts.icons._replanExpired(tau); + afterReplan(renderTime, tau, nowMs, camera); + const focusTarget = getFocusTarget(); + parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget); + + // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. + // Drop all models the moment we leave it (toggled off / zoomed out) so icons resume. + const useModels = _modelRegimeActive(); + // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that + // resolves after zoom-out could briefly add a model outside the 3D-model regime). + if (!useModels && (state._models.size || state._modelPending.size)) + _releaseModels(); + if (!useModels) return; + + // 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the + // cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen + // already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius, + // nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots + // (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint). + // Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an + // on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap + // with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum + // filtering dropped near off-screen planes entirely. This does both right. + const cap = _modelCap(); + const camPos = camera.positionWC; + const addM = _modelAddDistM(); + const addDistSq = addM * addM; + const keepM = _modelKeepDistM(); + const keepDistSq = keepM * keepM; + const cull = camera.frustum.computeCullingVolume( + camPos, + camera.directionWC, + camera.upWC, + ); + // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. + const cand = []; + for (let row = 0; row < icons.count; row++) { + const icao = icons.idAt(row); + if (icao === state._trackedIcao) continue; + // A converted TR-3B renders as an icon and can never take a model, so + // it must not occupy a CAP SLOT either — excluded here at selection time, + // not just at the handoff below, or accumulated conversions would starve + // ordinary contacts of 3D models. (The handoff guard stays as defence.) + if (isTr3b(icao)) continue; + // Ground planes compete for model slots like everyone else (owner decision + // 2026-07-03: "3D mode is respected regardless of whether a plane is on the + // ground or in the air — no distinction"). The cap + nearest-first ordering + // below already bound airport clusters; grounded placement is handled by the + // one-shot ground snap in _modelDisplayPosition. + const position = icons.position(row, tau, state._scratchModelBS.center); + const d2 = Cesium.Cartesian3.distanceSquared(camPos, position); + if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible + cand.push([ + icao, + d2, + cull.computeVisibility(state._scratchModelBS) !== + Cesium.Intersect.OUTSIDE, + ]); + } + cand.sort((a, b) => a[1] - b[1]); // nearest first + if (cand.length > cap && nowMs - state._lastModelCapWarnMs > 5000) { + console.warn( + `[${logLabel}] ${cand.length} planes in 3D range; capped at ${cap} (${state._models3dMode}). On-screen planes are prioritized.`, + ); + state._lastModelCapWarnMs = nowMs; + } + const modelEligible = selectModelEligible(cand, { + cap, + addDistSq, + isModeled: (icao) => state._models.has(icao), + }); + const toRelease = []; + for (const icao of state._models.keys()) { + if (icao !== state._trackedIcao && !modelEligible.has(icao)) + toRelease.push(icao); + } + for (const icao of toRelease) _releaseModel(icao); + + // 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown + // until the model is actually READY to render, so a plane is never both iconless AND + // modelless (the "planes vanish when 3D turns on" bug). Position the model every tick + // regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on + // purpose: the Easter egg IS the triangle, and there is no GLB for it. + const occluder = horizonOccluder(camera); + for (const icao24 of modelEligible) { + if (isTr3b(icao24)) continue; + const row = icons.row(icao24); + if (row < 0) continue; + const position = icons.position(row, tau, state._scratchFleetPos); + if (!parts.icons._aboveHorizon(row, position, occluder)) { + // A model that crossed the limb would keep rendering through the + // hidden globe at its last matrix; the icon's own cull is on the GPU. + const m = state._models.get(icao24); + if (m && modelState(m)?.show) setModel(m, 'show', false); + continue; + } + _ensureModel(icao24); + const model = state._models.get(icao24); + const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position); + _driveFleetModelHandoff( + icao24, + model, + row, + position, + icons.course(row, tau), + () => { + applyAircraftModelTreatment({ + model, + // IR boost must survive the per-tick treatment write — otherwise + // any alpha change would repaint the ordinary tint over the hot + // white. Boosted models also skip the recession fade: hot targets + // stay full-strength at any range (icons keep their normal + // fade — full-opacity glyph walls read as overwhelming). + baseColor: state._irBoost + ? Cesium.Color.WHITE + : _modelColor(icao24), + alpha: state._irBoost + ? 1 + : aircraftTreatmentAlpha({ + baseAlpha: icons.alpha(row), + focusFactor: icons.focus(row), + cameraDistanceM, + cameraHeightM: camera.positionCartographic?.height, + }), + }); + }, + ); + } + } + return { + _modelRegimeActive, + _modelCap, + _modelAddDistM, + _modelKeepDistM, + _modelMatrix, + _groundSampleExclusions, + _modelDisplayPosition, + _driveFleetModelHandoff, + _modelOwnsVisual, + _modelIsRendering, + _specKeyFor, + _syncModelToClass, + _reloadModelsForIrBoost, + _drainIrReloadQueue, + _ensureModel, + _releaseModel, + _cleanupModelGen, + _releaseModels, + _showReleasedIcon, + _releaseTrackedModel, + _updateTrackedModel, + _fleetTick, + }; +} diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 0afc0fb96..a39ae2954 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -1,4 +1,5 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; +import { readFileSync } from 'node:fs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -39,9 +40,9 @@ for (const layer of LAYERS) { test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => { const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(regime, '_modelRegimeActive is defined'); - assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/, + assert.match(regime, /if\s*\(\s*!(?:(?:flightState|state)\.)?_models3dEnabled,?\s*\)\s*return\s*false;/, 'OFF must keep Cockpit AIR contacts in 2D'); - assert.doesNotMatch(regime, /!(?:flightState\.)?_models3dEnabled\s*&&\s*!(?:flightState\.)?_cockpitContactMode/, + assert.doesNotMatch(regime, /!(?:(?:flightState|state)\.)?_models3dEnabled\s*&&\s*!(?:(?:flightState|state)\.)?_cockpitContactMode/, 'Cockpit must not bypass the owner-visible Display toggle'); }); @@ -53,7 +54,7 @@ for (const layer of LAYERS) { // suppression is now an explicit early return. const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(regime, '_trackedModelRegimeActive is defined'); - assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/, + assert.match(regime, /if\s*\(\s*!(?:(?:flightState|state)\.)?_trackedIcao\s*\|\|\s*(?:(?:flightState|state)\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/, '_trackedModelRegimeActive excludes cockpit'); const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(tracked, '_updateTrackedModel is defined'); @@ -70,13 +71,13 @@ for (const layer of LAYERS) { const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; - assert.match(add, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_ADD_M\s*:\s*MODEL_PROX_ADD_M/); - assert.match(keep, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_KEEP_M\s*:\s*MODEL_PROX_KEEP_M/); + assert.match(add, /(?:(?:flightState|state)\.)?_models3dMode\s*===\s*'all'\s*\?\s*(?:policy\.)?MODEL_ALL_ADD_M\s*:\s*(?:policy\.)?MODEL_PROX_ADD_M/); + assert.match(keep, /(?:(?:flightState|state)\.)?_models3dMode\s*===\s*'all'\s*\?\s*(?:policy\.)?MODEL_ALL_KEEP_M\s*:\s*(?:policy\.)?MODEL_PROX_KEEP_M/); assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/); assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/); const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; - assert.match(cap, /Math\.min\(\s*COCKPIT_MODEL_MAX/, + assert.match(cap, /Math\.min\(\s*(?:policy\.)?COCKPIT_MODEL_MAX/, 'Cockpit keeps its 60-model performance ceiling'); }); @@ -100,7 +101,29 @@ for (const layer of LAYERS) { test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => { const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; - assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/); + assert.match(setMode, /else\s*(?:(?:flightState|state)\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/); assert.match(setMode, /for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/); }); } + +// The source reads above include the shared model module, so they pass from it +// alone. These pin that each layer actually builds its models from it, and that +// flights keeps its own hooks. +const RENDERING = { + flights: new URL('../layers/flights/rendering.js', import.meta.url), + militaryFlights: new URL('../layers/military/rendering.js', import.meta.url), +}; + +for (const [name, url] of Object.entries(RENDERING)) { + test(`${name}: 3D models come from the shared aircraft model module`, () => { + const source = readFileSync(url, 'utf8'); + assert.match(source, /createAircraftModels\(\{/); + assert.match(source, /\.\.\.models\b/); + }); +} + +test('flights: model eligibility nudges enrichment and the tick floors grounded icons', () => { + const source = readFileSync(RENDERING.flights, 'utf8'); + assert.match(source, /onModelEligible:[\s\S]*?_requestTypeEnrichment\(icao24, true\)/); + assert.match(source, /afterReplan:[\s\S]*?_floorGroundedIcons\(/); +}); diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index 37cb1ca31..e4f404ca8 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -370,9 +370,9 @@ function layerConstants(sourceFile) { function normalBillboardScaleByDistance(sourceFile) { const src = readLayerSource(path.join(ROOT, sourceFile)); - const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); - assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); - const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); + const fn = src.match(/const NORMAL_SCALE_BY_DISTANCE = ([\s\S]*?\);)/); + assert.ok(fn, `${sourceFile}: NORMAL_SCALE_BY_DISTANCE not found`); + const scalar = fn[1].match(/NearFarScalar\(\s*(\d+),\s*([\d.]+),\s*(\d+),\s*([\d.]+),?\s*\)/); assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); return scalar.slice(1).map(Number); } diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index fa0e705b2..09fd23e36 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -956,14 +956,14 @@ for (const fixture of LAYERS) { test('both layers gate the tracked-model load and record its failures', async () => { for (const name of ['flights.js', 'militaryFlights.js']) { const source = readLayerSource(new URL(`./${name}`, import.meta.url)); - assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/, + assert.match(source, /!(?:(?:flightState|state)\.)?_trackedModel\s*&&\s*!(?:(?:flightState|state)\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/, `${name}: the driver asks permission before starting another tracked-model load`); assert.match(source, /(?:parts\.\w+\.)?_noteTrackedModelLoadFailure\(\s*trackedSpec\.url,\s*err,?\s*\)/, `${name}: a rejected load is recorded against this selection, not silently retried`); // The reset must be reachable from the real teardown, not only a test seam. assert.match(source, /(?:parts\.\w+\.)?_releaseTrackedModel\(\s*,?\s*\);\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/, `${name}: deselect clears the per-selection latches in the production path`); - assert.match(source, /(?:flightState\.)?_trackedIcao\s*=\s*icao24;\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/, + assert.match(source, /(?:(?:flightState|state)\.)?_trackedIcao\s*=\s*icao24;\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/, `${name}: selecting a contact starts from a clean per-selection state`); } }); @@ -973,13 +973,13 @@ test('the tracked regime never consults the fleet models3d toggle', async () => const source = readLayerSource(new URL(`./${name}`, import.meta.url)); const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; assert.ok(regime, `${name}: _trackedModelRegimeActive is defined`); - assert.doesNotMatch(regime, /(?:flightState\.)?_models3dEnabled|(?:parts\.\w+\.)?_modelRegimeActive\(\s*,?\s*\)/, + assert.doesNotMatch(regime, /(?:(?:flightState|state)\.)?_models3dEnabled|(?:parts\.\w+\.)?_modelRegimeActive\(\s*,?\s*\)/, `${name}: the tracked contact's handoff is default-on, not gated on the fleet toggle`); assert.match(regime, /trackedModelZoomActive\(/, `${name}: the tracked contact uses the shared hysteretic zoom policy`); // The FLEET must still obey the toggle — the budget decision stays the operator's. const fleet = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; - assert.match(fleet, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/, + assert.match(fleet, /if\s*\(\s*!(?:(?:flightState|state)\.)?_models3dEnabled,?\s*\)\s*return\s*false;/, `${name}: the fleet regime still obeys the DISPLAY-rail 3D toggle`); } }); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 67b9d14ef..397f554c3 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,33 +1,15 @@ import * as Cesium from 'cesium'; -import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; -import { horizonOccluder } from '../../data/iconOrientation.js'; -import { aircraftTreatmentAlpha } from '../../data/aircraftRecession.js'; import { MIL_TINT, EMERGENCY_TINT, GROUND_SCALE, COCKPIT_CIVILIAN_COLOR, - MODEL_ALT_CEIL_M, - MODEL_MAX_ALL, - MODEL_MAX, - COCKPIT_MODEL_MAX, - MODEL_ALL_ADD_M, - MODEL_PROX_ADD_M, - MODEL_ALL_KEEP_M, - MODEL_PROX_KEEP_M, - MODEL_HEADING_OFFSET_DEG, - IR_RELOAD_BATCH, - MODEL_MIN_PX, - TRACKED_MODEL_MIN_PX, - TRACKED_MODEL_MAX_PX, - FLEET_DR_INTERVAL_MS, } from './policy.js'; -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; -import { aircraftVisuals } from '../aircraft/visualState.js'; -import { modelState, setModel } from '../../data/modelMirror.js'; +import * as policy from './policy.js'; +import { createAircraftModels } from '../../data/aircraftModelLayer.js'; export function createRendering({ flightState, @@ -37,10 +19,7 @@ export function createRendering({ resolveAsset, }) { const { isMilitaryIcao } = services.militaryRegistry; - const { tr3bIconKind, isTr3b } = services.aircraftPresentation; - const { trackedModelScaleForPixelCap } = services.camera; - const { focusNowMs, getFocusTarget } = services.focus; - const { applyAircraftModelTreatment } = services.recession; + const { tr3bIconKind } = services.aircraftPresentation; /** True while this contact's latest poll broadcast an emergency code. */ @@ -87,16 +66,6 @@ export function createRendering({ return spec; } - function _normalBillboardScaleByDistance() { - // Preserve the established close-range 3× scale. Any smaller owner-visible - // default belongs in a separate evidence-backed proposal. - return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); - } - - function _cockpitBillboardScaleByDistance() { - return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); - } - /** Sprite kind for one contact's billboard. Identity for every aircraft except * the ones the operator converted into a TR-3B (Easter egg), which draw the * black-triangle glyph — its thermal-reactive variant while an IR style owns @@ -113,873 +82,34 @@ export function createRendering({ return _fleetBillboardColor(icao24); } - /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude - * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the - * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in - * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms - * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED - * contact does not route through here: it is one model, it is what the camera is aimed at, - * and it takes its own default-on, hysteretic zoom regime - * (`_trackedModelRegimeActive`). */ - - function _modelRegimeActive() { - if (!flightState._models3dEnabled) return false; - const h = - flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; - return h < MODEL_ALT_CEIL_M; - } - - /** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the - * SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses - * at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ - - function _modelCap() { - const mapCap = - flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; - // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is - // already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal - // retraversal, the cloud pass) and every model is its own draw call. - return flightState._cockpitContactMode - ? Math.min(COCKPIT_MODEL_MAX, mapCap) - : mapCap; - } - - /** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */ - - function _modelAddDistM() { - return flightState._models3dMode === 'all' - ? MODEL_ALL_ADD_M - : MODEL_PROX_ADD_M; - } - - /** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */ - - function _modelKeepDistM() { - return flightState._models3dMode === 'all' - ? MODEL_ALL_KEEP_M - : MODEL_PROX_KEEP_M; - } - - /** 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 - * 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. */ - - function _modelMatrix( - pos, - headingDeg, - result = flightState._scratchModelMtx, - ) { - flightState._scratchModelHpr.heading = Cesium.Math.toRadians( - (headingDeg || 0) + MODEL_HEADING_OFFSET_DEG, - ); - flightState._scratchModelHpr.pitch = 0; - flightState._scratchModelHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( - pos, - flightState._scratchModelHpr, - Cesium.Ellipsoid.WGS84, - undefined, - result, - ); - } - - /** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the - * vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked - * neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion - * matches picked-object IDs, and every icon AND model in this layer carries its - * icao as `id`, so the icao strings cover both; the tracked entity is excluded as the - * object itself. Built lazily — only when a sample actually fires (one-shot). */ - - function _groundSampleExclusions() { - const out = parts.icons._iconIds(); - if (flightState._trackedEntity) out.push(flightState._trackedEntity); - return out; - } - - /** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position - * verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near - * the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports, - * hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested - * model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides - * a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js, - * CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a - * bounded last-known and resamples), plus - * the belly offset so it sits on its gear rather than sinking to the model-origin - * fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss) - * there is no safe depth-tested placement at all: this returns null, the caller - * keeps the 2D billboard visible and the model hidden, and it retries later. Once - * a contact has resolved once, a later outage holds that measurement inside - * groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D - * because a resample is mid-backoff. */ - - function _modelDisplayPosition(icao24, pos, result) { - const meta = flightState.records.data.get(icao24); - if (!meta || !meta.onGround) return pos; - const h = flightState._groundSnap.heightFor( - flightState._viewer, - icao24, - pos, - _groundSampleExclusions, - ); - if (h == null) return null; - const carto = Cesium.Cartographic.fromCartesian( - pos, - Cesium.Ellipsoid.WGS84, - flightState._scratchGroundCarto, - ); - carto.height = h + _modelSpec(meta.klass).bellyM; - return Cesium.Cartesian3.fromRadians( - carto.longitude, - carto.latitude, - carto.height, - Cesium.Ellipsoid.WGS84, - result, - ); - } - - /** Atomically hand one fleet contact from its icon to a safely placed, - * ready 3D model. Missing/loading models and unresolved terrain always leave - * the icon owning the visual, so no render frame can hide both. - * `beforeShow` runs only on the committing path — the per-tick model treatment - * belongs to a model that is about to draw, not to one still waiting. */ - - function _driveFleetModelHandoff( - icao24, - model, - row, - pos, - course, - beforeShow, - ) { - const icons = flightState._icons; - if (!model) { - icons.setShown(row, true); - return false; - } - const displayPos = _modelDisplayPosition( - icao24, - pos, - flightState._scratchGroundPos, - ); - if (!displayPos) { - setModel(model, 'show', 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', _modelMatrix(displayPos, course)); - if (!model.ready) { - setModel(model, 'show', 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); - icons.setShown(row, false); // hand off ONLY once the model renders - return true; - } - - /** - * Whether a 3D model — not the billboard — is what the user actually SEES for - * this contact, and therefore whether the display floor should stand aside. - * - * Model EXISTENCE is not ownership. A fleet model that is still loading, or has - * no resolved ground to stand on, stays hidden while `bb.show` stays true (the - * gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model - * is retained but hidden whenever the model regime is off — 3D disabled, camera - * zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore - * suppressed the clamp in exactly the states where the BILLBOARD is the visual, - * putting the burial straight back. - * - * Cesium's own default `show === true` is the reason admission clears it - * explicitly: a fleet model registered in `_models` the instant its glTF resolves - * would otherwise claim ownership — at the identity matrix — for the frames - * between admission and the next fleet tick, while the BILLBOARD was still the - * visual. Ownership means actually rendering, and every site that sets `show` - * true does so only after committing a matrix. - * - * The two halves: - * - Fleet: the rendering test alone. Safe for the ground snap either way, - * because a fleet model is positioned from the RAW dead-reckon, not from - * the billboard. - * - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked - * model is fed from `_trackedDisplayPosition`, so once it is live the clamp - * must stand aside: two different chains would otherwise be deciding one - * contact's ground, and the billboard's is the one the operator is not - * looking at (T7). The regime check is what makes a regime - * flip take effect on the same frame rather than waiting for - * `_updateTrackedModel` to clear `show`; the rendering test is what keeps a - * 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. - * @returns {boolean} - */ - - function _modelOwnsVisual(icao24, model) { - if (icao24 === flightState._trackedIcao) { - return ( - parts.tracking._trackedModelRegimeActive() && - _modelIsRendering(flightState._trackedModel) - ); - } - return _modelIsRendering( - model === undefined ? flightState._models.get(icao24) : model, - ); - } - - /** 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; - } - - /** Spec identity for a LOADED model: URL and scale together (same-URL classes - * differ by scale — airliner vs quadjet both ship airplane.glb). */ - - const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; - }; - - /** Class-change model sync (enrichment AND poll-path klass updates): when the - * aircraft's live model or in-flight load no longer matches its class's spec, - * drop it so the eligibility pass reloads the right asset at the right scale. - * Gap-proof: the fleet billboard is re-shown BEFORE the release so the - * contact never goes invisible for the tick gap; _releaseModel's generation - * bump also invalidates any pending load. The tracked standalone model gets - * the same rule (its billboard entity is always the fallback visual). */ - - function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); - const current = flightState._models.get(icao24); - if ( - (current && aircraftVisuals.read(current)?.specKey !== key) || - (!current && flightState._modelPending.has(icao24)) - ) { - _releaseModel(icao24); - } - if ( - icao24 === flightState._trackedIcao && - flightState._trackedModel && - aircraftVisuals.read(flightState._trackedModel)?.specKey !== key - ) { - _releaseTrackedModel(); - } - } - - function _reloadModelsForIrBoost() { - flightState._irReloadQueue = [...flightState._models.keys()]; - for (const icao of flightState._modelPending) { - if (!flightState._models.has(icao)) - flightState._modelGen.set( - icao, - (flightState._modelGen.get(icao) || 0) + 1, - ); - } - _releaseTrackedModel(); - } - - function _drainIrReloadQueue() { - if (!flightState._irReloadQueue) return; - const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH); - for (const icao of batch) { - const model = flightState._models.get(icao); - if ( - !model || - aircraftVisuals.read(model)?.irBoost === flightState._irBoost - ) - continue; // already right state - _releaseModel(icao); - } - if (flightState._irReloadQueue.length === 0) - flightState._irReloadQueue = null; - } - - /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ - - async function _ensureModel(icao24) { - // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet - // tick skips it, so a model here would be orphaned + double-rendered. - if (icao24 === flightState._trackedIcao) return; - parts.enrichment._requestTypeEnrichment(icao24, true); // model-eligible: about to render in 3D — jump the ambient backlog - if ( - flightState._models.has(icao24) || - flightState._modelPending.has(icao24) - ) - return; - // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads - // (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL. - if ( - flightState._models.size + flightState._modelPending.size >= - _modelCap() - ) - return; - const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead - const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale - flightState._modelPending.add(icao24); - let model = null; - // Spec identity captured at load START — if enrichment reclassifies the - // aircraft mid-load, the post-await admission below rejects the stale asset. - // Boost state likewise: the creation options bake it in, so a mid-load - // toggle must reject too (the reload queue only covers ADMITTED models). - const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); - const loadIrBoost = flightState._irBoost; - try { - const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // Launch presentation keeps the code-side tint dominant for every approved - // model; IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount, - customShader: flightState._irBoost - ? flightState._IR_UNLIT_SHADER - : undefined, - id: icao24, // so scene.pick returns the icao for click-to-track - }); - } catch { - // asset/decode fail — stay an icon. Only touch this lifecycle's state if still current - // (a destroy/re-init may have swapped the globals while this load was in flight). - if (epoch === flightState._modelEpoch) { - flightState._modelPending.delete(icao24); - _cleanupModelGen(icao24); - } - return; - } - // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new - // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. - if (epoch !== flightState._modelEpoch) { - try { - model.destroy(); - } catch { - /* gone */ - } - return; - } - flightState._modelPending.delete(icao24); - // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the - // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was - // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. - // Recheck the shared Display 3D toggle and altitude ceiling after the async - // load. Cockpit uses the same OFF / Proximity / All contract as map Display. - const stale = - (flightState._modelGen.get(icao24) || 0) !== gen || - !_modelRegimeActive() || - !flightState._modelCollection || - flightState._modelCollection.isDestroyed() || - !flightState.records.data.has(icao24) || - icao24 === flightState._trackedIcao || - flightState._models.has(icao24) || - flightState._models.size >= _modelCap() || - // Class reclassified mid-load → this GLB/scale is for the OLD class. - _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || - // IR boost flipped mid-load → this model baked the wrong shader/tint. - flightState._irBoost !== loadIrBoost; - if (stale) { - try { - model.destroy(); - } catch { - /* already gone */ - } - _cleanupModelGen(icao24); // bound the map - return; - } - // 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 - // 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); - 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 - } - - /** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff). - * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ - - function _releaseModel(icao24) { - const m = flightState._models.get(icao24); - const pending = flightState._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) { - flightState._modelGen.set( - icao24, - (flightState._modelGen.get(icao24) || 0) + 1, - ); - } - if (m) { - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - try { - flightState._modelCollection.remove(m); - } catch { - /* gone */ - } - } - flightState._models.delete(icao24); - _showReleasedIcon(icao24); - } - // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping - // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so - // the resolving load's gen-check still rejects it. - _cleanupModelGen(icao24); - } - - /** Drop an icao's generation entry once nothing references it (no live model, no in-flight - * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ - - function _cleanupModelGen(icao24) { - if ( - !flightState._modelPending.has(icao24) && - !flightState._models.has(icao24) - ) - flightState._modelGen.delete(icao24); - } - - /** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */ - - function _releaseModels() { - flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain - // Invalidate in-flight loads so a completion after this bulk release can't add a model. - for (const icao of flightState._modelPending) - flightState._modelGen.set( - icao, - (flightState._modelGen.get(icao) || 0) + 1, - ); - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - for (const m of flightState._models.values()) { - try { - flightState._modelCollection.remove(m); - } catch { - /* gone */ - } - } - } - for (const icao24 of flightState._models.keys()) _showReleasedIcon(icao24); - flightState._models.clear(); - } - - /** A released fleet model hands the visual straight back to its icon (the - * tracked contact's entity keeps its own). */ - - function _showReleasedIcon(icao24) { - const icons = flightState._icons; - const row = icons?.row(icao24) ?? -1; - if (row >= 0 && icao24 !== flightState._trackedIcao) - icons.setShown(row, true); - } - - /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ - - function _releaseTrackedModel() { - flightState._trackedModelGen++; - flightState._trackedModelLoading = false; - if (flightState._trackedModel) { - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - try { - flightState._modelCollection.remove(flightState._trackedModel); - } catch { - /* gone */ - } - } - flightState._trackedModel = null; - } - } - - /** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet - * cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the - * follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed - * in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */ - - function _updateTrackedModel() { - const active = - flightState._trackedIcao && - parts.tracking._trackedModelRegimeActive() && - flightState._modelCollection && - !flightState._modelCollection.isDestroyed(); - if (!active) { - if (flightState._trackedModel) - setModel(flightState._trackedModel, 'show', false); - return; - } - // Ask the frame-cached source directly. If the entity callback already ran, - // this is a no-op; if model loading completed between phases, it establishes - // this frame's single sample before the model renders. Camera, detection, and - // readout consumers then reuse that exact cached position. - const pos = - parts.motion._trackedDisplayPosition(flightState._trackedIcao) || - parts.icons._iconPosition(flightState._trackedIcao); - if (!pos) { - if (flightState._trackedModel) - setModel(flightState._trackedModel, 'show', false); - return; - } - if ( - !flightState._trackedModel && - !flightState._trackedModelLoading && - parts.tracking._trackedModelLoadAllowed() - ) { - flightState._trackedModelLoading = true; - const gen = flightState._trackedModelGen; - const trackedSpec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, - ); - const trackedKey = _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, - ); - const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize: TRACKED_MODEL_MIN_PX, - scale: trackedSpec.scale, - color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // The tracked aircraft uses the same dominant light tint as the fleet; - // IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount, - customShader: flightState._irBoost - ? flightState._IR_UNLIT_SHADER - : undefined, - // Pick id (H1): without it, clicking the very plane being tracked read as - // EMPTY SPACE (scene.pick → primitive with no id) → an unintended - // deselect. With the icao, the click handler recognizes it as ours. - id: flightState._trackedIcao, - }) - .then((m) => { - // Untracked / re-tracked / torn down during the load → drop it. - if ( - gen !== flightState._trackedModelGen || - !flightState._modelCollection || - flightState._modelCollection.isDestroyed() - ) { - try { - m.destroy(); - } catch { - /* gone */ - } - return; - } - // Class reclassified OR boost flipped mid-load (no release ran — - // _trackedModel was still null): drop the stale asset; driver reloads. - if ( - _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, - ) !== trackedKey || - flightState._irBoost !== trackedIrBoost - ) { - try { - m.destroy(); - } catch { - /* gone */ - } - flightState._trackedModelLoading = false; - return; - } - // Assign after resolution as well as in the creation options so the - // standalone primitive always exposes the tracked aircraft pick id. - m.id = flightState._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 - // 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. - const currentPos = - parts.motion._trackedDisplayCached() || - parts.icons._iconPosition(flightState._trackedIcao); - if (currentPos) { - const displayPos = _modelDisplayPosition( - flightState._trackedIcao, - currentPos, - flightState._scratchGroundPos, - ); - if (displayPos) - setModel( - m, - 'modelMatrix', - _modelMatrix(displayPos, parts.motion._trackedDisplayCourse()), - ); - } - flightState._trackedModel = m; - flightState._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. - flightState._trackedModelFailIcao = null; - flightState._trackedModelFailCount = 0; - flightState._trackedModelRetryAtMs = 0; - flightState._modelCollection.add(m); - flightState._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up - }) - .catch((err) => { - if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure - flightState._trackedModelLoading = false; - parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); - }); - return; // billboard carries the visual until the model is ready - } - if (flightState._trackedModel) { - // Keep the transform current while GPU resources are still loading. Cesium - // can flip ready during scene update after this callback; waiting for ready - // here would let that first rendered frame use a stale load-start matrix. - const displayPos = _modelDisplayPosition( - flightState._trackedIcao, - pos, - flightState._scratchGroundPos, - ); - if (!displayPos) { - setModel(flightState._trackedModel, 'show', false); // no ground evidence → the billboard carries it - return; - } - setModel( - flightState._trackedModel, - 'modelMatrix', - _modelMatrix(displayPos, parts.motion._trackedDisplayCourse()), - ); - if (!flightState._trackedModel.ready) return; - const spec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, - ); - flightState._trackedModel.scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM: Cesium.Cartesian3.distance( - flightState._viewer.camera.positionWC, - displayPos, - ), - viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, - fovyRad: flightState._viewer.camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - setModel(flightState._trackedModel, 'show', true); - } - } - - /** - * ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and - * the two-tier glyph swap all run on the GPU from each row's segment, so this - * only re-plans rows whose regime ended, pins grounded contacts to the floor - * under them, advances focus de-emphasis while a target exists, and drives - * the 3D-model handoff inside the model regime. Driven by scene.preRender. - * @returns {void} - */ - - function _fleetTick() { - const icons = flightState._icons; - if (!flightState._viewer || !icons || !icons.show) return; - const scene = flightState._viewer.scene; - const camera = flightState._viewer.camera; - const nowMs = focusNowMs(Date.now()); - - if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return; - flightState._lastFleetTickMs = nowMs; - // Every contact in this pass shares the same delayed display instant. - const renderTime = parts.icons._displayTime( - flightState._scratchFleetRenderTime, - ); - const tau = icons.seconds(renderTime); - - _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload - if (flightState._cockpitContactMode) - parts.tracking._refreshCockpitNearContacts(); - parts.icons._replanExpired(tau); - parts.icons._floorGroundedIcons(renderTime, tau, nowMs, camera.positionWC); - const focusTarget = getFocusTarget(); - parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget); - - // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. - // Drop all models the moment we leave it (toggled off / zoomed out) so icons resume. - const useModels = _modelRegimeActive(); - // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that - // resolves after zoom-out could briefly add a model outside the 3D-model regime). - if ( - !useModels && - (flightState._models.size || flightState._modelPending.size) - ) - _releaseModels(); - if (!useModels) return; - - // 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the - // cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen - // already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius, - // nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots - // (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint). - // Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an - // on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap - // with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum - // filtering dropped near off-screen planes entirely. This does both right. - const cap = _modelCap(); - const camPos = camera.positionWC; - const addM = _modelAddDistM(); - const addDistSq = addM * addM; - const keepM = _modelKeepDistM(); - const keepDistSq = keepM * keepM; - const cull = camera.frustum.computeCullingVolume( - camPos, - camera.directionWC, - camera.upWC, - ); - // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. - const cand = []; - for (let row = 0; row < icons.count; row++) { - const icao = icons.idAt(row); - if (icao === flightState._trackedIcao) continue; - // A converted TR-3B renders as an icon and can never take a model, so - // it must not occupy a CAP SLOT either — excluded here at selection time, - // not just at the handoff below, or accumulated conversions would starve - // ordinary contacts of 3D models. (The handoff guard stays as defence.) - if (isTr3b(icao)) continue; - // Ground planes compete for model slots like everyone else (owner decision - // 2026-07-03: "3D mode is respected regardless of whether a plane is on the - // ground or in the air — no distinction"). The cap + nearest-first ordering - // below already bound airport clusters; grounded placement is handled by the - // one-shot ground snap in _modelDisplayPosition. - const position = icons.position( - row, + const models = createAircraftModels({ + state: flightState, + services, + parts, + resolveAsset, + logLabel: 'Data:Flights', + policy, + modelSpec: _modelSpec, + modelColor: _modelColor, + // About to render in 3D: jump the ambient type-enrichment backlog. + onModelEligible: (icao24) => + parts.enrichment._requestTypeEnrichment(icao24, true), + afterReplan: (renderTime, tau, nowMs, camera) => + parts.icons._floorGroundedIcons( + renderTime, tau, - flightState._scratchModelBS.center, - ); - const d2 = Cesium.Cartesian3.distanceSquared(camPos, position); - if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible - cand.push([ - icao, - d2, - cull.computeVisibility(flightState._scratchModelBS) !== - Cesium.Intersect.OUTSIDE, - ]); - } - cand.sort((a, b) => a[1] - b[1]); // nearest first - if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { - console.warn( - `[Data:Flights] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`, - ); - flightState._lastModelCapWarnMs = nowMs; - } - const modelEligible = selectModelEligible(cand, { - cap, - addDistSq, - isModeled: (icao) => flightState._models.has(icao), - }); - const toRelease = []; - for (const icao of flightState._models.keys()) { - if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) - toRelease.push(icao); - } - for (const icao of toRelease) _releaseModel(icao); + nowMs, + camera.positionWC, + ), + }); - // 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown - // until the model is actually READY to render, so a plane is never both iconless AND - // modelless (the "planes vanish when 3D turns on" bug). Position the model every tick - // regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on - // purpose: the Easter egg IS the triangle, and there is no GLB for it. - const occluder = horizonOccluder(camera); - for (const icao24 of modelEligible) { - if (isTr3b(icao24)) continue; - const row = icons.row(icao24); - if (row < 0) continue; - const position = icons.position(row, tau, flightState._scratchFleetPos); - if (!parts.icons._aboveHorizon(row, position, occluder)) { - // A model that crossed the limb would keep rendering through the - // hidden globe at its last matrix; the icon's own cull is on the GPU. - const m = flightState._models.get(icao24); - if (m && modelState(m)?.show) setModel(m, 'show', false); - continue; - } - _ensureModel(icao24); - const model = flightState._models.get(icao24); - const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position); - _driveFleetModelHandoff( - icao24, - model, - row, - position, - icons.course(row, tau), - () => { - applyAircraftModelTreatment({ - model, - // IR boost must survive the per-tick treatment write — otherwise - // any alpha change would repaint the ordinary tint over the hot - // white. Boosted models also skip the recession fade: hot targets - // stay full-strength at any range (icons keep their normal - // fade — full-opacity glyph walls read as overwhelming). - baseColor: flightState._irBoost - ? Cesium.Color.WHITE - : _modelColor(icao24), - alpha: flightState._irBoost - ? 1 - : aircraftTreatmentAlpha({ - baseAlpha: icons.alpha(row), - focusFactor: icons.focus(row), - cameraDistanceM, - cameraHeightM: camera.positionCartographic?.height, - }), - }); - }, - ); - } - } return { _fleetBillboardColor, _fleetBillboardScale, _cockpitContactColor, _modelSpec, - _normalBillboardScaleByDistance, - _cockpitBillboardScaleByDistance, _iconKind, _modelColor, - _modelRegimeActive, - _modelCap, - _modelAddDistM, - _modelKeepDistM, - _modelMatrix, - _groundSampleExclusions, - _modelDisplayPosition, - _driveFleetModelHandoff, - _modelOwnsVisual, - _modelIsRendering, - _specKeyFor, - _syncModelToClass, - _reloadModelsForIrBoost, - _drainIrReloadQueue, - _ensureModel, - _releaseModel, - _cleanupModelGen, - _releaseModels, - _releaseTrackedModel, - _updateTrackedModel, - _fleetTick, + ...models, }; } diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 7cb1b2297..bc98a2c91 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -7,10 +7,6 @@ import { visualCenterForModel, trailAnchorForModel, } from '../../data/modelVisualAnchor.js'; -import * as Cesium from 'cesium'; -import { selectModelEligible } from '../../data/modelEligibility.js'; -import { horizonOccluder } from '../../data/iconOrientation.js'; -import { aircraftTreatmentAlpha } from '../../data/aircraftRecession.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; import { PLANE_MODEL_SCALE, @@ -27,24 +23,10 @@ import { MIL_ICON_COLOR, TRACKED_ICON_COLOR, EMERGENCY_ICON_COLOR, - MODEL_ALT_CEIL_M, - MODEL_MAX_ALL, - MODEL_MAX, - COCKPIT_MODEL_MAX, - MODEL_ALL_ADD_M, - MODEL_PROX_ADD_M, - MODEL_ALL_KEEP_M, - MODEL_PROX_KEEP_M, - IR_RELOAD_BATCH, - MODEL_MIN_PX, MODEL_COLOR_BLEND_AMOUNT, - TRACKED_MODEL_MIN_PX, - TRACKED_MODEL_MAX_PX, - FLEET_DR_INTERVAL_MS, } from './policy.js'; -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; -import { aircraftVisuals } from '../aircraft/visualState.js'; -import { modelState, setModel } from '../../data/modelMirror.js'; +import * as policy from './policy.js'; +import { createAircraftModels } from '../../data/aircraftModelLayer.js'; export function createRendering({ flightState, @@ -53,10 +35,7 @@ export function createRendering({ layer, resolveAsset, }) { - const { tr3bIconKind, isTr3b } = services.aircraftPresentation; - const { trackedModelScaleForPixelCap } = services.camera; - const { focusNowMs, getFocusTarget } = services.focus; - const { applyAircraftModelTreatment } = services.recession; + const { tr3bIconKind } = services.aircraftPresentation; function _modelSpec(klass) { let spec = flightState._specCache.get(klass); @@ -69,6 +48,7 @@ export function createRendering({ nativeRadiusM: real.radiusM, bellyM: real.bellyM, headingOffsetDeg: 180, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, visualCenterNative: visualCenterForModel(real.url), trailAnchorNative: trailAnchorForModel(real.url), }; @@ -84,6 +64,7 @@ export function createRendering({ nativeRadiusM: PLANE_NATIVE_RADIUS_M, bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: 180, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL), }; @@ -95,6 +76,7 @@ export function createRendering({ nativeRadiusM: MODEL_NATIVE_RADIUS_M, bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + blendAmount: MODEL_COLOR_BLEND_AMOUNT, visualCenterNative: visualCenterForModel(JET_MODEL_URL), trailAnchorNative: trailAnchorForModel(JET_MODEL_URL), }; @@ -103,16 +85,6 @@ export function createRendering({ return spec; } - function _normalBillboardScaleByDistance() { - // Match commercial flights and preserve the established close-range 3× - // default. Smaller owner-visible scaling must be proposed separately. - return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); - } - - function _cockpitBillboardScaleByDistance() { - return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); - } - /** Fleet icon scale: per-class scale, ×GROUND_SCALE while grounded. */ function _fleetBillboardScale(icao24) { @@ -154,829 +126,24 @@ export function createRendering({ const _cockpitContactColor = _fleetBillboardColor; - /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude - * ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the - * budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in - * view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms - * `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED - * contact does not route through here: it is one model, it is what the camera is aimed at, - * and it takes its own default-on, hysteretic zoom regime - * (`_trackedModelRegimeActive`). Mirror of flights.js. */ + const models = createAircraftModels({ + state: flightState, + services, + parts, + resolveAsset, + logLabel: 'Data:Military', + policy, + modelSpec: _modelSpec, + modelColor: _modelColor, + }); - function _modelRegimeActive() { - if (!flightState._models3dEnabled) return false; - const h = - flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; - return h < MODEL_ALT_CEIL_M; - } - - /** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the - * SAME value, else 'all' (MODEL_MAX_ALL) marks planes eligible that _ensureModel refuses at the - * lower MODEL_MAX, silently degrading 'all' to 'proximity'. */ - - function _modelCap() { - const mapCap = - flightState._models3dMode === 'all' ? MODEL_MAX_ALL : MODEL_MAX; - // `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. - return flightState._cockpitContactMode - ? Math.min(COCKPIT_MODEL_MAX, mapCap) - : mapCap; - } - - /** Active ADD/KEEP radii (m) — mode-aware ('all' reaches ~to the horizon). Mirror of flights.js. */ - - function _modelAddDistM() { - return flightState._models3dMode === 'all' - ? MODEL_ALL_ADD_M - : MODEL_PROX_ADD_M; - } - - function _modelKeepDistM() { - return flightState._models3dMode === 'all' - ? MODEL_ALL_KEEP_M - : MODEL_PROX_KEEP_M; - } - - /** 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 - * object (see flights.js for the full rationale). */ - - function _modelMatrix( - pos, - headingDeg, - result = flightState._scratchModelMtx, - offsetDeg = MODEL_HEADING_OFFSET_DEG, - ) { - flightState._scratchModelHpr.heading = Cesium.Math.toRadians( - (headingDeg || 0) + offsetDeg, - ); - flightState._scratchModelHpr.pitch = 0; - flightState._scratchModelHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( - pos, - flightState._scratchModelHpr, - Cesium.Ellipsoid.WGS84, - undefined, - result, - ); - } - - /** Everything scene.sampleHeight must NOT hit when snapping a grounded model (mirror of - * flights.js): the vertical pick ray at a plane's own lat/lon otherwise lands on its - * (or a parked neighbor's) icon/model instead of the tile skin. Exclusion matches - * picked-object IDs — every icon AND model here carries its icao as `id` — plus - * the tracked entity object itself. Built lazily, only when a sample actually fires. */ - - function _groundSampleExclusions() { - const out = parts.icons._iconIds(); - if (flightState._trackedEntity) out.push(flightState._trackedEntity); - return out; - } - - /** Position a 3D MODEL renders at (mirror of flights.js — see the full rationale there). - * Grounded planes ride a ONE-SHOT cached scene.sampleHeight of the photoreal tile skin - * (groundSnap.js; taxiing >50 m retires it to a bounded last-known) plus the belly - * offset so they sit on their - * gear; airborne planes pass through. Until the FIRST sample lands, callers keep the - * icon visible and the model hidden while this returns null; a - * contact that has already resolved once holds that measurement through a later - * outage inside groundSnap's drift bound, so taxiing does not pop it back to 2D. */ - - function _modelDisplayPosition(icao24, pos, result) { - const meta = flightState.records.data.get(icao24); - if (!meta || !meta.onGround) return pos; - const h = flightState._groundSnap.heightFor( - flightState._viewer, - icao24, - pos, - _groundSampleExclusions, - ); - if (h == null) return null; - const carto = Cesium.Cartographic.fromCartesian( - pos, - Cesium.Ellipsoid.WGS84, - flightState._scratchGroundCarto, - ); - carto.height = h + _modelSpec(meta.klass).bellyM; - return Cesium.Cartesian3.fromRadians( - carto.longitude, - carto.latitude, - carto.height, - Cesium.Ellipsoid.WGS84, - result, - ); - } - - /** Atomically hand one fleet contact from its icon to a safely placed, - * ready 3D model. Missing/loading models and unresolved terrain always leave - * the icon owning the visual, so no render frame can hide both. - * `beforeShow` runs only on the committing path — the per-tick model treatment - * belongs to a model that is about to draw, not to one still waiting. */ - - function _driveFleetModelHandoff( - icao24, - model, - row, - pos, - course, - beforeShow, - ) { - const icons = flightState._icons; - if (!model) { - icons.setShown(row, true); - return false; - } - const displayPos = _modelDisplayPosition( - icao24, - pos, - flightState._scratchGroundPos, - ); - if (!displayPos) { - setModel(model, 'show', 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', - _modelMatrix( - displayPos, - course, - undefined, - _modelSpec(flightState.records.data.get(icao24)?.klass) - .headingOffsetDeg, - ), - ); - if (!model.ready) { - setModel(model, 'show', 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); - icons.setShown(row, false); // hand off ONLY once the model renders - return true; - } - - /** A model owns the visual only while it is ready, shown, and (for the tracked - * primitive) inside the tracked-model regime. Mere existence/loading is not ownership. */ - - function _modelOwnsVisual(icao24) { - if (icao24 === flightState._trackedIcao) { - return ( - parts.tracking._trackedModelRegimeActive() && - _modelIsRendering(flightState._trackedModel) - ); - } - return _modelIsRendering(flightState._models.get(icao24)); - } - - /** `show` is sufficient evidence of a safe placement because only the handoff and - * the tracked driver ever set it true, and both do so after committing a matrix; - * everything else — admission, unresolved ground, an unready glTF, the limb cull, - * a regime exit — only ever clears it. Mirror of flights.js. */ - - function _modelIsRendering(model) { - return !!model && model.ready === true && modelState(model)?.show === true; - } - - /** Spec identity for a LOADED model — mirror of flights.js: URL and scale - * together (same-URL classes differ by scale). */ - - const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; - }; - - /** Class-change model sync — mirror of flights.js: drop a live model or - * in-flight load whose spec no longer matches the class, re-showing the - * fleet icon (on release, gap-proof) and covering the tracked standalone. */ - - function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); - const current = flightState._models.get(icao24); - if ( - (current && aircraftVisuals.read(current)?.specKey !== key) || - (!current && flightState._modelPending.has(icao24)) - ) { - _releaseModel(icao24); - } - if ( - icao24 === flightState._trackedIcao && - flightState._trackedModel && - aircraftVisuals.read(flightState._trackedModel)?.specKey !== key - ) { - _releaseTrackedModel(); - } - } - - function _reloadModelsForIrBoost() { - flightState._irReloadQueue = [...flightState._models.keys()]; - for (const icao of flightState._modelPending) { - if (!flightState._models.has(icao)) - flightState._modelGen.set( - icao, - (flightState._modelGen.get(icao) || 0) + 1, - ); - } - _releaseTrackedModel(); - } - - function _drainIrReloadQueue() { - if (!flightState._irReloadQueue) return; - const batch = flightState._irReloadQueue.splice(0, IR_RELOAD_BATCH); - for (const icao of batch) { - const model = flightState._models.get(icao); - if ( - !model || - aircraftVisuals.read(model)?.irBoost === flightState._irBoost - ) - continue; // already right state - _releaseModel(icao); - } - if (flightState._irReloadQueue.length === 0) - flightState._irReloadQueue = null; - } - - /** Lazily create the glTF model for an aircraft (fire-and-forget; its icon shows until ready). */ - - async function _ensureModel(icao24) { - // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet - // tick skips it, so a model here would be orphaned + double-rendered. - if (icao24 === flightState._trackedIcao) return; - if ( - flightState._models.has(icao24) || - flightState._modelPending.has(icao24) - ) - return; - // Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads - // (the cap is rechecked post-await too, before the add). - if ( - flightState._models.size + flightState._modelPending.size >= - _modelCap() - ) - return; - const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead - const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale - flightState._modelPending.add(icao24); - let model = null; - // Spec identity captured at load START (mirror of flights.js): a mid-load - // reclassification makes the post-await admission reject the stale asset. - // Boost state likewise — creation options bake it in. - const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); - const loadIrBoost = flightState._irBoost; - try { - const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: flightState._irBoost - ? flightState._IR_UNLIT_SHADER - : undefined, - id: icao24, // so scene.pick returns the icao for click-to-track - }); - } catch { - // asset/decode fail — stay an icon. Only touch this lifecycle's state if still current - // (a destroy/re-init may have swapped the globals while this load was in flight). - if (epoch === flightState._modelEpoch) { - flightState._modelPending.delete(icao24); - _cleanupModelGen(icao24); - } - return; - } - // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new - // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. - if (epoch !== flightState._modelEpoch) { - try { - model.destroy(); - } catch { - /* gone */ - } - return; - } - flightState._modelPending.delete(icao24); - // Post-await admission: reject (and DESTROY the loaded model) if anything changed during the - // load — a release bumped the generation (track/untrack/remove), the layer toggled off / was - // torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled. - // Recheck the shared Display 3D toggle and altitude ceiling after the async - // load. Cockpit uses the same OFF / Proximity / All contract as map Display. - const stale = - (flightState._modelGen.get(icao24) || 0) !== gen || - !_modelRegimeActive() || - !flightState._modelCollection || - flightState._modelCollection.isDestroyed() || - !flightState.records.data.has(icao24) || - icao24 === flightState._trackedIcao || - flightState._models.has(icao24) || - flightState._models.size >= _modelCap() || - // Class reclassified mid-load → this GLB/scale is for the OLD class. - _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || - // IR boost flipped mid-load → this model baked the wrong shader/tint. - flightState._irBoost !== loadIrBoost; - if (stale) { - try { - model.destroy(); - } catch { - /* already gone */ - } - _cleanupModelGen(icao24); // bound the map - return; - } - // 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 - // 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); - 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 - } - - /** Remove the 3D model for ONE aircraft (removal / track handoff). - * Bumps the load generation so any in-flight load for this icao is rejected on completion. */ - - function _releaseModel(icao24) { - const m = flightState._models.get(icao24); - const pending = flightState._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) { - flightState._modelGen.set( - icao24, - (flightState._modelGen.get(icao24) || 0) + 1, - ); - } - if (m) { - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - try { - flightState._modelCollection.remove(m); - } catch { - /* gone */ - } - } - flightState._models.delete(icao24); - _showReleasedIcon(icao24); - } - // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping - // it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so - // the resolving load's gen-check still rejects it. - _cleanupModelGen(icao24); - } - - /** Drop an icao's generation entry once nothing references it (no live model, no in-flight - * load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */ - - function _cleanupModelGen(icao24) { - if ( - !flightState._modelPending.has(icao24) && - !flightState._models.has(icao24) - ) - flightState._modelGen.delete(icao24); - } - - /** Remove all live models (toggle-off / zoom-out); icons take back over at once. */ - - function _releaseModels() { - flightState._irReloadQueue = null; // a full release supersedes any pending boost-flip drain - // Invalidate in-flight loads so a completion after this bulk release can't add a model. - for (const icao of flightState._modelPending) - flightState._modelGen.set( - icao, - (flightState._modelGen.get(icao) || 0) + 1, - ); - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - for (const m of flightState._models.values()) { - try { - flightState._modelCollection.remove(m); - } catch { - /* gone */ - } - } - } - for (const icao24 of flightState._models.keys()) _showReleasedIcon(icao24); - flightState._models.clear(); - } - - /** A released fleet model hands the visual straight back to its icon (the - * tracked contact's entity keeps its own). */ - - function _showReleasedIcon(icao24) { - const icons = flightState._icons; - const row = icons?.row(icao24) ?? -1; - if (row >= 0 && icao24 !== flightState._trackedIcao) - icons.setShown(row, true); - } - - /** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */ - - function _releaseTrackedModel() { - flightState._trackedModelGen++; - flightState._trackedModelLoading = false; - if (flightState._trackedModel) { - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - try { - flightState._modelCollection.remove(flightState._trackedModel); - } catch { - /* gone */ - } - } - flightState._trackedModel = null; - } - } - - /** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The - * tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */ - - function _updateTrackedModel() { - const active = - flightState._trackedIcao && - parts.tracking._trackedModelRegimeActive() && - flightState._modelCollection && - !flightState._modelCollection.isDestroyed(); - if (!active) { - if (flightState._trackedModel) - setModel(flightState._trackedModel, 'show', false); - return; - } - // Ask the frame-cached source directly. If the entity callback already ran, - // this is a no-op; if model loading completed between phases, it establishes - // this frame's single sample before the model renders. Camera, detection, and - // readout consumers then reuse that exact cached position. - const pos = - parts.motion._trackedDisplayPosition(flightState._trackedIcao) || - parts.icons._iconPosition(flightState._trackedIcao); - if (!pos) { - if (flightState._trackedModel) - setModel(flightState._trackedModel, 'show', false); - return; - } - if ( - !flightState._trackedModel && - !flightState._trackedModelLoading && - parts.tracking._trackedModelLoadAllowed() - ) { - flightState._trackedModelLoading = true; - const gen = flightState._trackedModelGen; - const trackedSpec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, - ); - const trackedKey = _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, - ); - const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize: TRACKED_MODEL_MIN_PX, - scale: trackedSpec.scale, - color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: flightState._irBoost - ? flightState._IR_UNLIT_SHADER - : undefined, - // Pick id (H1): without it, clicking the very plane being tracked read as - // EMPTY SPACE (scene.pick → primitive with no id) → an unintended - // deselect. With the icao, the click handler recognizes it as ours. - id: flightState._trackedIcao, - }) - .then((m) => { - if ( - gen !== flightState._trackedModelGen || - !flightState._modelCollection || - flightState._modelCollection.isDestroyed() - ) { - try { - m.destroy(); - } catch { - /* gone */ - } - return; - } - // Class reclassified OR boost flipped mid-load (no release ran — - // _trackedModel was still null): drop the stale asset; driver reloads. - if ( - _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, - ) !== trackedKey || - flightState._irBoost !== trackedIrBoost - ) { - try { - m.destroy(); - } catch { - /* gone */ - } - flightState._trackedModelLoading = false; - return; - } - // Assign after resolution as well as in the creation options so the - // standalone primitive always exposes the tracked aircraft pick id. - m.id = flightState._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 - // 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. - const currentPos = - parts.motion._trackedDisplayCached() || - parts.icons._iconPosition(flightState._trackedIcao); - if (currentPos) { - const displayPos = _modelDisplayPosition( - flightState._trackedIcao, - currentPos, - flightState._scratchGroundPos, - ); - if (displayPos) { - setModel( - m, - 'modelMatrix', - _modelMatrix( - displayPos, - parts.motion._trackedDisplayCourse(), - undefined, - trackedSpec.headingOffsetDeg, - ), - ); - } - } - flightState._trackedModel = m; - flightState._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. - flightState._trackedModelFailIcao = null; - flightState._trackedModelFailCount = 0; - flightState._trackedModelRetryAtMs = 0; - flightState._modelCollection.add(m); - flightState._planeModelLoaded = true; - }) - .catch((err) => { - if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure - flightState._trackedModelLoading = false; - parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err); - }); - return; - } - if (flightState._trackedModel) { - // Keep the transform current while GPU resources are still loading. Cesium - // can flip ready during scene update after this callback; waiting for ready - // here would let that first rendered frame use a stale load-start matrix. - const displayPos = _modelDisplayPosition( - flightState._trackedIcao, - pos, - flightState._scratchGroundPos, - ); - if (!displayPos) { - setModel(flightState._trackedModel, 'show', false); // no ground evidence → the billboard carries it - return; - } - const spec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, - ); - setModel( - flightState._trackedModel, - 'modelMatrix', - _modelMatrix( - displayPos, - parts.motion._trackedDisplayCourse(), - undefined, - spec.headingOffsetDeg, - ), - ); - if (!flightState._trackedModel.ready) return; - flightState._trackedModel.scale = trackedModelScaleForPixelCap({ - baseScale: spec.scale, - nativeRadiusM: spec.nativeRadiusM, - rangeM: Cesium.Cartesian3.distance( - flightState._viewer.camera.positionWC, - displayPos, - ), - viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, - fovyRad: flightState._viewer.camera.frustum.fovy, - maximumPixelSize: TRACKED_MODEL_MAX_PX, - }); - setModel(flightState._trackedModel, 'show', true); - } - } - - /** - * ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and - * the two-tier glyph swap all run on the GPU from each row's segment, so this - * only re-plans rows whose regime ended, advances focus de-emphasis while a - * target exists, and drives the 3D-model handoff inside the model regime. - * Driven by scene.preRender. - * @returns {void} - */ - - function _fleetTick() { - const icons = flightState._icons; - if (!flightState._viewer || !icons || !icons.show) return; - const scene = flightState._viewer.scene; - const camera = flightState._viewer.camera; - const nowMs = focusNowMs(Date.now()); - - if (nowMs - flightState._lastFleetTickMs < FLEET_DR_INTERVAL_MS) return; - flightState._lastFleetTickMs = nowMs; - // Every contact in this pass shares the same delayed display instant. - const renderTime = parts.icons._displayTime( - flightState._scratchFleetRenderTime, - ); - const tau = icons.seconds(renderTime); - - _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload - if (flightState._cockpitContactMode) - parts.tracking._refreshCockpitNearContacts(); - parts.icons._replanExpired(tau); - const focusTarget = getFocusTarget(); - parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget); - - // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. - // Drop all models the moment we leave it (toggled off / zoomed out) so icons resume. - const useModels = _modelRegimeActive(); - // Drop live models AND invalidate in-flight loads on leaving the regime (else a load that - // resolves after zoom-out could briefly add a model outside the 3D-model regime). - if ( - !useModels && - (flightState._models.size || flightState._modelPending.size) - ) - _releaseModels(); - if (!useModels) return; - - // 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the - // cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen - // already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius, - // nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots - // (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint). - // Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an - // on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap - // with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum - // filtering dropped near off-screen planes entirely. This does both right. - const cap = _modelCap(); - const camPos = camera.positionWC; - const addM = _modelAddDistM(); - const addDistSq = addM * addM; - const keepM = _modelKeepDistM(); - const keepDistSq = keepM * keepM; - const cull = camera.frustum.computeCullingVolume( - camPos, - camera.directionWC, - camera.upWC, - ); - // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. - const cand = []; - for (let row = 0; row < icons.count; row++) { - const icao = icons.idAt(row); - if (icao === flightState._trackedIcao) continue; - // A converted TR-3B renders as an icon and can never take a model, so - // it must not occupy a CAP SLOT either — excluded here at selection time, - // not just at the handoff below, or accumulated conversions would starve - // ordinary contacts of 3D models. (The handoff guard stays as defence.) - if (isTr3b(icao)) continue; - // Ground planes compete for model slots like everyone else (owner decision - // 2026-07-03: "3D mode is respected regardless of whether a plane is on the - // ground or in the air — no distinction"). The cap + nearest-first ordering - // below already bound airport clusters; grounded placement is handled by the - // one-shot ground snap in _modelDisplayPosition. - const position = icons.position( - row, - tau, - flightState._scratchModelBS.center, - ); - const d2 = Cesium.Cartesian3.distanceSquared(camPos, position); - if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible - cand.push([ - icao, - d2, - cull.computeVisibility(flightState._scratchModelBS) !== - Cesium.Intersect.OUTSIDE, - ]); - } - cand.sort((a, b) => a[1] - b[1]); // nearest first - if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { - console.warn( - `[Data:Military] ${cand.length} planes in 3D range; capped at ${cap} (${flightState._models3dMode}). On-screen planes are prioritized.`, - ); - flightState._lastModelCapWarnMs = nowMs; - } - const modelEligible = selectModelEligible(cand, { - cap, - addDistSq, - isModeled: (icao) => flightState._models.has(icao), - }); - const toRelease = []; - for (const icao of flightState._models.keys()) { - if (icao !== flightState._trackedIcao && !modelEligible.has(icao)) - toRelease.push(icao); - } - for (const icao of toRelease) _releaseModel(icao); - - // 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown - // until the model is actually READY to render, so a plane is never both iconless AND - // modelless (the "planes vanish when 3D turns on" bug). Position the model every tick - // regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on - // purpose: the Easter egg IS the triangle, and there is no GLB for it. - const occluder = horizonOccluder(camera); - for (const icao24 of modelEligible) { - if (isTr3b(icao24)) continue; - const row = icons.row(icao24); - if (row < 0) continue; - const position = icons.position(row, tau, flightState._scratchFleetPos); - if (!parts.icons._aboveHorizon(row, position, occluder)) { - // A model that crossed the limb would keep rendering through the - // hidden globe at its last matrix; the icon's own cull is on the GPU. - const m = flightState._models.get(icao24); - if (m && modelState(m)?.show) setModel(m, 'show', false); - continue; - } - _ensureModel(icao24); - const model = flightState._models.get(icao24); - const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position); - _driveFleetModelHandoff( - icao24, - model, - row, - position, - icons.course(row, tau), - () => { - applyAircraftModelTreatment({ - model, - // IR boost must survive the per-tick treatment write — otherwise - // any alpha change would repaint the ordinary tint over the hot - // white. Boosted models also skip the recession fade: hot targets - // stay full-strength at any range (icons keep their normal - // fade — full-opacity glyph walls read as overwhelming). - baseColor: flightState._irBoost - ? Cesium.Color.WHITE - : _modelColor(icao24), - alpha: flightState._irBoost - ? 1 - : aircraftTreatmentAlpha({ - baseAlpha: icons.alpha(row), - focusFactor: icons.focus(row), - cameraDistanceM, - cameraHeightM: camera.positionCartographic?.height, - }), - }); - }, - ); - } - } return { _modelSpec, - _normalBillboardScaleByDistance, - _cockpitBillboardScaleByDistance, _fleetBillboardScale, _cockpitContactColor, _iconKind, _modelColor, _fleetBillboardColor, - _modelRegimeActive, - _modelCap, - _modelAddDistM, - _modelKeepDistM, - _modelMatrix, - _groundSampleExclusions, - _modelDisplayPosition, - _driveFleetModelHandoff, - _modelOwnsVisual, - _modelIsRendering, - _specKeyFor, - _syncModelToClass, - _reloadModelsForIrBoost, - _drainIrReloadQueue, - _ensureModel, - _releaseModel, - _cleanupModelGen, - _releaseModels, - _releaseTrackedModel, - _updateTrackedModel, - _fleetTick, + ...models, }; } diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index 07a871241..47d92031f 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -45,9 +45,12 @@ export function readLayerSource(file) { ? '../layers/cctv' : '../layers/vessels', ); - // The aircraft layers draw through one shared icon layer; it is their code too. + // The aircraft layers draw through shared icon and model layers; they are + // their code too. const shared = ['flights.js', 'militaryFlights.js'].includes(basename(path)) - ? [join(dirname(path), 'aircraftIconLayer.js')] + ? ['aircraftIconLayer.js', 'aircraftModelLayer.js'].map((name) => + join(dirname(path), name), + ) : []; return readdirSync(directory) .filter((name) => name.endsWith('.js')) diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index da84f29d5..d001a77c4 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -650,8 +650,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () const hud = read('../hud.js'); const hudView = read('./views/IntelHudView.vue'); const detection = read('../data/detection.js'); - const civilRendering = read('../layers/flights/rendering.js'); - const militaryRendering = read('../layers/military/rendering.js'); + const aircraftModels = read('../data/aircraftModelLayer.js'); const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud'); assert.match(display, /