mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
Share the aircraft 3D-model regime between flights and military
This commit is contained in:
1 parent
9fde0a2a59
commit
fe32e47bdf
10 files changed
+992
-1773
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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\(/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
+23
-893
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -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, /<option value="cyber">\{\{ t\('Cyber'\) \}\}<\/option>/);
|
||||
@@ -701,12 +700,8 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
);
|
||||
assert.doesNotMatch(detection, /sonarActive \? 1 : 0/);
|
||||
assert.match(
|
||||
civilRendering,
|
||||
/alpha: flightState\._irBoost\s*\?\s*1\s*:\s*aircraftTreatmentAlpha\(/,
|
||||
);
|
||||
assert.match(
|
||||
militaryRendering,
|
||||
/alpha: flightState\._irBoost\s*\?\s*1\s*:\s*aircraftTreatmentAlpha\(/,
|
||||
aircraftModels,
|
||||
/alpha: state\._irBoost\s*\?\s*1\s*:\s*aircraftTreatmentAlpha\(/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
|
||||
Reference in new issue
Block a user