Merge branch 'main' into german/map-render-error-state

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-13 18:37:03 -08:00
commit c43bf103d6
199 files changed
+7964 -1408

No files matched your search

+14 -2
View File
@@ -109,6 +109,8 @@ export function createApplicationCatalog({
const vessels = createApplicationVessels({
source: sources.vessels,
options: vesselOptions,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
});
const installations = createApplicationInstallations({
surface,
@@ -167,7 +169,12 @@ export function createApplicationCatalog({
createApplicationTraffic({ source: sources.traffic, surface }),
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationTransit({
surface,
source: sources.transit,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
@@ -200,7 +207,12 @@ export function createApplicationCatalog({
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationRail({ surface, source: sources.rail }),
createApplicationRail({
surface,
source: sources.rail,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationFirms({
surface,
id: 'local-firms',
+16 -2
View File
@@ -10,9 +10,19 @@ import * as focus from '../../data/focusDeemphasis.js';
import * as worldFocus from '../../worldFocus.js';
import * as render from '../../renderGovernor.js';
import * as readouts from '../../hudReadouts.js';
import { fleetModelServices } from './fleetModels.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({ source, options = {} }) {
/**
* Construct one layer using the application scene owners and a supplied
* source. `displayParams` reads the Flights layer's DISPLAY-rail 3D
* preference, under which nearby vessels are drawn as fleet models.
*/
export function createApplicationVessels({
source,
options = {},
displayParams = null,
resolveAsset,
}) {
return createVesselLayer({
source,
options,
@@ -28,6 +38,10 @@ export function createApplicationVessels({ source, options = {} }) {
worldFocus,
render,
readouts,
fleet: fleetModelServices({
display: displayParams,
...(resolveAsset ? { resolveAsset } : {}),
}),
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import { FLEET_MODEL_POLICY } from '../../layers/flights/policy.js';
/**
* The fleet-model service the Vessels, Transit and Rail layers draw their 3D
* models with: the Flights layer's DISPLAY-rail 3D preference
* (`{ models3d, models3dMode }`), the aircraft layers' icon-to-model rules,
* and the resolver the fleet set's files are fetched through.
* @param {{display?: (() => object|null)|null,
* resolveAsset?: (url: string) => string}} [options]
* @returns {object|null} Null without a display preference (no models).
*/
export function fleetModelServices({
display = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
} = {}) {
if (!display) return null;
return Object.freeze({ display, policy: FLEET_MODEL_POLICY, resolveAsset });
}
+15 -2
View File
@@ -1,5 +1,6 @@
import { createRailLayer } from '../../layers/rail/index.js';
import * as render from '../../renderGovernor.js';
import { fleetModelServices } from './fleetModels.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
@@ -13,10 +14,18 @@ import {
/**
* Construct the Rail layer on the application's scene owners and its shared
* ground floor, the same floor Transit and the aircraft stand on.
* @param {{surface: object, source?: object}} options
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby trains are drawn as fleet models.
* @param {{surface: object, source?: object,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object}
*/
export function createApplicationRail({ surface, source }) {
export function createApplicationRail({
surface,
source,
displayParams = null,
resolveAsset,
}) {
if (!surface?.groundFloor)
throw new TypeError('Rail needs the application surface services');
const { groundFloor } = surface;
@@ -45,6 +54,10 @@ export function createApplicationRail({ surface, source }) {
globalThis.document?.body?.classList?.contains('cockpit-mode') ===
true,
},
fleet: fleetModelServices({
display: displayParams,
...(resolveAsset ? { resolveAsset } : {}),
}),
},
});
}
+16 -2
View File
@@ -1,5 +1,6 @@
import { createTransitLayer } from '../../layers/transit/index.js';
import * as render from '../../renderGovernor.js';
import { fleetModelServices } from './fleetModels.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
@@ -19,10 +20,19 @@ import {
* own mesh floors. An earlier cut imported the legacy module-level singleton
* instead and could not see either.
*
* @param {{surface: object}} options
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby vehicles are drawn as fleet models.
*
* @param {{surface: object, source?: object,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object}
*/
export function createApplicationTransit({ surface, source }) {
export function createApplicationTransit({
surface,
source,
displayParams = null,
resolveAsset,
}) {
if (!surface?.groundFloor || !surface?.meshFloor)
throw new TypeError('Transit needs the application surface services');
const { groundFloor, meshFloor } = surface;
@@ -44,6 +54,10 @@ export function createApplicationTransit({ surface, source }) {
allocateCorridorCells: groundFloor.allocateCorridorCells,
},
mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells },
fleet: fleetModelServices({
display: displayParams,
...(resolveAsset ? { resolveAsset } : {}),
}),
},
});
}
-51
View File
@@ -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 },
};
+2 -13
View File
@@ -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);
}
});
+2 -2
View File
@@ -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: `
+5 -4
View File
@@ -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));
+58
View File
@@ -258,3 +258,61 @@ test('partial static data keeps newer live metadata ahead of the old static cach
assert.equal(rowFor(mmsi).type, '80');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
});
// A static report's dimensions are the antenna's distances to bow (A), stern
// (B), port (C) and starboard (D): length A+B, beam C+D. Zero is AIS's "not
// available", so a vessel reporting none has no size, not a size of zero.
test('a static report gives a vessel its hull length and beam', () => {
ingestAisStreamEnvelope(positionReport('636019825', { Sog: 14 }));
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 636019825,
Name: 'EVER GIVEN',
Type: 70,
Dimension: { A: 330, B: 70, C: 30, D: 29 },
},
},
MetaData: { MMSI: '636019825' },
});
const row = rowFor('636019825');
assert.equal(row.length, 400);
assert.equal(row.beam, 59);
ingestAisStreamEnvelope(positionReport('636019826', { Sog: 3 }));
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 636019826,
Type: 52,
Dimension: { A: 0, B: 0, C: 0, D: 0 },
},
},
MetaData: { MMSI: '636019826' },
});
assert.equal(rowFor('636019826').length, null);
assert.equal(rowFor('636019826').beam, null);
});
test('a Class B report carries its dimensions in part B', () => {
ingestAisStreamEnvelope(positionReport('244000777', { Sog: 6 }));
ingestAisStreamEnvelope({
MessageType: 'StaticDataReport',
Message: {
StaticDataReport: {
UserID: 244000777,
ReportB: {
Valid: true,
ShipType: 37,
Dimension: { A: 8, B: 4, C: 2, D: 2 },
},
},
},
MetaData: { MMSI: '244000777' },
});
const row = rowFor('244000777');
assert.equal(row.length, 12);
assert.equal(row.beam, 4);
});
+5 -3
View File
@@ -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`);
}
});
+462
View File
@@ -0,0 +1,462 @@
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();
/** Models under the NVG/FLIR styles render unlit, as aircraft models do. */
const IR_UNLIT_SHADER = new Cesium.CustomShader({
lightingModel: Cesium.LightingModel.UNLIT,
});
/**
* 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, groundLiftM}`,
* `position` the point the model's origin stands on (the waterline, the road
* or rail top; the spec's `elevationM` is added here), `groundLiftM` any lift
* the layer's icon carries above that point (taken off again), `tint` a
* Cesium.Color for a tinted model's route colour (or null), `show` false to
* keep it hidden.
*
* Under the luminance-mapped NVG/FLIR styles (`irBoost`) models render
* unlit flat white, as aircraft models do. With `waterline`, each model is
* clipped at its origin's horizontal plane and only the part above it is
* drawn: a ship's hull below the waterline would otherwise show through a
* plain globe, which Cesium does not depth-test models against.
* @param {object} options
* @param {object} options.viewer Cesium viewer.
* @param {() => object|null} [options.display] `{models3d, models3dMode,
* irBoost}`.
* @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]
* @param {boolean} [options.waterline] Clip each model at its waterline.
* @returns {object}
*/
export function createFleetModelLayer({
viewer,
display = () => null,
policy,
waterline = false,
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;
/** Whether the models drawn now were built for the NVG/FLIR styles. */
let irBoost = false;
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) {
if (irBoost) return IR_UNLIT_SHADER;
return item.spec.tinted && item.tint
? fleetTintShader(item.tint)
: undefined;
}
/** The clipping that keeps a model's part above its waterline. */
function waterlineClipping() {
return new Cesium.ClippingPlaneCollection({
// The planes sit in the model matrix's frame: its origin is the
// waterline and its Z axis the local vertical, so the plane z = 0 with
// its normal up keeps the hull above the water.
planes: [new Cesium.ClippingPlane(Cesium.Cartesian3.UNIT_Z, 0)],
edgeWidth: 0,
});
}
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),
...(irBoost
? {
color: Cesium.Color.WHITE,
colorBlendMode: Cesium.ColorBlendMode.MIX,
colorBlendAmount: 1,
}
: {}),
...(waterline ? { clippingPlanes: waterlineClipping() } : {}),
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 && !irBoost ? item.tint : null;
model._gevIrBoost = irBoost;
// 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()) if (!eligible.has(id)) release(id);
for (const id of 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) - (item.groundLiftM || 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;
}
// A model's look under NVG/FLIR is set when it is built (a custom
// shader on a ready model does not take), so a style change rebuilds.
const boost = prefs.irBoost === true;
if (boost !== irBoost) {
releaseAll();
irBoost = boost;
lastEligibilityAt = -Infinity;
}
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 && !irBoost ? 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);
},
};
}
+487
View File
@@ -0,0 +1,487 @@
// The icon-to-model handoff the Vessels, Transit and Rail layers share
// (fleetModelInstances.js): models only under the DISPLAY 3D toggle and the
// altitude ceiling, at most `cap` of the nearest, each vehicle's icon the
// visual until its model is loaded, ready and placed, and a tinted model
// painted with its route colour.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import * as Cesium from 'cesium';
import {
createFleetModel,
createFleetModelLayer,
fleetModelMatrix,
fleetTintShader,
FLEET_HEADING_OFFSET_DEG,
} from './fleetModelInstances.js';
import {
loadFleetModelSet,
transitModelSpec,
vesselModelSpec,
} from './fleetModels.js';
import { fetchPublicFile } from '../testSupport/fleetGlb.mjs';
const realInfo = console.info;
console.info = () => {};
const set = await loadFleetModelSet({ fetchImpl: fetchPublicFile });
console.info = realInfo;
const POLICY = Object.freeze({
altitudeCeilingM: 800_000,
minimumPixelSize: 16,
cap: 2,
capAll: 4,
addM: 5_000,
addAllM: 20_000,
keepM: 6_000,
keepAllM: 24_000,
intervalMs: 80,
});
const CENTRE = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, 0);
function fakeViewer(height = 1_000) {
const primitives = [];
const position = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, height);
const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
position,
new Cesium.Cartesian3(),
);
const camera = {
positionWC: position,
positionCartographic: { height },
directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()),
upWC: Cesium.Cartesian3.UNIT_Z,
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
return {
camera,
scene: {
primitives: {
add(p) {
primitives.push(p);
return p;
},
remove(p) {
const i = primitives.indexOf(p);
if (i >= 0) primitives.splice(i, 1);
return i >= 0;
},
},
},
primitives,
isDestroyed: () => false,
};
}
function fakeModel(options) {
return {
...options,
ready: false,
show: true,
modelMatrix: new Cesium.Matrix4(),
destroyed: false,
destroy() {
this.destroyed = true;
},
isDestroyed() {
return this.destroyed;
},
update() {},
};
}
/** A vehicle `metres` east of the camera's foot. */
function itemAt(metres, spec, extra = {}) {
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(CENTRE);
const position = Cesium.Matrix4.multiplyByPoint(
enu,
new Cesium.Cartesian3(metres, 0, 0),
new Cesium.Cartesian3(),
);
return { position, headingDeg: 90, spec, tint: null, pickId: null, ...extra };
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('models load from the up-front set, never by URL', async () => {
const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 32 });
let options = null;
await createFleetModel(
spec,
{ scale: spec.scale },
{
set,
resolveAsset: (url) => `/base${url}`,
load: async (o) => {
options = o;
return {};
},
},
);
assert.ok(options.gltf instanceof Uint8Array, 'the loaded GLB bytes');
assert.equal(options.gltf, set.bytes.get(spec.model));
assert.equal(options.basePath, `/base${spec.url}`);
assert.equal(options.url, undefined, 'no fetch by URL');
assert.deepEqual({ ...options.environmentMapOptions }, { enabled: false });
await assert.rejects(
createFleetModel({ model: 'missing' }, {}, { set, load: async () => ({}) }),
/not loaded/,
);
});
test('the icon stays the visual until the model is ready; only the nearest are drawn', async () => {
const viewer = fakeViewer();
const display = { models3d: true, models3dMode: 'proximity' };
const created = [];
const layer = createFleetModelLayer({
viewer,
display: () => display,
policy: POLICY,
createModel: async (spec, options) => {
const model = fakeModel({ spec, ...options });
created.push(model);
return model;
},
});
const spec = transitModelSpec(set, { mode: 'tram' });
const items = new Map([
['near', itemAt(100, spec, { pickId: 'near' })],
['mid', itemAt(1_000, spec, { pickId: 'mid' })],
['far', itemAt(4_000, spec, { pickId: 'far' })],
]);
layer.frame(items, 0);
await flush();
assert.equal(created.length, POLICY.cap, 'the cap holds');
assert.deepEqual(
created.map((m) => m.id).sort(),
['mid', 'near'],
'the nearest are modelled',
);
assert.equal(created[0].scale, spec.scale, 'drawn life size');
assert.equal(created[0].minimumPixelSize, POLICY.minimumPixelSize);
layer.frame(items, 100);
for (const item of items.values())
assert.equal(item.modelOwnsVisual, false, 'not ready: the icon draws');
assert.ok(
layer.settling > 0,
'the layer asks for frames while models settle',
);
for (const model of created) model.ready = true;
layer.frame(items, 200);
assert.equal(items.get('near').modelOwnsVisual, true);
assert.equal(items.get('mid').modelOwnsVisual, true);
assert.equal(items.get('far').modelOwnsVisual, false);
assert.equal(layer.settling, 0);
// A placed model stands on its item's point, nose along its heading.
const near = created.find((m) => m.id === 'near');
const origin = Cesium.Matrix4.getTranslation(
near.modelMatrix,
new Cesium.Cartesian3(),
);
assert.ok(
Cesium.Cartesian3.distance(origin, items.get('near').position) < 1e-6,
);
// 3D off: every model goes and every icon comes back.
display.models3d = false;
layer.frame(items, 300);
assert.equal(layer.size, 0);
for (const item of items.values()) assert.equal(item.modelOwnsVisual, false);
assert.ok(
created.every((m) => m.destroyed),
'released models are destroyed',
);
layer.destroy();
assert.equal(viewer.primitives.length, 0);
});
test('models stay off above the altitude ceiling', async () => {
const viewer = fakeViewer(POLICY.altitudeCeilingM + 1);
let calls = 0;
const layer = createFleetModelLayer({
viewer,
display: () => ({ models3d: true }),
policy: POLICY,
createModel: async (spec, options) => {
calls += 1;
return fakeModel(options);
},
});
assert.equal(layer.active(), false);
layer.frame(new Map([['a', itemAt(0, transitModelSpec(set, {}))]]), 0);
await flush();
assert.equal(calls, 0);
layer.destroy();
});
test('a tinted model is painted with its route colour; an untinted one is not', async () => {
const viewer = fakeViewer();
const created = [];
const layer = createFleetModelLayer({
viewer,
display: () => ({ models3d: true }),
policy: POLICY,
createModel: async (spec, options) => {
const model = fakeModel(options);
created.push(model);
return model;
},
});
const red = Cesium.Color.RED;
const tram = transitModelSpec(set, { mode: 'tram' });
const ship = vesselModelSpec(set, { type: '70', length: 180 });
assert.equal(tram.tinted, true);
assert.equal(ship.tinted, false);
layer.frame(
new Map([
['tram', itemAt(10, tram, { pickId: 'tram', tint: red })],
['ship', itemAt(20, ship, { pickId: 'ship', tint: red })],
]),
0,
);
await flush();
const byId = Object.fromEntries(created.map((m) => [m.id, m]));
assert.equal(byId.tram.customShader, fleetTintShader(red));
assert.equal(byId.ship.customShader, undefined);
assert.equal(
fleetTintShader(red),
fleetTintShader(Cesium.Color.RED),
'one shader per colour',
);
layer.destroy();
});
test('an elevated vehicle is raised by its type elevation', async () => {
const viewer = fakeViewer();
const created = [];
const layer = createFleetModelLayer({
viewer,
display: () => ({ models3d: true }),
policy: POLICY,
createModel: async (spec, options) => {
const model = fakeModel({ ...options, ready: true });
model.ready = true;
created.push(model);
return model;
},
});
const monorail = transitModelSpec(set, { mode: 'monorail' });
assert.equal(monorail.elevationM, set.types.transit.elevation.monorail);
const item = itemAt(10, monorail, { pickId: 'm' });
const items = new Map([['m', item]]);
layer.frame(items, 0);
await flush();
layer.frame(items, 100);
const origin = Cesium.Matrix4.getTranslation(
created[0].modelMatrix,
new Cesium.Cartesian3(),
);
const lifted = Cesium.Cartographic.fromCartesian(origin).height;
const ground = Cesium.Cartographic.fromCartesian(item.position).height;
assert.ok(Math.abs(lifted - ground - monorail.elevationM) < 1e-3);
layer.destroy();
});
test('a vessel stretched to its reported beam keeps its length scale on the model', () => {
const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 60 });
assert.ok(spec.scaleXYZ[1] > spec.scaleXYZ[0], 'a wide hull for its length');
const matrix = fleetModelMatrix(
CENTRE,
0,
spec,
new Cesium.Matrix4(),
new Cesium.HeadingPitchRoll(),
);
const scale = Cesium.Matrix4.getScale(matrix, new Cesium.Cartesian3());
// Cesium's axis correction puts the beam on local X, the length on local Y.
assert.ok(Math.abs(scale.x - spec.scaleXYZ[1] / spec.scaleXYZ[0]) < 1e-9);
assert.ok(Math.abs(scale.y - 1) < 1e-9);
assert.equal(FLEET_HEADING_OFFSET_DEG, 180, 'fleet models are built nose -X');
});
test('Vessels, Transit and Rail draw fleet models under the shared 3D toggle', () => {
for (const file of [
'src/layers/vessels/rendering.js',
'src/layers/transit/rendering.js',
'src/layers/rail/rendering.js',
]) {
const source = fs.readFileSync(
new URL(`../../${file}`, import.meta.url),
'utf8',
);
assert.match(
source,
/createFleetModelLayer\(/,
`${file}: draws fleet models`,
);
assert.match(
source,
/loadFleetModelSet\(/,
`${file}: loads the set up front`,
);
assert.match(
source,
/modelOwnsVisual/,
`${file}: hands its icon to the model`,
);
}
for (const file of [
'src/app/layers/aisLiveVessels.js',
'src/app/layers/transit.js',
'src/app/layers/rail.js',
]) {
const source = fs.readFileSync(
new URL(`../../${file}`, import.meta.url),
'utf8',
);
assert.match(
source,
/fleet: fleetModelServices\(/,
`${file}: given the fleet service`,
);
}
const catalog = fs.readFileSync(
new URL('../app/constructCatalog.js', import.meta.url),
'utf8',
);
assert.equal(
catalog.match(/displayParams: \(\) => flights\.getParams\(\)/g)?.length,
4,
'Local ADS-B, Vessels, Transit and Rail follow the Flights 3D toggle',
);
});
/** A layer whose models are all ready as soon as they are created. */
function readyLayer({ display, waterline = false } = {}) {
const viewer = fakeViewer();
const created = [];
const layer = createFleetModelLayer({
viewer,
display: () => display,
policy: POLICY,
waterline,
createModel: async (spec, options) => {
const model = fakeModel(options);
model.ready = true;
created.push(model);
return model;
},
});
return { layer, created };
}
test('a model stands on the floor its icon is lifted above', async () => {
const { layer, created } = readyLayer({ display: { models3d: true } });
const tram = transitModelSpec(set, { mode: 'tram' });
const item = itemAt(10, tram, { pickId: 't', groundLiftM: 1.5 });
const items = new Map([['t', item]]);
layer.frame(items, 0);
await flush();
layer.frame(items, 100);
const origin = Cesium.Matrix4.getTranslation(
created[0].modelMatrix,
new Cesium.Cartesian3(),
);
const drop =
Cesium.Cartographic.fromCartesian(item.position).height -
Cesium.Cartographic.fromCartesian(origin).height;
assert.ok(Math.abs(drop - 1.5) < 1e-3, `dropped ${drop} m`);
layer.destroy();
});
test('under NVG/FLIR models are rebuilt unlit white, and rebuilt again on exit', async () => {
const display = { models3d: true, irBoost: false };
const { layer, created } = readyLayer({ display });
const tram = transitModelSpec(set, { mode: 'tram' });
const items = new Map([
['t', itemAt(10, tram, { pickId: 't', tint: Cesium.Color.RED })],
]);
layer.frame(items, 0);
await flush();
assert.equal(created[0].customShader, fleetTintShader(Cesium.Color.RED));
display.irBoost = true;
layer.frame(items, 100);
assert.ok(created[0].destroyed, 'the lit model is released');
await flush();
const boosted = created[1];
assert.equal(boosted.customShader.lightingModel, Cesium.LightingModel.UNLIT);
assert.ok(Cesium.Color.equals(boosted.color, Cesium.Color.WHITE));
assert.equal(boosted.colorBlendAmount, 1);
display.irBoost = false;
layer.frame(items, 200);
await flush();
assert.ok(boosted.destroyed);
assert.equal(created[2].customShader, fleetTintShader(Cesium.Color.RED));
layer.destroy();
});
test('a waterline layer clips each model at its origin, keeping the part above', async () => {
const { layer, created } = readyLayer({
display: { models3d: true },
waterline: true,
});
const ship = vesselModelSpec(set, { type: '70', length: 200, beam: 32 });
layer.frame(new Map([['s', itemAt(10, ship, { pickId: 's' })]]), 0);
await flush();
const planes = created[0].clippingPlanes;
assert.ok(planes instanceof Cesium.ClippingPlaneCollection);
assert.equal(planes.length, 1);
const plane = planes.get(0);
assert.equal(plane.distance, 0);
assert.ok(Cesium.Cartesian3.equals(plane.normal, Cesium.Cartesian3.UNIT_Z));
assert.equal(planes.unionClippingRegions, false);
// Cesium keeps a point where normal · p + distance >= 0: above the waterline.
const above = Cesium.Cartesian3.dot(
plane.normal,
new Cesium.Cartesian3(0, 0, 3),
);
const below = Cesium.Cartesian3.dot(
plane.normal,
new Cesium.Cartesian3(0, 0, -3),
);
assert.ok(above + plane.distance > 0 && below + plane.distance < 0);
layer.destroy();
const plain = readyLayer({ display: { models3d: true } });
plain.layer.frame(
new Map([['t', itemAt(10, transitModelSpec(set, { mode: 'tram' }))]]),
0,
);
await flush();
assert.equal(plain.created[0].clippingPlanes, undefined, 'others unclipped');
plain.layer.destroy();
});
test('a vehicle drawn as its model keeps its card and its detection entry', () => {
const read = (file) =>
fs.readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
assert.match(
read('src/layers/transit/selection.js'),
/marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/,
);
assert.match(
read('src/layers/rail/selection.js'),
/entry\.marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/,
);
assert.match(
read('src/layers/transit/queries.js'),
/marker\.show === false && entry\.modelOwnsVisual !== true/,
);
assert.match(
read('src/layers/vessels/queries.js'),
/!visual\.billboard\.show &&\s*!visual\.modelOwnsVisual/,
);
assert.match(
read('src/layers/transit/rendering.js'),
/entry\.mode === 'unknown'/,
'a vehicle of unknown mode keeps its icon',
);
});
+481
View File
@@ -0,0 +1,481 @@
/**
* @module fleetModels
* @description The project's procedural fleet models (`public/models/fleet/`):
* which model draws an aircraft, a vessel or a transit or rail vehicle, and at
* what size, from the set's `catalog.json` (each model's file, bounds,
* length, beam, clearance and icon class) and `types.json` (which model
* draws which type, and the fallbacks).
*
* Every model is metres, glTF Y-up, nose toward local -X, origin at the body
* centre (an aircraft's fuselage axis, a vessel's waterline, a road or rail
* vehicle's road or rail top), and is drawn life size: a type's published
* length over the model's built length is its scale.
*
* The resolvers are plain functions over the parsed JSON; `loadFleetModelSet`
* fetches the JSON and every GLB once, up front, so no model is fetched
* mid-session.
*/
import { classifyAircraft } from './aircraftClass.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
/** Where the model set is served, relative to the application base. */
export const FLEET_MODEL_BASE = '/models/fleet/';
/**
* Longest and widest hulls afloat, metres (Seawise Giant 458 m; the TI
* tankers 68 m abeam). AIS gives the bow and stern from the antenna in 9 bits
* and each side in 6, their largest values (511, 63) meaning "that or more"
* or a set never configured: a 30 m tug reported 511 m long would be drawn 17
* times its size.
*/
const MAX_HULL_LENGTH_M = 460;
const MAX_HULL_BEAM_M = 75;
/** The model a known aircraft class draws when its type has no entry. */
const CLASS_MODELS = Object.freeze({
light: 'c172',
glider: 'glider',
turboprop: 'at72',
widebody: 'b789',
quadjet: 'b744',
helicopter: 'generic_heli',
fastjet: 't38',
bizjet: 'c56x',
uav: 'mq9',
});
/** OpenSky's integer aircraft category (state vector index 17) → ADS-B emitter category. */
const OPENSKY_EMITTER_CATEGORY = Object.freeze({
2: 'A1',
3: 'A2',
4: 'A3',
5: 'A4',
6: 'A5',
7: 'A6',
8: 'A7',
9: 'B1',
10: 'B2',
11: 'B3',
12: 'B4',
14: 'B6',
15: 'B7',
16: 'C1',
17: 'C2',
18: 'C3',
19: 'C4',
20: 'C5',
});
/** A text vessel type (as some AIS sources name it) → a representative AIS ship-type code. */
const TEXT_SHIP_TYPES = [
[/tanker|lng|lpg|gas carrier/i, 80],
[/cargo|container|bulk|carrier|freighter|ro-?ro/i, 70],
[/passenger|ferry|cruise/i, 60],
[/high.?speed/i, 40],
[/fishing|trawler/i, 30],
[/tug|towing/i, 52],
[/pilot/i, 50],
[/search|rescue|\bsar\b/i, 51],
[/dredg/i, 33],
[/military|naval|navy/i, 35],
[/law|police|patrol|coast ?guard/i, 55],
[/sail/i, 36],
[/pleasure|yacht/i, 37],
];
/**
* The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B
* emitter category `category`: the type's, else the category's, else
* `default_aircraft`.
* @param {object} types Parsed `types.json`.
* @param {string} [type]
* @param {string} [category]
* @returns {{model: string, length: number|null}}
*/
export function resolveAircraft(types, type, category) {
const t = type?.trim().toUpperCase();
const [model, length] =
(t && (types.aircraft[t] ?? types.helicopters[t])) ||
(category && types.categories[category]) ||
types.default_aircraft;
return { model, length };
}
/**
* A reported length as one a hull can have, else undefined (drawn as built).
* @param {number} [length]
* @returns {number|undefined}
*/
export function hullLength(length) {
return length !== undefined &&
length !== null &&
length > 0 &&
length <= MAX_HULL_LENGTH_M
? length
: undefined;
}
/**
* A reported beam as one a hull can have, else undefined.
* @param {number} [beam]
* @returns {number|undefined}
*/
export function hullBeam(beam) {
return beam !== undefined &&
beam !== null &&
beam > 0 &&
beam <= MAX_HULL_BEAM_M
? beam
: undefined;
}
/**
* The model for a vessel of AIS ship type `shipType` and the reported length
* and beam (metres) and name: the first rule that matches in the first type
* range that has one, else `default_vessel`. Drawn at the reported length;
* when it is unknown (most live feeds send none), chosen and drawn at the
* range's `typical_len`, else as built.
* @param {object} types Parsed `types.json`.
* @param {number} [shipType]
* @param {number} [length]
* @param {number} [beam]
* @param {string} [name]
* @returns {{model: string, length: number|null}}
*/
export function resolveVessel(types, shipType, length, beam, name) {
const L = hullLength(length) ?? 0;
const B = hullBeam(beam) ?? 0;
if (shipType !== undefined && shipType !== null) {
for (const group of types.vessels) {
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
const len = L > 0 ? L : (group.typical_len ?? 0);
const drawn = len > 0 ? len : null;
for (const rule of group.rules) {
if (rule.min_len && len < rule.min_len) continue;
if (rule.max_len && len > rule.max_len) continue;
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
continue;
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
return { model: rule.model, length: drawn };
}
}
}
return { model: types.default_vessel, length: L > 0 ? L : null };
}
/**
* The model for a public transport vehicle: a rail category and mode
* (`transit`/`local`/`regional`/`national`/`international` × `rail`/`metro`/
* `tram`), else a GTFS `route_type` (basic 0-12 or extended 100-1507), else a
* mode name (`bus`, `tram`, `subway`, `rail`, `ferry`, `light_rail`, …),
* else `transit.default`. Each comes with the length to draw it at.
* @param {object} types Parsed `types.json`.
* @param {{railCategory?: string, railMode?: string, routeType?: number|string,
* mode?: string}} vehicle
* @returns {{model: string, length: number|null}}
*/
export function resolveTransit(
types,
{ railCategory, railMode, routeType, mode } = {},
) {
const transit = types.transit;
const rail = railCategory ? transit.rail[railCategory] : null;
const routeKey =
routeType === undefined || routeType === null || routeType === ''
? null
: String(Number(routeType));
const [model, length] =
(rail && (rail[railMode || 'rail'] ?? rail.rail)) ||
(routeKey && transit.route_types[routeKey]) ||
(mode && transit.modes[mode]) ||
transit.default;
return { model, length };
}
/**
* An AIS ship type as its numeric code: a code as given ("70"), or the code
* of the family a text type names ("Cargo" → 70); undefined when it names
* none.
* @param {string|number|null|undefined} type
* @returns {number|undefined}
*/
export function aisShipTypeCode(type) {
const text = String(type ?? '').trim();
if (!text) return undefined;
if (/^\d+$/.test(text)) {
const code = Number(text);
return code > 0 && code < 100 ? code : undefined;
}
for (const [pattern, code] of TEXT_SHIP_TYPES)
if (pattern.test(text)) return code;
return undefined;
}
/**
* An aircraft category as an ADS-B emitter category: an emitter string as
* given ("A3"), or OpenSky's integer category mapped to one.
* @param {string|number|null|undefined} category
* @returns {string|undefined}
*/
export function emitterCategory(category) {
if (typeof category === 'number')
return OPENSKY_EMITTER_CATEGORY[category] || undefined;
const text = String(category ?? '')
.trim()
.toUpperCase();
if (/^\d+$/.test(text)) return OPENSKY_EMITTER_CATEGORY[Number(text)];
return /^[A-D][0-7]$/.test(text) ? text : undefined;
}
/**
* The scale of model `model` drawn `length` metres long, along the model's
* length, beam and height. A vessel's reported beam stretches it sideways;
* its height follows the mean of the two.
* @param {object} model Catalog entry.
* @param {number|null} length
* @param {number} [beam] A vessel's reported beam.
* @returns {[number, number, number]}
*/
export function placeScale(model, length, beam) {
if (length === null || length === undefined || !(model.length > 0))
return [1, 1, 1];
const sx = length / model.length;
const b = hullBeam(beam);
const sy = b !== undefined && model.beam > 0 ? b / model.beam : sx;
return [sx, sy, (sx + sy) / 2];
}
/** Half the diagonal of a model's bounds: the radius Cesium bounds it by. */
function boundingRadius(model) {
const [x0, y0, z0] = model.min;
const [x1, y1, z1] = model.max;
return Math.hypot(x1 - x0, y1 - y0, z1 - z0) / 2;
}
/**
* The drawing of model `name` at `length` metres: what the layers place.
*
* `scale` is the uniform scale (along the model's length) a Cesium Model
* takes; `scaleXYZ` the model's length, beam and height scales, for a vessel
* stretched to its reported beam. `clearanceM` is how far the model reaches
* below its origin at that scale (wheels below an aircraft's axis, a keel
* below the waterline): a grounded aircraft stands its origin that far up.
* `elevationM` lifts a vehicle off the route it follows (a monorail, a
* gondola). `tinted` models carry the `_TINT` vertex attribute marking where
* a route colour applies. `nativeRadiusM` (unscaled), `visualCenterNative`
* and `trailAnchorNative` (raw glTF axes, unscaled) serve the aircraft
* layers' pixel-size, bracket and trail geometry.
* @param {{catalog: object, types: object}} set
* @param {string} name Catalog model name.
* @param {number|null} length Metres along the body; null draws it as built.
* @param {{beam?: number}} [options]
* @returns {object}
*/
export function fleetModelSpec(set, name, length, { beam } = {}) {
const model = set.catalog[name];
// Ground vehicles, balloons and obstacles are built at the size their type
// names (a tower's 60 m is its height, not a length to scale to).
const drawnLength = model.category === 'other' ? null : length;
const scaleXYZ = placeScale(model, drawnLength, beam);
const elevationM = set.types.transit?.elevation?.[name] || 0;
return Object.freeze({
model: name,
url: FLEET_MODEL_BASE + model.file,
category: model.category,
icon: model.icon,
length: model.length * scaleXYZ[0],
scale: scaleXYZ[0],
scaleXYZ: Object.freeze(scaleXYZ),
clearanceM: model.clearance * scaleXYZ[2],
elevationM,
tinted: model.category === 'transit',
nativeRadiusM: boundingRadius(model),
visualCenterNative: CENTRED,
trailAnchorNative: FLEET_TRAIL_ANCHORS[name] || CENTRED,
key: `${name}@${scaleXYZ.map((s) => s.toFixed(4)).join('x')}`,
});
}
const CENTRED = Object.freeze([0, 0, 0]);
/** `resolved` drawn from `set`, its model replaced by `fallback` when the catalog lacks it. */
function specOf(set, resolved, fallback, options) {
const known = set.catalog[resolved.model] ? resolved : fallback(resolved);
return fleetModelSpec(set, known.model, known.length, options);
}
/**
* The model an aircraft is drawn with. The type's entry first; then, for a
* type the map lacks, the model of its class (a fast jet, a helicopter, a
* glider) when the type names one; then the emitter category's; then
* `default_aircraft`.
* @param {{catalog: object, types: object}} set
* @param {{typeCode?: string|null, type?: string|null,
* category?: string|number|null}} aircraft
* @returns {object} {@link fleetModelSpec}
*/
export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
const { types, catalog } = set;
const code = String(typeCode ?? type ?? '')
.trim()
.toUpperCase();
const emitter = emitterCategory(category);
let resolved = resolveAircraft(types, code, emitter);
const typed = code && (types.aircraft[code] || types.helicopters[code]);
if (!typed && code) {
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
if (model && catalog[model])
resolved = { model, length: catalog[model].length };
}
return specOf(set, resolved, () => ({
model: types.default_aircraft[0],
length: types.default_aircraft[1],
}));
}
/**
* The model a vessel is drawn with, from its AIS ship type (a code or a text
* family), reported length and beam (metres) and name.
* @param {{catalog: object, types: object}} set
* @param {{type?: string|number|null, length?: number|null,
* beam?: number|null, name?: string|null}} vessel
* @returns {object} {@link fleetModelSpec}
*/
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
// A beam counts only beside a reported length: a vessel drawn at its type's
// typical length keeps the model's proportions.
const reported = hullLength(length ?? undefined);
const hullWidth = reported === undefined ? undefined : (beam ?? undefined);
const resolved = resolveVessel(
set.types,
aisShipTypeCode(type),
reported,
hullWidth,
name ?? undefined,
);
return specOf(
set,
resolved,
(r) => ({ model: set.types.default_vessel, length: r.length }),
{ beam: hullWidth },
);
}
/**
* The model a transit or rail vehicle is drawn with ({@link resolveTransit}).
* @param {{catalog: object, types: object}} set
* @param {object} vehicle
* @returns {object} {@link fleetModelSpec}
*/
export function transitModelSpec(set, vehicle) {
return specOf(set, resolveTransit(set.types, vehicle), () => ({
model: set.types.transit.default[0],
length: set.types.transit.default[1],
}));
}
/**
* Every model the type map names, defaults and fallbacks included.
* @param {object} types Parsed `types.json`.
* @returns {Set<string>}
*/
export function namedFleetModels(types) {
const pairs = (map) => Object.values(map).map(([model]) => model);
const transit = types.transit;
return new Set([
...pairs(types.aircraft),
...pairs(types.helicopters),
...pairs(types.categories),
...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)),
types.default_aircraft[0],
types.default_vessel,
...pairs(transit.modes),
...pairs(transit.route_types),
...Object.values(transit.rail).flatMap(pairs),
transit.default[0],
...Object.keys(transit.elevation),
...Object.values(CLASS_MODELS),
]);
}
let loading = null;
let loaded = null;
/**
* The model set once {@link loadFleetModelSet} has loaded it, else null.
* @returns {object|null}
*/
export function fleetModelSet() {
return loaded;
}
/**
* Fetch the model set once for every layer: `catalog.json`, `types.json` and
* every GLB the catalog names, all up front, so no model is fetched while a
* session runs. Resolves to `{catalog, types, bytes, loadMs, totalBytes}`,
* `bytes` holding each model's GLB by name; a GLB that fails to load is left
* out (that model then draws nothing and its entities stay icons).
* @param {{resolveAsset?: (url: string) => string,
* fetchImpl?: typeof fetch, now?: () => number}} [options]
* @returns {Promise<object>}
*/
export function loadFleetModelSet({
resolveAsset = (url) => url,
fetchImpl = globalThis.fetch,
now = () => globalThis.performance?.now?.() ?? Date.now(),
} = {}) {
if (loading) return loading;
const started = now();
const json = async (name) => {
const response = await fetchImpl(resolveAsset(FLEET_MODEL_BASE + name));
if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`);
return response.json();
};
loading = (async () => {
const [catalog, types] = await Promise.all([
json('catalog.json'),
json('types.json'),
]);
const bytes = new Map();
let totalBytes = 0;
await Promise.all(
Object.entries(catalog).map(async ([name, model]) => {
try {
const response = await fetchImpl(
resolveAsset(FLEET_MODEL_BASE + model.file),
);
if (!response.ok) return;
const glb = new Uint8Array(await response.arrayBuffer());
bytes.set(name, glb);
totalBytes += glb.byteLength;
} catch {
/* that model draws nothing; its entities stay icons */
}
}),
);
loaded = Object.freeze({
catalog,
types,
bytes,
loadMs: now() - started,
totalBytes,
});
console.info(
`[Fleet] ${bytes.size}/${Object.keys(catalog).length} models ` +
`(${(totalBytes / 1048576).toFixed(2)} MB) loaded in ${Math.round(loaded.loadMs)} ms`,
);
return loaded;
})();
// A failed catalog load may be retried by the next layer that starts.
loading.catch(() => {
loading = null;
});
return loading;
}
/** Forget the loaded set (tests). */
export function resetFleetModelSet() {
loading = null;
loaded = null;
}
+457
View File
@@ -0,0 +1,457 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
FLEET_MODEL_BASE,
aircraftModelSpec,
aisShipTypeCode,
emitterCategory,
fleetModelSet,
fleetModelSpec,
hullBeam,
hullLength,
loadFleetModelSet,
namedFleetModels,
placeScale,
resetFleetModelSet,
resolveAircraft,
resolveTransit,
resolveVessel,
transitModelSpec,
vesselModelSpec,
} from './fleetModels.js';
import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js';
import {
centrelineSegments,
distanceToSurface,
fleetMesh,
measuredTrailAnchor,
readGlb,
vertexBounds,
} from '../testSupport/fleetGlb.mjs';
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const DIR = path.join(ROOT, 'public', 'models', 'fleet');
const json = (name) =>
JSON.parse(fs.readFileSync(path.join(DIR, name), 'utf8'));
const catalog = json('catalog.json');
const types = json('types.json');
const set = { catalog, types };
// A slice of the set's types.json and catalog.json, as the resolver's own
// reference tests use, so these read independently of later model changes.
const sliceTypes = {
aircraft: {
A320: ['a320', 37.57],
A21N: ['a320', 44.51],
A319: ['a320', 33.84],
B38M: ['b738', 39.52],
},
helicopters: { EC35: ['ec35', 10.2] },
categories: { A1: ['c172', 8.3] },
vessels: [
{
types: [70, 79],
typical_len: 300,
rules: [
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
{
name_re: '\\b(LEADER|HIGHWAY|ACE|GLOVIS|HOEGH|SPIRIT OF)\\b',
min_len: 150,
model: 'car_carrier',
},
{ min_len: 250, model: 'container_ship' },
{ model: 'general_cargo' },
],
},
{ types: [31, 32], rules: [{ model: 'tug' }] },
{ types: [52, 52], rules: [{ model: 'tug' }] },
{ types: [0, 99], rules: [{ model: 'generic_vessel' }] },
],
default_aircraft: ['generic_plane', 36],
default_vessel: 'generic_vessel',
};
const model = (category, file, length, beam, clearance) => ({
category,
file,
min: [0, 0, 0],
max: [0, 0, 0],
length,
beam,
clearance,
tris: 1,
icon: 'jet',
});
const sliceCatalog = {
a320: model('aircraft', 'aircraft/a320.glb', 37.57, 35.6, 3.063),
container_ship: model('ships', 'ships/container_ship.glb', 309.9, 48, 13.9),
};
test('aircraft resolve by type, then emitter category, then the generic plane', () => {
assert.deepEqual(resolveAircraft(sliceTypes, 'A320'), {
model: 'a320',
length: 37.57,
});
assert.deepEqual(resolveAircraft(sliceTypes, ' b38m '), {
model: 'b738',
length: 39.52,
});
assert.equal(resolveAircraft(sliceTypes, 'EC35').model, 'ec35');
assert.deepEqual(resolveAircraft(sliceTypes, 'ZZZZ', 'A1'), {
model: 'c172',
length: 8.3,
});
assert.deepEqual(resolveAircraft(sliceTypes, undefined, 'Q9'), {
model: 'generic_plane',
length: 36,
});
});
test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => {
assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
assert.equal(
resolveVessel(sliceTypes, 71, 199, 32, 'GLOVIS SPIRIT').model,
'car_carrier',
);
assert.equal(resolveVessel(sliceTypes, 79, 110, 11.4).model, 'inland_cargo');
assert.equal(resolveVessel(sliceTypes, 70, 90, 15).model, 'general_cargo');
assert.deepEqual(resolveVessel(sliceTypes, 52, 30, 10), {
model: 'tug',
length: 30,
});
assert.deepEqual(resolveVessel(sliceTypes, undefined, 50), {
model: 'generic_vessel',
length: 50,
});
assert.deepEqual(resolveVessel(sliceTypes, 0), {
model: 'generic_vessel',
length: null,
});
});
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
assert.deepEqual(resolveVessel(sliceTypes, 70), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
model: 'container_ship',
length: 300,
});
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
model: 'general_cargo',
length: 90,
});
assert.deepEqual(resolveVessel(sliceTypes, 31), {
model: 'tug',
length: null,
});
// Every passenger, cargo and tanker range of the set has one.
for (const code of [60, 70, 80]) {
assert.ok(resolveVessel(types, code).length > 100, String(code));
}
});
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
model: 'tug',
length: null,
});
assert.equal(hullLength(511), undefined);
assert.equal(hullLength(0), undefined);
assert.equal(hullBeam(126), undefined);
assert.equal(hullBeam(32), 32);
const ship = sliceCatalog.container_ship;
assert.deepEqual(placeScale(ship, ship.length, 126), [1, 1, 1]);
});
test('scale: aircraft by the type length, vessels by length and beam', () => {
const a320 = sliceCatalog.a320;
const [sx, sy, sz] = placeScale(a320, 44.51);
assert.ok(Math.abs(sx - 44.51 / 37.57) < 1e-12);
assert.equal(sy, sx);
assert.equal(sz, sx);
const ship = sliceCatalog.container_ship;
assert.deepEqual(placeScale(ship, ship.length / 2, 24), [0.5, 0.5, 0.5]);
assert.deepEqual(placeScale(ship, null), [1, 1, 1]);
});
test('every model the type map names is in the catalog, and its file is a GLB on disk', () => {
for (const name of namedFleetModels(types))
assert.ok(
catalog[name],
`${name}: named by types.json but not in the catalog`,
);
assert.equal(catalog.generic_plane?.category, 'aircraft');
assert.equal(catalog.generic_vessel?.category, 'ships');
assert.equal(catalog.city_bus?.category, 'transit');
for (const [name, metres] of Object.entries(types.transit.elevation)) {
assert.ok(catalog[name], name);
assert.ok(metres > 0, name);
}
let total = 0;
for (const [name, entry] of Object.entries(catalog)) {
const file = path.join(DIR, entry.file);
assert.ok(fs.existsSync(file), `${name}: ${entry.file} is missing`);
const bytes = fs.readFileSync(file);
assert.equal(bytes.subarray(0, 4).toString('latin1'), 'glTF', name);
assert.ok(entry.length > 0, name);
assert.ok(entry.tris <= 50_000, name);
total += bytes.length;
}
// The set ships with the repository and loads up front: keep it small.
assert.ok(total < 3 * 1024 * 1024, `fleet GLBs total ${total} bytes`);
});
test('transit bodies mark the share of the route colour they take; other models mark none', () => {
for (const [name, entry] of Object.entries(catalog)) {
const { json: gltf } = readGlb(path.join(DIR, entry.file));
for (const mesh of gltf.meshes)
for (const primitive of mesh.primitives)
assert.equal(
'_TINT' in primitive.attributes,
entry.category === 'transit',
name,
);
assert.equal(
fleetModelSpec(set, name, null).tinted,
entry.category === 'transit',
);
}
});
test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => {
assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320');
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
// An unlisted fast jet is still a fast jet, not the generic airliner.
assert.equal(
aircraftModelSpec(set, { typeCode: 'FA18', category: 'A3' }).model,
't38',
);
assert.equal(aircraftModelSpec(set, { typeCode: 'MQ9' }).model, 'mq9');
assert.equal(
aircraftModelSpec(set, { typeCode: 'PUMA' }).model,
'generic_heli',
);
// An unlisted type of no special class falls to its category.
assert.equal(
aircraftModelSpec(set, { typeCode: 'ZZZZ', category: 'A5' }).model,
'b77w',
);
// OpenSky's integer category: 8 is rotorcraft (A7), 2 light (A1).
assert.equal(emitterCategory(8), 'A7');
assert.equal(emitterCategory('a3'), 'A3');
assert.equal(emitterCategory(1), undefined);
assert.equal(aircraftModelSpec(set, { category: 8 }).model, 'generic_heli');
assert.equal(aircraftModelSpec(set, { category: 2 }).model, 'c172');
assert.equal(aircraftModelSpec(set, {}).model, types.default_aircraft[0]);
});
test('aircraft specs are life size, standing on their scaled clearance', () => {
const spec = aircraftModelSpec(set, { typeCode: 'A21N' });
const [, length] = types.aircraft.A21N;
assert.equal(spec.model, types.aircraft.A21N[0]);
assert.ok(Math.abs(spec.length - length) < 1e-9);
assert.ok(Math.abs(spec.scale - length / catalog[spec.model].length) < 1e-12);
assert.ok(
Math.abs(spec.clearanceM - catalog[spec.model].clearance * spec.scale) <
1e-12,
);
assert.equal(spec.url, `${FLEET_MODEL_BASE}${catalog[spec.model].file}`);
assert.equal(spec.tinted, false);
assert.equal(spec.elevationM, 0);
assert.ok(
spec.nativeRadiusM * spec.scale > 25 &&
spec.nativeRadiusM * spec.scale < 60,
);
assert.deepEqual(spec.trailAnchorNative, FLEET_TRAIL_ANCHORS[spec.model]);
// A grounded service vehicle and an obstacle are drawn as built: the
// tower's 60 m is its height, not a length to stretch it to.
const tower = aircraftModelSpec(set, { category: 'C3' });
assert.equal(tower.model, 'tower');
assert.equal(tower.scale, 1);
assert.equal(aircraftModelSpec(set, { category: 'C1' }).scale, 1);
});
test('vessel specs read AIS codes and text families, stretched to the reported beam', () => {
assert.equal(aisShipTypeCode('70'), 70);
assert.equal(aisShipTypeCode(' Cargo '), 70);
assert.equal(aisShipTypeCode('Tanker'), 80);
assert.equal(aisShipTypeCode('Tug'), 52);
assert.equal(aisShipTypeCode('0'), undefined);
assert.equal(aisShipTypeCode(''), undefined);
const box = vesselModelSpec(set, { type: '71', length: 366, beam: 51 });
assert.equal(box.model, 'container_ship');
assert.ok(Math.abs(box.length - 366) < 1e-9);
const [sx, sy, sz] = box.scaleXYZ;
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12);
// A beam with no reported length: the type's typical length, in the model's
// own proportions, not a typical-length hull squeezed to that beam.
const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 });
assert.equal(unsized.model, 'container_ship');
const [ux, uy, uz] = unsized.scaleXYZ;
assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12);
assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug');
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
// No type, no size: the generic vessel as built.
const unknown = vesselModelSpec(set, {});
assert.equal(unknown.model, types.default_vessel);
assert.equal(unknown.scale, 1);
});
test('transit specs: rail category and mode, then route type, then mode, then the default', () => {
assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), {
model: 'intercity',
length: types.transit.rail.national.rail[1],
});
assert.equal(
resolveTransit(types, { railCategory: 'transit', railMode: 'metro' }).model,
'metro',
);
assert.equal(
resolveTransit(types, { railCategory: 'regional', railMode: 'tram' }).model,
'light_rail',
);
assert.equal(resolveTransit(types, { routeType: 3 }).model, 'city_bus');
assert.equal(resolveTransit(types, { routeType: '0' }).model, 'tram');
assert.equal(resolveTransit(types, { routeType: 109 }).model, 'commuter_emu');
assert.equal(resolveTransit(types, { mode: 'subway' }).model, 'metro');
assert.equal(resolveTransit(types, { mode: 'ferry' }).model, 'fast_ferry');
assert.equal(
resolveTransit(types, { mode: 'unknown' }).model,
types.transit.default[0],
);
const tram = transitModelSpec(set, { mode: 'tram' });
assert.equal(tram.tinted, true);
assert.equal(tram.clearanceM, 0);
assert.ok(Math.abs(tram.length - types.transit.modes.tram[1]) < 1e-9);
assert.equal(
transitModelSpec(set, { routeType: 12 }).elevationM,
types.transit.elevation.monorail,
);
assert.equal(
transitModelSpec(set, { mode: 'gondola' }).elevationM,
types.transit.elevation.gondola,
);
assert.equal(transitModelSpec(set, { mode: 'ferry' }).tinted, false);
});
test('a model missing from the catalog falls back to the default of its kind', () => {
const thin = {
catalog: { ...catalog },
types: {
...types,
aircraft: { ...types.aircraft, XXXX: ['no_such_model', 20] },
},
};
assert.equal(
aircraftModelSpec(thin, { typeCode: 'XXXX' }).model,
types.default_aircraft[0],
);
});
const ANCHORED = Object.entries(catalog).filter(([, entry]) =>
['aircraft', 'helicopters', 'other'].includes(entry.category),
);
test('every aircraft-layer model has a trail anchor on its real aft-belly hull', async () => {
assert.deepEqual(
new Set(Object.keys(FLEET_TRAIL_ANCHORS)),
new Set(ANCHORED.map(([name]) => name)),
're-run scripts/measure-fleet-trail-anchors.mjs',
);
for (const [name, entry] of ANCHORED) {
const mesh = await fleetMesh(path.join(DIR, entry.file));
const { min, max } = vertexBounds(mesh.verts);
const anchor = FLEET_TRAIL_ANCHORS[name];
// On the hull, not in the air beside it.
const offHull = distanceToSurface(anchor, mesh.verts, mesh.tris);
assert.ok(
offHull <= 1e-3,
`${name}: trail anchor is ${offHull.toFixed(4)} m off the hull`,
);
// Aft of the middle, so the head segment does not run into the body.
assert.ok(
anchor[0] > (min[0] + max[0]) / 2,
`${name}: trail anchor is not aft`,
);
// Nothing of the centreline hangs lower at or behind its station.
const lowest = centrelineSegments(mesh.verts, mesh.tris)
.flat()
.filter((p) => p[0] >= anchor[0] - 1e-3)
.reduce((low, p) => Math.min(low, p[1]), Infinity);
assert.ok(
anchor[1] <= lowest + 1e-3,
`${name}: trail anchor is above the hull aft of it`,
);
assert.equal(anchor[2], 0, `${name}: trail anchor is off the centreline`);
// And it is the construction, re-derived.
const measured = measuredTrailAnchor(mesh);
for (const axis of [0, 1])
assert.ok(
Math.abs(anchor[axis] - measured[axis]) <= 1e-3,
`${name} axis ${axis}: table ${anchor[axis]} vs measured ${measured[axis].toFixed(4)}`,
);
}
});
test('catalog bounds match the GLBs they describe', async () => {
for (const [name, entry] of Object.entries(catalog)) {
const { min, max } = vertexBounds(
(await fleetMesh(path.join(DIR, entry.file))).verts,
);
// The catalog is Z up (glTF Y), glTF Z across.
const tolerance = 0.01 * Math.max(1, entry.length);
assert.ok(Math.abs(min[0] - entry.min[0]) <= tolerance, `${name} min x`);
assert.ok(Math.abs(max[0] - entry.max[0]) <= tolerance, `${name} max x`);
assert.ok(Math.abs(min[1] - entry.min[2]) <= tolerance, `${name} min up`);
assert.ok(
Math.abs(-min[1] - entry.clearance) <= tolerance,
`${name} clearance`,
);
assert.ok(
Math.abs(max[2] - min[2] - (entry.max[1] - entry.min[1])) <= tolerance,
`${name} beam`,
);
}
});
test('the model set loads once, every GLB up front, shared by every layer', async () => {
resetFleetModelSet();
const requested = [];
const fetchImpl = async (url) => {
requested.push(url);
const file = path.join(ROOT, 'public', url.replace(/^\/app/, ''));
if (!fs.existsSync(file)) return { ok: false, status: 404 };
const bytes = fs.readFileSync(file);
return {
ok: true,
json: async () => JSON.parse(bytes.toString('utf8')),
arrayBuffer: async () =>
bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
),
};
};
const resolveAsset = (url) => `/app${url}`;
const [first, second] = await Promise.all([
loadFleetModelSet({ resolveAsset, fetchImpl }),
loadFleetModelSet({ resolveAsset, fetchImpl }),
]);
assert.equal(first, second);
assert.equal(fleetModelSet(), first);
assert.equal(first.bytes.size, Object.keys(catalog).length);
assert.equal(requested.length, Object.keys(catalog).length + 2);
assert.ok(requested.every((url) => url.startsWith('/app/models/fleet/')));
assert.ok(first.totalBytes > 0 && Number.isFinite(first.loadMs));
await loadFleetModelSet({ resolveAsset, fetchImpl });
assert.equal(requested.length, Object.keys(catalog).length + 2);
resetFleetModelSet();
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Aft-belly trail attachment points of the fleet models the aircraft layers
* draw, in raw glTF axes (X along the body, nose toward -X; Y up), metres
* before scale, node transforms applied. Each is the point of the model's
* centreline profile (its z = 0 slice) nearest its aft-belly bounding-box
* corner: on the hull, aft, and on the centreline (see modelVisualAnchor.js).
*
* Written by scripts/measure-fleet-trail-anchors.mjs; re-measured against the
* GLBs by fleetModels.test.mjs.
*/
export const FLEET_TRAIL_ANCHORS = Object.freeze({
a139: Object.freeze([6.8847, 0.2766, 0]),
a320: Object.freeze([17.2078, 0.7416, 0]),
a333: Object.freeze([29.7276, 1.1084, 0]),
a343: Object.freeze([30.0371, 1.2335, 0]),
a359: Object.freeze([31.0731, 1.1573, 0]),
a388: Object.freeze([34.3139, 1.9279, 0]),
as50: Object.freeze([5.2278, -0.0999, 0]),
at72: Object.freeze([12.8258, 0.6792, 0]),
b06: Object.freeze([4.62, -0.3002, 0]),
b407: Object.freeze([5.1264, -0.2797, 0]),
b738: Object.freeze([18.3819, 0.8391, 0]),
b744: Object.freeze([33.6232, 1.5411, 0]),
b763: Object.freeze([25.5464, 0.9889, 0]),
b77w: Object.freeze([34.4639, 1.2084, 0]),
b789: Object.freeze([29.0604, 1.0649, 0]),
balloon: Object.freeze([3.8215, -0.0009, 0]),
bcs3: Object.freeze([17.8658, 0.713, 0]),
be20: Object.freeze([6.6699, 0.5505, 0]),
blimp: Object.freeze([32.2493, -7.1382, 0]),
c17: Object.freeze([26.5207, 2.332, 0]),
c172: Object.freeze([4.0507, 0.4481, 0]),
c30j: Object.freeze([13.568, 0.9917, 0]),
c56x: Object.freeze([7.4627, 0.4526, 0]),
car: Object.freeze([2.3, 0.418, 0]),
cl35: Object.freeze([10.4607, 0.8247, 0]),
crj9: Object.freeze([17.8413, 0.8624, 0]),
dh8d: Object.freeze([15.7902, 0.7501, 0]),
e175: Object.freeze([14.697, 0.6077, 0]),
e55p: Object.freeze([7.6566, 0.503, 0]),
ec35: Object.freeze([5.1003, 0.1471, 0]),
ec45: Object.freeze([5.8004, 0.555, 0]),
fire_truck: Object.freeze([5.5, 0.6051, 0]),
generic_heli: Object.freeze([5.4997, 0.1604, 0]),
generic_plane: Object.freeze([16.5228, 0.712, 0]),
glf4: Object.freeze([13.4603, 0.8243, 0]),
glider: Object.freeze([3.3997, 0.1283, 0]),
h47: Object.freeze([7.7303, 0.0035, 0]),
h60: Object.freeze([7.6295, 0.1365, 0]),
h64: Object.freeze([6.9228, -0.5553, 0]),
k35r: Object.freeze([28.0221, -2.4508, 0]),
md11: Object.freeze([30.8002, 1.2291, 0]),
mq9: Object.freeze([5.8498, -0.062, 0]),
p28a: Object.freeze([3.3695, 0.3959, 0]),
pc12: Object.freeze([6.6416, 0.5288, 0]),
pushback: Object.freeze([3, 0.2499, 0]),
r44: Object.freeze([4.4219, -0.3001, 0]),
s76: Object.freeze([6.6001, 0.1915, 0]),
sr22: Object.freeze([3.6762, 0.2376, 0]),
t38: Object.freeze([7.0246, -0.3791, 0]),
tex2: Object.freeze([4.7383, 0.2302, 0]),
tower: Object.freeze([1.2952, -0.0007, 0]),
ultralight: Object.freeze([3.2271, 0.3259, 0]),
v22: Object.freeze([7.9868, 0.2956, 0]),
van: Object.freeze([2.75, 0.418, 0]),
});
File diff suppressed because it is too large. Load diff
+7 -87
View File
@@ -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.
+19 -6
View File
@@ -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.
+3 -3
View File
@@ -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);
+10 -28
View File
@@ -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();
+64 -46
View File
@@ -1,52 +1,70 @@
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,
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;
// Layers ask on every tick; each type and category is resolved once per set.
let cache = specCaches.get(set);
if (!cache) specCaches.set(set, (cache = new Map()));
const key = aircraftModelSpecCacheKey(aircraft);
let layerSpec = cache.get(key);
if (!layerSpec) {
const spec = aircraftModelSpec(set, {
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
category: aircraft?.category ?? null,
});
layerSpec = Object.freeze({
...spec,
bellyM: spec.clearanceM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(real.url),
trailAnchorNative: trailAnchorForModel(real.url),
};
});
cache.set(key, layerSpec);
}
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,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(url),
trailAnchorNative: trailAnchorForModel(url),
};
return layerSpec;
}
/** @type {WeakMap<object, Map<string, object>>} set -> cache key -> spec */
const specCaches = new WeakMap();
/**
* 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 ?? ''}`;
}
+4 -4
View File
@@ -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
+20 -17
View File
@@ -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;
+1 -3
View File
@@ -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
+38 -26
View File
@@ -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,
+8 -3
View File
@@ -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
+9 -18
View File
@@ -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();
+3
View File
@@ -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) {
+15 -4
View File
@@ -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(
+33 -19
View File
@@ -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?.();
+10 -28
View File
@@ -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();
+4 -4
View File
@@ -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
+7 -41
View File
@@ -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);
+1 -3
View File
@@ -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
+2
View File
@@ -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,
+45 -79
View File
@@ -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,
+9 -4
View File
@@ -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 {
+9 -16
View File
@@ -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();
+3
View File
@@ -95,6 +95,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
parts.rendering.onPreRender,
);
state._networkFade = -1;
parts.rendering.startModels();
parts.viewport.loadCatalog();
parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer);
@@ -127,6 +128,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
state._heldFeedId = null;
state._feedStatus.clear();
dropAllTrains();
parts.rendering.stopModels();
// Floor cells and track samples queued for trains now gone.
parts.rendering.reset();
parts.routes.hideAll();
@@ -154,6 +156,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
/** Tear down the collection and release every shared registration. */
destroy(viewer) {
this.disable(viewer);
parts.rendering.stopModels(true);
parts.routes.destroy();
if (state._markers) {
unregisterSpriteCollection('rail', state._markers);
+153 -4
View File
@@ -26,6 +26,22 @@ import {
UNSELECTED_TRAIN_ALPHA,
} from './policy.js';
import { placeOnPath } from './pathBinding.js';
import {
fleetModelSet,
loadFleetModelSet,
transitModelSpec,
} from '../../data/fleetModels.js';
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
/** How often the trains near enough for a model are gathered again (wall ms). */
const MODEL_CANDIDATES_MS = 500;
/** Each category's colour, painted where a train model marks it. */
const CATEGORY_TINTS = Object.fromEntries(
Object.entries(RAIL_CATEGORY_COLORS).map(([category, css]) => [
category,
Cesium.Color.fromCssColorString(css),
]),
);
const SPRITE_PX = 64;
/** Billboard tints; the setter copies, so sharing one instance is safe. */
@@ -507,6 +523,11 @@ export function createRendering({ state, services, parts }) {
);
}
/** Whether a train's icon is drawn: shown, and not drawn by its model. */
function markerShown(entry) {
return categoryShown(entry) && !entry.modelOwnsVisual;
}
/**
* A train bound to its railway line is drawn on the line, at the arc its
* playback has reached; any other train (or a bound train whose reports do
@@ -536,7 +557,7 @@ export function createRendering({ state, services, parts }) {
undefined,
scratchPosition,
);
entry.marker.show = categoryShown(entry);
entry.marker.show = markerShown(entry);
}
/** While a train is selected, every other train is drawn see-through. */
@@ -554,7 +575,7 @@ export function createRendering({ state, services, parts }) {
entry.marker.width = size;
entry.marker.height = size;
entry.marker.color = tintFor(entry);
entry.marker.show = categoryShown(entry);
entry.marker.show = markerShown(entry);
}
/** Re-tint every train after the selection appeared, moved or cleared. */
@@ -658,7 +679,7 @@ export function createRendering({ state, services, parts }) {
function cullSelected() {
const entry = state._trains.get(state._selectedKey);
if (!entry?.marker) return;
const shown = categoryShown(entry) && facesCamera(entry.marker.position);
const shown = markerShown(entry) && facesCamera(entry.marker.position);
if (entry.marker.show !== shown) entry.marker.show = shown;
parts.routes.cullStops(facesCamera);
}
@@ -666,6 +687,132 @@ export function createRendering({ state, services, parts }) {
function onPreRender() {
advanceMoving();
if (state._enabled && state._selectedKey !== null) cullSelected();
if (state._enabled) updateModels();
}
// Under the DISPLAY rail's 3D toggle, trains near the camera are drawn as
// the project's procedural fleet models, chosen by category and mode,
// painted in their category colour where the model marks it, on the
// aircraft layers' icon-to-model rules (services.fleet). The fleet set
// loads once, up front, when the layer starts.
let fleetModels = null;
/** Items drawn last frame, and the map the next frame's are gathered into. */
let modelItems = new Map();
let spareItems = new Map();
let modelCandidatesAtMs = -Infinity;
/** Load the fleet set and create the layer's models (on enable). */
function startModels() {
const fleet = services.fleet;
if (!fleet?.policy || !state._viewer) return;
loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => {
/* models stay off; trains keep their icons */
});
fleetModels ||= createFleetModelLayer({
viewer: state._viewer,
display: fleet.display,
policy: fleet.policy,
resolveAsset: fleet.resolveAsset,
});
}
/** Hand a train's visual back to its icon. */
function handBack(entry) {
if (!entry.modelOwnsVisual) return;
entry.modelOwnsVisual = false;
if (entry.marker) entry.marker.show = markerShown(entry);
}
function modelItem(entry, set) {
let item = modelItems.get(entry.key);
if (!item || item.entry !== entry)
item = {
entry,
pickId: entry.key,
modelOwnsVisual: false,
// Markers are drawn this far above the track; a model stands on it.
groundLiftM: GROUND_FLOOR_LIFT_M,
};
const specKey = `${entry.category}|${entry.railMode}`;
if (item.specKey !== specKey) {
item.specKey = specKey;
item.spec = transitModelSpec(set, {
railCategory: entry.category,
railMode: entry.railMode,
});
item.tint = CATEGORY_TINTS[entry.category] || CATEGORY_TINTS.local;
}
item.position = entry.marker.position;
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
item.show = categoryShown(entry);
return item;
}
function updateModels() {
if (!fleetModels) return;
if (!fleetModels.active() || state._trains.size === 0) {
if (modelItems.size) {
for (const item of modelItems.values()) handBack(item.entry);
modelItems.clear();
fleetModels.frame(modelItems, performance.now());
}
return;
}
const set = fleetModelSet();
const now = performance.now();
const next = spareItems;
next.clear();
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
modelCandidatesAtMs = now;
const camera = state._viewer?.camera;
const policy = services.fleet.policy;
const keepM =
services.fleet.display?.()?.models3dMode === 'all'
? policy.keepAllM
: policy.keepM;
const limitSq = keepM * keepM * 1.44;
if (camera?.positionWC)
for (const entry of state._trains.values()) {
if (!entry.marker || !categoryShown(entry)) continue;
if (
Cesium.Cartesian3.distanceSquared(
camera.positionWC,
entry.marker.position,
) > limitSq
)
continue;
next.set(entry.key, modelItem(entry, set));
}
} else {
for (const [key, item] of modelItems)
if (state._trains.get(key) === item.entry && item.entry.marker)
next.set(key, modelItem(item.entry, set));
}
for (const [key, item] of modelItems)
if (next.get(key) !== item) handBack(item.entry);
spareItems = modelItems;
modelItems = next;
fleetModels.frame(modelItems, now);
for (const item of modelItems.values()) {
const entry = item.entry;
if (item.modelOwnsVisual) {
entry.modelOwnsVisual = true;
entry.marker.show = false;
} else handBack(entry);
}
if (fleetModels.settling) governorRequestRender('rail-models');
}
/** Drop every model (layer disabled); `destroy` also removes the collection. */
function stopModels(destroy = false) {
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
modelItems.clear();
spareItems.clear();
modelCandidatesAtMs = -Infinity;
if (destroy) {
fleetModels?.destroy();
fleetModels = null;
} else fleetModels?.clear();
}
function advanceMoving() {
@@ -697,7 +844,7 @@ export function createRendering({ state, services, parts }) {
function applyCategoryVisibility() {
for (const entry of state._trains.values()) {
if (!entry.marker) continue;
entry.marker.show = categoryShown(entry);
entry.marker.show = markerShown(entry);
schedulePlayback(entry);
}
syncRenderHold();
@@ -720,5 +867,7 @@ export function createRendering({ state, services, parts }) {
applyCategoryVisibility,
categoryShown,
reset,
startModels,
stopModels,
};
}
+7 -2
View File
@@ -19,10 +19,15 @@ import { t } from '../../i18n/index.js';
export function createSelection({ state, services, parts }) {
const { governorRequestRender } = services.render;
/** The card's anchor: the marker's current position, read on every paint. */
/**
* The card's anchor: the marker's current position, read on every paint,
* whether the train is drawn as its marker or as its 3D model.
*/
function anchorFor(entry) {
return () => {
const p = entry.marker?.show !== false ? entry.marker?.position : null;
const drawn =
entry.marker?.show !== false || entry.modelOwnsVisual === true;
const p = drawn ? entry.marker?.position : null;
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
};
}
+3
View File
@@ -195,6 +195,7 @@ export function createLifecycle({ state, services, parts }) {
parts.rendering.onPreRender,
);
}
parts.rendering.startModels();
parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer);
},
@@ -242,6 +243,7 @@ export function createLifecycle({ state, services, parts }) {
destroyTrack(entry.track);
}
state._vehicles.clear();
parts.rendering.stopModels();
parts.height.clear();
state._markers?.removeAll();
state._animatedMarkers?.removeAll();
@@ -282,6 +284,7 @@ export function createLifecycle({ state, services, parts }) {
*/
destroy(viewer) {
this.disable(viewer);
parts.rendering.stopModels(true);
if (state._markers) {
unregisterSpriteCollection('transit', state._markers);
viewer?.scene?.primitives?.remove(state._markers);
+3 -1
View File
@@ -129,7 +129,9 @@ export function createQueries({ state, parts }) {
const visible = [];
for (const entry of state._visible) {
const marker = entry.marker;
if (!marker?.position || marker.show === false) continue;
// A vehicle drawn as its 3D model is present though its marker is hidden.
if (!marker?.position) continue;
if (marker.show === false && entry.modelOwnsVisual !== true) continue;
visible.push(entry);
}
let chosen = visible;
+117 -2
View File
@@ -27,6 +27,12 @@ import {
TRANSIT_MODE_COLORS,
vehicleNearView,
} from './policy.js';
import {
fleetModelSet,
loadFleetModelSet,
transitModelSpec,
} from '../../data/fleetModels.js';
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
export const MODE_CESIUM_COLORS = Object.fromEntries(
Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [
@@ -100,7 +106,8 @@ export function createRendering({ state, services, parts }) {
);
entry.surfaceReady = !!known;
entry.heightPending = !known;
entry.marker.show = !!known && vehicleInView(entry);
entry.marker.show =
!!known && vehicleInView(entry) && !entry.modelOwnsVisual;
if (known) {
entry.marker.position = state._scratchCartesian;
entry.hasRendered = true;
@@ -403,6 +410,7 @@ export function createRendering({ state, services, parts }) {
const now = state.now();
if (state._vehicles.size === 0) {
state._moving.clear();
updateModels();
syncRenderHold();
return;
}
@@ -450,9 +458,111 @@ export function createRendering({ state, services, parts }) {
: 0;
}
if (state._heightDirty.size) governorRequestRender('transit-height-batch');
updateModels();
syncRenderHold();
}
// Under the DISPLAY rail's 3D toggle, vehicles in view near the camera are
// drawn as the project's procedural fleet models, chosen by mode, painted
// in their mode colour where the model marks it, on the aircraft layers'
// icon-to-model rules (services.fleet). The fleet set loads once, up front,
// when the layer starts.
let fleetModels = null;
/** Items drawn last frame, and the map the next frame's are gathered into. */
let modelItems = new Map();
let spareItems = new Map();
/** The lift icons are drawn with above the floor; a model stands on the floor. */
const groundLiftM = services.ground?.GROUND_FLOOR_LIFT_M || 0;
/** Load the fleet set and create the layer's models (on enable). */
function startModels() {
const fleet = services.fleet;
if (!fleet?.policy || !state._viewer) return;
loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => {
/* models stay off; vehicles keep their icons */
});
fleetModels ||= createFleetModelLayer({
viewer: state._viewer,
display: fleet.display,
policy: fleet.policy,
resolveAsset: fleet.resolveAsset,
});
}
/** Hand an entry's visual back to its icon. */
function handBack(entry) {
if (!entry.modelOwnsVisual) return;
entry.modelOwnsVisual = false;
if (entry.marker)
entry.marker.show =
vehicleInView(entry) &&
!entry.heightPending &&
entry.surfaceReady !== false;
}
function updateModels() {
if (!fleetModels) return;
if (!fleetModels.active() || state._vehicles.size === 0) {
if (modelItems.size) {
for (const item of modelItems.values()) handBack(item.entry);
modelItems.clear();
fleetModels.frame(modelItems, performance.now());
}
return;
}
const set = fleetModelSet();
const next = spareItems;
next.clear();
for (const entry of state._visible) {
// A vehicle of no known mode keeps its icon: no model says what it is.
if (!entry.marker || !entry.hasRendered || entry.mode === 'unknown')
continue;
let item = modelItems.get(entry.key);
if (!item || item.entry !== entry) {
item = {
entry,
pickId: entry.key,
modelOwnsVisual: false,
groundLiftM,
};
}
if (item.mode !== entry.mode) {
item.mode = entry.mode;
item.spec = transitModelSpec(set, { mode: entry.mode });
item.tint =
MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown;
}
item.position = entry.marker.position;
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
item.show = !entry.heightPending && entry.surfaceReady !== false;
next.set(entry.key, item);
}
for (const [key, item] of modelItems)
if (next.get(key) !== item) handBack(item.entry);
spareItems = modelItems;
modelItems = next;
fleetModels.frame(modelItems, performance.now());
for (const item of modelItems.values()) {
const entry = item.entry;
if (item.modelOwnsVisual) {
entry.modelOwnsVisual = true;
entry.marker.show = false;
} else handBack(entry);
}
if (fleetModels.settling) governorRequestRender('transit-models');
}
/** Drop every model (layer disabled); `destroy` also removes the collection. */
function stopModels(destroy = false) {
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
modelItems.clear();
spareItems.clear();
if (destroy) {
fleetModels?.destroy();
fleetModels = null;
} else fleetModels?.clear();
}
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
const sphere = new Cesium.BoundingSphere(undefined, 5);
const cameraPosition = new Cesium.Cartesian3();
@@ -556,7 +666,10 @@ export function createRendering({ state, services, parts }) {
}
}
entry.marker.show =
visible && !entry.heightPending && entry.surfaceReady !== false;
visible &&
!entry.heightPending &&
entry.surfaceReady !== false &&
!entry.modelOwnsVisual;
visibility.heightPending = !!entry.heightPending;
visibility.surfaceReady = entry.surfaceReady ?? null;
schedulePlayback(entry);
@@ -621,5 +734,7 @@ export function createRendering({ state, services, parts }) {
setStylePreset,
onPreRender,
syncRenderHold,
startModels,
stopModels,
};
}
+4 -2
View File
@@ -24,14 +24,16 @@ export function createSelection({ state, services, parts }) {
* (the tracked-aircraft readout uses the same), so the anchor moves with the
* sprite at frame rate, through poll arrivals, settling, a late floor and a
* reset alike, with no per-frame work here. Null when there is nothing to
* anchor to: a hidden or removed marker draws no card.
* anchor to: a hidden or removed marker draws no card, unless the vehicle's
* 3D model is drawing it in the marker's place.
* @param {object} entry
* @returns {() => Cesium.Cartesian3|null}
*/
function anchorFor(entry) {
return () => {
const marker = entry.marker;
const p = marker?.show !== false ? marker?.position : null;
const drawn = marker?.show !== false || entry.modelOwnsVisual === true;
const p = drawn ? marker?.position : null;
return p &&
Number.isFinite(p.x) &&
Number.isFinite(p.y) &&
+2
View File
@@ -213,6 +213,8 @@ export function createIngestion({
imo: record.imo,
type: record.type,
destination: record.destination,
length: record.lengthM ?? null,
beam: record.beamM ?? null,
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
course: record.courseDeg,
heading: record.headingDeg,
+1
View File
@@ -248,6 +248,7 @@ export function createLifecycle({
if (state.preRenderRemover) {
state.preRenderRemover();
}
components.rendering.destroyModels();
resetState();
},
};
+7 -1
View File
@@ -664,7 +664,13 @@ export function createQueries({
if ((idx - start) % stride !== 0) continue;
const record = records[idx];
const visual = components.rendering.getVisual(record);
if (visual.billboard && !visual.billboard.show) continue;
// A vessel drawn as its model is present though its chevron is hidden.
if (
visual.billboard &&
!visual.billboard.show &&
!visual.modelOwnsVisual
)
continue;
const position = visual.billboard?.position || visual.position;
if (!position) continue;
result.push({
+10
View File
@@ -25,6 +25,9 @@ export function normalizeVessel(row) {
imo: String(row.imo || ''),
type: String(row.type || ''),
destination: String(row.destination || ''),
// Hull length and beam (metres), null when unreported.
length: positiveNumber(row.length),
beam: positiveNumber(row.beam),
speed: finiteNumber(row.speed),
course: finiteNumber(row.course),
heading: finiteNumber(row.heading),
@@ -46,6 +49,11 @@ function stampFix(record, receivedAtMs, feedLatestEpoch) {
record.fixFeedEpoch = feedLatestEpoch;
}
function positiveNumber(value) {
const number = finiteNumber(value);
return number !== null && number > 0 ? number : null;
}
function finiteNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
@@ -147,6 +155,8 @@ export class VesselRecords {
record.imo = next.imo;
record.type = next.type;
record.destination = next.destination;
record.length = next.length;
record.beam = next.beam;
record.speed = next.speed;
record.course = next.course;
record.heading = next.heading;
+167 -1
View File
@@ -22,10 +22,19 @@ import {
followsClock,
} from './policy.js';
import { vesselPositionAt } from './motion.js';
import {
fleetModelSet,
loadFleetModelSet,
vesselModelSpec,
} from '../../data/fleetModels.js';
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
/** How often vessels move on as the world clock plays (wall ms). */
const FOLLOW_POSITION_MS = 250;
/** How often the vessels near enough for a model are gathered again (wall ms). */
const MODEL_CANDIDATES_MS = 500;
export function createRendering({
vesselState,
services,
@@ -56,6 +65,12 @@ export function createRendering({
VESSEL_LIFT_M,
);
visual.position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
// A ship model's origin is its waterline: the sea surface, unlifted.
visual.waterline = Cesium.Cartesian3.fromDegrees(
lon,
lat,
heightM - VESSEL_LIFT_M,
);
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
visual.position,
@@ -101,6 +116,7 @@ export function createRendering({
}
function ensureCollections(viewer) {
ensureModels(viewer);
if (!viewer || state.billboardCollection) return;
state.billboardCollection = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
@@ -203,6 +219,143 @@ export function createRendering({
return icon;
}
// Under the DISPLAY rail's 3D toggle, vessels near the camera are drawn as
// the project's procedural fleet models at their reported length and beam,
// on the aircraft layers' icon-to-model rules (services.fleet). The fleet
// set loads once, up front, when the layer starts.
let fleetModels = null;
/** Items drawn last frame, and the map the next frame's are gathered into. */
let modelItems = new Map();
let spareItems = new Map();
let modelCandidatesAtMs = -Infinity;
const modelOccluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
function ensureModels(viewer) {
const fleet = services.fleet;
if (!viewer || fleetModels || !fleet?.policy) return;
loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => {
/* models stay off; vessels keep their icons */
});
fleetModels = createFleetModelLayer({
viewer,
display: fleet.display,
policy: fleet.policy,
resolveAsset: fleet.resolveAsset,
// A hull below the waterline would show through a plain globe.
waterline: true,
});
}
/**
* Whether a vessel's chevron is drawn: on the camera's side of the Earth,
* not the bridge camera's own vessel, and not drawn by its model. The
* visibility pass and the model handoff both decide by this.
*/
function billboardShown(record, occluder) {
const visual = getVisual(record);
return (
isVisible(visual.surfacePosition, occluder) &&
record.id !== state.cockpitSubjectId &&
!visual.modelOwnsVisual
);
}
/** The model item for `record`, its spec rebuilt only when its type or size changes. */
function modelItem(record, set) {
let item = modelItems.get(record.id);
if (!item || item.record !== record)
item = { record, pickId: record, tint: null, modelOwnsVisual: false };
const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`;
if (item.specKey !== specKey) {
item.specKey = specKey;
item.spec = vesselModelSpec(set, record);
}
item.position = getVisual(record).waterline;
item.headingDeg = vesselCourseDeg(record);
item.show = record.id !== state.cockpitSubjectId;
return item;
}
/** Gather the vessels within the models' keep radius of the camera into `into`. */
function gatherModelItems(set, into) {
const camera = state.viewer?.camera;
if (!camera?.positionWC) return;
const policy = services.fleet.policy;
const keepM =
services.fleet.display?.()?.models3dMode === 'all'
? policy.keepAllM
: policy.keepM;
const limitSq = keepM * keepM * 1.44;
for (const record of state.records.all) {
const waterline = getVisual(record).waterline;
if (!waterline) continue;
if (
Cesium.Cartesian3.distanceSquared(camera.positionWC, waterline) >
limitSq
)
continue;
into.set(record.id, modelItem(record, set));
}
}
/** Give a vessel's chevron back when its model no longer draws it. */
function handBackVisual(record, occluder) {
const visual = getVisual(record);
if (!visual.modelOwnsVisual) return;
visual.modelOwnsVisual = false;
if (visual.billboard)
visual.billboard.show = billboardShown(record, occluder);
}
function updateModels() {
if (!fleetModels) return;
const camera = state.viewer?.camera;
const occluder = camera?.positionWC ? modelOccluder : null;
if (occluder) occluder.cameraPosition = camera.positionWC;
if (!fleetModels.active()) {
if (modelItems.size) {
modelItems.clear();
fleetModels.frame(modelItems, performance.now());
for (const record of state.records.all)
handBackVisual(record, occluder);
}
return;
}
const set = fleetModelSet();
const now = performance.now();
const next = spareItems;
next.clear();
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
modelCandidatesAtMs = now;
gatherModelItems(set, next);
} else {
for (const [id, item] of modelItems)
if (state.records.byId.get(id) === item.record)
next.set(id, modelItem(item.record, set));
}
for (const [id, item] of modelItems)
if (next.get(id) !== item) handBackVisual(item.record, occluder);
spareItems = modelItems;
modelItems = next;
fleetModels.frame(modelItems, now);
for (const item of modelItems.values()) {
const visual = getVisual(item.record);
if (item.modelOwnsVisual) {
visual.modelOwnsVisual = true;
if (visual.billboard) visual.billboard.show = false;
} else handBackVisual(item.record, occluder);
}
if (fleetModels.settling) governorRequestRender('ais-vessels-models');
}
function destroyModels() {
fleetModels?.destroy();
fleetModels = null;
modelItems = new Map();
spareItems = new Map();
modelCandidatesAtMs = -Infinity;
}
// Vessels move only when a poll lands, so the layer does not hold the
// continuous render loop. Its passes run in preRender, which idle mode fires
// only for rendered frames: a forced pass (new data, selection) requests its
@@ -216,6 +369,7 @@ export function createRendering({
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (followsClock(vesselState)) followClockPositions();
updateVisibility();
if (state.feed.enabled) updateModels();
});
}
@@ -306,8 +460,11 @@ export function createRendering({
const visible = isVisible(visual.surfacePosition, occluder);
if (visual.billboard) {
// The bridge camera sits on this sprite; showing it would fill the view.
// The same rule as billboardShown, its horizon test already made.
visual.billboard.show =
visible && record.id !== state.cockpitSubjectId;
visible &&
record.id !== state.cockpitSubjectId &&
!visual.modelOwnsVisual;
if (visible && doRotations && scene) {
const rot = screenProjectedRotation(
scene,
@@ -582,6 +739,13 @@ export function createRendering({
if (state.billboardCollection) {
state.billboardCollection.show = show;
}
fleetModels?.setVisible(show);
if (!show && fleetModels) {
fleetModels.clear();
for (const item of modelItems.values())
getVisual(item.record).modelOwnsVisual = false;
modelItems.clear();
}
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
}
return {
@@ -607,5 +771,7 @@ export function createRendering({
labelPriority,
hasUsefulName,
setVisible,
updateModels,
destroyModels,
};
}
+9
View File
@@ -10,6 +10,12 @@ function vesselMmsi(row) {
return MMSI_PATTERN.test(text) ? text : '';
}
/** A positive measurement, else null. */
function positive(value) {
const number = finite(value);
return number !== null && number > 0 ? number : null;
}
/** A row's stable id when its source names none: its MMSI or identifier. */
function defaultVesselId(row) {
return String(row?.mmsi || row?.input_identifier || '');
@@ -39,6 +45,9 @@ export function normalizeVesselObservation(row, reference = null, id = null) {
imo: String(row.imo || ''),
type: String(row.type_specific || row.type || ''),
destination: String(row.destination || ''),
// Hull length and beam (metres) when the source reports them.
lengthM: positive(row.length),
beamM: positive(row.beam),
speedMps: speedKts == null ? null : speedKts * 0.514444,
courseDeg: finite(row.course),
headingDeg: finite(row.heading),
+356
View File
@@ -0,0 +1,356 @@
/**
* Reads the procedural fleet models' GLBs in Node: their JSON chunk, and their
* triangles in scene space (raw glTF axes, node transforms applied, metres).
*
* The GLBs use EXT_meshopt_compression and KHR_mesh_quantization, so their
* buffers are decoded with the meshoptimizer decoder Cesium itself ships (a
* dependency of `@cesium/engine`, resolved through it rather than named as one
* of this package's own).
*/
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
let decoderPromise = null;
/** The meshoptimizer decoder Cesium decodes these models with. */
async function meshoptDecoder() {
decoderPromise ??= (async () => {
const fromHere = createRequire(import.meta.url);
const fromCesium = createRequire(fromHere.resolve('cesium/package.json'));
const fromEngine = createRequire(
fromCesium.resolve('@cesium/engine/package.json'),
);
const module = await import(
pathToFileURL(fromEngine.resolve('meshoptimizer')).href
);
await module.MeshoptDecoder.ready;
return module.MeshoptDecoder;
})();
return decoderPromise;
}
/**
* A GLB's JSON and binary chunks.
* @param {string} file
* @returns {{json: object, bin: Uint8Array}}
*/
export function readGlb(file) {
const bytes = fs.readFileSync(file);
if (bytes.readUInt32LE(0) !== 0x46546c67)
throw new Error(`${file}: not a GLB`);
const jsonLength = bytes.readUInt32LE(12);
const json = JSON.parse(bytes.subarray(20, 20 + jsonLength).toString('utf8'));
const binStart = 20 + jsonLength;
const bin =
binStart + 8 <= bytes.length
? new Uint8Array(
bytes.buffer,
bytes.byteOffset + binStart + 8,
bytes.readUInt32LE(binStart),
)
: new Uint8Array(0);
return { json, bin };
}
/** Column-major node matrix from a glTF node's TRS (or its matrix). */
function nodeMatrix(node) {
if (node.matrix) return node.matrix;
const t = node.translation || [0, 0, 0];
const [x, y, z, w] = node.rotation || [0, 0, 0, 1];
const s = node.scale || [1, 1, 1];
return [
(1 - 2 * (y * y + z * z)) * s[0],
2 * (x * y + z * w) * s[0],
2 * (x * z - y * w) * s[0],
0,
2 * (x * y - z * w) * s[1],
(1 - 2 * (x * x + z * z)) * s[1],
2 * (y * z + x * w) * s[1],
0,
2 * (x * z + y * w) * s[2],
2 * (y * z - x * w) * s[2],
(1 - 2 * (x * x + y * y)) * s[2],
0,
t[0],
t[1],
t[2],
1,
];
}
function multiply(a, b) {
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++)
for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
return out;
}
const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
const READERS = {
5120: ['getInt8', 1, 127],
5121: ['getUint8', 1, 255],
5122: ['getInt16', 2, 32767],
5123: ['getUint16', 2, 65535],
5125: ['getUint32', 4, 4294967295],
5126: ['getFloat32', 4, 1],
};
/**
* Every element of accessor `index`, normalised integers mapped to -1..1 or
* 0..1 the way a renderer reads them.
*/
function readAccessor(gltf, views, index) {
const accessor = gltf.accessors[index];
const view = gltf.bufferViews[accessor.bufferView];
const bytes = views[accessor.bufferView];
const [getter, size, max] = READERS[accessor.componentType];
const components = COMPONENTS[accessor.type];
const stride = view.byteStride || size * components;
const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const out = new Float64Array(accessor.count * components);
for (let i = 0; i < accessor.count; i++)
for (let c = 0; c < components; c++) {
const value = data[getter](
(accessor.byteOffset || 0) + i * stride + c * size,
true,
);
out[i * components + c] = accessor.normalized
? Math.max(value / max, -1)
: value;
}
return out;
}
/** Each buffer view's bytes, meshopt-compressed ones decoded. */
async function bufferViews(gltf, bin) {
const decoder = await meshoptDecoder();
return gltf.bufferViews.map((view) => {
const meshopt = view.extensions?.EXT_meshopt_compression;
if (!meshopt)
return bin.subarray(
view.byteOffset || 0,
(view.byteOffset || 0) + view.byteLength,
);
const source = bin.subarray(
meshopt.byteOffset || 0,
(meshopt.byteOffset || 0) + meshopt.byteLength,
);
const target = new Uint8Array(meshopt.count * meshopt.byteStride);
decoder.decodeGltfBuffer(
target,
meshopt.count,
meshopt.byteStride,
source,
meshopt.mode,
meshopt.filter,
);
return target;
});
}
/**
* A fleet model's triangles in scene space: raw glTF axes (X along the body,
* nose toward -X; Y up; Z across), node transforms applied, metres.
* @param {string} file
* @returns {Promise<{verts: number[][], tris: number[][]}>}
*/
export async function fleetMesh(file) {
const { json: gltf, bin } = readGlb(file);
const views = await bufferViews(gltf, bin);
const verts = [];
const tris = [];
const visit = (index, parent) => {
const node = gltf.nodes[index];
const m = multiply(parent, nodeMatrix(node));
if (node.mesh != null) {
for (const primitive of gltf.meshes[node.mesh].primitives) {
const base = verts.length;
const positions = readAccessor(
gltf,
views,
primitive.attributes.POSITION,
);
for (let i = 0; i < positions.length; i += 3) {
const [x, y, z] = [positions[i], positions[i + 1], positions[i + 2]];
verts.push([
m[0] * x + m[4] * y + m[8] * z + m[12],
m[1] * x + m[5] * y + m[9] * z + m[13],
m[2] * x + m[6] * y + m[10] * z + m[14],
]);
}
const indices = readAccessor(gltf, views, primitive.indices);
for (let i = 0; i + 2 < indices.length; i += 3)
tris.push([
base + indices[i],
base + indices[i + 1],
base + indices[i + 2],
]);
}
}
for (const child of node.children || []) visit(child, m);
};
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
for (const root of gltf.scenes[gltf.scene ?? 0].nodes) visit(root, identity);
return { verts, tris };
}
/** Axis-aligned bounds of a vertex list. */
export function vertexBounds(verts) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const v of verts)
for (let a = 0; a < 3; a++) {
if (v[a] < min[a]) min[a] = v[a];
if (v[a] > max[a]) max[a] = v[a];
}
return { min, max };
}
const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
/** Closest point of triangle abc to p (Ericson, Real-Time Collision Detection). */
function closestOnTriangle(p, a, b, c) {
const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]];
const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
const ab = sub(b, a);
const ac = sub(c, a);
const ap = sub(p, a);
const d1 = dot(ab, ap);
const d2 = dot(ac, ap);
if (d1 <= 0 && d2 <= 0) return a;
const bp = sub(p, b);
const d3 = dot(ab, bp);
const d4 = dot(ac, bp);
if (d3 >= 0 && d4 <= d3) return b;
const vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0) {
const v = d1 / (d1 - d3);
return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v];
}
const cp = sub(p, c);
const d5 = dot(ab, cp);
const d6 = dot(ac, cp);
if (d6 >= 0 && d5 <= d6) return c;
const vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0) {
const w = d2 / (d2 - d6);
return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w];
}
const va = d3 * d6 - d5 * d4;
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
const w = (d4 - d3) / (d4 - d3 + (d5 - d6));
return [
b[0] + (c[0] - b[0]) * w,
b[1] + (c[1] - b[1]) * w,
b[2] + (c[2] - b[2]) * w,
];
}
const den = 1 / (va + vb + vc);
const v = vb * den;
const w = vc * den;
return [
a[0] + ab[0] * v + ac[0] * w,
a[1] + ab[1] * v + ac[1] * w,
a[2] + ab[2] * v + ac[2] * w,
];
}
/** How far a point lies from the nearest triangle of the mesh. */
export function distanceToSurface(p, verts, tris) {
let best = Infinity;
for (const [i, j, k] of tris) {
const d = dist3(p, closestOnTriangle(p, verts[i], verts[j], verts[k]));
if (d < best) best = d;
}
return best;
}
/** The segments where the mesh's triangles cross the z = 0 (centreline) plane. */
export function centrelineSegments(verts, tris) {
const segments = [];
for (const [i, j, k] of tris) {
const t = [verts[i], verts[j], verts[k]];
const points = [];
for (let e = 0; e < 3; e++) {
const a = t[e];
const b = t[(e + 1) % 3];
if (a[2] === 0) points.push(a);
if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) {
const s = a[2] / (a[2] - b[2]);
points.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]);
}
}
if (points.length >= 2) segments.push([points[0], points[1]]);
}
return segments;
}
/** Closest point of the centreline segments to `target`, or null. */
export function closestCentrelinePoint(target, segments) {
let best = null;
let bestD = Infinity;
for (const [a, b] of segments) {
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const den = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
let q = a;
if (den > 0) {
let s =
((target[0] - a[0]) * ab[0] +
(target[1] - a[1]) * ab[1] +
(target[2] - a[2]) * ab[2]) /
den;
s = Math.max(0, Math.min(1, s));
q = [a[0] + ab[0] * s, a[1] + ab[1] * s, a[2] + ab[2] * s];
}
const d = dist3(target, q);
if (d < bestD) {
bestD = d;
best = q;
}
}
return best;
}
/**
* The trail attachment point a model's construction gives: the point of its
* centreline profile nearest its aft-belly bounding-box corner, in scene
* space (raw glTF axes, metres). Null when the centreline crosses no triangle.
* @param {{verts: number[][], tris: number[][]}} mesh
* @returns {number[]|null}
*/
export function measuredTrailAnchor({ verts, tris }) {
const { min, max } = vertexBounds(verts);
return closestCentrelinePoint(
[max[0], min[1], 0],
centrelineSegments(verts, tris),
);
}
/** The repository's `public/` directory, where the fleet set is served from. */
const PUBLIC_DIR = new URL('../../public/', import.meta.url);
/**
* A `fetch` that serves the repository's `public/` files from disk, for
* loading the fleet model set in Node.
* @param {string} url Site path, e.g. `/models/fleet/catalog.json`.
* @returns {Promise<{ok: boolean, status: number, json: () => Promise<object>,
* arrayBuffer: () => Promise<ArrayBuffer>}>}
*/
export async function fetchPublicFile(url) {
const file = new URL(String(url).replace(/^\//, ''), PUBLIC_DIR);
let body;
try {
body = fs.readFileSync(file);
} catch {
return { ok: false, status: 404 };
}
return {
ok: true,
status: 200,
json: async () => JSON.parse(body.toString('utf8')),
arrayBuffer: async () =>
body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
};
}