diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 17af12c52..0a2a652db 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b8727c81f..4679199b2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index b5ad8ac87..bb29285d8 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -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); diff --git a/scripts/qa-vessel-datum.mjs b/scripts/qa-vessel-datum.mjs index 188f447c7..d0e73198e 100644 --- a/scripts/qa-vessel-datum.mjs +++ b/scripts/qa-vessel-datum.mjs @@ -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(); diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 49887282b..e4327ea87 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -10,20 +10,20 @@ import { rendererFor, } from './layers/renderers.js'; -test('the Navara mode registers the aircraft layers’ renderers, once', async () => { +test('the Navara mode registers the aircraft layers’ and vessels’ renderers, once', async () => { assert.equal(layerAvailableOn('military', 'navara'), false); await Promise.all([ registerNavaraLayerRenderers(), registerNavaraLayerRenderers(), ]); await registerNavaraLayerRenderers(); - for (const id of ['flights', 'military', 'local-adsb']) + for (const id of ['flights', 'military', 'local-adsb', 'ais-live-vessels']) assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.deepEqual( createLayerAvailability('navara') .available() .map(({ id }) => id), - ['flights', 'military', 'local-adsb'], + ['flights', 'military', 'local-adsb', 'ais-live-vessels'], ); assert.equal(rendererFor('satellites', { kind: 'navara' }), null); }); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 1e4be2f1d..9800b4945 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -64,6 +64,9 @@ export async function registerNavaraLayerRenderers() { 'local-adsb': async () => (await import('../layers/localAdsb/render/navara.js')) .createLocalAdsbNavaraRenderer, + 'ais-live-vessels': async () => + (await import('../layers/vessels/render/navara.js')) + .createVesselsNavaraRenderer, }; const missing = Object.keys(loaders).filter( (id) => !layerAvailableOn(id, 'navara'), diff --git a/src/app/qaProbe.js b/src/app/qaProbe.js index 66d2aecef..7eb044963 100644 --- a/src/app/qaProbe.js +++ b/src/app/qaProbe.js @@ -68,7 +68,12 @@ export function createQaProbe({ engine, dataManager, availability }) { ); return row ? screenOf(row.position) : null; }, - selected: (layerId) => moduleOf(layerId)?.getTrackedInfo?.() ?? null, + // The tracked object, or the selected one of a layer that selects + // without tracking (vessels). + selected: (layerId) => { + const module = moduleOf(layerId); + return module?.getTrackedInfo?.() ?? module?.getSelectedInfo?.() ?? null; + }, drawn: (layerId) => moduleOf(layerId)?.qaDrawn?.() ?? null, following() { const handle = engine.camera.following(); diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index a8447e744..e5dab38dc 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -22,7 +22,7 @@ import { aircraftIconCells, drawAircraftIconAtlas, } from '../../../data/aircraftIcons.js'; -import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js'; +import { createNavaraLayerView } from '../../shared/render/navaraView.js'; const BASE_SIZE_PX = 20; @@ -252,130 +252,13 @@ export function createAircraftNavaraRenderer({ }; // --- the view ------------------------------------------------------------ - let frameNumber = 0; - removers.push( - engine.frames.preUpdate.addEventListener(() => { - frameNumber++; - }), - ); - const camera = { - positionWC: geo.cartesian3.create(), - positionCartographic: geo.cartographic.create(), - directionWC: geo.cartesian3.create(), - upWC: geo.cartesian3.create(), - rightWC: geo.cartesian3.create(), - heading: 0, - pitch: 0, - roll: 0, - frustum: { fovy: 1, aspectRatio: 1 }, - }; - let poseFrame = -1; - const lastPosition = geo.cartesian3.create(Number.NaN, 0, 0); - // The camera as of now: refreshed once per frame, and whenever it moved. - const freshCamera = () => { - const live = engine.camera.livePosition; - if ( - poseFrame === frameNumber && - live.x === lastPosition.x && - live.y === lastPosition.y && - live.z === lastPosition.z - ) - return camera; - const pose = engine.camera.pose(); - geo.cartesian3.clone(pose.position, camera.positionWC); - geo.cartographic.clone(pose.cartographic, camera.positionCartographic); - geo.cartesian3.clone(pose.direction, camera.directionWC); - geo.cartesian3.clone(pose.up, camera.upWC); - geo.cartesian3.clone(pose.right, camera.rightWC); - camera.heading = pose.heading; - camera.pitch = pose.pitch; - camera.roll = pose.roll; - camera.frustum.fovy = pose.fovy; - camera.frustum.aspectRatio = pose.aspectRatio ?? 1; - geo.cartesian3.clone(live, lastPosition); - poseFrame = frameNumber; - return camera; - }; - const cameraView = new Proxy(camera, { - get: (target, key) => freshCamera()[key], + const layerView = createNavaraLayerView(engine, { + layerId, + wrap: (run) => inFrameDo(run), + isDestroyed: () => destroyed, }); - const scene = { - get camera() { - return cameraView; - }, - canvas: { - get clientHeight() { - return engine.host.size().height; - }, - }, - frameState: { - get frameNumber() { - return frameNumber; - }, - }, - }; - // Shared helpers project through the engine registered for their scene. - removers.push(registerViewerEngine(scene, engine)); - const horizon = { - isPointVisible: (position) => engine.projector.horizonVisible(position), - }; - const scratchDelta = geo.cartesian3.create(); - let frustum = null; - const inFrustum = (position) => { - const { origin, direction, right, up, tanX, tanY } = frustum; - geo.cartesian3.subtract(position, origin, scratchDelta); - const z = geo.cartesian3.dot(scratchDelta, direction); - if (z <= -1) return false; - const x = Math.abs(geo.cartesian3.dot(scratchDelta, right)); - const y = Math.abs(geo.cartesian3.dot(scratchDelta, up)); - return x <= z * tanX + 1 && y <= z * tanY + 1; - }; - const view = { - get camera() { - return cameraView; - }, - scene, - surfaceOwner: engine, - surfaceScene: engine, - frameNumber: () => frameNumber, - viewportHeightPx: () => engine.host.size().height, - frustumTester() { - const cam = freshCamera(); - const tanY = Math.tan(cam.frustum.fovy / 2); - const { width, height } = engine.host.size(); - frustum = { - origin: cam.positionWC, - direction: cam.directionWC, - right: cam.rightWC, - up: cam.upWC, - tanY, - tanX: tanY * (height > 0 ? width / height : 1), - }; - return inFrustum; - }, - horizon: () => horizon, - worldToWindow: (position, result) => - engine.projector.worldToWindow(position, result), - requestRender: () => engine.render.request(layerId), - on(event, listener) { - const source = - event === 'moveEnd' - ? engine.camera.moveEnd - : event === 'preRender' - ? engine.frames.preRender - : event === 'preUpdate' - ? engine.frames.preUpdate - : null; - if (!source) return null; - return source.addEventListener((...args) => - inFrameDo(() => listener(...args)), - ); - }, - isDestroyed: () => destroyed || engine.isDestroyed?.() === true, - holdFrames() {}, - closeFrameLatch() {}, - releaseFrames() {}, - }; + removers.push(layerView.destroy); + const { view, freshCamera } = layerView; // --- fleet sprites --------------------------------------------------------- let atlas = null; diff --git a/src/layers/shared/render/navaraView.js b/src/layers/shared/render/navaraView.js new file mode 100644 index 000000000..172fde1d3 --- /dev/null +++ b/src/layers/shared/render/navaraView.js @@ -0,0 +1,161 @@ +// The view a layer renderer on the Navara engine gives its logic (shared by +// the aircraft and vessel renderers): `camera` camera-shaped and `scene` +// scene-shaped adapters over engine.camera.pose() (refreshed once per frame, +// and whenever the camera moved), with the engine registered for the scene +// (engineFromViewer) so the shared helpers (iconOrientation, focus) project +// through its projector; the frame number, frustum and horizon tests, the +// window projection and the frame/camera events. No three, no Cesium. + +import * as geo from '../../../geo/index.js'; +import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js'; + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ layerId: string, wrap?: (run: () => any) => any, + * isDestroyed?: () => boolean }} options `wrap` runs each event listener + * (the renderer's frame commit scope). + * @returns {{ view: object, freshCamera: () => object, destroy: () => void }} + */ +export function createNavaraLayerView( + engine, + { layerId, wrap = (run) => run(), isDestroyed = () => false }, +) { + const removers = []; + let frameNumber = 0; + removers.push( + engine.frames.preUpdate.addEventListener(() => { + frameNumber++; + }), + ); + const camera = { + positionWC: geo.cartesian3.create(), + positionCartographic: geo.cartographic.create(), + directionWC: geo.cartesian3.create(), + upWC: geo.cartesian3.create(), + rightWC: geo.cartesian3.create(), + heading: 0, + pitch: 0, + roll: 0, + frustum: { fovy: 1, aspectRatio: 1 }, + // The ellipsoid under a window point (the vessels' view-area request). + pickEllipsoid: (screen) => engine.camera.pickEllipsoid(screen), + }; + let poseFrame = -1; + const lastPosition = geo.cartesian3.create(Number.NaN, 0, 0); + // The camera as of now: refreshed once per frame, and whenever it moved. + const freshCamera = () => { + const live = engine.camera.livePosition; + if ( + poseFrame === frameNumber && + live.x === lastPosition.x && + live.y === lastPosition.y && + live.z === lastPosition.z + ) + return camera; + const pose = engine.camera.pose(); + geo.cartesian3.clone(pose.position, camera.positionWC); + geo.cartographic.clone(pose.cartographic, camera.positionCartographic); + geo.cartesian3.clone(pose.direction, camera.directionWC); + geo.cartesian3.clone(pose.up, camera.upWC); + geo.cartesian3.clone(pose.right, camera.rightWC); + camera.heading = pose.heading; + camera.pitch = pose.pitch; + camera.roll = pose.roll; + camera.frustum.fovy = pose.fovy; + camera.frustum.aspectRatio = pose.aspectRatio ?? 1; + geo.cartesian3.clone(live, lastPosition); + poseFrame = frameNumber; + return camera; + }; + const cameraView = new Proxy(camera, { + get: (target, key) => freshCamera()[key], + }); + const scene = { + get camera() { + return cameraView; + }, + canvas: { + get clientWidth() { + return engine.host.size().width; + }, + get clientHeight() { + return engine.host.size().height; + }, + }, + frameState: { + get frameNumber() { + return frameNumber; + }, + }, + }; + // Shared helpers project through the engine registered for their scene. + removers.push(registerViewerEngine(scene, engine)); + const horizon = { + isPointVisible: (position) => engine.projector.horizonVisible(position), + }; + const scratchDelta = geo.cartesian3.create(); + let frustum = null; + const inFrustum = (position) => { + const { origin, direction, right, up, tanX, tanY } = frustum; + geo.cartesian3.subtract(position, origin, scratchDelta); + const z = geo.cartesian3.dot(scratchDelta, direction); + if (z <= -1) return false; + const x = Math.abs(geo.cartesian3.dot(scratchDelta, right)); + const y = Math.abs(geo.cartesian3.dot(scratchDelta, up)); + return x <= z * tanX + 1 && y <= z * tanY + 1; + }; + const view = { + get camera() { + return cameraView; + }, + scene, + surfaceOwner: engine, + surfaceScene: engine, + frameNumber: () => frameNumber, + viewportHeightPx: () => engine.host.size().height, + frustumTester() { + const cam = freshCamera(); + const tanY = Math.tan(cam.frustum.fovy / 2); + const { width, height } = engine.host.size(); + frustum = { + origin: cam.positionWC, + direction: cam.directionWC, + right: cam.rightWC, + up: cam.upWC, + tanY, + tanX: tanY * (height > 0 ? width / height : 1), + }; + return inFrustum; + }, + horizon: () => horizon, + worldToWindow: (position, result) => + engine.projector.worldToWindow(position, result), + requestRender: () => engine.render.request(layerId), + on(event, listener) { + const source = + event === 'moveEnd' + ? engine.camera.moveEnd + : event === 'preRender' + ? engine.frames.preRender + : event === 'preUpdate' + ? engine.frames.preUpdate + : null; + if (!source) return null; + return source.addEventListener((...args) => + wrap(() => listener(...args)), + ); + }, + isDestroyed: () => isDestroyed() || engine.isDestroyed?.() === true, + holdFrames() {}, + closeFrameLatch() {}, + releaseFrames() {}, + }; + + return { + view, + freshCamera, + destroy() { + for (const remove of removers.splice(0)) remove?.(); + }, + }; +} diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index ca4472141..9fdcda120 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -10,6 +10,7 @@ import { createCards } from './cards.js'; import { createTesting } from './testing.js'; import { createEvidence } from './evidence.js'; import { createQueries } from './queries.js'; +import { vesselTypeCss } from '../../data/vesselLabels.js'; /** Compose one vessel layer with application-owned scene services. */ export function createVesselLayer({ source, services, options = {} } = {}) { const vesselState = createVesselState({ source, services }); @@ -67,6 +68,40 @@ export function createVesselLayer({ source, services, options = {} } = {}) { cardScreenSeparated: parts.cards.cardScreenSeparated, }); Object.defineProperty(layer, 'testing', { value: parts.testing }); + /** + * What the layer draws, as plain data (the QA probe's drawn(id)): each + * chevron as the renderer holds it, and the cards published last. + */ + layer.qaDrawn = () => { + const { state } = vesselState; + if (!state.feed.enabled) return null; + const plain = (p) => (p ? { x: p.x, y: p.y, z: p.z } : null); + return { + chevrons: state.records.all + .map((record) => [record, parts.rendering.getVisual(record).billboard]) + .filter(([, billboard]) => billboard) + .map(([record, billboard]) => ({ + id: record.mmsi ?? null, + position: plain(billboard.position), + show: billboard.show === true, + rotation: billboard.rotation, + scale: billboard.scale, + alpha: billboard.color?.alpha ?? 1, + depthFree: + billboard.disableDepthTestDistance === Number.POSITIVE_INFINITY, + selected: record === state.selectedRecord, + css: + record === state.selectedRecord + ? '#ffffff' + : vesselTypeCss(record.type), + })), + cards: (vesselState._publishedCards ?? []).map((entry) => ({ + id: entry.id, + selected: entry.selected === true, + position: plain(entry.position), + })), + }; + }; return layer; } export { AIS_FIRST_CONNECT_GRACE_MS } from './policy.js'; diff --git a/src/layers/vessels/render/navara.js b/src/layers/vessels/render/navara.js new file mode 100644 index 000000000..4aa505eb6 --- /dev/null +++ b/src/layers/vessels/render/navara.js @@ -0,0 +1,399 @@ +// The vessels layer's renderer on the Navara engine (phase 3 Task 13b), +// with render/cesium.js's interface (./contract.js), built only on the +// engine contract: the chevrons are one engine.movers sprite bank (always on +// top, as Cesium's depth-test-free billboards) whose atlas holds every +// chevron the layer draws (../icons.js, rasterised at 32 px as Cesium's +// billboard texture); the trail is engine.lines; the camera reads go +// through the shared Navara layer view (engine.camera.pose(), the +// projector). No three, no Cesium. Registered for 'navara' by the Navara +// mode after its import() (src/app/navaraMode.js). +// +// Handles keep Cesium's Billboard field vocabulary as setters that write +// the bank; the writes of a frame are committed once, at the end of the +// frame listener that made them (or in a microtask for writes outside a +// frame). + +import * as geo from '../../../geo/index.js'; +import { createMoverBank } from '../../aircraft/render/navara.js'; +import { createNavaraLayerView } from '../../shared/render/navaraView.js'; +import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; +import { allVesselChevronIcons, VESSEL_ICON_PX } from '../icons.js'; + +const LAYER_ID = 'ais-live-vessels'; +const TRAIL_ALPHA = 0.85; +const TRAIL_OCCLUDED_ALPHA = 0.4; +const MIN_SEGMENT_DISTANCE_SQ = 0.01; +const ATLAS_PAD = 2; + +/** The chevron atlas layout (pure): one 32 px cell per icon, in a row. */ +export function vesselAtlasLayout(icons = allVesselChevronIcons()) { + const cell = VESSEL_ICON_PX + 2 * ATLAS_PAD; + const width = icons.length * cell; + const height = cell; + const rects = new Map(); + const cells = {}; + icons.forEach((icon, column) => { + const x = column * cell + ATLAS_PAD; + const y = ATLAS_PAD; + rects.set(icon, { x, y, size: VESSEL_ICON_PX }); + cells[icon] = [ + x / width, + y / height, + (x + VESSEL_ICON_PX) / width, + (y + VESSEL_ICON_PX) / height, + ]; + }); + return { width, height, rects, cells }; +} + +/** Draws the atlas (browser): a canvas and its cells, once the SVGs decode. */ +export async function drawVesselAtlas() { + const layout = vesselAtlasLayout(); + const canvas = document.createElement('canvas'); + canvas.width = layout.width; + canvas.height = layout.height; + const context = canvas.getContext('2d'); + await Promise.all( + [...layout.rects].map(async ([uri, { x, y, size }]) => { + const image = new Image(); + image.src = uri; + await image.decode(); + context.drawImage(image, x, y, size, size); + }), + ); + return { canvas, cells: layout.cells }; +} + +const cssToRgb = (css) => { + const hex = String(css).replace('#', ''); + return [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255); +}; + +/** Plain colour copy (what a handle's colour getter returns). */ +class StoredColor { + constructor(color) { + this.red = color?.red ?? 1; + this.green = color?.green ?? 1; + this.blue = color?.blue ?? 1; + this.alpha = color?.alpha ?? 1; + } + + withAlpha(alpha) { + return new StoredColor({ ...this, alpha }); + } +} + +/** + * Registry factory (src/app/layers/renderers.js): `(engine, options)`. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ drawAtlas?: () => Promise<{ canvas: object, cells: object }> }} [options] + * Test seam: the chevron atlas. + * @returns {import('./contract.js').VesselRenderer} + */ +export function createVesselsNavaraRenderer( + engine, + { drawAtlas = drawVesselAtlas } = {}, +) { + let destroyed = false; + + // --- commits: once per frame listener, or in a microtask ----------------- + let bank = null; + let inFrame = 0; + let microtask = false; + let pendingCommit = false; + const flush = () => { + microtask = false; + if (!pendingCommit) return; + pendingCommit = false; + bank?.commit(); + }; + const onCommit = () => { + pendingCommit = true; + if (inFrame === 0 && !microtask) { + microtask = true; + queueMicrotask(flush); + } + }; + const inFrameDo = (run) => { + inFrame++; + try { + return run(); + } finally { + inFrame--; + if (inFrame === 0) flush(); + } + }; + + // --- the view -------------------------------------------------------------- + const layerView = createNavaraLayerView(engine, { + layerId: LAYER_ID, + wrap: inFrameDo, + isDestroyed: () => destroyed, + }); + const base = layerView.view; + const view = { + get camera() { + return base.camera; + }, + scene: base.scene, + horizon: () => base.horizon(), + worldToWindow: (position, result) => base.worldToWindow(position, result), + canvasSize: () => engine.host.size(), + requestRender: () => base.requestRender(), + on(event, listener) { + return event === 'preRender' || event === 'moveEnd' + ? base.on(event, listener) + : null; + }, + }; + + // --- the chevrons ------------------------------------------------------------ + let atlas = null; + let visible = true; + let attached = false; + const handles = new Set(); + const records = new Map(); // pickId → record + let unkeyedSeq = 0; + const scratchColor = [1, 1, 1, 1]; + const write = (handle, set, index) => { + const color = handle.storedColor; + scratchColor[0] = color.red; + scratchColor[1] = color.green; + scratchColor[2] = color.blue; + scratchColor[3] = color.alpha; + set.set(index, { + position: handle.storedPosition, + rotation: handle.storedRotation, + scale: handle.storedScale, + color: scratchColor, + cell: atlas.cells[handle.storedImage] + ? handle.storedImage + : Object.keys(atlas.cells)[0], + show: handle.storedShow, + pickId: handle.pickId, + }); + }; + const ensureBank = () => { + if (bank || !atlas || destroyed) return; + bank = createMoverBank(engine, { + options: (capacity, generation) => ({ + id: `${LAYER_ID}-chevrons-${generation}`, + kind: 'sprite', + capacity, + order: SPRITE_LAYER_ORDER.indexOf('ais'), + // Cesium's disableDepthTestDistance: Infinity. + depthTest: 'always', + pickable: true, + sprite: { atlas, sizePx: VESSEL_ICON_PX }, + }), + write, + onCommit, + }); + bank.setVisible(visible && attached); + for (const handle of handles) bank.add(handle); + }; + const atlasReady = Promise.resolve() + .then(() => drawAtlas()) + .then((drawn) => { + if (destroyed) return; + atlas = drawn; + if (attached) ensureBank(); + }); + + const update = (handle, fields) => { + if (handle.gevBank) handle.gevBank.update(handle, fields); + }; + const scratchRgba = [1, 1, 1, 1]; + const newHandle = (spec) => { + const record = spec.id; + const pickId = + record?.mmsi != null && record.mmsi !== '' + ? String(record.mmsi) + : `gev-vessel-unkeyed:${++unkeyedSeq}`; + const handle = { + id: record, + pickId, + gevBank: null, + gevIndex: -1, + storedPosition: geo.cartesian3.clone(spec.position), + storedShow: spec.show !== false, + storedRotation: spec.rotation ?? 0, + storedScale: spec.scale ?? 1, + storedImage: spec.image, + storedColor: new StoredColor(), + // Cesium reads these before the image loads as undefined (the logic + // falls back to the 32 px artwork). + width: undefined, + height: undefined, + // The bank draws without a depth test (Cesium's billboards: Infinity). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + get position() { + return handle.storedPosition; + }, + set position(value) { + handle.storedPosition = geo.cartesian3.clone(value); + update(handle, { position: handle.storedPosition }); + }, + get show() { + return handle.storedShow; + }, + set show(value) { + const next = value !== false; + if (next === handle.storedShow) return; + handle.storedShow = next; + update(handle, { show: next }); + }, + get rotation() { + return handle.storedRotation; + }, + set rotation(value) { + handle.storedRotation = value; + update(handle, { rotation: value }); + }, + get scale() { + return handle.storedScale; + }, + set scale(value) { + handle.storedScale = value; + update(handle, { scale: value }); + }, + get image() { + return handle.storedImage; + }, + set image(value) { + handle.storedImage = value; + if (atlas?.cells[value]) update(handle, { cell: value }); + }, + get color() { + return handle.storedColor; + }, + set color(value) { + handle.storedColor = new StoredColor(value); + scratchRgba[0] = handle.storedColor.red; + scratchRgba[1] = handle.storedColor.green; + scratchRgba[2] = handle.storedColor.blue; + scratchRgba[3] = handle.storedColor.alpha; + update(handle, { color: scratchRgba }); + }, + }; + return handle; + }; + + const fleet = { + collection: null, + attached: () => attached, + attach({ show }) { + if (attached || destroyed) return; + attached = true; + visible = show !== false; + ensureBank(); + bank?.setVisible(visible); + }, + setVisible(show) { + visible = show !== false; + bank?.setVisible(visible && attached); + if (visible) view.requestRender(); + }, + add(spec) { + const handle = newHandle(spec); + handles.add(handle); + records.set(handle.pickId, handle.id); + bank?.add(handle); + return handle; + }, + remove(handle) { + if (!handle || !handles.delete(handle)) return; + records.delete(handle.pickId); + handle.gevBank?.remove(handle); + }, + detach() { + attached = false; + for (const handle of handles) { + handle.gevBank = null; + handle.gevIndex = -1; + } + handles.clear(); + records.clear(); + bank?.destroy(); + bank = null; + }, + }; + + /** The record a pick is of: pick ids are the MMSI (unkeyed: their own). */ + const pickedRecord = (picked) => { + const id = picked?.id; + return id === undefined || id === null + ? null + : (records.get(String(id)) ?? null); + }; + + // Vessels never follow: a follow belongs to another layer. + const follow = { + onChanged: (listener) => + engine.camera.followChanged.addEventListener(listener), + active: () => engine.camera.following() !== null, + }; + + // --- the trail (engine.lines) ---------------------------------------------- + let lineSeq = 0; + const trails = { + create({ color, width = 2.5 }) { + const [r, g, b] = cssToRgb(color); + const id = `gev-trail:${LAYER_ID}-${++lineSeq}`; + let line = null; + const ensure = () => + (line ??= engine.lines.createLine({ + id, + widthPx: width, + color: [r, g, b, TRAIL_ALPHA], + depthFailAlpha: TRAIL_OCCLUDED_ALPHA / TRAIL_ALPHA, + arc: 'geodesic', + pickId: id, + })); + return { + setPositions(cartesians) { + if (destroyed) return; + const positions = []; + for (const position of Array.isArray(cartesians) ? cartesians : []) { + if (!position) continue; + const last = positions[positions.length - 1]; + if ( + last && + geo.cartesian3.distanceSquared(last, position) < + MIN_SEGMENT_DISTANCE_SQ + ) + continue; + positions.push(position); + } + ensure().setPositions(positions.length >= 2 ? positions : []); + }, + clear() { + line?.setPositions([]); + }, + destroy() { + line?.destroy(); + line = null; + }, + }; + }, + }; + + return { + kind: 'navara', + engine, + view, + fleet, + pickedRecord, + follow, + trails, + /** The atlas is drawn (tests, QA). */ + whenReady: () => atlasReady, + destroy() { + if (destroyed) return; + flush(); + fleet.detach(); + destroyed = true; + layerView.destroy(); + }, + }; +} diff --git a/src/layers/vessels/renderer.test.mjs b/src/layers/vessels/renderer.test.mjs index fa6573b31..fd8dd3fce 100644 --- a/src/layers/vessels/renderer.test.mjs +++ b/src/layers/vessels/renderer.test.mjs @@ -208,3 +208,81 @@ test('vessel logic draws and selects through a plain renderer on an engine, with globalThis.document = previousDocument; } }); + +test('vessels on the Navara renderer: one always-on-top chevron bank, MMSI picks, the trail on engine.lines', async () => { + const { createVesselsNavaraRenderer, vesselAtlasLayout } = + await import('./render/navara.js'); + const engine = createFakeEngine(); + const layout = vesselAtlasLayout(); + const renderer = createVesselsNavaraRenderer(engine, { + drawAtlas: async () => ({ canvas: { kind: 'atlas' }, cells: layout.cells }), + }); + assert.deepEqual(missingVesselRendererMembers(renderer), []); + const layer = createVesselLayer({ + source: { + label: 'Fixture vessels', + getSnapshot: async () => ({ + records: [row('111', 40.6, -73.9), row('222', 40.7, -74.0)], + source: 'Fixture vessels', + complete: true, + stale: false, + freshness: 'current', + observedAtMs: 500, + transportStatus: 'live', + rawRowCount: 2, + }), + getTrack: async () => ({ + records: [{ latitude: 40.5, longitude: -73.8 }], + }), + }, + services: { + ...services(renderer), + geoid: { + ensureGeoidReady: () => new Promise(noop), + geoidHeight: () => 0, + }, + }, + }); + const previousDocument = globalThis.document; + globalThis.document = Object.assign(new EventTarget(), { + getElementById: () => null, + }); + try { + layer.init(null, { engine }); + await layer.enable(null, { engine }); + await renderer.whenReady(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const sets = engine.movers.list(); + assert.equal(sets.length, 1); + assert.equal(sets[0].count, 2); + const set = engine.fake.moverSet(sets[0].id); + assert.equal(set.options.depthTest, 'always'); + assert.equal(set.options.sprite.sizePx, 32); + assert.deepEqual([...set.store.pickIds.slice(0, 2)].sort(), ['111', '222']); + + // A click on its pick (the MMSI) selects it: the white chevron ×1.2. + engine.fake.script('pick', () => ({ id: '111', owner: sets[0].id })); + engine.fake.input('click', { position: { x: 10, y: 10 } }); + assert.equal(layer.getSelectedInfo()?.mmsi, '111'); + await new Promise((resolve) => setTimeout(resolve, 0)); + const index = [...set.store.pickIds].indexOf('111'); + assert.ok(Math.abs(set.store.scales[index] - 0.72) < 1e-6); + // The trail: an engine line (the backfill fix + the live position). + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.ok(engine.fake.line('gev-trail:ais-live-vessels-1')); + + // Another layer follows something: the vessel selection goes. + const handle = engine.camera.follow({ + position: () => engine.camera.pose().position, + viewFrom: { x: 0, y: -100, z: 100 }, + }); + assert.equal(layer.getSelectedInfo(), null); + handle.stop(); + + layer.disable(null, { engine }); + layer.destroy(null, { engine }); + assert.equal(engine.movers.list().length, 0); + } finally { + globalThis.document = previousDocument; + } +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index c4829a29b..3b8fca79a 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -413,6 +413,8 @@ export function createRendering({ height, labelRowLimit(), ); + // The cards published last (QA: qaDrawn). + vesselState._publishedCards = entries; vesselState._vesselOverlayHost.setEntries( VESSEL_OVERLAY_SOURCE_ID, entries.map((entry) => { diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 5addea1b0..7f362be5c 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -46,6 +46,10 @@ export function createVesselState({ source, services }) { vesselState.shipIconCache = new Map(); + /** The card entries published last (QA: the layer's qaDrawn). */ + + vesselState._publishedCards = []; + vesselState.state = { feed: createVesselFeed(), records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),