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:
Sameh Khamis committed 2026-11-13 07:16:25 -08:00
1 parent 42540ad85e
commit f6760ad96c
129 files changed
+5680 -1

No files matched your search

+475
View File
@@ -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;
}
+427
View File
@@ -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();
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Aft-belly trail attachment points of the fleet models the aircraft layers
* draw, in raw glTF axes (X along the body, nose toward -X; Y up), metres
* before scale, node transforms applied. Each is the point of the model's
* centreline profile (its z = 0 slice) nearest its aft-belly bounding-box
* corner: on the hull, aft, and on the centreline (see modelVisualAnchor.js).
*
* Written by scripts/measure-fleet-trail-anchors.mjs; re-measured against the
* GLBs by fleetModels.test.mjs.
*/
export const FLEET_TRAIL_ANCHORS = Object.freeze({
a139: Object.freeze([6.8847, 0.2766, 0]),
a320: Object.freeze([17.2078, 0.7416, 0]),
a333: Object.freeze([29.7276, 1.1084, 0]),
a343: Object.freeze([30.0371, 1.2335, 0]),
a359: Object.freeze([31.0731, 1.1573, 0]),
a388: Object.freeze([34.3139, 1.9279, 0]),
as50: Object.freeze([5.2278, -0.0999, 0]),
at72: Object.freeze([12.8258, 0.6792, 0]),
b06: Object.freeze([4.62, -0.3002, 0]),
b407: Object.freeze([5.1264, -0.2797, 0]),
b738: Object.freeze([18.3819, 0.8391, 0]),
b744: Object.freeze([33.6232, 1.5411, 0]),
b763: Object.freeze([25.5464, 0.9889, 0]),
b77w: Object.freeze([34.4639, 1.2084, 0]),
b789: Object.freeze([29.0604, 1.0649, 0]),
balloon: Object.freeze([3.8215, -0.0009, 0]),
bcs3: Object.freeze([17.8658, 0.713, 0]),
be20: Object.freeze([6.6699, 0.5505, 0]),
blimp: Object.freeze([32.2493, -7.1382, 0]),
c17: Object.freeze([26.5207, 2.332, 0]),
c172: Object.freeze([4.0507, 0.4481, 0]),
c30j: Object.freeze([13.568, 0.9917, 0]),
c56x: Object.freeze([7.4627, 0.4526, 0]),
car: Object.freeze([2.3, 0.418, 0]),
cl35: Object.freeze([10.4607, 0.8247, 0]),
crj9: Object.freeze([17.8413, 0.8624, 0]),
dh8d: Object.freeze([15.7902, 0.7501, 0]),
e175: Object.freeze([14.697, 0.6077, 0]),
e55p: Object.freeze([7.6566, 0.503, 0]),
ec35: Object.freeze([5.1003, 0.1471, 0]),
ec45: Object.freeze([5.8004, 0.555, 0]),
fire_truck: Object.freeze([5.5, 0.6051, 0]),
generic_heli: Object.freeze([5.4997, 0.1604, 0]),
generic_plane: Object.freeze([16.5228, 0.712, 0]),
glf4: Object.freeze([13.4603, 0.8243, 0]),
glider: Object.freeze([3.3997, 0.1283, 0]),
h47: Object.freeze([7.7303, 0.0035, 0]),
h60: Object.freeze([7.6295, 0.1365, 0]),
h64: Object.freeze([6.9228, -0.5553, 0]),
k35r: Object.freeze([28.0221, -2.4508, 0]),
md11: Object.freeze([30.8002, 1.2291, 0]),
mq9: Object.freeze([5.8498, -0.062, 0]),
p28a: Object.freeze([3.3695, 0.3959, 0]),
pc12: Object.freeze([6.6416, 0.5288, 0]),
pushback: Object.freeze([3, 0.2499, 0]),
r44: Object.freeze([4.4219, -0.3001, 0]),
s76: Object.freeze([6.6001, 0.1915, 0]),
sr22: Object.freeze([3.6762, 0.2376, 0]),
t38: Object.freeze([7.0246, -0.3791, 0]),
tex2: Object.freeze([4.7383, 0.2302, 0]),
tower: Object.freeze([1.2952, -0.0007, 0]),
ultralight: Object.freeze([3.2271, 0.3259, 0]),
v22: Object.freeze([7.9868, 0.2956, 0]),
van: Object.freeze([2.75, 0.418, 0]),
});
+356
View File
@@ -0,0 +1,356 @@
/**
* Reads the procedural fleet models' GLBs in Node: their JSON chunk, and their
* triangles in scene space (raw glTF axes, node transforms applied, metres).
*
* The GLBs use EXT_meshopt_compression and KHR_mesh_quantization, so their
* buffers are decoded with the meshoptimizer decoder Cesium itself ships (a
* dependency of `@cesium/engine`, resolved through it rather than named as one
* of this package's own).
*/
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
let decoderPromise = null;
/** The meshoptimizer decoder Cesium decodes these models with. */
async function meshoptDecoder() {
decoderPromise ??= (async () => {
const fromHere = createRequire(import.meta.url);
const fromCesium = createRequire(fromHere.resolve('cesium/package.json'));
const fromEngine = createRequire(
fromCesium.resolve('@cesium/engine/package.json'),
);
const module = await import(
pathToFileURL(fromEngine.resolve('meshoptimizer')).href
);
await module.MeshoptDecoder.ready;
return module.MeshoptDecoder;
})();
return decoderPromise;
}
/**
* A GLB's JSON and binary chunks.
* @param {string} file
* @returns {{json: object, bin: Uint8Array}}
*/
export function readGlb(file) {
const bytes = fs.readFileSync(file);
if (bytes.readUInt32LE(0) !== 0x46546c67)
throw new Error(`${file}: not a GLB`);
const jsonLength = bytes.readUInt32LE(12);
const json = JSON.parse(bytes.subarray(20, 20 + jsonLength).toString('utf8'));
const binStart = 20 + jsonLength;
const bin =
binStart + 8 <= bytes.length
? new Uint8Array(
bytes.buffer,
bytes.byteOffset + binStart + 8,
bytes.readUInt32LE(binStart),
)
: new Uint8Array(0);
return { json, bin };
}
/** Column-major node matrix from a glTF node's TRS (or its matrix). */
function nodeMatrix(node) {
if (node.matrix) return node.matrix;
const t = node.translation || [0, 0, 0];
const [x, y, z, w] = node.rotation || [0, 0, 0, 1];
const s = node.scale || [1, 1, 1];
return [
(1 - 2 * (y * y + z * z)) * s[0],
2 * (x * y + z * w) * s[0],
2 * (x * z - y * w) * s[0],
0,
2 * (x * y - z * w) * s[1],
(1 - 2 * (x * x + z * z)) * s[1],
2 * (y * z + x * w) * s[1],
0,
2 * (x * z + y * w) * s[2],
2 * (y * z - x * w) * s[2],
(1 - 2 * (x * x + y * y)) * s[2],
0,
t[0],
t[1],
t[2],
1,
];
}
function multiply(a, b) {
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++)
for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
return out;
}
const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
const READERS = {
5120: ['getInt8', 1, 127],
5121: ['getUint8', 1, 255],
5122: ['getInt16', 2, 32767],
5123: ['getUint16', 2, 65535],
5125: ['getUint32', 4, 4294967295],
5126: ['getFloat32', 4, 1],
};
/**
* Every element of accessor `index`, normalised integers mapped to -1..1 or
* 0..1 the way a renderer reads them.
*/
function readAccessor(gltf, views, index) {
const accessor = gltf.accessors[index];
const view = gltf.bufferViews[accessor.bufferView];
const bytes = views[accessor.bufferView];
const [getter, size, max] = READERS[accessor.componentType];
const components = COMPONENTS[accessor.type];
const stride = view.byteStride || size * components;
const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const out = new Float64Array(accessor.count * components);
for (let i = 0; i < accessor.count; i++)
for (let c = 0; c < components; c++) {
const value = data[getter](
(accessor.byteOffset || 0) + i * stride + c * size,
true,
);
out[i * components + c] = accessor.normalized
? Math.max(value / max, -1)
: value;
}
return out;
}
/** Each buffer view's bytes, meshopt-compressed ones decoded. */
async function bufferViews(gltf, bin) {
const decoder = await meshoptDecoder();
return gltf.bufferViews.map((view) => {
const meshopt = view.extensions?.EXT_meshopt_compression;
if (!meshopt)
return bin.subarray(
view.byteOffset || 0,
(view.byteOffset || 0) + view.byteLength,
);
const source = bin.subarray(
meshopt.byteOffset || 0,
(meshopt.byteOffset || 0) + meshopt.byteLength,
);
const target = new Uint8Array(meshopt.count * meshopt.byteStride);
decoder.decodeGltfBuffer(
target,
meshopt.count,
meshopt.byteStride,
source,
meshopt.mode,
meshopt.filter,
);
return target;
});
}
/**
* A fleet model's triangles in scene space: raw glTF axes (X along the body,
* nose toward -X; Y up; Z across), node transforms applied, metres.
* @param {string} file
* @returns {Promise<{verts: number[][], tris: number[][]}>}
*/
export async function fleetMesh(file) {
const { json: gltf, bin } = readGlb(file);
const views = await bufferViews(gltf, bin);
const verts = [];
const tris = [];
const visit = (index, parent) => {
const node = gltf.nodes[index];
const m = multiply(parent, nodeMatrix(node));
if (node.mesh != null) {
for (const primitive of gltf.meshes[node.mesh].primitives) {
const base = verts.length;
const positions = readAccessor(
gltf,
views,
primitive.attributes.POSITION,
);
for (let i = 0; i < positions.length; i += 3) {
const [x, y, z] = [positions[i], positions[i + 1], positions[i + 2]];
verts.push([
m[0] * x + m[4] * y + m[8] * z + m[12],
m[1] * x + m[5] * y + m[9] * z + m[13],
m[2] * x + m[6] * y + m[10] * z + m[14],
]);
}
const indices = readAccessor(gltf, views, primitive.indices);
for (let i = 0; i + 2 < indices.length; i += 3)
tris.push([
base + indices[i],
base + indices[i + 1],
base + indices[i + 2],
]);
}
}
for (const child of node.children || []) visit(child, m);
};
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
for (const root of gltf.scenes[gltf.scene ?? 0].nodes) visit(root, identity);
return { verts, tris };
}
/** Axis-aligned bounds of a vertex list. */
export function vertexBounds(verts) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const v of verts)
for (let a = 0; a < 3; a++) {
if (v[a] < min[a]) min[a] = v[a];
if (v[a] > max[a]) max[a] = v[a];
}
return { min, max };
}
const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
/** Closest point of triangle abc to p (Ericson, Real-Time Collision Detection). */
function closestOnTriangle(p, a, b, c) {
const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]];
const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
const ab = sub(b, a);
const ac = sub(c, a);
const ap = sub(p, a);
const d1 = dot(ab, ap);
const d2 = dot(ac, ap);
if (d1 <= 0 && d2 <= 0) return a;
const bp = sub(p, b);
const d3 = dot(ab, bp);
const d4 = dot(ac, bp);
if (d3 >= 0 && d4 <= d3) return b;
const vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0) {
const v = d1 / (d1 - d3);
return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v];
}
const cp = sub(p, c);
const d5 = dot(ab, cp);
const d6 = dot(ac, cp);
if (d6 >= 0 && d5 <= d6) return c;
const vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0) {
const w = d2 / (d2 - d6);
return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w];
}
const va = d3 * d6 - d5 * d4;
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
const w = (d4 - d3) / (d4 - d3 + (d5 - d6));
return [
b[0] + (c[0] - b[0]) * w,
b[1] + (c[1] - b[1]) * w,
b[2] + (c[2] - b[2]) * w,
];
}
const den = 1 / (va + vb + vc);
const v = vb * den;
const w = vc * den;
return [
a[0] + ab[0] * v + ac[0] * w,
a[1] + ab[1] * v + ac[1] * w,
a[2] + ab[2] * v + ac[2] * w,
];
}
/** How far a point lies from the nearest triangle of the mesh. */
export function distanceToSurface(p, verts, tris) {
let best = Infinity;
for (const [i, j, k] of tris) {
const d = dist3(p, closestOnTriangle(p, verts[i], verts[j], verts[k]));
if (d < best) best = d;
}
return best;
}
/** The segments where the mesh's triangles cross the z = 0 (centreline) plane. */
export function centrelineSegments(verts, tris) {
const segments = [];
for (const [i, j, k] of tris) {
const t = [verts[i], verts[j], verts[k]];
const points = [];
for (let e = 0; e < 3; e++) {
const a = t[e];
const b = t[(e + 1) % 3];
if (a[2] === 0) points.push(a);
if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) {
const s = a[2] / (a[2] - b[2]);
points.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]);
}
}
if (points.length >= 2) segments.push([points[0], points[1]]);
}
return segments;
}
/** Closest point of the centreline segments to `target`, or null. */
export function closestCentrelinePoint(target, segments) {
let best = null;
let bestD = Infinity;
for (const [a, b] of segments) {
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const den = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
let q = a;
if (den > 0) {
let s =
((target[0] - a[0]) * ab[0] +
(target[1] - a[1]) * ab[1] +
(target[2] - a[2]) * ab[2]) /
den;
s = Math.max(0, Math.min(1, s));
q = [a[0] + ab[0] * s, a[1] + ab[1] * s, a[2] + ab[2] * s];
}
const d = dist3(target, q);
if (d < bestD) {
bestD = d;
best = q;
}
}
return best;
}
/**
* The trail attachment point a model's construction gives: the point of its
* centreline profile nearest its aft-belly bounding-box corner, in scene
* space (raw glTF axes, metres). Null when the centreline crosses no triangle.
* @param {{verts: number[][], tris: number[][]}} mesh
* @returns {number[]|null}
*/
export function measuredTrailAnchor({ verts, tris }) {
const { min, max } = vertexBounds(verts);
return closestCentrelinePoint(
[max[0], min[1], 0],
centrelineSegments(verts, tris),
);
}
/** The repository's `public/` directory, where the fleet set is served from. */
const PUBLIC_DIR = new URL('../../public/', import.meta.url);
/**
* A `fetch` that serves the repository's `public/` files from disk, for
* loading the fleet model set in Node.
* @param {string} url Site path, e.g. `/models/fleet/catalog.json`.
* @returns {Promise<{ok: boolean, status: number, json: () => Promise<object>,
* arrayBuffer: () => Promise<ArrayBuffer>}>}
*/
export async function fetchPublicFile(url) {
const file = new URL(String(url).replace(/^\//, ''), PUBLIC_DIR);
let body;
try {
body = fs.readFileSync(file);
} catch {
return { ok: false, status: 404 };
}
return {
ok: true,
status: 200,
json: async () => JSON.parse(body.toString('utf8')),
arrayBuffer: async () =>
body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
};
}