Merge branch 'german/refactor-e13-navara-workarounds' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 15:27:12 +04:00
commit 0953287c3d
4 files changed
+625 -35

No files matched your search

+320
View File
@@ -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: '<body style="margin:0"><div id="globe" style="width:800px;height:600px"></div></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;
}
+80
View File
@@ -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.
+94 -32
View File
@@ -10,6 +10,61 @@ import { dropOrphanFeatureChanges } from './featureQueue.js';
export const NAVARA_INTERNALS_VERSION = '0.2.1'; 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. // Navara 0.2.1's animation=false still updates the tile tree every RAF.
// Until ThreeView exposes suspend/resume, stop its underlying Three loop // Until ThreeView exposes suspend/resume, stop its underlying Three loop
// while this view is parked and reinstall its normal loop when it returns. // while this view is parked and reinstall its normal loop when it returns.
@@ -32,8 +87,10 @@ export function retainTileGeometry() {
if (restoreRelease) return restoreRelease; if (restoreRelease) return restoreRelease;
const proto = ModelMesh.prototype; const proto = ModelMesh.prototype;
const release = proto._releaseGeometryArrays; const release = proto._releaseGeometryArrays;
if (typeof release !== 'function') requireMember(
throw new Error('ModelMesh._releaseGeometryArrays not found'); 'ModelMesh.prototype._releaseGeometryArrays',
typeof release === 'function',
);
proto._releaseGeometryArrays = function releaseAllButPositions(mesh) { proto._releaseGeometryArrays = function releaseAllButPositions(mesh) {
const geometry = mesh.geometry; const geometry = mesh.geometry;
const position = geometry?.attributes?.position; const position = geometry?.attributes?.position;
@@ -85,24 +142,13 @@ export function isIdle(view) {
* (the clouds swap that material when their quality changes). * (the clouds swap that material when their quality changes).
* Calls `visit(key, uniform, scaled)` for each, scaled false for the stars. * 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 * 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. * luminance or atmosphere exposure that leaves unlit content alone.
*/ */
const LUMINANCE_UNIFORMS = Object.freeze([ const LUMINANCE_UNIFORMS = Object.freeze([
'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE', 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE',
'SKY_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) { function visitLuminance(name, raw, uniforms, scaled, visit) {
if (!raw) return; // not built yet if (!raw) return; // not built yet
const get = (key) => const get = (key) =>
@@ -110,8 +156,7 @@ function visitLuminance(name, raw, uniforms, scaled, visit) {
const sun = get(LUMINANCE_UNIFORMS[0]); const sun = get(LUMINANCE_UNIFORMS[0]);
const sky = get(LUMINANCE_UNIFORMS[1]); const sky = get(LUMINANCE_UNIFORMS[1]);
if (!sun?.value?.isVector3 || !sky?.value?.isVector3) { if (!sun?.value?.isVector3 || !sky?.value?.isVector3) {
luminancePathLost(name); requireMember(`${name} luminance uniforms`, false);
return;
} }
visit(LUMINANCE_UNIFORMS[0], sun, scaled); visit(LUMINANCE_UNIFORMS[0], sun, scaled);
visit(LUMINANCE_UNIFORMS[1], sky, scaled); visit(LUMINANCE_UNIFORMS[1], sky, scaled);
@@ -119,12 +164,24 @@ function visitLuminance(name, raw, uniforms, scaled, visit) {
export function forEachLuminanceUniform(handles, visit) { export function forEachLuminanceUniform(handles, visit) {
const sky = handles.sky?.ref?.raw; 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; 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; const aerial = handles.aerialPerspective?.ref?.raw;
visitLuminance( visitLuminance(
'aerial perspective', 'aerialPerspective.ref.raw.rawEffect.uniforms',
aerial, aerial,
aerial?.rawEffect?.uniforms, aerial?.rawEffect?.uniforms,
true, true,
@@ -132,7 +189,7 @@ export function forEachLuminanceUniform(handles, visit) {
); );
const clouds = handles.clouds?.ref?.raw; const clouds = handles.clouds?.ref?.raw;
visitLuminance( visitLuminance(
'clouds', 'clouds.ref.raw.rawEffect.cloudsPass.currentMaterial.uniforms',
clouds, clouds,
clouds?.rawEffect?.cloudsPass?.currentMaterial?.uniforms, clouds?.rawEffect?.cloudsPass?.currentMaterial?.uniforms,
true, true,
@@ -177,24 +234,29 @@ export function featureChangeQueue(view) {
} }
/** The hooks createNavaraEngine takes. */ /** The hooks createNavaraEngine takes. */
export const navaraHooks = (view) => ({ export const navaraHooks = (view) => {
retainTileGeometry, assertNavaraInternals(view);
tileRoots: () => tileRoots(view), return {
isIdle: () => isIdle(view), retainTileGeometry,
dropOrphanFeatureChanges: () => tileRoots: () => tileRoots(view),
dropOrphanFeatureChanges( isIdle: () => isIdle(view),
featureChangeQueue(view), dropOrphanFeatureChanges: () =>
(id) => view.findLayerById(id) != null, dropOrphanFeatureChanges(
), featureChangeQueue(view),
}); (id) => view.findLayerById(id) != null,
),
};
};
/** /**
* QA diagnostics only: the names of the passes in the view's * 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 }). * Private: ctx.renderPassOrchestrator.passMap.list ({ name, pass }).
* Upstream ask: a public read of the pipeline order. * Upstream ask: a public read of the pipeline order.
*/ */
export function pipelinePassNames(ctx) { export function pipelinePassNames(ctx) {
if (ctx == null) return null; // The optional effect has not been created.
const list = ctx?.renderPassOrchestrator?.passMap?.list; 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);
} }
+131 -3
View File
@@ -1,6 +1,21 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs'; 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 here = new URL('./', import.meta.url);
const read = (name) => readFileSync(new URL(name, here), 'utf8'); 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']`. // `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', () => { test('the private-member pattern catches dotted and bracketed access', () => {
for (const code of [ for (const code of [
@@ -29,14 +45,22 @@ test('the private-member pattern catches dotted and bracketed access', () => {
'view?._scenes', 'view?._scenes',
"view['_isIdle']", "view['_isIdle']",
'v[ "_x" ]', 'v[ "_x" ]',
'view.viewContext',
"ctx['renderPassOrchestrator']",
'clouds.rawEffect.cloudsPass.currentMaterial',
]) ])
assert.ok(PRIVATE_ACCESS.test(code), code); 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); assert.ok(!PRIVATE_ACCESS.test(code), code);
}); });
test('only internals.js reaches private Navara members', () => { test('only internals.js reaches private Navara members', () => {
const offenders = readdirSync(here).filter( const offenders = readdirSync(here, { recursive: true }).filter(
(name) => (name) =>
name.endsWith('.js') && name.endsWith('.js') &&
name !== 'internals.js' && name !== 'internals.js' &&
@@ -45,6 +69,110 @@ test('only internals.js reaches private Navara members', () => {
assert.deepEqual(offenders, []); 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 () => { test('feature changes of a deleted vector layer leave the queue; live ones stay', async () => {
const { dropOrphanFeatureChanges } = await import('./featureQueue.js'); const { dropOrphanFeatureChanges } = await import('./featureQueue.js');
const freed = []; const freed = [];