mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Merge branch 'german/refactor-e13-navara-workarounds' into german/navara-refactor
This commit is contained in:
commit
0953287c3d
4 files changed
+625
-35
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in new issue
Block a user