diff --git a/example/three/deepZoom.js b/example/three/deepZoom.js index 1857f2764..8e028826a 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/addons/libs/lil-gui.module.min.js'; let controls, scene, renderer; @@ -14,8 +14,6 @@ let tiles, transition; const params = { errorTarget: 1, - renderScale: 1, - orthographic: false, }; @@ -53,8 +51,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; @@ -91,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/example/three/geojson.js b/example/three/geojson.js index d92ac8277..164168963 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/addons/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', } ), ); 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 0323a3d95..1b0c996a5 100644 --- a/example/three/mapTiles.js +++ b/example/three/mapTiles.js @@ -2,18 +2,28 @@ import { Scene, WebGLRenderer, PerspectiveCamera, + Raycaster, + Vector2, + Matrix4, + MathUtils, } from 'three'; import { TilesRenderer, GlobeControls, EnvironmentControls } from '3d-tiles-renderer'; -import { TilesFadePlugin, UpdateOnChangePlugin, XYZTilesPlugin, } from '3d-tiles-renderer/plugins'; +import { TilesFadePlugin, UpdateOnChangePlugin, GeneratedSurfacePlugin, XYZTilesOverlay, CesiumIonOverlay } from '3d-tiles-renderer/plugins'; import { GUI } from 'three/addons/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 = { errorTarget: 1, planar: false, + overlay: 'OpenStreetMap', }; @@ -44,10 +54,12 @@ function init() { // events onWindowResize(); window.addEventListener( 'resize', onWindowResize, false ); + renderer.domElement.addEventListener( 'mousemove', onMouseMove, false ); // 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; @@ -73,21 +85,26 @@ 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() ); - tiles.registerPlugin( new XYZTilesPlugin( { - center: true, + surfacePlugin = new GeneratedSurfacePlugin( { + overlay, shape: params.planar ? 'planar' : 'ellipsoid', - url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' - } ) ); + } ); + tiles.registerPlugin( surfacePlugin ); 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 ) { @@ -158,6 +175,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; diff --git a/example/three/wmsTiles.js b/example/three/wmsTiles.js index a8ad66426..3f41673ae 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/addons/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 5a8a8ee90..180c8b7d5 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/addons/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 ); diff --git a/src/three/plugins/API.md b/src/three/plugins/API.md index 567f7a812..fd2779294 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,53 @@ constructor( ) ``` +## GeneratedSurfacePlugin + +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. +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: ImageOverlay, + shape = 'ellipsoid': string, + endCaps = true: boolean, + center = true: boolean, + useRecommendedSettings = true: boolean, + } +) +``` + +### .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 new file mode 100644 index 000000000..406a38627 --- /dev/null +++ b/src/three/plugins/images/GeneratedSurfacePlugin.js @@ -0,0 +1,638 @@ +/** @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'; +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(); +const _sphere = /* @__PURE__ */ new Sphere(); + +/** + * 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. + * 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 {ImageOverlay} [options.overlay=null] Overlay instance to derive the tiling scheme from. + * @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. + * @param {boolean} [options.useRecommendedSettings=true] Apply recommended TilesRenderer settings. + */ +export class GeneratedSurfacePlugin { + + constructor( options = {} ) { + + const { + overlay = null, + shape = 'ellipsoid', + endCaps = true, + center = true, + useRecommendedSettings = true, + } = options; + + this.priority = - 10; + this.tiles = null; + + this.overlay = overlay; + this.shape = shape; + this.endCaps = endCaps; + this.center = center; + this.useRecommendedSettings = useRecommendedSettings; + + this._tiling = null; + + } + + // Plugin functions + init( tiles ) { + + if ( this.useRecommendedSettings ) { + + tiles.errorTarget = 1; + + } + + this.tiles = tiles; + + } + + async loadRootTileset() { + + const { overlay } = this; + if ( overlay ) { + + await overlay.init(); + this._tiling = overlay.tiling || this._createDefaultTiling(); + + } else { + + this._tiling = this._createDefaultTiling(); + + } + + return this.getTileset(); + + } + + async parseToMesh( buffer, tile, extension, uri, abortSignal ) { + + if ( extension !== 'generated_surface' ) { + + return null; + + } + + const { overlay } = this; + let res; + if ( this._useEllipsoid() ) { + + res = this._createEllipsoidMesh( tile ); + + } else { + + res = this._createPlanarMesh( tile ); + + } + + if ( overlay ) { + + const x = tile[ TILE_X ]; + const y = tile[ TILE_Y ]; + const level = tile[ TILE_LEVEL ]; + const range = this._tiling.getTileBounds( x, y, level, true, false ); + + if ( overlay.hasContent( range, level ) ) { + + await overlay.lockTexture( range, level ); + + const texture = overlay.getTexture( range, level ); + tile.overlayRange = range; + tile.overlayLevel = level; + + if ( abortSignal.aborted ) { + + overlay.releaseTexture( range, level ); + tile.overlayRange = null; + tile.overlayLevel = null; + return null; + + } + + res.material.map = texture; + res.material.needsUpdate = true; + + } + + } + + return res; + + } + + preprocessNode( tile ) { + + const tiling = this._tiling; + const maxLevel = tiling.maxLevel; + const level = tile[ TILE_LEVEL ]; + if ( level < maxLevel && tile.parent !== null ) { + + this.expandChildren( tile ); + + } + + } + + disposeTile( tile ) { + + const { overlayRange, overlayLevel } = tile; + if ( this.overlay && overlayRange ) { + + this.overlay.releaseTexture( overlayRange, overlayLevel ); + tile.overlayRange = null; + tile.overlayLevel = null; + + } + + } + + /** + * 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. + * @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; + + } + + // whether the plugin is loading as an ellipsoid or not + _useEllipsoid() { + + return this._tiling.projection.isCartographic && this.shape === 'ellipsoid'; + + } + + _createPlanarMesh( tile ) { + + 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 ]; + + } + + // 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 ++ ) { + + 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; + + } + + _createEllipsoidMesh( tile ) { + + const { tiles, endCaps, _tiling: tiling } = this; + const { projection } = tiling; + const level = tile[ TILE_LEVEL ]; + 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 ) ); + 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 ); + + // 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; + + 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; + + // 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; + + } + + } + + // 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 ); + const vStep = 1 / latVerts; + 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; + + } + + } + + // get the position and normal + 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 ); + + // 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 ); + + } + + const mesh = new Mesh( geometry, new MeshBasicMaterial() ); + mesh.position.copy( _sphere.center ); + return mesh; + + } + + getTileset() { + + const { tiles, _tiling: tiling } = 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 ); + + } + + } + + } + + // generate tileset + 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, '' ); + return tileset; + + } + + getUrl( /* x, y, level */ ) { + + return 'tile.generated_surface'; + + } + + fetchData( uri ) { + + if ( /generated_surface/.test( uri ) ) { + + return new ArrayBuffer(); + + } + + } + + createBoundingVolume( x, y, level, regionHeight = 0 ) { + + const { _tiling: tiling } = this; + + const isRoot = level === - 1; + if ( this._useEllipsoid() ) { + + const { endCaps } = this; + + 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 ) { + + 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; + let normalizedBounds; + if ( isRoot ) { + + normalizedBounds = tiling.getContentBounds( true ); + + } else { + + normalizedBounds = tiling.getTileBounds( x, y, level, true ); + + } + + // 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; + let centerX = minX + extentsX; + let centerY = minY + extentsY; + + if ( center ) { + + centerX -= 0.5; + centerY -= 0.5; + + } + + // scale the fields + centerX *= tiling.aspectRatio; + extentsX *= tiling.aspectRatio; + + // return bounding box + 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, + ], + }; + + } + + } + + createChild( x, y, level ) { + + const { _tiling: tiling } = this; + const { projection } = tiling; + if ( ! tiling.getTileExists( x, y, level ) ) { + + return null; + + } + + let geometricError; + const useRegions = this._useEllipsoid(); + if ( useRegions ) { + + 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; + + // 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, + boundingVolume: this.createBoundingVolume( x, y, level, useRegions ? geometricError : 0 ), + content: { + uri: this.getUrl( x, y, level ), + }, + children: [], + + // save the tile params so we can expand later + [ 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 ); + + } + + } + + } + + } + + _createDefaultTiling() { + + const tiling = new TilingScheme(); + if ( this.shape === 'ellipsoid' ) { + + const projection = new ProjectionScheme(); + tiling.setProjection( projection ); + tiling.generateLevels( DEFAULT_LEVELS, projection.tileCountX, projection.tileCountY ); + + } else { + + const projection = new ProjectionScheme( 'none' ); + tiling.setProjection( projection ); + tiling.generateLevels( DEFAULT_LEVELS, 1, 1 ); + + } + + return tiling; + + } + +} diff --git a/src/three/plugins/images/ImageFormatPlugin.js b/src/three/plugins/images/ImageFormatPlugin.js index 0b6308f88..468c61df0 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( `${ this.constructor.name } has been deprecated. Use "GeneratedSurfacePlugin" & "ImageOverlayPlugin", instead.` ); + const { pixelSize = null, center = false, diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index a95ac7a29..2363b9c32 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(); @@ -1349,35 +1350,35 @@ export 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 ) { } @@ -1485,40 +1486,40 @@ export 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 > level; } - 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; } @@ -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 diff --git a/src/three/plugins/images/sources/RegionImageSource.js b/src/three/plugins/images/sources/RegionImageSource.js index e54400c72..4740add91 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 ) { @@ -59,36 +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; - return clone; + const clone = tiledImageSource.get( tx, ty, tl ).clone(); + clone[ IS_DIRECT_TILE ] = true; + clone[ LOCK_TOKENS ] = tokens; - } + return clone; } @@ -116,6 +132,7 @@ 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 ); 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; 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 ) { 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'; 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();