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

+3
View File
@@ -365,6 +365,9 @@
"src/layers/vessels/render/cesium.js",
"src/layers/vessels/render/contract.js",
"src/layers/vessels/renderer.test.mjs",
"src/layers/vessels/render/navara.js",
"src/layers/shared/render/navaraView.js",
"scripts/qa-vessels-engines.mjs",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
+3 -1
View File
@@ -2606,7 +2606,9 @@
"src/layers/aircraft/qaDrawn.js",
"src/layers/localAdsb/render/cesium.js",
"src/layers/vessels/render/cesium.js",
"src/layers/localAdsb/render/navara.js"
"src/layers/localAdsb/render/navara.js",
"src/layers/vessels/render/navara.js",
"src/layers/shared/render/navaraView.js"
],
"external": [
"hls.js",
+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);
+86 -7
View File
@@ -17,6 +17,12 @@
* Usage:
* node scripts/qa-vessel-datum.mjs
* node scripts/qa-vessel-datum.mjs --ports rotterdam --url http://localhost:4173
* node scripts/qa-vessel-datum.mjs --engine navara --url 'http://localhost:PORT/?qaFeed=vessels:fixture'
*
* --engine cesium|navara (phase 3): the engine-neutral path on either engine,
* through window.__godsEyeView.engine and the QA probe (layers.drawn: each
* chevron's drawn position and depth-test flag) instead of the Cesium
* viewer's primitives; the URL gets ?engine=. Without it, as before.
*/
import fs from 'node:fs';
import path from 'node:path';
@@ -31,6 +37,13 @@ const getOpt = (name, dflt) => {
const APP_URL = getOpt('--url', 'http://localhost:4173');
const PORT_KEYS = getOpt('--ports', 'rotterdam,houston').split(',').map((s) => s.trim()).filter(Boolean);
const OUT_DIR = getOpt('--out', 'qa-shots');
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;
})();
/**
* Camera framings match qa-vessel-cards.mjs; heightBand is the expected
@@ -132,9 +145,63 @@ function probeVessels({ portLat, portLon, nearDeg, sampleCap }) {
};
}
/**
* In-page probe on either engine (the QA probe): the chevrons the layer
* draws, their depth-test flag and the ellipsoidal heights of those near the
* port.
*/
async function probeVesselsEngine({ portLat, portLon, nearDeg, sampleCap }) {
const gev = window.__godsEyeView;
const drawn = gev?.layers?.drawn('ais-live-vessels');
if (!drawn) return { error: 'vessels layer not drawn' };
const geo = await import('/src/geo/index.js');
const chevrons = drawn.chevrons;
let depthFreeCount = 0;
let depthTestedCount = 0;
const nearHeights = [];
const step = Math.max(1, Math.floor(chevrons.length / sampleCap));
for (let i = 0; i < chevrons.length; i += step) {
const row = chevrons[i];
if (row.depthFree) depthFreeCount += 1;
else depthTestedCount += 1;
const carto = geo.cartographic.fromCartesian(row.position);
if (!carto) continue;
const lat = geo.math.toDegrees(carto.latitude);
const lon = geo.math.toDegrees(carto.longitude);
if (Math.abs(lat - portLat) <= nearDeg && Math.abs(lon - portLon) <= nearDeg)
nearHeights.push(carto.height);
}
nearHeights.sort((a, b) => a - b);
const median = nearHeights.length ? nearHeights[Math.floor(nearHeights.length / 2)] : null;
return {
total: chevrons.length,
sampled: depthFreeCount + depthTestedCount,
depthFreeCount,
depthTestedCount,
nearCount: nearHeights.length,
medianHeight: median,
minHeight: nearHeights[0] ?? null,
maxHeight: nearHeights[nearHeights.length - 1] ?? null,
};
}
/** Frames a port through the engine (either engine). */
async function frameEngine(p, enable) {
const geo = await import('/src/geo/index.js');
const gev = window.__godsEyeView;
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 },
});
if (enable) await gev.dataManager.setEnabled('ais-live-vessels', true);
gev.engine.render.request('qa-vessel-datum');
}
async function main() {
console.log(`\nAIS Vessel Datum — assertion harness`);
console.log(` App URL : ${APP_URL}`);
console.log(` Engine : ${ENGINE ?? 'viewer (Cesium)'}`);
console.log(` Ports : ${PORT_KEYS.join(', ')}\n`);
try {
@@ -179,11 +246,15 @@ async function main() {
await page.setViewport({ width: 1600, height: 900 });
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.
await page.evaluate(async (p) => {
if (ENGINE) await page.evaluate(frameEngine, port, true);
else await page.evaluate(async (p) => {
const gev = window.__godsEyeView;
const v = gev.viewer;
v.camera.cancelFlight?.();
@@ -208,7 +279,7 @@ async function main() {
let probe = null;
let settled = false;
while (Date.now() < deadline) {
probe = await page.evaluate(probeVessels, probeArgs);
probe = await page.evaluate(ENGINE ? probeVesselsEngine : probeVessels, probeArgs);
if (probe && !probe.error && probe.nearCount >= 3
&& probe.medianHeight !== null
&& probe.medianHeight >= port.heightBand[0]
@@ -245,7 +316,8 @@ async function main() {
// Visual proof — re-assert the framing (the app's intro flight can land
// mid-probe on slow runs), wait for the photoreal tileset, screenshot.
await page.evaluate((p) => {
if (ENGINE) await page.evaluate(frameEngine, port, false);
else await page.evaluate((p) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
@@ -260,10 +332,17 @@ async function main() {
});
}, port);
await page
.waitForFunction(() => window.__godsEyeView?.tileset?.tilesLoaded, { timeout: 60000, polling: 500 })
.waitForFunction(
(engineNeutral) =>
engineNeutral
? window.__godsEyeView?.engine?.surface?.tilesLoaded?.()
: window.__godsEyeView?.tileset?.tilesLoaded,
{ timeout: 60000, polling: 500 },
Boolean(ENGINE),
)
.catch(() => console.log(' (tileset settle timeout — screenshotting anyway)'));
await new Promise((r) => setTimeout(r, 2500));
const outPath = path.join(OUT_DIR, `vessel-datum-${key}.png`);
const outPath = path.join(OUT_DIR, `vessel-datum-${ENGINE ? `${ENGINE}-` : ''}${key}.png`);
await page.screenshot({ path: outPath });
console.log(` [SHOT] ${outPath}`);
await page.close();