mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge current main before wind weather prototype
# Conflicts: # CHANGELOG.md # docs/CURRENT-STATE.md # scripts/package-boundaries.json
This commit is contained in:
commit
1a52cea265
71 files changed
+10553
-5631
No files matched your search
@@ -35,9 +35,12 @@ const portableExport = (key) =>
|
||||
/^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test(
|
||||
key,
|
||||
) ||
|
||||
['./voice/action-schemas', './voice/session', './data/lifecycle'].includes(
|
||||
key,
|
||||
);
|
||||
[
|
||||
'./director',
|
||||
'./voice/action-schemas',
|
||||
'./voice/session',
|
||||
'./data/lifecycle',
|
||||
].includes(key);
|
||||
|
||||
/** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */
|
||||
export function checkImportDirections(root) {
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/layerKeyRequirement.test.mjs",
|
||||
"src/ui/layerPanel.test.mjs",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"scripts/qa-layer-panel.mjs",
|
||||
"scripts/qa-directions.mjs",
|
||||
@@ -705,6 +706,7 @@
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/lifecycle.test.mjs",
|
||||
"scripts/qa-transit.mjs",
|
||||
"scripts/qa-transit-heading.mjs",
|
||||
"src/tooling/transitProvider.test.mjs",
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transitIcons.js",
|
||||
@@ -727,6 +729,7 @@
|
||||
"scripts/qa-transit-controls.mjs",
|
||||
"src/tooling/transitQa.test.mjs",
|
||||
"scripts/qa-transit-browser.mjs",
|
||||
"scripts/qa-transit-recovery.mjs",
|
||||
"scripts/format-runtime.json",
|
||||
"src/tooling/transitService.test.mjs",
|
||||
"src/sources/overpassFeatures.test.mjs",
|
||||
|
||||
@@ -511,6 +511,8 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
@@ -583,7 +585,11 @@
|
||||
"src/ui/visualSettings.js",
|
||||
"src/worldFocus.js",
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js"
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/ui/aircraftDisplay.js",
|
||||
"src/ui/layerBindings.js",
|
||||
"src/ui/displayBindings.js",
|
||||
"src/ui/shellFacade.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -615,6 +621,11 @@
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"director": {
|
||||
"exports": ["./director"],
|
||||
"modules": ["src/director/playback.js"],
|
||||
"external": []
|
||||
},
|
||||
"scenes": {
|
||||
"exports": ["./scenes"],
|
||||
"modules": [
|
||||
@@ -624,6 +635,8 @@
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/playbackAdapter.js",
|
||||
"src/director/playback.js",
|
||||
"src/scenes/recipes.js",
|
||||
"src/scenes/scenePolicy.js",
|
||||
"src/bloom.js",
|
||||
@@ -1117,7 +1130,18 @@
|
||||
"src/voice/session.js",
|
||||
"src/voice/sessionCommands.js",
|
||||
"src/voice/realtimeSession.js",
|
||||
"src/voice/realtimeEvents.js"
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/voice/realtimePreferences.js",
|
||||
"src/voice/realtimeProtocol.js",
|
||||
"src/voice/realtimeDiagnostics.js",
|
||||
"src/voice/realtimeViewport.js",
|
||||
"src/voice/realtimeInputPolicy.js",
|
||||
"src/voice/realtimeInput.js",
|
||||
"src/voice/realtimeCost.js",
|
||||
"src/voice/realtimeFacade.js",
|
||||
"src/voice/realtimeRadio.js",
|
||||
"src/voice/realtimeTurns.js",
|
||||
"src/voice/realtimeConnection.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1539,6 +1563,8 @@
|
||||
"src/renderGovernor.js",
|
||||
"src/rightRailPolicy.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/playbackAdapter.js",
|
||||
"src/director/playback.js",
|
||||
"src/scenes/recipes.js",
|
||||
"src/scenes/scenePolicy.js",
|
||||
"src/scopeMask.js",
|
||||
@@ -1568,6 +1594,8 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
@@ -1714,7 +1742,22 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/ui/aircraftDisplay.js",
|
||||
"src/ui/layerBindings.js",
|
||||
"src/ui/displayBindings.js",
|
||||
"src/ui/shellFacade.js",
|
||||
"src/voice/realtimePreferences.js",
|
||||
"src/voice/realtimeProtocol.js",
|
||||
"src/voice/realtimeDiagnostics.js",
|
||||
"src/voice/realtimeViewport.js",
|
||||
"src/voice/realtimeInputPolicy.js",
|
||||
"src/voice/realtimeInput.js",
|
||||
"src/voice/realtimeCost.js",
|
||||
"src/voice/realtimeFacade.js",
|
||||
"src/voice/realtimeRadio.js",
|
||||
"src/voice/realtimeTurns.js",
|
||||
"src/voice/realtimeConnection.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
|
||||
@@ -2171,7 +2171,8 @@ try {
|
||||
);
|
||||
|
||||
const resetResult = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
// Both the UI and compatibility facade delegate to this navigation owner.
|
||||
const manager = window.__godsEyeView.styleManager._locationNavigation;
|
||||
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
|
||||
window.__qaCockpitReset = {
|
||||
calls: 0,
|
||||
@@ -2203,7 +2204,7 @@ try {
|
||||
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
||||
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
|
||||
const height = gev.viewer.camera.positionCartographic?.height;
|
||||
gev.styleManager.resetToGlobeView = qa.original;
|
||||
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
|
||||
delete window.__qaCockpitReset;
|
||||
return {
|
||||
calls: qa.calls,
|
||||
|
||||
@@ -85,6 +85,9 @@ try {
|
||||
);
|
||||
promptValue = '<b>QA shot</b>';
|
||||
await page.click('.scene-shot-label', { count: 2 });
|
||||
await page.waitForSelector('.scene-shot-rename', { visible: true });
|
||||
await page.type('.scene-shot-rename', promptValue);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('.scene-shot-label')?.textContent ===
|
||||
|
||||
+107
-37
@@ -367,7 +367,7 @@ export async function sampleTransitPixels(page, palette) {
|
||||
const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
const readLuma = (cx, cy) => {
|
||||
const px = Math.floor(cx * scale);
|
||||
const py = Math.floor(canvas.height - 1 - cy * scale);
|
||||
const py = canvas.height - 1 - Math.floor(cy * scale);
|
||||
const buf = new Uint8Array(4);
|
||||
gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
return luma(buf[0], buf[1], buf[2]);
|
||||
@@ -399,46 +399,117 @@ export async function sampleTransitPixels(page, palette) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Pick nine points from a solid patch of the source silhouette,
|
||||
// before examining framebuffer brightness. Avoid panels/window gaps.
|
||||
let corePoints = null,
|
||||
widestBand = -1;
|
||||
const cosCore = Math.cos(s.rotation),
|
||||
sinCore = Math.sin(s.rotation);
|
||||
// A tram's parallel sides have equally wide bands. Prefer its upper
|
||||
// body band over the geometric centre when those widths tie.
|
||||
const offsets =
|
||||
s.mode === 'tram'
|
||||
? [-0.2, 0.2, -0.1, 0.1, 0]
|
||||
: [0, -0.1, 0.1, -0.2, 0.2];
|
||||
for (const offset of offsets) {
|
||||
const candidates = [];
|
||||
let solid = true,
|
||||
bandWidth = Infinity;
|
||||
// Rank source geometry, never the observed framebuffer brightness.
|
||||
// A thin tram needs the widest opaque white band, with room for
|
||||
// the pixel filter on each side of the sampled patch.
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const ry = Math.floor(
|
||||
((dy + offset * s.rect.height) / s.rect.height + 0.5) *
|
||||
s.raster.height,
|
||||
);
|
||||
const middle = Math.floor(s.raster.width / 2);
|
||||
const white = (x) =>
|
||||
x >= 0 &&
|
||||
x < s.raster.width &&
|
||||
[0, 1, 2, 3].every(
|
||||
(c) =>
|
||||
s.raster.data[(ry * s.raster.width + x) * 4 + c] >= 240,
|
||||
);
|
||||
let left = middle,
|
||||
right = middle;
|
||||
while (white(left - 1)) left--;
|
||||
while (white(right + 1)) right++;
|
||||
bandWidth = Math.min(
|
||||
bandWidth,
|
||||
white(middle) ? right - left + 1 : 0,
|
||||
);
|
||||
}
|
||||
for (let dx = -1; dx <= 1; dx++)
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const localY = dy + offset * s.rect.height;
|
||||
const rx = Math.floor(
|
||||
(dx / s.rect.width + 0.5) * s.raster.width,
|
||||
);
|
||||
const ry = Math.floor(
|
||||
(localY / s.rect.height + 0.5) * s.raster.height,
|
||||
);
|
||||
const i = (ry * s.raster.width + rx) * 4;
|
||||
if ([0, 1, 2, 3].some((c) => s.raster.data[i + c] < 240))
|
||||
solid = false;
|
||||
candidates.push(
|
||||
screenPoint(
|
||||
s.screenX + dx * cosCore + localY * sinCore,
|
||||
s.screenY - dx * sinCore + localY * cosCore,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (solid && bandWidth > widestBand) {
|
||||
corePoints = candidates;
|
||||
widestBand = bandWidth;
|
||||
}
|
||||
}
|
||||
if (!corePoints) {
|
||||
skipped.push({
|
||||
key: s.key,
|
||||
reason: 'no solid silhouette interior for nine samples',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Picking is independent of rendered brightness: an overlapping
|
||||
// label or sprite must not masquerade as a dim sensor signature.
|
||||
// Check the whole sampled core, not just its centre pick.
|
||||
let coreOwned = true;
|
||||
if (scene.pick) {
|
||||
const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
|
||||
for (let dx = -1; dx <= 1; dx++)
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
let u = (s.x + dx) / width,
|
||||
v = 1 - (s.y + dy) / height;
|
||||
if (style === 'surveillance') {
|
||||
const x = u * 2 - 1,
|
||||
y = v * 2 - 1,
|
||||
r2 = x * x + y * y;
|
||||
const d =
|
||||
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
u = (x * d + 1) / 2;
|
||||
v = (y * d + 1) / 2;
|
||||
}
|
||||
if (sensor) {
|
||||
const grid = 1 + (pixelation - 1) * intensity;
|
||||
u +=
|
||||
((Math.floor((u * canvas.width) / grid) * grid) /
|
||||
canvas.width -
|
||||
u) *
|
||||
intensity;
|
||||
v +=
|
||||
((Math.floor((v * canvas.height) / grid) * grid) /
|
||||
canvas.height -
|
||||
v) *
|
||||
intensity;
|
||||
}
|
||||
const picked = scene.pick(
|
||||
{ x: u * width, y: (1 - v) * height },
|
||||
1,
|
||||
1,
|
||||
);
|
||||
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
|
||||
coreOwned = false;
|
||||
for (const core of corePoints) {
|
||||
let u = core.x / width,
|
||||
v = 1 - core.y / height;
|
||||
if (style === 'surveillance') {
|
||||
const x = u * 2 - 1,
|
||||
y = v * 2 - 1,
|
||||
r2 = x * x + y * y;
|
||||
const d =
|
||||
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
u = (x * d + 1) / 2;
|
||||
v = (y * d + 1) / 2;
|
||||
}
|
||||
if (sensor) {
|
||||
const grid = 1 + (pixelation - 1) * intensity;
|
||||
u +=
|
||||
((Math.floor((u * canvas.width) / grid) * grid) /
|
||||
canvas.width -
|
||||
u) *
|
||||
intensity;
|
||||
v +=
|
||||
((Math.floor((v * canvas.height) / grid) * grid) /
|
||||
canvas.height -
|
||||
v) *
|
||||
intensity;
|
||||
}
|
||||
const picked = scene.pick(
|
||||
{ x: u * width, y: (1 - v) * height },
|
||||
1,
|
||||
1,
|
||||
);
|
||||
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
|
||||
coreOwned = false;
|
||||
}
|
||||
if (gl.bindFramebuffer)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||
}
|
||||
@@ -451,9 +522,8 @@ export async function sampleTransitPixels(page, palette) {
|
||||
continue;
|
||||
}
|
||||
let centre = 0;
|
||||
for (let dx = -1; dx <= 1; dx += 1)
|
||||
for (let dy = -1; dy <= 1; dy += 1)
|
||||
centre += readLuma(s.x + dx, s.y + dy) / 9;
|
||||
for (const core of corePoints)
|
||||
centre += readLuma(core.x, core.y) / corePoints.length;
|
||||
let ringMin = 1;
|
||||
let ringMax = 0;
|
||||
// Sample the actual rotated raster halo, not a fixed-radius circle
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
/** Prove parked and moving headings in the real scene, without live feeds. */
|
||||
export async function runTransitHeadingRegression(base, check) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: ['--no-sandbox', '--disable-background-timer-throttling'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(
|
||||
`${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.dataManager?.layers?.has('transit'),
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
layer._loadTransitFleetForTest(4, { lat: 42.3601, lon: -71.0589 }, 100);
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const { destroyTrack } = await import('/src/data/contactPlayback.js');
|
||||
const {
|
||||
initializePlayback,
|
||||
recordFix,
|
||||
updatePlayback,
|
||||
attachFloorToPlace,
|
||||
} = await import('/src/layers/transit/movement.js');
|
||||
// The first three are parked: held east, reported east only, unknown.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const entry = state._vehicles.get(`mbta:qa-${i}`);
|
||||
state._moving.delete(entry);
|
||||
destroyTrack(entry.track);
|
||||
initializePlayback(entry, state._historyBudget);
|
||||
recordFix(entry, {
|
||||
t: Date.now() - 30000,
|
||||
lat: entry.record.lat,
|
||||
lon: entry.record.lon,
|
||||
});
|
||||
attachFloorToPlace(entry, entry.record, 20);
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
entry.courseDeg = i === 0 ? 90 : null;
|
||||
entry.record.bearing = i === 1 ? 90 : null;
|
||||
entry.displayPaths.clear();
|
||||
parts.rendering.placeSample(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
}
|
||||
parts.rendering.syncRenderHold();
|
||||
window.__headingErrors = [];
|
||||
app.viewer.scene.renderError.addEventListener((scene, error) =>
|
||||
window.__headingErrors.push(error.message),
|
||||
);
|
||||
});
|
||||
const pose = async (heading) => {
|
||||
await page.evaluate((heading) => {
|
||||
const camera = window.__godsEyeView.viewer.camera;
|
||||
const C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)),
|
||||
orientation: { heading, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, heading);
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
};
|
||||
const sample = () =>
|
||||
page.evaluate(async () => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const state = app.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest();
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(r) =>
|
||||
m[r] * v[0] +
|
||||
m[r + 4] * v[1] +
|
||||
m[r + 8] * v[2] +
|
||||
m[r + 12] * v[3],
|
||||
);
|
||||
const project = (p) => {
|
||||
const clip = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2,
|
||||
y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2,
|
||||
};
|
||||
};
|
||||
return [0, 1, 2, 3].map((i) => {
|
||||
const entry = state._vehicles.get(`mbta:qa-${i}`);
|
||||
const p = project(entry.marker.position);
|
||||
return {
|
||||
rotation: entry.marker.rotation,
|
||||
course: entry.courseDeg,
|
||||
moving: state._moving.has(entry),
|
||||
visible: entry.marker.show,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
};
|
||||
});
|
||||
});
|
||||
await pose(0);
|
||||
const before = await sample();
|
||||
await pose(Math.PI / 2);
|
||||
const after = await sample();
|
||||
const deltaDeg = (a, b) =>
|
||||
(Math.atan2(Math.sin(b - a), Math.cos(b - a)) * 180) / Math.PI;
|
||||
for (const [i, label] of ['held course', 'reported bearing'].entries()) {
|
||||
const delta = deltaDeg(before[i].rotation, after[i].rotation);
|
||||
check(
|
||||
`parked ${label} reprojects through a 90 degree camera orbit`,
|
||||
before[i].visible &&
|
||||
after[i].visible &&
|
||||
!before[i].moving &&
|
||||
!after[i].moving &&
|
||||
Math.abs(delta - 90) <= 5,
|
||||
JSON.stringify({ before: before[i], after: after[i], delta }),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'unknown parked course remains screen-up',
|
||||
before[2].rotation === 0 && after[2].rotation === 0,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
const moved = (await sample())[3];
|
||||
const dx = moved.x - after[3].x,
|
||||
dy = moved.y - after[3].y;
|
||||
const travelRotation = Math.atan2(-dx, -dy);
|
||||
const error = Math.abs(deltaDeg(travelRotation, moved.rotation));
|
||||
check(
|
||||
'moving vehicle still points along its rendered travel after orbit',
|
||||
moved.visible && moved.moving && Math.hypot(dx, dy) > 0.5 && error <= 5,
|
||||
JSON.stringify({
|
||||
pixels: Math.hypot(dx, dy),
|
||||
error,
|
||||
after: after[3],
|
||||
moved,
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'heading regression has no scene render errors',
|
||||
(await page.evaluate(() => window.__headingErrors)).length === 0,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/** Camera-jump regression against an existing server; never starts a server. */
|
||||
import puppeteer from 'puppeteer';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
const base = process.env.QA_BASE_URL || 'http://localhost:4305';
|
||||
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
|
||||
await mkdir(shots, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(
|
||||
`${base}/?welcome=0#lat=30.271&lon=-97.7431&alt=600&heading=0&pitch=-60`,
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
window.__godsEyeView?.dataManager?.layers.has('transit'),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
return scene.globe.tilesLoaded && scene.globe.show;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const cover = document.getElementById('loading-screen');
|
||||
return !cover || cover.classList.contains('hidden');
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const app = window.__godsEyeView,
|
||||
camera = app.viewer.camera;
|
||||
const C = camera.positionCartographic.constructor,
|
||||
lat = 30.267 + 90 / 111320;
|
||||
const floor =
|
||||
app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
lat - 600 / Math.tan(Math.PI / 3) / 111320,
|
||||
floor + 600,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene,
|
||||
camera = app.viewer.camera;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const { getRegisteredTransitFeed } =
|
||||
await import('/src/data/transitFeeds.js');
|
||||
const { setRate } = await import('/src/data/contactPlayback.js');
|
||||
const C = camera.positionCartographic.constructor;
|
||||
const feed = {
|
||||
...getRegisteredTransitFeed('capmetro-austin'),
|
||||
id: 'qa-camera-recovery',
|
||||
};
|
||||
const aim = (lat, height = 600) =>
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
lat - 600 / Math.tan(Math.PI / 3) / 111320,
|
||||
height,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
||||
});
|
||||
parts.selection.clearSelection();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
for (const key of state._vehicles.keys())
|
||||
parts.ingestion.removeVehicle(key);
|
||||
// Model an outstanding floor lookup while reports arrive off screen.
|
||||
// Only acquisition is controlled; selection, visibility, paths and rendering
|
||||
// use their real owners. Release to the loaded terrain at the camera jump.
|
||||
const requestHeight = parts.height.requestHeight;
|
||||
parts.height.requestHeight = (lat, lon) => ({
|
||||
cell: `${lat},${lon}`,
|
||||
height: null,
|
||||
prior: null,
|
||||
});
|
||||
aim(30.28);
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const timestamp = now - 135000 + i * 15000;
|
||||
parts.ingestion.applySnapshot(
|
||||
feed,
|
||||
{
|
||||
fetchedAt: timestamp,
|
||||
vehicles: [
|
||||
{
|
||||
id: 'moving-bus',
|
||||
routeId: '1',
|
||||
tripId: 'qa',
|
||||
lat: 30.267 + (i * 90) / 111320,
|
||||
lon: -97.7431,
|
||||
timestamp: timestamp / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ stale: false },
|
||||
);
|
||||
}
|
||||
const entry = state._vehicles.get(`${feed.id}:moving-bus`);
|
||||
entry.qaFixture = true; // Protect this disclosed fixture from live feed eviction.
|
||||
parts.rendering.sampleIdle(entry);
|
||||
const before = {
|
||||
shown: entry.marker.show,
|
||||
heightPending: entry.heightPending,
|
||||
phase: entry.sample.phase,
|
||||
fixes: entry.track.count,
|
||||
};
|
||||
clearTimeout(state._floorTimer);
|
||||
state._floorTimer = null;
|
||||
const started = performance.now();
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
const preparedAtSelection = !!parts.trails.diagnostics()?.body;
|
||||
parts.height.requestHeight = requestHeight;
|
||||
const floor =
|
||||
scene.globe.getHeight(
|
||||
C.fromDegrees(entry.sample.lon, entry.sample.lat),
|
||||
) || 0;
|
||||
aim(entry.sample.lat, floor + 600);
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let trail;
|
||||
while (performance.now() - started < 1400) {
|
||||
trail = parts.trails.diagnostics();
|
||||
if (entry.marker.show && trail?.body?.show && trail.body.ready) break;
|
||||
scene.requestRender();
|
||||
await wait(25);
|
||||
}
|
||||
const status = {
|
||||
before,
|
||||
preparedAtSelection,
|
||||
markerShow: entry.marker.show,
|
||||
body: !!trail?.body,
|
||||
bodyShow: trail?.body?.show === true,
|
||||
visibility: layer
|
||||
._transitVisibilityForTest()
|
||||
.find((e) => e.key === entry.key),
|
||||
recoveryMs: performance.now() - started,
|
||||
};
|
||||
if (!entry.marker.show || !trail?.body?.show || !trail.body.ready)
|
||||
return {
|
||||
...status,
|
||||
reason: 'marker or body did not recover before pixel deadline',
|
||||
};
|
||||
setRate(entry.track, 0, {
|
||||
wallNowMs: Date.now(),
|
||||
monoNowMs: performance.now(),
|
||||
});
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
const V = entry.marker.position.constructor;
|
||||
const points = entry.trailSegments
|
||||
.filter((s) => s.toT <= entry.sample.displayT)
|
||||
.flatMap((s) => s.positions);
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++)
|
||||
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
||||
const canvas = scene.canvas,
|
||||
gl = scene.context._gl,
|
||||
scale = canvas.width / canvas.clientWidth;
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(i) =>
|
||||
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
|
||||
);
|
||||
const project = (p) => {
|
||||
const q = mul(
|
||||
camera.frustum.projectionMatrix,
|
||||
mul(camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
|
||||
inFront: q[3] > 0,
|
||||
};
|
||||
};
|
||||
const samples = Array.from({ length: 8 }, (_, i) => {
|
||||
const d = (lengths.at(-1) * (i + 1)) / 9;
|
||||
let j = 1;
|
||||
while (j < lengths.length - 1 && lengths[j] < d) j++;
|
||||
const p = V.lerp(
|
||||
points[j - 1],
|
||||
points[j],
|
||||
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
|
||||
new V(),
|
||||
);
|
||||
const cart = C.fromCartesian(p);
|
||||
cart.height = scene.globe.getHeight(cart) || 0;
|
||||
return {
|
||||
...project(C.toCartesian(cart)),
|
||||
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
|
||||
};
|
||||
});
|
||||
const sprite = {
|
||||
...project(entry.marker.position),
|
||||
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
|
||||
};
|
||||
const read = () =>
|
||||
samples.map(({ x, y }) => {
|
||||
const r = Math.ceil(3 * scale),
|
||||
px = Math.round(x * scale),
|
||||
py = Math.round(canvas.height - 1 - y * scale);
|
||||
if (
|
||||
px < r ||
|
||||
py < r ||
|
||||
px + r >= canvas.width ||
|
||||
py + r >= canvas.height
|
||||
)
|
||||
return null;
|
||||
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
|
||||
gl.readPixels(
|
||||
px - r,
|
||||
py - r,
|
||||
2 * r + 1,
|
||||
2 * r + 1,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
return Array.from(data);
|
||||
});
|
||||
const frame = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('No recovery frame'));
|
||||
}, 500);
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
remove();
|
||||
clearTimeout(timer);
|
||||
resolve(read());
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
let visible = true;
|
||||
const remove = scene.preRender.addEventListener(() => {
|
||||
trail.body.show = visible;
|
||||
if (trail.headPrimitive)
|
||||
trail.headPrimitive.show = visible && trail.head.show;
|
||||
});
|
||||
const captures = [];
|
||||
try {
|
||||
for (const show of [false, true, false, true]) {
|
||||
visible = show;
|
||||
await frame();
|
||||
captures.push(await frame());
|
||||
}
|
||||
} finally {
|
||||
remove();
|
||||
parts.trails.update();
|
||||
}
|
||||
const [off, on, offAgain, onAgain] = captures;
|
||||
return {
|
||||
...status,
|
||||
samples,
|
||||
sprite,
|
||||
off,
|
||||
on,
|
||||
offAgain,
|
||||
onAgain,
|
||||
tilesReady:
|
||||
scene.globe.tilesLoaded &&
|
||||
parts.trails.diagnostics().body === trail.body,
|
||||
elapsedMs: performance.now() - started,
|
||||
attributes: Array.from({ length: 4 }, (_, i) => {
|
||||
const a = trail.body.getGeometryInstanceAttributes(`${i}:1`);
|
||||
return { show: Array.from(a.show), color: Array.from(a.color) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
const pixels = reduceTrailPixels(result.on, result.off, result);
|
||||
result.present = pixels.present;
|
||||
result.pixelPass = pixels.pass;
|
||||
result.pixelDetails = pixels;
|
||||
result.errors = errors;
|
||||
await page.screenshot({ path: `${shots}/recovery.png` });
|
||||
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||
const pass =
|
||||
result.before?.shown === false &&
|
||||
result.before?.phase === 'playing' &&
|
||||
result.preparedAtSelection &&
|
||||
result.markerShow &&
|
||||
result.body &&
|
||||
result.bodyShow &&
|
||||
result.elapsedMs <= 2000 &&
|
||||
pixels.pass &&
|
||||
!errors.length;
|
||||
console.log(
|
||||
`${pass ? 'PASS' : 'FAIL'} select moving bus, setView, marker + body + >=6/8 trail pixels within 2 s`,
|
||||
);
|
||||
console.log(JSON.stringify(result));
|
||||
process.exitCode = pass ? 0 : 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
||||
|
||||
// Not registered or active: camera discovery and live polls never own this feed.
|
||||
/** CapMetro metadata under a private QA identity for selected-trail pixels. */
|
||||
export const TRAIL_VISIBILITY_FEED = Object.freeze({
|
||||
...getRegisteredTransitFeed('capmetro-austin'),
|
||||
id: 'qa-trail-capmetro',
|
||||
});
|
||||
|
||||
import { reportTransitVisibility } from './qa-transit-browser.mjs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
@@ -5,6 +14,7 @@ import {
|
||||
reduceMotion,
|
||||
reduceBackground,
|
||||
reduceSensorContrast,
|
||||
reduceTrailPixels,
|
||||
} from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
/** CDP includes objects collected during the sample, not just surviving objects. */
|
||||
@@ -581,3 +591,459 @@ export async function runBostonLive({
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
/** Google 3D proof: paired framebuffer reads reject coincidental road colours. */
|
||||
export async function runTrailVisibility({
|
||||
page,
|
||||
check,
|
||||
wait,
|
||||
shots,
|
||||
tag,
|
||||
selectStyle,
|
||||
altitude = 600,
|
||||
pitch = 45,
|
||||
}) {
|
||||
const name = 'Google 3D selected trail visible at 6 of 8 projected samples';
|
||||
await selectStyle(page, 'normal');
|
||||
await page.evaluate(
|
||||
async ({ altitude, pitch }) => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
if (app.mapStackController.isStackAvailable('photoreal'))
|
||||
await app.mapStackController.setStack('photoreal');
|
||||
const camera = app.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
30.26725 - altitude / Math.tan((pitch * Math.PI) / 180) / 111320,
|
||||
altitude,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
},
|
||||
{ altitude, pitch },
|
||||
);
|
||||
await wait(8000);
|
||||
const setup = await page.evaluate(
|
||||
async ({ altitude, pitch, feed }) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const google =
|
||||
!!app.tileset &&
|
||||
app.tileset.show !== false &&
|
||||
app.mapStackController.getActiveId() === 'photoreal';
|
||||
if (!google)
|
||||
return { google: false, reason: 'visible Google 3D tileset required' };
|
||||
const C = scene.camera.positionCartographic.constructor;
|
||||
const centreLat = 30.267 + 90 / 111320;
|
||||
const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat));
|
||||
if (Number.isFinite(floor))
|
||||
scene.camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
centreLat - altitude / Math.tan((pitch * Math.PI) / 180) / 111320,
|
||||
floor + altitude,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
parts.selection.clearSelection();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
for (const key of state._vehicles.keys())
|
||||
parts.ingestion.removeVehicle(key);
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
const { seek, setRate } = await import('/src/data/contactPlayback.js');
|
||||
const now = Date.now();
|
||||
// Nine straight street fixes, 30 m apart. Use the production surface path.
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const timestamp = (now - 120000 + i * 15000) / 1000;
|
||||
parts.ingestion.applySnapshot(
|
||||
feed,
|
||||
{
|
||||
fetchedAt: timestamp * 1000,
|
||||
vehicles: [
|
||||
{
|
||||
id: 'trail-visible',
|
||||
routeId: '1',
|
||||
tripId: 'trail-visible',
|
||||
lat: 30.267 + (i * 30) / 111320,
|
||||
lon: -97.7431,
|
||||
timestamp,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ stale: false },
|
||||
);
|
||||
}
|
||||
const entry = state._vehicles.get(`${feed.id}:trail-visible`);
|
||||
entry.qaFixture = true;
|
||||
const clocks = { wallNowMs: now, monoNowMs: performance.now() };
|
||||
seek(entry.track, now - 45000, clocks);
|
||||
setRate(entry.track, 1, clocks);
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.height.anchorFloors();
|
||||
parts.rendering.refreshVisibility();
|
||||
parts.rendering.maintainPresentation();
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
return {
|
||||
google,
|
||||
key: entry.key,
|
||||
fixes: entry.track.count,
|
||||
startDisplayT: now - 45000,
|
||||
startLat: entry.sample.lat,
|
||||
};
|
||||
},
|
||||
{ altitude, pitch, feed: TRAIL_VISIBILITY_FEED },
|
||||
);
|
||||
if (!setup.google) {
|
||||
check(name, false, JSON.stringify(setup), { unexercised: true });
|
||||
return;
|
||||
}
|
||||
const cost = await page.evaluate(async (key) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const parts = layer._transitPartsForTest(),
|
||||
entry = layer._transitStateForTest()._vehicles.get(key);
|
||||
const gl = scene.context._gl,
|
||||
originals = new Map(),
|
||||
wrapped = new Map();
|
||||
let owner = null,
|
||||
headBytes = 0,
|
||||
bodyBytes = 0,
|
||||
headUploadMax = 0,
|
||||
bodyUploadMax = 0,
|
||||
frames = 0;
|
||||
const headCpu = [];
|
||||
const wrap = (primitive, kind) => {
|
||||
if (!primitive || wrapped.has(primitive)) return;
|
||||
const update = primitive.update;
|
||||
wrapped.set(primitive, update);
|
||||
primitive.update = function (frameState) {
|
||||
const before = performance.now();
|
||||
owner = kind;
|
||||
try {
|
||||
return update.call(this, frameState);
|
||||
} finally {
|
||||
owner = null;
|
||||
if (kind === 'head') headCpu.push(performance.now() - before);
|
||||
}
|
||||
};
|
||||
};
|
||||
for (const name of ['bufferData', 'bufferSubData']) {
|
||||
const original = gl[name];
|
||||
originals.set(name, original);
|
||||
gl[name] = function (...args) {
|
||||
if (owner) {
|
||||
const value = args[name === 'bufferData' ? 1 : 2],
|
||||
offset = args[3] || 0,
|
||||
length = args[4];
|
||||
const bytes =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: value
|
||||
? length !== undefined
|
||||
? length * (value.BYTES_PER_ELEMENT || 1)
|
||||
: value.byteLength - offset * (value.BYTES_PER_ELEMENT || 1)
|
||||
: 0;
|
||||
if (owner === 'head') headBytes += bytes;
|
||||
else bodyBytes += bytes;
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
const pre = scene.preRender.addEventListener(() => {
|
||||
headBytes = bodyBytes = 0;
|
||||
const trail = parts.trails.diagnostics();
|
||||
wrap(trail?.body, 'body');
|
||||
wrap(trail?.headPrimitive || trail?.head?._polylineCollection, 'head');
|
||||
});
|
||||
const post = scene.postRender.addEventListener(() => {
|
||||
frames++;
|
||||
headUploadMax = Math.max(headUploadMax, headBytes);
|
||||
bodyUploadMax = Math.max(bodyUploadMax, bodyBytes);
|
||||
});
|
||||
try {
|
||||
// Include a full history upload, even if the first selection already rendered.
|
||||
entry.pathRevision = -1;
|
||||
parts.trails.prepareEntry(entry);
|
||||
scene.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20000));
|
||||
headCpu.sort((a, b) => a - b);
|
||||
return {
|
||||
frames,
|
||||
headUploadMax,
|
||||
bodyUploadMax,
|
||||
headCpuP95Ms: headCpu[Math.floor(headCpu.length * 0.95)] ?? null,
|
||||
headCpuMaxMs: headCpu.at(-1) ?? null,
|
||||
};
|
||||
} finally {
|
||||
pre();
|
||||
post();
|
||||
for (const [name, original] of originals) gl[name] = original;
|
||||
for (const [primitive, update] of wrapped)
|
||||
if (!primitive.isDestroyed()) primitive.update = update;
|
||||
}
|
||||
}, setup.key);
|
||||
const result = await page.evaluate(async (setup) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const entry = state._vehicles.get(setup.key);
|
||||
const trail = parts.trails.diagnostics();
|
||||
if (!entry || !trail)
|
||||
return { ...setup, present: 0, reason: 'selection disappeared' };
|
||||
const { setRate } = await import('/src/data/contactPlayback.js');
|
||||
setRate(entry.track, 0, {
|
||||
wallNowMs: Date.now(),
|
||||
monoNowMs: performance.now(),
|
||||
});
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
parts.rendering.syncRenderHold();
|
||||
const ready = () =>
|
||||
app.tileset?.show !== false &&
|
||||
app.tileset?.tilesLoaded === true &&
|
||||
(scene.globe?.show === false || scene.globe?.tilesLoaded === true) &&
|
||||
trail.body?.ready === true &&
|
||||
(!trail.headPrimitive || trail.headPrimitive.ready === true);
|
||||
// Require sustained readiness after freezing playback, before projecting
|
||||
// onto the refined surface. Repeated captures still check pixel stability.
|
||||
const tilesReady = await new Promise((resolve) => {
|
||||
const started = performance.now();
|
||||
let readySince = null;
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
readySince = ready() ? (readySince ?? now) : null;
|
||||
scene.requestRender();
|
||||
if (
|
||||
(readySince !== null && now - readySince >= 500) ||
|
||||
now - started >= 30000
|
||||
) {
|
||||
clearInterval(timer);
|
||||
resolve(readySince !== null && now - readySince >= 500);
|
||||
}
|
||||
}, 80);
|
||||
});
|
||||
if (!tilesReady)
|
||||
return {
|
||||
...setup,
|
||||
present: 0,
|
||||
reason: 'tiles or trail geometry did not become ready',
|
||||
};
|
||||
const canvas = scene.canvas,
|
||||
gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
const points = entry.trailSegments
|
||||
.filter((s) => s.toT <= entry.sample.displayT)
|
||||
.flatMap((s) => s.positions);
|
||||
if (trail.head?.show) points.push(...trail.head.positions);
|
||||
if (points.length < 2)
|
||||
return { ...setup, present: 0, reason: 'no completed path' };
|
||||
const C = scene.camera.positionCartographic.constructor;
|
||||
const V = entry.marker.position.constructor;
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++)
|
||||
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(i) =>
|
||||
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
|
||||
);
|
||||
const samples = Array.from({ length: 8 }, (_, i) => {
|
||||
const d = (lengths.at(-1) * (i + 1)) / 9;
|
||||
let j = 1;
|
||||
while (j < lengths.length - 1 && lengths[j] < d) j++;
|
||||
const p = V.lerp(
|
||||
points[j - 1],
|
||||
points[j],
|
||||
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
|
||||
new V(),
|
||||
);
|
||||
const cart = C.fromCartesian(p);
|
||||
const height = scene.sampleHeight(
|
||||
cart,
|
||||
[entry.marker, trail.body, trail.headPrimitive].filter(Boolean),
|
||||
);
|
||||
if (Number.isFinite(height)) cart.height = height;
|
||||
const surface = C.toCartesian(cart);
|
||||
const q = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
|
||||
inFront: q[3] > 0,
|
||||
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
|
||||
};
|
||||
});
|
||||
const marker = entry.marker.position;
|
||||
const markerClip = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]),
|
||||
);
|
||||
const sprite = {
|
||||
x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight,
|
||||
// Include the padded, rotated sprite and its selection bracket.
|
||||
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
|
||||
};
|
||||
const read = () =>
|
||||
samples.map(({ x, y, inFront }) => {
|
||||
const r = Math.ceil(3 * scale),
|
||||
px = Math.round(x * scale),
|
||||
py = Math.round(canvas.height - 1 - y * scale);
|
||||
if (
|
||||
!inFront ||
|
||||
px < r ||
|
||||
py < r ||
|
||||
px + r >= canvas.width ||
|
||||
py + r >= canvas.height
|
||||
)
|
||||
return null;
|
||||
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
|
||||
gl.readPixels(
|
||||
px - r,
|
||||
py - r,
|
||||
2 * r + 1,
|
||||
2 * r + 1,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
return data;
|
||||
});
|
||||
const frame = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('Trail framebuffer did not render within 5 s'));
|
||||
}, 5000);
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
remove();
|
||||
clearTimeout(timeout);
|
||||
resolve(read());
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
const bodyShow = trail.body?.show,
|
||||
headShow = trail.head?.show,
|
||||
headPrimitiveShow = trail.headPrimitive?.show,
|
||||
backingShow = trail.backing?.show;
|
||||
let showTrail = false;
|
||||
const applyVisibility = () => {
|
||||
if (trail.body) trail.body.show = showTrail && bodyShow;
|
||||
if (trail.headPrimitive)
|
||||
trail.headPrimitive.show = showTrail && headPrimitiveShow;
|
||||
if (trail.head) trail.head.show = showTrail && headShow;
|
||||
if (trail.backing) trail.backing.show = showTrail && backingShow;
|
||||
};
|
||||
// Override the layer's frame update in both states; selection stays fixed.
|
||||
const removeVisibility = scene.preRender.addEventListener(applyVisibility);
|
||||
const captures = [];
|
||||
let captureReady = true;
|
||||
try {
|
||||
for (const visible of [false, true, false, true]) {
|
||||
showTrail = visible;
|
||||
// Let post-processing settle before each measured framebuffer.
|
||||
for (let i = 0; i < 3; i++) await frame();
|
||||
captures.push(await frame());
|
||||
const current = parts.trails.diagnostics();
|
||||
captureReady &&=
|
||||
ready() &&
|
||||
current?.body === trail.body &&
|
||||
current?.headPrimitive === trail.headPrimitive;
|
||||
}
|
||||
} finally {
|
||||
removeVisibility();
|
||||
if (trail.body) trail.body.show = bodyShow;
|
||||
if (trail.headPrimitive) trail.headPrimitive.show = headPrimitiveShow;
|
||||
if (trail.head) trail.head.show = headShow;
|
||||
if (trail.backing) trail.backing.show = backingShow;
|
||||
await frame();
|
||||
}
|
||||
const [off, on, offAgain, onAgain] = captures.map((patches) =>
|
||||
patches.map((p) => (p ? Array.from(p) : null)),
|
||||
);
|
||||
return {
|
||||
...setup,
|
||||
fixes: entry.track.count,
|
||||
selected: state._selectedKey === setup.key,
|
||||
pathMetres: lengths.at(-1),
|
||||
samples,
|
||||
on,
|
||||
off,
|
||||
onAgain,
|
||||
offAgain,
|
||||
sprite,
|
||||
tilesReady: captureReady,
|
||||
elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT,
|
||||
movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320,
|
||||
body: trail.body?.constructor.name,
|
||||
headStrategy: trail.headStrategy || 'PolylineCollection',
|
||||
headRebuilds: trail.headRebuilds ?? null,
|
||||
};
|
||||
}, setup);
|
||||
const pixels = reduceTrailPixels(result.on, result.off, result);
|
||||
result.cost = cost;
|
||||
result.altitude = altitude;
|
||||
result.pitch = pitch;
|
||||
result.present = pixels.present;
|
||||
result.total = pixels.total;
|
||||
result.samples = result.samples?.map((s, i) => ({
|
||||
...s,
|
||||
hit: pixels.hits[i],
|
||||
stable: pixels.stable[i],
|
||||
eligible: pixels.eligible[i],
|
||||
}));
|
||||
await page.screenshot({
|
||||
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 95,
|
||||
});
|
||||
await writeFile(
|
||||
`${shots}/${tag}-${altitude}m-trail-visible.json`,
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||
check(
|
||||
name,
|
||||
result.google &&
|
||||
result.selected &&
|
||||
result.fixes >= 5 &&
|
||||
result.pathMetres > 15 &&
|
||||
result.elapsedDisplayMs >= 19000 &&
|
||||
result.movedMetres >= 5 &&
|
||||
pixels.pass,
|
||||
JSON.stringify(result),
|
||||
);
|
||||
check(
|
||||
'selected trail stays within 2 MiB rebuild and 2 KiB/frame head uploads',
|
||||
cost.frames > 30 &&
|
||||
cost.bodyUploadMax > 0 &&
|
||||
cost.bodyUploadMax <= 2 * 1024 * 1024 &&
|
||||
cost.headUploadMax > 0 &&
|
||||
cost.headUploadMax <= 2048,
|
||||
JSON.stringify({ altitude, ...cost }),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitPartsForTest()
|
||||
.selection.clearSelection(),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { runTransitHeadingRegression } from './qa-transit-heading.mjs';
|
||||
import {
|
||||
boundPageEvaluations,
|
||||
reportTransitVisibility,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
runFleetBudgets,
|
||||
runBostonMatrix,
|
||||
runBostonLive,
|
||||
runTrailVisibility,
|
||||
} from './qa-transit-scenes.mjs';
|
||||
/**
|
||||
* Browser proof for the Transit layer against a running app.
|
||||
@@ -78,6 +80,18 @@ const check = (
|
||||
failures += 1;
|
||||
} else if (!passed) failures += 1;
|
||||
};
|
||||
// A focused deterministic regression can run independently of live-feed QA.
|
||||
if (process.argv.includes('--heading-only')) {
|
||||
try {
|
||||
await runTransitHeadingRegression(BASE, check);
|
||||
} catch (error) {
|
||||
check('heading regression completed', false, error.stack || error.message);
|
||||
}
|
||||
console.log(`HEADING: ${failures ? 'FAIL' : 'PASS'} (${failures} failed)`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
if (runs('heading')) await runTransitHeadingRegression(BASE, check);
|
||||
|
||||
const renderingSource = await readFile(
|
||||
new URL('../src/layers/transit/rendering.js', import.meta.url),
|
||||
'utf8',
|
||||
@@ -1774,6 +1788,13 @@ try {
|
||||
selectStyle,
|
||||
sampleRendered,
|
||||
};
|
||||
if (runs('trail-visible'))
|
||||
for (const [altitude, pitch] of [
|
||||
[300, 55],
|
||||
[600, 45],
|
||||
[900, 45],
|
||||
])
|
||||
await runTrailVisibility({ ...sceneQA, altitude, pitch });
|
||||
if (runs('budgets')) await runFleetBudgets(sceneQA);
|
||||
if (runs('matrix')) await runBostonMatrix(sceneQA);
|
||||
if (runs('boston-live')) await runBostonLive(sceneQA);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Credentialed AI voice acceptance using a prerecorded Chromium microphone.
|
||||
*
|
||||
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189
|
||||
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk]
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -11,8 +11,13 @@ import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const appUrl = process.argv[2] || 'http://localhost:4189';
|
||||
const wavPath = process.argv[3]
|
||||
const pushToTalk = process.argv.includes('--push-to-talk');
|
||||
const positional = process.argv.slice(2).filter(arg => arg !== '--push-to-talk');
|
||||
const targetUrl = new URL(positional[0] || 'http://localhost:4189');
|
||||
// Keep first-run focus changes outside this voice/keyboard acceptance test.
|
||||
targetUrl.searchParams.set('welcome', '0');
|
||||
const appUrl = targetUrl.href;
|
||||
const wavPath = positional[1]
|
||||
|| path.join(repoRoot, 'scripts', 'fixtures', 'voice', 'full-globe-turn-on-radio.wav');
|
||||
const expectedFixtureSha256 = 'b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb';
|
||||
|
||||
@@ -60,6 +65,7 @@ try {
|
||||
await page.waitForFunction(() => (
|
||||
window.__godsEyeView?.voiceCommands
|
||||
&& document.getElementById('gev-voice-button')
|
||||
&& document.getElementById('loading-screen')?.classList.contains('hidden')
|
||||
), { timeout: 30_000 });
|
||||
|
||||
const readState = () => page.evaluate(() => {
|
||||
@@ -81,7 +87,26 @@ try {
|
||||
});
|
||||
|
||||
const initial = await readState();
|
||||
await page.evaluate(() => document.getElementById('gev-voice-button').click());
|
||||
let pushToTalkStarted = false;
|
||||
let microphoneReleased = false;
|
||||
if (pushToTalk) {
|
||||
await page.bringToFront();
|
||||
await page.focus('.cesium-widget canvas');
|
||||
await page.keyboard.down('Space');
|
||||
await page.waitForFunction(() => document.querySelector(
|
||||
'[data-push-to-talk="held"][data-microphone="active"]',
|
||||
), { timeout: 30_000 });
|
||||
pushToTalkStarted = true;
|
||||
// The hash-pinned fixture is 9.2 seconds long and plays once from mic acquisition.
|
||||
await new Promise(resolve => setTimeout(resolve, 10_000));
|
||||
await page.keyboard.up('Space');
|
||||
microphoneReleased = await page.evaluate(() => {
|
||||
const status = window.__godsEyeView.voiceCommands.status;
|
||||
return status === 'idle' || Boolean(document.querySelector('[data-microphone="muted"]'));
|
||||
});
|
||||
} else {
|
||||
await page.click('#gev-voice-button');
|
||||
}
|
||||
|
||||
const timeline = [initial];
|
||||
let lastSignature = JSON.stringify(initial);
|
||||
@@ -113,10 +138,14 @@ try {
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: finalState.voiceStatus === 'idle'
|
||||
ok: (!pushToTalk || (pushToTalkStarted && microphoneReleased))
|
||||
&& finalState.voiceStatus === 'idle'
|
||||
&& finalState.radioAudioState === 'playing'
|
||||
&& finalState.radioVoiceDucked === false
|
||||
&& Number(finalState.cameraHeightM) >= 10_000_000,
|
||||
mode: pushToTalk ? 'push-to-talk' : 'click',
|
||||
pushToTalkStarted,
|
||||
microphoneReleased,
|
||||
fixture: wavPath,
|
||||
fixtureSha256,
|
||||
appUrl,
|
||||
|
||||
Reference in new issue
Block a user