UNPKG

3d-tiles-renderer

Version:

https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification

2,004 lines (1,293 loc) 57.4 kB
/** @import { WebGLRenderer } from 'three' */ /** @import { WMTSTileMatrix } from './WMTSImageSource.js' */ /** @import { VectorTileStyle } from './utils/VectorShapeCanvasRenderer.js' */ import { Color, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture } from 'three'; import { PriorityQueue, unifiedPriorityCallback, DEFAULT_DOWNLOAD_QUEUE } from '3d-tiles-renderer/core'; import { CesiumIonAuth, GoogleCloudAuth } from '3d-tiles-renderer/core/plugins'; import { XYZImageSource } from './sources/XYZImageSource.js'; import { QuadKeyImageSource } from './sources/QuadKeyImageSource.js'; import { TMSImageSource } from './sources/TMSImageSource.js'; import { getMeshesCartographicRange, getMeshesPlanarRange } from './overlays/utils.js'; import { wrapOverlaysMaterial } from './overlays/wrapOverlaysMaterial.js'; import { GeometryClipper } from '../utilities/GeometryClipper.js'; import { WMTSImageSource } from './sources/WMTSImageSource.js'; import { MemoryUtils } from '3d-tiles-renderer/three'; import { GeoJSONImageSource } from './sources/GeoJSONImageSource.js'; import { WMSImageSource } from './sources/WMSImageSource.js'; import { TiledRegionImageSource } from './sources/RegionImageSource.js'; import { TiledTextureComposer } from './overlays/TiledTextureComposer.js'; import { DeepZoomImageSource } from './sources/DeepZoomImageSource.js'; const _matrix = /* @__PURE__ */ new Matrix4(); const _vec = /* @__PURE__ */ new Vector3(); const _center = /* @__PURE__ */ new Vector3(); const _sphereCenter = /* @__PURE__ */ new Vector3(); const _normal = /* @__PURE__ */ new Vector3(); const _box = /* @__PURE__ */ new Box3(); const SPLIT_TILE_DATA = Symbol( 'SPLIT_TILE_DATA' ); const SPLIT_HASH = Symbol( 'SPLIT_HASH' ); const ORIGINAL_REFINE = Symbol( 'ORIGINAL_REFINE' ); const PROCESS_QUEUE = /* @__PURE__ */ new PriorityQueue(); PROCESS_QUEUE.maxJobs = 10; PROCESS_QUEUE.priorityCallback = ( a, b ) => { const tileA = a.tile; const tileB = b.tile; const rendererA = tileA.internal.renderer; const rendererB = tileB.internal.renderer; const visibleA = rendererA.visibleTiles.has( tileA ); const visibleB = rendererB.visibleTiles.has( tileB ); if ( visibleA !== visibleB ) { // load visible tiles first return visibleA ? 1 : - 1; } else { // the fallback to the download queue tile priority return unifiedPriorityCallback( tileA, tileB ); } }; /** * Plugin that composites one or more tiled image overlays onto 3D tile geometry by * generating per-tile textures from image sources (XYZ, TMS, WMTS, WMS, GeoJSON, etc.). * Image sources are added via `addOverlay()` and removed via `deleteOverlay()`. * @param {Object} [options] * @param {Array} [options.overlays=[]] Initial image overlay sources to add. * @param {number} [options.resolution=256] Resolution of each generated tile texture in pixels. * @param {boolean} [options.enableTileSplitting=true] Allow tiles to be split to match image tile boundaries. */ export class ImageOverlayPlugin { get enableTileSplitting() { return this._enableTileSplitting; } set enableTileSplitting( v ) { if ( this._enableTileSplitting !== v ) { this._enableTileSplitting = v; this._markNeedsUpdate(); } } constructor( options = {} ) { const { overlays = [], resolution = 256, enableTileSplitting = true, } = options; // plugin needs to run before other plugins that fetch data since content // is handled and loaded in a custom way this.name = 'IMAGE_OVERLAY_PLUGIN'; this.priority = - 15; // options this.resolution = resolution; this._enableTileSplitting = enableTileSplitting; this.overlays = []; // internal this.needsUpdate = false; this.tiles = null; this.tileComposer = null; this.tileControllers = new Map(); this.overlayInfo = new Map(); this.meshParams = new WeakMap(); this.pendingTiles = new Map(); this.processedTiles = new Set(); this.processQueue = null; this._onUpdateAfter = null; this._onTileDownloadStart = null; this._onTileVisibilityChange = null; this._virtualChildResetId = 0; this._bytesUsed = new WeakMap(); overlays.forEach( overlay => { this.addOverlay( overlay ); } ); } // plugin functions init( tiles ) { const tileComposer = new TiledTextureComposer(); // save variables this.tiles = tiles; this.tileComposer = tileComposer; this.processQueue = PROCESS_QUEUE; // init all existing tiles tiles.forEachLoadedModel( ( scene, tile ) => { this._processTileModel( scene, tile, true ); } ); // update callback for when overlays have changed this._onUpdateAfter = async () => { // check if the projection changed for any of the overlays and refresh them let overlayChanged = false; this.overlayInfo.forEach( ( info, overlay ) => { if ( Boolean( overlay.frame ) !== Boolean( info.frame ) || overlay.frame && info.frame && ! info.frame.equals( overlay.frame ) ) { const order = info.order; this.deleteOverlay( overlay ); this.addOverlay( overlay, order ); overlayChanged = true; } } ); // trigger redraws for visible tiles if overlays updated if ( overlayChanged ) { const { processQueue } = this; const maxJobs = processQueue.maxJobs; let count = 0; processQueue.items.forEach( info => { if ( tiles.visibleTiles.has( info.tile ) ) { count ++; } } ); processQueue.maxJobs = count + processQueue.currJobs; processQueue.tryRunJobs(); processQueue.maxJobs = maxJobs; this.needsUpdate = true; } // update all the layer uvs if ( this.needsUpdate ) { this.needsUpdate = false; const { overlays, overlayInfo } = this; overlays.sort( ( a, b ) => { return overlayInfo.get( a ).order - overlayInfo.get( b ).order; } ); this.processedTiles.forEach( tile => { this._updateLayers( tile ); } ); this.resetVirtualChildren( ! this.enableTileSplitting ); tiles.recalculateBytesUsed(); tiles.dispatchEvent( { type: 'needs-render' } ); } }; this._onTileDownloadStart = ( { tile, url } ) => { // TODO: it's not super straight forward to detect whether a tile is "geometry" or not ahead of time. Checking // for "subtree" or "json" are good broad strokes but some cases will still be missed. if ( ! /\.json$/i.test( url ) && ! /\.subtree/i.test( url ) ) { this.processedTiles.add( tile ); this._initTileOverlayInfo( tile ); } }; this._onTileVisibilityChange = ( { tile, visible } ) => { this.overlayInfo.forEach( ( { tileInfo }, overlay ) => { if ( tileInfo.has( tile ) ) { const { range } = tileInfo.get( tile ); overlay.setRegionVisible( range, visible, tile ); } } ); }; tiles.addEventListener( 'update-after', this._onUpdateAfter ); tiles.addEventListener( 'tile-download-start', this._onTileDownloadStart ); tiles.addEventListener( 'tile-visibility-change', this._onTileVisibilityChange ); this.overlays.forEach( overlay => { this._initOverlay( overlay ); } ); } _removeVirtualChildren( tile ) { if ( ! ( ORIGINAL_REFINE in tile ) ) { return; } // remove the virtual children associated with the given tile const { tiles } = this; const { virtualChildCount } = tile.internal; const len = tile.children.length; const start = len - virtualChildCount; for ( let i = start; i < len; i ++ ) { const child = tile.children[ i ]; tiles.processNodeQueue.remove( child ); tiles.lruCache.remove( child ); child.parent = null; } tile.children.length -= virtualChildCount; tile.internal.virtualChildCount = 0; tile.refine = tile[ ORIGINAL_REFINE ]; delete tile[ ORIGINAL_REFINE ]; delete tile[ SPLIT_HASH ]; } disposeTile( tile ) { const { overlayInfo, tileControllers, processQueue, pendingTiles, processedTiles } = this; processedTiles.delete( tile ); // remove any virtual children since they depend on this tile being loaded for regeneration. // they will be recreated with fresh split configuration when the tile is reloaded. this._removeVirtualChildren( tile ); // Cancel any ongoing tasks. If a tile is cancelled while downloading // this will not have been created, yet. if ( tileControllers.has( tile ) ) { tileControllers.get( tile ).abort(); tileControllers.delete( tile ); pendingTiles.delete( tile ); } // stop any tile loads overlayInfo.forEach( ( ( { tileInfo }, overlay ) => { if ( tileInfo.has( tile ) ) { const { meshInfo, range } = tileInfo.get( tile ); if ( range !== null ) { overlay.releaseTexture( range ); } tileInfo.delete( tile ); meshInfo.clear(); } } ) ); // Remove any items that reference the tile being disposed processQueue.removeByFilter( item => { return item.tile === tile; } ); } calculateBytesUsed( tile ) { const { overlayInfo } = this; const bytesUsed = this._bytesUsed; let bytes = null; overlayInfo.forEach( ( { tileInfo }, overlay ) => { if ( tileInfo.has( tile ) ) { const { target } = tileInfo.get( tile ); bytes = bytes || 0; bytes += MemoryUtils.getTextureByteLength( target ); } } ); if ( bytes !== null ) { bytesUsed.set( tile, bytes ); return bytes; } else if ( bytesUsed.has( tile ) ) { return bytesUsed.get( tile ); } else { return 0; } } processTileModel( scene, tile ) { return this._processTileModel( scene, tile ); } async _processTileModel( scene, tile, initialization = false ) { const { tileControllers, processedTiles, pendingTiles } = this; tileControllers.set( tile, new AbortController() ); if ( ! initialization ) { // we save all these pending tiles so that they can be correctly initialized if an // overlay is added in the time between when this function starts and after the async // await call. Otherwise the tile could be missed. But if we're initializing the plugin // then we don't need to do this because the tiles are already included in the traversal. pendingTiles.set( tile, scene ); } // track which tiles we have been processed and remove them in "disposeTile" processedTiles.add( tile ); this._wrapMaterials( scene ); this._initTileOverlayInfo( tile ); await this._initTileSceneOverlayInfo( scene, tile ); this.expandVirtualChildren( scene, tile ); this._updateLayers( tile ); pendingTiles.delete( tile ); } dispose() { const { tiles } = this; // dispose of all overlays const overlays = [ ...this.overlays ]; overlays.forEach( overlay => { this.deleteOverlay( overlay ); } ); // reset the textures of the meshes this.processedTiles.forEach( tile => { this._updateLayers( tile ); this.disposeTile( tile ); } ); tiles.removeEventListener( 'update-after', this._onUpdateAfter ); tiles.removeEventListener( 'tile-download-start', this._onTileDownloadStart ); tiles.removeEventListener( 'tile-visibility-change', this._onTileVisibilityChange ); this.resetVirtualChildren( true ); } getAttributions( target ) { this.overlays.forEach( overlay => { if ( overlay.opacity > 0 ) { overlay.getAttributions( target ); } } ); } parseToMesh( buffer, tile, extension, url ) { if ( extension === 'image_overlay_tile_split' ) { return tile[ SPLIT_TILE_DATA ]; } } async resetVirtualChildren( fullDispose = false ) { // only run this if all the overlays are ready and tile targets have been generated, etc // so we can make an effort to only remove the necessary tiles. this._virtualChildResetId ++; const id = this._virtualChildResetId; await Promise.all( this.overlays.map( o => o.whenReady() ) ); if ( id !== this._virtualChildResetId ) { return; } // collect the tiles split into virtual tiles, sorted deepest-first so nested virtual tiles // are cleaned up before their parents when iterating const { tiles } = this; const splitTiles = []; this.processedTiles.forEach( tile => { if ( SPLIT_HASH in tile ) { splitTiles.push( tile ); } } ); // ensure we clean depth first splitTiles.sort( ( a, b ) => b.internal.depth - a.internal.depth ); // dispose of the virtual children if this tile would not be split or the split could change // under the current overlays used. splitTiles.forEach( tile => { const clone = tile.engineData.scene.clone(); clone.updateMatrixWorld(); if ( fullDispose || tile[ SPLIT_HASH ] !== this._getSplitVectors( clone, tile ).hash ) { // note that we need to remove children from the processing queue in this case // because we are forcibly evicting them from the cache. Since parents is sorted // deepest-first, nested virtual tiles are already cleaned up before we reach // their parent here. this._removeVirtualChildren( tile ); } } ); // re-expand tiles if needed if ( ! fullDispose ) { tiles.forEachLoadedModel( ( scene, tile ) => { this.expandVirtualChildren( scene, tile ); } ); } } _getSplitVectors( scene, tile, centerTarget = _center ) { const { tiles, overlayInfo } = this; // get the center of the content const box = new Box3(); box.setFromObject( scene ); box.getCenter( centerTarget ); // find the vectors that are orthogonal to every overlay projection const splitDirections = []; const hashTokens = []; overlayInfo.forEach( ( { tileInfo }, overlay ) => { // if the tile has a render target associated with the overlay and the last level of detail // is not being displayed, yet, then we need to split const info = tileInfo.get( tile ); if ( info && info.target && overlay.shouldSplit( info.range ) ) { // get the vector representing the projection direction if ( overlay.frame ) { _normal.set( 0, 0, 1 ).transformDirection( overlay.frame ); } else { tiles.ellipsoid.getPositionToNormal( centerTarget, _normal ); if ( _normal.length() < 1e-6 ) { _normal.set( 1, 0, 0 ); } } // dedupe vectors in the hash const token = `${ _normal.x.toFixed( 3 ) },${ _normal.y.toFixed( 3 ) },${ _normal.z.toFixed( 3 ) }_`; if ( ! hashTokens.includes( token ) ) { hashTokens.push( token ); } // construct the orthogonal vectors const other = _vec.set( 0, 0, 1 ); if ( Math.abs( _normal.dot( other ) ) > 1 - 1e-4 ) { other.set( 1, 0, 0 ); } const ortho0 = new Vector3().crossVectors( _normal, other ).normalize(); const ortho1 = new Vector3().crossVectors( _normal, ortho0 ).normalize(); splitDirections.push( ortho0, ortho1 ); } } ); // Generate a reduced set of vectors by averages directions in a 45 degree cone so // we don't split unnecessarily const directions = []; while ( splitDirections.length !== 0 ) { const normalized = splitDirections.pop().clone(); const average = normalized.clone(); for ( let i = 0; i < splitDirections.length; i ++ ) { const dir = splitDirections[ i ]; const dotProduct = normalized.dot( dir ); if ( Math.abs( dotProduct ) > Math.cos( Math.PI / 8 ) ) { average.addScaledVector( dir, Math.sign( dotProduct ) ); normalized.copy( average ).normalize(); splitDirections.splice( i, 1 ); i --; } } directions.push( average.normalize() ); } return { directions, hash: hashTokens.join( '' ) }; } async expandVirtualChildren( scene, tile ) { const { refine } = tile; // Only split tiles that would benefit from it: // - REPLACE tiles with no children are leaf tiles where splitting improves overlay UV projection quality. REPLACE tiles // that already have children are already refined by their children so splitting is unnecessary. // - ADD tiles always need splitting since their content is rendered alongside children at all levels. // Also skip any tiles that already have virtual children to avoid interfering with other plugins. const shouldSplit = ( refine === 'REPLACE' && tile.children.length === 0 ) || refine === 'ADD'; const alreadySplit = tile.internal.virtualChildCount !== 0; if ( this.enableTileSplitting === false || ! shouldSplit || alreadySplit ) { return; } // create a copy of the content to transform and split const clone = scene.clone(); clone.updateMatrixWorld(); // get the directions to split on & if there are no directions to split on then exit early const { directions, hash } = this._getSplitVectors( clone, tile, _center ); if ( directions.length === 0 ) { return; } tile[ SPLIT_HASH ] = hash; // set up the splitter to ignore overlay uvs const clipper = new GeometryClipper(); clipper.attributeList = key => ! /^layer_uv_\d+/.test( key ); directions.map( splitDirection => { clipper.addSplitOperation( ( geometry, i0, i1, i2, barycoord, matrixWorld ) => { Triangle.getInterpolatedAttribute( geometry.attributes.position, i0, i1, i2, barycoord, _vec ); return _vec.applyMatrix4( matrixWorld ).sub( _center ).dot( splitDirection ); } ); } ); // run the clipping operations by performing every permutation of sides // defined by the split directions const splitChildren = []; clipper.forEachSplitPermutation( () => { // clip the object itself const result = clipper.clipObject( clone ); // remove the parent transform because it will be multiplied back in after the fact result.matrix .premultiply( tile.engineData.transformInverse ) .decompose( result.position, result.quaternion, result.scale ); // collect the meshes const meshes = []; result.traverse( c => { if ( c.isMesh ) { const material = c.material.clone(); c.material = material; for ( const key in material ) { const value = material[ key ]; if ( value && value.isTexture ) { if ( value.source.data instanceof ImageBitmap ) { // clone any image bitmap textures using canvas because if we share the texture then when // the clipped child is disposed then it will dispose of the parent tile texture data, as well. const canvas = document.createElement( 'canvas' ); canvas.width = value.image.width; canvas.height = value.image.height; const ctx = canvas.getContext( '2d' ); ctx.scale( 1, - 1 ); ctx.drawImage( value.source.data, 0, 0, canvas.width, - canvas.height ); const tex = new CanvasTexture( canvas ); tex.mapping = value.mapping; tex.wrapS = value.wrapS; tex.wrapT = value.wrapT; tex.minFilter = value.minFilter; tex.magFilter = value.magFilter; tex.format = value.format; tex.type = value.type; tex.anisotropy = value.anisotropy; tex.colorSpace = value.colorSpace; tex.generateMipmaps = value.generateMipmaps; material[ key ] = tex; } } } meshes.push( c ); } } ); if ( meshes.length === 0 ) { return; } // generate a region bounding volume const boundingVolume = {}; if ( tile.boundingVolume.region ) { boundingVolume.region = getMeshesCartographicRange( meshes, this.tiles.ellipsoid ).region; } // create a sphere bounding volume if ( tile.boundingVolume.box || tile.boundingVolume.sphere ) { // compute the sphere center _box .setFromObject( result, true ) .getCenter( _sphereCenter ); // calculate the sq radius from all vertices let maxSqRadius = 0; result.traverse( c => { const geometry = c.geometry; if ( geometry ) { const position = geometry.attributes.position; for ( let i = 0, l = position.count; i < l; i ++ ) { const sqRadius = _vec .fromBufferAttribute( position, i ) .applyMatrix4( c.matrixWorld ) .distanceToSquared( _sphereCenter ); maxSqRadius = Math.max( maxSqRadius, sqRadius ); } } } ); boundingVolume.sphere = [ ..._sphereCenter, Math.sqrt( maxSqRadius ) ]; } splitChildren.push( { internal: { isVirtual: true }, refine: 'REPLACE', geometricError: tile.geometricError * 0.5, boundingVolume: boundingVolume, content: { uri: './child.image_overlay_tile_split' }, children: [], [ SPLIT_TILE_DATA ]: result, } ); } ); // force the tile "refine" mode to be set to "REPLACE" so that the virtual children // replace this tile's geometry display. Save the original mode so it can be restored // if virtual children are later removed. tile[ ORIGINAL_REFINE ] = tile.refine; tile.refine = 'REPLACE'; tile.children.push( ...splitChildren ); tile.internal.virtualChildCount += splitChildren.length; } fetchData( url, options ) { // if this is our custom url indicating a tile split then return fake response if ( /image_overlay_tile_split/.test( url ) ) { return new ArrayBuffer(); } } /** * Adds an image overlay source to the plugin. The `order` parameter controls the draw * order among overlays; lower values are drawn first. If omitted, the overlay is appended * after all existing overlays. * @param {ImageOverlay} overlay An image overlay instance. * @param {number|null} [order=null] Draw order for this overlay. */ addOverlay( overlay, order = null ) { const { tiles, overlays, overlayInfo } = this; if ( order === null ) { // set the order to the next largest order value order = overlays.reduce( ( v, o ) => Math.max( v, o.order + 1 ), 0 ); } const controller = new AbortController(); overlays.push( overlay ); overlayInfo.set( overlay, { order: order, uniforms: {}, tileInfo: new Map(), controller: controller, frame: overlay.frame ? overlay.frame.clone() : null, } ); if ( tiles !== null ) { this._initOverlay( overlay ); } } /** * Updates the draw order for the given overlay. * @param {ImageOverlay} overlay The overlay to reorder. * @param {number} order New draw order value. */ setOverlayOrder( overlay, order ) { const index = this.overlays.indexOf( overlay ); if ( index !== - 1 ) { this.overlayInfo.get( overlay ).order = order; this._markNeedsUpdate(); } } /** * Removes the given overlay from the plugin. * @param {ImageOverlay} overlay The overlay to remove. */ deleteOverlay( overlay ) { const { overlays, overlayInfo, processQueue, processedTiles, tiles } = this; const index = overlays.indexOf( overlay ); if ( index !== - 1 ) { // delete tile info explicitly instead of blindly dispose of the full overlay const { tileInfo, controller } = overlayInfo.get( overlay ); processedTiles.forEach( tile => { if ( ! tileInfo.has( tile ) ) { // check for the case where tiles have been added but not properly initialized with the // given overlay, yet return; } const { meshInfo, range, } = tileInfo.get( tile ); // release the ranges if ( range !== null ) { if ( tiles.visibleTiles.has( tile ) ) { overlay.setRegionVisible( range, false ); } overlay.releaseTexture( range ); } tileInfo.delete( tile ); meshInfo.clear(); } ); tileInfo.clear(); overlayInfo.delete( overlay ); controller.abort(); // Remove any items that reference the overlay being disposed - we check if the tiles // is in this "processedTiles" map since the queue can be shared among plugin instances. processQueue.removeByFilter( item => { return item.overlay === overlay && processedTiles.has( item.tile ); } ); // remove the overlay overlays.splice( index, 1 ); // update all tiles to truncate texture arrays and remove references immediately processedTiles.forEach( tile => { this._updateLayers( tile ); } ); this._markNeedsUpdate(); } } // initialize the overlay to use the right fetch options, load all data for existing tiles _initOverlay( overlay ) { const { processedTiles } = this; overlay.init().then( () => { // Set resolution on the overlay overlay.setResolution( this.resolution ); } ); const promises = []; processedTiles.forEach( async tile => { const scene = tile.engineData.scene; this._initTileOverlayInfo( tile, overlay ); const promise = this._initTileSceneOverlayInfo( scene, tile, overlay ); promises.push( promise ); // mark tiles as needing an update after initialized so we get a trickle in of tiles await promise; this._updateLayers( tile ); } ); Promise.all( promises ).then( () => { this._markNeedsUpdate(); } ); } // wrap all materials in the given scene wit the overlay material shader _wrapMaterials( scene ) { scene.traverse( c => { if ( c.material ) { const params = wrapOverlaysMaterial( c.material, c.material.onBeforeCompile ); this.meshParams.set( c, params ); } } ); } // Initialize per-tile overlay information. This function triggers an async function but // does not need to be awaited for use since it's just locking textures which are awaited later. _initTileOverlayInfo( tile, overlay = this.overlays ) { if ( Array.isArray( overlay ) ) { overlay.forEach( o => this._initTileOverlayInfo( tile, o ) ); return; } // This function is resilient to multiple calls in case an overlay is added after a tile starts loading // and before it is loaded, meaning this function needs to be called twice to ensure it's initialized. const { overlayInfo } = this; if ( overlayInfo.get( overlay ).tileInfo.has( tile ) ) { return; } const info = { range: null, target: null, meshInfo: new Map(), failed: false, }; overlayInfo .get( overlay ) .tileInfo .set( tile, info ); // if the overlay isn't ready then we can't convert the range correctly, yet if ( overlay.isReady ) { if ( overlay.isPlanarProjection ) { // TODO: we could project the shape into the frame, compute 2d bounds, and then mark tiles } else if ( tile.boundingVolume.region ) { // If the tile has a region bounding volume then mark the tiles to preload, clamped to the extents of // the overlay image const [ minLon, minLat, maxLon, maxLat ] = tile.boundingVolume.region; let range = [ minLon, minLat, maxLon, maxLat ]; range = overlay.projection.clampToBounds( range ); range = overlay.projection.toNormalizedRange( range ); info.range = range; overlay.lockTextureSafe( range ); } } } // initialize the scene meshes async _initTileSceneOverlayInfo( scene, tile, overlay = this.overlays ) { if ( Array.isArray( overlay ) ) { return Promise.all( overlay.map( o => this._initTileSceneOverlayInfo( scene, tile, o ) ) ); } const { tiles, overlayInfo, tileControllers } = this; const { ellipsoid } = tiles; const { controller, tileInfo } = overlayInfo.get( overlay ); const tileController = tileControllers.get( tile ); // wait for the overlay to be completely loaded so projection and tiling are available if ( ! overlay.isReady ) { await overlay.whenReady(); } // check if the overlay or tile have been disposed since starting this function // if the tileController is not present then the tile has been disposed of already if ( controller.signal.aborted || tileController.signal.aborted ) { return; } // find all meshes to project on and ensure matrices are up to date const meshes = []; scene.updateMatrixWorld(); scene.traverse( c => { if ( c.isMesh ) { meshes.push( c ); } } ); const { aspectRatio, projection } = overlay; const info = tileInfo.get( tile ); let range, uvs, heightInRange; // retrieve the uvs and range for all the meshes if ( overlay.isPlanarProjection ) { // construct a matrix transforming _into_ the local frame in which the texture // will be sampled, scaling by the aspect ratio of the overlay so it is scaled // to [0, 1] _matrix .makeScale( 1 / aspectRatio, 1, 1 ) .multiply( overlay.frame ); if ( scene.parent !== null ) { _matrix.multiply( tiles.group.matrixWorldInverse ); } let heightRange; ( { range, uvs, heightRange } = getMeshesPlanarRange( meshes, _matrix ) ); heightInRange = ! ( heightRange[ 0 ] > 1 || heightRange[ 1 ] < 0 ); } else { _matrix.identity(); if ( scene.parent !== null ) { _matrix.copy( tiles.group.matrixWorldInverse ); } ( { range, uvs } = getMeshesCartographicRange( meshes, ellipsoid, _matrix, projection, info.range ) ); heightInRange = true; } // calculate the tiling level here if not already created if ( info.range === null ) { info.range = range; overlay.lockTextureSafe( range ); } if ( tiles.visibleTiles.has( tile ) ) { overlay.setRegionVisible( info.range, true ); } // 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. if ( heightInRange && overlay.hasContent( range ) ) { await this._fetchTileOverlayTexture( tile, overlay, info ); } meshes.forEach( ( mesh, i ) => { const array = new Float32Array( uvs[ i ] ); const attribute = new BufferAttribute( array, 3 ); info.meshInfo.set( mesh, { attribute } ); } ); } // 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; const { controller } = overlayInfo.get( overlay ); const tileController = tileControllers.get( tile ); const { range } = info; info.target = await processQueue .add( { tile, overlay }, async () => { // check if the overlay has been disposed since starting this function if ( controller.signal.aborted || tileController.signal.aborted ) { 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 ) { return null; } return regionTarget; } ) .catch( err => { if ( err.name === 'AbortError' ) { return null; } info.failed = true; tiles.dispatchEvent( { type: 'load-error', tile, overlay, error: err, url: null } ); return null; } ); } /** * 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; 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 => { const { tileInfo } = overlayInfo.get( overlay ); const info = tileInfo.get( tile ); if ( ! info.failed ) { return; } info.failed = false; overlay.releaseTexture( info.range ); failed.push( { tile, overlay, info } ); } ); } ); // 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 } ) => { overlay.lockTextureSafe( info.range ); this._fetchTileOverlayTexture( tile, overlay, info ) .then( () => { this._updateLayers( tile ); } ) .catch( err => { if ( err.name !== 'AbortError' ) { throw err; } } ); } ); } ); } _updateLayers( tile ) { const { overlayInfo, overlays, tileControllers, meshParams } = this; const tileController = tileControllers.get( tile ); // by this point all targets should be present and we can force the memory to update this.tiles.recalculateBytesUsed( tile ); // if the tile has been disposed before this function is called then exit early if ( ! tileController || tileController.signal.aborted ) { return; } // handle the case where all overlays have been removed - we need to reset // the materials to have no layers if ( overlays.length === 0 ) { const scene = tile.engineData && tile.engineData.scene; if ( scene ) { scene.traverse( c => { if ( c.material && meshParams.has( c ) ) { const params = meshParams.get( c ); params.layerMaps.length = 0; params.layerInfo.length = 0; c.material.defines.LAYER_COUNT = 0; c.material.needsUpdate = true; } } ); } return; } // update the uvs and texture overlays for each mesh overlays.forEach( ( overlay, i ) => { const { tileInfo } = overlayInfo.get( overlay ); const { meshInfo, target } = tileInfo.get( tile ); meshInfo.forEach( ( { attribute }, mesh ) => { const { geometry, material } = mesh; const params = meshParams.get( mesh ); // assign the new uvs const key = `layer_uv_${ i }`; if ( geometry.getAttribute( key ) !== attribute ) { geometry.setAttribute( key, attribute ); geometry.dispose(); } // set the uniform array lengths params.layerMaps.length = overlays.length; params.layerInfo.length = overlays.length; // assign the uniforms params.layerMaps.value[ i ] = target !== null ? target : null; params.layerInfo.value[ i ] = overlay; // mark per-layer defines material.defines[ `LAYER_${ i }_EXISTS` ] = Number( target !== null ); material.defines[ `LAYER_${ i }_ALPHA_INVERT` ] = Number( overlay.alphaInvert ); material.defines[ `LAYER_${ i }_ALPHA_MASK` ] = Number( overlay.alphaMask ); material.defines.LAYER_COUNT = overlays.length; material.needsUpdate = true; } ); } ); } _markNeedsUpdate() { if ( this.needsUpdate === false ) { this.needsUpdate = true; if ( this.tiles !== null ) { this.tiles.dispatchEvent( { type: 'needs-update' } ); } } } } /** * Base class for all image overlays. Provides the interface that `ImageOverlayPlugin` uses to * fetch, lock, and release overlay textures. * @param {Object} [options] * @param {number} [options.opacity=1] Opacity of the overlay layer (0–1). * @param {number|Color} [options.color=0xffffff] Tint color multiplied with the overlay texture. * @param {Matrix4} [options.frame=null] World-space transform defining the plane for planar * projection. If null, cartographic (lat/lon) projection is used instead. * @param {Function} [options.preprocessURL=null] Optional function `(url) => url` called before * every fetch to allow URL rewriting or token injection. * @param {boolean} [options.alphaMask=false] If true, the overlay alpha channel masks the * underlying tile surface rather than blending on top of it. * @param {boolean} [options.alphaInvert=false] If true, inverts the alpha channel before * applying the mask or blend. */ export class ImageOverlay { get isPlanarProjection() { return Boolean( this.frame ); } constructor( options = {} ) { const { opacity = 1, color = 0xffffff, frame = null, preprocessURL = null, alphaMask = false, alphaInvert = false, } = options; this.preprocessURL = preprocessURL; this.opacity = opacity; this.color = new Color( color ); this.frame = frame !== null ? frame.clone() : null; this.alphaMask = alphaMask; this.alphaInvert = alphaInvert; this.downloadQueue = DEFAULT_DOWNLOAD_QUEUE; this._whenReady = null; this.isReady = false; this.isInitialized = false; this._visibleRegionCounts = new Map(); } init() { if ( ! this.isInitialized ) { this.isInitialized = true; this._whenReady = this._init().then( () => this.isReady = true ); } return this._whenReady; } whenReady() { return this._whenReady; } // overrideable _init() { return Promise.resolve(); } fetch( url, options = {} ) { if ( this.preprocessURL ) { url = this.preprocessURL( url ); } const item = { priority: - performance.now() }; const promise = this.downloadQueue.add( item, () => fetch( url, options ) ); if ( options.signal ) { options.signal.addEventListener( 'abort', () => this.downloadQueue.remove( item ), { once: true } ); } return promise; } getAttributions( target ) { } hasContent( range, level = null ) { return false; } async getTexture( range, level = null ) { return null; } async lockTexture( range, level = null ) { return null; } lockTextureSafe( range ) { // locks a texture without risk of throwing due to abort const result = this.lockTexture( range ); if ( result instanceof Promise ) { result.catch( err => { if ( err.name !== 'AbortError' ) throw err; } ); } return result; } releaseTexture( range, level = null ) { } shouldSplit( range, level = null ) { return false; } setResolution( resolution ) { } setRegionVisible( range, visible ) { const { _visibleRegionCounts } = this; const key = range.join( '_' ); let entry = _visibleRegionCounts.get( key ); if ( ! entry ) { entry = { range: [ ...range ], count: 0 }; _visibleRegionCounts.set( key, entry ); } entry.count += visible ? 1 : - 1; if ( entry.count < 0 ) { throw new Error(); } else if ( entry.count === 0 ) { _visibleRegionCounts.delete( key ); } } } /** * Base class for overlays backed by a tiled image source (XYZ, TMS, WMS, WMTS, etc.). * Manages a `TiledImageSource` and a `RegionImageSource` that handles compositing * multiple source tiles into a single texture per 3D tile region. * @extends ImageOverlay */ export class TiledImageOverlay extends ImageOverlay { get tiling() { return this.imageSource.tiling; } get projection() { return this.tiling.projection; } get aspectRatio() { return this.tiling && this.isReady ? this.tiling.aspectRatio : 1; } get fetchOptions() { return this.imageSource.fetchOptions; } set fetchOptions( v ) { this.imageSource.fetchOptions = v; } constructor( options = {} ) { const { imageSource = null, ...rest } = options; super( rest ); this.imageSource = imageSource; this.regionImageSource = null; } _init() { return this ._initImageSource() .then( () => { this.imageSource.fetchData = ( ...args ) => this.fetch( ...args ); this.regionImageSource = new TiledRegionImageSource( this.imageSource ); } ); } _initImageSource() { return this.imageSource.init(); } // Texture acquisition API implementations calculateLevel( range, resolution = null ) { const [ minX, minY, maxX, maxY ] = range; const w = maxX - minX; const h = maxY - minY; // callers can provide their own target resolution to select a different level than the // one used for drawing the overlay textures if ( resolution === null ) { resolution = this.regionImageSource.resolution; } let level = 0; const maxLevel = this.tiling.maxLevel; for ( ; level < maxLevel; level ++ ) { // the number of pixels per image on each axis const wProj = resolution / w; const hProj = resolution / h; const levelData = this.tiling.getLevel( level ); if ( levelData === null || levelData === undefined ) { continue; } const { pixelWidth, pixelHeight } = levelData; if ( pixelWidth >= wProj || pixelHeight >= hProj ) { break; } } return level; } hasContent( range, level = this.calculateLevel( range ) ) { return this.regionImageSource.hasContent( ...range, level ); } getTexture( range, level = this.calculateLevel( range ) ) { return this.regionImageSource.get( ...range, level ); } lockTexture( range, level = this.calculateLevel( range ) ) { return this.regionImageSource.lock( ...range, level ); } releaseTexture( range, level = this.calculateLevel( range ) ) { this.regionImageSource.release( ...range, level ); } shouldSplit( range, level = this.calculateLevel( range ) ) { // if we haven't reached the max level yet then continue splitting return this.tiling.maxLevel > level; } setResolution( resolution ) { this.regionImageSource.resolution = resolution; } } /** * Overlay that renders XYZ/Slippy-map image tiles (e.g. OpenStreetMap) on top of 3D tile * geometry. See the {@link https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames Slippy map tilenames specification}. * @extends TiledImageOverlay * @param {Object} [options] * @param {string} [options.url] URL template with `{x}`, `{y}`, `{z}` placeholders. * @param {number} [options.levels=20] Number of zoom levels. * @param {number} [options.tileDimension=256] Tile pixel size. * @param {string} [options.projection='EPSG:3857'] Projection scheme identifier. * @param {number} [options.opacity=1] Overlay opacity (0–1). * @param {number|Color} [options.color=0xffffff] Tint color. * @param {Matrix4} [options.frame=null] Planar projection frame. If null, cartographic projection is used. * @param {Function} [options.preprocessURL=null] URL rewriting callback. * @param {boolean} [options.alphaMask=false] Use alpha channel as a surface mask. * @param {boolean} [options.alphaInvert=false] Invert the alpha channel. */ export class XYZTilesOverlay extends TiledImageOverlay { constructor( options = {} ) { super( options ); this.imageSource = new XYZImageSource( options ); } } /** * Plugin that renders a Deep Zoom Image (DZI) as a tiled overlay. Only a single embedded "Image" is supported. * See the {@link https://learn.microsoft.com/en-us/previous-versions/windows/silverlight/dotnet-windows-silverlight/cc645077(v=vs.95) Deep Zoom specification} * and {@link https://openseadragon.github.io OpenSeadragon}. * @extends TiledImageOverlay * @param {Object} [options] * @param {string} [options.url] URL to the `.dzi` descriptor file. */ export class DeepZoomOverlay extends TiledImageOverlay { constructor( options ) { super( options ); this.imageSource = new DeepZoomImageSource( options ); } } /** * @callback GeoJSONGetStyleCallback * @param {Object} feature The GeoJSON feature object being rendered. * @param {Object} properties The feature's properties object. * @returns {VectorTileStyle|null} Style to apply, or `null` to use defaults. */ /** * Overlay that rasterizes a GeoJSON dataset onto 3D tile geometry. Features are drawn using the * Canvas 2D API at the tile's native resolution. Per-feature style overrides can be provided via * the `strokeStyle`, `fillStyle`, `strokeWidth`, and `pointRadius` properties on each GeoJSON * feature's `properties` object. * @extends ImageOverlay * @param {Object} [options] * @param {Object} [options.geojson=null] GeoJSON FeatureCollection or Feature object to render. * If not provided, `url` must be set so the data can be fetched on init. * @param {string} [options.url=null] URL to a GeoJSON file to fetch on initialization (used when * `geojson` is not supplied directly). * @param {number} [options.resolution=256] Canvas resolution (pixels) used when compositing tiles. * @param {GeoJSONGetStyleCallback} [options.getStyle] Per-feature style callback. When provided, overrides `strokeStyle`, `fillStyle`, `strokeWidth`, and `pointRadius`. * @param {number} [options.pointRadius=6] Radius in pixels used to render Point features. * @param {string} [options.strokeStyle='white'] Canvas stroke style for feature outlines. * @param {number} [options.strokeWidth=2] Stroke line width in pixels. * @param {string} [options.fillStyle='rgba( 255, 255, 255, 0.5 )'] Canvas fill style for feature interiors. * @param {number} [options.opacity=1] Overlay opacity (0–1). * @param {number|Color} [options.color=0xffffff] Tint color. * @param {Matrix4} [options.frame=null] Planar projection frame. If null, cartographic projection is used. * @param {Function} [options.preprocessURL=null] URL rewriting callback. * @param {boolean} [options.alphaMask=false] Use alpha channel as a surface mask. * @param {boolean} [options.alphaInvert=false] Invert the alpha channel. */ export class GeoJSONOverlay extends ImageOverlay { get projection() { return this.imageSource.projection; } get aspectRatio() { return 2; } get pointRadius() { return this.imageSource.pointRadius; } set pointRadius( v ) { this.imageSource.pointRadius = v; } get strokeStyle() { return this.imageSource.strokeStyle; } set strokeStyle( v ) { this.imageSource.strokeStyle = v; } get strokeWidth() { return this.imageSource.strokeWidth; } set strokeWidth( v ) { this.imageSource.strokeWidth = v; } get fillStyle() { return this.imageSource.fillStyle; } set fillStyle( v ) { this.imageSource.fillStyle = v; } get geojson() { return this.imageSource.geojson; } set geojson( v ) { this.imageSource.geojson = v; } constructor( options = {} ) { super( options ); this.imageSource = new GeoJSONImageSource( options ); this._redrawQueue = new PriorityQueue(); this._redrawQueue.maxJobs = 4; this._redrawQueue.priorityCallback = () => 0; } _init() { return this.imageSource.init(); } hasContent( range ) { return this.imageSource.hasContent( ...range ); } getTexture( range ) { return this.imageSource.get( ...range ); } lockTexture( range ) { return this.imageSource.lock( ...range ); } releaseTexture( range ) { this.imageSource.release( ...range ); } setResolution( resolution ) { this.imageSource.resolution = resolution; } shouldSplit( range ) { // geojson can always split return true; } setRegionVisible( range, visible ) { super.setRegionVisible( range, visible ); if ( visible ) { const { _redrawQueue } = this; const key = range.join( '_' ); if ( _redrawQueue.has( key ) ) { _redrawQueue.flush( key ); } } } redraw() { const { imageSource, _redrawQueue, _visibleRegionCounts, } = this; for ( const { range } of _visibleRegionCounts.values() ) { imageSource.redraw( ...range ); } imageSource.forEachItem( ( _, args ) => { const key = args.join( '_' ); if ( ! _visibleRegionCounts.has( key ) && ! _redrawQueue.has( key ) ) { _redrawQueue.add( key, () => { imageSource.redraw( ...args ); } ); } } ); } } /** * Overlay that renders WMS (Web Map Service) image tiles on top of 3D tile geometry. * See the {@link https://www.ogc.org/standard/wms/ WMS specification}. * @extends TiledImageOverlay * @param {Object} [options] * @param {string} [options.url] WMS base URL. * @param {string} [options.layer] WMS layer name. * @param {string} [options.crs] Coordinate reference system, e.g. `'EPSG:4326'`. * @param {string} [options.format] Image MIME type, e.g. `'image/png'`. * @param {number} [options.tileDimension=256] Tile pixel size. * @param {string} [options.styles] WMS styles parameter. * @param {string} [options.version='1.3.0'] WMS version string. * @param {boolean} [options.transparent=false] Whether to request a transparent image. * @param {number} [options.levels=18] Number of zoom levels. * @param {number[]|null} [options.contentBoundingBox=null] Content bounding box in radians `[west, south, east, north]`. If null, uses full projection bounds. * @param {number} [options.opacity=1] Overlay opacity (0–1). * @param {number|Color} [options.color=0xffffff] Tint color. * @param {Matrix4} [options.frame=null] Planar projection frame. If null, cartographic projection is used. * @param {Function} [options.preprocessURL=null] URL rewriting callback. * @param {boolean} [options.alphaMask=false] Use alpha channel as a surface mask. * @param {boolean} [options.alphaInvert=false] Invert the alpha channel. */ export class WMSTilesOverlay extends TiledImageOverlay { constructor( options = {} ) { super( options ); this.imageSource = new WMSImageSource( options ); } } /** * Overlay that renders WMTS (Web Map Tile Service) image tiles on top of 3D tile geometry. * Pass a parsed capabilities object from `WMTSCapabilitiesLoader` or provide a URL template * directly. See the {@link https://www.ogc.org/standard/wmts/ WMTS specification}. * @extends TiledImageOverlay * @param {Object} [options] * @param {string} [options.url] - WMTS service URL. * @param {string} [options.layer] - WMTS layer identifier. * @param {string} [options.tileMatrixSet] - TileMatrixSet identifier (e.g., 'GoogleMapsCompatible', 'EPSG:3857'). * @param {string} [options.style='default'] - Style identifier. * @param {string} [options.format='image/jpeg'] - Output image format (e.g., 'image/png', 'image/jpeg'). * @param {Object<string, string|number>|null} [options.dimensions=null] - WMTS dimension values * @param {string[]|null} [options.tileMatrixLabels=null] - Custom TileMatrix identifiers per level * @param {WMTSTileMatrix[]|null} [options.tileMatrices=null] - Explicit per-level tile matrix definitions. When provided, `levels` and `tileMatrixLabels` are ignored. * @param {string|null} [options.projection=null] - Projection identifier ('EPSG:3857' or 'EPSG:4326'). Defaults to 'EPSG:3857' if not specified. * @param {number} [options.levels=20] - Number of zoom levels. Ignored if `tileMatrices` is provided. * @param {number} [options.tileDimension=256] - Default tile width and height in pixels. * @param {number[]|null} [options.contentBoundingBox=null] - Content bounding box in radians, `[west, south, east, north]`. If null, uses full projection bounds. */ export class WMTSTilesOverlay extends TiledImageOverlay { constructor( options = {} ) { super( options ); this.imageSource = new WMTSImageSource( options ); } } /** *