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