mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
## 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.
241 lines
8.2 KiB
JavaScript
241 lines
8.2 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
FLEET_SCREEN_PX,
|
|
TRANSIT_ICON_KINDS,
|
|
haloFrame,
|
|
transitIcon,
|
|
transitIconCacheSize,
|
|
} from './transitIcons.js';
|
|
|
|
const decode = (uri) =>
|
|
Buffer.from(uri.replace('data:image/svg+xml;base64,', ''), 'base64').toString(
|
|
'utf8',
|
|
);
|
|
|
|
test('a halo is sized in screen pixels and pads the frame so it cannot clip', () => {
|
|
const none = haloFrame(0);
|
|
assert.deepEqual(none, { units: 0, pad: 0, ratio: 1 });
|
|
const two = haloFrame(2, FLEET_SCREEN_PX);
|
|
// Two pixels of ring on an eighteen-pixel glyph is a 4-px stroke: 21.3 of 96 units.
|
|
assert.ok(Math.abs(two.units - (4 * 96) / 20) < 1e-9);
|
|
assert.ok(
|
|
two.pad >= two.units / 2,
|
|
'padding covers the half-stroke outside the body',
|
|
);
|
|
assert.ok(two.ratio > 1, 'and the billboard grows by the same ratio');
|
|
});
|
|
|
|
test('a haloed glyph draws the dark ring under the shipped white body', () => {
|
|
const plain = decode(transitIcon('bus'));
|
|
const haloed = decode(transitIcon('bus', 48, { haloScreenPx: 2 }));
|
|
assert.notEqual(plain, haloed);
|
|
assert.match(
|
|
haloed,
|
|
/stroke="#05080C" stroke-opacity="1" stroke-width="14\.77"/,
|
|
);
|
|
assert.ok(
|
|
haloed.indexOf('#05080C') < haloed.indexOf('fill="white"'),
|
|
'ring first, body on top',
|
|
);
|
|
assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame');
|
|
assert.match(plain, /stroke-width="9\.60"/, 'normal has a one-pixel halo');
|
|
});
|
|
|
|
test('the raster cache is bounded by kind, size and halo — never by call count', () => {
|
|
const before = transitIconCacheSize();
|
|
for (let i = 0; i < 50; i += 1) {
|
|
transitIcon('tram', 48, { haloScreenPx: 2 });
|
|
transitIcon('tram', 48);
|
|
transitIcon('tram', 128, { haloScreenPx: 1.25 });
|
|
}
|
|
assert.ok(
|
|
transitIconCacheSize() - before <= 3,
|
|
`three variants, not ${transitIconCacheSize() - before}`,
|
|
);
|
|
assert.equal(
|
|
transitIcon('tram', 48, { haloScreenPx: 2 }),
|
|
transitIcon('tram', 48, { haloScreenPx: 2 }),
|
|
);
|
|
for (const kind of TRANSIT_ICON_KINDS)
|
|
transitIcon(kind, 48, { haloScreenPx: 2 });
|
|
assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length);
|
|
});
|
|
|
|
test('all 36 variants retain the specified final halo at both display sizes', async () => {
|
|
const { presetSpriteScale, presetSpriteOutlinePx } =
|
|
await import('./transitPresetStyle.js');
|
|
for (const kind of TRANSIT_ICON_KINDS) {
|
|
for (const selected of [false, true]) {
|
|
for (const style of ['normal', 'thermal', 'retro']) {
|
|
const display =
|
|
(selected ? 30 : 20) * presetSpriteScale(style, selected);
|
|
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
|
|
const units = Number(
|
|
svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1],
|
|
);
|
|
assert.ok(
|
|
Math.abs(
|
|
(units * display) / 192 - presetSpriteOutlinePx(style, selected),
|
|
) < 0.001,
|
|
);
|
|
const frame = haloFrame(
|
|
presetSpriteOutlinePx(style, selected),
|
|
display,
|
|
);
|
|
assert.match(
|
|
svg,
|
|
new RegExp(
|
|
`width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < 100; i++)
|
|
transitIcon('invalid', i, { haloScreenPx: i, screenPx: i });
|
|
assert.equal(transitIconCacheSize(), 36);
|
|
});
|
|
|
|
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
|
|
const sharp = (await import('sharp')).default;
|
|
const { presetSpriteScale, presetSpriteOutlinePx } =
|
|
await import('./transitPresetStyle.js');
|
|
const rasterBody = async (svg) => {
|
|
// Compare fills in the same frame; the separately tested external halo is excluded.
|
|
const d =
|
|
[...svg.matchAll(/<path d="([^"]+)" fill="white"/g)][0]?.[1] ??
|
|
svg.match(/<path d="([^"]+)"\s+fill="white"/)[1];
|
|
const { data, info } = await sharp(
|
|
Buffer.from(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="-48 -48 96 96"><path d="${d}" fill="white"/></svg>`,
|
|
),
|
|
)
|
|
.ensureAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
const mask = [];
|
|
let minX = info.width,
|
|
minY = info.height,
|
|
maxX = 0,
|
|
maxY = 0;
|
|
for (let y = 0; y < info.height; y++)
|
|
for (let x = 0; x < info.width; x++) {
|
|
const alpha = data[(y * info.width + x) * 4 + 3];
|
|
mask.push(alpha);
|
|
if (alpha > 127) {
|
|
minX = Math.min(minX, x);
|
|
maxX = Math.max(maxX, x);
|
|
minY = Math.min(minY, y);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
}
|
|
return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) };
|
|
};
|
|
for (const selected of [false, true])
|
|
for (const kind of TRANSIT_ICON_KINDS) {
|
|
const normal = await rasterBody(
|
|
decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })),
|
|
);
|
|
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
|
|
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
|
|
const mono = await rasterBody(svg);
|
|
assert.deepEqual(
|
|
mono.mask,
|
|
normal.mask,
|
|
`${kind}/${style}: no envelope`,
|
|
);
|
|
assert.ok(
|
|
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
|
|
`${kind}/${style}: aspect`,
|
|
);
|
|
const display =
|
|
(selected ? 30 : 20) * presetSpriteScale(style, selected);
|
|
const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected);
|
|
assert.equal(display, crt);
|
|
const frame = haloFrame(
|
|
presetSpriteOutlinePx(style, selected),
|
|
display,
|
|
);
|
|
const crtFrame = haloFrame(
|
|
presetSpriteOutlinePx('retro', selected),
|
|
crt,
|
|
);
|
|
assert.equal(
|
|
(display * frame.ratio) / frame.ratio,
|
|
(crt * crtFrame.ratio) / crtFrame.ratio,
|
|
'unpadded silhouette matches CRT; only external halo padding grows',
|
|
);
|
|
assert.equal(
|
|
(svg.match(/<path /g) || []).length,
|
|
2,
|
|
'only outer halo and opaque silhouette',
|
|
);
|
|
assert.match(svg, /fill="white" \/>/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('sensor raster halo stays dark across its screen-space band on a white roof', async () => {
|
|
const sharp = (await import('sharp')).default;
|
|
const { presetSpriteScale, presetSpriteOutlinePx } =
|
|
await import('./transitPresetStyle.js');
|
|
const { reduceSensorContrast } =
|
|
await import('../layers/transit/qaMetrics.js');
|
|
const sides = {
|
|
bus: 17,
|
|
tram: 10,
|
|
subway: 11.5,
|
|
rail: 10.5,
|
|
ferry: 12.5,
|
|
unknown: 10,
|
|
};
|
|
for (const selected of [false, true]) {
|
|
const pixels = [];
|
|
for (const kind of TRANSIT_ICON_KINDS) {
|
|
const display =
|
|
(selected ? 30 : 20) * presetSpriteScale('thermal', selected);
|
|
const frame = haloFrame(
|
|
presetSpriteOutlinePx('thermal', selected),
|
|
display,
|
|
);
|
|
const size = Math.round(display * frame.ratio * 8);
|
|
const svg = decode(
|
|
transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }),
|
|
);
|
|
const { data, info } = await sharp(Buffer.from(svg))
|
|
.resize(size, size)
|
|
.flatten({ background: '#ffffff' })
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
const sample = (offsetPx) => {
|
|
const x = Math.floor(
|
|
size / 2 + (offsetPx * size) / (display * frame.ratio),
|
|
);
|
|
const i = (Math.floor(size / 2) * info.width + x) * info.channels;
|
|
return (
|
|
(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255
|
|
);
|
|
};
|
|
// The body stays at CRT size; the ring must cover this fixed external band.
|
|
const ringMin = sample((sides[kind] * display) / 96 + 1.25);
|
|
pixels.push({
|
|
key: kind,
|
|
verified: true,
|
|
centre: sample(0),
|
|
ringMin,
|
|
ringMax: 1 - ringMin,
|
|
background: 1,
|
|
});
|
|
}
|
|
const white = reduceSensorContrast(pixels, 'white', 'thermal');
|
|
assert.ok(white.pass, JSON.stringify({ selected, pixels, white }));
|
|
const black = reduceSensorContrast(
|
|
pixels.map((p) => ({ ...p, centre: 1 - p.centre })),
|
|
'black',
|
|
'thermal',
|
|
);
|
|
assert.ok(black.pass, JSON.stringify({ selected, black }));
|
|
}
|
|
});
|