mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
feat(flights): aircraft draw life-size fleet models chosen by type
Flights, Military Flights and Local ADS-B draw each aircraft with the fleet model its ICAO type, class or emitter category selects, scaled to the type's length, built from the up-front loaded bytes. The eight third-party aircraft models and their attribution are removed; every shipped model is now the project's own.
This commit is contained in:
1 parent
f6760ad96c
commit
fcc44d0eaf
45 files changed
+970
-1371
No files matched your search
@@ -467,54 +467,3 @@ export const CLASS_SCALE_2D = {
|
||||
bizjet: 0.72,
|
||||
uav: 0.75,
|
||||
};
|
||||
|
||||
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
|
||||
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
|
||||
* real per-class models land in CLASS_MODEL_URL. */
|
||||
export const CLASS_SCALE_3D = {
|
||||
light: 0.75,
|
||||
glider: 0.75,
|
||||
turboprop: 0.85,
|
||||
airliner: 1.0,
|
||||
widebody: 1.3,
|
||||
quadjet: 1.45,
|
||||
helicopter: 0.8,
|
||||
fastjet: 0.8,
|
||||
bizjet: 0.8,
|
||||
uav: 0.8,
|
||||
};
|
||||
|
||||
/** Per-class glTF — all the shared airplane today; drop real assets in here.
|
||||
* NOTE for future models: each asset may need its own heading offset (the
|
||||
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
|
||||
export const CLASS_MODEL_URL = {
|
||||
light: '/models/airplane.glb',
|
||||
glider: '/models/airplane.glb',
|
||||
turboprop: '/models/airplane.glb',
|
||||
airliner: '/models/airplane.glb',
|
||||
widebody: '/models/airplane.glb',
|
||||
quadjet: '/models/airplane.glb',
|
||||
helicopter: '/models/airplane.glb',
|
||||
fastjet: '/models/airplane.glb',
|
||||
bizjet: '/models/airplane.glb',
|
||||
uav: '/models/airplane.glb',
|
||||
};
|
||||
|
||||
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
|
||||
* provenance in public/models/README.md). Every asset is vertex-baked to the
|
||||
* airplane.glb convention: Y-up, X = length, Z = span, nose −X (so the layers'
|
||||
* MODEL_HEADING_OFFSET_DEG = 180 applies unchanged), origin at bbox centre,
|
||||
* REAL-WORLD METERS (render at scale 1 — no MODEL_SCALE, no CLASS_SCALE_3D).
|
||||
* bellyM = origin→lowest-vertex distance (grounded-model lift, replaces the
|
||||
* airplane.glb MODEL_BELLY_OFFSET_NATIVE formula); radiusM = bounding-sphere
|
||||
* radius in meters (pixel-size math). Values measured from the shipped GLBs —
|
||||
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
|
||||
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
|
||||
export const CLASS_MODEL_REAL = {
|
||||
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
|
||||
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
|
||||
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
|
||||
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
|
||||
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
|
||||
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/data/aircraftClass.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js';
|
||||
import { classifyAircraft, CLASS_SCALE_2D } from './aircraftClass.js';
|
||||
|
||||
test('type-code classification (military layer path)', () => {
|
||||
assert.equal(classifyAircraft({ typeCode: 'F16' }), 'fastjet');
|
||||
@@ -48,19 +48,8 @@ test('typeCode outranks category; unknown → airliner', () => {
|
||||
assert.equal(classifyAircraft({ typeCode: null, category: 0 }), 'airliner');
|
||||
});
|
||||
|
||||
test('scale/url tables cover every class', () => {
|
||||
test('the icon scale table covers every class', () => {
|
||||
for (const kind of ['light','glider','turboprop','airliner','widebody','quadjet','helicopter','fastjet','bizjet','uav']) {
|
||||
assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind);
|
||||
assert.ok(CLASS_SCALE_3D[kind] >= 0.75 && CLASS_SCALE_3D[kind] <= 1.45, kind);
|
||||
assert.ok(typeof CLASS_MODEL_URL[kind] === 'string', kind);
|
||||
}
|
||||
});
|
||||
|
||||
test('CLASS_MODEL_REAL entries carry the fields the layers consume', () => {
|
||||
for (const [kind, spec] of Object.entries(CLASS_MODEL_REAL)) {
|
||||
assert.ok(spec.url.startsWith('/models/') && spec.url.endsWith('.glb'), kind);
|
||||
assert.ok(spec.radiusM > 0 && spec.bellyM > 0, kind);
|
||||
// every real-model class must also exist in the classifier tables
|
||||
assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind);
|
||||
}
|
||||
});
|
||||
@@ -160,8 +160,8 @@ const BODIES = {
|
||||
L -3,31 L -8,34 L -8,30 L -6,16
|
||||
L -27,26 L -27,20 L -5,-8
|
||||
C -4.6,-16 -4,-24 -3.5,-30 Z" fill="white" ${STROKE}/>`,
|
||||
// 2026-08-15 Hangar additions: two NEW classes shipped with the real-model
|
||||
// fleet (CLASS_MODEL_REAL). Same contract as the set above: 96×96 nose-up,
|
||||
// 2026-08-15 additions: two NEW classes (bizjet, uav). Same contract as
|
||||
// the set above: 96×96 nose-up,
|
||||
// white fill, hairline stroke, tint-safe.
|
||||
// Business jet — slender fuselage, modest swept wing, aft engines, T-tail.
|
||||
bizjet: `
|
||||
|
||||
@@ -6,9 +6,8 @@ import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||
|
||||
/**
|
||||
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
|
||||
* map (a sky render and GPU readback per model). The preload calls only warm
|
||||
* the glTF cache and are never added to the scene, so they are left out:
|
||||
* drawn models are the calls that set a minimum pixel size.
|
||||
* map (a sky render and GPU readback per model). Drawn models are the calls
|
||||
* that set a minimum pixel size.
|
||||
*/
|
||||
const SOURCES = [
|
||||
{
|
||||
@@ -36,7 +35,9 @@ test('the shared option turns the dynamic environment map off', () => {
|
||||
for (const { name, source } of SOURCES) {
|
||||
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
|
||||
const calls = [
|
||||
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
|
||||
...source.matchAll(
|
||||
/(?:fromGltfAsync\(|loadModel\(|(?:_createModel|createFleetModel)\(\s*\w+,\s*)\{([\s\S]*?)\n\s*\}[,)]/g,
|
||||
),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((options) => /\bminimumPixelSize:/.test(options));
|
||||
|
||||
@@ -28,11 +28,13 @@ for (const layer of LAYERS) {
|
||||
const source = readLayerSource(layer.path);
|
||||
|
||||
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
|
||||
const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)];
|
||||
assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`);
|
||||
const calls = [...source.matchAll(/_createModel\(\s*\w+,\s*\{([\s\S]*?)\n\s*\}\)/g)];
|
||||
assert.ok(calls.length >= 2, `expected fleet and tracked model calls; found ${calls.length}`);
|
||||
assert.doesNotMatch(source, /Cesium\.Model\.fromGltfAsync\(/,
|
||||
'every model is built from the loaded fleet set through _createModel');
|
||||
for (const [index, call] of calls.entries()) {
|
||||
assert.match(call[1], /\basynchronous:\s*false\b/,
|
||||
`Model.fromGltfAsync call ${index + 1} must keep bounded GLB readiness independent of tile jobs`);
|
||||
`_createModel call ${index + 1} must keep bounded GLB readiness independent of tile jobs`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from './iconOrientation.js';
|
||||
import { selectModelEligible } from './modelEligibility.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||
import { fleetModelSet } from './fleetModels.js';
|
||||
|
||||
/**
|
||||
* Cesium models of the procedural fleet set (`fleetModels.js`), and the
|
||||
* icon-to-model handoff the Vessels, Transit and Rail layers share.
|
||||
*/
|
||||
|
||||
/** Every fleet GLB is nose -X: opposite Cesium's heading 0. */
|
||||
export const FLEET_HEADING_OFFSET_DEG = 180;
|
||||
|
||||
/**
|
||||
* Create a Cesium Model of `spec` from the up-front loaded bytes, so no model
|
||||
* is fetched mid-session. Each model keeps its own URL as its base path: the
|
||||
* glTF cache keys by it, and two models sharing one would share geometry.
|
||||
* Rejects when the set is not loaded or the model's GLB failed to load.
|
||||
* @param {object} spec `fleetModelSpec`.
|
||||
* @param {object} options Cesium `Model.fromGltfAsync` options.
|
||||
* @param {{resolveAsset?: (url: string) => string, set?: object|null,
|
||||
* load?: (options: object) => Promise<object>}} [context]
|
||||
* @returns {Promise<object>} Cesium Model.
|
||||
*/
|
||||
export function createFleetModel(
|
||||
spec,
|
||||
options,
|
||||
{
|
||||
resolveAsset = (url) => url,
|
||||
set = fleetModelSet(),
|
||||
// Looked up when called, so a replaced `fromGltfAsync` (tests) is the one used.
|
||||
load = (o) => Cesium.Model.fromGltfAsync(o),
|
||||
} = {},
|
||||
) {
|
||||
const bytes = set?.bytes?.get(spec?.model);
|
||||
if (!bytes)
|
||||
return Promise.reject(new Error(`fleet model ${spec?.model} not loaded`));
|
||||
return load({
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
...options,
|
||||
gltf: bytes,
|
||||
basePath: resolveAsset(spec.url),
|
||||
});
|
||||
}
|
||||
|
||||
const tintShaders = new Map();
|
||||
|
||||
/**
|
||||
* The custom shader that paints a route colour where a transit model's
|
||||
* `_TINT` vertex attribute marks it: drawn colour = vertex colour ×
|
||||
* mix(1, colour, _TINT). One shader per colour, shared by every model drawn in
|
||||
* it (Cesium compiles the program once; uniforms are per shader).
|
||||
* @param {Cesium.Color} color
|
||||
* @returns {Cesium.CustomShader}
|
||||
*/
|
||||
export function fleetTintShader(color) {
|
||||
const key = color.toCssHexString();
|
||||
let shader = tintShaders.get(key);
|
||||
if (!shader) {
|
||||
shader = new Cesium.CustomShader({
|
||||
mode: Cesium.CustomShaderMode.MODIFY_MATERIAL,
|
||||
uniforms: {
|
||||
u_routeColor: {
|
||||
type: Cesium.UniformType.VEC3,
|
||||
value: new Cesium.Cartesian3(color.red, color.green, color.blue),
|
||||
},
|
||||
},
|
||||
fragmentShaderText: `
|
||||
void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
|
||||
vec3 route = czm_srgbToLinear(u_routeColor);
|
||||
material.diffuse *= mix(vec3(1.0), route, fsInput.attributes.tint);
|
||||
}
|
||||
`,
|
||||
});
|
||||
tintShaders.set(key, shader);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the world matrix of a fleet model standing at `position` heading
|
||||
* `headingDeg` (clockwise from north) into `result`, stretched by the spec's
|
||||
* beam and height scales relative to its length scale (a Model's own `scale`
|
||||
* carries the length scale, so its minimum pixel size still applies).
|
||||
* @param {Cesium.Cartesian3} position
|
||||
* @param {number} headingDeg
|
||||
* @param {object} spec `fleetModelSpec`.
|
||||
* @param {Cesium.Matrix4} result
|
||||
* @param {Cesium.HeadingPitchRoll} scratchHpr
|
||||
* @returns {Cesium.Matrix4}
|
||||
*/
|
||||
export function fleetModelMatrix(
|
||||
position,
|
||||
headingDeg,
|
||||
spec,
|
||||
result,
|
||||
scratchHpr,
|
||||
) {
|
||||
scratchHpr.heading = Cesium.Math.toRadians(
|
||||
(headingDeg || 0) + FLEET_HEADING_OFFSET_DEG,
|
||||
);
|
||||
scratchHpr.pitch = 0;
|
||||
scratchHpr.roll = 0;
|
||||
Cesium.Transforms.headingPitchRollToFixedFrame(
|
||||
position,
|
||||
scratchHpr,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
undefined,
|
||||
result,
|
||||
);
|
||||
const [sx, sy, sz] = spec.scaleXYZ;
|
||||
if (sx > 0 && (sy !== sx || sz !== sx)) {
|
||||
// Cesium's glTF axis correction puts the model's length on local Y, its
|
||||
// beam on local X and its height on local Z.
|
||||
scratchScale.x = sy / sx;
|
||||
scratchScale.y = 1;
|
||||
scratchScale.z = sz / sx;
|
||||
Cesium.Matrix4.multiplyByScale(result, scratchScale, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const scratchScale = new Cesium.Cartesian3();
|
||||
|
||||
/**
|
||||
* 3D models for one layer's vehicles under the DISPLAY rail's 3D toggle (the
|
||||
* Flights layer's `models3d`/`models3dMode`), on the same rules the aircraft
|
||||
* layers follow: models only below the camera altitude ceiling, for the
|
||||
* nearest `cap` vehicles in the add/keep band, on-screen first. A vehicle's
|
||||
* icon stays the visual until its model is loaded and placed; the layer reads
|
||||
* `modelOwnsVisual` back from each item and hides that icon.
|
||||
*
|
||||
* Each frame the layer passes its vehicles as items:
|
||||
* `{position, headingDeg, spec, tint, pickId, show}`, `position` the point
|
||||
* the model's origin stands on (the waterline, the road or rail top; the
|
||||
* spec's `elevationM` is added here), `tint` a Cesium.Color for a tinted
|
||||
* model's route colour (or null), `show` false to keep it hidden.
|
||||
* @param {object} options
|
||||
* @param {object} options.viewer Cesium viewer.
|
||||
* @param {() => object|null} [options.display] `{models3d, models3dMode}`.
|
||||
* @param {object} options.policy `{altitudeCeilingM, minimumPixelSize,
|
||||
* cap, capAll, addM, addAllM, keepM, keepAllM, intervalMs}`.
|
||||
* @param {(url: string) => string} [options.resolveAsset]
|
||||
* @param {(spec: object, options: object) => Promise<object>} [options.createModel]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createFleetModelLayer({
|
||||
viewer,
|
||||
display = () => null,
|
||||
policy,
|
||||
resolveAsset = (url) => url,
|
||||
createModel = (spec, options) =>
|
||||
createFleetModel(spec, options, { resolveAsset }),
|
||||
}) {
|
||||
const collection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(collection);
|
||||
/** @type {Map<string, object>} id -> admitted model */
|
||||
const models = new Map();
|
||||
/** @type {Map<string, number>} id -> generation of an in-flight load */
|
||||
const pending = new Map();
|
||||
let generation = 0;
|
||||
let destroyed = false;
|
||||
let lastEligibilityAt = -Infinity;
|
||||
let eligible = new Set();
|
||||
let settling = 0;
|
||||
const scratchHpr = new Cesium.HeadingPitchRoll();
|
||||
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
|
||||
const scratchCarto = new Cesium.Cartographic();
|
||||
const scratchPosition = new Cesium.Cartesian3();
|
||||
|
||||
function release(id) {
|
||||
pending.delete(id);
|
||||
const model = models.get(id);
|
||||
if (!model) return;
|
||||
models.delete(id);
|
||||
if (!collection.isDestroyed()) collection.remove(model);
|
||||
}
|
||||
|
||||
function releaseAll() {
|
||||
for (const id of [...models.keys()]) release(id);
|
||||
pending.clear();
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
function preferences() {
|
||||
return display?.() || null;
|
||||
}
|
||||
|
||||
function regime(prefs) {
|
||||
if (!prefs?.models3d || !fleetModelSet()) return false;
|
||||
const height = viewer.camera?.positionCartographic?.height ?? Infinity;
|
||||
return height < policy.altitudeCeilingM;
|
||||
}
|
||||
|
||||
function shaderFor(item) {
|
||||
return item.spec.tinted && item.tint
|
||||
? fleetTintShader(item.tint)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function ensure(id, item, cap) {
|
||||
const key = item.spec.key;
|
||||
const current = models.get(id);
|
||||
if (current && current._gevSpecKey === key) return;
|
||||
if (current) release(id);
|
||||
if (pending.has(id) || models.size + pending.size >= cap) return;
|
||||
const loadGeneration = generation;
|
||||
pending.set(id, loadGeneration);
|
||||
let model;
|
||||
try {
|
||||
model = await createModel(item.spec, {
|
||||
asynchronous: false,
|
||||
minimumPixelSize: policy.minimumPixelSize,
|
||||
scale: item.spec.scale,
|
||||
customShader: shaderFor(item),
|
||||
id: item.pickId ?? id,
|
||||
});
|
||||
} catch {
|
||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||
return;
|
||||
}
|
||||
const stale =
|
||||
destroyed ||
|
||||
pending.get(id) !== loadGeneration ||
|
||||
generation !== loadGeneration ||
|
||||
!eligible.has(id) ||
|
||||
models.has(id);
|
||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||
if (stale) {
|
||||
model.destroy?.();
|
||||
return;
|
||||
}
|
||||
model.id = item.pickId ?? id;
|
||||
model._gevSpecKey = key;
|
||||
model._gevTint = item.spec.tinted ? item.tint : null;
|
||||
// Admitted, not yet the visual: placement turns it on.
|
||||
model.show = false;
|
||||
collection.add(model);
|
||||
models.set(id, model);
|
||||
}
|
||||
|
||||
function refreshEligibility(items, prefs) {
|
||||
const all = prefs.models3dMode === 'all';
|
||||
const cap = all ? policy.capAll : policy.cap;
|
||||
const addM = all ? policy.addAllM : policy.addM;
|
||||
const keepM = all ? policy.keepAllM : policy.keepM;
|
||||
const camera = viewer.camera;
|
||||
const cameraPosition = camera.positionWC;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
cameraPosition,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
const candidates = [];
|
||||
for (const [id, item] of items) {
|
||||
if (!item.position || item.show === false || !item.spec) continue;
|
||||
const distanceSq = Cesium.Cartesian3.distanceSquared(
|
||||
cameraPosition,
|
||||
item.position,
|
||||
);
|
||||
if (distanceSq > keepM * keepM) continue;
|
||||
Cesium.Cartesian3.clone(item.position, scratchSphere.center);
|
||||
candidates.push([
|
||||
id,
|
||||
distanceSq,
|
||||
cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE,
|
||||
]);
|
||||
}
|
||||
candidates.sort((a, b) => a[1] - b[1]);
|
||||
eligible = selectModelEligible(candidates, {
|
||||
cap,
|
||||
addDistSq: addM * addM,
|
||||
isModeled: (id) => models.has(id),
|
||||
});
|
||||
for (const id of [...models.keys(), ...pending.keys()])
|
||||
if (!eligible.has(id)) release(id);
|
||||
for (const id of eligible) void ensure(id, items.get(id), cap);
|
||||
}
|
||||
|
||||
/** Where the model's origin stands: the item's point raised by the spec's elevation. */
|
||||
function placement(item) {
|
||||
const lift = item.spec.elevationM || 0;
|
||||
if (!lift) return item.position;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
item.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scratchCarto,
|
||||
);
|
||||
if (!carto) return item.position;
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height + lift,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scratchPosition,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Ids whose model admission is current. */
|
||||
get eligible() {
|
||||
return eligible;
|
||||
},
|
||||
|
||||
/**
|
||||
* Per-frame pass: re-derive eligibility at the policy's cadence, then
|
||||
* place every admitted model and hand each item's visual to its model
|
||||
* only once the model is ready and placed (`item.modelOwnsVisual`).
|
||||
* @param {Map<string, object>} items
|
||||
* @param {number} nowMs
|
||||
* @returns {boolean} Whether any model is drawn.
|
||||
*/
|
||||
frame(items, nowMs) {
|
||||
if (destroyed) return false;
|
||||
const prefs = preferences();
|
||||
if (!regime(prefs)) {
|
||||
settling = 0;
|
||||
if (models.size || pending.size) releaseAll();
|
||||
eligible = new Set();
|
||||
for (const item of items.values()) item.modelOwnsVisual = false;
|
||||
return false;
|
||||
}
|
||||
if (nowMs - lastEligibilityAt >= policy.intervalMs) {
|
||||
lastEligibilityAt = nowMs;
|
||||
refreshEligibility(items, prefs);
|
||||
}
|
||||
const occluder = horizonOccluder(viewer.camera);
|
||||
let drawn = false;
|
||||
settling = pending.size;
|
||||
for (const [id, item] of items) {
|
||||
const model = models.get(id);
|
||||
item.modelOwnsVisual = false;
|
||||
if (!model) continue;
|
||||
if (
|
||||
!item.position ||
|
||||
item.show === false ||
|
||||
!occluder.isPointVisible(item.position)
|
||||
) {
|
||||
model.show = false;
|
||||
continue;
|
||||
}
|
||||
if (item.spec.key !== model._gevSpecKey) {
|
||||
// A new type or size: the next eligibility pass loads it.
|
||||
model.show = false;
|
||||
lastEligibilityAt = -Infinity;
|
||||
continue;
|
||||
}
|
||||
const tint = item.spec.tinted ? item.tint : null;
|
||||
if (tint && !Cesium.Color.equals(tint, model._gevTint)) {
|
||||
model.customShader = fleetTintShader(tint);
|
||||
model._gevTint = tint;
|
||||
}
|
||||
fleetModelMatrix(
|
||||
placement(item),
|
||||
item.headingDeg,
|
||||
item.spec,
|
||||
model.modelMatrix,
|
||||
scratchHpr,
|
||||
);
|
||||
if (!model.ready) {
|
||||
model.show = false;
|
||||
settling += 1;
|
||||
continue;
|
||||
}
|
||||
model.show = true;
|
||||
item.modelOwnsVisual = true;
|
||||
drawn = true;
|
||||
}
|
||||
for (const id of [...models.keys()]) if (!items.has(id)) release(id);
|
||||
return drawn;
|
||||
},
|
||||
|
||||
/**
|
||||
* Models loading or not yet ready after the last frame: the layer keeps
|
||||
* frames coming while any are, so each one takes over as soon as it can.
|
||||
*/
|
||||
get settling() {
|
||||
return settling;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether models are drawn now: 3D on, the set loaded and the camera
|
||||
* below the altitude ceiling. A layer skips building items while not.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
active() {
|
||||
return !destroyed && regime(preferences());
|
||||
},
|
||||
|
||||
/** The model drawing `id`, if one is admitted. */
|
||||
modelFor(id) {
|
||||
return models.get(id) || null;
|
||||
},
|
||||
|
||||
/** Show or hide every model (the layer's own visibility). */
|
||||
setVisible(show) {
|
||||
if (!collection.isDestroyed()) collection.show = show;
|
||||
},
|
||||
|
||||
/** Drop every model (3D off, layer disabled). */
|
||||
clear: releaseAll,
|
||||
|
||||
/** Number of admitted models (tests and diagnostics). */
|
||||
get size() {
|
||||
return models.size;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
releaseAll();
|
||||
if (!viewer.isDestroyed?.() && !collection.isDestroyed())
|
||||
viewer.scene.primitives.remove(collection);
|
||||
},
|
||||
};
|
||||
}
|
||||
+135
-773
File diff suppressed because it is too large.
Load diff
@@ -1,74 +1,15 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Model-space visual centres measured from the shipped GLBs' scene-space AABBs.
|
||||
* Model-space anchors on the aircraft layers' fleet models: where brackets
|
||||
* and readouts sit (a model's visual centre) and where a tracked trail
|
||||
* attaches (its aft-belly point, measured per model in fleetTrailAnchors.js
|
||||
* and handed to the layers with each model's spec by fleetModels.js).
|
||||
*
|
||||
* glTF stores these assets Y-up. Cesium's model frame is Z-up, so a glTF
|
||||
* centre `[x, y, z]` becomes `[x, -z, y]` before the layer's ENU modelMatrix
|
||||
* is applied. Values remain in native model units; callers supply the Model's
|
||||
* Values are native model units in raw glTF axes; callers supply the Model's
|
||||
* effective rendered `computedScale` because Cesium does not bake it into
|
||||
* modelMatrix and may raise it above `scale` to satisfy minimumPixelSize.
|
||||
*/
|
||||
const CENTERED_MODEL = Object.freeze([0, 0, 0]);
|
||||
|
||||
export const MODEL_VISUAL_CENTER_NATIVE = Object.freeze({
|
||||
'/models/airplane.glb': CENTERED_MODEL,
|
||||
'/models/jet.glb': CENTERED_MODEL,
|
||||
});
|
||||
|
||||
/**
|
||||
* Aft-belly trail attachment points, measured on each approved model's actual
|
||||
* MESH SURFACE. Every bundled asset is baked to one convention (see
|
||||
* aircraftClass.js): origin at the bbox centre, X = length with the NOSE at
|
||||
* local −X, glTF Y = up (Cesium-local Z here).
|
||||
*
|
||||
* Each value is the point of the hull's CENTRELINE PROFILE (the y = 0 slice of
|
||||
* the mesh) closest to that model's aft-belly AABB corner. Three things follow
|
||||
* from that construction, and each is asserted rather than described in
|
||||
* modelScale.test.mjs:
|
||||
*
|
||||
* ON THE HULL. This is the correction that matters. The previous round put
|
||||
* the anchor ON the AABB corner itself, reasoning that an anchor aft of
|
||||
* every vertex makes non-intersection geometric. It does — but an AABB
|
||||
* corner is EMPTY SPACE, and on these airframes it is a long way from
|
||||
* any of them: 4.80 m out for airplane.glb and 6.14 m for jet.glb in
|
||||
* native units, which at the 450 m framing is a visible gap between the
|
||||
* aircraft and the end of its own trail. The trail must terminate on the
|
||||
* aeroplane, so the anchor is a surface point.
|
||||
*
|
||||
* AFT. The head segment is drawn from a point behind the aircraft to this
|
||||
* anchor, so an anchor near the middle makes the segment enter at the
|
||||
* tail and stop mid-fuselage — a line drawn through the airframe. These
|
||||
* sit at 83–96 % of each model's aft extreme, on the aft-most hull the
|
||||
* centreline has.
|
||||
*
|
||||
* BELOW. Nothing of the aircraft is lower than the anchor at or behind its
|
||||
* own station, so the trail leaves from underneath the way a wake does
|
||||
* rather than out of the roof. On most assets that is a frank belly
|
||||
* point; on jet.glb the aft centreline profile has swept up into the
|
||||
* tail cone by then, so the anchor sits just above the model's vertical
|
||||
* centre (+0.13 native on a 5.63 m half-height) at the exhaust — which
|
||||
* is where a fast jet's wake leaves it anyway.
|
||||
*
|
||||
* Y stays on the centreline — the profile is sliced at y = 0 rather than taking
|
||||
* the nearest surface point in the round — so the attachment cannot drift
|
||||
* screen-left or screen-right as the aircraft turns. (The unconstrained nearest
|
||||
* point would put jet.glb's anchor 1.29 m off-axis.) Values are native model
|
||||
* units; callers scale them by Cesium's effective `computedScale`.
|
||||
*
|
||||
* Locked against the shipped GLBs by modelScale.test.mjs, which re-reads the
|
||||
* POSITION buffers and re-derives every property above — re-measure, never
|
||||
* re-guess, when an asset changes.
|
||||
*/
|
||||
export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
|
||||
'/models/airplane.glb': Object.freeze([24.0879, -2.2163, 0]),
|
||||
'/models/jet.glb': Object.freeze([18.9103, 0.1266, 0]),
|
||||
'/models/bell206.glb': Object.freeze([5.2077, -0.0005, 0]),
|
||||
'/models/c172.glb': Object.freeze([3.6816, -0.2425, 0]),
|
||||
'/models/citation2.glb': Object.freeze([6.8804, -0.6136, 0]),
|
||||
'/models/mq9.glb': Object.freeze([5.1951, -1.1516, 0]),
|
||||
'/models/b789.glb': Object.freeze([30.1007, -3.4814, 0]),
|
||||
'/models/atr72.glb': Object.freeze([12.9785, -1.2935, 0]),
|
||||
});
|
||||
|
||||
/** @constant Cesium's OWN axis correction for a glTF loaded with the
|
||||
* `Model.fromGltfAsync` defaults (upAxis Y, forwardAxis Z), assembled from its
|
||||
@@ -134,27 +75,6 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
|
||||
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the model-local visual centre for an approved bundled GLB.
|
||||
* Assets absent from the table were audited as origin-centred.
|
||||
* @param {string} url Bundled model URL.
|
||||
* @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset.
|
||||
*/
|
||||
export function visualCenterForModel(url) {
|
||||
return MODEL_VISUAL_CENTER_NATIVE[url] || CENTERED_MODEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the model-local aft-belly attachment point for a tracked trail.
|
||||
* Unknown future assets safely fall back to their visual centre until their
|
||||
* geometry is measured and added to the table.
|
||||
* @param {string} url Bundled model URL.
|
||||
* @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset.
|
||||
*/
|
||||
export function trailAnchorForModel(url) {
|
||||
return MODEL_TRAIL_ANCHOR_NATIVE[url] || visualCenterForModel(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a trail head segment should START being drawn: the whole segment, a
|
||||
* shortened piece of it, or nothing at all.
|
||||
@@ -171,7 +91,7 @@ export function trailAnchorForModel(url) {
|
||||
* mistake matters:
|
||||
*
|
||||
* - Testing segment LENGTH against the radius hid real trail. With
|
||||
* `airplane.glb`'s 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m
|
||||
* an airliner model's 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m
|
||||
* aft gives a 33.98 m segment — under the radius, so suppressed — even
|
||||
* though ~23.6 m of it is in open air behind the aircraft. It also made the
|
||||
* whole segment pop in at once on crossing ~58.4 m.
|
||||
|
||||
@@ -45,6 +45,18 @@ import militaryFlightsLayer, {
|
||||
} from './militaryFlights.js';
|
||||
import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js';
|
||||
import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js';
|
||||
import { loadFleetModelSet } from './fleetModels.js';
|
||||
import { aircraftLayerModelSpec } from '../layers/flights/modelSpec.js';
|
||||
import { fetchPublicFile } from '../testSupport/fleetGlb.mjs';
|
||||
|
||||
// The layers draw only once the fleet model set has loaded; load it from disk.
|
||||
// Model creation itself is stubbed per test through Cesium.Model.fromGltfAsync.
|
||||
const _silentInfo = console.info;
|
||||
console.info = () => {};
|
||||
await loadFleetModelSet({ fetchImpl: fetchPublicFile });
|
||||
console.info = _silentInfo;
|
||||
const FLIGHTS_META = Object.freeze({ typeCode: 'A320', klass: 'airliner' });
|
||||
const MILITARY_META = Object.freeze({ typeCode: 'F16', klass: 'fastjet' });
|
||||
|
||||
// Sample altitudes are expressed RELATIVE to the band rather than as absolute
|
||||
// offsets. The thresholds have already been retuned once (1_000_000 → 150_000
|
||||
@@ -201,7 +213,8 @@ const LAYERS = [
|
||||
driveFleetModel: flightsDriveFleetModel,
|
||||
ensureFleetModel: flightsEnsureFleetModel,
|
||||
trackedBillboardColor: flightsTrackedBillboardColor,
|
||||
bellyM: 6.719,
|
||||
/** How far a grounded A320 stands its origin above the ground (scaled wheel clearance). */
|
||||
bellyM: aircraftLayerModelSpec(FLIGHTS_META).bellyM,
|
||||
seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) {
|
||||
const billboard = contactBillboard();
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
@@ -213,7 +226,7 @@ const LAYERS = [
|
||||
tracked,
|
||||
models,
|
||||
modelCollection,
|
||||
meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround },
|
||||
meta: { callsign: 'TEST123 ', altitude: 10_668, ...FLIGHTS_META, onGround },
|
||||
});
|
||||
return billboard;
|
||||
},
|
||||
@@ -226,7 +239,7 @@ const LAYERS = [
|
||||
driveFleetModel: militaryDriveFleetModel,
|
||||
ensureFleetModel: militaryEnsureFleetModel,
|
||||
trackedBillboardColor: militaryTrackedBillboardColor,
|
||||
bellyM: 5.631 * 0.8,
|
||||
bellyM: aircraftLayerModelSpec(MILITARY_META).bellyM,
|
||||
seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) {
|
||||
const billboard = contactBillboard();
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
@@ -238,7 +251,7 @@ const LAYERS = [
|
||||
tracked,
|
||||
models,
|
||||
modelCollection,
|
||||
meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet', onGround },
|
||||
meta: { callsign: 'TEST123 ', altitudeFt: 35_000, ...MILITARY_META, onGround },
|
||||
});
|
||||
return billboard;
|
||||
},
|
||||
@@ -841,8 +854,8 @@ for (const fixture of LAYERS) {
|
||||
Date.now = () => now;
|
||||
let attempts = 0;
|
||||
const requested = [];
|
||||
Cesium.Model.fromGltfAsync = ({ url }) => {
|
||||
attempts++; requested.push(url);
|
||||
Cesium.Model.fromGltfAsync = ({ basePath }) => {
|
||||
attempts++; requested.push(basePath);
|
||||
return Promise.reject(new Error('simulated missing GLB'));
|
||||
};
|
||||
// The give-up path warns once by design; keep the run's output clean.
|
||||
|
||||
@@ -164,10 +164,10 @@ export function createEnrichment({
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (bb)
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
// Hangar fleet: the class's GLB/scale may have changed — resync the
|
||||
// live model, any in-flight load, and the tracked standalone model.
|
||||
parts.rendering._syncModelToClass(icao24);
|
||||
}
|
||||
// The type picks the fleet model and its length — resync the live
|
||||
// model, any in-flight load, and the tracked standalone model.
|
||||
parts.rendering._syncModelToClass(icao24);
|
||||
}
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity)
|
||||
parts.tracking._updateTrackedLabelModel(icao24);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { PLANE_MODEL_URL } from './policy.js';
|
||||
import { loadFleetModelSet } from '../../data/fleetModels.js';
|
||||
|
||||
export function createLifecycle({
|
||||
flightState,
|
||||
@@ -53,29 +53,19 @@ export function createLifecycle({
|
||||
registerSpriteCollection('flights', flightState._billboardCollection);
|
||||
flightState._modelCollection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(flightState._modelCollection);
|
||||
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
|
||||
// (keeps the retained instance referenced; never rendered). Captured against this epoch so a
|
||||
// destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle.
|
||||
if (!flightState._preloadModel) {
|
||||
// Every fleet model loads once, up front, for every layer that draws
|
||||
// them (fleetModels.js), so no model is fetched while the session runs.
|
||||
// Captured against this epoch so a destroy/re-init mid-load doesn't flip
|
||||
// the flag for a torn-down lifecycle.
|
||||
{
|
||||
const epoch = flightState._modelEpoch;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(PLANE_MODEL_URL),
|
||||
asynchronous: false,
|
||||
})
|
||||
.then((m) => {
|
||||
if (epoch === flightState._modelEpoch) {
|
||||
flightState._preloadModel = m;
|
||||
loadFleetModelSet({ resolveAsset })
|
||||
.then(() => {
|
||||
if (epoch === flightState._modelEpoch)
|
||||
flightState._planeModelLoaded = true;
|
||||
} else {
|
||||
try {
|
||||
m.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* tracked plane just stays a billboard a beat longer */
|
||||
/* aircraft stay billboards */
|
||||
});
|
||||
}
|
||||
flightState._billboards = new Map();
|
||||
@@ -296,14 +286,6 @@ export function createLifecycle({
|
||||
flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
|
||||
flightState._modelPending.clear();
|
||||
flightState._modelGen.clear();
|
||||
if (flightState._preloadModel) {
|
||||
try {
|
||||
flightState._preloadModel.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
flightState._preloadModel = null;
|
||||
}
|
||||
flightState._planeModelLoaded = false;
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
|
||||
@@ -1,52 +1,58 @@
|
||||
import {
|
||||
CLASS_MODEL_REAL,
|
||||
CLASS_MODEL_URL,
|
||||
CLASS_SCALE_3D,
|
||||
} from '../../data/aircraftClass.js';
|
||||
import {
|
||||
trailAnchorForModel,
|
||||
visualCenterForModel,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
MODEL_BELLY_OFFSET_NATIVE,
|
||||
MODEL_COLOR_BLEND_AMOUNT,
|
||||
MODEL_NATIVE_RADIUS_M,
|
||||
MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
} from './policy.js';
|
||||
import { aircraftModelSpec, fleetModelSet } from '../../data/fleetModels.js';
|
||||
import { MODEL_COLOR_BLEND_AMOUNT } from './policy.js';
|
||||
|
||||
/**
|
||||
* The glTF asset, scale and measured geometry a civil aircraft class renders
|
||||
* with. Real per-class GLBs are baked to real-world meters (scale 1, measured
|
||||
* belly and radius); classes without one share airplane.glb at the class's
|
||||
* 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw
|
||||
* the same model for the same class.
|
||||
* @param {string} klass Aircraft class from `classifyAircraft`.
|
||||
* @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number,
|
||||
* blendAmount:number, visualCenterNative:object, trailAnchorNative:object}}
|
||||
* What an aircraft draws with before the fleet model set has loaded: no
|
||||
* model (its billboard stays the visual), with neutral geometry for any
|
||||
* reader that asks.
|
||||
*/
|
||||
export function civilAircraftModelSpec(klass) {
|
||||
const real = CLASS_MODEL_REAL[klass];
|
||||
if (real) {
|
||||
return {
|
||||
url: real.url,
|
||||
scale: 1,
|
||||
nativeRadiusM: real.radiusM,
|
||||
bellyM: real.bellyM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(real.url),
|
||||
trailAnchorNative: trailAnchorForModel(real.url),
|
||||
};
|
||||
}
|
||||
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
|
||||
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
|
||||
return {
|
||||
url,
|
||||
scale,
|
||||
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
|
||||
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
|
||||
export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
|
||||
model: null,
|
||||
url: null,
|
||||
scale: 1,
|
||||
scaleXYZ: Object.freeze([1, 1, 1]),
|
||||
length: 0,
|
||||
nativeRadiusM: 20,
|
||||
clearanceM: 0,
|
||||
bellyM: 0,
|
||||
elevationM: 0,
|
||||
tinted: false,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: Object.freeze([0, 0, 0]),
|
||||
trailAnchorNative: Object.freeze([0, 0, 0]),
|
||||
key: 'unloaded',
|
||||
});
|
||||
|
||||
/**
|
||||
* The fleet model, life-size scale and measured geometry an aircraft draws
|
||||
* with, chosen by its ICAO type and emitter category (`fleetModels.js`).
|
||||
* Shared by the Flights, Military Flights and Local ADS-B layers so all three
|
||||
* draw the same model for the same aircraft. `bellyM` is the model's scaled
|
||||
* clearance: how far a grounded aircraft's origin stands above the ground.
|
||||
* @param {{typeCode?: string|null, type?: string|null,
|
||||
* category?: string|number|null}|null|undefined} aircraft
|
||||
* @param {object|null} [set] The loaded fleet model set.
|
||||
* @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before
|
||||
* the set has loaded.
|
||||
*/
|
||||
export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
|
||||
if (!set) return UNLOADED_AIRCRAFT_MODEL_SPEC;
|
||||
const spec = aircraftModelSpec(set, {
|
||||
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
|
||||
category: aircraft?.category ?? null,
|
||||
});
|
||||
return Object.freeze({
|
||||
...spec,
|
||||
bellyM: spec.clearanceM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(url),
|
||||
trailAnchorNative: trailAnchorForModel(url),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key of an aircraft's model spec: what selects its model.
|
||||
* @param {object|null|undefined} aircraft
|
||||
* @returns {string}
|
||||
*/
|
||||
export function aircraftModelSpecCacheKey(aircraft) {
|
||||
return `${aircraft?.typeCode ?? aircraft?.type ?? ''}|${aircraft?.category ?? ''}`;
|
||||
}
|
||||
@@ -431,7 +431,7 @@ export function createMotion({
|
||||
const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position);
|
||||
if (parts.rendering._modelOwnsVisual(icao24)) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState.records.data.get(icao24)?.klass,
|
||||
flightState.records.data.get(icao24),
|
||||
);
|
||||
const scale = trackedModelScaleForPixelCap({
|
||||
baseScale: spec.scale,
|
||||
@@ -614,7 +614,7 @@ export function createMotion({
|
||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
return modelVisualAnchor(
|
||||
flightState._trackedModel.modelMatrix,
|
||||
@@ -659,7 +659,7 @@ export function createMotion({
|
||||
)
|
||||
return 0;
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
const scale = Number.isFinite(flightState._trackedModel.computedScale)
|
||||
? flightState._trackedModel.computedScale
|
||||
@@ -673,7 +673,7 @@ export function createMotion({
|
||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
// Through the model's OWN render chain (modelVisualAnchor's hand-rolled
|
||||
// half-correction put this offset on the lateral axis — see
|
||||
|
||||
@@ -39,8 +39,6 @@ export const GROUND_SCALE = 0.8;
|
||||
// the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each
|
||||
// backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet).
|
||||
|
||||
export const PLANE_MODEL_URL = '/models/airplane.glb';
|
||||
|
||||
export const MODEL_ALT_CEIL_M = 800000;
|
||||
// m: below this camera altitude, draw 3D models (raised so it's easy to trigger)
|
||||
|
||||
@@ -55,10 +53,6 @@ export const TRACKED_MODEL_MIN_PX = 40;
|
||||
export const TRACKED_MODEL_MAX_PX = 200;
|
||||
// owner-selected close-range tracked-target feel
|
||||
|
||||
export const MODEL_NATIVE_RADIUS_M = 34.41;
|
||||
|
||||
export const MODEL_SCALE = 1;
|
||||
// airplane.glb is transform-applied and baked to real-world meters
|
||||
// Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost.
|
||||
|
||||
export const MODEL_MAX = 150;
|
||||
@@ -90,21 +84,12 @@ export const MODEL_ALL_KEEP_M = 450000;
|
||||
// all: KEEP modeled planes out to 450 km
|
||||
|
||||
export const MODEL_HEADING_OFFSET_DEG = 180;
|
||||
// airplane.glb nose is opposite Cesium heading-0
|
||||
// every fleet model is nose -X: opposite Cesium heading 0
|
||||
// Owner launch-polish direction: models should read as clean light silhouettes,
|
||||
// with only a weak diffuse contribution from the existing approved textures.
|
||||
// with only a weak diffuse contribution from the models' own colours.
|
||||
|
||||
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
|
||||
|
||||
// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its
|
||||
// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as
|
||||
// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m
|
||||
// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled
|
||||
// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB
|
||||
// by modelScale.test.mjs.
|
||||
|
||||
export const MODEL_BELLY_OFFSET_NATIVE = 6.719;
|
||||
|
||||
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
|
||||
|
||||
export const COCKPIT_CONTACT_SIZE_PX = 6;
|
||||
@@ -203,6 +188,24 @@ export const LANDED_SPEED_MAX_MPS = 23;
|
||||
|
||||
export const FLEET_DR_INTERVAL_MS = 80;
|
||||
|
||||
/**
|
||||
* The icon-to-model rules the aircraft layers follow, as the Vessels, Transit
|
||||
* and Rail layers take them (`createFleetModelLayer`): the same altitude
|
||||
* ceiling, minimum pixel size, proximity/all caps, add/keep radii and
|
||||
* eligibility cadence, so every layer turns icons into models at one distance.
|
||||
*/
|
||||
export const FLEET_MODEL_POLICY = Object.freeze({
|
||||
altitudeCeilingM: MODEL_ALT_CEIL_M,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
cap: MODEL_MAX,
|
||||
capAll: MODEL_MAX_ALL,
|
||||
addM: MODEL_PROX_ADD_M,
|
||||
addAllM: MODEL_ALL_ADD_M,
|
||||
keepM: MODEL_PROX_KEEP_M,
|
||||
keepAllM: MODEL_ALL_KEEP_M,
|
||||
intervalMs: FLEET_DR_INTERVAL_MS,
|
||||
});
|
||||
|
||||
/** @constant {number} Max ms between rotation passes while the camera is idle. */
|
||||
|
||||
export const ROTATION_REFRESH_MS = 1000;
|
||||
|
||||
@@ -480,9 +480,7 @@ export function createQueries({
|
||||
// and rises only as the coarse ground-floor cell warms. Zero extra sampling and
|
||||
// no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep
|
||||
// `bb.position` — sprite and bracket are co-located there, so association holds.
|
||||
const spec = modelOwnsVisual
|
||||
? parts.rendering._modelSpec(info?.klass)
|
||||
: null;
|
||||
const spec = modelOwnsVisual ? parts.rendering._modelSpec(info) : null;
|
||||
const pos = isTracked
|
||||
? parts.motion._trackedVisualCached() || bb.position
|
||||
: modelOwnsVisual
|
||||
|
||||
@@ -2,7 +2,11 @@ import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||
import {
|
||||
aircraftLayerModelSpec,
|
||||
aircraftModelSpecCacheKey,
|
||||
} from './modelSpec.js';
|
||||
import { createFleetModel } from '../../data/fleetModelInstances.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
@@ -115,11 +119,14 @@ export function createRendering({
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function _modelSpec(klass) {
|
||||
const cached = flightState._specCache.get(klass);
|
||||
/** The fleet model an aircraft draws with, by its type and emitter category. */
|
||||
function _modelSpec(meta) {
|
||||
const key = aircraftModelSpecCacheKey(meta);
|
||||
const cached = flightState._specCache.get(key);
|
||||
if (cached) return cached;
|
||||
const spec = civilAircraftModelSpec(klass);
|
||||
flightState._specCache.set(klass, spec);
|
||||
const spec = aircraftLayerModelSpec(meta);
|
||||
// Not cached before the model set loads: the answer changes once it has.
|
||||
if (spec.model) flightState._specCache.set(key, spec);
|
||||
return spec;
|
||||
}
|
||||
|
||||
@@ -309,7 +316,7 @@ export function createRendering({
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
flightState._scratchGroundCarto,
|
||||
);
|
||||
carto.height = h + _modelSpec(meta.klass).bellyM;
|
||||
carto.height = h + _modelSpec(meta).bellyM;
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
@@ -416,16 +423,13 @@ export function createRendering({
|
||||
return !!model && model.ready === true && 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). */
|
||||
/** Spec identity for a LOADED model: the model and its scale together (one
|
||||
* model draws several types, each at its own length). */
|
||||
|
||||
const _specKeyFor = (klass) => {
|
||||
const spec = _modelSpec(klass);
|
||||
return `${spec.url}@${spec.scale}`;
|
||||
};
|
||||
const _specKeyFor = (meta) => _modelSpec(meta).key;
|
||||
|
||||
/** 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,
|
||||
/** Type-change model sync (enrichment AND poll-path type/category updates): when the
|
||||
* aircraft's live model or in-flight load no longer matches its type'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
|
||||
@@ -433,7 +437,7 @@ export function createRendering({
|
||||
* the same rule (its billboard entity is always the fallback visual). */
|
||||
|
||||
function _syncModelToClass(icao24) {
|
||||
const key = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
||||
const key = _specKeyFor(flightState.records.data.get(icao24));
|
||||
const current = flightState._models.get(icao24);
|
||||
if (
|
||||
(current && current._gevSpecKey !== key) ||
|
||||
@@ -478,6 +482,11 @@ export function createRendering({
|
||||
flightState._irReloadQueue = null;
|
||||
}
|
||||
|
||||
/** A Cesium Model of `spec` from the fleet set loaded up front (no fetch mid-session). */
|
||||
function _createModel(spec, options) {
|
||||
return createFleetModel(spec, options, { resolveAsset });
|
||||
}
|
||||
|
||||
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
|
||||
|
||||
async function _ensureModel(icao24) {
|
||||
@@ -497,6 +506,8 @@ export function createRendering({
|
||||
_modelCap()
|
||||
)
|
||||
return;
|
||||
// Until the fleet model set has loaded the aircraft stays a billboard.
|
||||
if (!_modelSpec(flightState.records.data.get(icao24)).model) 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);
|
||||
@@ -505,12 +516,11 @@ export function createRendering({
|
||||
// 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 specKey = _specKeyFor(flightState.records.data.get(icao24));
|
||||
const loadIrBoost = flightState._irBoost;
|
||||
try {
|
||||
const spec = _modelSpec(flightState.records.data.get(icao24)?.klass);
|
||||
model = await Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(spec.url),
|
||||
const spec = _modelSpec(flightState.records.data.get(icao24));
|
||||
model = await _createModel(spec, {
|
||||
asynchronous: false,
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
@@ -560,7 +570,7 @@ export function createRendering({
|
||||
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 ||
|
||||
_specKeyFor(flightState.records.data.get(icao24)) !== specKey ||
|
||||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
||||
flightState._irBoost !== loadIrBoost;
|
||||
if (stale) {
|
||||
@@ -716,6 +726,9 @@ export function createRendering({
|
||||
return;
|
||||
}
|
||||
if (
|
||||
// Until the fleet model set has loaded the billboard carries it.
|
||||
_modelSpec(flightState.records.data.get(flightState._trackedIcao))
|
||||
.model &&
|
||||
!flightState._trackedModel &&
|
||||
!flightState._trackedModelLoading &&
|
||||
parts.tracking._trackedModelLoadAllowed()
|
||||
@@ -723,14 +736,13 @@ export function createRendering({
|
||||
flightState._trackedModelLoading = true;
|
||||
const gen = flightState._trackedModelGen;
|
||||
const trackedSpec = _modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
const trackedKey = _specKeyFor(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
const trackedIrBoost = flightState._irBoost;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(trackedSpec.url),
|
||||
_createModel(trackedSpec, {
|
||||
asynchronous: false,
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||
@@ -766,7 +778,7 @@ export function createRendering({
|
||||
// _trackedModel was still null): drop the stale asset; driver reloads.
|
||||
if (
|
||||
_specKeyFor(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
) !== trackedKey ||
|
||||
flightState._irBoost !== trackedIrBoost
|
||||
) {
|
||||
@@ -840,7 +852,7 @@ export function createRendering({
|
||||
_applyTrackedModelFade(flightState._trackedModel);
|
||||
if (!flightState._trackedModel.ready) return;
|
||||
const spec = _modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
|
||||
baseScale: spec.scale,
|
||||
|
||||
@@ -198,9 +198,14 @@ export function createFlightSnapshotRenderer({
|
||||
) {
|
||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
// Poll-path class change (category updates): same model resync rule
|
||||
// as the enrichment path — the class's GLB/scale may have changed.
|
||||
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
|
||||
// Poll-path type or category change: same model resync rule as the
|
||||
// enrichment path — the fleet model or its scale may have changed.
|
||||
if (
|
||||
prevMeta?.klass !== meta.klass ||
|
||||
prevMeta?.category !== meta.category ||
|
||||
prevMeta?.typeCode !== meta.typeCode
|
||||
)
|
||||
rendering._syncModelToClass(icao24);
|
||||
} else {
|
||||
// Start where the fleet pass will draw it: the fleet renders behind
|
||||
// real time, so a fresh contact's displayed position is its fix
|
||||
|
||||
@@ -20,17 +20,13 @@ export function createFlightState({ source, services }) {
|
||||
* layer); see followsClock and displayDelayMs in policy.js. */
|
||||
flightState.timeline = null;
|
||||
|
||||
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
||||
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
||||
* they render at scale 1 with their own measured belly lift and bounding
|
||||
* radius. Every other class keeps the shared-airplane.glb formula
|
||||
* (MODEL_SCALE × CLASS_SCALE_3D). nativeRadiusM is PER SCALE UNIT — pixel-cap
|
||||
* math multiplies it by `scale`, so world radius = nativeRadiusM × scale in
|
||||
* both branches. The code-side MIX tint dominates every existing asset so
|
||||
* class silhouettes stay light without modifying third-party GLBs/textures. */
|
||||
/* Specs are static per class, and the detection weld now asks for one per
|
||||
* MODELED contact per frame (up to the fleet cap) on top of the 12 Hz fleet
|
||||
* pass — so this is memoized, as militaryFlights.js already does. */
|
||||
/** Model spec per aircraft type and emitter category (aircraftLayerModelSpec in
|
||||
* flights/modelSpec.js): the procedural fleet model that draws the type, life size,
|
||||
* with its measured clearance, bounding radius and trail anchor. nativeRadiusM is PER
|
||||
* SCALE UNIT — pixel-cap math multiplies it by `scale`. The code-side MIX tint dominates
|
||||
* every model so silhouettes stay light (and this layer's own colour). Memoized: the
|
||||
* detection weld asks for one per MODELED contact per frame on top of the 12 Hz fleet
|
||||
* pass. */
|
||||
|
||||
flightState._specCache = new Map();
|
||||
|
||||
@@ -79,16 +75,11 @@ export function createFlightState({ source, services }) {
|
||||
// throttle the "more planes in view than the cap" console notice
|
||||
// The tracked entity's billboard goes transparent (rather than hidden) once the model takes
|
||||
// over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its
|
||||
// alpha after the GLB is preloaded so the model is ready to render the instant the billboard
|
||||
// fades — no gap, no double-image. Preloaded once at init; the instance is retained (not
|
||||
// destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation.
|
||||
// alpha after the fleet model set is loaded so the model is ready to render the instant the
|
||||
// billboard fades — no gap, no double-image. The set loads once at init (fleetModels.js).
|
||||
|
||||
flightState._planeModelLoaded = false;
|
||||
|
||||
/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */
|
||||
|
||||
flightState._preloadModel = null;
|
||||
|
||||
flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0);
|
||||
|
||||
flightState._scratchModelMtx = new Cesium.Matrix4();
|
||||
|
||||
@@ -254,6 +254,9 @@ export function createLocalAdsbLayer({
|
||||
);
|
||||
marker.evidence = evidence;
|
||||
marker.klass = klass;
|
||||
// What picks its fleet model: the adsbdb type, else the emitter category.
|
||||
marker.typeCode = enrichment.get(marker.record.icao)?.typeCode || null;
|
||||
marker.category = marker.record.category || null;
|
||||
const billboard = marker.entity?.billboard;
|
||||
if (!billboard) return;
|
||||
if (marker.iconKind !== klass) {
|
||||
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
localAdsbReceiverLine,
|
||||
localAdsbStatus,
|
||||
} from './index.js';
|
||||
import { loadFleetModelSet } from '../../data/fleetModels.js';
|
||||
import { aircraftLayerModelSpec } from '../flights/modelSpec.js';
|
||||
import { fetchPublicFile } from '../../testSupport/fleetGlb.mjs';
|
||||
|
||||
// The layer draws models only once the fleet model set has loaded; load it from disk.
|
||||
const realInfo = console.info;
|
||||
console.info = () => {};
|
||||
await loadFleetModelSet({ fetchImpl: fetchPublicFile });
|
||||
console.info = realInfo;
|
||||
|
||||
function fakeReceiver(initial = {}) {
|
||||
const state = {
|
||||
@@ -529,12 +538,14 @@ test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t
|
||||
await layer.update();
|
||||
assert.equal(loads.length, 1);
|
||||
const [model] = loads;
|
||||
const spec = aircraftLayerModelSpec({ category: 'A7' });
|
||||
assert.equal(
|
||||
model.url,
|
||||
'/models/bell206.glb',
|
||||
'the Flights helicopter model',
|
||||
model.basePath,
|
||||
'/models/fleet/helicopters/generic_heli.glb',
|
||||
'the Flights rotorcraft model',
|
||||
);
|
||||
assert.equal(model.scale, 1);
|
||||
assert.ok(model.gltf instanceof Uint8Array, 'built from the loaded set');
|
||||
assert.equal(model.scale, spec.scale, 'drawn life size');
|
||||
assert.equal(model.id, 'local-adsb:a0b702');
|
||||
assert.ok(
|
||||
Cesium.Color.equals(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from '../flights/modelSpec.js';
|
||||
import { aircraftLayerModelSpec } from '../flights/modelSpec.js';
|
||||
import { createFleetModel } from '../../data/fleetModelInstances.js';
|
||||
import { loadFleetModelSet } from '../../data/fleetModels.js';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
MODEL_ALL_ADD_M,
|
||||
@@ -16,19 +18,24 @@ import {
|
||||
} from '../flights/policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
const specKey = (spec) => `${spec.url}@${spec.scale}`;
|
||||
const specOf = (marker) =>
|
||||
aircraftLayerModelSpec({
|
||||
typeCode: marker?.typeCode,
|
||||
category: marker?.category,
|
||||
});
|
||||
|
||||
/**
|
||||
* 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle.
|
||||
*
|
||||
* Mirrors the public Flights fleet: models only below the altitude ceiling,
|
||||
* the same proximity/all caps and add/keep radii, the same visible-first
|
||||
* eligibility (`selectModelEligible`), the same per-class asset and scale
|
||||
* (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here
|
||||
* eligibility (`selectModelEligible`), the same fleet model per type, life
|
||||
* size (`aircraftLayerModelSpec`) and the same tint path (a MIX colour blend, here
|
||||
* magenta, as Flights tints military amber). Each model owns its matrix. A
|
||||
* billboard stays the visual until its model is loaded AND placed; a grounded
|
||||
* aircraft's model rides the one-shot ground snap plus the class belly
|
||||
* offset, and without a resolved ground it stays a billboard.
|
||||
* offset, and without a resolved ground it stays a billboard. Every fleet
|
||||
* model loads once, up front, when the layer starts (`loadFleetModelSet`).
|
||||
* @param {object} options
|
||||
* @param {object} options.viewer Cesium viewer.
|
||||
* @param {Cesium.Color} options.color Model tint.
|
||||
@@ -44,6 +51,9 @@ export function createLocalAdsbModels({
|
||||
groundSnap = null,
|
||||
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
|
||||
}) {
|
||||
void loadFleetModelSet({ resolveAsset }).catch(() => {
|
||||
/* aircraft stay billboards */
|
||||
});
|
||||
const collection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(collection);
|
||||
/** @type {Map<string, object>} id -> admitted model */
|
||||
@@ -75,8 +85,9 @@ export function createLocalAdsbModels({
|
||||
}
|
||||
|
||||
async function ensure(id, marker, cap) {
|
||||
const spec = civilAircraftModelSpec(marker.klass);
|
||||
const key = specKey(spec);
|
||||
const spec = specOf(marker);
|
||||
if (!spec.model) return;
|
||||
const key = spec.key;
|
||||
const current = models.get(id);
|
||||
if (current && current._gevSpecKey === key) return;
|
||||
if (current) release(id);
|
||||
@@ -85,17 +96,20 @@ export function createLocalAdsbModels({
|
||||
pending.set(id, loadGeneration);
|
||||
let model;
|
||||
try {
|
||||
model = await loadModel({
|
||||
url: resolveAsset(spec.url),
|
||||
asynchronous: false,
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color,
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
colorBlendAmount: spec.blendAmount,
|
||||
id,
|
||||
});
|
||||
model = await createFleetModel(
|
||||
spec,
|
||||
{
|
||||
asynchronous: false,
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color,
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
colorBlendAmount: spec.blendAmount,
|
||||
id,
|
||||
},
|
||||
{ resolveAsset, load: loadModel },
|
||||
);
|
||||
} catch {
|
||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||
return;
|
||||
@@ -106,7 +120,7 @@ export function createLocalAdsbModels({
|
||||
generation !== loadGeneration ||
|
||||
!eligible.has(id) ||
|
||||
models.has(id) ||
|
||||
specKey(civilAircraftModelSpec(marker.klass)) !== key;
|
||||
specOf(marker).key !== key;
|
||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||
if (stale) {
|
||||
model.destroy?.();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { JET_MODEL_URL } from './policy.js';
|
||||
import { loadFleetModelSet } from '../../data/fleetModels.js';
|
||||
|
||||
export function createLifecycle({
|
||||
flightState,
|
||||
@@ -47,29 +47,19 @@ export function createLifecycle({
|
||||
registerSpriteCollection('military', flightState._billboardCollection);
|
||||
flightState._modelCollection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(flightState._modelCollection);
|
||||
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
|
||||
// (keeps the retained instance referenced; never rendered). Captured against this epoch so a
|
||||
// destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle.
|
||||
if (!flightState._preloadModel) {
|
||||
// Every fleet model loads once, up front, for every layer that draws
|
||||
// them (fleetModels.js), so no model is fetched while the session runs.
|
||||
// Captured against this epoch so a destroy/re-init mid-load doesn't flip
|
||||
// the flag for a torn-down lifecycle.
|
||||
{
|
||||
const epoch = flightState._modelEpoch;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(JET_MODEL_URL),
|
||||
asynchronous: false,
|
||||
})
|
||||
.then((m) => {
|
||||
if (epoch === flightState._modelEpoch) {
|
||||
flightState._preloadModel = m;
|
||||
loadFleetModelSet({ resolveAsset })
|
||||
.then(() => {
|
||||
if (epoch === flightState._modelEpoch)
|
||||
flightState._planeModelLoaded = true;
|
||||
} else {
|
||||
try {
|
||||
m.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* tracked plane just stays a billboard a beat longer */
|
||||
/* aircraft stay billboards */
|
||||
});
|
||||
}
|
||||
flightState._billboards = new Map();
|
||||
@@ -269,14 +259,6 @@ export function createLifecycle({
|
||||
flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
|
||||
flightState._modelPending.clear();
|
||||
flightState._modelGen.clear();
|
||||
if (flightState._preloadModel) {
|
||||
try {
|
||||
flightState._preloadModel.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
flightState._preloadModel = null;
|
||||
}
|
||||
flightState._planeModelLoaded = false;
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
|
||||
@@ -301,7 +301,7 @@ export function createMotion({
|
||||
const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position);
|
||||
if (parts.rendering._modelOwnsVisual(icao24)) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState.records.data.get(icao24)?.klass,
|
||||
flightState.records.data.get(icao24),
|
||||
);
|
||||
const scale = trackedModelScaleForPixelCap({
|
||||
baseScale: spec.scale,
|
||||
@@ -459,7 +459,7 @@ export function createMotion({
|
||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
return modelVisualAnchor(
|
||||
flightState._trackedModel.modelMatrix,
|
||||
@@ -502,7 +502,7 @@ export function createMotion({
|
||||
)
|
||||
return 0;
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
const scale = Number.isFinite(flightState._trackedModel.computedScale)
|
||||
? flightState._trackedModel.computedScale
|
||||
@@ -519,7 +519,7 @@ export function createMotion({
|
||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
// Through the model's OWN render chain (modelVisualAnchor's hand-rolled
|
||||
// half-correction put this offset on the lateral axis — see
|
||||
|
||||
@@ -58,20 +58,18 @@ export const EMERGENCY_ICON_COLOR =
|
||||
export const GROUND_SCALE = 0.8;
|
||||
|
||||
// --- 3D model rendering (mirrors flights.js) -----------------------------------------
|
||||
// When enabled, military aircraft render as 3D glTF jet models once the camera is below
|
||||
// When enabled, military aircraft render as 3D glTF models once the camera is below
|
||||
// MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is
|
||||
// FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity')
|
||||
// or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion
|
||||
// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer
|
||||
// apart from the commercial flights layer.
|
||||
|
||||
export const JET_MODEL_URL = '/models/jet.glb';
|
||||
// can't tank the frame (no instancing yet). Each aircraft draws the fleet model of its
|
||||
// type, as the flights layer does; the amber tint sets this layer apart.
|
||||
|
||||
export const MODEL_ALT_CEIL_M = 800000;
|
||||
// m: below this camera altitude, draw 3D models (raised so it's easy to trigger)
|
||||
|
||||
export const MODEL_MIN_PX = 24;
|
||||
// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size)
|
||||
// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js)
|
||||
|
||||
export const TRACKED_MODEL_MIN_PX = 40;
|
||||
// keep the glTF silhouette comparable to the selected 2D glyph at handoff
|
||||
@@ -79,17 +77,6 @@ export const TRACKED_MODEL_MIN_PX = 40;
|
||||
export const TRACKED_MODEL_MAX_PX = 200;
|
||||
// owner-selected close-range tracked-target feel
|
||||
|
||||
export const MODEL_NATIVE_RADIUS_M = 29.83;
|
||||
|
||||
// jet.glb is transform-applied at real-world scale — native bounding radius
|
||||
// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching
|
||||
// flights.js world sizes. At the old copied ×24, models rendered
|
||||
// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize
|
||||
// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by
|
||||
// modelScale.test.mjs.
|
||||
|
||||
export const MODEL_SCALE = 1;
|
||||
|
||||
// Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity
|
||||
// and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js).
|
||||
|
||||
@@ -119,33 +106,12 @@ export const MODEL_ALL_KEEP_M = 450000;
|
||||
// all: KEEP modeled planes out to 450 km
|
||||
|
||||
export const MODEL_HEADING_OFFSET_DEG = 180;
|
||||
// every aircraft GLB is exported nose -X in the shared transform-applied convention
|
||||
// Preserve the layer's amber identity while reducing approved texture/livery
|
||||
// contribution to a weak diffuse hint, matching civilian launch presentation.
|
||||
// every fleet model is nose -X: opposite Cesium heading 0
|
||||
// Preserve the layer's amber identity while reducing the models' own colours
|
||||
// to a weak diffuse hint, matching civilian launch presentation.
|
||||
|
||||
export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
|
||||
|
||||
// airplane.glb (the shared 747) constants for this layer's heavy classes. The
|
||||
// asset has its former 24× runtime calibration baked into transform-applied
|
||||
// meter-scale geometry; these values mirror flights.js and are regression-pinned.
|
||||
|
||||
export const PLANE_MODEL_URL = '/models/airplane.glb';
|
||||
|
||||
export const PLANE_MODEL_SCALE = 1;
|
||||
|
||||
export const PLANE_NATIVE_RADIUS_M = 34.41;
|
||||
|
||||
export const PLANE_BELLY_OFFSET_NATIVE = 6.719;
|
||||
|
||||
// Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this
|
||||
// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node
|
||||
// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03).
|
||||
// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model
|
||||
// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to
|
||||
// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs.
|
||||
|
||||
export const MODEL_BELLY_OFFSET_NATIVE = 5.631;
|
||||
|
||||
/** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */
|
||||
|
||||
export const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0);
|
||||
|
||||
@@ -484,9 +484,7 @@ export function createQueries({
|
||||
// contacts read the translation the fleet tick already wrote into their
|
||||
// modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket
|
||||
// are co-located anyway.
|
||||
const spec = modelOwnsVisual
|
||||
? parts.rendering._modelSpec(info?.klass)
|
||||
: null;
|
||||
const spec = modelOwnsVisual ? parts.rendering._modelSpec(info) : null;
|
||||
const pos = isTracked
|
||||
? parts.motion._trackedVisualCached() || bb.position
|
||||
: modelOwnsVisual
|
||||
|
||||
@@ -181,6 +181,8 @@ export class MilitaryFlightRecords {
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
type: stickyType,
|
||||
// The emitter category, kept for the fleet model a type-less row draws.
|
||||
category: aircraft?.category ?? prevMeta?.category ?? null,
|
||||
// Type outranks category automatically inside classifyAircraft.
|
||||
klass: classifyAircraft({
|
||||
typeCode: stickyType,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
CLASS_MODEL_REAL,
|
||||
CLASS_SCALE_3D,
|
||||
CLASS_SCALE_2D,
|
||||
} from '../../data/aircraftClass.js';
|
||||
import {
|
||||
visualCenterForModel,
|
||||
trailAnchorForModel,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
aircraftLayerModelSpec,
|
||||
aircraftModelSpecCacheKey,
|
||||
} from '../flights/modelSpec.js';
|
||||
import { createFleetModel } from '../../data/fleetModelInstances.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
@@ -20,14 +17,6 @@ import {
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
PLANE_MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
PLANE_NATIVE_RADIUS_M,
|
||||
PLANE_BELLY_OFFSET_NATIVE,
|
||||
MODEL_SCALE,
|
||||
JET_MODEL_URL,
|
||||
MODEL_NATIVE_RADIUS_M,
|
||||
MODEL_BELLY_OFFSET_NATIVE,
|
||||
MODEL_HEADING_OFFSET_DEG,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
@@ -83,48 +72,20 @@ export function createRendering({
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function _modelSpec(klass) {
|
||||
let spec = flightState._specCache.get(klass);
|
||||
/** The fleet model an aircraft draws with, by its type and emitter category
|
||||
* (shared with flights.js), in this layer's own tint. */
|
||||
function _modelSpec(meta) {
|
||||
const key = aircraftModelSpecCacheKey(meta);
|
||||
let spec = flightState._specCache.get(key);
|
||||
if (spec) return spec;
|
||||
const real = CLASS_MODEL_REAL[klass];
|
||||
if (real) {
|
||||
spec = {
|
||||
url: real.url,
|
||||
scale: 1,
|
||||
nativeRadiusM: real.radiusM,
|
||||
bellyM: real.bellyM,
|
||||
headingOffsetDeg: 180,
|
||||
visualCenterNative: visualCenterForModel(real.url),
|
||||
trailAnchorNative: trailAnchorForModel(real.url),
|
||||
};
|
||||
} else if (
|
||||
klass === 'airliner' ||
|
||||
klass === 'quadjet' ||
|
||||
klass === 'glider'
|
||||
) {
|
||||
const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
|
||||
spec = {
|
||||
url: PLANE_MODEL_URL,
|
||||
scale,
|
||||
nativeRadiusM: PLANE_NATIVE_RADIUS_M,
|
||||
bellyM: PLANE_BELLY_OFFSET_NATIVE * scale,
|
||||
headingOffsetDeg: 180,
|
||||
visualCenterNative: visualCenterForModel(PLANE_MODEL_URL),
|
||||
trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL),
|
||||
};
|
||||
} else {
|
||||
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
|
||||
spec = {
|
||||
url: JET_MODEL_URL,
|
||||
scale,
|
||||
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
|
||||
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
|
||||
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
|
||||
visualCenterNative: visualCenterForModel(JET_MODEL_URL),
|
||||
trailAnchorNative: trailAnchorForModel(JET_MODEL_URL),
|
||||
};
|
||||
}
|
||||
flightState._specCache.set(klass, spec);
|
||||
const base = aircraftLayerModelSpec(meta);
|
||||
spec = Object.freeze({
|
||||
...base,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
|
||||
});
|
||||
// Not cached before the model set loads: the answer changes once it has.
|
||||
if (spec.model) flightState._specCache.set(key, spec);
|
||||
return spec;
|
||||
}
|
||||
|
||||
@@ -312,7 +273,7 @@ export function createRendering({
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
flightState._scratchGroundCarto,
|
||||
);
|
||||
carto.height = h + _modelSpec(meta.klass).bellyM;
|
||||
carto.height = h + _modelSpec(meta).bellyM;
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
@@ -350,7 +311,7 @@ export function createRendering({
|
||||
displayPos,
|
||||
course,
|
||||
model.modelMatrix,
|
||||
_modelSpec(flightState.records.data.get(icao24)?.klass).headingOffsetDeg,
|
||||
_modelSpec(flightState.records.data.get(icao24)).headingOffsetDeg,
|
||||
);
|
||||
if (!model.ready) {
|
||||
model.show = false; // not loaded yet → keep the 2D icon, no half-model flash
|
||||
@@ -385,20 +346,17 @@ export function createRendering({
|
||||
return !!model && model.ready === true && model.show === true;
|
||||
}
|
||||
|
||||
/** Spec identity for a LOADED model — mirror of flights.js: URL and scale
|
||||
* together (same-URL classes differ by scale). */
|
||||
/** Spec identity for a LOADED model — mirror of flights.js: the model and
|
||||
* its scale together. */
|
||||
|
||||
const _specKeyFor = (klass) => {
|
||||
const spec = _modelSpec(klass);
|
||||
return `${spec.url}@${spec.scale}`;
|
||||
};
|
||||
const _specKeyFor = (meta) => _modelSpec(meta).key;
|
||||
|
||||
/** 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
|
||||
/** Type-change model sync — mirror of flights.js: drop a live model or
|
||||
* in-flight load whose spec no longer matches the type, re-showing the
|
||||
* fleet billboard FIRST (gap-proof) and covering the tracked standalone. */
|
||||
|
||||
function _syncModelToClass(icao24) {
|
||||
const key = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
||||
const key = _specKeyFor(flightState.records.data.get(icao24));
|
||||
const current = flightState._models.get(icao24);
|
||||
if (
|
||||
(current && current._gevSpecKey !== key) ||
|
||||
@@ -445,6 +403,11 @@ export function createRendering({
|
||||
|
||||
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
|
||||
|
||||
/** A Cesium Model of `spec` from the fleet set loaded up front (no fetch mid-session). */
|
||||
function _createModel(spec, options) {
|
||||
return createFleetModel(spec, options, { resolveAsset });
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -461,6 +424,8 @@ export function createRendering({
|
||||
_modelCap()
|
||||
)
|
||||
return;
|
||||
// Until the fleet model set has loaded the aircraft stays a billboard.
|
||||
if (!_modelSpec(flightState.records.data.get(icao24)).model) 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);
|
||||
@@ -468,12 +433,11 @@ export function createRendering({
|
||||
// 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 specKey = _specKeyFor(flightState.records.data.get(icao24));
|
||||
const loadIrBoost = flightState._irBoost;
|
||||
try {
|
||||
const spec = _modelSpec(flightState.records.data.get(icao24)?.klass);
|
||||
model = await Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(spec.url),
|
||||
const spec = _modelSpec(flightState.records.data.get(icao24));
|
||||
model = await _createModel(spec, {
|
||||
asynchronous: false,
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
@@ -522,7 +486,7 @@ export function createRendering({
|
||||
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 ||
|
||||
_specKeyFor(flightState.records.data.get(icao24)) !== specKey ||
|
||||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
||||
flightState._irBoost !== loadIrBoost;
|
||||
if (stale) {
|
||||
@@ -676,6 +640,9 @@ export function createRendering({
|
||||
return;
|
||||
}
|
||||
if (
|
||||
// Until the fleet model set has loaded the billboard carries it.
|
||||
_modelSpec(flightState.records.data.get(flightState._trackedIcao))
|
||||
.model &&
|
||||
!flightState._trackedModel &&
|
||||
!flightState._trackedModelLoading &&
|
||||
parts.tracking._trackedModelLoadAllowed()
|
||||
@@ -683,14 +650,13 @@ export function createRendering({
|
||||
flightState._trackedModelLoading = true;
|
||||
const gen = flightState._trackedModelGen;
|
||||
const trackedSpec = _modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
const trackedKey = _specKeyFor(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
const trackedIrBoost = flightState._irBoost;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(trackedSpec.url),
|
||||
_createModel(trackedSpec, {
|
||||
asynchronous: false,
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||
@@ -724,7 +690,7 @@ export function createRendering({
|
||||
// _trackedModel was still null): drop the stale asset; driver reloads.
|
||||
if (
|
||||
_specKeyFor(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
) !== trackedKey ||
|
||||
flightState._irBoost !== trackedIrBoost
|
||||
) {
|
||||
@@ -793,7 +759,7 @@ export function createRendering({
|
||||
return;
|
||||
}
|
||||
const spec = _modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao),
|
||||
);
|
||||
_modelMatrix(
|
||||
displayPos,
|
||||
|
||||
@@ -164,10 +164,15 @@ export function createMilitarySnapshotRenderer({
|
||||
) {
|
||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
// Per-class GLBs: a class change can mean a different asset OR scale —
|
||||
// resync the live model, any in-flight load, and the tracked
|
||||
// standalone (mirror of flights.js).
|
||||
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
|
||||
// Per-type fleet models: a type or category change can mean a
|
||||
// different model OR scale — resync the live model, any in-flight
|
||||
// load, and the tracked standalone (mirror of flights.js).
|
||||
if (
|
||||
prevMeta?.klass !== meta.klass ||
|
||||
prevMeta?.type !== meta.type ||
|
||||
prevMeta?.category !== meta.category
|
||||
)
|
||||
rendering._syncModelToClass(icao24);
|
||||
// Round 5: depth policy is uniform (always depth-test-free, see
|
||||
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
||||
} else {
|
||||
|
||||
@@ -19,15 +19,13 @@ export function createFlightState({ source, services }) {
|
||||
* layer); see followsClock and displayDelayMs in policy.js. */
|
||||
flightState.timeline = null;
|
||||
|
||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||
* amber). Real Hangar GLBs serve the classes they cover (meters, nose −X →
|
||||
* 180° offset); airliner/quadjet/glider get the shared 747 silhouette
|
||||
* (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets);
|
||||
* fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays
|
||||
* dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR,
|
||||
* and the tint must dominate any livery (owner: "stays yellow"). Specs are
|
||||
* static per class — memoized (the fleet pass asks at 12 Hz per model). */
|
||||
/** Model spec per aircraft type and emitter category (aircraftLayerModelSpec in
|
||||
* flights/modelSpec.js): the procedural fleet model that draws the type, life size,
|
||||
* with its measured clearance, bounding radius and trail anchor. nativeRadiusM is PER
|
||||
* SCALE UNIT — pixel-cap math multiplies it by `scale`. The code-side MIX tint dominates
|
||||
* every model so silhouettes stay light (and in this layer's own colour). Memoized: the
|
||||
* detection weld asks for one per MODELED contact per frame on top of the 12 Hz fleet
|
||||
* pass. */
|
||||
|
||||
flightState._specCache = new Map();
|
||||
|
||||
@@ -76,16 +74,11 @@ export function createFlightState({ source, services }) {
|
||||
// throttle the "more planes in view than the cap" console notice
|
||||
// The tracked entity's billboard goes transparent (rather than hidden) once the model takes
|
||||
// over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its
|
||||
// alpha after the GLB is preloaded so the model is ready to render the instant the billboard
|
||||
// fades — no gap, no double-image. Preloaded once at init; the instance is retained (not
|
||||
// destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation.
|
||||
// alpha after the fleet model set is loaded so the model is ready to render the instant the
|
||||
// billboard fades — no gap, no double-image. The set loads once at init (fleetModels.js).
|
||||
|
||||
flightState._planeModelLoaded = false;
|
||||
|
||||
/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */
|
||||
|
||||
flightState._preloadModel = null;
|
||||
|
||||
flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0);
|
||||
|
||||
flightState._scratchModelMtx = new Cesium.Matrix4();
|
||||
|
||||
Reference in new issue
Block a user