diff --git a/scripts/contract-mutants.test.mjs b/scripts/contract-mutants.test.mjs index d4c06183c..e73ce1b80 100644 --- a/scripts/contract-mutants.test.mjs +++ b/scripts/contract-mutants.test.mjs @@ -375,6 +375,20 @@ const mutants = [ ); }, ], + [ + 'released photoreal tiles still answer surface queries', + 'surface.sampleHeightDetailed: leaving photoreal', + (e) => { + replace(e.maps, 'setPhotorealTiles', (set) => async (source) => { + await set(source); + if (source === null) + e.fake.script('sampleHeightDetailed', () => ({ + height: 50, + source: 'tiles', + })); + }); + }, + ], ]; for (const [name, killedBy, mutate] of mutants) { @@ -400,6 +414,28 @@ for (const [name, killedBy, mutate] of mutants) { }); } +test('released tile surface check runs without a tileset fixture', async () => { + const engine = createFakeEngine(); + try { + const check = createContractSuite(engine, { + ...fakeContractOptions(engine), + fixtures: null, + mapSources: null, + }).find(({ name }) => + name.startsWith('surface.sampleHeightDetailed: leaving photoreal'), + ); + assert.ok(check); + await check.run(); + engine.fake.script('sampleHeightDetailed', () => ({ + height: 50, + source: 'tiles', + })); + await assert.rejects(check.run(), /released photoreal tiles/); + } finally { + engine.destroy(); + } +}); + test('tiles-only ground pixels on terrain are detected without a tileset fixture or a picking defect', async () => { for (const drawsOnTerrain of [false, true]) { const engine = createFakeEngine(); diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index 87eef5aa3..df938a163 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -204,7 +204,7 @@ test('the contract suite movers and lines checks pass on the Cesium engine (no p name, ), ); - assert.equal(checks.length, 10); + assert.equal(checks.length, 11); try { for (const { name, run } of checks) await t.test(name, run); } finally { diff --git a/src/globe/navara/WORKAROUNDS.md b/src/globe/navara/WORKAROUNDS.md index dd22430eb..ed4780bb2 100644 --- a/src/globe/navara/WORKAROUNDS.md +++ b/src/globe/navara/WORKAROUNDS.md @@ -38,6 +38,7 @@ is fixed. Neither excluded check is a signal to remove a workaround. | `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` (`releaseDeletedTileGeometry`), `src/globe/navara/maps.js` (`retire`) | With a cache budget, deleting a 3D-tiles layer and source emits no removals for cached models. They remain in the render scene and answer CPU surface queries. Track feature creation and dispose each model on layer deletion, including late decodes. Private accesses: `FeatureEvaluator.obj`, `view._meshes`. | 0.2.1 | `maps-release.test.mjs` loads the real WASM fixture: four model removals without a budget, zero with the production budget, although source deletion succeeds. Adapter regressions check roots, retained geometry, queries, credits, replacements and late decodes for fixture and Google descriptions. | Remove `releaseDeletedTileGeometry` and its startup call when native layer deletion evicts cached models; keep the adapter regressions. | | `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. | diff --git a/src/globe/navara/ground/surface.test.mjs b/src/globe/navara/ground/surface.test.mjs index af81b5b11..49149233d 100644 --- a/src/globe/navara/ground/surface.test.mjs +++ b/src/globe/navara/ground/surface.test.mjs @@ -18,6 +18,14 @@ for (const [name, depth, surface, globe, expected] of [ ['only globe depth after the scene depth is cleared', 0.6, 1, 0.6, 1], ['only tile depth', 0.4, 0.4, 1, 2], ['tiles in front of the globe', 0.4, 0.4, 0.6, 2], + ['fixture tiles without globe depth', 0.5036, 0.5036, 1, 2], + [ + 'retained fixture tiles after photoreal is cleared', + 0.5036, + 0.5036, + 0.507, + 2, + ], ['globe in front of tiles', 0.4, 0.6, 0.4, 1], ['globe retained in the scene depth', 0.6, 0.6, 0.6, 1], ['visible globe with a different scene depth', 0.6, 0.4, 0.6, 1], diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index 7003303ad..26a3e6b71 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -33,6 +33,7 @@ export function assertNavaraInternals(view) { `view._scenes.${name}.children`, Array.isArray(view?._scenes?.[name]?.children), ); + requireMember('view._meshes', view?._meshes instanceof Map); requireMember( 'ModelMesh.prototype._releaseGeometryArrays', typeof ModelMesh.prototype._releaseGeometryArrays === 'function', @@ -123,6 +124,65 @@ export function tileRoots(view) { ); } +// With a cache budget, Navara 0.2.1 deletes a 3D-tiles source without +// emitting removals for its cached models. Tie each model to its layer so +// deleting that layer also releases the scene objects and CPU geometry. +// Private: FeatureEvaluator.obj and view._meshes. Upstream ask: deleting a +// layer must evict its models regardless of the tile-cache budget. +function releaseDeletedTileGeometry(view) { + const byLayer = new WeakMap(); + view.on('layer', (event, layerId, { evaluator } = {}) => { + if (event !== 'featureCreated') return; + const root = evaluator?.obj; + if (!(root instanceof ModelMesh)) return; + const layer = view.findLayerById(layerId); + const release = (root) => { + root.traverse((object) => { + const geometry = object.geometry; + if (geometry) { + geometry.dispose(); + for (const name of Object.keys(geometry.attributes)) + geometry.deleteAttribute(name); + geometry.setIndex(null); + geometry.boundingBox = geometry.boundingSphere = null; + } + const materials = Array.isArray(object.material) + ? object.material + : [object.material]; + for (const material of materials) material?.dispose(); + object.customDepthMaterial?.dispose(); + }); + root.dispatchEvent({ type: 'removedFromWorld' }); + root.clear(); + root.removeFromParent(); + }; + // A decode already in flight can finish after the layer was deleted. + if (!layer) { + for (const [id, mesh] of view._meshes) + if (mesh === root) view._meshes.delete(id); + release(root); + return; + } + let roots = byLayer.get(layer); + if (!roots) { + roots = new Set(); + byLayer.set(layer, roots); + layer.once('deleted', () => { + for (const [id, mesh] of view._meshes) + if (roots.has(mesh)) view._meshes.delete(id); + for (const root of roots) release(root); + roots.clear(); + }); + } + roots.add(root); + const removed = () => { + roots.delete(root); + root.removeEventListener('removedFromWorld', removed); + }; + root.addEventListener('removedFromWorld', removed); + }); +} + /** * Whether Navara has stopped updating (tiles loaded, camera still). * Private: view._isIdle, which the public `idle` event reports only as an @@ -249,6 +309,7 @@ export function setCameraFovy(view, radians) { /** The hooks createNavaraEngine takes. */ export const navaraHooks = (view) => { assertNavaraInternals(view); + releaseDeletedTileGeometry(view); return { retainTileGeometry, setCameraFovy: (radians) => setCameraFovy(view, radians), diff --git a/src/globe/navara/internals.test.mjs b/src/globe/navara/internals.test.mjs index 8093481d8..3b3557a1e 100644 --- a/src/globe/navara/internals.test.mjs +++ b/src/globe/navara/internals.test.mjs @@ -72,10 +72,12 @@ test('only internals.js reaches private Navara members', () => { function stubView() { return { + on() {}, _isIdle: false, _startMainLoop() {}, _renderer: { setAnimationLoop() {} }, _scenes: { mrt: { children: [] }, light: { children: [] } }, + _meshes: new Map(), _eventManager: { stacks: { renderable_feature_changed: [] }, removeAt() {}, @@ -100,6 +102,7 @@ test('engine startup probes every required view internal', () => { '_renderer.setAnimationLoop', '_scenes.mrt.children', '_scenes.light.children', + '_meshes', '_eventManager.stacks.renderable_feature_changed', '_eventManager.removeAt', 'viewContext.findMesh', diff --git a/src/globe/navara/maps-release.test.mjs b/src/globe/navara/maps-release.test.mjs new file mode 100644 index 000000000..2d69a715d --- /dev/null +++ b/src/globe/navara/maps-release.test.mjs @@ -0,0 +1,311 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { registerHooks } from 'node:module'; +import { BoxGeometry, Mesh, MeshBasicMaterial, Scene } from 'three'; +import { cartesian3, cartographic } from '../../geo/index.js'; +import { MapSourceController } from '../../maps/controller.js'; +import { createFakeEngine } from '../testing/fakeEngine.js'; +import { createNavaraMaps } from './maps.js'; +import { createNavaraSurface } from './surface.js'; + +const sdkStub = `data:text/javascript,${encodeURIComponent(` + import { Group } from ${JSON.stringify(import.meta.resolve('three'))}; + export class ModelMesh extends Group { _releaseGeometryArrays() {} } +`)}`; +const loader = registerHooks({ + resolve(specifier, context, next) { + return specifier === '@navaramap/three' + ? { url: sdkStub, shortCircuit: true } + : next(specifier, context); + }, +}); +const { navaraHooks } = await import('./internals.js'); +loader.deregister(); +const { ModelMesh } = await import(sdkStub); + +function setup(core) { + let sequence = 0; + const sources = new Set(); + const layers = new Map(); + const credits = new Set(); + const view = Object.assign(new EventEmitter(), { + _isIdle: true, + _startMainLoop() {}, + _renderer: { setAnimationLoop() {} }, + _scenes: { mrt: new Scene(), light: new Scene() }, + _meshes: new Map(), + _eventManager: { + stacks: { renderable_feature_changed: [] }, + removeAt() {}, + }, + viewContext: { + renderPassOrchestrator: { passMap: { list: [] } }, + findMesh() {}, + findEffect() {}, + }, + addSource(description) { + const source = { + id: core?.addSource(description), + description, + delete() { + assert.ok(![...layers.values()].some((l) => l.source === source)); + if (core) assert.equal(core.deleteSource(source.id), true); + return sources.delete(source); + }, + }; + sources.add(source); + return source; + }, + addLayer(description) { + const layer = Object.assign(new EventEmitter(), description, { + id: + core?.addLayer({ ...description, source: description.source.id }) ?? + String(sequence++), + update(next) { + Object.assign(layer, next); + }, + delete() { + core?.deleteLayer(layer.id); + layers.delete(layer.id); + layer.emit('deleted'); + // The budgeted native cache emits no model removals here. + }, + }); + layers.set(layer.id, layer); + return layer; + }, + findLayerById: (id) => layers.get(id), + forceUpdate() {}, + sampleTerrainHeight: () => 0, + attribution: { + add: (items) => items.forEach((item) => credits.add(item)), + remove: (items) => items.forEach((item) => credits.delete(item)), + show() {}, + }, + }); + const hooks = navaraHooks(view); + const adapter = createNavaraMaps(view, { setInterval: () => null }); + const { surface } = createNavaraSurface(view, hooks); + function load(layer, visible = true) { + const root = new ModelMesh(); + const mesh = new Mesh( + new BoxGeometry(100, 100, 100), + new MeshBasicMaterial(), + ); + root.position.x = 6378137; + root.visible = visible; + root.add(mesh); + root.updateMatrixWorld(true); + view._scenes.mrt.add(root); + view._meshes.set(root.id, root); + let disposed = 0; + mesh.geometry.addEventListener('dispose', () => disposed++); + view.emit('layer', 'featureCreated', layer.id, { + evaluator: { obj: root }, + }); + return { root, mesh, disposed: () => disposed }; + } + return { view, hooks, ...adapter, surface, sources, layers, credits, load }; +} + +const place = cartographic.fromDegrees(0, 0); +const fixture = { tilesetUrl: '/__qa/tiles3d/tileset.json' }; +const google = { googleApiKey: 'test-key' }; + +for (const [name, source] of [ + ['fixture', fixture], + ['Google', google], +]) { + for (const action of ['clear', 'release', 'stack', 'destroy']) { + test(`${name} photoreal ${action} removes visible and hidden roots, geometry, source and credit`, async (t) => { + globalThis.location = { origin: 'http://localhost' }; + t.after(() => { + delete globalThis.location; + }); + const state = setup(); + const { maps, surface, hooks, layers, sources, credits } = state; + t.after(state.destroy); + const engine = createFakeEngine({ kind: 'navara' }); + engine.maps = maps; + t.after(() => engine.destroy()); + const controller = new MapSourceController({ + engine, + requestRender() {}, + registry: { + defaultId: 'osm', + sources: [ + { descriptor: { id: 'osm', kind: 'osm' }, imagery() {} }, + { descriptor: { id: 'photoreal', kind: 'photoreal' }, ...source }, + ], + }, + }); + t.after(() => controller.destroy()); + await controller.setStack('osm'); + await controller.setStack('photoreal'); + const layer = [...layers.values()].find((l) => l.type === '3d-tiles'); + assert.ok(layer); + const tiles = [state.load(layer), state.load(layer, false)]; + assert.equal(surface.sampleHeightDetailed(place).source, 'tiles'); + assert.equal(hooks.tileRoots().length, 2); + if (action === 'clear') await maps.setPhotorealTiles(null); + if (action === 'release') + maps.releasePhotorealTiles(maps.current().photoreal); + if (action === 'stack') await controller.setStack('osm'); + if (action === 'destroy') state.destroy(); + assert.deepEqual(hooks.tileRoots(), []); + assert.equal(state.view._meshes.size, 0); + assert.ok(!sources.has(layer.source)); + assert.ok(!layers.has(layer.id)); + assert.ok( + ![...credits].some((credit) => credit.creditLayerId === layer.id), + ); + assert.equal(maps.photorealActive(), false); + assert.deepEqual(surface.sampleHeightDetailed(place), { + height: 0, + source: 'terrain', + }); + assert.equal(surface.sampleHeight(place), 0); + assert.equal( + cartographic.fromCartesian( + surface.clampToHeight(cartesian3.fromDegrees(0, 0, 500)), + ).height, + 0, + ); + assert.equal( + surface.raycast({ + origin: { x: 6379000, y: 0, z: 0 }, + direction: { x: -1, y: 0, z: 0 }, + far: 1000, + }), + undefined, + ); + for (const { root, mesh, disposed } of tiles) { + assert.equal(root.parent, null); + assert.equal(root.children.length, 0); + assert.equal(mesh.geometry.attributes.position, undefined); + assert.equal(mesh.geometry.index, null); + assert.equal(disposed(), 1); + } + if (action !== 'destroy') { + const base = [...layers.values()].find((l) => l.type === 'raster'); + assert.equal(base.raster.show, true); + assert.equal(maps.current().baseImagery.descriptor.kind, 'osm'); + } + }); + } +} + +test('replacement and a late tile decode cannot retain the old layer or remove the new one', async (t) => { + const state = setup(); + t.after(state.destroy); + await state.maps.setPhotorealTiles(google); + const oldLayer = [...state.layers.values()][0]; + const old = state.load(oldLayer); + await state.maps.setPhotorealTiles({ googleApiKey: 'replacement-key' }); + const newLayer = [...state.layers.values()][0]; + const current = state.load(newLayer); + const late = state.load(oldLayer); + state.maps.releasePhotorealTiles(google); + assert.deepEqual(state.hooks.tileRoots(), [current.root]); + assert.equal(state.view._meshes.size, 1); + assert.equal(old.disposed(), 1); + assert.equal(late.disposed(), 1); + assert.equal(current.disposed(), 0); + assert.equal(state.surface.sampleHeightDetailed(place).source, 'tiles'); +}); + +test('native tile eviction removes the root from layer cleanup', async (t) => { + const state = setup(); + t.after(state.destroy); + await state.maps.setPhotorealTiles(google); + const layer = [...state.layers.values()][0]; + const { root, disposed } = state.load(layer); + assert.equal(layer.listenerCount('deleted'), 1); + root.dispatchEvent({ type: 'removedFromWorld' }); + root.removeFromParent(); + state.view._meshes.delete(root.id); + await state.maps.setPhotorealTiles(null); + assert.equal(layer.listenerCount('deleted'), 0); + assert.equal(disposed(), 0, 'native eviction already owns disposal'); +}); + +test('native budgeted source deletion leaves no adapter tile roots', async (t) => { + const { Core, initSync } = await import('@navaramap/engine'); + initSync({ + module: readFileSync( + new URL( + '../../../node_modules/@navaramap/engine/navara_wasm_bg.wasm', + import.meta.url, + ), + ), + }); + globalThis.location = { origin: 'http://localhost' }; + t.after(() => { + delete globalThis.location; + }); + for (const cacheBytes of [undefined, 768 * 1024 * 1024]) { + const core = new Core('tiles-release-test'); + core.start(); + core.resize(1280, 800, 1); + core.setCacheBytes(cacheBytes); + core.changeCamera(new Float64Array([-97.7431, 30.2672, 1100]), -90, 0, 0); + const state = setup(core); + let added = 0; + let removed = 0; + try { + await state.maps.setPhotorealTiles(fixture); + const layer = [...state.layers.values()][0]; + for (let frame = 0; frame < 120; frame++) { + if (frame === 60) { + assert.equal(added, 4, 'all native fixture models loaded'); + await state.maps.setPhotorealTiles(null); + assert.equal(core.getSourceDescription(layer.source.id), undefined); + assert.deepEqual(state.hooks.tileRoots(), []); + assert.equal(state.view._meshes.size, 0); + assert.equal( + state.surface.sampleHeightDetailed(place).source, + 'terrain', + ); + } + core.update(frame * 16); + const events = core.readEvents(); + if (!events) continue; + for (const event of events.take_data_requested()) { + const name = new URL(event.url).pathname.split('/').at(-1); + const bytes = readFileSync( + new URL( + `../../../scripts/fixtures/tiles3d/${name}`, + import.meta.url, + ), + ); + core.setBufferU8(event.handle, event.bits, bytes.length, (buffer) => + buffer.set(bytes), + ); + event.free(); + } + for (const event of events.take_renderable_feature_added()) { + state.load(layer); + added++; + core.markFeatureIsRendered('model', event.bits); + event.free(); + } + for (const event of events.take_renderable_feature_removed()) { + removed++; + event.free(); + } + for (const event of events.take_mesh_added()) { + core.setTileMeshPrepared(event.tile_handle); + event.free(); + } + events.free(); + } + // Package canary: a budget suppresses the removals even after deletion. + assert.equal(removed, cacheBytes === undefined ? 4 : 0); + } finally { + state.destroy(); + core.free(); + } + } +}); diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index e1b5732b6..24b45f648 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -318,9 +318,7 @@ export function createNavaraMaps( releasePhotorealTiles(source) { if (!knownTiles.delete(source)) return; if (tilesSource !== source) return; - tilesSource = null; - clear('photoreal'); - changed(); + void maps.setPhotorealTiles(null); }, photorealActive: () => slots.photoreal != null, /** diff --git a/src/globe/testing/contractSuite/maps.mjs b/src/globe/testing/contractSuite/maps.mjs index ed10d398f..e02b1c288 100644 --- a/src/globe/testing/contractSuite/maps.mjs +++ b/src/globe/testing/contractSuite/maps.mjs @@ -1,8 +1,8 @@ import { createEngineEvent, engineCapabilities } from '../../contract.js'; import { MapSourceController } from '../../../maps/controller.js'; -import { ensure } from './helpers.mjs'; +import { cartographic, ensure } from './helpers.mjs'; -export function photorealChecks(engine) { +export function photorealChecks(engine, { settle }) { return [ [ 'maps.setPhotorealTiles: photoreal is inactive', @@ -14,6 +14,22 @@ export function photorealChecks(engine) { ); }, ], + [ + 'surface.sampleHeightDetailed: leaving photoreal no longer reports tiles', + async () => { + const places = [ + cartographic.fromCartesian(engine.camera.pose().position), + cartographic.fromDegrees(20, 10), + ].filter(Boolean); + await engine.maps.setPhotorealTiles(null); + await settle(); + for (const place of places) + ensure( + engine.surface.sampleHeightDetailed(place)?.source !== 'tiles', + 'released photoreal tiles still answer surface queries', + ); + }, + ], ]; }