fix(rail, transit): a train or vehicle a first-person view holds draws no 3D model either, since the view's eye is inside it; let go, its model draws again

The fleet models (#1714) draw nearby trains and vehicles under the 3D toggle. A
held one now leaves out its model as it does its icon, as the Vessels layer does
for its bridge subject: its model item is hidden, and handing the visual back
to its icon keeps the icon hidden while held. Tests drive each layer with the
real fleet-model layer and a stubbed glTF loader; both fail without the fix.
This commit is contained in:
Denis Groshev committed 2026-11-13 16:43:50 +01:00
1 parent 546ac22708
commit 974023510f
12 files changed
+212 -17

No files matched your search

+3 -3
View File
@@ -2263,7 +2263,7 @@ which way it runs (`pathBinding.js` learns it), its rail height, speed as
reported or derived, position source, feed credit and stops, and
`selectedAtMs`, when the user last selected it by a click or `focusTrain`,
even again; null after a deselection and for a train only a hold selected),
`setCockpitSubject(key)` (its marker and card hidden, the selection kept, its
`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its
feed held, clicks and Escape left to the view; the held copy stays the one
drawn of its train while held, another feed's copy hidden, and it keeps
moving, still selected, when its category is turned off) and
@@ -2273,8 +2273,8 @@ and these read APIs are experimental and may change without notice. A snapshot r
the one read before, and its `path.line` is a frozen copy. Transit
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
out of the camera's view, and its sprite, card, trail and detection box are
hidden; released, it is out of play until a visibility sweep finds it in
out of the camera's view, and its sprite, 3D model, card, trail and detection
box are hidden; released, it is out of play until a visibility sweep finds it in
view. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
+1
View File
@@ -1441,5 +1441,6 @@
"src/data/fleetModels.test.mjs",
"src/data/fleetModelInstances.test.mjs",
"src/testSupport/fleetGlb.mjs",
"src/testSupport/fleetModelFakes.mjs",
"scripts/measure-fleet-trail-anchors.mjs"
]
+2 -2
View File
@@ -95,8 +95,8 @@ function reuse(previous, next) {
* and a part that did not change (its feed, its line, its stops) is the same
* object in the next one, so a view can read it on every frame. `path.line`
* is a frozen copy of the train's line for `pointAtS` and `projectOnPath`.
* `setCockpitSubject(key)` holds a train: its marker, card and selected line
* are hidden, it stays selected (so its stops stay fresh and its feed stays
* `setCockpitSubject(key)` holds a train: its marker, 3D model, card and
* selected line are hidden, it stays selected (so its stops stay fresh and its feed stays
* polled at any distance) and clicks and Escape no longer change the
* selection. A held train keeps moving, hidden, and keeps the selection when
* its category is turned off, and is the copy of its train drawn though
+33
View File
@@ -18,6 +18,7 @@ import {
import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
import { publicRailCatalog } from '../../data/railFeeds.js';
import { pointAtS } from '../../data/railPaths.js';
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
const M_PER_DEG = 111_320;
const LAT = 42.36;
@@ -228,6 +229,38 @@ test('a held train hides its marker and card and keeps the selection', async (t)
);
});
test('a held train draws no 3D model either; let go, its model draws again', async (t) => {
const models = await fleetModelFakes(t);
const app = await onTheLine(t, { fleet: models.fleet, altitude: 1_000 });
// A model's load answers off the frame; the next frames place it.
const frames = async () => {
app.run(500);
await app.settle();
app.run(500);
};
await frames();
assert.equal(models.drawn(T1).length, 1, 'near the camera: its model');
assert.equal(app.entry(T1).modelOwnsVisual, true);
assert.equal(app.entry(T1).marker.show, false, 'its icon hands over');
assert.equal(app.layer.setCockpitSubject(T1), true);
app.run(250);
// The view's eye is inside the train.
assert.equal(models.drawn(T1).length, 0, 'no model');
assert.equal(app.entry(T1).modelOwnsVisual, false);
assert.equal(app.entry(T1).marker.show, false, 'and no icon');
assert.equal(models.drawn(T2).length, 1, 'another train keeps its model');
await frames();
assert.equal(models.drawn(T1).length, 0, 'not on a later frame either');
assert.equal(app.entry(T1).marker.show, false);
assert.equal(app.layer.setCockpitSubject(null), true);
await frames();
assert.equal(models.drawn(T1).length, 1, 'let go: its model again');
assert.equal(app.entry(T1).modelOwnsVisual, true);
assert.equal(app.entry(T1).marker.show, false);
});
const MADRID = { lat: 40.4168, lon: -3.7038 };
test('a held train stays the copy drawn when another feed’s copy outranks it, and keeps moving', async (t) => {
+1 -1
View File
@@ -89,7 +89,7 @@ function createState({ services }) {
_heldFeedId: null,
/**
* The train a first-person view holds (cockpitSubject.js), or null: its
* marker and card are hidden and the selection stays on it.
* marker, 3D model and card are hidden and the selection stays on it.
*/
_cockpitSubjectKey: null,
/**
+4 -2
View File
@@ -526,7 +526,7 @@ export function createRendering({ state, services, parts }) {
/**
* Whether a train's icon is drawn: shown, not drawn by its model, and no
* first-person view rides it (cockpitSubject.js). A held train keeps its
* playback; only its icon hides.
* playback; only its icon and model hide.
*/
function markerShown(entry) {
return (
@@ -760,7 +760,9 @@ export function createRendering({ state, services, parts }) {
}
item.position = entry.marker.position;
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
item.show = categoryShown(entry);
// A train a first-person view rides draws no model: the camera is inside
// it (cockpitSubject.js).
item.show = categoryShown(entry) && entry.key !== state._cockpitSubjectKey;
return item;
}
+2 -2
View File
@@ -27,8 +27,8 @@ function reuse(previous, next) {
* that did not change (its feed, its stop) is the same object in the next
* one, so a view can read it on every frame. Transit has no route shapes or
* stops, so a snapshot names only the vehicle's last reported stop.
* `setCockpitSubject(key)` holds a vehicle: its sprite, card, trail and
* detection box are hidden, it keeps moving when it is out of the camera's
* `setCockpitSubject(key)` holds a vehicle: its sprite, 3D model, card,
* trail and detection box are hidden, it keeps moving when it is out of the camera's
* view, its feed stays polled, and clicks and Escape no longer change the
* selection. The hold ends with `setCockpitSubject(null)`, and on
* its own when the vehicle is removed or the layer is turned off.
+61
View File
@@ -11,6 +11,7 @@ import {
VEHICLE_MAX_FIX_AGE_MS,
} from './policy.js';
import { FLOOR_WARM_PER_POLL } from './policy.js';
import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs';
import {
FUTURE_STAMP_TOLERANCE_MS,
ORDER_REJECTS_BEFORE_RESET,
@@ -66,6 +67,25 @@ function snapshot(
};
}
/** A camera's place and frame looking straight down from `altitude` over `at`. */
function lookingDown(at, altitude) {
const positionWC = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude);
const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
positionWC,
new Cesium.Cartesian3(),
);
return {
positionWC,
directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()),
upWC: Cesium.Cartesian3.UNIT_Z,
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
}
/** A vehicle record as the proxy emits it, with its timestamp source resolved. */
function vehicle(id, lat, lon, atSeconds, extra = {}) {
return {
@@ -160,6 +180,9 @@ function harness(
east: at.lon + 0.1,
north: at.lat + 0.1,
},
// Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera
// looking straight down for them; without them vehicles stay icons.
fleet = null,
} = {},
) {
const viewRect = { ...view };
@@ -220,6 +243,7 @@ function harness(
viewRect.north,
);
},
...(fleet ? lookingDown(at, altitude) : {}),
},
scene: {
canvas: { addEventListener() {}, removeEventListener() {} },
@@ -266,6 +290,7 @@ function harness(
const layer = createTransitLayer({
source,
services: {
...(fleet ? { fleet } : {}),
ground,
mesh: {
sampleMeshFloorCells(scene, points, options) {
@@ -4217,6 +4242,42 @@ test('a vehicle read again while nothing changed is the snapshot read before', a
assert.equal(app.layer.getSelectedVehicle(), moved);
});
test('a held vehicle draws no 3D model either; let go, its model draws again', async (t) => {
const models = await fleetModelFakes(t);
const app = harness(t, { fleet: models.fleet, altitude: 1_000 });
const key = await busRunningNorth(app);
const [entry] = app.vehicles();
assert.notEqual(entry.mode, 'unknown', 'a mode its model is chosen by');
// The throttled sweep runs; a model's load answers off the frame and the
// next frames place it.
const frames = async () => {
app.advance(250);
app.settle();
for (let i = 0; i < 8; i += 1) await Promise.resolve();
app.settle();
};
await frames();
assert.equal(models.drawn(key).length, 1, 'near the camera: its model');
assert.equal(entry.modelOwnsVisual, true);
assert.equal(entry.marker.show, false, 'its icon hands over');
assert.equal(app.layer.setCockpitSubject(key), true);
app.frame();
// The view's eye is inside the vehicle.
assert.equal(models.drawn(key).length, 0, 'no model');
assert.equal(entry.modelOwnsVisual, false);
assert.equal(entry.marker.show, false, 'and no icon');
await frames();
assert.equal(models.drawn(key).length, 0, 'not on a later frame either');
assert.equal(entry.marker.show, false);
assert.equal(app.layer.setCockpitSubject(null), true);
await frames();
assert.equal(models.drawn(key).length, 1, 'let go: its model again');
assert.equal(entry.modelOwnsVisual, true);
assert.equal(entry.marker.show, false);
});
test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => {
const app = harness(t);
const key = await busRunningNorth(app);
+9 -3
View File
@@ -493,7 +493,7 @@ export function createRendering({ state, services, parts }) {
});
}
/** Hand an entry's visual back to its icon. */
/** Hand an entry's visual back to its icon, unless a view rides it. */
function handBack(entry) {
if (!entry.modelOwnsVisual) return;
entry.modelOwnsVisual = false;
@@ -501,7 +501,8 @@ export function createRendering({ state, services, parts }) {
entry.marker.show =
vehicleInView(entry) &&
!entry.heightPending &&
entry.surfaceReady !== false;
entry.surfaceReady !== false &&
entry.key !== state._cockpitSubjectKey;
}
function updateModels() {
@@ -538,7 +539,12 @@ export function createRendering({ state, services, parts }) {
}
item.position = entry.marker.position;
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
item.show = !entry.heightPending && entry.surfaceReady !== false;
// A vehicle a first-person view rides draws no model: the camera is
// inside it (cockpitSubject.js).
item.show =
!entry.heightPending &&
entry.surfaceReady !== false &&
entry.key !== state._cockpitSubjectKey;
next.set(entry.key, item);
}
for (const [key, item] of modelItems)
+2 -2
View File
@@ -99,8 +99,8 @@ export function createState({ services }) {
state._selectedAtMs = null;
/**
* The vehicle a first-person view holds (cockpitSubject.js), or null: its
* sprite, card, trail and detection box are hidden, and it keeps moving
* out of the camera's view.
* sprite, 3D model, card, trail and detection box are hidden, and it keeps
* moving out of the camera's view.
* @type {string|null}
*/
state._cockpitSubjectKey = null;
+68
View File
@@ -0,0 +1,68 @@
// Fleet models for a layer test (fleetModelInstances.js): the project's model
// set loaded from public/, a DISPLAY 3D toggle that is on, and Cesium's glTF
// loader replaced by one that answers at once with a ready model, recorded.
import * as Cesium from 'cesium';
import { loadFleetModelSet } from '../data/fleetModels.js';
import { fetchPublicFile } from './fleetGlb.mjs';
/** A model policy whose eligibility is read again on every frame. */
export const FLEET_TEST_POLICY = Object.freeze({
altitudeCeilingM: 800_000,
minimumPixelSize: 16,
cap: 4,
capAll: 8,
addM: 5_000,
addAllM: 20_000,
keepM: 6_000,
keepAllM: 24_000,
intervalMs: 0,
});
/**
* Load the fleet set (once a process, as the app does) and fake the loader
* for this test. Call it before the layer's harness, which replaces `fetch`.
* @param {import('node:test').TestContext} t
* @returns {Promise<{fleet: object, created: object[],
* drawn: (id: string) => object[]}>} `fleet` for the layer's services;
* `drawn(id)` the models drawn for the vehicle `id` now.
*/
export async function fleetModelFakes(t) {
const info = console.info;
console.info = () => {};
try {
await loadFleetModelSet({ fetchImpl: fetchPublicFile });
} finally {
console.info = info;
}
const created = [];
t.mock.method(Cesium.Model, 'fromGltfAsync', async (options) => {
const model = {
...options,
ready: true,
show: false,
modelMatrix: new Cesium.Matrix4(),
destroyed: false,
destroy() {
this.destroyed = true;
},
isDestroyed() {
return this.destroyed;
},
update() {},
};
created.push(model);
return model;
});
return {
fleet: {
policy: FLEET_TEST_POLICY,
display: () => ({ models3d: true, models3dMode: 'proximity' }),
resolveAsset: (url) => url,
},
created,
drawn: (id) =>
created.filter(
(model) => model.id === id && !model.destroyed && model.show,
),
};
}
+26 -2
View File
@@ -12,6 +12,24 @@ import { publicRailCatalog } from '../data/railFeeds.js';
export const RAIL_HARNESS_T0 = 1_789_000_000_000;
export const BOSTON = Object.freeze({ lat: 42.3601, lon: -71.0589 });
/** A camera's frame looking straight down from `altitude` over `at`. */
function lookingDown(at, altitude) {
const position = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude);
const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
position,
new Cesium.Cartesian3(),
);
return {
directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()),
upWC: Cesium.Cartesian3.UNIT_Z,
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
}
/** A train as the rail proxy sends it. */
export function railTrain(id, lat, lon, extra = {}) {
return {
@@ -64,7 +82,7 @@ const headersOf = (values = {}) => ({
* catalog?: object[]|Promise<object[]>|Function|false,
* ground?: object,
* stops?: object|null, scheduleStops?: boolean,
* paths?: Record<string, object>|null}} [options]
* paths?: Record<string, object>|null, fleet?: object|null}} [options]
* `ground`: a floor service in place of the always-cold stub; `catalog`:
* the server's feed catalog (it may list fewer feeds than are bundled; a
* promise answers when it settles, a function is called per request and
@@ -74,7 +92,9 @@ const headersOf = (values = {}) => ({
* layer's own provider, its schedule tier, in its place;
* `paths`: feed id → the path pack the proxy serves, or a
* failure status it answers with (404 for any other feed; without it the
* source has no path route at all).
* source has no path route at all); `fleet`: the fleet-model services
* (testSupport/fleetModelFakes.mjs), with a camera that looks straight
* down for them (none by default: trains stay icons).
*/
export function railLayerHarness(
t,
@@ -86,6 +106,7 @@ export function railLayerHarness(
stops = null,
scheduleStops = false,
paths = null,
fleet = null,
} = {},
) {
const T0 = RAIL_HARNESS_T0;
@@ -179,6 +200,8 @@ export function railLayerHarness(
cancelFlight() {
this.flightsCancelled += 1;
},
// What the fleet models' eligibility reads: a camera looking down.
...(fleet ? lookingDown(at, altitude) : {}),
},
scene: {
canvas: { addEventListener() {}, removeEventListener() {} },
@@ -308,6 +331,7 @@ export function railLayerHarness(
cachedGroundFloor: () => null,
warmGroundFloor() {},
},
...(fleet ? { fleet } : {}),
camera: {
framing: WORLD_FOCUS_FRAMING.train,
requestWorldFocus(detail) {