feat(vessels, transit, rail): draw fleet models under the 3D toggle

Vessels, Transit and Rail turn nearby icons into fleet models at the
aircraft layers' distances. A vessel is drawn at its AIS length and beam
(static dimensions now reach vessel rows), standing at the sea surface; a
transit vehicle or train stands on its road or rail, raised by its type's
elevation, and is painted in its mode or category colour where the model
marks it.
This commit is contained in:
Sameh Khamis committed 2026-11-13 07:16:25 -08:00
1 parent fcc44d0eaf
commit 7b93045895
18 files changed
+984 -15

No files matched your search

+37
View File
@@ -1504,6 +1504,9 @@
"civil-flights": {
"exports": ["./layers/flights"],
"modules": [
"src/data/fleetModels.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/data/headingDegrees.js",
"src/i18n/marker.js",
"src/cyberSonar.js",
@@ -1578,6 +1581,11 @@
"military-flights": {
"exports": ["./layers/military"],
"modules": [
"src/layers/flights/modelSpec.js",
"src/layers/flights/policy.js",
"src/data/fleetModels.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/data/headingDegrees.js",
"src/i18n/marker.js",
"src/cyberSonar.js",
@@ -1677,6 +1685,13 @@
"vessel-layer": {
"exports": ["./layers/vessels"],
"modules": [
"src/data/fleetModels.js",
"src/data/aircraftClass.js",
"src/i18n/marker.js",
"src/data/fleetModelInstances.js",
"src/data/aircraftModelLighting.js",
"src/data/modelEligibility.js",
"src/data/fleetTrailAnchors.js",
"src/data/headingDegrees.js",
"src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js",
@@ -2545,6 +2560,10 @@
"./standalone/catalog"
],
"modules": [
"src/data/fleetModels.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/app/layers/fleetModels.js",
"src/sources/adsbFields.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
@@ -3811,6 +3830,10 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
"src/data/fleetModels.js",
"src/data/fleetTrailAnchors.js",
"src/data/fleetModelInstances.js",
"src/app/layers/fleetModels.js",
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/sources/adsbFields.js",
@@ -5165,6 +5188,13 @@
"transit-standalone": {
"exports": ["./layers/transit/standalone"],
"modules": [
"src/data/fleetModels.js",
"src/data/aircraftClass.js",
"src/i18n/marker.js",
"src/data/fleetModelInstances.js",
"src/data/modelEligibility.js",
"src/data/fleetTrailAnchors.js",
"src/data/aircraftModelLighting.js",
"src/data/headingDegrees.js",
"src/cyberSonar.js",
"src/celestialRing.js",
@@ -5248,6 +5278,13 @@
"transit-layer": {
"exports": ["./layers/transit"],
"modules": [
"src/i18n/marker.js",
"src/data/fleetModels.js",
"src/data/fleetModelInstances.js",
"src/data/aircraftClass.js",
"src/data/aircraftModelLighting.js",
"src/data/fleetTrailAnchors.js",
"src/data/modelEligibility.js",
"src/data/headingDegrees.js",
"src/data/iconOrientation.js",
"src/data/inputOwnership.js",
+28
View File
@@ -57,8 +57,13 @@ export function ingestAisStreamEnvelope(envelope) {
message.ReportB?.Valid === false
? undefined
: message.ReportB?.ShipType,
Dimension:
message.ReportB?.Valid === false
? undefined
: message.ReportB?.Dimension,
}
: message;
const dimensions = aisDimensions(fields.Dimension);
// Static reports may be partial (including Class B's separate A/B parts).
// Keep known values in the cache as well as in the current live row.
const staticData = {
@@ -84,6 +89,8 @@ export function ingestAisStreamEnvelope(envelope) {
staticCode(live?.imo) ||
staticCode(previous.imo) ||
'',
length: dimensions.length ?? live?.length ?? previous.length ?? null,
beam: dimensions.beam ?? live?.beam ?? previous.beam ?? null,
};
_aisStreamStatic.set(mmsi, staticData);
mergeAisStaticIntoLiveVessel(mmsi, staticData);
@@ -113,6 +120,8 @@ export function ingestAisStreamEnvelope(envelope) {
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
type: vesselTypeFromAis(message, staticData),
destination: stringValue(message.Destination ?? staticData.destination),
length: staticData.length ?? null,
beam: staticData.beam ?? null,
speed: normalizedSpeedOverGround(message.Sog ?? message.SOG),
course: normalizedCourseOverGround(message.Cog ?? message.COG),
heading: normalizedHeading(message.TrueHeading ?? message.Heading),
@@ -236,6 +245,25 @@ function mergeAisStaticIntoLiveVessel(mmsi, staticData) {
if (staticData.type) existing.type = staticData.type;
if (staticData.destination) existing.destination = staticData.destination;
if (staticData.imo) existing.imo = staticData.imo;
if (staticData.length) existing.length = staticData.length;
if (staticData.beam) existing.beam = staticData.beam;
}
/**
* A static report's hull size from its AIS dimensions: the antenna's
* distances to bow (A), stern (B), port (C) and starboard (D), in metres.
* Zero is AIS's "not available", so a side missing its two parts has none.
* @param {{A?: number, B?: number, C?: number, D?: number}|undefined} dimension
* @returns {{length: number|null, beam: number|null}}
*/
function aisDimensions(dimension) {
const part = (value) => {
const number = numberValue(value);
return number !== null && number > 0 ? number : 0;
};
const length = part(dimension?.A) + part(dimension?.B);
const beam = part(dimension?.C) + part(dimension?.D);
return { length: length > 0 ? length : null, beam: beam > 0 ? beam : null };
}
function vesselNameFromAis(metadata, message, staticData = {}) {
+14 -2
View File
@@ -109,6 +109,8 @@ export function createApplicationCatalog({
const vessels = createApplicationVessels({
source: sources.vessels,
options: vesselOptions,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
});
const installations = createApplicationInstallations({
surface,
@@ -167,7 +169,12 @@ export function createApplicationCatalog({
createApplicationTraffic({ source: sources.traffic, surface }),
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationTransit({
surface,
source: sources.transit,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
@@ -200,7 +207,12 @@ export function createApplicationCatalog({
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationRail({ surface, source: sources.rail }),
createApplicationRail({
surface,
source: sources.rail,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationFirms({
surface,
id: 'local-firms',
+16 -2
View File
@@ -10,9 +10,19 @@ import * as focus from '../../data/focusDeemphasis.js';
import * as worldFocus from '../../worldFocus.js';
import * as render from '../../renderGovernor.js';
import * as readouts from '../../hudReadouts.js';
import { fleetModelServices } from './fleetModels.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({ source, options = {} }) {
/**
* Construct one layer using the application scene owners and a supplied
* source. `displayParams` reads the Flights layer's DISPLAY-rail 3D
* preference, under which nearby vessels are drawn as fleet models.
*/
export function createApplicationVessels({
source,
options = {},
displayParams = null,
resolveAsset,
}) {
return createVesselLayer({
source,
options,
@@ -28,6 +38,10 @@ export function createApplicationVessels({ source, options = {} }) {
worldFocus,
render,
readouts,
fleet: fleetModelServices({
display: displayParams,
...(resolveAsset ? { resolveAsset } : {}),
}),
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import { FLEET_MODEL_POLICY } from '../../layers/flights/policy.js';
/**
* The fleet-model service the Vessels, Transit and Rail layers draw their 3D
* models with: the Flights layer's DISPLAY-rail 3D preference
* (`{ models3d, models3dMode }`), the aircraft layers' icon-to-model rules,
* and the resolver the fleet set's files are fetched through.
* @param {{display?: (() => object|null)|null,
* resolveAsset?: (url: string) => string}} [options]
* @returns {object|null} Null without a display preference (no models).
*/
export function fleetModelServices({
display = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
} = {}) {
if (!display) return null;
return Object.freeze({ display, policy: FLEET_MODEL_POLICY, resolveAsset });
}
+15 -2
View File
@@ -1,5 +1,6 @@
import { createRailLayer } from '../../layers/rail/index.js';
import * as render from '../../renderGovernor.js';
import { fleetModelServices } from './fleetModels.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
@@ -13,10 +14,18 @@ import {
/**
* Construct the Rail layer on the application's scene owners and its shared
* ground floor, the same floor Transit and the aircraft stand on.
* @param {{surface: object, source?: object}} options
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby trains are drawn as fleet models.
* @param {{surface: object, source?: object,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object}
*/
export function createApplicationRail({ surface, source }) {
export function createApplicationRail({
surface,
source,
displayParams = null,
resolveAsset,
}) {
if (!surface?.groundFloor)
throw new TypeError('Rail needs the application surface services');
const { groundFloor } = surface;
@@ -45,6 +54,10 @@ export function createApplicationRail({ surface, source }) {
globalThis.document?.body?.classList?.contains('cockpit-mode') ===
true,
},
fleet: fleetModelServices({
display: displayParams,
...(resolveAsset ? { resolveAsset } : {}),
}),
},
});
}
+16 -2
View File
@@ -1,5 +1,6 @@
import { createTransitLayer } from '../../layers/transit/index.js';
import * as render from '../../renderGovernor.js';
import { fleetModelServices } from './fleetModels.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
@@ -19,10 +20,19 @@ import {
* own mesh floors. An earlier cut imported the legacy module-level singleton
* instead and could not see either.
*
* @param {{surface: object}} options
* `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under
* which nearby vehicles are drawn as fleet models.
*
* @param {{surface: object, source?: object,
* displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options
* @returns {object}
*/
export function createApplicationTransit({ surface, source }) {
export function createApplicationTransit({
surface,
source,
displayParams = null,
resolveAsset,
}) {
if (!surface?.groundFloor || !surface?.meshFloor)
throw new TypeError('Transit needs the application surface services');
const { groundFloor, meshFloor } = surface;
@@ -44,6 +54,10 @@ export function createApplicationTransit({ surface, source }) {
allocateCorridorCells: groundFloor.allocateCorridorCells,
},
mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells },
fleet: fleetModelServices({
display: displayParams,
...(resolveAsset ? { resolveAsset } : {}),
}),
},
});
}
+58
View File
@@ -258,3 +258,61 @@ test('partial static data keeps newer live metadata ahead of the old static cach
assert.equal(rowFor(mmsi).type, '80');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
});
// A static report's dimensions are the antenna's distances to bow (A), stern
// (B), port (C) and starboard (D): length A+B, beam C+D. Zero is AIS's "not
// available", so a vessel reporting none has no size, not a size of zero.
test('a static report gives a vessel its hull length and beam', () => {
ingestAisStreamEnvelope(positionReport('636019825', { Sog: 14 }));
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 636019825,
Name: 'EVER GIVEN',
Type: 70,
Dimension: { A: 330, B: 70, C: 30, D: 29 },
},
},
MetaData: { MMSI: '636019825' },
});
const row = rowFor('636019825');
assert.equal(row.length, 400);
assert.equal(row.beam, 59);
ingestAisStreamEnvelope(positionReport('636019826', { Sog: 3 }));
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 636019826,
Type: 52,
Dimension: { A: 0, B: 0, C: 0, D: 0 },
},
},
MetaData: { MMSI: '636019826' },
});
assert.equal(rowFor('636019826').length, null);
assert.equal(rowFor('636019826').beam, null);
});
test('a Class B report carries its dimensions in part B', () => {
ingestAisStreamEnvelope(positionReport('244000777', { Sog: 6 }));
ingestAisStreamEnvelope({
MessageType: 'StaticDataReport',
Message: {
StaticDataReport: {
UserID: 244000777,
ReportB: {
Valid: true,
ShipType: 37,
Dimension: { A: 8, B: 4, C: 2, D: 2 },
},
},
},
MetaData: { MMSI: '244000777' },
});
const row = rowFor('244000777');
assert.equal(row.length, 12);
assert.equal(row.beam, 4);
});
+360
View File
@@ -0,0 +1,360 @@
// The icon-to-model handoff the Vessels, Transit and Rail layers share
// (fleetModelInstances.js): models only under the DISPLAY 3D toggle and the
// altitude ceiling, at most `cap` of the nearest, each vehicle's icon the
// visual until its model is loaded, ready and placed, and a tinted model
// painted with its route colour.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import * as Cesium from 'cesium';
import {
createFleetModel,
createFleetModelLayer,
fleetModelMatrix,
fleetTintShader,
FLEET_HEADING_OFFSET_DEG,
} from './fleetModelInstances.js';
import {
loadFleetModelSet,
transitModelSpec,
vesselModelSpec,
} from './fleetModels.js';
import { fetchPublicFile } from '../testSupport/fleetGlb.mjs';
const realInfo = console.info;
console.info = () => {};
const set = await loadFleetModelSet({ fetchImpl: fetchPublicFile });
console.info = realInfo;
const POLICY = Object.freeze({
altitudeCeilingM: 800_000,
minimumPixelSize: 16,
cap: 2,
capAll: 4,
addM: 5_000,
addAllM: 20_000,
keepM: 6_000,
keepAllM: 24_000,
intervalMs: 80,
});
const CENTRE = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, 0);
function fakeViewer(height = 1_000) {
const primitives = [];
const position = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, height);
const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
position,
new Cesium.Cartesian3(),
);
const camera = {
positionWC: position,
positionCartographic: { height },
directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()),
upWC: Cesium.Cartesian3.UNIT_Z,
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
return {
camera,
scene: {
primitives: {
add(p) {
primitives.push(p);
return p;
},
remove(p) {
const i = primitives.indexOf(p);
if (i >= 0) primitives.splice(i, 1);
return i >= 0;
},
},
},
primitives,
isDestroyed: () => false,
};
}
function fakeModel(options) {
return {
...options,
ready: false,
show: true,
modelMatrix: new Cesium.Matrix4(),
destroyed: false,
destroy() {
this.destroyed = true;
},
isDestroyed() {
return this.destroyed;
},
update() {},
};
}
/** A vehicle `metres` east of the camera's foot. */
function itemAt(metres, spec, extra = {}) {
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(CENTRE);
const position = Cesium.Matrix4.multiplyByPoint(
enu,
new Cesium.Cartesian3(metres, 0, 0),
new Cesium.Cartesian3(),
);
return { position, headingDeg: 90, spec, tint: null, pickId: null, ...extra };
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('models load from the up-front set, never by URL', async () => {
const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 32 });
let options = null;
await createFleetModel(
spec,
{ scale: spec.scale },
{
set,
resolveAsset: (url) => `/base${url}`,
load: async (o) => {
options = o;
return {};
},
},
);
assert.ok(options.gltf instanceof Uint8Array, 'the loaded GLB bytes');
assert.equal(options.gltf, set.bytes.get(spec.model));
assert.equal(options.basePath, `/base${spec.url}`);
assert.equal(options.url, undefined, 'no fetch by URL');
assert.deepEqual({ ...options.environmentMapOptions }, { enabled: false });
await assert.rejects(
createFleetModel({ model: 'missing' }, {}, { set, load: async () => ({}) }),
/not loaded/,
);
});
test('the icon stays the visual until the model is ready; only the nearest are drawn', async () => {
const viewer = fakeViewer();
const display = { models3d: true, models3dMode: 'proximity' };
const created = [];
const layer = createFleetModelLayer({
viewer,
display: () => display,
policy: POLICY,
createModel: async (spec, options) => {
const model = fakeModel({ spec, ...options });
created.push(model);
return model;
},
});
const spec = transitModelSpec(set, { mode: 'tram' });
const items = new Map([
['near', itemAt(100, spec, { pickId: 'near' })],
['mid', itemAt(1_000, spec, { pickId: 'mid' })],
['far', itemAt(4_000, spec, { pickId: 'far' })],
]);
layer.frame(items, 0);
await flush();
assert.equal(created.length, POLICY.cap, 'the cap holds');
assert.deepEqual(
created.map((m) => m.id).sort(),
['mid', 'near'],
'the nearest are modelled',
);
assert.equal(created[0].scale, spec.scale, 'drawn life size');
assert.equal(created[0].minimumPixelSize, POLICY.minimumPixelSize);
layer.frame(items, 100);
for (const item of items.values())
assert.equal(item.modelOwnsVisual, false, 'not ready: the icon draws');
assert.ok(
layer.settling > 0,
'the layer asks for frames while models settle',
);
for (const model of created) model.ready = true;
layer.frame(items, 200);
assert.equal(items.get('near').modelOwnsVisual, true);
assert.equal(items.get('mid').modelOwnsVisual, true);
assert.equal(items.get('far').modelOwnsVisual, false);
assert.equal(layer.settling, 0);
// A placed model stands on its item's point, nose along its heading.
const near = created.find((m) => m.id === 'near');
const origin = Cesium.Matrix4.getTranslation(
near.modelMatrix,
new Cesium.Cartesian3(),
);
assert.ok(
Cesium.Cartesian3.distance(origin, items.get('near').position) < 1e-6,
);
// 3D off: every model goes and every icon comes back.
display.models3d = false;
layer.frame(items, 300);
assert.equal(layer.size, 0);
for (const item of items.values()) assert.equal(item.modelOwnsVisual, false);
assert.ok(
created.every((m) => m.destroyed),
'released models are destroyed',
);
layer.destroy();
assert.equal(viewer.primitives.length, 0);
});
test('models stay off above the altitude ceiling', async () => {
const viewer = fakeViewer(POLICY.altitudeCeilingM + 1);
let calls = 0;
const layer = createFleetModelLayer({
viewer,
display: () => ({ models3d: true }),
policy: POLICY,
createModel: async (spec, options) => {
calls += 1;
return fakeModel(options);
},
});
assert.equal(layer.active(), false);
layer.frame(new Map([['a', itemAt(0, transitModelSpec(set, {}))]]), 0);
await flush();
assert.equal(calls, 0);
layer.destroy();
});
test('a tinted model is painted with its route colour; an untinted one is not', async () => {
const viewer = fakeViewer();
const created = [];
const layer = createFleetModelLayer({
viewer,
display: () => ({ models3d: true }),
policy: POLICY,
createModel: async (spec, options) => {
const model = fakeModel(options);
created.push(model);
return model;
},
});
const red = Cesium.Color.RED;
const tram = transitModelSpec(set, { mode: 'tram' });
const ship = vesselModelSpec(set, { type: '70', length: 180 });
assert.equal(tram.tinted, true);
assert.equal(ship.tinted, false);
layer.frame(
new Map([
['tram', itemAt(10, tram, { pickId: 'tram', tint: red })],
['ship', itemAt(20, ship, { pickId: 'ship', tint: red })],
]),
0,
);
await flush();
const byId = Object.fromEntries(created.map((m) => [m.id, m]));
assert.equal(byId.tram.customShader, fleetTintShader(red));
assert.equal(byId.ship.customShader, undefined);
assert.equal(
fleetTintShader(red),
fleetTintShader(Cesium.Color.RED),
'one shader per colour',
);
layer.destroy();
});
test('an elevated vehicle is raised by its type elevation', async () => {
const viewer = fakeViewer();
const created = [];
const layer = createFleetModelLayer({
viewer,
display: () => ({ models3d: true }),
policy: POLICY,
createModel: async (spec, options) => {
const model = fakeModel({ ...options, ready: true });
model.ready = true;
created.push(model);
return model;
},
});
const monorail = transitModelSpec(set, { mode: 'monorail' });
assert.equal(monorail.elevationM, set.types.transit.elevation.monorail);
const item = itemAt(10, monorail, { pickId: 'm' });
const items = new Map([['m', item]]);
layer.frame(items, 0);
await flush();
layer.frame(items, 100);
const origin = Cesium.Matrix4.getTranslation(
created[0].modelMatrix,
new Cesium.Cartesian3(),
);
const lifted = Cesium.Cartographic.fromCartesian(origin).height;
const ground = Cesium.Cartographic.fromCartesian(item.position).height;
assert.ok(Math.abs(lifted - ground - monorail.elevationM) < 1e-3);
layer.destroy();
});
test('a vessel stretched to its reported beam keeps its length scale on the model', () => {
const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 60 });
assert.ok(spec.scaleXYZ[1] > spec.scaleXYZ[0], 'a wide hull for its length');
const matrix = fleetModelMatrix(
CENTRE,
0,
spec,
new Cesium.Matrix4(),
new Cesium.HeadingPitchRoll(),
);
const scale = Cesium.Matrix4.getScale(matrix, new Cesium.Cartesian3());
// Cesium's axis correction puts the beam on local X, the length on local Y.
assert.ok(Math.abs(scale.x - spec.scaleXYZ[1] / spec.scaleXYZ[0]) < 1e-9);
assert.ok(Math.abs(scale.y - 1) < 1e-9);
assert.equal(FLEET_HEADING_OFFSET_DEG, 180, 'fleet models are built nose -X');
});
test('Vessels, Transit and Rail draw fleet models under the shared 3D toggle', () => {
for (const file of [
'src/layers/vessels/rendering.js',
'src/layers/transit/rendering.js',
'src/layers/rail/rendering.js',
]) {
const source = fs.readFileSync(
new URL(`../../${file}`, import.meta.url),
'utf8',
);
assert.match(
source,
/createFleetModelLayer\(/,
`${file}: draws fleet models`,
);
assert.match(
source,
/loadFleetModelSet\(/,
`${file}: loads the set up front`,
);
assert.match(
source,
/modelOwnsVisual/,
`${file}: hands its icon to the model`,
);
}
for (const file of [
'src/app/layers/aisLiveVessels.js',
'src/app/layers/transit.js',
'src/app/layers/rail.js',
]) {
const source = fs.readFileSync(
new URL(`../../${file}`, import.meta.url),
'utf8',
);
assert.match(
source,
/fleet: fleetModelServices\(/,
`${file}: given the fleet service`,
);
}
const catalog = fs.readFileSync(
new URL('../app/constructCatalog.js', import.meta.url),
'utf8',
);
assert.equal(
catalog.match(/displayParams: \(\) => flights\.getParams\(\)/g)?.length,
4,
'Local ADS-B, Vessels, Transit and Rail follow the Flights 3D toggle',
);
});
+3
View File
@@ -95,6 +95,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
parts.rendering.onPreRender,
);
state._networkFade = -1;
parts.rendering.startModels();
parts.viewport.loadCatalog();
parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer);
@@ -127,6 +128,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
state._heldFeedId = null;
state._feedStatus.clear();
dropAllTrains();
parts.rendering.stopModels();
// Floor cells and track samples queued for trains now gone.
parts.rendering.reset();
parts.routes.hideAll();
@@ -154,6 +156,7 @@ export function createLifecycle({ state, services, parts, timetable }) {
/** Tear down the collection and release every shared registration. */
destroy(viewer) {
this.disable(viewer);
parts.rendering.stopModels(true);
parts.routes.destroy();
if (state._markers) {
unregisterSpriteCollection('rail', state._markers);
+142 -4
View File
@@ -26,6 +26,22 @@ import {
UNSELECTED_TRAIN_ALPHA,
} from './policy.js';
import { placeOnPath } from './pathBinding.js';
import {
fleetModelSet,
loadFleetModelSet,
transitModelSpec,
} from '../../data/fleetModels.js';
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
/** How often the trains near enough for a model are gathered again (wall ms). */
const MODEL_CANDIDATES_MS = 500;
/** Each category's colour, painted where a train model marks it. */
const CATEGORY_TINTS = Object.fromEntries(
Object.entries(RAIL_CATEGORY_COLORS).map(([category, css]) => [
category,
Cesium.Color.fromCssColorString(css),
]),
);
const SPRITE_PX = 64;
/** Billboard tints; the setter copies, so sharing one instance is safe. */
@@ -507,6 +523,11 @@ export function createRendering({ state, services, parts }) {
);
}
/** Whether a train's icon is drawn: shown, and not drawn by its model. */
function markerShown(entry) {
return categoryShown(entry) && !entry.modelOwnsVisual;
}
/**
* A train bound to its railway line is drawn on the line, at the arc its
* playback has reached; any other train (or a bound train whose reports do
@@ -536,7 +557,7 @@ export function createRendering({ state, services, parts }) {
undefined,
scratchPosition,
);
entry.marker.show = categoryShown(entry);
entry.marker.show = markerShown(entry);
}
/** While a train is selected, every other train is drawn see-through. */
@@ -554,7 +575,7 @@ export function createRendering({ state, services, parts }) {
entry.marker.width = size;
entry.marker.height = size;
entry.marker.color = tintFor(entry);
entry.marker.show = categoryShown(entry);
entry.marker.show = markerShown(entry);
}
/** Re-tint every train after the selection appeared, moved or cleared. */
@@ -658,7 +679,7 @@ export function createRendering({ state, services, parts }) {
function cullSelected() {
const entry = state._trains.get(state._selectedKey);
if (!entry?.marker) return;
const shown = categoryShown(entry) && facesCamera(entry.marker.position);
const shown = markerShown(entry) && facesCamera(entry.marker.position);
if (entry.marker.show !== shown) entry.marker.show = shown;
parts.routes.cullStops(facesCamera);
}
@@ -666,6 +687,121 @@ export function createRendering({ state, services, parts }) {
function onPreRender() {
advanceMoving();
if (state._enabled && state._selectedKey !== null) cullSelected();
if (state._enabled) updateModels();
}
// Under the DISPLAY rail's 3D toggle, trains near the camera are drawn as
// the project's procedural fleet models, chosen by category and mode,
// painted in their category colour where the model marks it, on the
// aircraft layers' icon-to-model rules (services.fleet). The fleet set
// loads once, up front, when the layer starts.
let fleetModels = null;
let modelItems = new Map();
let modelCandidatesAtMs = -Infinity;
/** Load the fleet set and create the layer's models (on enable). */
function startModels() {
const fleet = services.fleet;
if (!fleet?.policy || !state._viewer) return;
loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => {
/* models stay off; trains keep their icons */
});
fleetModels ||= createFleetModelLayer({
viewer: state._viewer,
display: fleet.display,
policy: fleet.policy,
resolveAsset: fleet.resolveAsset,
});
}
/** Hand a train's visual back to its icon. */
function handBack(entry) {
if (!entry.modelOwnsVisual) return;
entry.modelOwnsVisual = false;
if (entry.marker) entry.marker.show = markerShown(entry);
}
function modelItem(entry, set) {
let item = modelItems.get(entry.key);
if (!item || item.entry !== entry)
item = { entry, pickId: entry.key, modelOwnsVisual: false };
const specKey = `${entry.category}|${entry.railMode}`;
if (item.specKey !== specKey) {
item.specKey = specKey;
item.spec = transitModelSpec(set, {
railCategory: entry.category,
railMode: entry.railMode,
});
item.tint = CATEGORY_TINTS[entry.category] || CATEGORY_TINTS.local;
}
item.position = entry.marker.position;
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
item.show = categoryShown(entry);
return item;
}
function updateModels() {
if (!fleetModels) return;
if (!fleetModels.active() || state._trains.size === 0) {
if (modelItems.size) {
fleetModels.frame(new Map(), performance.now());
for (const item of modelItems.values()) handBack(item.entry);
modelItems = new Map();
}
return;
}
const set = fleetModelSet();
const now = performance.now();
const next = new Map();
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
modelCandidatesAtMs = now;
const camera = state._viewer?.camera;
const policy = services.fleet.policy;
const keepM =
services.fleet.display?.()?.models3dMode === 'all'
? policy.keepAllM
: policy.keepM;
const limitSq = keepM * keepM * 1.44;
if (camera?.positionWC)
for (const entry of state._trains.values()) {
if (!entry.marker || !categoryShown(entry)) continue;
if (
Cesium.Cartesian3.distanceSquared(
camera.positionWC,
entry.marker.position,
) > limitSq
)
continue;
next.set(entry.key, modelItem(entry, set));
}
} else {
for (const [key, item] of modelItems)
if (state._trains.get(key) === item.entry && item.entry.marker)
next.set(key, modelItem(item.entry, set));
}
for (const [key, item] of modelItems)
if (next.get(key) !== item) handBack(item.entry);
modelItems = next;
fleetModels.frame(modelItems, now);
for (const item of modelItems.values()) {
const entry = item.entry;
if (item.modelOwnsVisual) {
entry.modelOwnsVisual = true;
entry.marker.show = false;
} else handBack(entry);
}
if (fleetModels.settling) governorRequestRender('rail-models');
}
/** Drop every model (layer disabled); `destroy` also removes the collection. */
function stopModels(destroy = false) {
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
modelItems = new Map();
modelCandidatesAtMs = -Infinity;
if (destroy) {
fleetModels?.destroy();
fleetModels = null;
} else fleetModels?.clear();
}
function advanceMoving() {
@@ -697,7 +833,7 @@ export function createRendering({ state, services, parts }) {
function applyCategoryVisibility() {
for (const entry of state._trains.values()) {
if (!entry.marker) continue;
entry.marker.show = categoryShown(entry);
entry.marker.show = markerShown(entry);
schedulePlayback(entry);
}
syncRenderHold();
@@ -720,5 +856,7 @@ export function createRendering({ state, services, parts }) {
applyCategoryVisibility,
categoryShown,
reset,
startModels,
stopModels,
};
}
+3
View File
@@ -195,6 +195,7 @@ export function createLifecycle({ state, services, parts }) {
parts.rendering.onPreRender,
);
}
parts.rendering.startModels();
parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer);
},
@@ -242,6 +243,7 @@ export function createLifecycle({ state, services, parts }) {
destroyTrack(entry.track);
}
state._vehicles.clear();
parts.rendering.stopModels();
parts.height.clear();
state._markers?.removeAll();
state._animatedMarkers?.removeAll();
@@ -282,6 +284,7 @@ export function createLifecycle({ state, services, parts }) {
*/
destroy(viewer) {
this.disable(viewer);
parts.rendering.stopModels(true);
if (state._markers) {
unregisterSpriteCollection('transit', state._markers);
viewer?.scene?.primitives?.remove(state._markers);
+103 -2
View File
@@ -27,6 +27,12 @@ import {
TRANSIT_MODE_COLORS,
vehicleNearView,
} from './policy.js';
import {
fleetModelSet,
loadFleetModelSet,
transitModelSpec,
} from '../../data/fleetModels.js';
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
export const MODE_CESIUM_COLORS = Object.fromEntries(
Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [
@@ -100,7 +106,8 @@ export function createRendering({ state, services, parts }) {
);
entry.surfaceReady = !!known;
entry.heightPending = !known;
entry.marker.show = !!known && vehicleInView(entry);
entry.marker.show =
!!known && vehicleInView(entry) && !entry.modelOwnsVisual;
if (known) {
entry.marker.position = state._scratchCartesian;
entry.hasRendered = true;
@@ -403,6 +410,7 @@ export function createRendering({ state, services, parts }) {
const now = state.now();
if (state._vehicles.size === 0) {
state._moving.clear();
updateModels();
syncRenderHold();
return;
}
@@ -450,9 +458,97 @@ export function createRendering({ state, services, parts }) {
: 0;
}
if (state._heightDirty.size) governorRequestRender('transit-height-batch');
updateModels();
syncRenderHold();
}
// Under the DISPLAY rail's 3D toggle, vehicles in view near the camera are
// drawn as the project's procedural fleet models, chosen by mode, painted
// in their mode colour where the model marks it, on the aircraft layers'
// icon-to-model rules (services.fleet). The fleet set loads once, up front,
// when the layer starts.
let fleetModels = null;
let modelItems = new Map();
/** Load the fleet set and create the layer's models (on enable). */
function startModels() {
const fleet = services.fleet;
if (!fleet?.policy || !state._viewer) return;
loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => {
/* models stay off; vehicles keep their icons */
});
fleetModels ||= createFleetModelLayer({
viewer: state._viewer,
display: fleet.display,
policy: fleet.policy,
resolveAsset: fleet.resolveAsset,
});
}
/** Hand an entry's visual back to its icon. */
function handBack(entry) {
if (!entry.modelOwnsVisual) return;
entry.modelOwnsVisual = false;
if (entry.marker)
entry.marker.show =
vehicleInView(entry) &&
!entry.heightPending &&
entry.surfaceReady !== false;
}
function updateModels() {
if (!fleetModels) return;
if (!fleetModels.active() || state._vehicles.size === 0) {
if (modelItems.size) {
fleetModels.frame(new Map(), performance.now());
for (const item of modelItems.values()) handBack(item.entry);
modelItems = new Map();
}
return;
}
const set = fleetModelSet();
const next = new Map();
for (const entry of state._visible) {
if (!entry.marker || !entry.hasRendered) continue;
let item = modelItems.get(entry.key);
if (!item || item.entry !== entry) {
item = { entry, pickId: entry.key, modelOwnsVisual: false };
}
if (item.mode !== entry.mode) {
item.mode = entry.mode;
item.spec = transitModelSpec(set, { mode: entry.mode });
item.tint =
MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown;
}
item.position = entry.marker.position;
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
item.show = !entry.heightPending && entry.surfaceReady !== false;
next.set(entry.key, item);
}
for (const [key, item] of modelItems)
if (next.get(key) !== item) handBack(item.entry);
modelItems = next;
fleetModels.frame(modelItems, performance.now());
for (const item of modelItems.values()) {
const entry = item.entry;
if (item.modelOwnsVisual) {
entry.modelOwnsVisual = true;
entry.marker.show = false;
} else handBack(entry);
}
if (fleetModels.settling) governorRequestRender('transit-models');
}
/** Drop every model (layer disabled); `destroy` also removes the collection. */
function stopModels(destroy = false) {
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
modelItems = new Map();
if (destroy) {
fleetModels?.destroy();
fleetModels = null;
} else fleetModels?.clear();
}
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
const sphere = new Cesium.BoundingSphere(undefined, 5);
const cameraPosition = new Cesium.Cartesian3();
@@ -556,7 +652,10 @@ export function createRendering({ state, services, parts }) {
}
}
entry.marker.show =
visible && !entry.heightPending && entry.surfaceReady !== false;
visible &&
!entry.heightPending &&
entry.surfaceReady !== false &&
!entry.modelOwnsVisual;
visibility.heightPending = !!entry.heightPending;
visibility.surfaceReady = entry.surfaceReady ?? null;
schedulePlayback(entry);
@@ -621,5 +720,7 @@ export function createRendering({ state, services, parts }) {
setStylePreset,
onPreRender,
syncRenderHold,
startModels,
stopModels,
};
}
+2
View File
@@ -213,6 +213,8 @@ export function createIngestion({
imo: record.imo,
type: record.type,
destination: record.destination,
length: record.lengthM ?? null,
beam: record.beamM ?? null,
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
course: record.courseDeg,
heading: record.headingDeg,
+1
View File
@@ -248,6 +248,7 @@ export function createLifecycle({
if (state.preRenderRemover) {
state.preRenderRemover();
}
components.rendering.destroyModels();
resetState();
},
};
+10
View File
@@ -25,6 +25,9 @@ export function normalizeVessel(row) {
imo: String(row.imo || ''),
type: String(row.type || ''),
destination: String(row.destination || ''),
// Hull length and beam (metres), null when unreported.
length: positiveNumber(row.length),
beam: positiveNumber(row.beam),
speed: finiteNumber(row.speed),
course: finiteNumber(row.course),
heading: finiteNumber(row.heading),
@@ -46,6 +49,11 @@ function stampFix(record, receivedAtMs, feedLatestEpoch) {
record.fixFeedEpoch = feedLatestEpoch;
}
function positiveNumber(value) {
const number = finiteNumber(value);
return number !== null && number > 0 ? number : null;
}
function finiteNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
@@ -147,6 +155,8 @@ export class VesselRecords {
record.imo = next.imo;
record.type = next.type;
record.destination = next.destination;
record.length = next.length;
record.beam = next.beam;
record.speed = next.speed;
record.course = next.course;
record.heading = next.heading;
+148 -1
View File
@@ -22,10 +22,19 @@ import {
followsClock,
} from './policy.js';
import { vesselPositionAt } from './motion.js';
import {
fleetModelSet,
loadFleetModelSet,
vesselModelSpec,
} from '../../data/fleetModels.js';
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
/** How often vessels move on as the world clock plays (wall ms). */
const FOLLOW_POSITION_MS = 250;
/** How often the vessels near enough for a model are gathered again (wall ms). */
const MODEL_CANDIDATES_MS = 500;
export function createRendering({
vesselState,
services,
@@ -56,6 +65,12 @@ export function createRendering({
VESSEL_LIFT_M,
);
visual.position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
// A ship model's origin is its waterline: the sea surface, unlifted.
visual.waterline = Cesium.Cartesian3.fromDegrees(
lon,
lat,
heightM - VESSEL_LIFT_M,
);
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
visual.position,
@@ -101,6 +116,7 @@ export function createRendering({
}
function ensureCollections(viewer) {
ensureModels(viewer);
if (!viewer || state.billboardCollection) return;
state.billboardCollection = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
@@ -203,6 +219,126 @@ export function createRendering({
return icon;
}
// Under the DISPLAY rail's 3D toggle, vessels near the camera are drawn as
// the project's procedural fleet models at their reported length and beam,
// on the aircraft layers' icon-to-model rules (services.fleet). The fleet
// set loads once, up front, when the layer starts.
let fleetModels = null;
let modelItems = new Map();
let modelCandidatesAtMs = -Infinity;
function ensureModels(viewer) {
const fleet = services.fleet;
if (!viewer || fleetModels || !fleet?.policy) return;
loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => {
/* models stay off; vessels keep their icons */
});
fleetModels = createFleetModelLayer({
viewer,
display: fleet.display,
policy: fleet.policy,
resolveAsset: fleet.resolveAsset,
});
}
/** The model item for `record`, its spec rebuilt only when its type or size changes. */
function modelItem(record, set) {
let item = modelItems.get(record.id);
const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`;
if (!item) {
item = { pickId: record, tint: null, modelOwnsVisual: false };
modelItems.set(record.id, item);
}
if (item.specKey !== specKey) {
item.specKey = specKey;
item.spec = vesselModelSpec(set, record);
}
const visual = getVisual(record);
item.record = record;
item.position = visual.waterline;
item.headingDeg = vesselCourseDeg(record);
item.show = record.id !== state.cockpitSubjectId;
return item;
}
/** Gather the vessels within the models' keep radius of the camera. */
function gatherModelItems(set, prefs) {
const camera = state.viewer?.camera;
const policy = services.fleet.policy;
const keepM =
prefs?.models3dMode === 'all' ? policy.keepAllM : policy.keepM;
const next = new Map();
if (camera?.positionWC) {
const limitSq = keepM * keepM * 1.44;
for (const record of state.records.all) {
const visual = getVisual(record);
if (!visual.waterline) continue;
if (
Cesium.Cartesian3.distanceSquared(
camera.positionWC,
visual.waterline,
) > limitSq
)
continue;
next.set(record.id, modelItem(record, set));
}
}
for (const [id, item] of modelItems)
if (!next.has(id)) handBackVisual(item);
modelItems = next;
}
/** Give a vessel's icon back when its model no longer draws it. */
function handBackVisual(item) {
if (!item.ownedBillboard) return;
item.ownedBillboard = false;
const billboard = getVisual(item.record).billboard;
if (billboard)
billboard.show =
isVisible(getVisual(item.record).surfacePosition, makeOccluder()) &&
item.record.id !== state.cockpitSubjectId;
}
function updateModels() {
if (!fleetModels) return;
if (!fleetModels.active()) {
if (modelItems.size) {
fleetModels.frame(modelItems, performance.now());
for (const item of modelItems.values()) handBackVisual(item);
modelItems = new Map();
}
return;
}
const set = fleetModelSet();
const now = performance.now();
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
modelCandidatesAtMs = now;
gatherModelItems(set, services.fleet.display?.());
} else {
for (const [id, item] of modelItems) {
if (state.records.byId.get(id) === item.record)
modelItem(item.record, set);
else modelItems.delete(id);
}
}
fleetModels.frame(modelItems, now);
for (const item of modelItems.values()) {
const billboard = getVisual(item.record).billboard;
if (item.modelOwnsVisual) {
item.ownedBillboard = true;
if (billboard) billboard.show = false;
} else handBackVisual(item);
}
if (fleetModels.settling) governorRequestRender('ais-vessels-models');
}
function destroyModels() {
fleetModels?.destroy();
fleetModels = null;
modelItems = new Map();
modelCandidatesAtMs = -Infinity;
}
// Vessels move only when a poll lands, so the layer does not hold the
// continuous render loop. Its passes run in preRender, which idle mode fires
// only for rendered frames: a forced pass (new data, selection) requests its
@@ -216,6 +352,7 @@ export function createRendering({
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (followsClock(vesselState)) followClockPositions();
updateVisibility();
if (state.feed.enabled) updateModels();
});
}
@@ -307,7 +444,9 @@ export function createRendering({
if (visual.billboard) {
// The bridge camera sits on this sprite; showing it would fill the view.
visual.billboard.show =
visible && record.id !== state.cockpitSubjectId;
visible &&
record.id !== state.cockpitSubjectId &&
!modelItems.get(record.id)?.ownedBillboard;
if (visible && doRotations && scene) {
const rot = screenProjectedRotation(
scene,
@@ -582,6 +721,12 @@ export function createRendering({
if (state.billboardCollection) {
state.billboardCollection.show = show;
}
fleetModels?.setVisible(show);
if (!show && fleetModels) {
fleetModels.clear();
for (const item of modelItems.values()) item.ownedBillboard = false;
modelItems = new Map();
}
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
}
return {
@@ -607,5 +752,7 @@ export function createRendering({
labelPriority,
hasUsefulName,
setVisible,
updateModels,
destroyModels,
};
}
+9
View File
@@ -10,6 +10,12 @@ function vesselMmsi(row) {
return MMSI_PATTERN.test(text) ? text : '';
}
/** A positive measurement, else null. */
function positive(value) {
const number = finite(value);
return number !== null && number > 0 ? number : null;
}
/** A row's stable id when its source names none: its MMSI or identifier. */
function defaultVesselId(row) {
return String(row?.mmsi || row?.input_identifier || '');
@@ -39,6 +45,9 @@ export function normalizeVesselObservation(row, reference = null, id = null) {
imo: String(row.imo || ''),
type: String(row.type_specific || row.type || ''),
destination: String(row.destination || ''),
// Hull length and beam (metres) when the source reports them.
lengthM: positive(row.length),
beamM: positive(row.beam),
speedMps: speedKts == null ? null : speedKts * 0.514444,
courseDeg: finite(row.course),
headingDeg: finite(row.heading),