diff --git a/scripts/qa-navara-canaries.mjs b/scripts/qa-navara-canaries.mjs new file mode 100644 index 000000000..1b884e41a --- /dev/null +++ b/scripts/qa-navara-canaries.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node +// Package canaries deliberately bypass adapter workarounds. A passing canary +// means the upstream bug still exists. Fixture errors prove nothing about it. +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +import { closeBrowser } from './qa/browser.mjs'; + +const origin = new URL(process.argv[2] ?? 'http://localhost:4300').origin; +assert.ok(['localhost', '127.0.0.1'].includes(new URL(origin).hostname)); +assert.equal( + new URL(origin).port, + '4300', + 'Use the dedicated keyless canary server on port 4300', +); +const profile = await mkdtemp(path.resolve('tmp_navara_canaries_profile_')); +const results = { confirmed: 0, gone: 0, fixtureErrors: 0 }; +let browser; +try { + browser = await puppeteer.launch({ + headless: true, + userDataDir: profile, + protocolTimeout: 60_000, + args: ['--use-angle=metal', '--enable-gpu'], + }); + const page = await browser.newPage(); + await page.setViewport({ width: 800, height: 600 }); + page.setDefaultTimeout(30_000); + page.on('pageerror', (error) => console.error(error.message)); + await page.setRequestInterception(true); + const pendingTiles = []; + const tile = { + contentType: 'image/png', + body: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=', + 'base64', + ), + }; + let holdTiles = true; + await page.exposeFunction('pendingTileCount', () => pendingTiles.length); + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname === '/tmp_navara_canaries.html') { + request.respond({ + contentType: 'text/html', + body: '
', + }); + } else if (url.pathname.startsWith('/tmp_navara_canaries_tile/')) { + if (holdTiles) pendingTiles.push(request); + else request.respond(tile); + } else if ( + url.origin === origin || + ['blob:', 'data:'].includes(url.protocol) + ) { + request.continue(); + } else request.abort(); + }); + await page.goto(`${origin}/tmp_navara_canaries.html`); + await page.evaluate(async () => { + // Use Vite's resolved package URLs so the adapter and fixture share classes. + const imports = await ( + await fetch('/src/globe/navara/internals.js') + ).text(); + const sdkUrl = imports.match( + /from "([^"]*\/three\/dist\/index.js[^\"]*)"/, + )[1]; + const optionsSource = await ( + await fetch('/src/globe/navara/viewOptions.js') + ).text(); + const threeUrl = optionsSource.match(/from "([^"]+)"/)[1]; + const [sdk, three, internals, options] = await Promise.all([ + import(sdkUrl), + import(threeUrl), + import('/src/globe/navara/internals.js'), + import('/src/globe/navara/viewOptions.js'), + ]); + const container = document.getElementById('globe'); + const canvas = document.createElement('canvas'); + container.appendChild(canvas); + canvas.getContext('webgl2', options.NAVARA_CONTEXT_ATTRIBUTES); + const view = new sdk.default( + options.navaraViewOptions({ container, canvas }), + ); + await view.init(); + internals.navaraHooks(view); + view.lit = false; + view.setCamera({ + lng: 0, + lat: 0, + height: 18_000_000, + heading: 0, + pitch: -90, + roll: 0, + }); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const until = async (predicate, timeout = 5000) => { + const start = performance.now(); + while (!predicate()) { + if (performance.now() - start > timeout) + throw new Error('Fixture did not become ready'); + await wait(20); + } + }; + const frame = () => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + view.off('postRender', done); + reject(new Error('No rendered frame within five seconds')); + }, 5000); + const done = () => { + clearTimeout(timer); + view.off('postRender', done); + resolve(); + }; + view.on('postRender', done); + view.forceUpdate(); + }); + let ctx; + class Probe extends sdk.MeshDesc { + createMesh() { + ctx = this.ctx; + return new three.Group(); + } + } + view.registerMesh('canaryProbe', Probe); + const probe = view.addMesh({ canaryProbe: {} }); + await frame(); + const polygon = { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-8, -8], + [8, -8], + [8, 8], + [-8, 8], + [-8, -8], + ], + ], + }, + }; + window.canary = { + sdk, + three, + internals, + view, + canvas, + ctx, + probe, + wait, + until, + frame, + polygon, + }; + }); + + async function check(name, remove, run) { + try { + const evidence = await page.evaluate(run); + assert.equal( + typeof evidence?.present, + 'boolean', + 'Fixture returned no verdict', + ); + if (evidence.present) { + console.log(`PASS ${name}: bug confirmed; ${JSON.stringify(evidence)}`); + results.confirmed++; + } else { + console.error( + `BUG GONE ${name}: bug absent with fixture controls satisfied; review/delete ${remove}. ${JSON.stringify(evidence)}`, + ); + results.gone++; + } + } catch (error) { + console.error( + `FIXTURE ERROR ${name}: ${error.message}; no conclusion about the bug; keep the workaround`, + ); + results.fixtureErrors++; + } + } + + await check( + 'batch-ids-retained', + 'src/globe/navara/movers/batchIds.js and its pool wiring', + async () => { + const { sdk, three, ctx, frame } = window.canary; + const batches = []; + for (let round = 0; round < 3; round++) { + const mesh = new three.InstancedMesh( + new three.PlaneGeometry(), + new three.MeshBasicMaterial(), + 8, + ); + const wrapper = new sdk.PickableInstancedMeshWrapper(mesh, 8, ctx); + ctx.registerPickableMesh('canary-batch', wrapper); + batches.push([...wrapper.batchIds]); + ctx.unregisterPickableMesh('canary-batch'); + mesh.geometry.dispose(); + mesh.material.dispose(); + await frame(); + } + const ids = batches.flat(); + if ( + batches.some( + (batch) => + batch.length !== 8 || + batch.some((id) => !Number.isInteger(id) || id <= 0) || + new Set(batch).size !== batch.length, + ) + ) + throw new Error('Fixture did not allocate eight valid IDs per batch'); + return { + present: new Set(ids).size === ids.length, + batches, + }; + }, + ); + + await check( + 'deleted-vector-events-retained', + 'src/globe/navara/featureQueue.js and dropOrphanFeatureChanges wiring', + async () => { + const { view, internals, polygon, frame, wait } = window.canary; + for (let attempt = 0; attempt < 12; attempt++) { + const source = view.addSource({ type: 'geojson', data: polygon }); + const layer = view.addLayer({ + type: 'vector', + source, + polygon: { color: 0xff0000 }, + }); + await wait(30 + attempt * 10); + layer.update({ type: 'vector', source, polygon: { color: 0x00ff00 } }); + layer.delete(); + source.delete(); + await wait(100); + } + const orphaned = () => + internals + .featureChangeQueue(view) + .events.filter( + (event) => !view.findLayerById(String(event.layer_id)), + ); + const before = orphaned().length; + await frame(); + await wait(500); + await frame(); + const after = orphaned().length; + internals.navaraHooks(view).dropOrphanFeatureChanges(); + if (before === 0) + throw new Error('Fixture did not produce orphaned vector events'); + return { present: after > 0, before, after }; + }, + ); + + await check( + 'idle-before-raster-arrives', + 'maps.js SETTLE_MS/SETTLE_EVERY_MS redraw timer', + async () => { + const { view, internals, until } = window.canary; + await until(() => internals.isIdle(view)); + let idle = false; + const onIdle = () => { + idle = true; + }; + view.on('idle', onIdle); + const source = view.addSource({ + type: 'raster-tile', + url: `${location.origin}/tmp_navara_canaries_tile/{z}/{x}/{y}.png`, + maxZoom: 1, + }); + const layer = view.addLayer({ type: 'raster', source }); + view.forceUpdate(); + await until(() => idle); + view.off('idle', onIdle); + window.canary.raster = { source, layer }; + const pending = await window.pendingTileCount(); + if (pending === 0) + throw new Error('Fixture did not hold any raster requests'); + return { + present: idle && pending > 0, + idle, + pending, + }; + }, + ); + holdTiles = false; + for (const request of pendingTiles) await request.respond(tile); + if (pendingTiles.length) { + await page.waitForFunction(() => + performance + .getEntriesByType('resource') + .some( + (entry) => + entry.name.includes('/tmp_navara_canaries_tile/') && + entry.responseEnd > 0, + ), + ); + } + await page.evaluate(() => { + window.canary.raster?.layer.delete(); + window.canary.raster?.source.delete(); + }); + + await page.evaluate(() => window.canary.view.dispose()); +} catch (error) { + console.error( + `FIXTURE ERROR browser session: ${error.message}; no conclusion about the bugs; keep the workarounds`, + ); + results.fixtureErrors++; +} finally { + if (browser) await closeBrowser(browser); + await rm(profile, { recursive: true, force: true }); + console.log( + `${results.confirmed} bugs confirmed, ${results.gone} bugs gone, ${results.fixtureErrors} fixture errors`, + ); + process.exitCode = results.gone === 0 && results.fixtureErrors === 0 ? 0 : 1; +} diff --git a/src/globe/navara/WORKAROUNDS.md b/src/globe/navara/WORKAROUNDS.md new file mode 100644 index 000000000..3ca605b6b --- /dev/null +++ b/src/globe/navara/WORKAROUNDS.md @@ -0,0 +1,80 @@ +# Navara dependency workarounds + +`@navaramap/three` and its default plugin are pinned to **0.2.1**. +Locations below are repository paths; a unit test of our replacement is not +an upstream canary. `PASS` means the package bug is confirmed. `BUG GONE` +means the fixture controls passed and the bug is absent; only this outcome +names a removal target. `FIXTURE ERROR` means the check could not establish a +verdict, so keep the workaround. The script exits 0 only when every active +canary confirms its bug; either other outcome exits non-zero. + +Run the browser canaries against a keyless Vite dev server on port 4300: + +```sh +GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= OPENAI_API_KEY= FIRMS_MAP_KEY= MAPILLARY_CLIENT_TOKEN= TOMTOM_API_KEY= GEMINI_API_KEY= npx vite --port 4300 --strictPort +node scripts/qa-navara-canaries.mjs http://localhost:4300 +``` + +Set `PUPPETEER_EXECUTABLE_PATH` to the installed Chrome executable if needed. +The script uses a bare real Navara view so adapter cleanup, redraw timers and +pick validation cannot hide the defects. All network requests outside the dev +origin are blocked; raster responses are local fixtures. A browser run confirmed +the batch-ID, deleted-vector-event and raster-idle bugs in 0.2.1. These are the +three active canaries; expected output is three `PASS` lines followed by +`3 bugs confirmed, 0 bugs gone, 0 fixture errors` (exit 0). + +There is no initial-hide canary: its shared readiness timeout did not identify +whether `featureCreated` or a shown/hidden pixel control stalled. The installed +SDK supports the inline GeoJSON Feature, vector layer description and event, +but the event follows asynchronous geometry creation. The fixture hides the +layer immediately, before creation is observed. Code inspection cannot tell +which wait failed or why; a browser trace is needed before restoring the check. +There is no rapid-click canary: the timing-dependent stale pick was not +reproducible headless. Correct IDs from that run do not establish that the bug +is fixed. Neither excluded check is a signal to remove a workaround. + +| Location | Package defect or missing API | Seen in | Detection | Remove or replace when fixed | +| --- | --- | --- | --- | --- | +| `src/globe/navara/internals.js:71` | `animation=false` still updates the tile tree on every RAF; no suspend/resume API. | 0.2.1 | No canary: needs an upstream update-loop counter, not just rendered-frame counts. | Replace `_startMainLoop` / `_renderer.setAnimationLoop` access with suspend/resume. | +| `src/globe/navara/internals.js:86` | `ModelMesh._releaseGeometryArrays` drops CPU positions and indices after upload, preventing tile ray queries. | 0.2.1 | No canary: needs a loaded synthetic 3D tile and an upload; surface unit tests exercise retained geometry only. | Remove the prototype patch when geometry retention or native ray/height queries are available. | +| `src/globe/navara/internals.js:120` | No public enumeration of loaded 3D tile roots. | 0.2.1 | No canary: API gap; startup guard checks `_scenes.mrt.children`. | Replace `tileRoots` private scene traversal with the public query. | +| `src/globe/navara/internals.js:131` | Idle is an event edge, with no public current-state getter. | 0.2.1 | No canary: API gap; startup guard checks boolean `_isIdle`. | Replace `isIdle` with a public idle/tiles-loaded getter. | +| `src/globe/navara/internals.js:165`, `src/globe/navara/look.js:276` | No independent atmosphere exposure; sky/cloud radiance needs scaling without brightening unlit content. Uniform vectors are shared and materials may be rebuilt. | 0.2.1 | No canary: appearance needs a controlled daylight pixel reference. Startup checks sky/stars `ref.raw.material.uniforms`, aerial `rawEffect.uniforms`, and clouds `rawEffect.cloudsPass.currentMaterial.uniforms`; reads also validate rebuilt materials. | Remove `forEachLuminanceUniform`, the scaler and shared-vector bookkeeping when independent exposure is supported. | +| `src/globe/navara/internals.js:209`, `src/globe/navara/cascadedLightCopy.js:49` | Transparent-pass copies clone cascaded shadow lights each frame. | 0.2.1 | `cascadedLightCopy.test.mjs`: “Navara 0.2.1's copy clones new lights every call (the leak)” uses the real CSM package. | Remove `patchCascadedLightCopy`, `keepCascadedLightIds`, and their startup call. | +| `src/globe/navara/internals.js:224`, `src/globe/navara/featureQueue.js:15` | Late changes for deleted vector layers remain queued and prevent idle. | 0.2.1 | `deleted-vector-events-retained` (browser, confirmed); startup checks `_eventManager.stacks.renderable_feature_changed` and `removeAt`. | Remove queue access, `dropOrphanFeatureChanges`, and its pre-update hook. | +| `src/globe/navara/internals.js:257` | No public ordered pass-name list for diagnostics. | 0.2.1 | No canary: API gap; startup checks `view.viewContext` queries and `renderPassOrchestrator.passMap.list`. A not-yet-created optional effect has no context. | Replace `pipelinePassNames` and the startup context probe with public diagnostics/descriptor queries. | +| `src/globe/navara/movers/batchIds.js:11` | Unregistering pickable wrappers does not recycle their global batch IDs. | 0.2.1 | `batch-ids-retained` (browser, confirmed) checks disjoint allocations after unregistering; this measures ID reuse, not WASM byte reclamation. | Remove `createBatchIdPool` and pool wiring in `movers/index.js`. | +| `src/globe/navara/ground/index.js:145` | The original native-vector path lost the first `show: false` while a new layer was being built. Current ground rendering uses its own pass, so the hide retry is already absent. | 0.2.1 (original path) | No canary: `initial-vector-hide-dropped` timed out before a verdict; the shared readiness error does not identify the failed wait. Requires browser diagnosis. | No current hide retry to delete. Do not remove the ground pass: it also implements draping and materials. | +| `src/globe/navara/maps.js:76` | Idle can be reported before a fetched raster tile arrives after a map switch; the map may keep a stale frame. | 0.2.1 | `idle-before-raster-arrives` (browser, confirmed) holds real raster requests across the idle event, then releases local PNG responses. | Remove the `SETTLE_MS` / `SETTLE_EVERY_MS` redraw timer from `changed`. | +| `src/globe/navara/maps.js:145` | Removing the final raster layer interrupts terrain loading at the current view. | 0.2.1 | No canary: needs terrain-loading continuity across final-layer removal; the raster idle fixture does not test terrain. | Remove the add-before-delete constraint and hidden base-raster retention under 3D tiles. | +| `src/globe/navara/maps.js:37`, `src/globe/navara/maps.js:209` | Quantized-mesh root URLs without the tile template can panic. | 0.2.1 | No canary: deliberately panicking WASM would invalidate the remaining view checks; no isolated terrain fixture here. | Use the upstream root-URL API instead of manually appending `{z}/{x}/{y}.terrain`. | +| `src/globe/navara/maps.js:65` | No ion imagery path. | 0.2.1 | No canary: explicit unsupported source; requires a real ion imagery endpoint. | Replace the ion-imagery rejection with native source support. | +| `src/globe/navara/maps.js:209` | No terrain-request authentication retry hook; an expired ion endpoint token cannot be refreshed after a tile 401. | 0.2.1 | No canary: no authenticated fixture or exposed request callback. This is an unresolved gap, not a working retry. | Add retry through the upstream hook; there is no compensating retry code to delete. | +| `src/globe/navara/maps.js:230` | Raster tile failures after source creation are not exposed with source/status information for map fallback. | 0.2.1 | No canary: no adapter fallback exists; successful layer construction is not proof of successful tiles. | Wire native errors into map fallback when the package exposes them; nothing currently compensates for this. | +| `src/globe/navara/index.js:52`, `src/globe/navara/viewOptions.js:19` | No `preserveDrawingBuffer` option; a default canvas also allocates antialiasing despite rendering through offscreen targets. | 0.2.1 | No canary: missing constructor options; `viewOptions.test.mjs` tests our chosen context attributes. | Replace pre-created WebGL2 context with equivalent native canvas/context options. | +| `src/globe/navara/index.js:48` | Default cache budget can retain excessive tile memory; a fixed total also includes screen-size render targets and coarsens HD views. | 0.2.1 | No canary: requires a navigation/resize memory soak. | Remove fixed-target tracking and `TILE_CACHE_ROOM` override if native budgeting preserves the desired tile allowance. | +| `src/globe/navara/index.js:289` | Disposing the view leaves shared postprocessing fullscreen geometry listeners holding renderer state. | 0.2.1 | No canary: requires renderer/heap retention measurements after repeated disposal. | Remove `Pass.fullscreenGeometry.dispose()` once upstream releases those listeners. | +| `src/globe/navara/frameDescriptor.js:4` | Auto-bound descriptor frame callbacks are not unregistered on deletion. | 0.2.1 | No canary: existing `frameDescriptor.test.mjs` uses a stub base class, not the real descriptor lifecycle. | Remove `withFrameUpdates` and use native descriptor updates. | +| `src/globe/navara/frameDescriptor.js:24` | Effect deletion removes the composer pass without disposing its GPU resources. | 0.2.1 | No canary: current tests cover the wrapper's disposal, not native resource ownership. | Remove `withPassDisposal` when native deletion disposes passes exactly once. | +| `src/globe/navara/postSamplingPass.js:8` | Every composer target is multisampled, including fullscreen effects that need one sample. | 0.2.1 | No canary: requires HD render-target/sample and pixel checks; unit tests use fake contexts. | Remove `GeometrySamplingPass` and its installation when native sampling separates geometry from effects. | +| `src/globe/navara/emptySceneRender.js:15` | Empty MRT/forward scenes still call render and trigger MSAA resolves. | 0.2.1 | No canary: requires instrumenting the real HD pipeline; unit tests cover the skip policy only. | Remove `skipEmptySceneRenders` when native passes skip safe empty renders. | +| `src/globe/navara/sceneDepth.js:60` | The transparent pass runs after effects without scene depth, drawing hidden meshes through terrain/tiles. | 0.2.1 | No canary: needs a controlled occluder/depth pixel scene. | Remove shader scene-depth injection when the native transparent target retains correct depth. | +| `src/globe/navara/meshes.js:71` | Transparent unlit colours still receive view exposure. | 0.2.1 | No canary: needs colour samples at multiple exposures; current mesh tests exercise adapter state. | Remove reciprocal exposure compensation when native display-space colours stay unchanged. | +| `src/globe/navara/groundFloor.js:59` | Camera zoom limits are geocentric distances, without terrain/3D-tile collision. | 0.2.1 | No canary: requires loaded relief plus actual wheel navigation. | Replace `createGroundFloor` and its engine wiring with native collision handling that preserves the contract. | +| `src/globe/navara/cameraModel.js:47` | Camera pitch clamps to ±89.99°, perturbing an exact authored pose when read back. | 0.2.1 | No canary: `cameraModel.test.mjs` simulates the clamp, not native round trips. | Remove pushed-pose tolerance once native pose readback is exact. | +| `src/globe/navara/cameraModel.js:51` | Flight easing has no sinusoidal-in-out preset. | 0.2.1 | No canary: requires sampling a native flight's time/position curve. | Replace the `quadInOut` approximation with the actual sine easing. | +| `src/globe/navara/input.js:193` | Camera controller options have a setter but no getter. | 0.2.1 | No canary: API gap; input unit tests verify retained/merged options. | Replace the local options mirror when options can be read and updated without losing prior settings. | +| `src/globe/navara/picking.js:53` | Quick clicks can receive the first gesture's GPU pick. | 0.2.1 | No canary: not reproducible headless. `rapid-click-first-pick` returned correct IDs for spaced and rapid clicks, which does not establish a fix for this timing-dependent bug. | Keep stale-answer CPU validation until rapid clicks reliably return the matching instance in the affected scenario. Preserve CPU paths needed for lines/drill picking. | +| `src/globe/navara/createNavaraEngine.js:255` | Depth reads immediately after a click can return the near plane. | 0.2.1 | No canary: requires a terrain/3D-tile depth scene and click timing; the two-quad pick fixture does not establish ground depth. | Replace the CPU ground ray with native depth picking when post-click depth is reliable. | +| `src/globe/navara/createNavaraEngine.js:169`, `src/globe/navara/ground/index.js:29` | Frame requests made during rendering are cleared at the end of the current frame. | 0.2.1 | No canary: current engine/ground unit tests emulate the flag reset. | Remove deferred re-request bookkeeping once native requests during a frame schedule the next one. | +| `src/globe/navara/viewOptions.js:175` | G-buffer normals for globe/3D tiles are zero, breaking atmosphere lighting. | 0.2.1 | No canary: needs actual G-buffer and lighting pixel checks; Google tiles are not used by these canaries. | Remove `useNormalBuffer: false` when native normals support the lighting path. | +| `src/globe/navara/viewOptions.js:191` | Cloud defaults override the low preset's shadow map size and cascade count. | 0.2.1 | No canary: requires inspecting the real cloud preset result; option tests only verify the override. | Remove explicit `shadowMapSize` / `shadowCascadeCount` once the low preset survives native defaults. | +| `src/globe/navara/viewOptions.js:93`, `src/globe/navara/look.js:172` | Cloud bases are ellipsoid-relative and do not clear local terrain. | 0.2.1 | No canary: needs sampled relief and cloud-base inspection at local scale. | Remove terrain-dependent cloud offsets if native cloud altitude can be terrain-relative. | +| `src/globe/navara/credits.js:13` | Content-based deduplication has no holder reference count. | 0.2.1 | No canary: current credit tests stub attribution; they do not test the plugin. | Remove local counts when native removal retains a credit until its last holder releases it. | +| `src/globe/navara/credits.js:28` | No separate persistent on-screen credit strip. | 0.2.1 | No canary: UI/API gap; opening the attribution card is only a partial substitute. | Replace forced `attribution.show()` with native on-screen credits. | +| `src/globe/navara/bloom.js:59` | Native selective bloom uses emissive/effect masks before the transparent pass; the contract needs whole-frame bloom after overlays. | 0.2.1 | No canary: capability gap; custom effect tests verify our implementation. | Replace the custom bloom descriptor with a native effect only if ordering and settings match. | + +Private-member checks run through `navaraHooks(view)` before engine construction +and fail with the member path and pinned version. The current recursive source +scan found no private access in `ground/`, `imagery/`, `lines/`, `movers/`, or +other adapter subfolders. No private-access moves were necessary. diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index 4c116d77f..0bd314a3d 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -10,6 +10,61 @@ import { dropOrphanFeatureChanges } from './featureQueue.js'; export const NAVARA_INTERNALS_VERSION = '0.2.1'; +function requireMember(name, valid) { + if (!valid) + throw new Error( + `Navara ${NAVARA_INTERNALS_VERSION}: required private member ${name} is missing or incompatible`, + ); +} + +/** Fail at startup, before an absent internal can leave tilesLoaded false forever. */ +export function assertNavaraInternals(view) { + requireMember('view._isIdle', typeof view?._isIdle === 'boolean'); + requireMember( + 'view._startMainLoop', + typeof view?._startMainLoop === 'function', + ); + requireMember( + 'view._renderer.setAnimationLoop', + typeof view?._renderer?.setAnimationLoop === 'function', + ); + for (const name of ['mrt', 'light']) + requireMember( + `view._scenes.${name}.children`, + Array.isArray(view?._scenes?.[name]?.children), + ); + requireMember( + 'ModelMesh.prototype._releaseGeometryArrays', + typeof ModelMesh.prototype._releaseGeometryArrays === 'function', + ); + requireMember( + 'view._eventManager.stacks.renderable_feature_changed', + Array.isArray(view?._eventManager?.stacks?.renderable_feature_changed), + ); + requireMember( + 'view._eventManager.removeAt', + typeof view?._eventManager?.removeAt === 'function', + ); + const ctx = view?.viewContext; + for (const name of ['findMesh', 'findEffect']) + requireMember( + `view.viewContext.${name}`, + typeof ctx?.[name] === 'function', + ); + pipelinePassNames(ctx); + const handles = {}; + for (const name of ['sky', 'stars', 'aerialPerspective', 'clouds']) { + const ref = + name === 'sky' || name === 'stars' + ? ctx.findMesh(name) + : ctx.findEffect(name); + if (!ref) continue; // This look does not use the descriptor. + requireMember(`${name}.ref.raw`, ref.raw != null); + handles[name] = { ref }; + } + forEachLuminanceUniform(handles, () => {}); +} + // Navara 0.2.1's animation=false still updates the tile tree every RAF. // Until ThreeView exposes suspend/resume, stop its underlying Three loop // while this view is parked and reinstall its normal loop when it returns. @@ -32,8 +87,10 @@ export function retainTileGeometry() { if (restoreRelease) return restoreRelease; const proto = ModelMesh.prototype; const release = proto._releaseGeometryArrays; - if (typeof release !== 'function') - throw new Error('ModelMesh._releaseGeometryArrays not found'); + requireMember( + 'ModelMesh.prototype._releaseGeometryArrays', + typeof release === 'function', + ); proto._releaseGeometryArrays = function releaseAllButPositions(mesh) { const geometry = mesh.geometry; const position = geometry?.attributes?.position; @@ -85,24 +142,13 @@ export function isIdle(view) { * (the clouds swap that material when their quality changes). * Calls `visit(key, uniform, scaled)` for each, scaled false for the stars. * A handle whose object is built but has lost a path fails loudly: it - * throws in the dev server and warns once in a build. Upstream ask: a sky + * throws before scaling. Upstream ask: a sky * luminance or atmosphere exposure that leaves unlit content alone. */ const LUMINANCE_UNIFORMS = Object.freeze([ 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE', 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE', ]); -const DEV = Boolean(import.meta.env?.DEV); -const reported = new Set(); - -function luminancePathLost(name) { - const message = `Navara ${NAVARA_INTERNALS_VERSION}: the ${name} luminance uniforms are not where internals.js expects them`; - if (DEV) throw new Error(message); - if (reported.has(name)) return; - reported.add(name); - console.warn(`[globe] ${message}; the atmosphere stays unscaled there`); -} - function visitLuminance(name, raw, uniforms, scaled, visit) { if (!raw) return; // not built yet const get = (key) => @@ -110,8 +156,7 @@ function visitLuminance(name, raw, uniforms, scaled, visit) { const sun = get(LUMINANCE_UNIFORMS[0]); const sky = get(LUMINANCE_UNIFORMS[1]); if (!sun?.value?.isVector3 || !sky?.value?.isVector3) { - luminancePathLost(name); - return; + requireMember(`${name} luminance uniforms`, false); } visit(LUMINANCE_UNIFORMS[0], sun, scaled); visit(LUMINANCE_UNIFORMS[1], sky, scaled); @@ -119,12 +164,24 @@ function visitLuminance(name, raw, uniforms, scaled, visit) { export function forEachLuminanceUniform(handles, visit) { const sky = handles.sky?.ref?.raw; - visitLuminance('sky', sky, sky?.material?.uniforms, true, visit); + visitLuminance( + 'sky.ref.raw.material.uniforms', + sky, + sky?.material?.uniforms, + true, + visit, + ); const stars = handles.stars?.ref?.raw; - visitLuminance('stars', stars, stars?.material?.uniforms, false, visit); + visitLuminance( + 'stars.ref.raw.material.uniforms', + stars, + stars?.material?.uniforms, + false, + visit, + ); const aerial = handles.aerialPerspective?.ref?.raw; visitLuminance( - 'aerial perspective', + 'aerialPerspective.ref.raw.rawEffect.uniforms', aerial, aerial?.rawEffect?.uniforms, true, @@ -132,7 +189,7 @@ export function forEachLuminanceUniform(handles, visit) { ); const clouds = handles.clouds?.ref?.raw; visitLuminance( - 'clouds', + 'clouds.ref.raw.rawEffect.cloudsPass.currentMaterial.uniforms', clouds, clouds?.rawEffect?.cloudsPass?.currentMaterial?.uniforms, true, @@ -177,24 +234,29 @@ export function featureChangeQueue(view) { } /** The hooks createNavaraEngine takes. */ -export const navaraHooks = (view) => ({ - retainTileGeometry, - tileRoots: () => tileRoots(view), - isIdle: () => isIdle(view), - dropOrphanFeatureChanges: () => - dropOrphanFeatureChanges( - featureChangeQueue(view), - (id) => view.findLayerById(id) != null, - ), -}); +export const navaraHooks = (view) => { + assertNavaraInternals(view); + return { + retainTileGeometry, + tileRoots: () => tileRoots(view), + isIdle: () => isIdle(view), + dropOrphanFeatureChanges: () => + dropOrphanFeatureChanges( + featureChangeQueue(view), + (id) => view.findLayerById(id) != null, + ), + }; +}; /** * QA diagnostics only: the names of the passes in the view's - * post-processing pipeline, in draw order, or null when the path is gone. + * post-processing pipeline, in draw order. * Private: ctx.renderPassOrchestrator.passMap.list ({ name, pass }). * Upstream ask: a public read of the pipeline order. */ export function pipelinePassNames(ctx) { + if (ctx == null) return null; // The optional effect has not been created. const list = ctx?.renderPassOrchestrator?.passMap?.list; - return Array.isArray(list) ? list.map((entry) => entry.name) : null; + requireMember('renderPassOrchestrator.passMap.list', Array.isArray(list)); + return list.map((entry) => entry.name); } diff --git a/src/globe/navara/internals.test.mjs b/src/globe/navara/internals.test.mjs index 7803e0c17..07a06d8ea 100644 --- a/src/globe/navara/internals.test.mjs +++ b/src/globe/navara/internals.test.mjs @@ -1,6 +1,21 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; +import { registerHooks } from 'node:module'; + +// The package's browser entry needs WebGL; only its prototype matters here. +const sdkStub = + 'data:text/javascript,export class ModelMesh { _releaseGeometryArrays() {} }'; +const loader = registerHooks({ + resolve(specifier, context, next) { + return specifier === '@navaramap/three' + ? { url: sdkStub, shortCircuit: true } + : next(specifier, context); + }, +}); +const { navaraHooks, forEachLuminanceUniform } = await import('./internals.js'); +loader.deregister(); +const { ModelMesh } = await import(sdkStub); const here = new URL('./', import.meta.url); const read = (name) => readFileSync(new URL(name, here), 'utf8'); @@ -21,7 +36,8 @@ test('Navara internals are pinned: package.json and internals.js name the same e }); // `x._member`, `x?._member` and `x['_member']`. -const PRIVATE_ACCESS = /\.\s*_[A-Za-z]|\[\s*['"`]_[A-Za-z]/; +const PRIVATE_ACCESS = + /\.\s*_[A-Za-z]|\[\s*['"`]_[A-Za-z]|(?:\.\s*|\[\s*['"`])(?:viewContext|renderPassOrchestrator|cloudsPass|currentMaterial)\b/; test('the private-member pattern catches dotted and bracketed access', () => { for (const code of [ @@ -29,14 +45,22 @@ test('the private-member pattern catches dotted and bracketed access', () => { 'view?._scenes', "view['_isIdle']", 'v[ "_x" ]', + 'view.viewContext', + "ctx['renderPassOrchestrator']", + 'clouds.rawEffect.cloudsPass.currentMaterial', ]) assert.ok(PRIVATE_ACCESS.test(code), code); - for (const code of ['view.isIdle', 'a_b.c', "map['key']"]) + for (const code of [ + 'view.isIdle', + 'a_b.c', + "map['key']", + 'aerial.ref.raw.rawEffect.blendMode.opacity', + ]) assert.ok(!PRIVATE_ACCESS.test(code), code); }); test('only internals.js reaches private Navara members', () => { - const offenders = readdirSync(here).filter( + const offenders = readdirSync(here, { recursive: true }).filter( (name) => name.endsWith('.js') && name !== 'internals.js' && @@ -45,6 +69,110 @@ test('only internals.js reaches private Navara members', () => { assert.deepEqual(offenders, []); }); +function stubView() { + return { + _isIdle: false, + _startMainLoop() {}, + _renderer: { setAnimationLoop() {} }, + _scenes: { mrt: { children: [] }, light: { children: [] } }, + _eventManager: { + stacks: { renderable_feature_changed: [] }, + removeAt() {}, + }, + viewContext: { + renderPassOrchestrator: { passMap: { list: [] } }, + findMesh() {}, + findEffect() {}, + }, + }; +} + +test('engine startup probes every required view internal', () => { + const view = stubView(); + const hooks = navaraHooks(view); + assert.equal(hooks.isIdle(), false); + view._isIdle = true; + assert.equal(hooks.isIdle(), true); + for (const path of [ + '_isIdle', + '_startMainLoop', + '_renderer.setAnimationLoop', + '_scenes.mrt.children', + '_scenes.light.children', + '_eventManager.stacks.renderable_feature_changed', + '_eventManager.removeAt', + 'viewContext.findMesh', + 'viewContext.findEffect', + 'viewContext.renderPassOrchestrator.passMap.list', + ]) { + for (const value of [undefined, null, 42]) { + const broken = stubView(); + const keys = path.split('.'); + const key = keys.pop(); + const parent = keys.reduce((object, name) => object[name], broken); + if (value === undefined) delete parent[key]; + else parent[key] = value; + assert.throws( + () => navaraHooks(broken), + (error) => { + assert.ok(error.message.includes('Navara 0.2.1')); + assert.ok( + error.message.includes( + path.replace( + 'viewContext.renderPassOrchestrator', + 'renderPassOrchestrator', + ), + ), + error.message, + ); + return true; + }, + ); + } + } +}); + +test('engine startup rejects a missing tile geometry release method', () => { + const release = ModelMesh.prototype._releaseGeometryArrays; + delete ModelMesh.prototype._releaseGeometryArrays; + try { + assert.throws( + () => navaraHooks(stubView()), + /Navara 0\.2\.1:.*ModelMesh\.prototype\._releaseGeometryArrays/, + ); + } finally { + ModelMesh.prototype._releaseGeometryArrays = release; + } +}); + +test('lazy atmosphere materials reject missing private uniforms in every build', () => { + for (const name of ['sky', 'stars', 'aerialPerspective', 'clouds']) { + assert.doesNotThrow(() => + forEachLuminanceUniform({ [name]: { ref: {} } }, () => {}), + ); + assert.throws( + () => forEachLuminanceUniform({ [name]: { ref: { raw: {} } } }, () => {}), + (error) => + error.message.includes('Navara 0.2.1') && + error.message.includes(`${name}.ref.raw.`), + ); + } +}); + +test('engine startup checks the active look materials before the first frame', () => { + for (const name of ['sky', 'stars', 'aerialPerspective', 'clouds']) { + const view = stubView(); + const find = name === 'sky' || name === 'stars' ? 'findMesh' : 'findEffect'; + view.viewContext[find] = (key) => (key === name ? { raw: {} } : undefined); + assert.throws( + () => navaraHooks(view), + (error) => + error.message.includes('Navara 0.2.1') && + error.message.includes(`${name}.ref.raw.`), + ); + } +}); + test('feature changes of a deleted vector layer leave the queue; live ones stay', async () => { const { dropOrphanFeatureChanges } = await import('./featureQueue.js'); const freed = [];