From 974023510f8733468b0ab15c37f287af789d84ae Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Fri, 13 Nov 2026 16:43:50 +0100 Subject: [PATCH] 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. --- docs/CURRENT-STATE.md | 6 +-- scripts/format-scope.json | 1 + src/layers/rail/cockpitSubject.js | 4 +- src/layers/rail/cockpitSubject.test.mjs | 33 ++++++++++++ src/layers/rail/index.js | 2 +- src/layers/rail/rendering.js | 6 ++- src/layers/transit/cockpitSubject.js | 4 +- src/layers/transit/lifecycle.test.mjs | 61 ++++++++++++++++++++++ src/layers/transit/rendering.js | 12 +++-- src/layers/transit/state.js | 4 +- src/testSupport/fleetModelFakes.mjs | 68 +++++++++++++++++++++++++ src/testSupport/railLayerHarness.mjs | 28 +++++++++- 12 files changed, 212 insertions(+), 17 deletions(-) create mode 100644 src/testSupport/fleetModelFakes.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b03966fd5..54fdedbe0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 31669cfd1..9a7e5e122 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js index 762ff9aa6..80cfc1daa 100644 --- a/src/layers/rail/cockpitSubject.js +++ b/src/layers/rail/cockpitSubject.js @@ -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 diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs index 172cf3b56..4700e71e6 100644 --- a/src/layers/rail/cockpitSubject.test.mjs +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -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) => { diff --git a/src/layers/rail/index.js b/src/layers/rail/index.js index 21077c0d0..ec6322fc3 100644 --- a/src/layers/rail/index.js +++ b/src/layers/rail/index.js @@ -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, /** diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index 485d8894c..21b719980 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -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; } diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js index 0439f0abf..b0e8a64c0 100644 --- a/src/layers/transit/cockpitSubject.js +++ b/src/layers/transit/cockpitSubject.js @@ -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. diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index d319da9e5..69b752b99 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index f34f1359a..058aa3a5d 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -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) diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 47016ba98..da21a08d1 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -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; diff --git a/src/testSupport/fleetModelFakes.mjs b/src/testSupport/fleetModelFakes.mjs new file mode 100644 index 000000000..3a51e994a --- /dev/null +++ b/src/testSupport/fleetModelFakes.mjs @@ -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, + ), + }; +} diff --git a/src/testSupport/railLayerHarness.mjs b/src/testSupport/railLayerHarness.mjs index 5f7a2ae62..ddfcba030 100644 --- a/src/testSupport/railLayerHarness.mjs +++ b/src/testSupport/railLayerHarness.mjs @@ -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|Function|false, * ground?: object, * stops?: object|null, scheduleStops?: boolean, - * paths?: Record|null}} [options] + * paths?: Record|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) {