From 77835cd258a2fbdfa8a3b51bb53eb36ca2d6e74a Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 20 May 2026 23:34:40 +0900 Subject: [PATCH 1/6] Add failure handling --- .../plugins/images/ImageOverlayPlugin.js | 93 +++++++++++++------ 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index f8e5ce95a..cb43edc47 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1005,6 +1005,7 @@ export class ImageOverlayPlugin { range: null, target: null, meshInfo: new Map(), + failed: false, }; overlayInfo @@ -1046,7 +1047,7 @@ export class ImageOverlayPlugin { } - const { tiles, overlayInfo, tileControllers, processQueue } = this; + const { tiles, overlayInfo, tileControllers } = this; const { ellipsoid } = tiles; const { controller, tileInfo } = overlayInfo.get( overlay ); const tileController = tileControllers.get( tile ); @@ -1136,48 +1137,88 @@ export class ImageOverlayPlugin { let target = null; if ( heightInRange && overlay.hasContent( range ) ) { - target = await processQueue - .add( { tile, overlay }, async () => { + target = await this._fetchTileOverlayTexture( tile, overlay, info ); - // check if the overlay has been disposed since starting this function - if ( controller.signal.aborted || tileController.signal.aborted ) { + } - return null; + info.target = target; - } + meshes.forEach( ( mesh, i ) => { - // Get the texture from the overlay - const regionTarget = await overlay.getTexture( range ); + const array = new Float32Array( uvs[ i ] ); + const attribute = new BufferAttribute( array, 3 ); + info.meshInfo.set( mesh, { attribute } ); - // check if the overlay has been disposed since starting this function - if ( controller.signal.aborted || tileController.signal.aborted ) { + } ); - return null; + } - } + async _fetchTileOverlayTexture( tile, overlay, info ) { - return regionTarget; + const { tiles, overlayInfo, tileControllers, processQueue } = this; + const { controller } = overlayInfo.get( overlay ); + const tileController = tileControllers.get( tile ); + const { range } = info; - } ) - .catch( err => { + return processQueue + .add( { tile, overlay }, async () => { - if ( ! ( err instanceof PriorityQueueItemRemovedError ) ) { + // check if the overlay has been disposed since starting this function + if ( controller.signal.aborted || tileController.signal.aborted ) { - throw err; + return null; - } + } - } ); + // Get the texture from the overlay + const regionTarget = await overlay.getTexture( range ); - } + // check if the overlay has been disposed since starting this function + if ( controller.signal.aborted || tileController.signal.aborted ) { - info.target = target; + return null; - meshes.forEach( ( mesh, i ) => { + } - const array = new Float32Array( uvs[ i ] ); - const attribute = new BufferAttribute( array, 3 ); - info.meshInfo.set( mesh, { attribute } ); + return regionTarget; + + } ) + .catch( err => { + + if ( err instanceof PriorityQueueItemRemovedError ) { + + return null; + + } + + info.failed = true; + tiles.dispatchEvent( { type: 'load-error', tile, overlay, error: err } ); + return null; + + } ); + + } + + resetFailedOverlays() { + + const { processedTiles, overlayInfo, overlays } = this; + processedTiles.forEach( tile => { + + overlays.forEach( overlay => { + + const { tileInfo } = overlayInfo.get( overlay ); + const info = tileInfo.get( tile ); + if ( ! info || ! info.failed ) return; + + info.failed = false; + this._fetchTileOverlayTexture( tile, overlay, info ).then( target => { + + info.target = target; + this._updateLayers( tile ); + + } ); + + } ); } ); From d57e45316a9f808a74c6e2122b5b71c295c830c4 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Thu, 21 May 2026 08:40:48 +0900 Subject: [PATCH 2/6] Clean up --- .../plugins/images/ImageOverlayPlugin.d.ts | 1 + .../plugins/images/ImageOverlayPlugin.js | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.d.ts b/src/three/plugins/images/ImageOverlayPlugin.d.ts index 4539fd99d..3eac27476 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.d.ts +++ b/src/three/plugins/images/ImageOverlayPlugin.d.ts @@ -15,6 +15,7 @@ export class ImageOverlayPlugin { addOverlay( overlay: ImageOverlay, order?: number ): void; setOverlayOrder( overlay: ImageOverlay, order?: number ): void; deleteOverlay( overlay: ImageOverlay ): void; + resetFailedOverlays(): void; } diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index cb43edc47..7de7c5ba7 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1134,15 +1134,12 @@ export class ImageOverlayPlugin { // if the image projection is outside the 0, 1 uvw range or there are no textures to draw in // the tiled image set the don't allocate a texture for it. - let target = null; if ( heightInRange && overlay.hasContent( range ) ) { - target = await this._fetchTileOverlayTexture( tile, overlay, info ); + await this._fetchTileOverlayTexture( tile, overlay, info ); } - info.target = target; - meshes.forEach( ( mesh, i ) => { const array = new Float32Array( uvs[ i ] ); @@ -1153,6 +1150,8 @@ export class ImageOverlayPlugin { } + // Queues an overlay texture fetch for the given tile, writing the result into info.target. + // Never throws — failures mark info.failed and dispatch a load-error event instead. async _fetchTileOverlayTexture( tile, overlay, info ) { const { tiles, overlayInfo, tileControllers, processQueue } = this; @@ -1160,7 +1159,7 @@ export class ImageOverlayPlugin { const tileController = tileControllers.get( tile ); const { range } = info; - return processQueue + info.target = await processQueue .add( { tile, overlay }, async () => { // check if the overlay has been disposed since starting this function @@ -1208,15 +1207,19 @@ export class ImageOverlayPlugin { const { tileInfo } = overlayInfo.get( overlay ); const info = tileInfo.get( tile ); - if ( ! info || ! info.failed ) return; + if ( ! info.failed ) { + + return; + + } info.failed = false; - this._fetchTileOverlayTexture( tile, overlay, info ).then( target => { + this._fetchTileOverlayTexture( tile, overlay, info ) + .then( () => { - info.target = target; - this._updateLayers( tile ); + this._updateLayers( tile ); - } ); + } ); } ); From 2eb6e9a8e591a385e7a66430e8f578699c8943eb Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Thu, 21 May 2026 08:43:12 +0900 Subject: [PATCH 3/6] Update --- src/core/renderer/API.md | 10 ++++++++++ src/three/plugins/API.md | 11 +++++++++++ src/three/plugins/images/ImageOverlayPlugin.js | 5 +++++ 3 files changed, 26 insertions(+) diff --git a/src/core/renderer/API.md b/src/core/renderer/API.md index 1d1da551a..3a960eb5e 100644 --- a/src/core/renderer/API.md +++ b/src/core/renderer/API.md @@ -631,6 +631,16 @@ tryRunJobs(): void Immediately attempts to dequeue and run pending jobs up to `maxJobs` concurrency. +### .flush + +```js +flush( item: any ): Promise | any +``` + +Immediately runs the callback for the given item, removing it from the queue. +Does nothing if the item is not queued. + + ### .scheduleJobRun ```js diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index 26a5f7b56..efefe96cc 100644 --- a/src/three/plugins/API.md +++ b/src/three/plugins/API.md @@ -1195,6 +1195,17 @@ deleteOverlay( overlay: ImageOverlay ): void Removes the given overlay from the plugin. +### .resetFailedOverlays + +```js +resetFailedOverlays(): void +``` + +Retries any overlay texture fetches that previously failed. Successfully loaded textures +are applied to their tiles without requiring a geometry reload. Pairs with the `load-error` +event, which fires on the `TilesRenderer` when an overlay texture fetch fails. + + ## LoadRegionPlugin Plugin that restricts tile loading and traversal to one or more geometric regions diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 7de7c5ba7..712142b85 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1198,6 +1198,11 @@ export class ImageOverlayPlugin { } + /** + * Retries any overlay texture fetches that previously failed. Successfully loaded textures + * are applied to their tiles without requiring a geometry reload. Pairs with the `load-error` + * event, which fires on the `TilesRenderer` when an overlay texture fetch fails. + */ resetFailedOverlays() { const { processedTiles, overlayInfo, overlays } = this; From dcaf3dedddb683299635a20945f1a317da697e84 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Thu, 21 May 2026 09:36:59 +0900 Subject: [PATCH 4/6] Updates --- .../plugins/images/ImageOverlayPlugin.js | 17 +++++++++++++ .../images/sources/GeoJSONImageSource.js | 6 ++++- .../plugins/images/sources/MVTImageSource.js | 10 ++++---- .../images/sources/RegionImageSource.js | 24 +++++++------------ .../images/sources/TiledImageSource.js | 6 +++++ src/three/plugins/images/utils/DataCache.js | 23 +++++++++++++----- 6 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 712142b85..8c6538a55 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1206,6 +1206,10 @@ export class ImageOverlayPlugin { resetFailedOverlays() { const { processedTiles, overlayInfo, overlays } = this; + const failed = []; + + // Release all failed entries synchronously so their DataCache disposal + // microtasks are queued before we re-lock below. processedTiles.forEach( tile => { overlays.forEach( overlay => { @@ -1219,6 +1223,19 @@ export class ImageOverlayPlugin { } info.failed = false; + overlay.releaseTexture( info.range ); + failed.push( { tile, overlay, info } ); + + } ); + + } ); + + // Re-lock and re-fetch after the disposal microtasks have run. + Promise.resolve().then( () => { + + failed.forEach( ( { tile, overlay, info } ) => { + + overlay.lockTexture( info.range ); this._fetchTileOverlayTexture( tile, overlay, info ) .then( () => { diff --git a/src/three/plugins/images/sources/GeoJSONImageSource.js b/src/three/plugins/images/sources/GeoJSONImageSource.js index e6d73feed..8f160efc8 100644 --- a/src/three/plugins/images/sources/GeoJSONImageSource.js +++ b/src/three/plugins/images/sources/GeoJSONImageSource.js @@ -118,7 +118,11 @@ export class GeoJSONImageSource extends RegionImageSource { disposeItem( texture ) { - texture.dispose(); + if ( texture ) { + + texture.dispose(); + + } } diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index abd58e371..f5d4b25d3 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -206,21 +206,23 @@ export class MVTImageSource extends RegionImageSource { tex.colorSpace = SRGBColorSpace; tex.generateMipmaps = false; tex.needsUpdate = true; - tex._regionArgs = [ minX, minY, maxX, maxY, level ]; return tex; } - disposeItem( texture ) { + disposeItem( texture, [ minX, minY, maxX, maxY, level ] ) { - const [ minX, minY, maxX, maxY, level ] = texture._regionArgs; forEachTileInBounds( [ minX, minY, maxX, maxY ], level, this._contentCache.tiling, ( tx, ty, tl ) => { this._contentCache.release( tx, ty, tl ); } ); - texture.dispose(); + if ( texture ) { + + texture.dispose(); + + } } diff --git a/src/three/plugins/images/sources/RegionImageSource.js b/src/three/plugins/images/sources/RegionImageSource.js index 4740add91..1aa09ccf5 100644 --- a/src/three/plugins/images/sources/RegionImageSource.js +++ b/src/three/plugins/images/sources/RegionImageSource.js @@ -47,8 +47,6 @@ export class TiledRegionImageSource extends RegionImageSource { this.tiledImageSource = tiledImageSource; this.tileComposer = new TiledTextureComposer(); this.resolution = 256; - this.IS_DIRECT_TILE = Symbol( 'IS_DIRECT_TILE' ); - this.LOCK_TOKENS = Symbol( 'LOCK_TOKENS' ); } @@ -68,10 +66,9 @@ export class TiledRegionImageSource extends RegionImageSource { async fetchItem( [ minX, minY, maxX, maxY, level ], signal ) { - const { tiledImageSource, tileComposer, IS_DIRECT_TILE, LOCK_TOKENS } = this; + const { tiledImageSource, tileComposer } = this; const range = [ minX, minY, maxX, maxY ]; const tiling = tiledImageSource.tiling; - const tokens = [ ...range, level ]; // lock tiles for the requested level await this._markImages( range, level, false ); @@ -95,14 +92,11 @@ export class TiledRegionImageSource extends RegionImageSource { if ( singleTileBounds !== null ) { // Clone rather than returning the texture directly so each region cache entry owns - // a distinct object. Returning the shared texture would cause symbol properties - // to be overwritten or deleted by concurrent cache entries during race conditions, - // (create, delete, create) leading to errors on disposal. + // a distinct object. Returning the shared texture would cause concurrent cache entries + // to alias the same object, leading to errors on disposal. // Cloning shares the underlying Source so no extra GPU memory is used. const [ tx, ty, tl ] = singleTileBounds; const clone = tiledImageSource.get( tx, ty, tl ).clone(); - clone[ IS_DIRECT_TILE ] = true; - clone[ LOCK_TOKENS ] = tokens; return clone; @@ -118,7 +112,6 @@ export class TiledRegionImageSource extends RegionImageSource { const target = new CanvasTexture( canvas ); target.colorSpace = SRGBColorSpace; target.generateMipmaps = false; - target[ LOCK_TOKENS ] = tokens; // TODO: we could draw the parent tile data here if it's available just to make sure we // have something to display but the texture is not usable until it returns. Though it @@ -142,14 +135,13 @@ export class TiledRegionImageSource extends RegionImageSource { } - disposeItem( target ) { + disposeItem( target, [ minX, minY, maxX, maxY, level ] ) { - const { IS_DIRECT_TILE, LOCK_TOKENS } = this; - const [ minX, minY, maxX, maxY, level ] = target[ LOCK_TOKENS ]; + if ( target ) { - target.dispose(); - delete target[ IS_DIRECT_TILE ]; - delete target[ LOCK_TOKENS ]; + target.dispose(); + + } // Unlock the component tiles using the stored tokens this._markImages( [ minX, minY, maxX, maxY ], level, true ); diff --git a/src/three/plugins/images/sources/TiledImageSource.js b/src/three/plugins/images/sources/TiledImageSource.js index 99057afbc..3cb1d4078 100644 --- a/src/three/plugins/images/sources/TiledImageSource.js +++ b/src/three/plugins/images/sources/TiledImageSource.js @@ -69,6 +69,12 @@ export class TiledImageSource extends DataCache { // dispose of the item that was fetched disposeItem( texture ) { + if ( ! texture ) { + + return; + + } + texture.dispose(); if ( texture.image instanceof ImageBitmap ) { diff --git a/src/three/plugins/images/utils/DataCache.js b/src/three/plugins/images/utils/DataCache.js index 5c137bdb2..e4ee94d24 100644 --- a/src/three/plugins/images/utils/DataCache.js +++ b/src/three/plugins/images/utils/DataCache.js @@ -19,6 +19,7 @@ export class DataCache { // overridable fetchItem() {} + // called with null if the fetch failed — implementations must handle it disposeItem() {} getMemoryUsage( item ) { @@ -207,17 +208,27 @@ export class DataCache { if ( result instanceof Promise ) { // "disposeItem" will throw potentially if fetch, etc are cancelled using the abort signal - result.then( item => { + result + .then( item => { - this.disposeItem( item ); - this.count --; - this.cachedBytes -= info.bytes; + this.disposeItem( item, info.args ); - } ).catch( () => {} ); + } ) + .catch( () => { + + this.disposeItem( null, info.args ); + + } ) + .finally( () => { + + this.count --; + this.cachedBytes -= info.bytes; + + } ); } else { - this.disposeItem( result ); + this.disposeItem( result, info.args ); this.count --; this.cachedBytes -= info.bytes; From 40d8b48a7a12293d5779a2a436db6c7d47996e96 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Thu, 21 May 2026 11:24:43 +0900 Subject: [PATCH 5/6] Final fix --- src/three/plugins/images/ImageOverlayPlugin.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 8c6538a55..fbf6f442e 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1230,8 +1230,9 @@ export class ImageOverlayPlugin { } ); - // Re-lock and re-fetch after the disposal microtasks have run. - Promise.resolve().then( () => { + // Defer to the next frame so all disposal microtasks — including nested sub-cache + // cleanup — have fully drained before re-locking. + requestAnimationFrame( () => { failed.forEach( ( { tile, overlay, info } ) => { From df9c08f28c7880ef0d7ee9c4f694781273aa62eb Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Thu, 21 May 2026 11:26:54 +0900 Subject: [PATCH 6/6] Update signatures --- src/three/plugins/images/utils/DataCache.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/three/plugins/images/utils/DataCache.js b/src/three/plugins/images/utils/DataCache.js index e4ee94d24..f3cadf4eb 100644 --- a/src/three/plugins/images/utils/DataCache.js +++ b/src/three/plugins/images/utils/DataCache.js @@ -18,9 +18,9 @@ export class DataCache { } // overridable - fetchItem() {} - // called with null if the fetch failed — implementations must handle it - disposeItem() {} + fetchItem( keys, signal ) {} + // called with null if the fetch failed + disposeItem( item, keys ) {} getMemoryUsage( item ) { return 0;