feat(vessels): render on Navara (WIP: browser parity not yet run)

render/navara.js: one always-on-top engine.movers chevron bank (atlas of
every chevron icon at 32 px), MMSI pick ids, trail on engine.lines,
camera reads through the shared Navara layer view (extracted from the
aircraft renderer to src/layers/shared/render/navaraView.js, + camera
pickEllipsoid and canvas clientWidth). Registered in navaraMode.
QA: vessel qaDrawn; probe selected() falls back to getSelectedInfo;
qa-w1-layers vessel section; qa-vessels-engines side by side; --engine
for qa-vessel-cards/datum; qa-flights-engines military near pose 100 km.
Unit tests on the fake engine pass; browser runs NOT done (see progress).
This commit is contained in:
German Khokhlov committed 2026-10-17 14:06:11 +04:00
1 parent 620ada6df3
commit 69a78710cc
14 files changed
+850 -154

No files matched your search

+60 -18
View File
@@ -38,6 +38,16 @@ const getOpt = (name, dflt) => {
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
// --engine cesium|navara (phase 3): frame, settle and read the renderer
// through window.__godsEyeView.engine (either engine) instead of the Cesium
// viewer; the URL gets ?engine=. Without it, as before.
const ENGINE = getOpt('--engine', null);
const PAGE_URL = (() => {
if (!ENGINE) return APP_URL;
const url = new URL(APP_URL);
url.searchParams.set('engine', ENGINE);
return url.href;
})();
const TAG = getOpt('--tag', 'new');
const PORT_KEYS = getOpt('--ports', 'rotterdam,houston').split(',').map((s) => s.trim()).filter(Boolean);
const OUT_DIR = getOpt('--out', 'qa-shots');
@@ -203,6 +213,7 @@ async function main() {
console.log(`\nAIS Vessel Cards — visual proof harness`);
console.log(` App URL : ${APP_URL}`);
console.log(` Tag : ${TAG}`);
console.log(` Engine : ${ENGINE ?? 'viewer (Cesium)'}`);
console.log(` Data : ${DATA_MODE}`);
console.log(` Browser : ${HEADFUL ? 'headful (hardware renderer requested)' : 'headless (SwiftShader)'}`);
console.log(` Ports : ${PORT_KEYS.join(', ')}\n`);
@@ -259,13 +270,24 @@ async function main() {
});
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
if (ENGINE)
await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 });
else
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
// Frame the port (kill the intro flight first) and enable the layer.
const syntheticRows = DATA_MODE === 'synthetic' ? syntheticVesselRows(key, port) : [];
const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows }) => {
const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows, engineNeutral }) => {
const gev = window.__godsEyeView;
if (engineNeutral) {
const geo = await import('/src/geo/index.js');
gev.engine.camera.cancelFlight?.();
gev.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(p.lon, p.lat, p.height),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
} else {
const v = gev.viewer;
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
@@ -278,6 +300,7 @@ async function main() {
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
}
await gev.dataManager.setEnabled('ais-live-vessels', true);
if (dataMode !== 'synthetic') return { mode: 'live', injected: 0 };
const seam = gev.dataManager.layers.get('ais-live-vessels')?.module?.__focusEvidence;
@@ -286,9 +309,10 @@ async function main() {
if (!result?.ok || result.count !== fixtureRows.length) {
throw new Error(`Synthetic AIS injection failed (${result?.count || 0}/${fixtureRows.length})`);
}
v.scene.requestRender?.();
if (engineNeutral) gev.engine.render.request('qa-vessel-cards');
else gev.viewer.scene.requestRender?.();
return { mode: 'synthetic', injected: result.count };
}, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows });
}, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows, engineNeutral: Boolean(ENGINE) });
console.log(
` [DATA] ${dataEvidence.mode}${dataEvidence.injected ? ` (${dataEvidence.injected} injected)` : ''}; `
+ `artifact provenance=${DATA_PROVENANCE_SLUGS[DATA_MODE]}`,
@@ -296,21 +320,32 @@ async function main() {
// Wait for the photoreal tileset and at least one vessel refresh.
const settled = await page
.waitForFunction(() => {
.waitForFunction((engineNeutral) => {
const gev = window.__godsEyeView;
const t = gev.tileset;
const tilesLoaded = engineNeutral
? gev.engine.surface.tilesLoaded()
: gev.tileset?.tilesLoaded;
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
const count = ais?.stats?.count ?? 0;
return t?.tilesLoaded && count > 0;
}, { timeout: 90000, polling: 500 })
return tilesLoaded && count > 0;
}, { timeout: 90000, polling: 500 }, Boolean(ENGINE))
.then(() => true)
.catch(() => false);
// Re-assert the port framing: on slow (SwiftShader) runs the app's
// intro flight can start AFTER the first cancelFlight and land mid-wait,
// dragging the camera back to the boot city before the screenshot.
await page.evaluate((p) => {
const v = window.__godsEyeView.viewer;
await page.evaluate(async (p, engineNeutral) => {
const gev = window.__godsEyeView;
if (engineNeutral) {
const geo = await import('/src/geo/index.js');
gev.engine.camera.cancelFlight?.();
gev.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(p.lon, p.lat, p.height),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
} else {
const v = gev.viewer;
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
const carto = {
@@ -322,25 +357,32 @@ async function main() {
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
}, port);
}
}, port, Boolean(ENGINE));
// A changed camera invalidates the previous settled result. Let the new
// view schedule its tiles before checking readiness again.
await new Promise((resolve) => setTimeout(resolve, 1500));
const resettled = await page
.waitForFunction(() => {
.waitForFunction((engineNeutral) => {
const gev = window.__godsEyeView;
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
return gev.tileset?.tilesLoaded && (ais?.stats?.count ?? 0) > 0;
}, { timeout: 60000, polling: 500 })
const tilesLoaded = engineNeutral
? gev.engine.surface.tilesLoaded()
: gev.tileset?.tilesLoaded;
return tilesLoaded && (ais?.stats?.count ?? 0) > 0;
}, { timeout: 60000, polling: 500 }, Boolean(ENGINE))
.then(() => true)
.catch(() => false);
// Let two visibility/declutter cycles (800 ms) and host rendering settle.
await new Promise((r) => setTimeout(r, 2500));
const expectedFixtureIds = syntheticRows.map((row) => row.mmsi);
const overlayEvidence = await page.evaluate((fixtureIds) => {
const overlayEvidence = await page.evaluate((fixtureIds, engineNeutral) => {
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
const gl = window.__godsEyeView?.viewer?.scene?.context?._gl;
const canvas = window.__godsEyeView?.engine?.host?.canvas;
const gl = engineNeutral
? canvas?.getContext('webgl2') || canvas?.getContext('webgl')
: window.__godsEyeView?.viewer?.scene?.context?._gl;
const debugInfo = gl?.getExtension?.('WEBGL_debug_renderer_info');
const seam = window.__godsEyeView?.dataManager?.layers
?.get('ais-live-vessels')?.module?.__focusEvidence;
@@ -358,7 +400,7 @@ async function main() {
renderer: String(gl.getParameter(debugInfo?.UNMASKED_RENDERER_WEBGL || gl.RENDERER) || 'unknown'),
} : { vendor: 'unavailable', renderer: 'unavailable' },
};
}, expectedFixtureIds);
}, expectedFixtureIds, Boolean(ENGINE));
await page.screenshot({ path: outPath });
const st = fs.statSync(outPath);
const hardwareRenderer = isHardwareRenderer(overlayEvidence.renderer);