fix(transit): keep the real silhouettes under sensor presets, make the selected trail visible, put Transit in Movement (#596)

## Changes

Follow-up to #587 after the owner's first look at the merged transit layer on Google 3D tiles.

- **Sensor presets keep the real silhouettes.** Under NVG, thermal (white-hot, black-hot, Ironbow) and noir, vehicles are the same silhouettes as in normal view, filled opaque white with a 2 px dark halo, at the CRT display sizes (26 px fleet, 39 px selected). The solid rounded "signature" bodies are gone.
- **The selected vehicle's trail is visible.** The retained history is now draped onto 3D tiles and terrain (`GroundPolylinePrimitive`, `ClassificationType.BOTH`, the way the traffic heat-lines are drawn), 3 px core over a 5 px dark backing, per-segment age colour, a short draped head clipped to the sprite. Two defects kept it off screen before: Cesium's per-instance attribute getters return copies, so the renderer's show/colour edits were discarded, and a marker did not recover after the camera jumped to it, which hid the trail with it. Trail geometry is now prepared on selection independent of floor resolution and marker visibility; sparse long intervals (rail) keep a bounded marker corridor instead of dropping the interval; classification survives map-source changes.
- **Transit sits in the Movement panel group** between Street Traffic and Bike Share (the panel orders by `PANEL_GROUPS`, not by catalog order; the catalog is moved too).

## How it was verified

- `npm run format:check` (854 files), `npm run check:boundaries`, `npm test` (4,062 passing), `npm run build` clean; tracking regression 109/109 in a real browser on Google 3D tiles.
- Browser harness (`scripts/qa-transit.mjs`), keyed on Google 3D tiles and keyless: keyed 194 pass, with the trail pixel check passing at 300, 600 and 900 m; the remaining keyed failures are the per-frame allocation gate already recorded as a follow-up from #587 (CPU and frame time within budget), four readability lines at threshold edges, and the card-anchor check now counting frames where the selected marker is legitimately off screen; keyless 193 pass with the remaining misses being threshold edges on the thin tram silhouette (centre 0.83–0.88 against 0.85) and one bus ring 0.28 against 0.25 on a pure-white roof.
- New harness checks: `Google 3D selected trail visible at 6 of 8 projected samples` (repeated off/on framebuffer captures at 300/600/900 m, samples outside the sprite footprint, a background-change negative control; fails on main, passes here), a camera-arrival check (select a moving vehicle, `setView` onto it, marker shown and trail body drawn within 2 s), `PANEL_ORDER` pinned, raster tests comparing every sensor raster's mask and aspect ratio to the normal silhouette.
- Direct look on a keyed build: a live CapMetro bus at 30 m/s selected through the layer API draws its draped trail along the highway lane (harness capture `keyed-600m-trail-visible.jpg`).
- Adversarial review by Codex Astra before the last two fix passes (stack-switch classification, marker corridor under the vertex cap, pixel-gate loophole), all three fixed with regressions.

Docs: `docs/CURRENT-STATE.md` and `CHANGELOG.md` updated in this PR. No data-source changes.
This commit is contained in:
Bilawal Sidhu authored and GitHub committed 2026-09-15 13:26:41 -05:00
1 parent cd22ca3559
commit 0bb61f53c6
24 files changed
+1945 -205

No files matched your search

+33 -12
View File
@@ -18,7 +18,9 @@ const MODE_COLOR = {
ferry: '#5FD6FF',
unknown: '#D8DDE5',
};
export const TRAIL_VERTEX_LIMIT = 2048;
// Final Cesium batching uses 3,152 bytes per two-lane edge.
// The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB.
export const TRAIL_VERTEX_LIMIT = 640;
const STEP_M = 25;
/** Shared prepared paths. Floors are aligned to work with Google 3D tiles. */
@@ -63,6 +65,7 @@ export function createTrails({ state, services, parts, source }) {
entry.trailSegments = [];
entry.trailVertices = 0;
}
const draped = entry === selected && ensureRenderer()?.diagnostics().ground;
const paths = new Map(),
all = [],
a = {},
@@ -113,16 +116,28 @@ export function createTrails({ state, services, parts, source }) {
if (entry === selected)
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
entry.trailTruncated = false;
let markerVertices = 0;
let markerIntervals = Math.max(0, entry.track.count - 1 - activeIndex);
for (const i of order) {
readFix(entry.track, i, a);
readFix(entry.track, i + 1, b);
const markerCorridor = i >= activeIndex;
if (markerCorridor) markerIntervals--;
if (a.epoch !== b.epoch || b.flags & FIX_FLAGS.BREAK) continue;
if (entry !== selected && b.t < entry.sample.displayT) continue;
const n = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M));
if (vertices + n + 1 > TRAIL_VERTEX_LIMIT) {
entry.trailTruncated = true;
continue;
}
const desired = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M));
// Reserve endpoints for every future marker interval. Sparse reports
// coarsen the active path instead of losing the vehicle to the body cap.
const n = markerCorridor
? Math.min(
desired,
TRAIL_VERTEX_LIMIT - markerVertices - 2 * markerIntervals - 1,
)
: desired;
const retainBody = vertices + n + 1 <= TRAIL_VERTEX_LIMIT;
if (!retainBody || n < desired) entry.trailTruncated = true;
if (!markerCorridor && !retainBody) continue;
if (markerCorridor) markerVertices += n + 1;
const positions = [],
heights = [];
let valid = true;
@@ -136,17 +151,21 @@ export function createTrails({ state, services, parts, source }) {
heights.push(h);
if (h === null) {
valid = false;
positions.push(null);
// Ground polylines consume longitude/latitude and drape themselves.
// Unknown floors still disqualify the marker corridor.
positions.push(
draped ? Cesium.Cartesian3.fromDegrees(point.lon, point.lat) : null,
);
} else
positions.push(
Cesium.Cartesian3.fromDegrees(point.lon, point.lat, h),
);
}
signature += `${a.seq}/${b.seq}:${heights.join(',')};`;
vertices += n + 1;
if (!valid) continue;
paths.set(a.seq, { positions, toSeq: b.seq });
if (entry === selected && fixDistanceM(a, b) > 0) {
if (retainBody) vertices += n + 1;
if (valid) paths.set(a.seq, { positions, toSeq: b.seq });
if (!valid && !draped) continue;
if (entry === selected && retainBody && fixDistanceM(a, b) > 0) {
for (let k = 0; k < n; k++)
all.push({
fromSeq: a.seq,
@@ -216,7 +235,9 @@ export function createTrails({ state, services, parts, source }) {
}
function update() {
if (!selected || !renderer) return;
renderer.setVisible(state._enabled && selected.marker?.show !== false);
renderer.setVisible(
state._enabled && state._vehicles.get(selected.key) === selected,
);
renderer.setDisplaySample(selected.sample, selected.marker?.position);
}
function select(entry) {