From 2edf218b28ebc40ad6798f0d024c93f3ce6540cf Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 12 Apr 2026 16:41:14 +0900 Subject: [PATCH 01/35] Add initial "GeneratedSurfacePlugin" --- .../plugins/images/GeneratedSurfacePlugin.js | 416 ++++++++++++++++++ .../plugins/images/utils/ProjectionScheme.js | 6 + 2 files changed, 422 insertions(+) create mode 100644 src/three/plugins/images/GeneratedSurfacePlugin.js diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js new file mode 100644 index 000000000..8a6dcbc3c --- /dev/null +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -0,0 +1,416 @@ +import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector2, Vector3, Sphere } from 'three'; +import { TILE_X, TILE_Y, TILE_LEVEL } from './ImageFormatPlugin.js'; +import { getCartographicToMeterDerivative } from './utils/getCartographicToMeterDerivative.js'; + +const MIN_LON_VERTS = 30; +const MIN_LAT_VERTS = 15; + +const _uv = /* @__PURE__ */ new Vector2(); +const _pos = /* @__PURE__ */ new Vector3(); +const _norm = /* @__PURE__ */ new Vector3(); +const _sphere = /* @__PURE__ */ new Sphere(); + +/** + * Plugin that generates tiled surface geometry from a tiling scheme, without loading + * any image data. Intended to be paired with `ImageOverlayPlugin` which handles + * image fetching and texturing separately. + * + * The tiling scheme and projection are derived from a provided overlay or image source. + * If the source's projection is cartographic (any EPSG scheme), the plugin supports + * both planar and ellipsoidal geometry via the `shape` option. + * + * @param {Object} [options] + * @param {Object} [options.overlay=null] Overlay instance to derive the image source from. + * @param {Object} [options.imageSource=null] Image source providing tiling metadata directly. + * @param {string} [options.shape='planar'] Geometry shape: `'planar'` or `'ellipsoid'`. Only + * meaningful for cartographic sources. + * @param {boolean} [options.endCaps=true] For Mercator ellipsoid mode, snap poles to ±90° lat. + * @param {boolean} [options.center=false] Shift planar tiles so the image is centered at origin. + * @param {boolean} [options.useRecommendedSettings=true] Apply recommended TilesRenderer settings. + */ +export class GeneratedSurfacePlugin { + + get tiling() { + + return this.imageSource.tiling; + + } + + get projection() { + + return this.tiling.projection; + + } + + constructor( options = {} ) { + + const { + overlay = null, + imageSource = null, + shape = 'planar', + endCaps = true, + center = false, + useRecommendedSettings = true, + } = options; + + this.priority = - 10; + this.tiles = null; + + this.imageSource = imageSource || ( overlay ? overlay.imageSource : null ); + this.shape = shape; + this.endCaps = endCaps; + this.center = center; + this.useRecommendedSettings = useRecommendedSettings; + + } + + // Plugin functions + init( tiles ) { + + if ( this.useRecommendedSettings ) { + + tiles.errorTarget = 1; + + } + + this.tiles = tiles; + + const { imageSource } = this; + if ( imageSource ) { + + imageSource.fetchOptions = tiles.fetchOptions; + imageSource.fetchData = ( url, options ) => { + + tiles.invokeAllPlugins( plugin => url = plugin.preprocessURL ? plugin.preprocessURL( url, null ) : url ); + return tiles.invokeOnePlugin( plugin => plugin !== this && plugin.fetchData && plugin.fetchData( url, options ) ); + + }; + + } + + } + + async loadRootTileset() { + + const { tiles, imageSource } = this; + imageSource.url = imageSource.url || tiles.rootURL; + tiles.invokeAllPlugins( plugin => imageSource.url = plugin.preprocessURL ? plugin.preprocessURL( imageSource.url, null ) : imageSource.url ); + await imageSource.init(); + + tiles.rootURL = imageSource.url; + return this.getTileset( imageSource.url ); + + } + + async parseToMesh( buffer, tile, extension, uri, abortSignal ) { + + if ( abortSignal.aborted ) { + + return null; + + } + + if ( extension !== 'generated_surface' ) { + + return null; + + } + + const { shape, projection } = this; + if ( projection.isCartographic && shape === 'ellipsoid' ) { + + return this._createEllipsoidMesh( tile ); + + } else { + + return this._createPlanarMesh( tile ); + + } + + } + + preprocessNode( tile ) { + + const { tiling } = this; + const maxLevel = tiling.maxLevel; + const level = tile[ TILE_LEVEL ]; + if ( level < maxLevel && tile.parent !== null ) { + + this.expandChildren( tile ); + + } + + } + + disposeTile( /* tile */ ) { + + // No texture data to release — geometry is managed by the renderer + + } + + // Local functions + _createPlanarMesh( tile ) { + + const boundingBox = tile.boundingVolume.box; + let sx = 1, sy = 1, x = 0, y = 0, z = 0; + if ( boundingBox ) { + + [ x, y, z ] = boundingBox; + sx = boundingBox[ 3 ]; + sy = boundingBox[ 7 ]; + + } + + const geometry = new PlaneGeometry( 2 * sx, 2 * sy ); + const mesh = new Mesh( geometry, new MeshBasicMaterial( { transparent: true, opacity: 0, depthWrite: false } ) ); + mesh.position.set( x, y, z ); + return mesh; + + } + + _createEllipsoidMesh( tile ) { + + const { projection, tiling, tiles, endCaps } = this; + const level = tile[ TILE_LEVEL ]; + const x = tile[ TILE_X ]; + const y = tile[ TILE_Y ]; + + const [ west, south, east, north ] = tile.boundingVolume.region; + const latVerts = Math.max( MIN_LAT_VERTS, Math.ceil( ( north - south ) * MathUtils.RAD2DEG * 0.25 ) ); + const lonVerts = Math.max( MIN_LON_VERTS, Math.ceil( ( east - west ) * MathUtils.RAD2DEG * 0.25 ) ); + const geometry = new PlaneGeometry( 1, 1, lonVerts, latVerts ); + + const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true, true ); + + const { position, normal, uv } = geometry.attributes; + const vertCount = position.count; + tile.engineData.boundingVolume.getSphere( _sphere ); + + for ( let i = 0; i < vertCount; i ++ ) { + + _uv.fromBufferAttribute( uv, i ); + + const lon = projection.convertNormalizedToLongitude( MathUtils.mapLinear( _uv.x, 0, 1, minU, maxU ) ); + let lat = projection.convertNormalizedToLatitude( MathUtils.mapLinear( _uv.y, 0, 1, minV, maxV ) ); + + // snap edges to poles for Mercator to avoid seams + if ( projection.isMercator && endCaps ) { + + if ( maxV === 1 && _uv.y === 1 ) lat = Math.PI / 2; + if ( minV === 0 && _uv.y === 0 ) lat = - Math.PI / 2; + + } + + // insert edge loop at Mercator lat limit to reduce UV distortion at low LoDs + if ( projection.isMercator && _uv.y !== 0 && _uv.y !== 1 ) { + + const latLimit = projection.convertNormalizedToLatitude( 1 ); + const vStep = 1 / latVerts; + const prevLat = MathUtils.mapLinear( _uv.y - vStep, 0, 1, south, north ); + const nextLat = MathUtils.mapLinear( _uv.y + vStep, 0, 1, south, north ); + + if ( lat > latLimit && prevLat < latLimit ) lat = latLimit; + if ( lat < - latLimit && nextLat > - latLimit ) lat = - latLimit; + + } + + tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, _pos ).sub( _sphere.center ); + tiles.ellipsoid.getCartographicToNormal( lat, lon, _norm ); + + position.setXYZ( i, _pos.x, _pos.y, _pos.z ); + normal.setXYZ( i, _norm.x, _norm.y, _norm.z ); + + } + + const mesh = new Mesh( geometry, new MeshBasicMaterial( { transparent: true, opacity: 0, depthWrite: false } ) ); + mesh.position.copy( _sphere.center ); + return mesh; + + } + + getTileset( baseUrl ) { + + const { tiling, tiles } = this; + const minLevel = tiling.minLevel; + const { tileCountX, tileCountY } = tiling.getLevel( minLevel ); + + const children = []; + for ( let x = 0; x < tileCountX; x ++ ) { + + for ( let y = 0; y < tileCountY; y ++ ) { + + const child = this.createChild( x, y, minLevel ); + if ( child !== null ) { + + children.push( child ); + + } + + } + + } + + const tileset = { + asset: { version: '1.1' }, + geometricError: Infinity, + root: { + refine: 'REPLACE', + geometricError: Infinity, + boundingVolume: this.createBoundingVolume( 0, 0, - 1 ), + children, + + [ TILE_LEVEL ]: - 1, + [ TILE_X ]: 0, + [ TILE_Y ]: 0, + }, + }; + + tiles.preprocessTileset( tileset, baseUrl ); + return tileset; + + } + + getUrl( /* x, y, level */ ) { + + return 'tile.generated_surface'; + + } + + fetchData( uri ) { + + if ( /generated_surface/.test( uri ) ) { + + return new ArrayBuffer(); + + } + + } + + createBoundingVolume( x, y, level ) { + + const { shape, tiling, projection } = this; + + if ( projection.isCartographic && shape === 'ellipsoid' ) { + + const { endCaps } = this; + const isRoot = level === - 1; + const normalizedBounds = isRoot ? tiling.getContentBounds( true ) : tiling.getTileBounds( x, y, level, true, true ); + const cartBounds = isRoot ? tiling.getContentBounds() : tiling.getTileBounds( x, y, level, false, true ); + + if ( endCaps ) { + + if ( normalizedBounds[ 3 ] === 1 ) cartBounds[ 3 ] = Math.PI / 2; + if ( normalizedBounds[ 1 ] === 0 ) cartBounds[ 1 ] = - Math.PI / 2; + + } + + return { region: [ ...cartBounds, - 1, 1 ] }; + + } else { + + const { center } = this; + const [ minX, minY, maxX, maxY ] = level === - 1 + ? tiling.getContentBounds( true ) + : tiling.getTileBounds( x, y, level, true ); + + let extentsX = ( maxX - minX ) / 2; + let extentsY = ( maxY - minY ) / 2; + let centerX = minX + extentsX; + let centerY = minY + extentsY; + + if ( center ) { + + centerX -= 0.5; + centerY -= 0.5; + + } + + centerX *= tiling.aspectRatio; + extentsX *= tiling.aspectRatio; + + return { + box: [ + centerX, centerY, 0, + extentsX, 0.0, 0.0, + 0.0, extentsY, 0.0, + 0.0, 0.0, 0.0, + ], + }; + + } + + } + + createChild( x, y, level ) { + + const { tiling, shape, projection } = this; + if ( ! tiling.getTileExists( x, y, level ) ) { + + return null; + + } + + let geometricError; + if ( projection.isCartographic && shape === 'ellipsoid' ) { + + const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true ); + const { tilePixelWidth, tilePixelHeight } = tiling.getLevel( level ); + + const tileUWidth = ( maxU - minU ) / tilePixelWidth; + const tileVWidth = ( maxV - minV ) / tilePixelHeight; + + const [ , south, east, north ] = tiling.getTileBounds( x, y, level ); + const midLat = ( south > 0 ) !== ( north > 0 ) ? 0 : Math.min( Math.abs( south ), Math.abs( north ) ); + const midV = projection.convertLatitudeToNormalized( midLat ); + const lonFactor = projection.getLongitudeDerivativeAtNormalized( minU ); + const latFactor = projection.getLatitudeDerivativeAtNormalized( midV ); + + const [ xDeriv, yDeriv ] = getCartographicToMeterDerivative( this.tiles.ellipsoid, midLat, east ); + geometricError = Math.max( tileUWidth * lonFactor * xDeriv, tileVWidth * latFactor * yDeriv ); + + } else { + + const { pixelWidth, pixelHeight } = tiling.getLevel( level ); + geometricError = Math.max( tiling.aspectRatio / pixelWidth, 1 / pixelHeight ); + + } + + return { + refine: 'REPLACE', + geometricError, + boundingVolume: this.createBoundingVolume( x, y, level ), + content: { uri: this.getUrl( x, y, level ) }, + children: [], + + [ TILE_X ]: x, + [ TILE_Y ]: y, + [ TILE_LEVEL ]: level, + }; + + } + + expandChildren( tile ) { + + const level = tile[ TILE_LEVEL ]; + const x = tile[ TILE_X ]; + const y = tile[ TILE_Y ]; + + const { tileSplitX, tileSplitY } = this.tiling.getLevel( level ); + for ( let cx = 0; cx < tileSplitX; cx ++ ) { + + for ( let cy = 0; cy < tileSplitY; cy ++ ) { + + const child = this.createChild( tileSplitX * x + cx, tileSplitY * y + cy, level + 1 ); + if ( child ) { + + tile.children.push( child ); + + } + + } + + } + + } + +} diff --git a/src/three/plugins/images/utils/ProjectionScheme.js b/src/three/plugins/images/utils/ProjectionScheme.js index 9b80b813b..5d9c0f19f 100644 --- a/src/three/plugins/images/utils/ProjectionScheme.js +++ b/src/three/plugins/images/utils/ProjectionScheme.js @@ -10,6 +10,12 @@ export class ProjectionScheme { } + get isCartographic() { + + return this.scheme !== 'none'; + + } + constructor( scheme = 'EPSG:4326' ) { this.scheme = scheme; From 141be51d3f7b421ee9722042719c68365e9409ad Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 12 Apr 2026 19:32:11 +0900 Subject: [PATCH 02/35] Adjust "mapTiles" demo --- example/three/mapTiles.js | 9 ++++++--- src/three/plugins/index.js | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/example/three/mapTiles.js b/example/three/mapTiles.js index 1e5b8acfc..aa327b180 100644 --- a/example/three/mapTiles.js +++ b/example/three/mapTiles.js @@ -4,7 +4,7 @@ import { PerspectiveCamera, } from 'three'; import { TilesRenderer, GlobeControls, EnvironmentControls } from '3d-tiles-renderer'; -import { TilesFadePlugin, UpdateOnChangePlugin, XYZTilesPlugin, } from '3d-tiles-renderer/plugins'; +import { TilesFadePlugin, UpdateOnChangePlugin, GeneratedSurfacePlugin, ImageOverlayPlugin, XYZTilesOverlay } from '3d-tiles-renderer/plugins'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; let controls, scene, renderer; @@ -75,13 +75,16 @@ function initTiles() { // tiles tiles = new TilesRenderer(); + const overlay = new XYZTilesOverlay( { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' } ); + tiles.registerPlugin( new TilesFadePlugin( { maximumFadeOutTiles: 200 } ) ); tiles.registerPlugin( new UpdateOnChangePlugin() ); - tiles.registerPlugin( new XYZTilesPlugin( { + tiles.registerPlugin( new GeneratedSurfacePlugin( { + overlay, center: true, shape: params.planar ? 'planar' : 'ellipsoid', - url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' } ) ); + tiles.registerPlugin( new ImageOverlayPlugin( { renderer, overlays: [ overlay ] } ) ); tiles.lruCache.minSize = 900; tiles.lruCache.maxSize = 1300; diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js index 55a450335..3ce6fb8ac 100644 --- a/src/three/plugins/index.js +++ b/src/three/plugins/index.js @@ -14,6 +14,7 @@ export * from './LoadRegionPlugin.js'; export * from './DebugTilesPlugin.js'; // other formats +export * from './images/GeneratedSurfacePlugin.js'; export * from './images/DeepZoomImagePlugin.js'; export * from './images/EPSGTilesPlugin.js'; From 960a2a15f6c5025620cb1b135483c1be5fbd1d88 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 10:47:28 +0900 Subject: [PATCH 03/35] Cleanup --- .../plugins/images/GeneratedSurfacePlugin.js | 88 +++++++++---------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 8a6dcbc3c..9ddf613f0 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -1,3 +1,4 @@ +/** @import { TiledImageOverlay } from './ImageOverlayPlugin.js' */ import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector2, Vector3, Sphere } from 'three'; import { TILE_X, TILE_Y, TILE_LEVEL } from './ImageFormatPlugin.js'; import { getCartographicToMeterDerivative } from './utils/getCartographicToMeterDerivative.js'; @@ -20,46 +21,41 @@ const _sphere = /* @__PURE__ */ new Sphere(); * both planar and ellipsoidal geometry via the `shape` option. * * @param {Object} [options] - * @param {Object} [options.overlay=null] Overlay instance to derive the image source from. - * @param {Object} [options.imageSource=null] Image source providing tiling metadata directly. + * @param {TiledImageOverlay} [options.overlay=null] Overlay instance to derive the tiling scheme from. * @param {string} [options.shape='planar'] Geometry shape: `'planar'` or `'ellipsoid'`. Only * meaningful for cartographic sources. * @param {boolean} [options.endCaps=true] For Mercator ellipsoid mode, snap poles to ±90° lat. - * @param {boolean} [options.center=false] Shift planar tiles so the image is centered at origin. + * @param {boolean} [options.center=true] Shift planar tiles so the image is centered at origin. * @param {boolean} [options.useRecommendedSettings=true] Apply recommended TilesRenderer settings. */ export class GeneratedSurfacePlugin { - get tiling() { - - return this.imageSource.tiling; - - } - - get projection() { - - return this.tiling.projection; - - } - constructor( options = {} ) { + // TODO: + // - automatically add texture overlay here + // - defaults to a basic quad, equirect surface otherwise + // - add skirts (option?) + // - need a target projection for planar definitions? How can we display the tiled + // carto image set as it's original aspect / projection? Separate option? + const { overlay = null, - imageSource = null, shape = 'planar', endCaps = true, - center = false, + center = true, useRecommendedSettings = true, + transparent = false, } = options; this.priority = - 10; this.tiles = null; - this.imageSource = imageSource || ( overlay ? overlay.imageSource : null ); + this.overlay = overlay; this.shape = shape; this.endCaps = endCaps; this.center = center; + this.transparent = transparent; this.useRecommendedSettings = useRecommendedSettings; } @@ -75,24 +71,21 @@ export class GeneratedSurfacePlugin { this.tiles = tiles; - const { imageSource } = this; - if ( imageSource ) { - - imageSource.fetchOptions = tiles.fetchOptions; - imageSource.fetchData = ( url, options ) => { + const { imageSource } = this.overlay; + imageSource.fetchOptions = tiles.fetchOptions; + imageSource.fetchData = ( url, options ) => { - tiles.invokeAllPlugins( plugin => url = plugin.preprocessURL ? plugin.preprocessURL( url, null ) : url ); - return tiles.invokeOnePlugin( plugin => plugin !== this && plugin.fetchData && plugin.fetchData( url, options ) ); + tiles.invokeAllPlugins( plugin => url = plugin.preprocessURL ? plugin.preprocessURL( url, null ) : url ); + return tiles.invokeOnePlugin( plugin => plugin !== this && plugin.fetchData && plugin.fetchData( url, options ) ); - }; - - } + }; } async loadRootTileset() { - const { tiles, imageSource } = this; + const { tiles } = this; + const { imageSource } = this.overlay; imageSource.url = imageSource.url || tiles.rootURL; tiles.invokeAllPlugins( plugin => imageSource.url = plugin.preprocessURL ? plugin.preprocessURL( imageSource.url, null ) : imageSource.url ); await imageSource.init(); @@ -116,22 +109,27 @@ export class GeneratedSurfacePlugin { } - const { shape, projection } = this; + const { shape, transparent } = this; + const { projection } = this.overlay; + let res; if ( projection.isCartographic && shape === 'ellipsoid' ) { - return this._createEllipsoidMesh( tile ); + res = this._createEllipsoidMesh( tile ); } else { - return this._createPlanarMesh( tile ); + res = this._createPlanarMesh( tile ); } + res.material.transparent = transparent; + return res; + } preprocessNode( tile ) { - const { tiling } = this; + const { tiling } = this.overlay; const maxLevel = tiling.maxLevel; const level = tile[ TILE_LEVEL ]; if ( level < maxLevel && tile.parent !== null ) { @@ -142,12 +140,6 @@ export class GeneratedSurfacePlugin { } - disposeTile( /* tile */ ) { - - // No texture data to release — geometry is managed by the renderer - - } - // Local functions _createPlanarMesh( tile ) { @@ -162,7 +154,7 @@ export class GeneratedSurfacePlugin { } const geometry = new PlaneGeometry( 2 * sx, 2 * sy ); - const mesh = new Mesh( geometry, new MeshBasicMaterial( { transparent: true, opacity: 0, depthWrite: false } ) ); + const mesh = new Mesh( geometry, new MeshBasicMaterial() ); mesh.position.set( x, y, z ); return mesh; @@ -170,7 +162,8 @@ export class GeneratedSurfacePlugin { _createEllipsoidMesh( tile ) { - const { projection, tiling, tiles, endCaps } = this; + const { tiles, endCaps } = this; + const { projection, tiling } = this.overlay; const level = tile[ TILE_LEVEL ]; const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; @@ -222,7 +215,7 @@ export class GeneratedSurfacePlugin { } - const mesh = new Mesh( geometry, new MeshBasicMaterial( { transparent: true, opacity: 0, depthWrite: false } ) ); + const mesh = new Mesh( geometry, new MeshBasicMaterial() ); mesh.position.copy( _sphere.center ); return mesh; @@ -230,7 +223,8 @@ export class GeneratedSurfacePlugin { getTileset( baseUrl ) { - const { tiling, tiles } = this; + const { tiles } = this; + const { tiling } = this.overlay; const minLevel = tiling.minLevel; const { tileCountX, tileCountY } = tiling.getLevel( minLevel ); @@ -288,7 +282,8 @@ export class GeneratedSurfacePlugin { createBoundingVolume( x, y, level ) { - const { shape, tiling, projection } = this; + const { shape } = this; + const { tiling, projection } = this.overlay; if ( projection.isCartographic && shape === 'ellipsoid' ) { @@ -343,7 +338,8 @@ export class GeneratedSurfacePlugin { createChild( x, y, level ) { - const { tiling, shape, projection } = this; + const { shape } = this; + const { tiling, projection } = this.overlay; if ( ! tiling.getTileExists( x, y, level ) ) { return null; @@ -395,7 +391,7 @@ export class GeneratedSurfacePlugin { const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; - const { tileSplitX, tileSplitY } = this.tiling.getLevel( level ); + const { tileSplitX, tileSplitY } = this.overlay.tiling.getLevel( level ); for ( let cx = 0; cx < tileSplitX; cx ++ ) { for ( let cy = 0; cy < tileSplitY; cy ++ ) { From 7cf6042cf48068b4fe89ffb49c14c5033df395b8 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 11:26:14 +0900 Subject: [PATCH 04/35] Embed overlays --- example/three/mapTiles.js | 9 +- .../plugins/images/GeneratedSurfacePlugin.js | 83 ++++++++++++++++--- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/example/three/mapTiles.js b/example/three/mapTiles.js index aa327b180..697d42700 100644 --- a/example/three/mapTiles.js +++ b/example/three/mapTiles.js @@ -4,7 +4,7 @@ import { PerspectiveCamera, } from 'three'; import { TilesRenderer, GlobeControls, EnvironmentControls } from '3d-tiles-renderer'; -import { TilesFadePlugin, UpdateOnChangePlugin, GeneratedSurfacePlugin, ImageOverlayPlugin, XYZTilesOverlay } from '3d-tiles-renderer/plugins'; +import { TilesFadePlugin, UpdateOnChangePlugin, GeneratedSurfacePlugin, XYZTilesOverlay } from '3d-tiles-renderer/plugins'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; let controls, scene, renderer; @@ -75,22 +75,19 @@ function initTiles() { // tiles tiles = new TilesRenderer(); - const overlay = new XYZTilesOverlay( { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' } ); - tiles.registerPlugin( new TilesFadePlugin( { maximumFadeOutTiles: 200 } ) ); tiles.registerPlugin( new UpdateOnChangePlugin() ); tiles.registerPlugin( new GeneratedSurfacePlugin( { - overlay, - center: true, + overlay: new XYZTilesOverlay( { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' } ), shape: params.planar ? 'planar' : 'ellipsoid', } ) ); - tiles.registerPlugin( new ImageOverlayPlugin( { renderer, overlays: [ overlay ] } ) ); tiles.lruCache.minSize = 900; tiles.lruCache.maxSize = 1300; tiles.parseQueue.maxJobs = 3; tiles.setCamera( camera ); scene.add( tiles.group ); + window.TILES = tiles; if ( params.planar ) { diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 9ddf613f0..0bfc347e7 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -33,7 +33,6 @@ export class GeneratedSurfacePlugin { constructor( options = {} ) { // TODO: - // - automatically add texture overlay here // - defaults to a basic quad, equirect surface otherwise // - add skirts (option?) // - need a target projection for planar definitions? How can we display the tiled @@ -71,24 +70,20 @@ export class GeneratedSurfacePlugin { this.tiles = tiles; - const { imageSource } = this.overlay; - imageSource.fetchOptions = tiles.fetchOptions; - imageSource.fetchData = ( url, options ) => { - - tiles.invokeAllPlugins( plugin => url = plugin.preprocessURL ? plugin.preprocessURL( url, null ) : url ); - return tiles.invokeOnePlugin( plugin => plugin !== this && plugin.fetchData && plugin.fetchData( url, options ) ); - - }; + this.overlay.imageSource.fetchOptions = tiles.fetchOptions; } async loadRootTileset() { const { tiles } = this; - const { imageSource } = this.overlay; + const { overlay } = this; + const { imageSource } = overlay; imageSource.url = imageSource.url || tiles.rootURL; tiles.invokeAllPlugins( plugin => imageSource.url = plugin.preprocessURL ? plugin.preprocessURL( imageSource.url, null ) : imageSource.url ); - await imageSource.init(); + + // overlay.init() initializes the image source and creates regionImageSource + await overlay.init(); tiles.rootURL = imageSource.url; return this.getTileset( imageSource.url ); @@ -109,8 +104,8 @@ export class GeneratedSurfacePlugin { } - const { shape, transparent } = this; - const { projection } = this.overlay; + const { shape, transparent, overlay } = this; + const { projection, tiling } = overlay; let res; if ( projection.isCartographic && shape === 'ellipsoid' ) { @@ -123,6 +118,52 @@ export class GeneratedSurfacePlugin { } res.material.transparent = transparent; + + // Apply the overlay texture directly + if ( ! overlay.isReady ) { + + await overlay.whenReady(); + + } + + if ( abortSignal.aborted ) { + + return null; + + } + + const x = tile[ TILE_X ]; + const y = tile[ TILE_Y ]; + const level = tile[ TILE_LEVEL ]; + const range = tiling.getTileBounds( x, y, level, true, true ); + + await overlay.lockTexture( range ); + + if ( abortSignal.aborted ) { + + overlay.releaseTexture( range ); + return null; + + } + + tile.overlayRange = range; + + if ( overlay.hasContent( range ) ) { + + const texture = await overlay.getTexture( range ); + + if ( abortSignal.aborted ) { + + overlay.releaseTexture( range ); + return null; + + } + + res.material.map = texture; + res.material.needsUpdate = true; + + } + return res; } @@ -140,6 +181,17 @@ export class GeneratedSurfacePlugin { } + disposeTile( tile ) { + + const { overlayRange } = tile; + if ( overlayRange ) { + + this.overlay.releaseTexture( overlayRange ); + + } + + } + // Local functions _createPlanarMesh( tile ) { @@ -210,8 +262,13 @@ export class GeneratedSurfacePlugin { tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, _pos ).sub( _sphere.center ); tiles.ellipsoid.getCartographicToNormal( lat, lon, _norm ); + // derive UV from the final (potentially adjusted) lat/lon so the overlay samples correctly + const u = MathUtils.mapLinear( projection.convertLongitudeToNormalized( lon ), minU, maxU, 0, 1 ); + const v = MathUtils.mapLinear( projection.convertLatitudeToNormalized( lat ), minV, maxV, 0, 1 ); + position.setXYZ( i, _pos.x, _pos.y, _pos.z ); normal.setXYZ( i, _norm.x, _norm.y, _norm.z ); + uv.setXY( i, u, v ); } From 2dfac0f64ff4777039518b871283473a99aefcd5 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 11:47:58 +0900 Subject: [PATCH 05/35] Add skirts --- .../plugins/images/GeneratedSurfacePlugin.js | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 0bfc347e7..f0752be53 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -1,12 +1,11 @@ /** @import { TiledImageOverlay } from './ImageOverlayPlugin.js' */ -import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector2, Vector3, Sphere } from 'three'; +import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector3, Sphere } from 'three'; import { TILE_X, TILE_Y, TILE_LEVEL } from './ImageFormatPlugin.js'; import { getCartographicToMeterDerivative } from './utils/getCartographicToMeterDerivative.js'; const MIN_LON_VERTS = 30; const MIN_LAT_VERTS = 15; -const _uv = /* @__PURE__ */ new Vector2(); const _pos = /* @__PURE__ */ new Vector3(); const _norm = /* @__PURE__ */ new Vector3(); const _sphere = /* @__PURE__ */ new Sphere(); @@ -34,7 +33,6 @@ export class GeneratedSurfacePlugin { // TODO: // - defaults to a basic quad, equirect surface otherwise - // - add skirts (option?) // - need a target projection for planar definitions? How can we display the tiled // carto image set as it's original aspect / projection? Separate option? @@ -118,6 +116,7 @@ export class GeneratedSurfacePlugin { } res.material.transparent = transparent; + res.material.side = 2; // Apply the overlay texture directly if ( ! overlay.isReady ) { @@ -223,7 +222,9 @@ export class GeneratedSurfacePlugin { const [ west, south, east, north ] = tile.boundingVolume.region; const latVerts = Math.max( MIN_LAT_VERTS, Math.ceil( ( north - south ) * MathUtils.RAD2DEG * 0.25 ) ); const lonVerts = Math.max( MIN_LON_VERTS, Math.ceil( ( east - west ) * MathUtils.RAD2DEG * 0.25 ) ); - const geometry = new PlaneGeometry( 1, 1, lonVerts, latVerts ); + const cols = lonVerts + 3; + const rows = latVerts + 3; + const geometry = new PlaneGeometry( 1, 1, lonVerts + 2, latVerts + 2 ); const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true, true ); @@ -233,26 +234,33 @@ export class GeneratedSurfacePlugin { for ( let i = 0; i < vertCount; i ++ ) { - _uv.fromBufferAttribute( uv, i ); + const col = i % cols; + const row = Math.floor( i / cols ); + const isSkirt = col === 0 || col === cols - 1 || row === 0 || row === rows - 1; - const lon = projection.convertNormalizedToLongitude( MathUtils.mapLinear( _uv.x, 0, 1, minU, maxU ) ); - let lat = projection.convertNormalizedToLatitude( MathUtils.mapLinear( _uv.y, 0, 1, minV, maxV ) ); + const innerCol = Math.max( 1, Math.min( cols - 2, col ) ); + const innerRow = Math.max( 1, Math.min( rows - 2, row ) ); + const uNorm = ( innerCol - 1 ) / lonVerts; + const vNorm = 1 - ( innerRow - 1 ) / latVerts; + + const lon = projection.convertNormalizedToLongitude( MathUtils.mapLinear( uNorm, 0, 1, minU, maxU ) ); + let lat = projection.convertNormalizedToLatitude( MathUtils.mapLinear( vNorm, 0, 1, minV, maxV ) ); // snap edges to poles for Mercator to avoid seams if ( projection.isMercator && endCaps ) { - if ( maxV === 1 && _uv.y === 1 ) lat = Math.PI / 2; - if ( minV === 0 && _uv.y === 0 ) lat = - Math.PI / 2; + if ( maxV === 1 && vNorm === 1 ) lat = Math.PI / 2; + if ( minV === 0 && vNorm === 0 ) lat = - Math.PI / 2; } // insert edge loop at Mercator lat limit to reduce UV distortion at low LoDs - if ( projection.isMercator && _uv.y !== 0 && _uv.y !== 1 ) { + if ( projection.isMercator && vNorm !== 0 && vNorm !== 1 ) { const latLimit = projection.convertNormalizedToLatitude( 1 ); const vStep = 1 / latVerts; - const prevLat = MathUtils.mapLinear( _uv.y - vStep, 0, 1, south, north ); - const nextLat = MathUtils.mapLinear( _uv.y + vStep, 0, 1, south, north ); + const prevLat = MathUtils.mapLinear( vNorm - vStep, 0, 1, south, north ); + const nextLat = MathUtils.mapLinear( vNorm + vStep, 0, 1, south, north ); if ( lat > latLimit && prevLat < latLimit ) lat = latLimit; if ( lat < - latLimit && nextLat > - latLimit ) lat = - latLimit; @@ -262,6 +270,12 @@ export class GeneratedSurfacePlugin { tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, _pos ).sub( _sphere.center ); tiles.ellipsoid.getCartographicToNormal( lat, lon, _norm ); + if ( isSkirt ) { + + _pos.addScaledVector( _norm, - tile.geometricError ); + + } + // derive UV from the final (potentially adjusted) lat/lon so the overlay samples correctly const u = MathUtils.mapLinear( projection.convertLongitudeToNormalized( lon ), minU, maxU, 0, 1 ); const v = MathUtils.mapLinear( projection.convertLatitudeToNormalized( lat ), minV, maxV, 0, 1 ); @@ -337,7 +351,7 @@ export class GeneratedSurfacePlugin { } - createBoundingVolume( x, y, level ) { + createBoundingVolume( x, y, level, skirtDepth = 0 ) { const { shape } = this; const { tiling, projection } = this.overlay; @@ -356,7 +370,7 @@ export class GeneratedSurfacePlugin { } - return { region: [ ...cartBounds, - 1, 1 ] }; + return { region: [ ...cartBounds, - skirtDepth, 1 ] }; } else { @@ -431,7 +445,7 @@ export class GeneratedSurfacePlugin { return { refine: 'REPLACE', geometricError, - boundingVolume: this.createBoundingVolume( x, y, level ), + boundingVolume: this.createBoundingVolume( x, y, level, projection.isCartographic && shape === 'ellipsoid' ? geometricError : 0 ), content: { uri: this.getUrl( x, y, level ) }, children: [], From efa5317628bcb33bcd50bc7f206c086609120c3b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 12:09:14 +0900 Subject: [PATCH 06/35] Add DeepZoom overlay --- src/three/plugins/images/ImageFormatPlugin.js | 2 ++ .../plugins/images/ImageOverlayPlugin.js | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/three/plugins/images/ImageFormatPlugin.js b/src/three/plugins/images/ImageFormatPlugin.js index 2f24669be..c24181579 100644 --- a/src/three/plugins/images/ImageFormatPlugin.js +++ b/src/three/plugins/images/ImageFormatPlugin.js @@ -25,6 +25,8 @@ export class ImageFormatPlugin { constructor( options = {} ) { + console.warn( '"ImageFormatPlugin" and derivative plugins have been deprecated. Use "GeneratedSurfacePlugin", instead.' ); + const { pixelSize = null, center = false, diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 6ef228d2d..741a06a3f 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -15,6 +15,7 @@ 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(); @@ -1551,6 +1552,25 @@ export class XYZTilesOverlay extends TiledImageOverlay { } +/** + * 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 ); + + } + +} + /** * 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 From 25591902f1b84305e483e8e65a828e08263f039f Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 12:13:42 +0900 Subject: [PATCH 07/35] Update docs --- src/three/plugins/API.md | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index 567f7a812..624909cee 100644 --- a/src/three/plugins/API.md +++ b/src/three/plugins/API.md @@ -192,6 +192,25 @@ Manages a `TiledImageSource` and a `RegionImageSource` that handles compositing multiple source tiles into a single texture per 3D tile region. +## DeepZoomOverlay + +_extends [`TiledImageOverlay`](#tiledimageoverlay)_ + +Plugin that renders a Deep Zoom Image (DZI) as a tiled overlay. Only a single embedded "Image" is supported. +See the [Deep Zoom specification](https://learn.microsoft.com/en-us/previous-versions/windows/silverlight/dotnet-windows-silverlight/cc645077(v=vs.95)) +and [OpenSeadragon](https://openseadragon.github.io). + + +### .constructor + +```js +constructor( + { + url?: string, + } +) +``` + ## GoogleMapsOverlay _extends [`TiledImageOverlay`](#tiledimageoverlay)_ @@ -566,6 +585,31 @@ constructor( ) ``` +## GeneratedSurfacePlugin + +Plugin that generates tiled surface geometry from a tiling scheme, without loading +any image data. Intended to be paired with `ImageOverlayPlugin` which handles +image fetching and texturing separately. + +The tiling scheme and projection are derived from a provided overlay or image source. +If the source's projection is cartographic (any EPSG scheme), the plugin supports +both planar and ellipsoidal geometry via the `shape` option. + + +### .constructor + +```js +constructor( + { + overlay = null: TiledImageOverlay, + shape = 'planar': string, + endCaps = true: boolean, + center = true: boolean, + useRecommendedSettings = true: boolean, + } +) +``` + ## GLTFCesiumRTCExtension GLTF loader plugin that applies the [CESIUM_RTC](https://github.com/KhronosGroup/glTF/blob/main/extensions/1.0/Vendor/CESIUM_RTC/README.md) From d320e4a94a3f4feacf04a4be8e725f49ba6271fb Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 12:26:36 +0900 Subject: [PATCH 08/35] Update deepzoom demo --- example/three/deepZoom.js | 10 +++++++--- src/three/plugins/images/ImageFormatPlugin.js | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/example/three/deepZoom.js b/example/three/deepZoom.js index 406c9db94..6131e6ce7 100644 --- a/example/three/deepZoom.js +++ b/example/three/deepZoom.js @@ -5,7 +5,7 @@ import { OrthographicCamera, } from 'three'; import { EnvironmentControls, TilesRenderer, CameraTransitionManager } from '3d-tiles-renderer'; -import { DeepZoomImagePlugin, UpdateOnChangePlugin } from '3d-tiles-renderer/plugins'; +import { UpdateOnChangePlugin, DeepZoomOverlay, GeneratedSurfacePlugin } from '3d-tiles-renderer/plugins'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; let controls, scene, renderer; @@ -53,8 +53,12 @@ function init() { } ); // tiles - tiles = new TilesRenderer( 'https://openseadragon.github.io/example-images/duomo/duomo.dzi' ); - tiles.registerPlugin( new DeepZoomImagePlugin( { center: true } ) ); + tiles = new TilesRenderer(); + tiles.registerPlugin( new GeneratedSurfacePlugin( { + overlay: new DeepZoomOverlay( { + url: 'https://openseadragon.github.io/example-images/duomo/duomo.dzi', + } ), + } ) ); tiles.registerPlugin( new UpdateOnChangePlugin() ); tiles.fetchOptions.mode = 'cors'; tiles.lruCache.minSize = 900; diff --git a/src/three/plugins/images/ImageFormatPlugin.js b/src/three/plugins/images/ImageFormatPlugin.js index c24181579..9e0d5282f 100644 --- a/src/three/plugins/images/ImageFormatPlugin.js +++ b/src/three/plugins/images/ImageFormatPlugin.js @@ -25,7 +25,7 @@ export class ImageFormatPlugin { constructor( options = {} ) { - console.warn( '"ImageFormatPlugin" and derivative plugins have been deprecated. Use "GeneratedSurfacePlugin", instead.' ); + console.warn( `${ this.constructor.name } has been deprecated. Use "GeneratedSurfacePlugin", instead.` ); const { pixelSize = null, From bfffa7944a3ff1b435061b05669a9f133b1864b1 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 15:37:10 +0900 Subject: [PATCH 09/35] Updates --- example/three/deepZoom.js | 5 +---- .../plugins/images/GeneratedSurfacePlugin.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/example/three/deepZoom.js b/example/three/deepZoom.js index 6131e6ce7..53ede7457 100644 --- a/example/three/deepZoom.js +++ b/example/three/deepZoom.js @@ -14,8 +14,6 @@ let tiles, transition; const params = { errorTarget: 1, - renderScale: 1, - orthographic: false, }; @@ -95,12 +93,11 @@ function init() { } ); - gui.add( params, 'errorTarget', 0, 100 ).onChange( () => { + gui.add( params, 'errorTarget', 1, 100 ).onChange( () => { tiles.getPluginByName( 'UPDATE_ON_CHANGE_PLUGIN' ).needsUpdate = true; } ); - gui.add( params, 'renderScale', 0.1, 1.0, 0.05 ).onChange( v => renderer.setPixelRatio( v * window.devicePixelRatio ) ); gui.open(); diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index f0752be53..54c374a0a 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -205,6 +205,21 @@ export class GeneratedSurfacePlugin { } const geometry = new PlaneGeometry( 2 * sx, 2 * sy ); + + const tx = tile[ TILE_X ]; + const ty = tile[ TILE_Y ]; + const level = tile[ TILE_LEVEL ]; + const uvRange = this.overlay.tiling.getTileContentUVBounds( tx, ty, level ); + const { uv } = geometry.attributes; + for ( let i = 0; i < uv.count; i ++ ) { + + uv.setXY( i, + MathUtils.mapLinear( uv.getX( i ), 0, 1, uvRange[ 0 ], uvRange[ 2 ] ), + MathUtils.mapLinear( uv.getY( i ), 0, 1, uvRange[ 1 ], uvRange[ 3 ] ), + ); + + } + const mesh = new Mesh( geometry, new MeshBasicMaterial() ); mesh.position.set( x, y, z ); return mesh; From 8aa8aaf56a53d2fb593013455ad9568acf061e37 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 16:37:30 +0900 Subject: [PATCH 10/35] add temp file --- .../plugins/images/GeneratedSurfacePlugin.js | 57 ++- .../plugins/images/GeneratedSurfacePlugin2.js | 356 ++++++++++++++++++ 2 files changed, 394 insertions(+), 19 deletions(-) create mode 100644 src/three/plugins/images/GeneratedSurfacePlugin2.js diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 54c374a0a..d7a84d327 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -68,8 +68,6 @@ export class GeneratedSurfacePlugin { this.tiles = tiles; - this.overlay.imageSource.fetchOptions = tiles.fetchOptions; - } async loadRootTileset() { @@ -194,6 +192,11 @@ export class GeneratedSurfacePlugin { // Local functions _createPlanarMesh( tile ) { + const { overlay } = this; + const tx = tile[ TILE_X ]; + const ty = tile[ TILE_Y ]; + const level = tile[ TILE_LEVEL ]; + const boundingBox = tile.boundingVolume.box; let sx = 1, sy = 1, x = 0, y = 0, z = 0; if ( boundingBox ) { @@ -205,11 +208,19 @@ export class GeneratedSurfacePlugin { } const geometry = new PlaneGeometry( 2 * sx, 2 * sy ); + const mesh = new Mesh( geometry, new MeshBasicMaterial() ); + mesh.position.set( x, y, z ); - const tx = tile[ TILE_X ]; - const ty = tile[ TILE_Y ]; - const level = tile[ TILE_LEVEL ]; - const uvRange = this.overlay.tiling.getTileContentUVBounds( tx, ty, level ); + tile.__DATA = [ sx, sy ]; + + + if ( level === 9 ) { + + console.log( sx, sy ); + + } + + const uvRange = overlay.tiling.getTileContentUVBounds( tx, ty, level ); const { uv } = geometry.attributes; for ( let i = 0; i < uv.count; i ++ ) { @@ -220,8 +231,6 @@ export class GeneratedSurfacePlugin { } - const mesh = new Mesh( geometry, new MeshBasicMaterial() ); - mesh.position.set( x, y, z ); return mesh; } @@ -309,8 +318,8 @@ export class GeneratedSurfacePlugin { getTileset( baseUrl ) { - const { tiles } = this; - const { tiling } = this.overlay; + const { tiles, overlay } = this; + const { tiling } = overlay; const minLevel = tiling.minLevel; const { tileCountX, tileCountY } = tiling.getLevel( minLevel ); @@ -366,10 +375,10 @@ export class GeneratedSurfacePlugin { } - createBoundingVolume( x, y, level, skirtDepth = 0 ) { + createBoundingVolume( x, y, level, regionHeight = 0 ) { - const { shape } = this; - const { tiling, projection } = this.overlay; + const { shape, overlay } = this; + const { tiling, projection } = overlay; if ( projection.isCartographic && shape === 'ellipsoid' ) { @@ -385,7 +394,7 @@ export class GeneratedSurfacePlugin { } - return { region: [ ...cartBounds, - skirtDepth, 1 ] }; + return { region: [ ...cartBounds, - regionHeight, 1 ] }; } else { @@ -409,9 +418,16 @@ export class GeneratedSurfacePlugin { centerX *= tiling.aspectRatio; extentsX *= tiling.aspectRatio; + + if ( level === 9 ) + console.log( centerX.toFixed( 3 ), centerY.toFixed( 3 ), extentsX.toFixed( 3 ), extentsY.toFixed( 3 ) ) + return { box: [ + // center centerX, centerY, 0, + + // x, y, z half extents extentsX, 0.0, 0.0, 0.0, extentsY, 0.0, 0.0, 0.0, 0.0, @@ -424,8 +440,8 @@ export class GeneratedSurfacePlugin { createChild( x, y, level ) { - const { shape } = this; - const { tiling, projection } = this.overlay; + const { shape, overlay } = this; + const { tiling, projection } = overlay; if ( ! tiling.getTileExists( x, y, level ) ) { return null; @@ -433,7 +449,8 @@ export class GeneratedSurfacePlugin { } let geometricError; - if ( projection.isCartographic && shape === 'ellipsoid' ) { + const useRegions = projection.isCartographic && shape === 'ellipsoid'; + if ( useRegions ) { const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true ); const { tilePixelWidth, tilePixelHeight } = tiling.getLevel( level ); @@ -460,8 +477,10 @@ export class GeneratedSurfacePlugin { return { refine: 'REPLACE', geometricError, - boundingVolume: this.createBoundingVolume( x, y, level, projection.isCartographic && shape === 'ellipsoid' ? geometricError : 0 ), - content: { uri: this.getUrl( x, y, level ) }, + boundingVolume: this.createBoundingVolume( x, y, level, useRegions ? geometricError : 0 ), + content: { + uri: this.getUrl( x, y, level ), + }, children: [], [ TILE_X ]: x, diff --git a/src/three/plugins/images/GeneratedSurfacePlugin2.js b/src/three/plugins/images/GeneratedSurfacePlugin2.js new file mode 100644 index 000000000..848888b5f --- /dev/null +++ b/src/three/plugins/images/GeneratedSurfacePlugin2.js @@ -0,0 +1,356 @@ +import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector2 } from 'three'; +import { TILE_X, TILE_Y, TILE_LEVEL } from './ImageFormatPlugin.js'; +import { getCartographicToMeterDerivative } from './utils/getCartographicToMeterDerivative.js'; + +const _uv = /* @__PURE__ */ new Vector2(); + +export class GeneratedSurfacePlugin2 { + + constructor( options = {} ) { + + const { + overlay = null, + shape = 'planar', + endCaps = true, + center = true, + useRecommendedSettings = true, + transparent = false, + } = options; + + this.priority = - 10; + this.tiles = null; + + this.overlay = overlay; + this.imageSource = overlay ? overlay.imageSource : null; + + this.shape = shape; + this.endCaps = endCaps; + this.center = center; + this.transparent = transparent; + this.useRecommendedSettings = useRecommendedSettings; + + } + + // Plugin functions + init( tiles ) { + + if ( this.useRecommendedSettings ) { + + tiles.errorTarget = 1; + + } + + this.tiles = tiles; + + this.overlay.imageSource.fetchOptions = tiles.fetchOptions; + + } + + async loadRootTileset() { + + const { tiles } = this; + const { overlay } = this; + const { imageSource } = overlay; + imageSource.url = imageSource.url || tiles.rootURL; + tiles.invokeAllPlugins( plugin => imageSource.url = plugin.preprocessURL ? plugin.preprocessURL( imageSource.url, null ) : imageSource.url ); + + // overlay.init() initializes the image source and creates regionImageSource + await overlay.init(); + + tiles.rootURL = imageSource.url; + return this.getTileset( imageSource.url ); + + } + + async parseToMesh( buffer, tile, extension, uri, abortSignal ) { + + if ( abortSignal.aborted ) { + + return null; + + } + + // Behavioral difference from GeneratedSurfacePlugin: load texture from imageSource + const { imageSource, transparent } = this; + const tx = tile[ TILE_X ]; + const ty = tile[ TILE_Y ]; + const level = tile[ TILE_LEVEL ]; + const texture = await imageSource.processBufferToTexture( buffer ); + + if ( abortSignal.aborted ) { + + texture.dispose(); + texture.image.close(); + return null; + + } + + imageSource.setData( tx, ty, level, texture ); + + const res = this._createPlanarMesh( tile ); + + res.material.transparent = transparent; + res.material.side = 2; + + // Behavioral difference from GeneratedSurfacePlugin: apply texture directly from imageSource + res.material.map = texture; + res.material.needsUpdate = true; + + return res; + + } + + preprocessNode( tile ) { + + const { tiling } = this.overlay; + const maxLevel = tiling.maxLevel; + const level = tile[ TILE_LEVEL ]; + if ( level < maxLevel && tile.parent !== null ) { + + this.expandChildren( tile ); + + } + + } + + disposeTile( tile ) { + + // Behavioral difference from GeneratedSurfacePlugin: release imageSource tile + const tx = tile[ TILE_X ]; + const ty = tile[ TILE_Y ]; + const level = tile[ TILE_LEVEL ]; + const { imageSource } = this; + if ( imageSource.has( tx, ty, level ) ) { + + imageSource.release( tx, ty, level ); + + } + + } + + // Local functions + _createPlanarMesh( tile ) { + + const { overlay } = this; + const tx = tile[ TILE_X ]; + const ty = tile[ TILE_Y ]; + const level = tile[ TILE_LEVEL ]; + + const boundingBox = tile.boundingVolume.box; + let sx = 1, sy = 1, x = 0, y = 0, z = 0; + if ( boundingBox ) { + + [ x, y, z ] = boundingBox; + sx = boundingBox[ 3 ]; + sy = boundingBox[ 7 ]; + + } + + const geometry = new PlaneGeometry( 2 * sx, 2 * sy ); + const mesh = new Mesh( geometry, new MeshBasicMaterial() ); + mesh.position.set( x, y, z ); + + const uvRange = overlay.tiling.getTileContentUVBounds( tx, ty, level ); + const { uv } = geometry.attributes; + for ( let i = 0; i < uv.count; i ++ ) { + + uv.setXY( i, + MathUtils.mapLinear( uv.getX( i ), 0, 1, uvRange[ 0 ], uvRange[ 2 ] ), + MathUtils.mapLinear( uv.getY( i ), 0, 1, uvRange[ 1 ], uvRange[ 3 ] ), + ); + + } + + return mesh; + + } + + getTileset( baseUrl ) { + + const { tiles, overlay } = this; + const { tiling } = overlay; + const minLevel = tiling.minLevel; + const { tileCountX, tileCountY } = tiling.getLevel( minLevel ); + + const children = []; + for ( let x = 0; x < tileCountX; x ++ ) { + + for ( let y = 0; y < tileCountY; y ++ ) { + + const child = this.createChild( x, y, minLevel ); + if ( child !== null ) { + + children.push( child ); + + } + + } + + } + + const tileset = { + asset: { version: '1.1' }, + geometricError: Infinity, + root: { + refine: 'REPLACE', + geometricError: Infinity, + boundingVolume: this.createBoundingVolume( 0, 0, - 1 ), + children, + + [ TILE_LEVEL ]: - 1, + [ TILE_X ]: 0, + [ TILE_Y ]: 0, + }, + }; + + tiles.preprocessTileset( tileset, baseUrl ); + return tileset; + + } + + getUrl( x, y, level ) { + + // Behavioral difference from GeneratedSurfacePlugin: returns real image URL + return this.imageSource.getUrl( x, y, level ); + + } + + fetchData( /* uri */ ) { + + // Behavioral difference from GeneratedSurfacePlugin: real fetches go through normal pipeline + + } + + createBoundingVolume( x, y, level, regionHeight = 0 ) { + + const { shape, overlay } = this; + const { tiling, projection } = overlay; + + if ( projection.isCartographic && shape === 'ellipsoid' ) { + + const { endCaps } = this; + const isRoot = level === - 1; + const normalizedBounds = isRoot ? tiling.getContentBounds( true ) : tiling.getTileBounds( x, y, level, true, true ); + const cartBounds = isRoot ? tiling.getContentBounds() : tiling.getTileBounds( x, y, level, false, true ); + + if ( endCaps ) { + + if ( normalizedBounds[ 3 ] === 1 ) cartBounds[ 3 ] = Math.PI / 2; + if ( normalizedBounds[ 1 ] === 0 ) cartBounds[ 1 ] = - Math.PI / 2; + + } + + return { region: [ ...cartBounds, - regionHeight, 1 ] }; + + } else { + + const { center } = this; + const [ minX, minY, maxX, maxY ] = level === - 1 + ? tiling.getContentBounds( true ) + : tiling.getTileBounds( x, y, level, true ); + + let extentsX = ( maxX - minX ) / 2; + let extentsY = ( maxY - minY ) / 2; + let centerX = minX + extentsX; + let centerY = minY + extentsY; + + if ( center ) { + + centerX -= 0.5; + centerY -= 0.5; + + } + + centerX *= tiling.aspectRatio; + extentsX *= tiling.aspectRatio; + + return { + box: [ + centerX, centerY, 0, + extentsX, 0.0, 0.0, + 0.0, extentsY, 0.0, + 0.0, 0.0, 0.0, + ], + }; + + } + + } + + createChild( x, y, level ) { + + const { shape, overlay } = this; + const { tiling, projection } = overlay; + if ( ! tiling.getTileExists( x, y, level ) ) { + + return null; + + } + + let geometricError; + const useRegions = projection.isCartographic && shape === 'ellipsoid'; + if ( useRegions ) { + + const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true ); + const { tilePixelWidth, tilePixelHeight } = tiling.getLevel( level ); + + const tileUWidth = ( maxU - minU ) / tilePixelWidth; + const tileVWidth = ( maxV - minV ) / tilePixelHeight; + + const [ , south, east, north ] = tiling.getTileBounds( x, y, level ); + const midLat = ( south > 0 ) !== ( north > 0 ) ? 0 : Math.min( Math.abs( south ), Math.abs( north ) ); + const midV = projection.convertLatitudeToNormalized( midLat ); + const lonFactor = projection.getLongitudeDerivativeAtNormalized( minU ); + const latFactor = projection.getLatitudeDerivativeAtNormalized( midV ); + + const [ xDeriv, yDeriv ] = getCartographicToMeterDerivative( this.tiles.ellipsoid, midLat, east ); + geometricError = Math.max( tileUWidth * lonFactor * xDeriv, tileVWidth * latFactor * yDeriv ); + + } else { + + const { pixelWidth, pixelHeight } = tiling.getLevel( level ); + geometricError = Math.max( tiling.aspectRatio / pixelWidth, 1 / pixelHeight ); + + } + + return { + refine: 'REPLACE', + geometricError, + boundingVolume: this.createBoundingVolume( x, y, level, useRegions ? geometricError : 0 ), + content: { + uri: this.getUrl( x, y, level ), + }, + children: [], + + [ TILE_X ]: x, + [ TILE_Y ]: y, + [ TILE_LEVEL ]: level, + }; + + } + + expandChildren( tile ) { + + const level = tile[ TILE_LEVEL ]; + const x = tile[ TILE_X ]; + const y = tile[ TILE_Y ]; + + const { tileSplitX, tileSplitY } = this.overlay.tiling.getLevel( level ); + for ( let cx = 0; cx < tileSplitX; cx ++ ) { + + for ( let cy = 0; cy < tileSplitY; cy ++ ) { + + const child = this.createChild( tileSplitX * x + cx, tileSplitY * y + cy, level + 1 ); + if ( child ) { + + tile.children.push( child ); + + } + + } + + } + + } + +} From c9cd3853b55da1f5a7952d2a66fdd4f416b3c3a1 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 21:32:47 +0900 Subject: [PATCH 11/35] Small tiling scheme fix --- src/three/plugins/images/utils/TilingScheme.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/three/plugins/images/utils/TilingScheme.js b/src/three/plugins/images/utils/TilingScheme.js index 8ff5f41d0..ab5bb8442 100644 --- a/src/three/plugins/images/utils/TilingScheme.js +++ b/src/three/plugins/images/utils/TilingScheme.js @@ -185,9 +185,9 @@ export class TilingScheme { getTileAtPoint( bx, by, level, normalized = false ) { const { flipY } = this; - const { tileCountX, tileCountY, tileBounds } = this.getLevel( level ); - const xStride = 1 / tileCountX; - const yStride = 1 / tileCountY; + const { tileCountY, tileBounds, pixelHeight, pixelWidth, tilePixelHeight, tilePixelWidth } = this.getLevel( level ); + const xStride = tilePixelWidth / pixelWidth; + const yStride = tilePixelHeight / pixelHeight; if ( ! normalized ) { From 4aa12ea8c7f0ecb9d8fb3f3462d3afe48f739c62 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 21:34:56 +0900 Subject: [PATCH 12/35] Cleanup --- src/three/plugins/images/GeneratedSurfacePlugin.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index d7a84d327..69a2f8ee3 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -211,15 +211,6 @@ export class GeneratedSurfacePlugin { const mesh = new Mesh( geometry, new MeshBasicMaterial() ); mesh.position.set( x, y, z ); - tile.__DATA = [ sx, sy ]; - - - if ( level === 9 ) { - - console.log( sx, sy ); - - } - const uvRange = overlay.tiling.getTileContentUVBounds( tx, ty, level ); const { uv } = geometry.attributes; for ( let i = 0; i < uv.count; i ++ ) { @@ -418,10 +409,6 @@ export class GeneratedSurfacePlugin { centerX *= tiling.aspectRatio; extentsX *= tiling.aspectRatio; - - if ( level === 9 ) - console.log( centerX.toFixed( 3 ), centerY.toFixed( 3 ), extentsX.toFixed( 3 ), extentsY.toFixed( 3 ) ) - return { box: [ // center From 45aa0cd4fcde1a3664af509e74c77f89dee848dd Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 23:21:16 +0900 Subject: [PATCH 13/35] Update demos --- example/three/wmsTiles.js | 10 +++++----- example/three/wmtsTiles.js | 23 ++++++++++++----------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/example/three/wmsTiles.js b/example/three/wmsTiles.js index bc8ad5242..d2baad441 100644 --- a/example/three/wmsTiles.js +++ b/example/three/wmsTiles.js @@ -1,6 +1,6 @@ import { Scene, WebGLRenderer, PerspectiveCamera } from 'three'; import { TilesRenderer, GlobeControls } from '3d-tiles-renderer'; -import { TilesFadePlugin, UpdateOnChangePlugin, WMSCapabilitiesLoader, WMSTilesPlugin } from '3d-tiles-renderer/plugins'; +import { TilesFadePlugin, UpdateOnChangePlugin, WMSCapabilitiesLoader, WMSTilesOverlay, GeneratedSurfacePlugin } from '3d-tiles-renderer/plugins'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; const url = @@ -110,9 +110,8 @@ function rebuildTiles() { tiles = new TilesRenderer(); tiles.registerPlugin( new TilesFadePlugin() ); tiles.registerPlugin( new UpdateOnChangePlugin() ); - tiles.registerPlugin( - new WMSTilesPlugin( { - shape: 'ellipsoid', + tiles.registerPlugin( new GeneratedSurfacePlugin( { + overlay: new WMSTilesOverlay( { url: capabilities.request.GetMap.href, layer: params.layer, contentBoundingBox: layer.contentBoundingBox, @@ -122,7 +121,8 @@ function rebuildTiles() { styles: params.styles, version: capabilities.version, } ), - ); + shape: 'ellipsoid', + } ) ); tiles.group.rotation.x = - Math.PI / 2; scene.add( tiles.group ); diff --git a/example/three/wmtsTiles.js b/example/three/wmtsTiles.js index 7acc7cece..ea9c62d4b 100644 --- a/example/three/wmtsTiles.js +++ b/example/three/wmtsTiles.js @@ -4,7 +4,7 @@ import { PerspectiveCamera, } from 'three'; import { TilesRenderer, GlobeControls, EnvironmentControls } from '3d-tiles-renderer'; -import { TilesFadePlugin, UpdateOnChangePlugin, WMTSCapabilitiesLoader, WMTSTilesPlugin } from '3d-tiles-renderer/plugins'; +import { TilesFadePlugin, UpdateOnChangePlugin, WMTSCapabilitiesLoader, WMTSTilesOverlay, GeneratedSurfacePlugin } from '3d-tiles-renderer/plugins'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; const url = window.location.hash.replace( /^#/, '' ) || 'https://gibs.earthdata.nasa.gov/wmts/epsg4326/best/wmts.cgi?SERVICE=WMTS&request=GetCapabilities'; @@ -169,18 +169,19 @@ function rebuildTiles() { tiles = new TilesRenderer(); tiles.registerPlugin( new TilesFadePlugin() ); tiles.registerPlugin( new UpdateOnChangePlugin() ); - tiles.registerPlugin( new WMTSTilesPlugin( { + tiles.registerPlugin( new GeneratedSurfacePlugin( { + overlay: new WMTSTilesOverlay( { + url, + tileMatrices: tileMatrixSet.tileMatrices, + contentBoundingBox, + projection: 'EPSG:4326', + dimensions: params.dimensions, + style: params.style, + layer: params.layer, + tileMatrixSet: params.tileMatrixSet, + } ), shape: params.planar ? 'planar' : 'ellipsoid', center: true, - - url, - tileMatrices: tileMatrixSet.tileMatrices, - contentBoundingBox, - projection: 'EPSG:4326', - dimensions: params.dimensions, - style: params.style, - layer: params.layer, - tileMatrixSet: params.tileMatrixSet, } ) ); tiles.setCamera( camera ); From db7647283e9b94d754c3b96d5461ed43e5f57479 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 23:23:14 +0900 Subject: [PATCH 14/35] Fix geojson overlay --- example/three/geojson.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/example/three/geojson.js b/example/three/geojson.js index fcad5b513..c8ab4daab 100644 --- a/example/three/geojson.js +++ b/example/three/geojson.js @@ -2,10 +2,11 @@ import { Scene, WebGLRenderer, PerspectiveCamera, MathUtils } from 'three'; import { TilesRenderer, GlobeControls, CAMERA_FRAME } from '3d-tiles-renderer'; import { GeoJSONOverlay, + GeneratedSurfacePlugin, ImageOverlayPlugin, TilesFadePlugin, UpdateOnChangePlugin, - XYZTilesPlugin, + XYZTilesOverlay, } from '3d-tiles-renderer/plugins'; import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js'; @@ -99,10 +100,12 @@ function init() { tiles.registerPlugin( new UpdateOnChangePlugin() ); tiles.registerPlugin( new TilesFadePlugin() ); tiles.registerPlugin( - new XYZTilesPlugin( { + new GeneratedSurfacePlugin( { + overlay: new XYZTilesOverlay( { + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + } ), center: true, shape: 'ellipsoid', - url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', } ), ); From 5120e09acdcb82d8211bbe45c4e8198995b5e70e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 23:40:48 +0900 Subject: [PATCH 15/35] clean up --- .../plugins/images/GeneratedSurfacePlugin.js | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 69a2f8ee3..94ba28301 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -102,8 +102,9 @@ export class GeneratedSurfacePlugin { const { shape, transparent, overlay } = this; const { projection, tiling } = overlay; + const useRegions = projection.isCartographic && shape === 'ellipsoid'; let res; - if ( projection.isCartographic && shape === 'ellipsoid' ) { + if ( useRegions ) { res = this._createEllipsoidMesh( tile ); @@ -116,19 +117,6 @@ export class GeneratedSurfacePlugin { res.material.transparent = transparent; res.material.side = 2; - // Apply the overlay texture directly - if ( ! overlay.isReady ) { - - await overlay.whenReady(); - - } - - if ( abortSignal.aborted ) { - - return null; - - } - const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; const level = tile[ TILE_LEVEL ]; @@ -371,12 +359,24 @@ export class GeneratedSurfacePlugin { const { shape, overlay } = this; const { tiling, projection } = overlay; + const isRoot = level === - 1; if ( projection.isCartographic && shape === 'ellipsoid' ) { const { endCaps } = this; - const isRoot = level === - 1; - const normalizedBounds = isRoot ? tiling.getContentBounds( true ) : tiling.getTileBounds( x, y, level, true, true ); - const cartBounds = isRoot ? tiling.getContentBounds() : tiling.getTileBounds( x, y, level, false, true ); + + let normalizedBounds; + let cartBounds; + if ( isRoot ) { + + normalizedBounds = tiling.getContentBounds( true ); + cartBounds = tiling.getContentBounds(); + + } else { + + normalizedBounds = tiling.getTileBounds( x, y, level, true, true ); + cartBounds = tiling.getTileBounds( x, y, level, false, true ); + + } if ( endCaps ) { @@ -390,10 +390,18 @@ export class GeneratedSurfacePlugin { } else { const { center } = this; - const [ minX, minY, maxX, maxY ] = level === - 1 - ? tiling.getContentBounds( true ) - : tiling.getTileBounds( x, y, level, true ); + let normalizedBounds; + if ( isRoot ) { + + normalizedBounds = tiling.getContentBounds( true ); + + } else { + + normalizedBounds = tiling.getTileBounds( x, y, level, true ); + + } + const [ minX, minY, maxX, maxY ] = normalizedBounds; let extentsX = ( maxX - minX ) / 2; let extentsY = ( maxY - minY ) / 2; let centerX = minX + extentsX; From 848a013ced412c143fac1d4ee7ed1247af5fab0b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 23:44:04 +0900 Subject: [PATCH 16/35] remove side assignment --- src/three/plugins/images/GeneratedSurfacePlugin.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 94ba28301..77f27a3b3 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -115,7 +115,6 @@ export class GeneratedSurfacePlugin { } res.material.transparent = transparent; - res.material.side = 2; const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; From a49dd10f157c4c3e2141e6f57cd65fb9e1f6dc65 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 13 Apr 2026 23:48:55 +0900 Subject: [PATCH 17/35] Add convenience getter --- .../plugins/images/GeneratedSurfacePlugin.js | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 77f27a3b3..dc282608f 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -100,11 +100,10 @@ export class GeneratedSurfacePlugin { } - const { shape, transparent, overlay } = this; - const { projection, tiling } = overlay; - const useRegions = projection.isCartographic && shape === 'ellipsoid'; + const { transparent, overlay } = this; + const { tiling } = overlay; let res; - if ( useRegions ) { + if ( this._useEllipsoid() ) { res = this._createEllipsoidMesh( tile ); @@ -176,6 +175,12 @@ export class GeneratedSurfacePlugin { } + _useEllipsoid() { + + return this.overlay.projection.isCartographic && this.shape === 'ellipsoid'; + + } + // Local functions _createPlanarMesh( tile ) { @@ -355,11 +360,11 @@ export class GeneratedSurfacePlugin { createBoundingVolume( x, y, level, regionHeight = 0 ) { - const { shape, overlay } = this; - const { tiling, projection } = overlay; + const { overlay } = this; + const { tiling } = overlay; const isRoot = level === - 1; - if ( projection.isCartographic && shape === 'ellipsoid' ) { + if ( this._useEllipsoid() ) { const { endCaps } = this; @@ -434,7 +439,7 @@ export class GeneratedSurfacePlugin { createChild( x, y, level ) { - const { shape, overlay } = this; + const { overlay } = this; const { tiling, projection } = overlay; if ( ! tiling.getTileExists( x, y, level ) ) { @@ -443,7 +448,7 @@ export class GeneratedSurfacePlugin { } let geometricError; - const useRegions = projection.isCartographic && shape === 'ellipsoid'; + const useRegions = this._useEllipsoid(); if ( useRegions ) { const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true ); From e8a8d25952ed23bf1ceadd98e6ac977cba0b3009 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 09:27:36 +0900 Subject: [PATCH 18/35] Add option for no overlays --- .../plugins/images/GeneratedSurfacePlugin.js | 121 ++++++++++++------ .../images/sources/RegionImageSource.js | 27 ++++ 2 files changed, 106 insertions(+), 42 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index dc282608f..c259f2e3b 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -2,9 +2,12 @@ import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector3, Sphere } from 'three'; import { TILE_X, TILE_Y, TILE_LEVEL } from './ImageFormatPlugin.js'; import { getCartographicToMeterDerivative } from './utils/getCartographicToMeterDerivative.js'; +import { TilingScheme } from './utils/TilingScheme.js'; +import { ProjectionScheme } from './utils/ProjectionScheme.js'; const MIN_LON_VERTS = 30; const MIN_LAT_VERTS = 15; +const DEFAULT_LEVELS = 20; const _pos = /* @__PURE__ */ new Vector3(); const _norm = /* @__PURE__ */ new Vector3(); @@ -55,6 +58,8 @@ export class GeneratedSurfacePlugin { this.transparent = transparent; this.useRecommendedSettings = useRecommendedSettings; + this._tiling = null; + } // Plugin functions @@ -72,17 +77,26 @@ export class GeneratedSurfacePlugin { async loadRootTileset() { - const { tiles } = this; - const { overlay } = this; - const { imageSource } = overlay; - imageSource.url = imageSource.url || tiles.rootURL; - tiles.invokeAllPlugins( plugin => imageSource.url = plugin.preprocessURL ? plugin.preprocessURL( imageSource.url, null ) : imageSource.url ); + const { tiles, overlay } = this; + if ( overlay ) { + + const { imageSource } = overlay; + imageSource.url = imageSource.url || tiles.rootURL; + tiles.invokeAllPlugins( plugin => imageSource.url = plugin.preprocessURL ? plugin.preprocessURL( imageSource.url, null ) : imageSource.url ); + + // overlay.init() initializes the image source and creates regionImageSource + await overlay.init(); + + this._tiling = overlay.tiling; + tiles.rootURL = imageSource.url; + return this.getTileset( imageSource.url ); - // overlay.init() initializes the image source and creates regionImageSource - await overlay.init(); + } else { + + this._tiling = this._createDefaultTiling(); + return this.getTileset( tiles.rootURL ); - tiles.rootURL = imageSource.url; - return this.getTileset( imageSource.url ); + } } @@ -101,7 +115,6 @@ export class GeneratedSurfacePlugin { } const { transparent, overlay } = this; - const { tiling } = overlay; let res; if ( this._useEllipsoid() ) { @@ -115,35 +128,39 @@ export class GeneratedSurfacePlugin { res.material.transparent = transparent; - const x = tile[ TILE_X ]; - const y = tile[ TILE_Y ]; - const level = tile[ TILE_LEVEL ]; - const range = tiling.getTileBounds( x, y, level, true, true ); + if ( overlay ) { - await overlay.lockTexture( range ); + const x = tile[ TILE_X ]; + const y = tile[ TILE_Y ]; + const level = tile[ TILE_LEVEL ]; + const range = this._tiling.getTileBounds( x, y, level, true, true ); - if ( abortSignal.aborted ) { + await overlay.lockTexture( range ); - overlay.releaseTexture( range ); - return null; + if ( abortSignal.aborted ) { - } + overlay.releaseTexture( range ); + return null; - tile.overlayRange = range; + } - if ( overlay.hasContent( range ) ) { + tile.overlayRange = range; - const texture = await overlay.getTexture( range ); + if ( overlay.hasContent( range ) ) { - if ( abortSignal.aborted ) { + const texture = await overlay.getTexture( range ); - overlay.releaseTexture( range ); - return null; + if ( abortSignal.aborted ) { - } + overlay.releaseTexture( range ); + return null; + + } - res.material.map = texture; - res.material.needsUpdate = true; + res.material.map = texture; + res.material.needsUpdate = true; + + } } @@ -153,7 +170,7 @@ export class GeneratedSurfacePlugin { preprocessNode( tile ) { - const { tiling } = this.overlay; + const tiling = this._tiling; const maxLevel = tiling.maxLevel; const level = tile[ TILE_LEVEL ]; if ( level < maxLevel && tile.parent !== null ) { @@ -167,7 +184,7 @@ export class GeneratedSurfacePlugin { disposeTile( tile ) { const { overlayRange } = tile; - if ( overlayRange ) { + if ( this.overlay && overlayRange ) { this.overlay.releaseTexture( overlayRange ); @@ -177,14 +194,13 @@ export class GeneratedSurfacePlugin { _useEllipsoid() { - return this.overlay.projection.isCartographic && this.shape === 'ellipsoid'; + return this._tiling.projection.isCartographic && this.shape === 'ellipsoid'; } // Local functions _createPlanarMesh( tile ) { - const { overlay } = this; const tx = tile[ TILE_X ]; const ty = tile[ TILE_Y ]; const level = tile[ TILE_LEVEL ]; @@ -203,7 +219,7 @@ export class GeneratedSurfacePlugin { const mesh = new Mesh( geometry, new MeshBasicMaterial() ); mesh.position.set( x, y, z ); - const uvRange = overlay.tiling.getTileContentUVBounds( tx, ty, level ); + const uvRange = this._tiling.getTileContentUVBounds( tx, ty, level ); const { uv } = geometry.attributes; for ( let i = 0; i < uv.count; i ++ ) { @@ -220,8 +236,8 @@ export class GeneratedSurfacePlugin { _createEllipsoidMesh( tile ) { - const { tiles, endCaps } = this; - const { projection, tiling } = this.overlay; + const { tiles, endCaps, _tiling: tiling } = this; + const { projection } = tiling; const level = tile[ TILE_LEVEL ]; const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; @@ -301,8 +317,7 @@ export class GeneratedSurfacePlugin { getTileset( baseUrl ) { - const { tiles, overlay } = this; - const { tiling } = overlay; + const { tiles, _tiling: tiling } = this; const minLevel = tiling.minLevel; const { tileCountX, tileCountY } = tiling.getLevel( minLevel ); @@ -360,8 +375,7 @@ export class GeneratedSurfacePlugin { createBoundingVolume( x, y, level, regionHeight = 0 ) { - const { overlay } = this; - const { tiling } = overlay; + const { _tiling: tiling } = this; const isRoot = level === - 1; if ( this._useEllipsoid() ) { @@ -439,8 +453,8 @@ export class GeneratedSurfacePlugin { createChild( x, y, level ) { - const { overlay } = this; - const { tiling, projection } = overlay; + const { _tiling: tiling } = this; + const { projection } = tiling; if ( ! tiling.getTileExists( x, y, level ) ) { return null; @@ -495,7 +509,7 @@ export class GeneratedSurfacePlugin { const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; - const { tileSplitX, tileSplitY } = this.overlay.tiling.getLevel( level ); + const { tileSplitX, tileSplitY } = this._tiling.getLevel( level ); for ( let cx = 0; cx < tileSplitX; cx ++ ) { for ( let cy = 0; cy < tileSplitY; cy ++ ) { @@ -513,4 +527,27 @@ export class GeneratedSurfacePlugin { } + _createDefaultTiling() { + + const tiling = new TilingScheme(); + if ( this.shape === 'ellipsoid' ) { + + const projection = new ProjectionScheme( 'EPSG:4326' ); + tiling.setProjection( projection ); + tiling.setContentBounds( ...projection.getBounds() ); + tiling.generateLevels( DEFAULT_LEVELS, projection.tileCountX, projection.tileCountY ); + + } else { + + const projection = new ProjectionScheme( 'none' ); + tiling.setProjection( projection ); + tiling.setContentBounds( ...projection.getBounds() ); + tiling.generateLevels( DEFAULT_LEVELS, 1, 1 ); + + } + + return tiling; + + } + } diff --git a/src/three/plugins/images/sources/RegionImageSource.js b/src/three/plugins/images/sources/RegionImageSource.js index e54400c72..f434451ff 100644 --- a/src/three/plugins/images/sources/RegionImageSource.js +++ b/src/three/plugins/images/sources/RegionImageSource.js @@ -7,6 +7,28 @@ import { SRGBColorSpace, CanvasTexture } from 'three'; // image tile. const BOUNDS_EPSILON = 1e-10; +function isArrayEqual( a, b, eps = 0 ) { + + if ( a.length !== b.length ) { + + return false; + + } + + for ( let i = 0, l = a.length; i < l; i ++ ) { + + if ( Math.abs( a[ i ] - b[ i ] ) > eps ) { + + return false; + + } + + } + + return true; + +} + export class RegionImageSource extends DataCache { hasContent( ...tokens ) { @@ -86,6 +108,8 @@ export class TiledRegionImageSource extends RegionImageSource { const clone = tiledImageSource.get( tx, ty, tl ).clone(); clone[ IS_DIRECT_TILE ] = true; clone[ LOCK_TOKENS ] = tokens; + + clone.__COUNT = tileCount; return clone; } @@ -116,11 +140,14 @@ export class TiledRegionImageSource extends RegionImageSource { // draw using normalized bounds since the mercator bounds are non-linear const span = tiling.getTileBounds( tx, ty, tl, true, false ); + const tex = tiledImageSource.get( tx, ty, tl ); tileComposer.draw( tex, span ); } ); + target.__COUNT = tileCount; + return target; } From e6dbb46cfaf884765825f242b059a06be35eff0f Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 09:40:18 +0900 Subject: [PATCH 19/35] Delete unused file --- .../plugins/images/GeneratedSurfacePlugin2.js | 356 ------------------ 1 file changed, 356 deletions(-) delete mode 100644 src/three/plugins/images/GeneratedSurfacePlugin2.js diff --git a/src/three/plugins/images/GeneratedSurfacePlugin2.js b/src/three/plugins/images/GeneratedSurfacePlugin2.js deleted file mode 100644 index 848888b5f..000000000 --- a/src/three/plugins/images/GeneratedSurfacePlugin2.js +++ /dev/null @@ -1,356 +0,0 @@ -import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector2 } from 'three'; -import { TILE_X, TILE_Y, TILE_LEVEL } from './ImageFormatPlugin.js'; -import { getCartographicToMeterDerivative } from './utils/getCartographicToMeterDerivative.js'; - -const _uv = /* @__PURE__ */ new Vector2(); - -export class GeneratedSurfacePlugin2 { - - constructor( options = {} ) { - - const { - overlay = null, - shape = 'planar', - endCaps = true, - center = true, - useRecommendedSettings = true, - transparent = false, - } = options; - - this.priority = - 10; - this.tiles = null; - - this.overlay = overlay; - this.imageSource = overlay ? overlay.imageSource : null; - - this.shape = shape; - this.endCaps = endCaps; - this.center = center; - this.transparent = transparent; - this.useRecommendedSettings = useRecommendedSettings; - - } - - // Plugin functions - init( tiles ) { - - if ( this.useRecommendedSettings ) { - - tiles.errorTarget = 1; - - } - - this.tiles = tiles; - - this.overlay.imageSource.fetchOptions = tiles.fetchOptions; - - } - - async loadRootTileset() { - - const { tiles } = this; - const { overlay } = this; - const { imageSource } = overlay; - imageSource.url = imageSource.url || tiles.rootURL; - tiles.invokeAllPlugins( plugin => imageSource.url = plugin.preprocessURL ? plugin.preprocessURL( imageSource.url, null ) : imageSource.url ); - - // overlay.init() initializes the image source and creates regionImageSource - await overlay.init(); - - tiles.rootURL = imageSource.url; - return this.getTileset( imageSource.url ); - - } - - async parseToMesh( buffer, tile, extension, uri, abortSignal ) { - - if ( abortSignal.aborted ) { - - return null; - - } - - // Behavioral difference from GeneratedSurfacePlugin: load texture from imageSource - const { imageSource, transparent } = this; - const tx = tile[ TILE_X ]; - const ty = tile[ TILE_Y ]; - const level = tile[ TILE_LEVEL ]; - const texture = await imageSource.processBufferToTexture( buffer ); - - if ( abortSignal.aborted ) { - - texture.dispose(); - texture.image.close(); - return null; - - } - - imageSource.setData( tx, ty, level, texture ); - - const res = this._createPlanarMesh( tile ); - - res.material.transparent = transparent; - res.material.side = 2; - - // Behavioral difference from GeneratedSurfacePlugin: apply texture directly from imageSource - res.material.map = texture; - res.material.needsUpdate = true; - - return res; - - } - - preprocessNode( tile ) { - - const { tiling } = this.overlay; - const maxLevel = tiling.maxLevel; - const level = tile[ TILE_LEVEL ]; - if ( level < maxLevel && tile.parent !== null ) { - - this.expandChildren( tile ); - - } - - } - - disposeTile( tile ) { - - // Behavioral difference from GeneratedSurfacePlugin: release imageSource tile - const tx = tile[ TILE_X ]; - const ty = tile[ TILE_Y ]; - const level = tile[ TILE_LEVEL ]; - const { imageSource } = this; - if ( imageSource.has( tx, ty, level ) ) { - - imageSource.release( tx, ty, level ); - - } - - } - - // Local functions - _createPlanarMesh( tile ) { - - const { overlay } = this; - const tx = tile[ TILE_X ]; - const ty = tile[ TILE_Y ]; - const level = tile[ TILE_LEVEL ]; - - const boundingBox = tile.boundingVolume.box; - let sx = 1, sy = 1, x = 0, y = 0, z = 0; - if ( boundingBox ) { - - [ x, y, z ] = boundingBox; - sx = boundingBox[ 3 ]; - sy = boundingBox[ 7 ]; - - } - - const geometry = new PlaneGeometry( 2 * sx, 2 * sy ); - const mesh = new Mesh( geometry, new MeshBasicMaterial() ); - mesh.position.set( x, y, z ); - - const uvRange = overlay.tiling.getTileContentUVBounds( tx, ty, level ); - const { uv } = geometry.attributes; - for ( let i = 0; i < uv.count; i ++ ) { - - uv.setXY( i, - MathUtils.mapLinear( uv.getX( i ), 0, 1, uvRange[ 0 ], uvRange[ 2 ] ), - MathUtils.mapLinear( uv.getY( i ), 0, 1, uvRange[ 1 ], uvRange[ 3 ] ), - ); - - } - - return mesh; - - } - - getTileset( baseUrl ) { - - const { tiles, overlay } = this; - const { tiling } = overlay; - const minLevel = tiling.minLevel; - const { tileCountX, tileCountY } = tiling.getLevel( minLevel ); - - const children = []; - for ( let x = 0; x < tileCountX; x ++ ) { - - for ( let y = 0; y < tileCountY; y ++ ) { - - const child = this.createChild( x, y, minLevel ); - if ( child !== null ) { - - children.push( child ); - - } - - } - - } - - const tileset = { - asset: { version: '1.1' }, - geometricError: Infinity, - root: { - refine: 'REPLACE', - geometricError: Infinity, - boundingVolume: this.createBoundingVolume( 0, 0, - 1 ), - children, - - [ TILE_LEVEL ]: - 1, - [ TILE_X ]: 0, - [ TILE_Y ]: 0, - }, - }; - - tiles.preprocessTileset( tileset, baseUrl ); - return tileset; - - } - - getUrl( x, y, level ) { - - // Behavioral difference from GeneratedSurfacePlugin: returns real image URL - return this.imageSource.getUrl( x, y, level ); - - } - - fetchData( /* uri */ ) { - - // Behavioral difference from GeneratedSurfacePlugin: real fetches go through normal pipeline - - } - - createBoundingVolume( x, y, level, regionHeight = 0 ) { - - const { shape, overlay } = this; - const { tiling, projection } = overlay; - - if ( projection.isCartographic && shape === 'ellipsoid' ) { - - const { endCaps } = this; - const isRoot = level === - 1; - const normalizedBounds = isRoot ? tiling.getContentBounds( true ) : tiling.getTileBounds( x, y, level, true, true ); - const cartBounds = isRoot ? tiling.getContentBounds() : tiling.getTileBounds( x, y, level, false, true ); - - if ( endCaps ) { - - if ( normalizedBounds[ 3 ] === 1 ) cartBounds[ 3 ] = Math.PI / 2; - if ( normalizedBounds[ 1 ] === 0 ) cartBounds[ 1 ] = - Math.PI / 2; - - } - - return { region: [ ...cartBounds, - regionHeight, 1 ] }; - - } else { - - const { center } = this; - const [ minX, minY, maxX, maxY ] = level === - 1 - ? tiling.getContentBounds( true ) - : tiling.getTileBounds( x, y, level, true ); - - let extentsX = ( maxX - minX ) / 2; - let extentsY = ( maxY - minY ) / 2; - let centerX = minX + extentsX; - let centerY = minY + extentsY; - - if ( center ) { - - centerX -= 0.5; - centerY -= 0.5; - - } - - centerX *= tiling.aspectRatio; - extentsX *= tiling.aspectRatio; - - return { - box: [ - centerX, centerY, 0, - extentsX, 0.0, 0.0, - 0.0, extentsY, 0.0, - 0.0, 0.0, 0.0, - ], - }; - - } - - } - - createChild( x, y, level ) { - - const { shape, overlay } = this; - const { tiling, projection } = overlay; - if ( ! tiling.getTileExists( x, y, level ) ) { - - return null; - - } - - let geometricError; - const useRegions = projection.isCartographic && shape === 'ellipsoid'; - if ( useRegions ) { - - const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true ); - const { tilePixelWidth, tilePixelHeight } = tiling.getLevel( level ); - - const tileUWidth = ( maxU - minU ) / tilePixelWidth; - const tileVWidth = ( maxV - minV ) / tilePixelHeight; - - const [ , south, east, north ] = tiling.getTileBounds( x, y, level ); - const midLat = ( south > 0 ) !== ( north > 0 ) ? 0 : Math.min( Math.abs( south ), Math.abs( north ) ); - const midV = projection.convertLatitudeToNormalized( midLat ); - const lonFactor = projection.getLongitudeDerivativeAtNormalized( minU ); - const latFactor = projection.getLatitudeDerivativeAtNormalized( midV ); - - const [ xDeriv, yDeriv ] = getCartographicToMeterDerivative( this.tiles.ellipsoid, midLat, east ); - geometricError = Math.max( tileUWidth * lonFactor * xDeriv, tileVWidth * latFactor * yDeriv ); - - } else { - - const { pixelWidth, pixelHeight } = tiling.getLevel( level ); - geometricError = Math.max( tiling.aspectRatio / pixelWidth, 1 / pixelHeight ); - - } - - return { - refine: 'REPLACE', - geometricError, - boundingVolume: this.createBoundingVolume( x, y, level, useRegions ? geometricError : 0 ), - content: { - uri: this.getUrl( x, y, level ), - }, - children: [], - - [ TILE_X ]: x, - [ TILE_Y ]: y, - [ TILE_LEVEL ]: level, - }; - - } - - expandChildren( tile ) { - - const level = tile[ TILE_LEVEL ]; - const x = tile[ TILE_X ]; - const y = tile[ TILE_Y ]; - - const { tileSplitX, tileSplitY } = this.overlay.tiling.getLevel( level ); - for ( let cx = 0; cx < tileSplitX; cx ++ ) { - - for ( let cy = 0; cy < tileSplitY; cy ++ ) { - - const child = this.createChild( tileSplitX * x + cx, tileSplitY * y + cy, level + 1 ); - if ( child ) { - - tile.children.push( child ); - - } - - } - - } - - } - -} From 0e7cd2eaac818a9ed9aacd20e090ec1f09ad981b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 09:48:43 +0900 Subject: [PATCH 20/35] Add tst --- test/three/TilingScheme.test.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/three/TilingScheme.test.js b/test/three/TilingScheme.test.js index 534f504f9..bae91bf6f 100644 --- a/test/three/TilingScheme.test.js +++ b/test/three/TilingScheme.test.js @@ -194,6 +194,31 @@ describe( 'TilingScheme', () => { } ); + it( 'should correctly compute tile indices when pixelWidth is not a multiple of tilePixelWidth.', () => { + + // 300 total pixels, 256 per tile → 2 tiles where the last is partial + // tile 0 covers pixels [0, 256) → normalized [0, 256/300) ≈ [0, 0.853) + // tile 1 covers pixels [256, 300) → normalized [256/300, 1] + const scheme = new TilingScheme(); + scheme.setLevel( 0, { + tileCountX: 2, + tileCountY: 2, + pixelWidth: 300, + pixelHeight: 300, + tilePixelWidth: 256, + tilePixelHeight: 256, + } ); + + // normalized 0.6 → pixel 180 → tile 0 (not tile 1 as old 1/tileCount stride would give) + expect( scheme.getTileAtPoint( 0.6, 0.4, 0, true ) ).toEqual( [ 0, 0 ] ); + expect( scheme.getTileAtPoint( 0.4, 0.6, 0, true ) ).toEqual( [ 0, 0 ] ); + + // normalized 0.9 → pixel 270 → tile 1 + expect( scheme.getTileAtPoint( 0.9, 0.4, 0, true ) ).toEqual( [ 1, 0 ] ); + expect( scheme.getTileAtPoint( 0.4, 0.9, 0, true ) ).toEqual( [ 0, 1 ] ); + + } ); + it( 'should correctly report the tile image uv range correctly when a levels bounds are larger.', () => { const scheme = new TilingScheme(); From f7be111f2e4eb878d607eadd0207f59ba592983c Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 09:58:56 +0900 Subject: [PATCH 21/35] Fix --- src/three/plugins/images/GeneratedSurfacePlugin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index c259f2e3b..af47db2ea 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -94,7 +94,7 @@ export class GeneratedSurfacePlugin { } else { this._tiling = this._createDefaultTiling(); - return this.getTileset( tiles.rootURL ); + return this.getTileset(); } @@ -315,7 +315,7 @@ export class GeneratedSurfacePlugin { } - getTileset( baseUrl ) { + getTileset( baseUrl = '' ) { const { tiles, _tiling: tiling } = this; const minLevel = tiling.minLevel; From 1cfcd0aa39ddf080f191dd54efa8d185cdaff6b9 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 10:15:01 +0900 Subject: [PATCH 22/35] fix race condition --- .../plugins/images/GeneratedSurfacePlugin.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index af47db2ea..a490c7cc4 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -135,24 +135,17 @@ export class GeneratedSurfacePlugin { const level = tile[ TILE_LEVEL ]; const range = this._tiling.getTileBounds( x, y, level, true, true ); - await overlay.lockTexture( range ); - - if ( abortSignal.aborted ) { - - overlay.releaseTexture( range ); - return null; - - } - - tile.overlayRange = range; - if ( overlay.hasContent( range ) ) { - const texture = await overlay.getTexture( range ); + await overlay.lockTexture( range ); + + const texture = overlay.getTexture( range ); + tile.overlayRange = range; if ( abortSignal.aborted ) { overlay.releaseTexture( range ); + tile.overlayRange = null; return null; } @@ -187,6 +180,7 @@ export class GeneratedSurfacePlugin { if ( this.overlay && overlayRange ) { this.overlay.releaseTexture( overlayRange ); + tile.overlayRange = null; } From 33b2606160d7bb09a25bbcabff9916c6efc58fbd Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 11:54:10 +0900 Subject: [PATCH 23/35] Fix texture assignment --- .../plugins/images/GeneratedSurfacePlugin.js | 15 ++++--- .../plugins/images/ImageOverlayPlugin.js | 43 ++++++++++--------- .../images/sources/RegionImageSource.js | 40 +++++++---------- 3 files changed, 47 insertions(+), 51 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index a490c7cc4..4cf0199d7 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -135,17 +135,19 @@ export class GeneratedSurfacePlugin { const level = tile[ TILE_LEVEL ]; const range = this._tiling.getTileBounds( x, y, level, true, true ); - if ( overlay.hasContent( range ) ) { + if ( overlay.hasContent( range, level ) ) { - await overlay.lockTexture( range ); + await overlay.lockTexture( range, level ); - const texture = overlay.getTexture( range ); + const texture = overlay.getTexture( range, level ); tile.overlayRange = range; + tile.overlayLevel = level; if ( abortSignal.aborted ) { - overlay.releaseTexture( range ); + overlay.releaseTexture( range, level ); tile.overlayRange = null; + tile.overlayLevel = null; return null; } @@ -176,11 +178,12 @@ export class GeneratedSurfacePlugin { disposeTile( tile ) { - const { overlayRange } = tile; + const { overlayRange, overlayLevel } = tile; if ( this.overlay && overlayRange ) { - this.overlay.releaseTexture( overlayRange ); + this.overlay.releaseTexture( overlayRange, overlayLevel ); tile.overlayRange = null; + tile.overlayLevel = null; } diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 741a06a3f..70b2cf37a 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1350,38 +1350,39 @@ class ImageOverlay { } - hasContent( range ) { + hasContent( range, level = null ) { return false; } - async getTexture( range ) { + async getTexture( range, level = null ) { return null; } - async lockTexture( range ) { + async lockTexture( range, level = null ) { return null; } - releaseTexture( range ) { + releaseTexture( range, level = null ) { } - setResolution( resolution ) { + shouldSplit( range, level = null ) { - } + return false; - shouldSplit( range ) { + } - return false; + setResolution( resolution ) { } + } /** @@ -1486,40 +1487,40 @@ class TiledImageOverlay extends ImageOverlay { } - hasContent( range ) { + hasContent( range, level = this.calculateLevel( range ) ) { - return this.regionImageSource.hasContent( ...range, this.calculateLevel( range ) ); + return this.regionImageSource.hasContent( ...range, level ); } - getTexture( range ) { + getTexture( range, level = this.calculateLevel( range ) ) { - return this.regionImageSource.get( ...range, this.calculateLevel( range ) ); + return this.regionImageSource.get( ...range, level ); } - lockTexture( range ) { + lockTexture( range, level = this.calculateLevel( range ) ) { - return this.regionImageSource.lock( ...range, this.calculateLevel( range ) ); + return this.regionImageSource.lock( ...range, level ); } - releaseTexture( range ) { + releaseTexture( range, level = this.calculateLevel( range ) ) { - this.regionImageSource.release( ...range, this.calculateLevel( range ) ); + this.regionImageSource.release( ...range, level ); } - setResolution( resolution ) { + shouldSplit( range, level = this.calculateLevel( range ) ) { - this.regionImageSource.resolution = resolution; + // if we haven't reached the max level yet then continue splitting + return this.tiling.maxLevel > this.calculateLevel( range ); } - shouldSplit( range ) { + setResolution( resolution ) { - // if we haven't reached the max level yet then continue splitting - return this.tiling.maxLevel > this.calculateLevel( range ); + this.regionImageSource.resolution = resolution; } diff --git a/src/three/plugins/images/sources/RegionImageSource.js b/src/three/plugins/images/sources/RegionImageSource.js index f434451ff..590eb9a5c 100644 --- a/src/three/plugins/images/sources/RegionImageSource.js +++ b/src/three/plugins/images/sources/RegionImageSource.js @@ -81,38 +81,30 @@ export class TiledRegionImageSource extends RegionImageSource { // Fast path: if the range maps to exactly one tile with matching bounds, use its // texture directly without compositing into an intermediate canvas to save memory. let singleTileBounds = null; - let tileCount = 0; forEachTileInBounds( range, level, tiling, ( tx, ty, tl ) => { - tileCount ++; - singleTileBounds = [ tx, ty, tl ]; + const thisBounds = tiling.getTileBounds( tx, ty, tl, true, false ); + if ( isArrayEqual( thisBounds, range, BOUNDS_EPSILON ) ) { + + singleTileBounds = [ tx, ty, tl ]; + + } } ); - if ( tileCount === 1 ) { + 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. + // Cloning shares the underlying Source so no extra GPU memory is used. const [ tx, ty, tl ] = singleTileBounds; - const tileBounds = tiling.getTileBounds( tx, ty, tl, true, false ); - if ( - Math.abs( tileBounds[ 0 ] - minX ) <= BOUNDS_EPSILON && - Math.abs( tileBounds[ 1 ] - minY ) <= BOUNDS_EPSILON && - Math.abs( tileBounds[ 2 ] - maxX ) <= BOUNDS_EPSILON && - Math.abs( tileBounds[ 3 ] - maxY ) <= BOUNDS_EPSILON - ) { - - // 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. - // Cloning shares the underlying Source so no extra GPU memory is used. - const clone = tiledImageSource.get( tx, ty, tl ).clone(); - clone[ IS_DIRECT_TILE ] = true; - clone[ LOCK_TOKENS ] = tokens; - - clone.__COUNT = tileCount; - return clone; + const clone = tiledImageSource.get( tx, ty, tl ).clone(); + clone[ IS_DIRECT_TILE ] = true; + clone[ LOCK_TOKENS ] = tokens; - } + return clone; } From 83e606653d33320f74baaf78eec8f222dea7ffc4 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 12:03:48 +0900 Subject: [PATCH 24/35] Add support for basic "ImageOverlay" --- .../plugins/images/GeneratedSurfacePlugin.js | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 4cf0199d7..6068a08ae 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -1,4 +1,4 @@ -/** @import { TiledImageOverlay } from './ImageOverlayPlugin.js' */ +/** @import { ImageOverlay } from './ImageOverlayPlugin.js' */ import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector3, Sphere } from 'three'; import { TILE_X, TILE_Y, TILE_LEVEL } from './ImageFormatPlugin.js'; import { getCartographicToMeterDerivative } from './utils/getCartographicToMeterDerivative.js'; @@ -23,7 +23,7 @@ const _sphere = /* @__PURE__ */ new Sphere(); * both planar and ellipsoidal geometry via the `shape` option. * * @param {Object} [options] - * @param {TiledImageOverlay} [options.overlay=null] Overlay instance to derive the tiling scheme from. + * @param {ImageOverlay} [options.overlay=null] Overlay instance to derive the tiling scheme from. * @param {string} [options.shape='planar'] Geometry shape: `'planar'` or `'ellipsoid'`. Only * meaningful for cartographic sources. * @param {boolean} [options.endCaps=true] For Mercator ellipsoid mode, snap poles to ±90° lat. @@ -77,27 +77,20 @@ export class GeneratedSurfacePlugin { async loadRootTileset() { - const { tiles, overlay } = this; + const { overlay } = this; if ( overlay ) { - const { imageSource } = overlay; - imageSource.url = imageSource.url || tiles.rootURL; - tiles.invokeAllPlugins( plugin => imageSource.url = plugin.preprocessURL ? plugin.preprocessURL( imageSource.url, null ) : imageSource.url ); - - // overlay.init() initializes the image source and creates regionImageSource await overlay.init(); - - this._tiling = overlay.tiling; - tiles.rootURL = imageSource.url; - return this.getTileset( imageSource.url ); + this._tiling = overlay.tiling || this._createDefaultTiling(); } else { this._tiling = this._createDefaultTiling(); - return this.getTileset(); } + return this.getTileset(); + } async parseToMesh( buffer, tile, extension, uri, abortSignal ) { From f5ae0d311d257648775bc8719678f922c15e70f7 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 12:13:42 +0900 Subject: [PATCH 25/35] Clean up --- .../plugins/images/GeneratedSurfacePlugin.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 6068a08ae..e97ded29f 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -24,7 +24,7 @@ const _sphere = /* @__PURE__ */ new Sphere(); * * @param {Object} [options] * @param {ImageOverlay} [options.overlay=null] Overlay instance to derive the tiling scheme from. - * @param {string} [options.shape='planar'] Geometry shape: `'planar'` or `'ellipsoid'`. Only + * @param {string} [options.shape='ellipsoid'] Geometry shape: `'planar'` or `'ellipsoid'`. Only * meaningful for cartographic sources. * @param {boolean} [options.endCaps=true] For Mercator ellipsoid mode, snap poles to ±90° lat. * @param {boolean} [options.center=true] Shift planar tiles so the image is centered at origin. @@ -34,18 +34,12 @@ export class GeneratedSurfacePlugin { constructor( options = {} ) { - // TODO: - // - defaults to a basic quad, equirect surface otherwise - // - need a target projection for planar definitions? How can we display the tiled - // carto image set as it's original aspect / projection? Separate option? - const { overlay = null, - shape = 'planar', + shape = 'ellipsoid', endCaps = true, center = true, useRecommendedSettings = true, - transparent = false, } = options; this.priority = - 10; @@ -55,7 +49,6 @@ export class GeneratedSurfacePlugin { this.shape = shape; this.endCaps = endCaps; this.center = center; - this.transparent = transparent; this.useRecommendedSettings = useRecommendedSettings; this._tiling = null; @@ -107,7 +100,7 @@ export class GeneratedSurfacePlugin { } - const { transparent, overlay } = this; + const { overlay } = this; let res; if ( this._useEllipsoid() ) { @@ -119,8 +112,6 @@ export class GeneratedSurfacePlugin { } - res.material.transparent = transparent; - if ( overlay ) { const x = tile[ TILE_X ]; From ca610b1f1e4c5ee63684c8f8c4a93b485c8c1ef5 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 14 Apr 2026 12:14:06 +0900 Subject: [PATCH 26/35] Update docs --- src/three/plugins/API.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index 624909cee..27d6e0a00 100644 --- a/src/three/plugins/API.md +++ b/src/three/plugins/API.md @@ -601,8 +601,8 @@ both planar and ellipsoidal geometry via the `shape` option. ```js constructor( { - overlay = null: TiledImageOverlay, - shape = 'planar': string, + overlay = null: ImageOverlay, + shape = 'ellipsoid': string, endCaps = true: boolean, center = true: boolean, useRecommendedSettings = true: boolean, From 036f042b661e612867f74f875ccd31ac88261a3e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 15 Apr 2026 08:14:38 +0900 Subject: [PATCH 27/35] Add new helper functions --- .../plugins/images/GeneratedSurfacePlugin.js | 75 ++++++++++++++++++- .../images/sources/RegionImageSource.js | 2 - 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index e97ded29f..ec090144e 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -173,6 +173,75 @@ export class GeneratedSurfacePlugin { } + /** + * Returns the cartographic coordinates for a given world-space position. + * @param {Vector3} position - World-space position. For ellipsoid surfaces this is a + * 3D point on the surface; for planar surfaces it is a 2D point in the plane. + * @param {{ lat: number, lon: number }} [target={}] - Optional target object to write results into. + * @returns {{ lat: number, lon: number }} The cartographic coordinates in radians. + * @throws {Error} If the tiling projection is not cartographic. + */ + getCartographicFromPosition( position, target = {} ) { + + const { _tiling: tiling } = this; + const { projection } = tiling; + + if ( ! projection.isCartographic ) { + + throw new Error( 'GeneratedSurfacePlugin: getCartographicFromPosition requires a cartographic projection.' ); + + } + + if ( this._useEllipsoid() ) { + + return this.tiles.ellipsoid.getPositionToCartographic( position, target ); + + } + + const { center } = this; + const normX = position.x / tiling.aspectRatio + ( center ? 0.5 : 0 ); + const normY = position.y + ( center ? 0.5 : 0 ); + target.lat = projection.convertNormalizedToLatitude( normY ); + target.lon = projection.convertNormalizedToLongitude( normX ); + return target; + + } + + /** + * Returns the world-space position for a given cartographic coordinate. + * @param {number} lat - Latitude in radians. + * @param {number} lon - Longitude in radians. + * @param {Vector3} [target=new Vector3()] - Optional target Vector3 to write results into. + * @returns {Vector3} The world-space position. For planar surfaces z is set to 0. + * @throws {Error} If the tiling projection is not cartographic. + */ + getPositionFromCartographic( lat, lon, target = new Vector3() ) { + + const { _tiling: tiling } = this; + const { projection } = tiling; + + if ( ! projection.isCartographic ) { + + throw new Error( 'GeneratedSurfacePlugin: getPositionFromCartographic requires a cartographic projection.' ); + + } + + if ( this._useEllipsoid() ) { + + return this.tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, target ); + + } + + const { center } = this; + const normX = projection.convertLongitudeToNormalized( lon ); + const normY = projection.convertLatitudeToNormalized( lat ); + target.x = ( normX - ( center ? 0.5 : 0 ) ) * tiling.aspectRatio; + target.y = normY - ( center ? 0.5 : 0 ); + target.z = 0; + return target; + + } + _useEllipsoid() { return this._tiling.projection.isCartographic && this.shape === 'ellipsoid'; @@ -296,7 +365,7 @@ export class GeneratedSurfacePlugin { } - getTileset( baseUrl = '' ) { + getTileset() { const { tiles, _tiling: tiling } = this; const minLevel = tiling.minLevel; @@ -333,7 +402,7 @@ export class GeneratedSurfacePlugin { }, }; - tiles.preprocessTileset( tileset, baseUrl ); + tiles.preprocessTileset( tileset, '' ); return tileset; } @@ -515,14 +584,12 @@ export class GeneratedSurfacePlugin { const projection = new ProjectionScheme( 'EPSG:4326' ); tiling.setProjection( projection ); - tiling.setContentBounds( ...projection.getBounds() ); tiling.generateLevels( DEFAULT_LEVELS, projection.tileCountX, projection.tileCountY ); } else { const projection = new ProjectionScheme( 'none' ); tiling.setProjection( projection ); - tiling.setContentBounds( ...projection.getBounds() ); tiling.generateLevels( DEFAULT_LEVELS, 1, 1 ); } diff --git a/src/three/plugins/images/sources/RegionImageSource.js b/src/three/plugins/images/sources/RegionImageSource.js index 590eb9a5c..4740add91 100644 --- a/src/three/plugins/images/sources/RegionImageSource.js +++ b/src/three/plugins/images/sources/RegionImageSource.js @@ -138,8 +138,6 @@ export class TiledRegionImageSource extends RegionImageSource { } ); - target.__COUNT = tileCount; - return target; } From f1b6eaedb91856a38978cc5719e268ff7cbaa8f8 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 15 Apr 2026 08:39:08 +0900 Subject: [PATCH 28/35] Update demo to have hover points --- example/three/mapTiles.html | 14 +++++++++++++ example/three/mapTiles.js | 42 ++++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/example/three/mapTiles.html b/example/three/mapTiles.html index 73fe2a773..4aa6b59f7 100644 --- a/example/three/mapTiles.html +++ b/example/three/mapTiles.html @@ -6,6 +6,19 @@ XYZ & TMS Tiles +
@@ -14,6 +27,7 @@
Example courtesy of OpenStreetMap.
+
diff --git a/example/three/mapTiles.js b/example/three/mapTiles.js index 697d42700..d360e90d3 100644 --- a/example/three/mapTiles.js +++ b/example/three/mapTiles.js @@ -2,13 +2,22 @@ import { Scene, WebGLRenderer, PerspectiveCamera, + Raycaster, + Vector2, + Matrix4, + MathUtils, } from 'three'; import { TilesRenderer, GlobeControls, EnvironmentControls } from '3d-tiles-renderer'; import { TilesFadePlugin, UpdateOnChangePlugin, GeneratedSurfacePlugin, XYZTilesOverlay } from '3d-tiles-renderer/plugins'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; let controls, scene, renderer; -let tiles, camera; +let tiles, camera, surfacePlugin; + +const toLocalMat = new Matrix4(); +const raycaster = new Raycaster(); +const mouse = new Vector2(); +const coordsEl = document.getElementById( 'coords' ); const params = { @@ -44,6 +53,7 @@ function init() { // events onWindowResize(); window.addEventListener( 'resize', onWindowResize, false ); + renderer.domElement.addEventListener( 'mousemove', onMouseMove, false ); // gui initialization const gui = new GUI(); @@ -77,10 +87,11 @@ function initTiles() { tiles = new TilesRenderer(); tiles.registerPlugin( new TilesFadePlugin( { maximumFadeOutTiles: 200 } ) ); tiles.registerPlugin( new UpdateOnChangePlugin() ); - tiles.registerPlugin( new GeneratedSurfacePlugin( { + surfacePlugin = new GeneratedSurfacePlugin( { overlay: new XYZTilesOverlay( { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' } ), shape: params.planar ? 'planar' : 'ellipsoid', - } ) ); + } ); + tiles.registerPlugin( surfacePlugin ); tiles.lruCache.minSize = 900; tiles.lruCache.maxSize = 1300; @@ -158,6 +169,31 @@ function render() { } +function onMouseMove( e ) { + + mouse.x = ( e.clientX / window.innerWidth ) * 2 - 1; + mouse.y = - ( e.clientY / window.innerHeight ) * 2 + 1; + + raycaster.setFromCamera( mouse, camera ); + const hits = raycaster.intersectObject( tiles.group, true ); + if ( hits.length > 0 ) { + + toLocalMat.copy( tiles.group.matrixWorld ).invert(); + hits[ 0 ].point.applyMatrix4( toLocalMat ); + + const cart = surfacePlugin.getCartographicFromPosition( hits[ 0 ].point ); + const lat = MathUtils.radToDeg( cart.lat ).toFixed( 2 ); + const lon = MathUtils.radToDeg( cart.lon ).toFixed( 2 ); + coordsEl.textContent = `${ lat }° ${ lon }°`; + + } else { + + coordsEl.textContent = ''; + + } + +} + function throttle( callback ) { let scheduled = false; From 72a658e72a76d22488f5c0345bf6eac7a84facbb Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 15 Apr 2026 08:43:32 +0900 Subject: [PATCH 29/35] Update demo --- example/three/mapTiles.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/example/three/mapTiles.js b/example/three/mapTiles.js index d360e90d3..96a3d5b50 100644 --- a/example/three/mapTiles.js +++ b/example/three/mapTiles.js @@ -8,7 +8,7 @@ import { MathUtils, } from 'three'; import { TilesRenderer, GlobeControls, EnvironmentControls } from '3d-tiles-renderer'; -import { TilesFadePlugin, UpdateOnChangePlugin, GeneratedSurfacePlugin, XYZTilesOverlay } from '3d-tiles-renderer/plugins'; +import { TilesFadePlugin, UpdateOnChangePlugin, GeneratedSurfacePlugin, XYZTilesOverlay, CesiumIonOverlay } from '3d-tiles-renderer/plugins'; import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; let controls, scene, renderer; @@ -23,6 +23,7 @@ const params = { errorTarget: 1, planar: false, + overlay: 'OpenStreetMap', }; @@ -58,6 +59,7 @@ function init() { // gui initialization const gui = new GUI(); gui.add( params, 'planar' ).onChange( initTiles ); + gui.add( params, 'overlay', [ 'OpenStreetMap', 'Sentinel-2' ] ).onChange( initTiles ); gui.add( params, 'errorTarget', 1, 40 ).onChange( () => { tiles.getPluginByName( 'UPDATE_ON_CHANGE_PLUGIN' ).needsUpdate = true; @@ -83,12 +85,16 @@ function initTiles() { } + const overlay = params.overlay === 'Sentinel-2' + ? new CesiumIonOverlay( { assetId: 3954, apiToken: import.meta.env.VITE_ION_KEY } ) + : new XYZTilesOverlay( { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' } ); + // tiles tiles = new TilesRenderer(); tiles.registerPlugin( new TilesFadePlugin( { maximumFadeOutTiles: 200 } ) ); tiles.registerPlugin( new UpdateOnChangePlugin() ); surfacePlugin = new GeneratedSurfacePlugin( { - overlay: new XYZTilesOverlay( { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' } ), + overlay, shape: params.planar ? 'planar' : 'ellipsoid', } ); tiles.registerPlugin( surfacePlugin ); From 47c95e0db9955fe87ecc5aaa41a532682d1a977d Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 15 Apr 2026 10:45:49 +0900 Subject: [PATCH 30/35] Docs update --- src/three/plugins/API.md | 23 +++++++++++++++++++ .../plugins/images/GeneratedSurfacePlugin.js | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index 27d6e0a00..d10158378 100644 --- a/src/three/plugins/API.md +++ b/src/three/plugins/API.md @@ -610,6 +610,29 @@ constructor( ) ``` +### .getCartographicFromPosition + +```js +getCartographicFromPosition( position: Vector3, target = {}: Object ): Object +``` + +Returns the cartographic coordinates for a given world-space position. "lat" and "lon" are assigned +to the target object. + + +### .getPositionFromCartographic + +```js +getPositionFromCartographic( + lat: number, + lon: number, + target = new Vector3(): Vector3 +): Vector3 +``` + +Returns the world-space position for a given cartographic coordinate. + + ## GLTFCesiumRTCExtension GLTF loader plugin that applies the [CESIUM_RTC](https://github.com/KhronosGroup/glTF/blob/main/extensions/1.0/Vendor/CESIUM_RTC/README.md) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index ec090144e..8a0169abd 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -174,7 +174,8 @@ export class GeneratedSurfacePlugin { } /** - * Returns the cartographic coordinates for a given world-space position. + * Returns the cartographic coordinates for a given world-space position. "lat" and "lon" are assigned + * to the target object. * @param {Vector3} position - World-space position. For ellipsoid surfaces this is a * 3D point on the surface; for planar surfaces it is a 2D point in the plane. * @param {{ lat: number, lon: number }} [target={}] - Optional target object to write results into. From 58b90d3cf964d81c320ffb30baaa6c899d217768 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 15 Apr 2026 12:03:51 +0900 Subject: [PATCH 31/35] Clean up --- .../plugins/images/GeneratedSurfacePlugin.js | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 8a0169abd..253f53399 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -14,11 +14,10 @@ const _norm = /* @__PURE__ */ new Vector3(); const _sphere = /* @__PURE__ */ new Sphere(); /** - * Plugin that generates tiled surface geometry from a tiling scheme, without loading - * any image data. Intended to be paired with `ImageOverlayPlugin` which handles - * image fetching and texturing separately. + * Plugin that generates tiled surface geometry from a tiling scheme, optionally loading + * image overlay data. * - * The tiling scheme and projection are derived from a provided overlay or image source. + * The tiling scheme and projection are derived from a provided overlay. * If the source's projection is cartographic (any EPSG scheme), the plugin supports * both planar and ellipsoidal geometry via the `shape` option. * @@ -88,12 +87,6 @@ export class GeneratedSurfacePlugin { async parseToMesh( buffer, tile, extension, uri, abortSignal ) { - if ( abortSignal.aborted ) { - - return null; - - } - if ( extension !== 'generated_surface' ) { return null; @@ -243,13 +236,13 @@ export class GeneratedSurfacePlugin { } + // whether the plugin is loading as an ellipsoid or not _useEllipsoid() { return this._tiling.projection.isCartographic && this.shape === 'ellipsoid'; } - // Local functions _createPlanarMesh( tile ) { const tx = tile[ TILE_X ]; @@ -266,10 +259,13 @@ export class GeneratedSurfacePlugin { } + // adjust the geometry transform itself rather than the mesh because it reduces the artifact errors + // when rendering. const geometry = new PlaneGeometry( 2 * sx, 2 * sy ); const mesh = new Mesh( geometry, new MeshBasicMaterial() ); mesh.position.set( x, y, z ); + // adjust the uvs so only the relevant texture portion is visible const uvRange = this._tiling.getTileContentUVBounds( tx, ty, level ); const { uv } = geometry.attributes; for ( let i = 0; i < uv.count; i ++ ) { @@ -293,6 +289,8 @@ export class GeneratedSurfacePlugin { const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; + // new geometry + // default to a minimum number of vertices per degree on each axis const [ west, south, east, north ] = tile.boundingVolume.region; const latVerts = Math.max( MIN_LAT_VERTS, Math.ceil( ( north - south ) * MathUtils.RAD2DEG * 0.25 ) ); const lonVerts = Math.max( MIN_LON_VERTS, Math.ceil( ( east - west ) * MathUtils.RAD2DEG * 0.25 ) ); @@ -302,12 +300,13 @@ export class GeneratedSurfacePlugin { const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true, true ); + // adjust the geometry to position it at the region const { position, normal, uv } = geometry.attributes; const vertCount = position.count; tile.engineData.boundingVolume.getSphere( _sphere ); - for ( let i = 0; i < vertCount; i ++ ) { + // determine whether this vertex is part of the skirt or not const col = i % cols; const row = Math.floor( i / cols ); const isSkirt = col === 0 || col === cols - 1 || row === 0 || row === rows - 1; @@ -317,18 +316,29 @@ export class GeneratedSurfacePlugin { const uNorm = ( innerCol - 1 ) / lonVerts; const vNorm = 1 - ( innerRow - 1 ) / latVerts; + // convert the plane position to lat / lon const lon = projection.convertNormalizedToLongitude( MathUtils.mapLinear( uNorm, 0, 1, minU, maxU ) ); let lat = projection.convertNormalizedToLatitude( MathUtils.mapLinear( vNorm, 0, 1, minV, maxV ) ); // snap edges to poles for Mercator to avoid seams if ( projection.isMercator && endCaps ) { - if ( maxV === 1 && vNorm === 1 ) lat = Math.PI / 2; - if ( minV === 0 && vNorm === 0 ) lat = - Math.PI / 2; + if ( maxV === 1 && vNorm === 1 ) { + + lat = Math.PI / 2; + + } + + if ( minV === 0 && vNorm === 0 ) { + + lat = - Math.PI / 2; + + } } - // insert edge loop at Mercator lat limit to reduce UV distortion at low LoDs + // ensure we have an edge loop positioned at the mercator limit to avoid UV distortion + // as much as possible at low LoDs. if ( projection.isMercator && vNorm !== 0 && vNorm !== 1 ) { const latLimit = projection.convertNormalizedToLatitude( 1 ); @@ -336,11 +346,21 @@ export class GeneratedSurfacePlugin { const prevLat = MathUtils.mapLinear( vNorm - vStep, 0, 1, south, north ); const nextLat = MathUtils.mapLinear( vNorm + vStep, 0, 1, south, north ); - if ( lat > latLimit && prevLat < latLimit ) lat = latLimit; - if ( lat < - latLimit && nextLat > - latLimit ) lat = - latLimit; + if ( lat > latLimit && prevLat < latLimit ) { + + lat = latLimit; + + } + + if ( lat < - latLimit && nextLat > - latLimit ) { + + lat = - latLimit; + + } } + // get the position and normal tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, _pos ).sub( _sphere.center ); tiles.ellipsoid.getCartographicToNormal( lat, lon, _norm ); @@ -354,6 +374,7 @@ export class GeneratedSurfacePlugin { const u = MathUtils.mapLinear( projection.convertLongitudeToNormalized( lon ), minU, maxU, 0, 1 ); const v = MathUtils.mapLinear( projection.convertLatitudeToNormalized( lat ), minV, maxV, 0, 1 ); + // update the geometry position.setXYZ( i, _pos.x, _pos.y, _pos.z ); normal.setXYZ( i, _norm.x, _norm.y, _norm.z ); uv.setXY( i, u, v ); From 1692f35f153e122acd7942006b7baf8ab3fad92d Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 15 Apr 2026 12:11:39 +0900 Subject: [PATCH 32/35] comments & cleanup --- .../plugins/images/GeneratedSurfacePlugin.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index 253f53399..c2a57bb3e 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -409,6 +409,7 @@ export class GeneratedSurfacePlugin { } + // generate tileset const tileset = { asset: { version: '1.1' }, geometricError: Infinity, @@ -491,6 +492,7 @@ export class GeneratedSurfacePlugin { } + // calculate the world space bounds position from the range const [ minX, minY, maxX, maxY ] = normalizedBounds; let extentsX = ( maxX - minX ) / 2; let extentsY = ( maxY - minY ) / 2; @@ -504,9 +506,11 @@ export class GeneratedSurfacePlugin { } + // scale the fields centerX *= tiling.aspectRatio; extentsX *= tiling.aspectRatio; + // return bounding box return { box: [ // center @@ -540,25 +544,35 @@ export class GeneratedSurfacePlugin { const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true ); const { tilePixelWidth, tilePixelHeight } = tiling.getLevel( level ); + // one pixel width in uv space const tileUWidth = ( maxU - minU ) / tilePixelWidth; const tileVWidth = ( maxV - minV ) / tilePixelHeight; - const [ , south, east, north ] = tiling.getTileBounds( x, y, level ); + // calculate the region ranges + const [ /* west */, south, east, north ] = tiling.getTileBounds( x, y, level ); + + // calculate the changes in lat / lon at the given point + // find the most bowed point of the latitude range since the amount that latitude changes is + // dependent on the Y value of the image const midLat = ( south > 0 ) !== ( north > 0 ) ? 0 : Math.min( Math.abs( south ), Math.abs( north ) ); const midV = projection.convertLatitudeToNormalized( midLat ); const lonFactor = projection.getLongitudeDerivativeAtNormalized( minU ); const latFactor = projection.getLatitudeDerivativeAtNormalized( midV ); + // calculate the size of a pixel on the surface const [ xDeriv, yDeriv ] = getCartographicToMeterDerivative( this.tiles.ellipsoid, midLat, east ); geometricError = Math.max( tileUWidth * lonFactor * xDeriv, tileVWidth * latFactor * yDeriv ); } else { + // Calculate geometric error: size of one pixel in world space. + // The tile contents span [0, 1] along Y and [0, aspectRatio] along X. const { pixelWidth, pixelHeight } = tiling.getLevel( level ); geometricError = Math.max( tiling.aspectRatio / pixelWidth, 1 / pixelHeight ); } + // Generate the node return { refine: 'REPLACE', geometricError, @@ -568,6 +582,7 @@ export class GeneratedSurfacePlugin { }, children: [], + // save the tile params so we can expand later [ TILE_X ]: x, [ TILE_Y ]: y, [ TILE_LEVEL ]: level, @@ -604,7 +619,7 @@ export class GeneratedSurfacePlugin { const tiling = new TilingScheme(); if ( this.shape === 'ellipsoid' ) { - const projection = new ProjectionScheme( 'EPSG:4326' ); + const projection = new ProjectionScheme(); tiling.setProjection( projection ); tiling.generateLevels( DEFAULT_LEVELS, projection.tileCountX, projection.tileCountY ); From ab80356d337978f87340b8406b94672a83286093 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 15 Apr 2026 13:47:25 +0900 Subject: [PATCH 33/35] Updates --- src/three/plugins/API.md | 7 +++---- src/three/plugins/images/GeneratedSurfacePlugin.js | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index d10158378..fd2779294 100644 --- a/src/three/plugins/API.md +++ b/src/three/plugins/API.md @@ -587,11 +587,10 @@ constructor( ## GeneratedSurfacePlugin -Plugin that generates tiled surface geometry from a tiling scheme, without loading -any image data. Intended to be paired with `ImageOverlayPlugin` which handles -image fetching and texturing separately. +Plugin that generates tiled surface geometry from a tiling scheme, optionally loading +image overlay data. -The tiling scheme and projection are derived from a provided overlay or image source. +The tiling scheme and projection are derived from a provided overlay. If the source's projection is cartographic (any EPSG scheme), the plugin supports both planar and ellipsoidal geometry via the `shape` option. diff --git a/src/three/plugins/images/GeneratedSurfacePlugin.js b/src/three/plugins/images/GeneratedSurfacePlugin.js index c2a57bb3e..406a38627 100644 --- a/src/three/plugins/images/GeneratedSurfacePlugin.js +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -110,7 +110,7 @@ export class GeneratedSurfacePlugin { const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; const level = tile[ TILE_LEVEL ]; - const range = this._tiling.getTileBounds( x, y, level, true, true ); + const range = this._tiling.getTileBounds( x, y, level, true, false ); if ( overlay.hasContent( range, level ) ) { From 4172fc0d1f411836f00556cdf147e3dfb2b82bef Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 11:39:28 +0900 Subject: [PATCH 34/35] Updates --- src/three/plugins/images/ImageOverlayPlugin.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 4d6f128c7..2363b9c32 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1382,7 +1382,6 @@ export class ImageOverlay { } - } /** @@ -1514,7 +1513,7 @@ export class TiledImageOverlay extends ImageOverlay { shouldSplit( range, level = this.calculateLevel( range ) ) { // if we haven't reached the max level yet then continue splitting - return this.tiling.maxLevel > this.calculateLevel( range ); + return this.tiling.maxLevel > level; } From 009436b81a289a97dae796cdb644a9897c8b7ae6 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sat, 16 May 2026 11:47:18 +0900 Subject: [PATCH 35/35] Update log --- src/three/plugins/images/ImageFormatPlugin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/three/plugins/images/ImageFormatPlugin.js b/src/three/plugins/images/ImageFormatPlugin.js index b280c0a01..468c61df0 100644 --- a/src/three/plugins/images/ImageFormatPlugin.js +++ b/src/three/plugins/images/ImageFormatPlugin.js @@ -25,7 +25,7 @@ export class ImageFormatPlugin { constructor( options = {} ) { - console.warn( `${ this.constructor.name } has been deprecated. Use "GeneratedSurfacePlugin", instead.` ); + console.warn( `${ this.constructor.name } has been deprecated. Use "GeneratedSurfacePlugin" & "ImageOverlayPlugin", instead.` ); const { pixelSize = null,