mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
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:
1 parent
546ac22708
commit
974023510f
12 files changed
+212
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user