mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
feat(models): the project's procedural fleet models and the resolver that picks one per vehicle
121 GLBs under public/models/fleet (about 2 MB, MIT like the code) with catalog.json and types.json. src/data/fleetModels.js picks a model by ICAO type, aircraft class, emitter category, AIS ship type and size, GTFS route type, transit mode or rail category, and sizes it life size; the set loads once, up front. Trail anchors are measured from the meshes.
This commit is contained in:
1 parent
42540ad85e
commit
f6760ad96c
129 files changed
+5680
-1
No files matched your search
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* @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, as
|
||||
* built when it is unknown.
|
||||
* @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;
|
||||
const drawn = L > 0 ? L : null;
|
||||
if (shipType !== undefined && shipType !== null) {
|
||||
for (const group of types.vessels) {
|
||||
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
|
||||
for (const rule of group.rules) {
|
||||
if (rule.min_len && L < rule.min_len) continue;
|
||||
if (rule.max_len && L > 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: drawn };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } = {}) {
|
||||
const resolved = resolveVessel(
|
||||
set.types,
|
||||
aisShipTypeCode(type),
|
||||
length ?? undefined,
|
||||
beam ?? undefined,
|
||||
name ?? undefined,
|
||||
);
|
||||
return specOf(
|
||||
set,
|
||||
resolved,
|
||||
(r) => ({ model: set.types.default_vessel, length: r.length }),
|
||||
{ beam: beam ?? undefined },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,427 @@
|
||||
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],
|
||||
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: 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);
|
||||
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]),
|
||||
});
|
||||
@@ -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