diff --git a/CHANGELOG.md b/CHANGELOG.md index d3664f4c0..11ede51a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## Unreleased +### Added +- `TilesRenderer.onTileVisibilityChange` callback. + ### Changed - Improved type definitions. diff --git a/README.md b/README.md index d2f6f3481..47b748a22 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,14 @@ onDisposeModel = null : ( scene : Object3D, tile : Tile ) => void Callback that is called every time a model is disposed of. This should be used in conjunction with [.onLoadModel](#onLoadModel) to dispose of any custom materials created for a tile. Note that the textures, materials, and geometries that a tile loaded in with are all automatically disposed of even if they have been removed from the tile meshes. +### .onTileVisibilityChange + +```js +onTileVisibilityChange = null : ( scene : Object3D, tile : Tile, visible : boolean ) => void +``` + +Callback that is called when a tile's visibility changed. The parameter `visible` is `true` when the tile is visible + ### .dispose ```js diff --git a/src/base/TileBase.d.ts b/src/base/TileBase.d.ts index ccff0af4d..f71c6e421 100644 --- a/src/base/TileBase.d.ts +++ b/src/base/TileBase.d.ts @@ -59,4 +59,6 @@ export interface TileBase { refine?: 'REPLACE' | 'ADD'; + transform?: number[]; + } diff --git a/src/base/traverseFunctions.d.ts b/src/base/traverseFunctions.d.ts new file mode 100644 index 000000000..6b79addfd --- /dev/null +++ b/src/base/traverseFunctions.d.ts @@ -0,0 +1,3 @@ +import { Tile } from './Tile'; + +export function isTileDownloadFinished( tile: Tile ): boolean; diff --git a/src/base/traverseFunctions.js b/src/base/traverseFunctions.js index 64bdc5997..9269eb61c 100644 --- a/src/base/traverseFunctions.js +++ b/src/base/traverseFunctions.js @@ -1,8 +1,8 @@ import { LOADED, FAILED } from './constants.js'; -function isDownloadFinished( value ) { +export function isTileDownloadFinished( tile ) { - return value === LOADED || value === FAILED; + return tile.__loadingState === LOADED || tile.__loadingState === FAILED; } @@ -59,7 +59,7 @@ function recursivelyLoadTiles( tile, depthFromRenderedParent, renderer ) { const doTraverse = tile.__contentEmpty && ( ! tile.__externalTileSet || - isDownloadFinished( tile.__loadingState ) + isTileDownloadFinished( tile ) ); if ( doTraverse ) { @@ -238,7 +238,7 @@ export function markUsedSetLeaves( tile, renderer ) { const childLoaded = c.__allChildrenLoaded || - ( ! c.__contentEmpty && isDownloadFinished( c.__loadingState ) ) || + ( ! c.__contentEmpty && isTileDownloadFinished( c ) ) || ( c.__externalTileSet && c.__loadingState === FAILED ); allChildrenLoaded = allChildrenLoaded && childLoaded; @@ -300,7 +300,7 @@ export function skipTraversal( tile, renderer ) { const includeTile = meetsSSE || tile.refine === 'ADD'; const hasModel = ! tile.__contentEmpty; const hasContent = hasModel || tile.__externalTileSet; - const loadedContent = isDownloadFinished( tile.__loadingState ) && hasContent; + const loadedContent = isTileDownloadFinished( tile ) && hasContent; const childrenWereVisible = tile.__childrenWereVisible; const children = tile.children; let allChildrenHaveContent = tile.__allChildrenLoaded; diff --git a/src/index.d.ts b/src/index.d.ts index b9fe5db06..85c15dc12 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -28,6 +28,9 @@ export { I3DMLoaderBase } from './base/I3DMLoaderBase'; export { PNTSLoaderBase } from './base/PNTSLoaderBase'; export { CMPTLoaderBase } from './base/CMPTLoaderBase'; export { LoaderBase } from './base/LoaderBase'; +export { isTileDownloadFinished } from './base/traverseFunctions'; + +export * as ThreeTileUtils from './three/ThreeTileUtils'; export { LRUCache } from './utilities/LRUCache'; export { PriorityQueue } from './utilities/PriorityQueue'; diff --git a/src/index.js b/src/index.js index f7963cc45..2c9f6b530 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,7 @@ import { PNTSLoader } from './three/PNTSLoader.js'; import { I3DMLoader } from './three/I3DMLoader.js'; import { CMPTLoader } from './three/CMPTLoader.js'; import { GLTFExtensionLoader } from './three/GLTFExtensionLoader.js'; +import * as ThreeTileUtils from './three/ThreeTileUtils.js'; import { TilesRendererBase } from './base/TilesRendererBase.js'; import { LoaderBase } from './base/LoaderBase.js'; @@ -24,6 +25,7 @@ import { B3DMLoaderBase } from './base/B3DMLoaderBase.js'; import { I3DMLoaderBase } from './base/I3DMLoaderBase.js'; import { PNTSLoaderBase } from './base/PNTSLoaderBase.js'; import { CMPTLoaderBase } from './base/CMPTLoaderBase.js'; +import { isTileDownloadFinished } from './base/traverseFunctions.js'; import { LRUCache } from './utilities/LRUCache.js'; import { PriorityQueue } from './utilities/PriorityQueue.js'; @@ -47,6 +49,9 @@ export { LRUCache, PriorityQueue, + isTileDownloadFinished, + ThreeTileUtils, + NONE, SCREEN_ERROR, GEOMETRIC_ERROR, diff --git a/src/three/ThreeTileUtils.d.ts b/src/three/ThreeTileUtils.d.ts new file mode 100644 index 000000000..8f724413d --- /dev/null +++ b/src/three/ThreeTileUtils.d.ts @@ -0,0 +1,19 @@ +import type { Box3, Matrix4, Sphere } from 'three'; +import type { TileBase } from '../base/TileBase'; + +// Convert optional 3d-tiles transform object into a THREE.Matrix4 +export function convertTileTransform( transform: TileBase['transform'], parentMatrix: Matrix4 ): Matrix4; + +type BoundingVolumeDescriptor = { + box: Box3|null, + boxTransform: Matrix4|null, + boxTransformInverse: Matrix4|null, + sphere: Sphere, + region: null, +} + +// Convert 3d-tiles boundingVolume optional definitions into concrete THREE box/sphere/matrix description +// - ex: used in 'tile.cached' +export function convertTileBoundingVolume( boundingVolume: TileBase['boundingVolume'], transform: Matrix4 ): BoundingVolumeDescriptor; + +export function convertBox3ToBoundingVolume( localBox: Box3, boxTransform: Matrix4 ): number[]; \ No newline at end of file diff --git a/src/three/ThreeTileUtils.js b/src/three/ThreeTileUtils.js new file mode 100644 index 000000000..3c4f6ccd2 --- /dev/null +++ b/src/three/ThreeTileUtils.js @@ -0,0 +1,173 @@ +import { Box3, Matrix4, Vector3, Sphere } from 'three'; + +const vecX = new Vector3(); +const vecY = new Vector3(); +const vecZ = new Vector3(); + +// Convert optional 3d-tiles transform object into a THREE.Matrix4 +export function convertTileTransform( transform, parentMatrix ) { + + const result = new Matrix4(); + if ( transform ) { + + const transformArr = transform; + for ( let i = 0; i < 16; i ++ ) { + + result.elements[ i ] = transformArr[ i ]; + + } + + } else { + + result.identity(); + + } + + if ( parentMatrix ) { + + result.premultiply( parentMatrix ); + + } + + return result; + +} + +// Convert 3d-tiles boundingVolume optional definitions into concrete THREE box/sphere/matrix description +// - ex: used in 'tile.cached' +export function convertTileBoundingVolume( boundingVolume, transform ) { + + let box = null; + let boxTransform = null; + let boxTransformInverse = null; + if ( 'box' in boundingVolume ) { + + const data = boundingVolume.box; + box = new Box3(); + boxTransform = new Matrix4(); + boxTransformInverse = new Matrix4(); + + // get the extents of the bounds in each axis + vecX.set( data[ 3 ], data[ 4 ], data[ 5 ] ); + vecY.set( data[ 6 ], data[ 7 ], data[ 8 ] ); + vecZ.set( data[ 9 ], data[ 10 ], data[ 11 ] ); + + const scaleX = vecX.length(); + const scaleY = vecY.length(); + const scaleZ = vecZ.length(); + + vecX.normalize(); + vecY.normalize(); + vecZ.normalize(); + + // handle the case where the box has a dimension of 0 in one axis + if ( scaleX === 0 ) { + + vecX.crossVectors( vecY, vecZ ); + + } + + if ( scaleY === 0 ) { + + vecY.crossVectors( vecX, vecZ ); + + } + + if ( scaleZ === 0 ) { + + vecZ.crossVectors( vecX, vecY ); + + } + + // create the oriented frame that the box exists in + boxTransform.set( + vecX.x, vecY.x, vecZ.x, data[ 0 ], + vecX.y, vecY.y, vecZ.y, data[ 1 ], + vecX.z, vecY.z, vecZ.z, data[ 2 ], + 0, 0, 0, 1 + ); + boxTransform.premultiply( transform ); + boxTransformInverse.copy( boxTransform ).invert(); + + // scale the box by the extents + box.min.set( - scaleX, - scaleY, - scaleZ ); + box.max.set( scaleX, scaleY, scaleZ ); + + } + + let sphere = null; + if ( 'sphere' in boundingVolume ) { + + const data = boundingVolume.sphere; + sphere = new Sphere(); + sphere.center.set( data[ 0 ], data[ 1 ], data[ 2 ] ); + sphere.radius = data[ 3 ]; + sphere.applyMatrix4( transform ); + + } else if ( 'box' in boundingVolume ) { + + const data = boundingVolume.box; + sphere = new Sphere(); + box.getBoundingSphere( sphere ); + sphere.center.set( data[ 0 ], data[ 1 ], data[ 2 ] ); + sphere.applyMatrix4( transform ); + + } + + let region = null; + if ( 'region' in boundingVolume ) { + + console.warn( 'ThreeTilesRenderer: region bounding volume not supported.' ); + + } + + return { + + box, + boxTransform, + boxTransformInverse, + sphere, + region, + + }; + +} + +// Convert a three box3 + transform into a 3d-tiles boundingVolume.box array +export function convertBox3ToBoundingVolume( box, boxTransform ) { + + const worldBox = new Box3().copy( box ).applyMatrix4( boxTransform ); + const min = [ worldBox.min.x, worldBox.min.y, worldBox.min.z ]; + const max = [ worldBox.max.x, worldBox.max.y, worldBox.max.z ]; + + const center = [ + ( max[ 0 ] - min[ 0 ] ) / 2 + min[ 0 ], + ( max[ 1 ] - min[ 1 ] ) / 2 + min[ 1 ], + ( max[ 2 ] - min[ 2 ] ) / 2 + min[ 2 ], + ]; + + const halfX = ( max[ 0 ] - min[ 0 ] ) / 2.0; + const halfY = ( max[ 1 ] - min[ 1 ] ) / 2.0; + const halfZ = ( max[ 2 ] - min[ 2 ] ) / 2.0; + + // oriented bounding box + // a right-handed 3-axis (x, y, z) Cartesian coordinate system where the z-axis is up. + const boundingVolumeBox = [ + // The first three elements define the x, y, and z values for the center of the box. + // center + center[ 0 ], center[ 1 ], center[ 2 ], + + // The next three elements (with indices 3, 4, and 5) define the x-axis direction and half-length. + halfX, 0.0, 0.0, + + // The next three elements (indices 6, 7, and 8) define the y-axis direction and half-length. + // TODO: verify handedness swap and whether we really need to flip the Y axis direction, or if something + // else is wrong internally? + 0.0, - halfY, 0.0, + + // The last three elements (indices 9, 10, and 11) define the z-axis direction and half-length. + 0.0, 0.0, halfZ, + ]; + return boundingVolumeBox; + +} diff --git a/src/three/TilesRenderer.d.ts b/src/three/TilesRenderer.d.ts index 61c04bbde..0a07fde82 100644 --- a/src/three/TilesRenderer.d.ts +++ b/src/three/TilesRenderer.d.ts @@ -30,5 +30,6 @@ export class TilesRenderer extends TilesRendererBase { onLoadTileSet : ( ( tileSet : Tileset ) => void ) | null; onLoadModel : ( ( scene : Object3D, tile : Tile ) => void ) | null; onDisposeModel : ( ( scene : Object3D, tile : Tile ) => void ) | null; + onTileVisibilityChange : ( ( scene : Object3D, tile : Tile, visible : boolean ) => void ) | null; } diff --git a/src/three/TilesRenderer.js b/src/three/TilesRenderer.js index f614eff1a..d2bb31fab 100644 --- a/src/three/TilesRenderer.js +++ b/src/three/TilesRenderer.js @@ -7,22 +7,21 @@ import { GLTFExtensionLoader } from './GLTFExtensionLoader.js'; import { TilesGroup } from './TilesGroup.js'; import { Matrix4, - Box3, - Sphere, Vector3, Vector2, Frustum, LoadingManager } from 'three'; import { raycastTraverse, raycastTraverseFirstHit } from './raycastTraverse.js'; +import { + convertTileBoundingVolume, + convertTileTransform, +} from './ThreeTileUtils.js'; const INITIAL_FRUSTUM_CULLED = Symbol( 'INITIAL_FRUSTUM_CULLED' ); const tempMat = new Matrix4(); const tempMat2 = new Matrix4(); const tempVector = new Vector3(); -const vecX = new Vector3(); -const vecY = new Vector3(); -const vecZ = new Vector3(); const X_AXIS = new Vector3( 1, 0, 0 ); const Y_AXIS = new Vector3( 0, 1, 0 ); @@ -75,6 +74,7 @@ export class TilesRenderer extends TilesRendererBase { this.onLoadTileSet = null; this.onLoadModel = null; this.onDisposeModel = null; + this.onTileVisibilityChange = null; const manager = new LoadingManager(); manager.setURLModifier( url => { @@ -434,113 +434,10 @@ export class TilesRenderer extends TilesRendererBase { super.preprocessNode( tile, parentTile, tileSetDir ); - const transform = new Matrix4(); - if ( tile.transform ) { - - const transformArr = tile.transform; - for ( let i = 0; i < 16; i ++ ) { - - transform.elements[ i ] = transformArr[ i ]; - - } - - } else { - - transform.identity(); - - } - - if ( parentTile ) { - - transform.premultiply( parentTile.cached.transform ); - - } - + const parentTransform = parentTile && parentTile.cached ? parentTile.cached.transform : undefined; + const transform = convertTileTransform( tile.transform, parentTransform ); const transformInverse = new Matrix4().copy( transform ).invert(); - - let box = null; - let boxTransform = null; - let boxTransformInverse = null; - if ( 'box' in tile.boundingVolume ) { - - const data = tile.boundingVolume.box; - box = new Box3(); - boxTransform = new Matrix4(); - boxTransformInverse = new Matrix4(); - - // get the extents of the bounds in each axis - vecX.set( data[ 3 ], data[ 4 ], data[ 5 ] ); - vecY.set( data[ 6 ], data[ 7 ], data[ 8 ] ); - vecZ.set( data[ 9 ], data[ 10 ], data[ 11 ] ); - - const scaleX = vecX.length(); - const scaleY = vecY.length(); - const scaleZ = vecZ.length(); - - vecX.normalize(); - vecY.normalize(); - vecZ.normalize(); - - // handle the case where the box has a dimension of 0 in one axis - if ( scaleX === 0 ) { - - vecX.crossVectors( vecY, vecZ ); - - } - - if ( scaleY === 0 ) { - - vecY.crossVectors( vecX, vecZ ); - - } - - if ( scaleZ === 0 ) { - - vecZ.crossVectors( vecX, vecY ); - - } - - // create the oriented frame that the box exists in - boxTransform.set( - vecX.x, vecY.x, vecZ.x, data[ 0 ], - vecX.y, vecY.y, vecZ.y, data[ 1 ], - vecX.z, vecY.z, vecZ.z, data[ 2 ], - 0, 0, 0, 1 - ); - boxTransform.premultiply( transform ); - boxTransformInverse.copy( boxTransform ).invert(); - - // scale the box by the extents - box.min.set( - scaleX, - scaleY, - scaleZ ); - box.max.set( scaleX, scaleY, scaleZ ); - - } - - let sphere = null; - if ( 'sphere' in tile.boundingVolume ) { - - const data = tile.boundingVolume.sphere; - sphere = new Sphere(); - sphere.center.set( data[ 0 ], data[ 1 ], data[ 2 ] ); - sphere.radius = data[ 3 ]; - sphere.applyMatrix4( transform ); - - } else if ( 'box' in tile.boundingVolume ) { - - const data = tile.boundingVolume.box; - sphere = new Sphere(); - box.getBoundingSphere( sphere ); - sphere.center.set( data[ 0 ], data[ 1 ], data[ 2 ] ); - sphere.applyMatrix4( transform ); - - } - - let region = null; - if ( 'region' in tile.boundingVolume ) { - - console.warn( 'ThreeTilesRenderer: region bounding volume not supported.' ); - - } + const { box, boxTransform, boxTransformInverse, sphere, region } = convertTileBoundingVolume( tile.boundingVolume, transform ); tile.cached = { @@ -817,6 +714,12 @@ export class TilesRenderer extends TilesRendererBase { } + if ( this.onTileVisibilityChange ) { + + this.onTileVisibilityChange( scene, tile, visible ); + + } + } setTileActive( tile, active ) { diff --git a/test/ThreeTileUtils.test.js b/test/ThreeTileUtils.test.js new file mode 100644 index 000000000..1aef6987a --- /dev/null +++ b/test/ThreeTileUtils.test.js @@ -0,0 +1,78 @@ +import { Matrix4, Quaternion, Vector3 } from 'three'; +import * as utils from '../src/three/ThreeTileUtils.js'; + +/** Tests to verify portions of the tile spec <-> threejs conversions */ + +const identity = new Matrix4().identity(); +const defaultTransform = { + "transform": [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + ] +}; +const testBounds = { + "boundingVolume": { + "box": [ + 66, - 13, 6, + 3, 0, 0, + 0, - 7, 0, + 0, 0, 2, + ] + }, +}; + +function decompose( transform ) { + + const position = new Vector3(); + const rotation = new Quaternion(); + const scale = new Vector3(); + transform.decompose( position, rotation, scale ); + return { position, rotation, scale }; + +} + +describe( 'ThreeTileUtils', () => { + + describe( 'transform', () => { + + it( 'default tile.transform is identity', () => { + + const transform = utils.convertTileTransform( defaultTransform.transform, identity ); + expect( transform.elements ).toEqual( identity.elements ); + + } ); + + } ); + + describe( 'boundingVolume', () => { + + it( 'boundingVolume.box to Box3', () => { + + const { box, boxTransform } = utils.convertTileBoundingVolume( testBounds.boundingVolume, identity ); + expect( box.min ).toEqual( new Vector3( - 3, - 7, - 2 ) ); + expect( box.max ).toEqual( new Vector3( 3, 7, 2 ) ); + + const transform = decompose( boxTransform ); + // - 0 is a thing three does internally apparently. ~= 0. + expect( transform.rotation ).toEqual( new Quaternion( 0, 0, 1, - 0 ) ); + expect( transform.position ).toEqual( new Vector3( 66, - 13, 6 ) ); + expect( transform.scale ).toEqual( new Vector3( - 1, 1, 1 ) ); + + } ); + + it( 'boundingVolume.box to Box3 to boundingVolume.box', () => { + + // from 3d-tiles tile data to threejs box objects + const { box, boxTransform } = utils.convertTileBoundingVolume( testBounds.boundingVolume, identity ); + // from threejs objects back to 3d-tiles volume box + const boundingVolumeBox = utils.convertBox3ToBoundingVolume( box, boxTransform ); + // should be roughly equal + expect( boundingVolumeBox ).toEqual( testBounds.boundingVolume.box ); + + } ); + + } ); + +} );