Share the aircraft 3D-model regime between flights and military

This commit is contained in:
Sameh Khamis committed 2026-11-03 20:55:09 -08:00
1 parent 9fde0a2a59
commit fe32e47bdf
10 files changed
+992 -1773

No files matched your search

+4
View File
@@ -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",
+15 -6
View File
@@ -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);
}
+888
View File
@@ -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,
};
}
+30 -7
View File
@@ -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\(/);
});
+3 -3
View File
@@ -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);
}
+4 -4
View File
@@ -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
View File
@@ -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,
};
}
+17 -850
View File
@@ -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,
};
}
+5 -2
View File
@@ -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'))
+3 -8
View File
@@ -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,