feat(All):Initial

This commit is contained in:
2025-10-04 23:36:07 +08:00
commit 2b4e5d2668
1321 changed files with 415958 additions and 0 deletions
@@ -0,0 +1 @@
export class EnforceNonZeroErrorPlugin {}
@@ -0,0 +1,48 @@
export class EnforceNonZeroErrorPlugin {
constructor() {
this.name = 'ENFORCE_NONZERO_ERROR';
this.priority = - Infinity;
this.originalError = new Map();
}
preprocessNode( tile ) {
// if a tile has zero error then traverse the parents and find some geometric error value in
// the parent hierarchy to use for calculating a pseudo geometric error for this tile.
if ( tile.geometricError === 0 ) {
let parent = tile.parent;
let depth = 1;
let targetDepth = - 1;
let targetError = Infinity;
while ( parent !== null ) {
if ( parent.geometricError !== 0 && parent.geometricError < targetError ) {
targetError = parent.geometricError;
targetDepth = depth;
}
parent = parent.parent;
depth ++;
}
// find the smallest error in the parent list to avoid grabbing artificially inflated error values
// for the sake of forced refinement. Then scale the error by the depth.
if ( targetDepth !== - 1 ) {
tile.geometricError = targetError * ( 2 ** - depth );
}
}
}
}
@@ -0,0 +1 @@
export class ImplicitTilingPlugin {}
@@ -0,0 +1,93 @@
import { SUBTREELoader } from './SUBTREELoader.js';
export class ImplicitTilingPlugin {
constructor() {
this.name = 'IMPLICIT_TILING_PLUGIN';
}
init( tiles ) {
this.tiles = tiles;
}
preprocessNode( tile, tileSetDir, parentTile ) {
if ( tile.implicitTiling ) {
tile.__hasUnrenderableContent = true;
tile.__hasRenderableContent = false;
// Declare some properties
tile.__subtreeIdx = 0; // Idx of the tile in its subtree
tile.__implicitRoot = tile; // Keep this tile as an Implicit Root Tile
// Coords of the tile
tile.__x = 0;
tile.__y = 0;
tile.__z = 0;
tile.__level = 0;
} else if ( /.subtree$/i.test( tile.content?.uri ) ) {
// Handling content uri pointing to a subtree file
tile.__hasUnrenderableContent = true;
tile.__hasRenderableContent = false;
}
}
parseTile( buffer, tile, extension ) {
if ( /^subtree$/i.test( extension ) ) {
const loader = new SUBTREELoader( tile );
loader.workingPath = tile.__basePath;
loader.fetchOptions = this.tiles.fetchOptions;
return loader.parse( buffer );
}
}
preprocessURL( url, tile ) {
if ( tile && tile.implicitTiling ) {
const implicitUri = tile.implicitTiling.subtrees.uri
.replace( '{level}', tile.__level )
.replace( '{x}', tile.__x )
.replace( '{y}', tile.__y )
.replace( '{z}', tile.__z );
return new URL( implicitUri, tile.__basePath + '/' ).toString();
}
return url;
}
disposeTile( tile ) {
if ( /.subtree$/i.test( tile.content?.uri ) ) {
// TODO: ideally the plugin doesn't need to know about children being processed
tile.children.forEach( child => {
// TODO: there should be a reliable way for removing children like this.
this.tiles.processNodeQueue.remove( child );
} );
tile.children.length = 0;
tile.__childrenProcessed = 0;
}
}
}
@@ -0,0 +1,867 @@
/**
* Structure almost identical to Cesium, also the comments and the names are kept
* https://github.com/CesiumGS/cesium/blob/0a69f67b393ba194eefb7254600811c4b712ddc0/packages/engine/Source/Scene/Implicit3DTileContent.js
*/
import { LoaderBase, LoaderUtils } from '3d-tiles-renderer/core';
function isOctreeSubdivision( tile ) {
return tile.__implicitRoot.implicitTiling.subdivisionScheme === 'OCTREE';
}
function getBoundsDivider( tile ) {
return isOctreeSubdivision( tile ) ? 8 : 4;
}
function getSubtreeCoordinates( tile, parentTile ) {
if ( ! parentTile ) {
return [ 0, 0, 0 ];
}
const x = 2 * parentTile.__x + ( tile.__subtreeIdx % 2 );
const y = 2 * parentTile.__y + ( Math.floor( tile.__subtreeIdx / 2 ) % 2 );
const z = isOctreeSubdivision( tile ) ?
2 * parentTile.__z + ( Math.floor( tile.__subtreeIdx / 4 ) % 2 ) : 0;
return [ x, y, z ];
}
class SubtreeTile {
constructor( parentTile, childMortonIndex ) {
this.parent = parentTile;
this.children = [];
this.__level = parentTile.__level + 1;
this.__implicitRoot = parentTile.__implicitRoot;
// Index inside the tree
this.__subtreeIdx = childMortonIndex;
[ this.__x, this.__y, this.__z ] = getSubtreeCoordinates( this, parentTile );
}
static copy( tile ) {
const copyTile = {};
copyTile.children = [];
copyTile.__level = tile.__level;
copyTile.__implicitRoot = tile.__implicitRoot;
// Index inside the tree
copyTile.__subtreeIdx = tile.__subtreeIdx;
[ copyTile.__x, copyTile.__y, copyTile.__z ] = [ tile.__x, tile.__y, tile.__z ];
copyTile.boundingVolume = tile.boundingVolume;
copyTile.geometricError = tile.geometricError;
return copyTile;
}
}
export class SUBTREELoader extends LoaderBase {
constructor( tile ) {
super();
this.tile = tile;
this.rootTile = tile.__implicitRoot; // The implicit root tile
this.workingPath = null;
}
/**
* A helper object for storing the two parts of the subtree binary
*
* @typedef {object} Subtree
* @property {number} version
* @property {JSON} subtreeJson
* @property {ArrayBuffer} subtreeByte
* @private
*/
/**
*
* @param buffer
* @return {Subtree}
*/
parseBuffer( buffer ) {
const dataView = new DataView( buffer );
let offset = 0;
// 16-byte header
// 4 bytes
const magic = LoaderUtils.readMagicBytes( dataView );
console.assert( magic === 'subt', 'SUBTREELoader: The magic bytes equal "subt".' );
offset += 4;
// 4 bytes
const version = dataView.getUint32( offset, true );
console.assert( version === 1, 'SUBTREELoader: The version listed in the header is "1".' );
offset += 4;
// From Cesium
// Read the bottom 32 bits of the 64-bit byte length.
// This is ok for now because:
// 1) not all browsers have native 64-bit operations
// 2) the data is well under 4GB
// 8 bytes
const jsonLength = dataView.getUint32( offset, true );
offset += 8;
// 8 bytes
const byteLength = dataView.getUint32( offset, true );
offset += 8;
const subtreeJson = JSON.parse( LoaderUtils.arrayToString( new Uint8Array( buffer, offset, jsonLength ) ) );
offset += jsonLength;
const subtreeByte = buffer.slice( offset, offset + byteLength );
return {
version,
subtreeJson,
subtreeByte
};
}
async parse( buffer ) {
// todo here : handle json
const subtree = this.parseBuffer( buffer );
const subtreeJson = subtree.subtreeJson;
// TODO Handle metadata
/*
const subtreeMetadata = subtreeJson.subtreeMetadata;
subtree._metadata = subtreeMetadata;
*/
/*
Tile availability indicates which tiles exist within the subtree
Content availability indicates which tiles have associated content resources
Child subtree availability indicates what subtrees are reachable from this subtree
*/
// After identifying how availability is stored, put the results in this new array for consistent processing later
subtreeJson.contentAvailabilityHeaders = [].concat( subtreeJson.contentAvailability );
const bufferHeaders = this.preprocessBuffers( subtreeJson.buffers );
const bufferViewHeaders = this.preprocessBufferViews(
subtreeJson.bufferViews,
bufferHeaders
);
// Buffers and buffer views are inactive until explicitly marked active.
// This way we can avoid fetching buffers that will not be used.
this.markActiveBufferViews( subtreeJson, bufferViewHeaders );
// Await the active buffers. If a buffer is external (isExternal === true),
// fetch it from its URI.
const buffersU8 = await this.requestActiveBuffers(
bufferHeaders,
subtree.subtreeByte
);
const bufferViewsU8 = this.parseActiveBufferViews( bufferViewHeaders, buffersU8 );
this.parseAvailability( subtree, subtreeJson, bufferViewsU8 );
this.expandSubtree( this.tile, subtree );
}
/**
* Determine which buffer views need to be loaded into memory. This includes:
*
* <ul>
* <li>The tile availability bitstream (if a bitstream is defined)</li>
* <li>The content availability bitstream(s) (if a bitstream is defined)</li>
* <li>The child subtree availability bitstream (if a bitstream is defined)</li>
* </ul>
*
* <p>
* This function modifies the buffer view headers' isActive flags in place.
* </p>
*
* @param {JSON} subtreeJson The JSON chunk from the subtree
* @param {BufferViewHeader[]} bufferViewHeaders The preprocessed buffer view headers
* @private
*/
markActiveBufferViews( subtreeJson, bufferViewHeaders ) {
let header;
const tileAvailabilityHeader = subtreeJson.tileAvailability;
// Check for bitstream first, which is part of the current schema.
// bufferView is the name of the bitstream from an older schema.
if ( ! isNaN( tileAvailabilityHeader.bitstream ) ) {
header = bufferViewHeaders[ tileAvailabilityHeader.bitstream ];
} else if ( ! isNaN( tileAvailabilityHeader.bufferView ) ) {
header = bufferViewHeaders[ tileAvailabilityHeader.bufferView ];
}
if ( header ) {
header.isActive = true;
header.bufferHeader.isActive = true;
}
const contentAvailabilityHeaders = subtreeJson.contentAvailabilityHeaders;
for ( let i = 0; i < contentAvailabilityHeaders.length; i ++ ) {
header = undefined;
if ( ! isNaN( contentAvailabilityHeaders[ i ].bitstream ) ) {
header = bufferViewHeaders[ contentAvailabilityHeaders[ i ].bitstream ];
} else if ( ! isNaN( contentAvailabilityHeaders[ i ].bufferView ) ) {
header = bufferViewHeaders[ contentAvailabilityHeaders[ i ].bufferView ];
}
if ( header ) {
header.isActive = true;
header.bufferHeader.isActive = true;
}
}
header = undefined;
const childSubtreeAvailabilityHeader = subtreeJson.childSubtreeAvailability;
if ( ! isNaN( childSubtreeAvailabilityHeader.bitstream ) ) {
header = bufferViewHeaders[ childSubtreeAvailabilityHeader.bitstream ];
} else if ( ! isNaN( childSubtreeAvailabilityHeader.bufferView ) ) {
header = bufferViewHeaders[ childSubtreeAvailabilityHeader.bufferView ];
}
if ( header ) {
header.isActive = true;
header.bufferHeader.isActive = true;
}
}
/**
* Go through the list of buffers and gather all the active ones into
* a dictionary.
* <p>
* The results are put into a dictionary object. The keys are indices of
* buffers, and the values are Uint8Arrays of the contents. Only buffers
* marked with the isActive flag are fetched.
* </p>
* <p>
* The internal buffer (the subtree's binary chunk) is also stored in this
* dictionary if it is marked active.
* </p>
* @param {BufferHeader[]} bufferHeaders The preprocessed buffer headers
* @param {ArrayBuffer} internalBuffer The binary chunk of the subtree file
* @returns {object} buffersU8 A dictionary of buffer index to a Uint8Array of its contents.
* @private
*/
async requestActiveBuffers( bufferHeaders, internalBuffer ) {
const promises = [];
for ( let i = 0; i < bufferHeaders.length; i ++ ) {
const bufferHeader = bufferHeaders[ i ];
// If the buffer is not active, resolve with undefined.
if ( ! bufferHeader.isActive ) {
promises.push( Promise.resolve( ) );
} else if ( bufferHeader.isExternal ) {
// Get the absolute URI of the external buffer.
const url = this.parseImplicitURIBuffer(
this.tile,
this.rootTile.implicitTiling.subtrees.uri,
bufferHeader.uri
);
const fetchPromise = fetch( url, this.fetchOptions )
.then( response => {
if ( ! response.ok ) {
throw new Error( `SUBTREELoader: Failed to load external buffer from ${ bufferHeader.uri } with error code ${ response.status }.` );
}
return response.arrayBuffer();
} )
.then( arrayBuffer => new Uint8Array( arrayBuffer ) );
promises.push( fetchPromise );
} else {
promises.push( Promise.resolve( new Uint8Array( internalBuffer ) ) );
}
}
const bufferResults = await Promise.all( promises );
const buffersU8 = {};
for ( let i = 0; i < bufferResults.length; i ++ ) {
const result = bufferResults[ i ];
if ( result ) {
buffersU8[ i ] = result;
}
}
return buffersU8;
}
/**
* Go through the list of buffer views, and if they are marked as active,
* extract a subarray from one of the active buffers.
*
* @param {BufferViewHeader[]} bufferViewHeaders
* @param {object} buffersU8 A dictionary of buffer index to a Uint8Array of its contents.
* @returns {object} A dictionary of buffer view index to a Uint8Array of its contents.
* @private
*/
parseActiveBufferViews( bufferViewHeaders, buffersU8 ) {
const bufferViewsU8 = {};
for ( let i = 0; i < bufferViewHeaders.length; i ++ ) {
const bufferViewHeader = bufferViewHeaders[ i ];
if ( ! bufferViewHeader.isActive ) {
continue;
}
const start = bufferViewHeader.byteOffset;
const end = start + bufferViewHeader.byteLength;
const buffer = buffersU8[ bufferViewHeader.buffer ];
bufferViewsU8[ i ] = buffer.slice( start, end );
}
return bufferViewsU8;
}
/**
* A buffer header is the JSON header from the subtree JSON chunk plus
* a couple extra boolean flags for easy reference.
*
* Buffers are assumed inactive until explicitly marked active. This is used
* to avoid fetching unneeded buffers.
*
* @typedef {object} BufferHeader
* @property {boolean} isActive Whether this buffer is currently used.
* @property {string} [uri] The URI of the buffer (external buffers only)
* @property {number} byteLength The byte length of the buffer, including any padding contained within.
* @private
*/
/**
* Iterate over the list of buffers from the subtree JSON and add the isActive field for easier parsing later.
* This modifies the objects in place.
* @param {Object[]} [bufferHeaders=[]] The JSON from subtreeJson.buffers.
* @returns {BufferHeader[]} The same array of headers with additional fields.
* @private
*/
preprocessBuffers( bufferHeaders = [] ) {
for ( let i = 0; i < bufferHeaders.length; i ++ ) {
const bufferHeader = bufferHeaders[ i ];
bufferHeader.isActive = false;
bufferHeader.isExternal = !! bufferHeader.uri;
}
return bufferHeaders;
}
/**
* A buffer view header is the JSON header from the subtree JSON chunk plus
* the isActive flag and a reference to the header for the underlying buffer.
*
* @typedef {object} BufferViewHeader
* @property {BufferHeader} bufferHeader A reference to the header for the underlying buffer
* @property {boolean} isActive Whether this bufferView is currently used.
* @property {number} buffer The index of the underlying buffer.
* @property {number} byteOffset The start byte of the bufferView within the buffer.
* @property {number} byteLength The length of the bufferView. No padding is included in this length.
* @private
*/
/**
* Iterate the list of buffer views from the subtree JSON and add the
* isActive flag. Also save a reference to the bufferHeader.
*
* @param {Object[]} [bufferViewHeaders=[]] The JSON from subtree.bufferViews.
* @param {BufferHeader[]} bufferHeaders The preprocessed buffer headers.
* @returns {BufferViewHeader[]} The same array of bufferView headers with additional fields.
* @private
*/
preprocessBufferViews( bufferViewHeaders = [], bufferHeaders ) {
for ( let i = 0; i < bufferViewHeaders.length; i ++ ) {
const bufferViewHeader = bufferViewHeaders[ i ];
bufferViewHeader.bufferHeader = bufferHeaders[ bufferViewHeader.buffer ];
bufferViewHeader.isActive = false;
// Keep the external flag for potential use in requestActiveBuffers
bufferViewHeader.isExternal = bufferViewHeader.bufferHeader.isExternal;
}
return bufferViewHeaders;
}
/**
* Parse the three availability bitstreams and store them in the subtree.
*
* @param {Subtree} subtree The subtree to modify.
* @param {Object} subtreeJson The subtree JSON.
* @param {Object} bufferViewsU8 A dictionary of buffer view index to a Uint8Array of its contents.
* @private
*/
parseAvailability( subtree, subtreeJson, bufferViewsU8 ) {
const branchingFactor = getBoundsDivider( this.rootTile );
const subtreeLevels = this.rootTile.implicitTiling.subtreeLevels;
const tileAvailabilityBits =
( Math.pow( branchingFactor, subtreeLevels ) - 1 ) / ( branchingFactor - 1 );
const childSubtreeBits = Math.pow( branchingFactor, subtreeLevels );
subtree._tileAvailability = this.parseAvailabilityBitstream(
subtreeJson.tileAvailability,
bufferViewsU8,
tileAvailabilityBits
);
subtree._contentAvailabilityBitstreams = [];
for ( let i = 0; i < subtreeJson.contentAvailabilityHeaders.length; i ++ ) {
const bitstream = this.parseAvailabilityBitstream(
subtreeJson.contentAvailabilityHeaders[ i ],
bufferViewsU8,
// content availability has the same length as tile availability.
tileAvailabilityBits
);
subtree._contentAvailabilityBitstreams.push( bitstream );
}
subtree._childSubtreeAvailability = this.parseAvailabilityBitstream(
subtreeJson.childSubtreeAvailability,
bufferViewsU8,
childSubtreeBits
);
}
/**
* Given the JSON describing an availability bitstream, turn it into an
* in-memory representation using an object. This handles bitstreams from a bufferView.
*
* @param {Object} availabilityJson A JSON object representing the availability.
* @param {Object} bufferViewsU8 A dictionary of buffer view index to its Uint8Array contents.
* @param {number} lengthBits The length of the availability bitstream in bits.
* @returns {object}
* @private
*/
parseAvailabilityBitstream(
availabilityJson,
bufferViewsU8,
lengthBits,
) {
if ( ! isNaN( availabilityJson.constant ) ) {
return {
constant: Boolean( availabilityJson.constant ),
lengthBits: lengthBits,
};
}
let bufferView;
// Check for bitstream first, which is part of the current schema.
// bufferView is the name of the bitstream from an older schema.
if ( ! isNaN( availabilityJson.bitstream ) ) {
bufferView = bufferViewsU8[ availabilityJson.bitstream ];
} else if ( ! isNaN( availabilityJson.bufferView ) ) {
bufferView = bufferViewsU8[ availabilityJson.bufferView ];
}
return {
bitstream: bufferView,
lengthBits: lengthBits
};
}
/**
* Expand a single subtree tile. This transcodes the subtree into
* a tree of {@link SubtreeTile}. The root of this tree is stored in
* the placeholder tile's children array. This method also creates
* tiles for the child subtrees to be lazily expanded as needed.
*
* @param {Object | SubtreeTile} subtreeRoot The first node of the subtree.
* @param {Subtree} subtree The parsed subtree.
* @private
*/
expandSubtree( subtreeRoot, subtree ) {
// TODO If multiple contents were supported then this tile could contain both renderable and un renderable content.
const contentTile = SubtreeTile.copy( subtreeRoot );
// If the subtree root tile has content, then create a placeholder child with cloned parameters
// Todo Multiple contents not handled, keep the first content found
for ( let i = 0; subtree && i < subtree._contentAvailabilityBitstreams.length; i ++ ) {
if ( subtree && this.getBit( subtree._contentAvailabilityBitstreams[ i ], 0 ) ) {
// Create a child holding the content uri, this child is similar to its parent and doesn't have any children.
contentTile.content = { uri: this.parseImplicitURI( subtreeRoot, this.rootTile.content.uri ) };
break;
}
}
subtreeRoot.children.push( contentTile );
// Creating each leaf inside the current subtree.
const bottomRow = this.transcodeSubtreeTiles(
contentTile,
subtree
);
// For each child subtree, create a tile containing the uri of the next subtree to fetch.
const childSubtrees = this.listChildSubtrees( subtree, bottomRow );
for ( let i = 0; i < childSubtrees.length; i ++ ) {
const subtreeLocator = childSubtrees[ i ];
const leafTile = subtreeLocator.tile;
const subtreeTile = this.deriveChildTile(
null,
leafTile,
null,
subtreeLocator.childMortonIndex
);
// Assign subtree uri as content.
subtreeTile.content = { uri: this.parseImplicitURI( subtreeTile, this.rootTile.implicitTiling.subtrees.uri ) };
leafTile.children.push( subtreeTile );
}
}
/**
* Transcode the implicitly defined tiles within this subtree and generate
* explicit {@link SubtreeTile} objects. This function only transcodes tiles,
* child subtrees are handled separately.
*
* @param {Object | SubtreeTile} subtreeRoot The root of the current subtree.
* @param {Subtree} subtree The subtree to get availability information.
* @returns {Array} The bottom row of transcoded tiles. This is helpful for processing child subtrees.
* @private
*/
transcodeSubtreeTiles( subtreeRoot, subtree ) {
// Sliding window over the levels of the tree.
// Each row is branchingFactor * length of previous row.
// Tiles within a row are ordered by Morton index.
let parentRow = [ subtreeRoot ];
let currentRow = [];
for ( let level = 1; level < this.rootTile.implicitTiling.subtreeLevels; level ++ ) {
const branchingFactor = getBoundsDivider( this.rootTile );
const levelOffset = ( Math.pow( branchingFactor, level ) - 1 ) / ( branchingFactor - 1 );
const numberOfChildren = branchingFactor * parentRow.length;
for ( let childMortonIndex = 0; childMortonIndex < numberOfChildren; childMortonIndex ++ ) {
const childBitIndex = levelOffset + childMortonIndex;
const parentMortonIndex = childMortonIndex >> Math.log2( branchingFactor );
const parentTile = parentRow[ parentMortonIndex ];
// Check if tile is available.
if ( ! this.getBit( subtree._tileAvailability, childBitIndex ) ) {
currentRow.push( undefined );
continue;
}
// Create a tile and add it as a child.
const childTile = this.deriveChildTile(
subtree,
parentTile,
childBitIndex,
childMortonIndex
);
parentTile.children.push( childTile );
currentRow.push( childTile );
}
parentRow = currentRow;
currentRow = [];
}
return parentRow;
}
/**
* Given a parent tile and information about which child to create, derive
* the properties of the child tile implicitly.
* <p>
* This creates a real tile for rendering.
* </p>
*
* @param {Subtree} subtree The subtree the child tile belongs to.
* @param {Object | SubtreeTile} parentTile The parent of the new child tile.
* @param {number} childBitIndex The index of the child tile within the tile's availability information.
* @param {number} childMortonIndex The morton index of the child tile relative to its parent.
* @returns {SubtreeTile} The new child tile.
* @private
*/
deriveChildTile(
subtree,
parentTile,
childBitIndex,
childMortonIndex
) {
const subtreeTile = new SubtreeTile( parentTile, childMortonIndex );
subtreeTile.boundingVolume = this.getTileBoundingVolume( subtreeTile );
subtreeTile.geometricError = this.getGeometricError( subtreeTile );
// Todo Multiple contents not handled, keep the first found content.
for ( let i = 0; subtree && i < subtree._contentAvailabilityBitstreams.length; i ++ ) {
if ( subtree && this.getBit( subtree._contentAvailabilityBitstreams[ i ], childBitIndex ) ) {
subtreeTile.content = { uri: this.parseImplicitURI( subtreeTile, this.rootTile.content.uri ) };
break;
}
}
return subtreeTile;
}
/**
* Get a bit from the bitstream as a Boolean. If the bitstream
* is a constant, the constant value is returned instead.
*
* @param {ParsedBitstream} object
* @param {number} index The integer index of the bit.
* @returns {boolean} The value of the bit.
* @private
*/
getBit( object, index ) {
if ( index < 0 || index >= object.lengthBits ) {
throw new Error( 'Bit index out of bounds.' );
}
if ( object.constant !== undefined ) {
return object.constant;
}
// byteIndex is floor(index / 8)
const byteIndex = index >> 3;
const bitIndex = index % 8;
return ( ( new Uint8Array( object.bitstream )[ byteIndex ] >> bitIndex ) & 1 ) === 1;
}
/**
* //TODO Adapt for Sphere
* To maintain numerical stability during this subdivision process,
* the actual bounding volumes should not be computed progressively by subdividing a non-root tile volume.
* Instead, the exact bounding volumes are computed directly for a given level.
* @param {Object | SubtreeTile} tile
* @return {Object} object containing the bounding volume.
*/
getTileBoundingVolume( tile ) {
const boundingVolume = {};
if ( this.rootTile.boundingVolume.region ) {
const region = [ ...this.rootTile.boundingVolume.region ];
const minX = region[ 0 ];
const maxX = region[ 2 ];
const minY = region[ 1 ];
const maxY = region[ 3 ];
const sizeX = ( maxX - minX ) / Math.pow( 2, tile.__level );
const sizeY = ( maxY - minY ) / Math.pow( 2, tile.__level );
region[ 0 ] = minX + sizeX * tile.__x; //west
region[ 2 ] = minX + sizeX * ( tile.__x + 1 ); //east
region[ 1 ] = minY + sizeY * tile.__y; //south
region[ 3 ] = minY + sizeY * ( tile.__y + 1 ); //north
for ( let k = 0; k < 4; k ++ ) {
const coord = region[ k ];
if ( coord < - Math.PI ) {
region[ k ] += 2 * Math.PI;
} else if ( coord > Math.PI ) {
region[ k ] -= 2 * Math.PI;
}
}
//Also divide the height in the case of octree.
if ( isOctreeSubdivision( tile ) ) {
const minZ = region[ 4 ];
const maxZ = region[ 5 ];
const sizeZ = ( maxZ - minZ ) / Math.pow( 2, tile.__level );
region[ 4 ] = minZ + sizeZ * tile.__z; //minimum height
region[ 5 ] = minZ + sizeZ * ( tile.__z + 1 ); //maximum height
}
boundingVolume.region = region;
}
if ( this.rootTile.boundingVolume.box ) {
// 0-2: center of the box
// 3-5: x axis direction and half length
// 6-8: y axis direction and half length
// 9-11: z axis direction and half length
const box = [ ...this.rootTile.boundingVolume.box ];
const cellSteps = 2 ** tile.__level - 1;
const scale = Math.pow( 2, - tile.__level );
const axisNumber = isOctreeSubdivision( tile ) ? 3 : 2;
for ( let i = 0; i < axisNumber; i ++ ) {
// scale the bounds axes
box[ 3 + i * 3 + 0 ] *= scale;
box[ 3 + i * 3 + 1 ] *= scale;
box[ 3 + i * 3 + 2 ] *= scale;
// axis vector
const x = box[ 3 + i * 3 + 0 ];
const y = box[ 3 + i * 3 + 1 ];
const z = box[ 3 + i * 3 + 2 ];
// adjust the center by the x, y and z axes
const axisOffset = i === 0 ? tile.__x : ( i === 1 ? tile.__y : tile.__z );
box[ 0 ] += 2 * x * ( - 0.5 * cellSteps + axisOffset );
box[ 1 ] += 2 * y * ( - 0.5 * cellSteps + axisOffset );
box[ 2 ] += 2 * z * ( - 0.5 * cellSteps + axisOffset );
}
boundingVolume.box = box;
}
return boundingVolume;
}
/**
* Each childs geometricError is half of its parents geometricError.
* @param {Object | SubtreeTile} tile
* @return {number}
*/
getGeometricError( tile ) {
return this.rootTile.geometricError / Math.pow( 2, tile.__level );
}
/**
* Determine what child subtrees exist and return a list of information.
*
* @param {Object} subtree The subtree for looking up availability.
* @param {Array} bottomRow The bottom row of tiles in a transcoded subtree.
* @returns {[]} A list of identifiers for the child subtrees.
* @private
*/
listChildSubtrees( subtree, bottomRow ) {
const results = [];
const branchingFactor = getBoundsDivider( this.rootTile );
for ( let i = 0; i < bottomRow.length; i ++ ) {
const leafTile = bottomRow[ i ];
if ( leafTile === undefined ) {
continue;
}
for ( let j = 0; j < branchingFactor; j ++ ) {
const index = i * branchingFactor + j;
if ( this.getBit( subtree._childSubtreeAvailability, index ) ) {
results.push( {
tile: leafTile,
childMortonIndex: index
} );
}
}
}
return results;
}
/**
* Replaces placeholder tokens in a URI template with the corresponding tile properties.
*
* The URI template should contain the tokens:
* - `{level}` for the tile's subdivision level.
* - `{x}` for the tile's x-coordinate.
* - `{y}` for the tile's y-coordinate.
* - `{z}` for the tile's z-coordinate.
*
* @param {Object} tile - The tile object containing properties __level, __x, __y, and __z.
* @param {string} uri - The URI template string with placeholders.
* @returns {string} The URI with placeholders replaced by the tile's properties.
*/
parseImplicitURI( tile, uri ) {
uri = uri.replace( '{level}', tile.__level );
uri = uri.replace( '{x}', tile.__x );
uri = uri.replace( '{y}', tile.__y );
uri = uri.replace( '{z}', tile.__z );
return uri;
}
/**
* Generates the full external buffer URI for a tile by combining an implicit URI with a buffer URI.
*
* First, it parses the implicit URI using the tile properties and the provided template. Then, it creates a new URL
* relative to the tile's base path, removes the last path segment, and appends the buffer URI.
*
* @param {Object} tile - The tile object that contains properties:
* - __level: the subdivision level,
* - __x, __y, __z: the tile coordinates,
* @param {string} uri - The URI template string with placeholders for the tile (e.g., `{level}`, `{x}`, `{y}`, `{z}`).
* @param {string} bufUri - The buffer file name to append (e.g., "0_1.bin").
* @returns {string} The full external buffer URI.
*/
parseImplicitURIBuffer( tile, uri, bufUri ) {
// Generate the base tile URI by replacing placeholders
const subUri = this.parseImplicitURI( tile, uri );
// Create a URL object relative to the tile's base path
const url = new URL( subUri, this.workingPath + '/' );
// Remove the last path segment
url.pathname = url.pathname.substring( 0, url.pathname.lastIndexOf( '/' ) );
// Construct the final URL with the buffer URI appended
return new URL( url.pathname + '/' + bufUri, this.workingPath + '/' ).toString();
}
}
@@ -0,0 +1,80 @@
// Class for making fetches to Cesium Ion, refreshing the token if needed.
export class CesiumIonAuth {
constructor( options = {} ) {
const { apiToken, autoRefreshToken = false } = options;
this.apiToken = apiToken;
this.autoRefreshToken = autoRefreshToken;
this.authURL = null;
this._tokenRefreshPromise = null;
this._bearerToken = null;
}
async fetch( url, options ) {
await this._tokenRefreshPromise;
// insert the authorization token
const fetchOptions = { ...options };
fetchOptions.headers = fetchOptions.headers || {};
fetchOptions.headers = {
...fetchOptions.headers,
Authorization: this._bearerToken,
};
// try to refresh the token if we failed to load the tile data
const res = await fetch( url, fetchOptions );
if ( res.status >= 400 && res.status <= 499 && this.autoRefreshToken ) {
// refresh the bearer token
await this.refreshToken( options );
fetchOptions.headers.Authorization = this._bearerToken;
return fetch( url, fetchOptions );
} else {
return res;
}
}
refreshToken( options ) {
if ( this._tokenRefreshPromise === null ) {
// construct the url to fetch the endpoint
const url = new URL( this.authURL );
url.searchParams.set( 'access_token', this.apiToken );
this._tokenRefreshPromise = fetch( url, options )
.then( res => {
if ( ! res.ok ) {
throw new Error( `CesiumIonAuthPlugin: Failed to load data with error code ${ res.status }` );
}
return res.json();
} )
.then( json => {
this._bearerToken = `Bearer ${ json.accessToken }`;
this._tokenRefreshPromise = null;
return json;
} );
}
return this._tokenRefreshPromise;
}
}
@@ -0,0 +1,166 @@
import { TraversalUtils } from '3d-tiles-renderer/core';
const TILES_MAP_URL = 'https://tile.googleapis.com/v1/createSession';
// Class for making fetches to Google Cloud, refreshing the token if needed.
// Supports both the 2d map tiles API in addition to 3d tiles.
export class GoogleCloudAuth {
get isMapTilesSession() {
return this.authURL === TILES_MAP_URL;
}
constructor( options = {} ) {
const { apiToken, sessionOptions = null, autoRefreshToken = false } = options;
this.apiToken = apiToken;
this.autoRefreshToken = autoRefreshToken;
this.authURL = TILES_MAP_URL;
this.sessionToken = null;
this.sessionOptions = sessionOptions;
this._tokenRefreshPromise = null;
}
async fetch( url, options ) {
// if we're using a map tiles session then we have to refresh the token separately
if ( this.sessionToken === null && this.isMapTilesSession ) {
this.refreshToken( options );
}
await this._tokenRefreshPromise;
// construct the url
const fetchUrl = new URL( url );
fetchUrl.searchParams.set( 'key', this.apiToken );
if ( this.sessionToken ) {
fetchUrl.searchParams.set( 'session', this.sessionToken );
}
// try to refresh the session token if we failed to load it
let res = await fetch( fetchUrl, options );
if ( res.status >= 400 && res.status <= 499 && this.autoRefreshToken ) {
// refresh the session token
await this.refreshToken( options );
if ( this.sessionToken ) {
fetchUrl.searchParams.set( 'session', this.sessionToken );
}
res = await fetch( fetchUrl, options );
}
if ( this.sessionToken === null && ! this.isMapTilesSession ) {
// if we're using a 3d tiles session then we get the session key in the first request
return res
.json()
.then( json => {
this.sessionToken = getSessionToken( json );
return json;
} );
} else {
return res;
}
}
refreshToken( options ) {
if ( this._tokenRefreshPromise === null ) {
// construct the url to fetch the endpoint
const url = new URL( this.authURL );
url.searchParams.set( 'key', this.apiToken );
// initialize options for map tiles
const fetchOptions = { ...options };
if ( this.isMapTilesSession ) {
fetchOptions.method = 'POST';
fetchOptions.body = JSON.stringify( this.sessionOptions );
fetchOptions.headers = fetchOptions.headers || {};
fetchOptions.headers = {
...fetchOptions.headers,
'Content-Type': 'application/json',
};
}
this._tokenRefreshPromise = fetch( url, fetchOptions )
.then( res => {
if ( ! res.ok ) {
throw new Error( `GoogleCloudAuth: Failed to load data with error code ${ res.status }` );
}
return res.json();
} )
.then( json => {
this.sessionToken = getSessionToken( json );
this._tokenRefreshPromise = null;
return json;
} );
}
return this._tokenRefreshPromise;
}
}
// Takes a json response from the auth url and extracts the session token
function getSessionToken( json ) {
if ( 'session' in json ) {
// if using the 2d maps api
return json.session;
} else {
// is using the 3d tiles api
let sessionToken = null;
const root = json.root;
TraversalUtils.traverseSet( root, tile => {
if ( tile.content && tile.content.uri ) {
const [ , params ] = tile.content.uri.split( '?' );
sessionToken = new URLSearchParams( params ).get( 'session' );
return true;
}
return false;
} );
return sessionToken;
}
}
@@ -0,0 +1,2 @@
export * from './ImplicitTilingPlugin.js';
export * from './EnforceNonZeroErrorPlugin.js';
@@ -0,0 +1,5 @@
export * from './ImplicitTilingPlugin.js';
export * from './EnforceNonZeroErrorPlugin.js';
export * from './auth/GoogleCloudAuth.js';
export * from './auth/CesiumIonAuth.js';
export * from './loaders/QuantizedMeshLoaderBase.js';
@@ -0,0 +1,261 @@
import { LoaderBase } from '3d-tiles-renderer/core';
export function zigZagDecode( value ) {
return ( value >> 1 ) ^ ( - ( value & 1 ) );
}
export class QuantizedMeshLoaderBase extends LoaderBase {
constructor( ...args ) {
super( ...args );
this.fetchOptions.header = {
Accept: 'application/vnd.quantized-mesh,application/octet-stream;q=0.9',
};
}
loadAsync( ...args ) {
const { fetchOptions } = this;
fetchOptions.header = fetchOptions.header || {};
fetchOptions.header[ 'Accept' ] = 'application/vnd.quantized-mesh,application/octet-stream;q=0.9';
fetchOptions.header[ 'Accept' ] += ';extensions=octvertexnormals-watermask-metadata';
return super.loadAsync( ...args );
}
parse( buffer ) {
let pointer = 0;
const view = new DataView( buffer );
const readFloat64 = () => {
const result = view.getFloat64( pointer, true );
pointer += 8;
return result;
};
const readFloat32 = () => {
const result = view.getFloat32( pointer, true );
pointer += 4;
return result;
};
const readInt = () => {
const result = view.getUint32( pointer, true );
pointer += 4;
return result;
};
const readByte = () => {
const result = view.getUint8( pointer );
pointer += 1;
return result;
};
const readBuffer = ( count, type ) => {
const result = new type( buffer, pointer, count );
pointer += count * type.BYTES_PER_ELEMENT;
return result;
};
// extract header
const header = {
center: [ readFloat64(), readFloat64(), readFloat64() ],
minHeight: readFloat32(),
maxHeight: readFloat32(),
sphereCenter: [ readFloat64(), readFloat64(), readFloat64() ],
sphereRadius: readFloat64(),
horizonOcclusionPoint: [ readFloat64(), readFloat64(), readFloat64() ],
};
// extract vertex data
const vertexCount = readInt();
const uBuffer = readBuffer( vertexCount, Uint16Array );
const vBuffer = readBuffer( vertexCount, Uint16Array );
const hBuffer = readBuffer( vertexCount, Uint16Array );
const uResult = new Float32Array( vertexCount );
const vResult = new Float32Array( vertexCount );
const hResult = new Float32Array( vertexCount );
// decode vertex data
let u = 0;
let v = 0;
let h = 0;
const MAX_VALUE = 32767;
for ( let i = 0; i < vertexCount; ++ i ) {
u += zigZagDecode( uBuffer[ i ] );
v += zigZagDecode( vBuffer[ i ] );
h += zigZagDecode( hBuffer[ i ] );
uResult[ i ] = u / MAX_VALUE;
vResult[ i ] = v / MAX_VALUE;
hResult[ i ] = h / MAX_VALUE;
}
// align pointer for index data
const is32 = vertexCount > 65536;
const bufferType = is32 ? Uint32Array : Uint16Array;
if ( is32 ) {
pointer = Math.ceil( pointer / 4 ) * 4;
} else {
pointer = Math.ceil( pointer / 2 ) * 2;
}
// extract index data
const triangleCount = readInt();
const indices = readBuffer( triangleCount * 3, bufferType );
// decode the index data
let highest = 0;
for ( var i = 0; i < indices.length; ++ i ) {
const code = indices[ i ];
indices[ i ] = highest - code;
if ( code === 0 ) {
++ highest;
}
}
// sort functions for the edges since they are not pre-sorted
const vSort = ( a, b ) => vResult[ b ] - vResult[ a ];
const vSortReverse = ( a, b ) => - vSort( a, b );
const uSort = ( a, b ) => uResult[ a ] - uResult[ b ];
const uSortReverse = ( a, b ) => - uSort( a, b );
// get edge indices
const westVertexCount = readInt();
const westIndices = readBuffer( westVertexCount, bufferType );
westIndices.sort( vSort );
const southVertexCount = readInt();
const southIndices = readBuffer( southVertexCount, bufferType );
southIndices.sort( uSort );
const eastVertexCount = readInt();
const eastIndices = readBuffer( eastVertexCount, bufferType );
eastIndices.sort( vSortReverse );
const northVertexCount = readInt();
const northIndices = readBuffer( northVertexCount, bufferType );
northIndices.sort( uSortReverse );
const edgeIndices = {
westIndices,
southIndices,
eastIndices,
northIndices,
};
// parse extensions
const extensions = {};
while ( pointer < view.byteLength ) {
const extensionId = readByte();
const extensionLength = readInt();
if ( extensionId === 1 ) {
// oct encoded normals
const xy = readBuffer( vertexCount * 2, Uint8Array );
const normals = new Float32Array( vertexCount * 3 );
// https://github.com/CesiumGS/cesium/blob/baaabaa49058067c855ad050be73a9cdfe9b6ac7/packages/engine/Source/Core/AttributeCompression.js#L119-L140
for ( let i = 0; i < vertexCount; i ++ ) {
let x = ( xy[ 2 * i + 0 ] / 255 ) * 2 - 1;
let y = ( xy[ 2 * i + 1 ] / 255 ) * 2 - 1;
const z = 1.0 - ( Math.abs( x ) + Math.abs( y ) );
if ( z < 0.0 ) {
const oldVX = x;
x = ( 1.0 - Math.abs( y ) ) * signNotZero( oldVX );
y = ( 1.0 - Math.abs( oldVX ) ) * signNotZero( y );
}
const len = Math.sqrt( x * x + y * y + z * z );
normals[ 3 * i + 0 ] = x / len;
normals[ 3 * i + 1 ] = y / len;
normals[ 3 * i + 2 ] = z / len;
}
extensions[ 'octvertexnormals' ] = {
extensionId,
normals,
};
} else if ( extensionId === 2 ) {
// water mask
const size = extensionLength === 1 ? 1 : 256;
const mask = readBuffer( size * size, Uint8Array );
extensions[ 'watermask' ] = {
extensionId,
mask,
size,
};
} else if ( extensionId === 4 ) {
// metadata
const jsonLength = readInt();
const jsonBuffer = readBuffer( jsonLength, Uint8Array );
const json = new TextDecoder().decode( jsonBuffer );
extensions[ 'metadata' ] = {
extensionId,
json: JSON.parse( json ),
};
}
}
return {
header,
indices,
vertexData: {
u: uResult,
v: vResult,
height: hResult,
},
edgeIndices,
extensions,
};
}
}
function signNotZero( v ) {
return v < 0.0 ? - 1.0 : 1.0;
}
@@ -0,0 +1,3 @@
export const WGS84_RADIUS: number;
export const WGS84_FLATTENING: number;
export const WGS84_HEIGHT: number;
@@ -0,0 +1,12 @@
// FAILED is negative so lru cache priority sorting will unload it first
export const FAILED = - 1;
export const UNLOADED = 0;
export const LOADING = 1;
export const PARSING = 2;
export const LOADED = 3;
// https://en.wikipedia.org/wiki/World_Geodetic_System
// https://en.wikipedia.org/wiki/Flattening
export const WGS84_RADIUS = 6378137;
export const WGS84_FLATTENING = 1 / 298.257223563;
export const WGS84_HEIGHT = - ( WGS84_FLATTENING * WGS84_RADIUS - WGS84_RADIUS );
@@ -0,0 +1,16 @@
// common
export { TilesRendererBase } from './tiles/TilesRendererBase.js';
export { Tile } from './tiles/Tile.js';
export { TileBase } from './tiles/TileBase.js';
export { Tileset } from './tiles/Tileset.js';
export * from './loaders/B3DMLoaderBase.js';
export * from './loaders/I3DMLoaderBase.js';
export * from './loaders/PNTSLoaderBase.js';
export * from './loaders/CMPTLoaderBase.js';
export * from './loaders/LoaderBase.js';
export * from './constants.js';
export { LRUCache } from './utilities/LRUCache.js';
export { PriorityQueue } from './utilities/PriorityQueue.js';
export { BatchTable } from './utilities/BatchTable.js';
export { FeatureTable } from './utilities/FeatureTable.js';
@@ -0,0 +1,13 @@
// common
export { TilesRendererBase } from './tiles/TilesRendererBase.js';
export { LoaderBase } from './loaders/LoaderBase.js';
export * from './loaders/B3DMLoaderBase.js';
export * from './loaders/I3DMLoaderBase.js';
export * from './loaders/PNTSLoaderBase.js';
export * from './loaders/CMPTLoaderBase.js';
export * from './constants.js';
export { LRUCache } from './utilities/LRUCache.js';
export { PriorityQueue } from './utilities/PriorityQueue.js';
export * as TraversalUtils from './utilities/TraversalUtils.js';
export * as LoaderUtils from './utilities/LoaderUtils.js';
@@ -0,0 +1,17 @@
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
export interface B3DMBaseResult {
version : string;
featureTable: FeatureTable;
batchTable : BatchTable;
glbBytes : Uint8Array;
}
export class B3DMLoaderBase<Result = B3DMBaseResult, ParseResult = Result>
extends LoaderBase<Result, ParseResult> {
}
@@ -0,0 +1,85 @@
// B3DM File Format
// https://github.com/CesiumGS/3d-tiles/blob/master/specification/TileFormats/Batched3DModel/README.md
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
import { readMagicBytes } from '../utilities/LoaderUtils.js';
export class B3DMLoaderBase extends LoaderBase {
parse( buffer ) {
// TODO: this should be able to take a uint8array with an offset and length
const dataView = new DataView( buffer );
// 28-byte header
// 4 bytes
const magic = readMagicBytes( dataView );
console.assert( magic === 'b3dm' );
// 4 bytes
const version = dataView.getUint32( 4, true );
console.assert( version === 1 );
// 4 bytes
const byteLength = dataView.getUint32( 8, true );
console.assert( byteLength === buffer.byteLength );
// 4 bytes
const featureTableJSONByteLength = dataView.getUint32( 12, true );
// 4 bytes
const featureTableBinaryByteLength = dataView.getUint32( 16, true );
// 4 bytes
const batchTableJSONByteLength = dataView.getUint32( 20, true );
// 4 bytes
const batchTableBinaryByteLength = dataView.getUint32( 24, true );
// Feature Table
const featureTableStart = 28;
const featureTableBuffer = buffer.slice(
featureTableStart,
featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength,
);
const featureTable = new FeatureTable(
featureTableBuffer,
0,
featureTableJSONByteLength,
featureTableBinaryByteLength,
);
// Batch Table
const batchTableStart = featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength;
const batchTableBuffer = buffer.slice(
batchTableStart,
batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength,
);
const batchTable = new BatchTable(
batchTableBuffer,
featureTable.getData( 'BATCH_LENGTH' ),
0,
batchTableJSONByteLength,
batchTableBinaryByteLength,
);
const glbStart = batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength;
const glbBytes = new Uint8Array( buffer, glbStart, byteLength - glbStart );
return {
version,
featureTable,
batchTable,
glbBytes,
};
}
}
@@ -0,0 +1,21 @@
import { LoaderBase } from './LoaderBase.js';
interface TileInfo {
type : string;
buffer : Uint8Array;
version : string;
}
export interface CMPTBaseResult {
version : string;
tiles : Array< TileInfo >;
}
export class CMPTLoaderBase<Result = CMPTBaseResult, ParseResult = Result>
extends LoaderBase<Result, ParseResult> {
}
@@ -0,0 +1,61 @@
// CMPT File Format
// https://github.com/CesiumGS/3d-tiles/blob/master/specification/TileFormats/Composite/README.md
import { LoaderBase } from './LoaderBase.js';
import { readMagicBytes } from '../utilities/LoaderUtils.js';
export class CMPTLoaderBase extends LoaderBase {
parse( buffer ) {
const dataView = new DataView( buffer );
// 16-byte header
// 4 bytes
const magic = readMagicBytes( dataView );
console.assert( magic === 'cmpt', 'CMPTLoader: The magic bytes equal "cmpt".' );
// 4 bytes
const version = dataView.getUint32( 4, true );
console.assert( version === 1, 'CMPTLoader: The version listed in the header is "1".' );
// 4 bytes
const byteLength = dataView.getUint32( 8, true );
console.assert( byteLength === buffer.byteLength, 'CMPTLoader: The contents buffer length listed in the header matches the file.' );
// 4 bytes
const tilesLength = dataView.getUint32( 12, true );
const tiles = [];
let offset = 16;
for ( let i = 0; i < tilesLength; i ++ ) {
const tileView = new DataView( buffer, offset, 12 );
const tileMagic = readMagicBytes( tileView );
const tileVersion = tileView.getUint32( 4, true );
const byteLength = tileView.getUint32( 8, true );
const tileBuffer = new Uint8Array( buffer, offset, byteLength );
tiles.push( {
type: tileMagic,
buffer: tileBuffer,
version: tileVersion,
} );
offset += byteLength;
}
return {
version,
tiles,
};
}
}
@@ -0,0 +1,17 @@
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
export interface I3DMBaseResult {
version : string;
featureTable: FeatureTable;
batchTable : BatchTable;
glbBytes : Uint8Array;
}
export class I3DMLoaderBase<Result = I3DMBaseResult, ParseResult = Result>
extends LoaderBase<Result, ParseResult> {
}
@@ -0,0 +1,127 @@
// I3DM File Format
// https://github.com/CesiumGS/3d-tiles/blob/master/specification/TileFormats/Instanced3DModel/README.md
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
import { readMagicBytes, arrayToString, getWorkingPath } from '../utilities/LoaderUtils.js';
export class I3DMLoaderBase extends LoaderBase {
parse( buffer ) {
const dataView = new DataView( buffer );
// 32-byte header
// 4 bytes
const magic = readMagicBytes( dataView );
console.assert( magic === 'i3dm' );
// 4 bytes
const version = dataView.getUint32( 4, true );
console.assert( version === 1 );
// 4 bytes
const byteLength = dataView.getUint32( 8, true );
console.assert( byteLength === buffer.byteLength );
// 4 bytes
const featureTableJSONByteLength = dataView.getUint32( 12, true );
// 4 bytes
const featureTableBinaryByteLength = dataView.getUint32( 16, true );
// 4 bytes
const batchTableJSONByteLength = dataView.getUint32( 20, true );
// 4 bytes
const batchTableBinaryByteLength = dataView.getUint32( 24, true );
// 4 bytes
const gltfFormat = dataView.getUint32( 28, true );
// Feature Table
const featureTableStart = 32;
const featureTableBuffer = buffer.slice(
featureTableStart,
featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength,
);
const featureTable = new FeatureTable(
featureTableBuffer,
0,
featureTableJSONByteLength,
featureTableBinaryByteLength,
);
// Batch Table
const batchTableStart = featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength;
const batchTableBuffer = buffer.slice(
batchTableStart,
batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength,
);
const batchTable = new BatchTable(
batchTableBuffer,
featureTable.getData( 'INSTANCES_LENGTH' ),
0,
batchTableJSONByteLength,
batchTableBinaryByteLength,
);
const glbStart = batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength;
const bodyBytes = new Uint8Array( buffer, glbStart, byteLength - glbStart );
let glbBytes = null;
let promise = null;
let gltfWorkingPath = null;
if ( gltfFormat ) {
glbBytes = bodyBytes;
promise = Promise.resolve();
} else {
const externalUri = this.resolveExternalURL( arrayToString( bodyBytes ) );
//Store the gltf working path
gltfWorkingPath = getWorkingPath( externalUri );
promise = fetch( externalUri, this.fetchOptions )
.then( res => {
if ( ! res.ok ) {
throw new Error( `I3DMLoaderBase : Failed to load file "${ externalUri }" with status ${ res.status } : ${ res.statusText }` );
}
return res.arrayBuffer();
} )
.then( buffer => {
glbBytes = new Uint8Array( buffer );
} );
}
return promise.then( () => {
return {
version,
featureTable,
batchTable,
glbBytes,
gltfWorkingPath
};
} );
}
}
@@ -0,0 +1,9 @@
export class LoaderBase<Result = any, ParseResult = Promise< Result >> {
fetchOptions: any;
workingPath: string;
load( url: string ): Promise< Result >;
resolveExternalURL( url: string ): string;
parse( buffer: ArrayBuffer ): ParseResult;
}
@@ -0,0 +1,58 @@
import { getWorkingPath } from '../utilities/LoaderUtils.js';
export class LoaderBase {
constructor() {
this.fetchOptions = {};
this.workingPath = '';
}
load( ...args ) {
console.warn( 'Loader: "load" function has been deprecated in favor of "loadAsync".' );
return this.loadAsync( ...args );
}
loadAsync( url ) {
return fetch( url, this.fetchOptions )
.then( res => {
if ( ! res.ok ) {
throw new Error( `Failed to load file "${ url }" with status ${ res.status } : ${ res.statusText }` );
}
return res.arrayBuffer();
} )
.then( buffer => {
if ( this.workingPath === '' ) {
this.workingPath = getWorkingPath( url );
}
return this.parse( buffer );
} );
}
resolveExternalURL( url ) {
return new URL( url, this.workingPath ).href;
}
parse( buffer ) {
throw new Error( 'LoaderBase: Parse not implemented.' );
}
}
@@ -0,0 +1,16 @@
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
export interface PNTSBaseResult {
version : string;
featureTable: FeatureTable;
batchTable : BatchTable;
}
export class PNTSLoaderBase<Result = PNTSBaseResult, ParseResult = Result>
extends LoaderBase<Result, ParseResult> {
}
@@ -0,0 +1,82 @@
// PNTS File Format
// https://github.com/CesiumGS/3d-tiles/blob/master/specification/TileFormats/PointCloud/README.md
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { readMagicBytes } from '../utilities/LoaderUtils.js';
import { LoaderBase } from './LoaderBase.js';
export class PNTSLoaderBase extends LoaderBase {
parse( buffer ) {
const dataView = new DataView( buffer );
// 28-byte header
// 4 bytes
const magic = readMagicBytes( dataView );
console.assert( magic === 'pnts' );
// 4 bytes
const version = dataView.getUint32( 4, true );
console.assert( version === 1 );
// 4 bytes
const byteLength = dataView.getUint32( 8, true );
console.assert( byteLength === buffer.byteLength );
// 4 bytes
const featureTableJSONByteLength = dataView.getUint32( 12, true );
// 4 bytes
const featureTableBinaryByteLength = dataView.getUint32( 16, true );
// 4 bytes
const batchTableJSONByteLength = dataView.getUint32( 20, true );
// 4 bytes
const batchTableBinaryByteLength = dataView.getUint32( 24, true );
// Feature Table
const featureTableStart = 28;
const featureTableBuffer = buffer.slice(
featureTableStart,
featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength,
);
const featureTable = new FeatureTable(
featureTableBuffer,
0,
featureTableJSONByteLength,
featureTableBinaryByteLength,
);
// Batch Table
const batchTableStart = featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength;
const batchTableBuffer = buffer.slice(
batchTableStart,
batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength,
);
const batchTable = new BatchTable(
batchTableBuffer,
featureTable.getData( 'BATCH_LENGTH' ) || featureTable.getData( 'POINTS_LENGTH' ),
0,
batchTableJSONByteLength,
batchTableBinaryByteLength,
);
return Promise.resolve( {
version,
featureTable,
batchTable,
} );
}
}
@@ -0,0 +1,50 @@
import { TileBase } from './TileBase.js';
/**
* Documented 3d-tile state managed by the TilesRenderer* / used/usable in priority / traverseFunctions!
*/
export interface Tile extends TileBase {
parent: Tile;
/**
* Hierarchy Depth from the TileGroup
*/
__depth : number;
/**
* The screen space error for this tile
*/
__error : number;
/**
* How far is this tiles bounds from the nearest active Camera.
* Expected to be filled in during calculateError implementations.
*/
__distanceFromCamera : number;
/**
* This tile is currently active if:
* 1: Tile content is loaded and ready to be made visible if needed
*/
__active : boolean;
/**
* This tile is currently visible if:
* 1: Tile content is loaded
* 2: Tile is within a camera frustum
* 3: Tile meets the SSE requirements
*/
__visible : boolean;
/**
* Whether or not the tile was visited during the last update run.
*/
__used : boolean;
/**
* Whether or not the tile was within the frustum on the last update run.
*/
__inFrustum : boolean;
/**
* The depth of the tiles that increments only when a child with geometry content is encountered
*/
__depthFromRenderedParent : number;
}
@@ -0,0 +1,76 @@
/**
* 3d-tiles Tile object per spec:
* (incomplete, expanding as features become supported by this package.)
*
* See spec for full schema: https://github.com/CesiumGS/3d-tiles/blob/master/specification/schema/tile.schema.json
*/
export interface TileBase {
boundingVolume: {
/**
* An array of 12 numbers that define an oriented bounding box. The first three elements define the x, y, and z
* values for the center of the box. The next three elements (with indices 3, 4, and 5) define the x axis
* direction and half-length. The next three elements (indices 6, 7, and 8) define the y axis direction and
* half-length. The last three elements (indices 9, 10, and 11) define the z axis direction and half-length.
*/
box?: number[];
/**
* An array of four numbers that define a bounding sphere. The first three elements define the x, y, and z
* values for the center of the sphere. The last element (with index 3) defines the radius in meters.
*/
sphere?: number[];
};
/**
* The error, in meters, introduced if this tileset is not rendered. At runtime, the geometric error is used to compute screen space error (SSE), i.e., the error measured in pixels.
*/
geometricError: number;
// optional properties
children?: TileBase[];
content?: {
uri: string;
/**
* Dictionary object with content specific extension objects.
*/
extensions?: Record<string, any>;
extras?: Record<string, any>;
// Non standard, noted here as it exists in the code in this package to support old pre-1.0 tilesets
url?: string;
};
// An object that describes the implicit subdivision of this tile.
implicitTiling: {
// A string describing the subdivision scheme used within the tileset.
subdivisionScheme: 'QUADTREE' | 'OCTREE';
subtreeLevels: number;
availableLevels: number;
// An object describing the location of subtree files.
subtrees: {
// A template URI pointing to subtree files
uri: string;
}
},
/**
* Dictionary object with tile specific extension objects.
*/
extensions?: Record<string, any>;
extras?: Record<string, any>;
refine?: 'REPLACE' | 'ADD';
transform?: number[];
}
@@ -0,0 +1,35 @@
import { Tile } from './Tile.js';
/**
* Internal state used/set by the package.
*/
export interface TileInternal extends Tile {
// tile description
__isLeaf: boolean;
__hasContent: boolean;
__hasRenderableContent: boolean;
__hasUnrenderableContent: boolean;
// resource tracking
__usedLastFrame: boolean;
__used: boolean;
// Visibility tracking
__allChildrenLoaded: boolean;
__inFrustum: boolean;
__wasSetVisible: boolean;
// download state tracking
/**
* This tile is currently active if:
* 1: Tile content is loaded and ready to be made visible if needed
*/
__active: boolean;
__loadIndex: number;
__loadAbort: AbortController | null;
__loadingState: number;
__wasSetActive: boolean;
}
@@ -0,0 +1,38 @@
import { LRUCache } from '../utilities/LRUCache.js';
import { PriorityQueue } from '../utilities/PriorityQueue.js';
export class TilesRendererBase {
readonly rootTileSet : object | null;
readonly root : object | null;
errorTarget : number;
errorThreshold : number;
displayActiveTiles : boolean;
maxDepth : number;
loadProgress: number;
fetchOptions : RequestInit;
preprocessURL : ( ( uri: string | URL ) => string ) | null;
lruCache : LRUCache;
parseQueue : PriorityQueue;
downloadQueue : PriorityQueue;
processNodeQueue: PriorityQueue;
constructor( url?: string );
update() : void;
registerPlugin( plugin: object ) : void;
unregisterPlugin( plugin: object | string ) : boolean;
getPluginByName( plugin: object | string ) : object;
traverse(
beforeCb : ( ( tile : object, parent : object, depth : number ) => boolean ) | null,
afterCb : ( ( tile : object, parent : object, depth : number ) => boolean ) | null
) : void;
getAttributions( target? : Array<{ type: string, value: any }> ) : Array<{ type: string, value: any }>;
dispose() : void;
resetFailedTiles() : void;
}
@@ -0,0 +1,66 @@
import { TileBase } from './TileBase.js';
/**
* A 3d-tiles tileset.
*
* Schema, see: https://github.com/CesiumGS/3d-tiles/blob/main/specification/schema/tileset.schema.json
*/
export interface Tileset {
/**
* Metadata about the entire tileset.
*/
asset: {
/**
* 3d-tiles version
*/
version: string,
/**
* Application specific version
*/
tilesetVersion?: string,
/**
* Dictionary object with extension-specific objects.
*/
extensions? : Record<string, any>,
};
/**
* The error, in meters, introduced if this tileset is not rendered. At runtime, the geometric error is used to compute screen space error (SSE), i.e., the error measured in pixels.
*/
geometricError: number;
/**
* The root tile.
*/
root: TileBase;
// optional properties
/**
* Names of 3D Tiles extensions used somewhere in this tileset.
*/
extensionsUsed?: string[];
/**
* Names of 3D Tiles extensions required to properly load this tileset.
*/
extensionsRequired?: string[];
/**
* A dictionary object of metadata about per-feature properties.
*/
properties?: Record<string, any>;
/**
* Dictionary object with extension-specific objects.
*/
extensions? : Record<string, any>;
extras? : Record<string, any>;
}
@@ -0,0 +1,454 @@
import { LOADED, FAILED } from '../constants.js';
const viewErrorTarget = {
inView: false,
error: Infinity,
distanceFromCamera: Infinity,
};
// flag guiding the behavior of the traversal to load the siblings at the root of the
// tile set or not. The spec seems to indicate "true" when using REPLACE define but
// Cesium's behavior is "false".
// See CesiumGS/3d-tiles#776
const LOAD_ROOT_SIBLINGS = true;
function isDownloadFinished( value ) {
return value === LOADED || value === FAILED;
}
// Checks whether this tile was last used on the given frame.
function isUsedThisFrame( tile, frameCount ) {
return tile.__lastFrameVisited === frameCount && tile.__used;
}
function areChildrenProcessed( tile ) {
return tile.__childrenProcessed === tile.children.length;
}
// Resets the frame frame information for the given tile
function resetFrameState( tile, renderer ) {
if ( tile.__lastFrameVisited !== renderer.frameCount ) {
tile.__lastFrameVisited = renderer.frameCount;
tile.__used = false;
tile.__inFrustum = false;
tile.__isLeaf = false;
tile.__visible = false;
tile.__active = false;
tile.__error = Infinity;
tile.__distanceFromCamera = Infinity;
tile.__allChildrenLoaded = false;
// update tile frustum and error state
renderer.calculateTileViewError( tile, viewErrorTarget );
tile.__inFrustum = viewErrorTarget.inView;
tile.__error = viewErrorTarget.error;
tile.__distanceFromCamera = viewErrorTarget.distanceFromCamera;
}
}
// Recursively mark tiles used down to the next layer, skipping external tile sets
function recursivelyMarkUsed( tile, renderer ) {
renderer.ensureChildrenArePreprocessed( tile );
resetFrameState( tile, renderer );
markUsed( tile, renderer );
// don't traverse if the children have not been processed, yet but tile set content
// should be considered to be "replaced" by the loaded children so await that here.
if ( tile.__hasUnrenderableContent && areChildrenProcessed( tile ) ) {
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
recursivelyMarkUsed( children[ i ], renderer );
}
}
}
// Recursively traverses to the next tiles with unloaded renderable content to load them
function recursivelyLoadNextRenderableTiles( tile, renderer ) {
renderer.ensureChildrenArePreprocessed( tile );
// exit the recursion if the tile hasn't been used this frame
if ( isUsedThisFrame( tile, renderer.frameCount ) ) {
// queue this tile to download content
if ( tile.__hasContent ) {
renderer.queueTileForDownload( tile );
}
if ( areChildrenProcessed( tile ) ) {
// queue any used child tiles
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
recursivelyLoadNextRenderableTiles( children[ i ], renderer );
}
}
}
}
// Mark a tile as being used by current view
function markUsed( tile, renderer ) {
if ( tile.__used ) {
return;
}
tile.__used = true;
renderer.markTileUsed( tile );
renderer.stats.used ++;
if ( tile.__inFrustum === true ) {
renderer.stats.inFrustum ++;
}
}
// Returns whether the tile can be traversed to the next layer of children by checking the tile metrics
function canTraverse( tile, renderer ) {
// If we've met the error requirements then don't load further - if an external tile set is encountered,
// though, then continue to refine.
if ( tile.__error <= renderer.errorTarget && ! tile.__hasUnrenderableContent ) {
return false;
}
// Early out if we've reached the maximum allowed depth.
if ( renderer.maxDepth > 0 && tile.__depth + 1 >= renderer.maxDepth ) {
return false;
}
// Early out if the children haven't been processed, yet
if ( ! areChildrenProcessed( tile ) ) {
return false;
}
return true;
}
// Determine which tiles are used by the renderer given the current camera configuration
export function markUsedTiles( tile, renderer ) {
// determine frustum set is run first so we can ensure the preprocessing of all the necessary
// child tiles has happened here.
renderer.ensureChildrenArePreprocessed( tile );
resetFrameState( tile, renderer );
if ( ! tile.__inFrustum ) {
return;
}
if ( ! canTraverse( tile, renderer ) ) {
markUsed( tile, renderer );
return;
}
// Traverse children and see if any children are in view.
let anyChildrenUsed = false;
let anyChildrenInFrustum = false;
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
markUsedTiles( c, renderer );
anyChildrenUsed = anyChildrenUsed || isUsedThisFrame( c, renderer.frameCount );
anyChildrenInFrustum = anyChildrenInFrustum || c.__inFrustum;
}
// Disabled for now because this will cause otherwise unused children to be added to the lru cache
// if none of the children are in the frustum then this tile shouldn't be displayed.
// Otherwise this can cause load oscillation as parents are traversed and loaded and then determined
// to not be used because children aren't visible. See #1165.
// if ( tile.refine === 'REPLACE' && ! anyChildrenInFrustum && children.length !== 0 && ! tile.__hasUnrenderableContent ) {
// // TODO: we're not checking tiles with unrenderable content here since external tile sets might look like they're in the frustum,
// // load the children, then the children indicate that it's not visible, causing it to be unloaded. Then it will be loaded again.
// // The impact when including external tile set roots in the check is more significant but can't be used unless we keep external tile
// // sets around even when they're not needed. See issue #741.
// // TODO: what if we mark the tile as not in the frustum but we _do_ mark it as used? Then we can stop frustum traversal and at least
// // prevent tiles from rendering unless they're needed.
// console.log('FAILED')
// tile.__inFrustum = false;
// return;
// }
// wait until after the above condition to mark the traversed tile as used or not
markUsed( tile, renderer );
// If this is a tile that needs children loaded to refine then recursively load child
// tiles until error is met
if ( anyChildrenUsed && tile.refine === 'REPLACE' && ( tile.__depth !== 0 || LOAD_ROOT_SIBLINGS ) ) {
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
recursivelyMarkUsed( c, renderer );
}
}
}
// Traverse and mark the tiles that are at the leaf nodes of the "used" tree.
export function markUsedSetLeaves( tile, renderer ) {
const frameCount = renderer.frameCount;
if ( ! isUsedThisFrame( tile, frameCount ) ) {
return;
}
// This tile is a leaf if none of the children had been used.
const children = tile.children;
let anyChildrenUsed = false;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
anyChildrenUsed = anyChildrenUsed || isUsedThisFrame( c, frameCount );
}
if ( ! anyChildrenUsed ) {
tile.__isLeaf = true;
} else {
let allChildrenLoaded = true;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
markUsedSetLeaves( c, renderer );
if ( isUsedThisFrame( c, frameCount ) ) {
// consider a child to be loaded if
// - the children's children have been loaded
// - the tile content has loaded
// - the tile is completely empty - ie has no children and no content
// - the child tile set has tried to load but failed
const childLoaded =
c.__allChildrenLoaded ||
! c.__hasContent ||
( c.__hasRenderableContent && isDownloadFinished( c.__loadingState ) ) ||
( c.__hasUnrenderableContent && c.__loadingState === FAILED );
allChildrenLoaded = allChildrenLoaded && childLoaded;
}
}
tile.__allChildrenLoaded = allChildrenLoaded;
}
}
// TODO: revisit implementation
// Skip past tiles we consider unrenderable because they are outside the error threshold.
export function markVisibleTiles( tile, renderer ) {
const stats = renderer.stats;
if ( ! isUsedThisFrame( tile, renderer.frameCount ) ) {
return;
}
// Request the tile contents or mark it as visible if we've found a leaf.
if ( tile.__isLeaf ) {
if ( tile.__loadingState === LOADED ) {
if ( tile.__inFrustum ) {
tile.__visible = true;
stats.visible ++;
}
tile.__active = true;
stats.active ++;
} else if ( tile.__hasContent ) {
renderer.queueTileForDownload( tile );
}
return;
}
const children = tile.children;
const hasContent = tile.__hasContent;
const loadedContent = isDownloadFinished( tile.__loadingState ) && hasContent;
const errorRequirement = ( renderer.errorTarget + 1 ) * renderer.errorThreshold;
const meetsSSE = tile.__error <= errorRequirement;
const isAdditiveRefine = tile.refine === 'ADD';
// TODO: the "meetsSSE" field can be removed when the "errorThreshold" field has been removed
// Don't wait for all children tiles to load if this tile set has empty tiles at the root in order
// to match Cesium's behavior
const allChildrenLoaded = tile.__allChildrenLoaded || ( tile.__depth === 0 && ! LOAD_ROOT_SIBLINGS );
// If we've met the SSE requirements and we can load content then fire a fetch.
if ( hasContent && ( meetsSSE || isAdditiveRefine ) ) {
renderer.queueTileForDownload( tile );
}
// By this time only tiles that meet the screen space error requirements will be traversed. Only mark this
// as visible if it's been loaded and not all children have loaded yet or it's an additive tile, meaning it needs
// to display in addition to the children.
// Skip the tile entirely if there's no content to load
if ( meetsSSE && loadedContent && ! allChildrenLoaded || loadedContent && isAdditiveRefine ) {
if ( tile.__inFrustum ) {
tile.__visible = true;
stats.visible ++;
}
tile.__active = true;
stats.active ++;
}
// If we're additive then don't stop the traversal here because it doesn't matter whether the children load in
// at the same rate.
if ( ! isAdditiveRefine && meetsSSE && ! allChildrenLoaded ) {
// load the child content if we've found that we've been loaded so we can move down to the next tile
// layer when the data has loaded.
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
if ( isUsedThisFrame( c, renderer.frameCount ) ) {
recursivelyLoadNextRenderableTiles( c, renderer );
}
}
} else {
for ( let i = 0, l = children.length; i < l; i ++ ) {
markVisibleTiles( children[ i ], renderer );
}
}
}
// Final traverse to toggle tile visibility.
export function toggleTiles( tile, renderer ) {
const isUsed = isUsedThisFrame( tile, renderer.frameCount );
if ( isUsed || tile.__usedLastFrame ) {
let setActive = false;
let setVisible = false;
if ( isUsed ) {
// enable visibility if active due to shadows
setActive = tile.__active;
if ( renderer.displayActiveTiles ) {
setVisible = tile.__active || tile.__visible;
} else {
setVisible = tile.__visible;
}
} else {
// if the tile was used last frame but not this one then there's potential for the tile
// to not have been visited during the traversal, meaning it hasn't been reset and has
// stale values. This ensures the values are not stale.
resetFrameState( tile, renderer );
}
// If the active or visible state changed then call the functions.
if ( tile.__hasRenderableContent && tile.__loadingState === LOADED ) {
if ( tile.__wasSetActive !== setActive ) {
renderer.invokeOnePlugin( plugin => plugin.setTileActive && plugin.setTileActive( tile, setActive ) );
}
if ( tile.__wasSetVisible !== setVisible ) {
renderer.invokeOnePlugin( plugin => plugin.setTileVisible && plugin.setTileVisible( tile, setVisible ) );
}
}
tile.__wasSetActive = setActive;
tile.__wasSetVisible = setVisible;
tile.__usedLastFrame = isUsed;
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
toggleTiles( c, renderer );
}
}
}
@@ -0,0 +1,24 @@
export class BatchTable {
count : number;
constructor(
buffer : ArrayBuffer,
count : number,
start : number,
headerLength : number,
binLength : number
);
getKeys() : Array< string >;
getDataFromId(
id: number,
target?: object
) : object;
getPropertyArray(
key: string,
) : number | string | ArrayBufferView;
}
@@ -0,0 +1,78 @@
import { BatchTableHierarchyExtension } from './BatchTableHierarchyExtension.js';
import { FeatureTable } from './FeatureTable.js';
export class BatchTable extends FeatureTable {
get batchSize() {
console.warn( 'BatchTable.batchSize has been deprecated and replaced with BatchTable.count.' );
return this.count;
}
constructor( buffer, count, start, headerLength, binLength ) {
super( buffer, start, headerLength, binLength );
this.count = count;
this.extensions = {};
const extensions = this.header.extensions;
if ( extensions ) {
if ( extensions[ '3DTILES_batch_table_hierarchy' ] ) {
this.extensions[ '3DTILES_batch_table_hierarchy' ] = new BatchTableHierarchyExtension( this );
}
}
}
getData( key, componentType = null, type = null ) {
console.warn( 'BatchTable: BatchTable.getData is deprecated. Use BatchTable.getDataFromId to get all' +
'properties for an id or BatchTable.getPropertyArray for getting an array of value for a property.' );
return super.getData( key, this.count, componentType, type );
}
getDataFromId( id, target = {} ) {
if ( id < 0 || id >= this.count ) {
throw new Error( `BatchTable: id value "${ id }" out of bounds for "${ this.count }" features number.` );
}
for ( const key of this.getKeys() ) {
target[ key ] = super.getData( key, this.count )[ id ];
}
for ( const extensionName in this.extensions ) {
const extension = this.extensions[ extensionName ];
if ( extension.getDataFromId instanceof Function ) {
target[ extensionName ] = target[ extensionName ] || {};
extension.getDataFromId( id, target[ extensionName ] );
}
}
return target;
}
getPropertyArray( key ) {
return super.getData( key, this.count );
}
}
@@ -0,0 +1,127 @@
import { parseBinArray } from './FeatureTable.js';
export class BatchTableHierarchyExtension {
constructor( batchTable ) {
this.batchTable = batchTable;
const extensionHeader = batchTable.header.extensions[ '3DTILES_batch_table_hierarchy' ];
this.classes = extensionHeader.classes;
for ( const classDef of this.classes ) {
const instances = classDef.instances;
for ( const property in instances ) {
classDef.instances[ property ] = this._parseProperty( instances[ property ], classDef.length, property );
}
}
this.instancesLength = extensionHeader.instancesLength;
this.classIds = this._parseProperty( extensionHeader.classIds, this.instancesLength, 'classIds' );
if ( extensionHeader.parentCounts ) {
this.parentCounts = this._parseProperty( extensionHeader.parentCounts, this.instancesLength, 'parentCounts' );
} else {
this.parentCounts = new Array( this.instancesLength ).fill( 1 );
}
if ( extensionHeader.parentIds ) {
const parentIdsLength = this.parentCounts.reduce( ( a, b ) => a + b, 0 );
this.parentIds = this._parseProperty( extensionHeader.parentIds, parentIdsLength, 'parentIds' );
} else {
this.parentIds = null;
}
this.instancesIds = [];
const classCounter = {};
for ( const classId of this.classIds ) {
classCounter[ classId ] = classCounter[ classId ] ?? 0;
this.instancesIds.push( classCounter[ classId ] );
classCounter[ classId ] ++;
}
}
_parseProperty( property, propertyLength, propertyName ) {
if ( Array.isArray( property ) ) {
return property;
} else {
const { buffer, binOffset } = this.batchTable;
const byteOffset = property.byteOffset;
const componentType = property.componentType || 'UNSIGNED_SHORT';
const arrayStart = binOffset + byteOffset;
return parseBinArray( buffer, arrayStart, propertyLength, 'SCALAR', componentType, propertyName );
}
}
getDataFromId( id, target = {} ) {
// Get properties inherited from parents
const parentCount = this.parentCounts[ id ];
if ( this.parentIds && parentCount > 0 ) {
let parentIdsOffset = 0;
for ( let i = 0; i < id; i ++ ) {
parentIdsOffset += this.parentCounts[ i ];
}
for ( let i = 0; i < parentCount; i ++ ) {
const parentId = this.parentIds[ parentIdsOffset + i ];
if ( parentId !== id ) {
this.getDataFromId( parentId, target );
}
}
}
// Get properties proper to this instance
const classId = this.classIds[ id ];
const instances = this.classes[ classId ].instances;
const className = this.classes[ classId ].name;
const instanceId = this.instancesIds[ id ];
for ( const key in instances ) {
target[ className ] = target[ className ] || {};
target[ className ][ key ] = instances[ key ][ instanceId ];
}
return target;
}
}
@@ -0,0 +1,30 @@
interface FeatureTableHeader {
extensions?: object;
extras?: any;
}
export class FeatureTable {
header: FeatureTableHeader;
constructor(
buffer : ArrayBuffer,
start : number,
headerLength : number,
binLength : number
);
getKeys() : Array< string >;
getData(
key : string,
count : number,
defaultComponentType? : string | null,
defaultType? : string | null
) : number | string | ArrayBufferView;
getBuffer( byteOffset : number, byteLength : number ) : ArrayBuffer;
}
@@ -0,0 +1,159 @@
import { arrayToString } from './LoaderUtils.js';
export function parseBinArray( buffer, arrayStart, count, type, componentType, propertyName ) {
let stride;
switch ( type ) {
case 'SCALAR':
stride = 1;
break;
case 'VEC2':
stride = 2;
break;
case 'VEC3':
stride = 3;
break;
case 'VEC4':
stride = 4;
break;
default:
throw new Error( `FeatureTable : Feature type not provided for "${ propertyName }".` );
}
let data;
const arrayLength = count * stride;
switch ( componentType ) {
case 'BYTE':
data = new Int8Array( buffer, arrayStart, arrayLength );
break;
case 'UNSIGNED_BYTE':
data = new Uint8Array( buffer, arrayStart, arrayLength );
break;
case 'SHORT':
data = new Int16Array( buffer, arrayStart, arrayLength );
break;
case 'UNSIGNED_SHORT':
data = new Uint16Array( buffer, arrayStart, arrayLength );
break;
case 'INT':
data = new Int32Array( buffer, arrayStart, arrayLength );
break;
case 'UNSIGNED_INT':
data = new Uint32Array( buffer, arrayStart, arrayLength );
break;
case 'FLOAT':
data = new Float32Array( buffer, arrayStart, arrayLength );
break;
case 'DOUBLE':
data = new Float64Array( buffer, arrayStart, arrayLength );
break;
default:
throw new Error( `FeatureTable : Feature component type not provided for "${ propertyName }".` );
}
return data;
}
export class FeatureTable {
constructor( buffer, start, headerLength, binLength ) {
this.buffer = buffer;
this.binOffset = start + headerLength;
this.binLength = binLength;
let header = null;
if ( headerLength !== 0 ) {
const headerData = new Uint8Array( buffer, start, headerLength );
header = JSON.parse( arrayToString( headerData ) );
} else {
header = {};
}
this.header = header;
}
getKeys() {
return Object.keys( this.header ).filter( key => key !== 'extensions' );
}
getData( key, count, defaultComponentType = null, defaultType = null ) {
const header = this.header;
if ( ! ( key in header ) ) {
return null;
}
const feature = header[ key ];
if ( ! ( feature instanceof Object ) ) {
return feature;
} else if ( Array.isArray( feature ) ) {
return feature;
} else {
const { buffer, binOffset, binLength } = this;
const byteOffset = feature.byteOffset || 0;
const featureType = feature.type || defaultType;
const featureComponentType = feature.componentType || defaultComponentType;
if ( 'type' in feature && defaultType && feature.type !== defaultType ) {
throw new Error( 'FeatureTable: Specified type does not match expected type.' );
}
const arrayStart = binOffset + byteOffset;
const data = parseBinArray( buffer, arrayStart, count, featureType, featureComponentType, key );
const dataEnd = arrayStart + data.byteLength;
if ( dataEnd > binOffset + binLength ) {
throw new Error( 'FeatureTable: Feature data read outside binary body length.' );
}
return data;
}
}
getBuffer( byteOffset, byteLength ) {
const { buffer, binOffset } = this;
return buffer.slice( binOffset + byteOffset, binOffset + byteOffset + byteLength );
}
}
@@ -0,0 +1,12 @@
export class LRUCache {
minSize: number;
maxSize: number;
minBytesSize: number;
maxBytesSize: number;
unloadPercent: number;
autoMarkUnused: boolean;
unloadPriorityCallback: ( item: any ) => number;
}
@@ -0,0 +1,369 @@
const GIGABYTE_BYTES = 2 ** 30;
class LRUCache {
get unloadPriorityCallback() {
return this._unloadPriorityCallback;
}
set unloadPriorityCallback( cb ) {
if ( cb.length === 1 ) {
console.warn( 'LRUCache: "unloadPriorityCallback" function has been changed to take two arguments.' );
this._unloadPriorityCallback = ( a, b ) => {
const valA = cb( a );
const valB = cb( b );
if ( valA < valB ) return - 1;
if ( valA > valB ) return 1;
return 0;
};
} else {
this._unloadPriorityCallback = cb;
}
}
constructor() {
// options
this.minSize = 6000;
this.maxSize = 8000;
this.minBytesSize = 0.3 * GIGABYTE_BYTES;
this.maxBytesSize = 0.4 * GIGABYTE_BYTES;
this.unloadPercent = 0.05;
this.autoMarkUnused = true;
// "itemSet" doubles as both the list of the full set of items currently
// stored in the cache (keys) as well as a map to the time the item was last
// used so it can be sorted appropriately.
this.itemSet = new Map();
this.itemList = [];
this.usedSet = new Set();
this.callbacks = new Map();
this.unloadingHandle = - 1;
this.cachedBytes = 0;
this.bytesMap = new Map();
this.loadedSet = new Set();
this._unloadPriorityCallback = null;
const itemSet = this.itemSet;
this.defaultPriorityCallback = item => itemSet.get( item );
}
// Returns whether or not the cache has reached the maximum size
isFull() {
return this.itemSet.size >= this.maxSize || this.cachedBytes >= this.maxBytesSize;
}
getMemoryUsage( item ) {
return this.bytesMap.get( item ) || 0;
}
setMemoryUsage( item, bytes ) {
const { bytesMap, itemSet } = this;
if ( ! itemSet.has( item ) ) {
return;
}
this.cachedBytes -= bytesMap.get( item ) || 0;
bytesMap.set( item, bytes );
this.cachedBytes += bytes;
}
add( item, removeCb ) {
const itemSet = this.itemSet;
if ( itemSet.has( item ) ) {
return false;
}
if ( this.isFull() ) {
return false;
}
const usedSet = this.usedSet;
const itemList = this.itemList;
const callbacks = this.callbacks;
itemList.push( item );
usedSet.add( item );
itemSet.set( item, Date.now() );
callbacks.set( item, removeCb );
return true;
}
has( item ) {
return this.itemSet.has( item );
}
remove( item ) {
const usedSet = this.usedSet;
const itemSet = this.itemSet;
const itemList = this.itemList;
const bytesMap = this.bytesMap;
const callbacks = this.callbacks;
const loadedSet = this.loadedSet;
if ( itemSet.has( item ) ) {
this.cachedBytes -= bytesMap.get( item ) || 0;
bytesMap.delete( item );
callbacks.get( item )( item );
const index = itemList.indexOf( item );
itemList.splice( index, 1 );
usedSet.delete( item );
itemSet.delete( item );
callbacks.delete( item );
loadedSet.delete( item );
return true;
}
return false;
}
// Marks whether tiles in the cache have been completely loaded or not. Tiles that have not been completely
// loaded are subject to being disposed early if the cache is full above its max size limits, even if they
// are marked as used.
setLoaded( item, value ) {
const { itemSet, loadedSet } = this;
if ( itemSet.has( item ) ) {
if ( value === true ) {
loadedSet.add( item );
} else {
loadedSet.delete( item );
}
}
}
markUsed( item ) {
const itemSet = this.itemSet;
const usedSet = this.usedSet;
if ( itemSet.has( item ) && ! usedSet.has( item ) ) {
itemSet.set( item, Date.now() );
usedSet.add( item );
}
}
markUnused( item ) {
this.usedSet.delete( item );
}
markAllUnused() {
this.usedSet.clear();
}
// TODO: this should be renamed because it's not necessarily unloading all unused content
// Maybe call it "cleanup" or "unloadToMinSize"
unloadUnusedContent() {
const {
unloadPercent,
minSize,
maxSize,
itemList,
itemSet,
usedSet,
loadedSet,
callbacks,
bytesMap,
minBytesSize,
maxBytesSize,
} = this;
const unused = itemList.length - usedSet.size;
const unloaded = itemList.length - loadedSet.size;
const excessNodes = Math.max( Math.min( itemList.length - minSize, unused ), 0 );
const excessBytes = this.cachedBytes - minBytesSize;
const unloadPriorityCallback = this.unloadPriorityCallback || this.defaultPriorityCallback;
let needsRerun = false;
const hasNodesToUnload = excessNodes > 0 && unused > 0 || unloaded && itemList.length > maxSize;
const hasBytesToUnload = unused && this.cachedBytes > minBytesSize || unloaded && this.cachedBytes > maxBytesSize;
if ( hasBytesToUnload || hasNodesToUnload ) {
// used items should be at the end of the array, "unloaded" items in the middle of the array
itemList.sort( ( a, b ) => {
const usedA = usedSet.has( a );
const usedB = usedSet.has( b );
if ( usedA === usedB ) {
const loadedA = loadedSet.has( a );
const loadedB = loadedSet.has( b );
if ( loadedA === loadedB ) {
// Use the sort function otherwise
// higher priority should be further to the left
return - unloadPriorityCallback( a, b );
} else {
return loadedA ? 1 : - 1;
}
} else {
// If one is used and the other is not move the used one towards the end of the array
return usedA ? 1 : - 1;
}
} );
// address corner cases where the minSize might be zero or smaller than maxSize - minSize,
// which would result in a very small or no items being unloaded.
const maxUnload = Math.max( minSize * unloadPercent, excessNodes * unloadPercent );
const nodesToUnload = Math.ceil( Math.min( maxUnload, unused, excessNodes ) );
const maxBytesUnload = Math.max( unloadPercent * excessBytes, unloadPercent * minBytesSize );
const bytesToUnload = Math.min( maxBytesUnload, excessBytes );
let removedNodes = 0;
let removedBytes = 0;
// evict up to the max node or bytes size, keeping one more item over the max bytes limit
// so the "full" function behaves correctly.
while (
this.cachedBytes - removedBytes > maxBytesSize ||
itemList.length - removedNodes > maxSize
) {
const item = itemList[ removedNodes ];
const bytes = bytesMap.get( item ) || 0;
if (
usedSet.has( item ) && loadedSet.has( item ) ||
this.cachedBytes - removedBytes - bytes < maxBytesSize &&
itemList.length - removedNodes <= maxSize
) {
break;
}
removedBytes += bytes;
removedNodes ++;
}
// evict up to the min node or bytes size, keeping one more item over the min bytes limit
// so we're meeting it
while (
removedBytes < bytesToUnload ||
removedNodes < nodesToUnload
) {
const item = itemList[ removedNodes ];
const bytes = bytesMap.get( item ) || 0;
if (
usedSet.has( item ) ||
this.cachedBytes - removedBytes - bytes < minBytesSize &&
removedNodes >= nodesToUnload
) {
break;
}
removedBytes += bytes;
removedNodes ++;
}
// remove the nodes
itemList.splice( 0, removedNodes ).forEach( item => {
this.cachedBytes -= bytesMap.get( item ) || 0;
callbacks.get( item )( item );
bytesMap.delete( item );
itemSet.delete( item );
callbacks.delete( item );
loadedSet.delete( item );
usedSet.delete( item );
} );
// if we didn't remove enough nodes or we still have excess bytes and there are nodes to removed
// then we want to fire another round of unloading
needsRerun = removedNodes < excessNodes || removedBytes < excessBytes && removedNodes < unused;
needsRerun = needsRerun && removedNodes > 0;
}
if ( needsRerun ) {
this.unloadingHandle = requestAnimationFrame( () => this.scheduleUnload() );
}
}
scheduleUnload() {
cancelAnimationFrame( this.unloadingHandle );
if ( ! this.scheduled ) {
this.scheduled = true;
queueMicrotask( () => {
this.scheduled = false;
this.unloadUnusedContent();
} );
}
}
}
export { LRUCache };
@@ -0,0 +1,49 @@
export function readMagicBytes( bufferOrDataView ) {
if ( bufferOrDataView === null || bufferOrDataView.byteLength < 4 ) {
return '';
}
let view;
if ( bufferOrDataView instanceof DataView ) {
view = bufferOrDataView;
} else {
view = new DataView( bufferOrDataView );
}
if ( String.fromCharCode( view.getUint8( 0 ) ) === '{' ) {
return null;
}
let magicBytes = '';
for ( let i = 0; i < 4; i ++ ) {
magicBytes += String.fromCharCode( view.getUint8( i ) );
}
return magicBytes;
}
const utf8decoder = new TextDecoder();
export function arrayToString( array ) {
return utf8decoder.decode( array );
}
// Returns a working path with a trailing slash
export function getWorkingPath( url ) {
return url.replace( /[\\/][^\\/]+$/, '' ) + '/';
}
@@ -0,0 +1,17 @@
export class PriorityQueue {
maxJobs : number;
autoUpdate : boolean;
priorityCallback : ( itemA : any, itemB : any ) => number;
schedulingCallback : ( func : Function ) => void;
sort() : void;
add( item : any, callback : ( item : any ) => any ) : Promise< any >;
remove( item : any ) : void;
removeByFilter( filter : ( item : any ) => boolean ) : void;
tryRunJobs() : void;
scheduleJobRun() : void;
}
@@ -0,0 +1,200 @@
class PriorityQueue {
// returns whether tasks are queued or actively running
get running() {
return this.items.length !== 0 || this.currJobs !== 0;
}
constructor() {
// options
this.maxJobs = 6;
this.items = [];
this.callbacks = new Map();
this.currJobs = 0;
this.scheduled = false;
this.autoUpdate = true;
this.priorityCallback = null;
// Customizable scheduling callback. Default using requestAnimationFrame()
this.schedulingCallback = func => {
requestAnimationFrame( func );
};
this._runjobs = () => {
this.scheduled = false;
this.tryRunJobs();
};
}
sort() {
const priorityCallback = this.priorityCallback;
const items = this.items;
if ( priorityCallback !== null ) {
items.sort( priorityCallback );
}
}
has( item ) {
return this.callbacks.has( item );
}
add( item, callback ) {
const data = {
callback,
reject: null,
resolve: null,
promise: null,
};
data.promise = new Promise( ( resolve, reject ) => {
const items = this.items;
const callbacks = this.callbacks;
data.resolve = resolve;
data.reject = reject;
items.unshift( item );
callbacks.set( item, data );
if ( this.autoUpdate ) {
this.scheduleJobRun();
}
} );
return data.promise;
}
remove( item ) {
const items = this.items;
const callbacks = this.callbacks;
const index = items.indexOf( item );
if ( index !== - 1 ) {
// reject the promise to ensure there are no dangling promises - add a
// catch here to handle the case where the promise was never used anywhere
// else.
const info = callbacks.get( item );
info.promise.catch( () => {} );
info.reject( new Error( 'PriorityQueue: Item removed.' ) );
items.splice( index, 1 );
callbacks.delete( item );
}
}
removeByFilter( filter ) {
const { items } = this;
for ( let i = 0; i < items.length; i ++ ) {
const item = items[ i ];
if ( filter( item ) ) {
this.remove( item );
}
}
}
tryRunJobs() {
this.sort();
const items = this.items;
const callbacks = this.callbacks;
const maxJobs = this.maxJobs;
let iterated = 0;
const completedCallback = () => {
this.currJobs --;
if ( this.autoUpdate ) {
this.scheduleJobRun();
}
};
while ( maxJobs > this.currJobs && items.length > 0 && iterated < maxJobs ) {
this.currJobs ++;
iterated ++;
const item = items.pop();
const { callback, resolve, reject } = callbacks.get( item );
callbacks.delete( item );
let result;
try {
result = callback( item );
} catch ( err ) {
reject( err );
completedCallback();
}
if ( result instanceof Promise ) {
result
.then( resolve )
.catch( reject )
.finally( completedCallback );
} else {
resolve( result );
completedCallback();
}
}
}
scheduleJobRun() {
if ( ! this.scheduled ) {
this.schedulingCallback( this._runjobs );
this.scheduled = true;
}
}
}
export { PriorityQueue };
@@ -0,0 +1,78 @@
// Helper function for traversing a tile set. If `beforeCb` returns `true` then the
// traversal will end early.
export function traverseSet( tile, beforeCb = null, afterCb = null ) {
const stack = [];
// A stack-based, depth-first traversal, storing
// triplets (tile, parent, depth) in the stack array.
stack.push( tile );
stack.push( null );
stack.push( 0 );
while ( stack.length > 0 ) {
const depth = stack.pop();
const parent = stack.pop();
const tile = stack.pop();
if ( beforeCb && beforeCb( tile, parent, depth ) ) {
if ( afterCb ) {
afterCb( tile, parent, depth );
}
return;
}
const children = tile.children;
// Children might be undefined if the tile has not been preprocessed yet
if ( children ) {
for ( let i = children.length - 1; i >= 0; i -- ) {
stack.push( children[ i ] );
stack.push( tile );
stack.push( depth + 1 );
}
}
if ( afterCb ) {
afterCb( tile, parent, depth );
}
}
}
// Traverses the ancestry of the tile up to the root tile.
export function traverseAncestors( tile, callback = null ) {
let current = tile;
while ( current ) {
const depth = current.__depth;
const parent = current.parent;
if ( callback ) {
callback( current, parent, depth );
}
current = parent;
}
}
@@ -0,0 +1,21 @@
// function that rate limits the amount of time a function can be called to once
// per frame, initially queuing a new call for the next frame.
export function throttle( callback ) {
let handle = null;
return () => {
if ( handle === null ) {
handle = requestAnimationFrame( () => {
handle = null;
callback();
} );
}
};
}
@@ -0,0 +1,43 @@
/**
* Returns the file extension of the path component of a URL
* @param {string} url
* @returns {string} null if no extension found
*/
export function getUrlExtension( url ) {
if ( ! url ) {
return null;
}
// Find the last occurrence of '?' and '#' to handle query params and fragments
let endIndex = url.length;
const queryIndex = url.indexOf( '?' );
const fragmentIndex = url.indexOf( '#' );
if ( queryIndex !== - 1 ) {
endIndex = Math.min( endIndex, queryIndex );
}
if ( fragmentIndex !== - 1 ) {
endIndex = Math.min( endIndex, fragmentIndex );
}
// Check if the string is just a hostname or whether the path does not end in an extension
const lastPeriodIndex = url.lastIndexOf( '.', endIndex );
const lastSlashIndex = url.lastIndexOf( '/', endIndex );
const protocolIndex = url.indexOf( '://' );
const isHostOnly = protocolIndex !== - 1 && protocolIndex + 2 === lastSlashIndex;
if ( isHostOnly || lastPeriodIndex === - 1 || lastPeriodIndex < lastSlashIndex ) {
return null;
}
return url.substring( lastPeriodIndex + 1, endIndex ) || null;
}
@@ -0,0 +1,2 @@
export * from '3d-tiles-renderer/core';
export * from '3d-tiles-renderer/three';
@@ -0,0 +1,2 @@
export * from '3d-tiles-renderer/core';
export * from '3d-tiles-renderer/three';
@@ -0,0 +1,2 @@
export * from '3d-tiles-renderer/core/plugins';
export * from '3d-tiles-renderer/three/plugins';
@@ -0,0 +1,2 @@
export * from '3d-tiles-renderer/core/plugins';
export * from '3d-tiles-renderer/three/plugins';
@@ -0,0 +1,250 @@
# 3D Tiles React Components
Set of components for loading and rendering 3D Tiles in [@react-three/fiber](https://r3f.docs.pmnd.rs/).
**Examples**
[Basic example](https://nasa-ammos.github.io/3DTilesRendererJS/example/bundle/r3f/basic.html)
[Cesium Ion example](https://nasa-ammos.github.io/3DTilesRendererJS/example/bundle/r3f/ion.html)
[Google Photorealistic Tiles example](https://nasa-ammos.github.io/3DTilesRendererJS/example/bundle/r3f/globe.html)
# Use
## Simple
```jsx
import { TilesRenderer } from '3d-tiles-renderer/r3f';
const TILESET_URL = /* your tile set url */;
const cameraPosition = [ x, y, z ]; // Set the camera position so the tiles are visible
export default function App() {
return (
<Canvas camera={ { position: cameraPosition } }>
<TilesRenderer url={ TILESET_URL } />
</Canvas>
);
}
```
## With Plugins, Controls, & Attribution
Basic set up for Google Photorealistic tiles, Globe controls, and an overlay for displaying data set attributions.
```jsx
import { TilesRenderer, TilesPlugin, GlobeControls, TilesAttributionOverlay } from '3d-tiles-renderer/r3f';
import { DebugTilesPlugin, GoogleCloudAuthPlugin } from '3d-tiles-renderer/plugins';
export default function App() {
return (
<Canvas camera={ { position: [ 0, 0, 1e8 ] } }>
<TilesRenderer>
<TilesPlugin plugin={ DebugTilesPlugin } displayBoxBounds={ true } />
<TilesPlugin plugin={ GoogleCloudAuthPlugin } args={ { apiToken: /* your api token here */ } } />
<GlobeControls />
<TilesAttributionOverlay />
</TilesRenderer>
</Canvas>
);
}
```
## Cesium Ion & Google Cloud
Simplified wrappers for using the TilesRenderer with Cesium Ion and Google Cloud for Photorealistic Tiles. Use the `TilesAttributionOverlay` to display appropriate credits for the data sets.
```jsx
import { TilesRenderer, TilesPlugin } from '3d-tiles-renderer/r3f';
import { CesiumIonAuthPlugin, GoogleCloudAuthPlugin } from '3d-tiles-renderer/plugins';
function GoogleTilesRenderer( { children, apiToken, ...rest } ) {
return (
<TilesRenderer { ...rest } key={ apiToken }>
<TilesPlugin plugin={ GoogleCloudAuthPlugin } args={ { apiToken } } />
{ children }
</TilesRenderer>
);
}
function CesiumIonTilesRenderer( { children, apiToken, assetId, ...rest } ) {
return (
<TilesRenderer { ...rest } key={ apiToken + assetId }>
<TilesPlugin plugin={ CesiumIonAuthPlugin } args={ { apiToken, assetId } } />
{ children }
</TilesRenderer>
);
}
```
# Components
## TilesRenderer
Wrapper for the three.js `TilesRenderer` class. Listening for events are specified with a camel-case property prefixed with `on`, such as `onLoadModel`, and all other properties are specified as individual properties with dashes being used to indicate nested properties. For example, `lruCache-minSize` is used to set `lruCache.minSize`.
```jsx
<TilesRenderer
url={ tilesetUrl }
// if false then "update" is not called
enabled={ true }
// pass properties to apply to the tile set root object
group={ {
position: [ 0, 10, 0 ],
rotation: [ Math.PI / 2, 0, 0 ],
} }
// set options to the TilesRenderer object
errorTarget={ 6 }
errorThreshold={ 10 }
// set nested object options of the TilesRenderer
parseQueue-maxJobs={ 30 }
downloadQueue-maxJobs={ 10 }
lruCache-minBytesSize={ 0.25 * 1e6 }
lruCache-maxBytesSize={ 0.5 * 1e6 }
// event registration
onLoadTileSet={ onLoadTileSetCallback }
onLoadModel={ onLoadModelCallback }
/>
```
## TilesPlugin
Plugins can be set as children of the TilesRenderer component to add additional functionality. TilePlugin components must be nested inside a TilesRenderer component. Constructor arguments are passed via the `args` parameter while local members can be passed via the regular properties. But note that depending on the plugin some properties cannot be changed after construction and initialization.
See the [PLUGINS documentation](https://github.com/NASA-AMMOS/3DTilesRendererJS/blob/master/PLUGINS.md) for docs on all avilable plugins.
```jsx
<TilesRenderer url={ tilesetUrl }>
<TilesPlugin
plugin={ PluginClassName }
args={ /* constructor arguments as array or object */ }
{ ...pluginProps }
/>
</TilesRenderer>
```
And a practical example of creating and using a plugin:
```jsx
<TilesRenderer url={ tilesetUrl } >
<TilesPlugin plugin={ GLTFExtensionsPlugin }
dracoLoader={ dracoLoader }
ktxLoader={ ktx2Loader }
autoDispose={ false }
{ /*
// alternatively the options can be passed via constructor arguments
// or a mix of both can be used.
args = { {
dracoLoader,
ktxLoader,
autoDispose: false,
} }
*/ }
/>
</TilesRenderer>
```
## Controls
These `EnvironmentControls` and `GlobeControls` classes have been wrapped as components to handle user-interaction. They will both be set to the `controls` react three fiber state field when in use. All properties on the original classes can be passed as properties:
```jsx
<>
<TilesRenderer url={ url } { ...props } />
<EnvironmentControls enableDamping={ true } enabled={ true } />
</>
```
The `GlobeControls` component must be set as a child of the `TilesRenderer` component that is providing the ellipsoid to orbit around.
```jsx
<TilesRenderer url={ url } { ...props }>
<GlobeControls enableDamping={ true } />
</TilesRenderer>
```
## EastNorthUpFrame
The `EastNorthUpFrame` creates a root object that is centered on the provided point relative to the tile sets ellipsoid, specified via lat/lon/height and euler angle props and is used to place 3D objects relative to that point. It does not rotate the original tile set and must be a child of a `TilesRenderer` component.
It can be used to place markers on the surface of the ellipsoid, such as a cone for pointing to a location:
```jsx
<TilesRenderer url={ url } { ...props }>
{ /* ... */ }
<EastNorthUpFrame
{/* The latitude and longitude to place the frame at in radians */}
lat={ lat }
lon={ lon }
{/* The height above the ellipsoid to place the frame at in meters */}
height={ 100 }
{/*
The azimuth, elevation, and roll around the "north" axis, applied
in that order intrinsicly, in radians
*/}
az={ 0 }
el={ 0 }
roll={ 0 }
>
{/* Children are position relative to the east, north, up frame */}
<mesh rotation-x={ - Math.PI / 2 } scale={ 100 } position-z={ 50 }>
<coneGeometry args={ [ 0.5 ] } />
<meshStandardMaterial color={ 'red' } />
</mesh>
</EastNorthUpFrame>
</TilesRenderer>
```
## TilesAttributionOverlay
The `TilesAttributionOverlay` component must be embedded in a tile set and will automatically display the credits associated with the loaded data set.
```jsx
<TilesRenderer url={ url } { ...props }>
<TilesAttributionOverlay
{ /*
Callback function for generating attribution elements from credit info.
Takes the list of attributions and a unique "id" assigned to the overlay dom element.
*/ }
generateAttributions={ null }
{ /* remaining properties are assigned to the root overlay element */ }
/>
</TilesRenderer>
```
## CompassGizmo
Adds a compass to the bottom right of the page that orients to "north" based on the camera position and orientation. Must be nested in a `TilesRenderer` component.
Any children passed into the class will replace the default red and white compass design with +Y pointing north and +X pointing east. The graphic children should fit within a volume from - 0.5 to 0.5 along all axes.
```jsx
<CompassGizmo
{/* Specifies whether the compass will render in '2d' or '3d' */}
mode={ '3d' }
{/* The size of the compass in pixels */}
scale={ 35 }
{/* The number pixels in margin to add relative to the bottom right of the screen */}
margin={ 10 }
{/* Whether to render the main scene */}
overrideRenderLoop={ true }
{/* Whether the gizmo is visible and rendering */}
visible={ true }
{/* Any remaining props including click events are passed through to the parent group */}
onClick={ () => console.log( 'compass clicked!' ) }
/>
```
@@ -0,0 +1,27 @@
import type { ForwardRefExoticComponent, RefAttributes } from 'react';
import type { Camera, Object3D } from 'three';
import type { EnvironmentControls as EnvironmentControlsImpl, GlobeControls as GlobeControlsImpl } from '3d-tiles-renderer/three';
import type { TilesRenderer } from './TilesRenderer.jsx';
interface ControlsBaseProps {
domElement?: HTMLCanvasElement | null;
scene?: Object3D | null;
camera?: Camera | null;
tilesRenderer?: typeof TilesRenderer | null;
}
type EnvironmentControlsProps = Partial<
InstanceType<typeof EnvironmentControlsImpl>
> &
ControlsBaseProps;
type GlobeControlsProps = Partial<InstanceType<typeof GlobeControlsImpl>> &
ControlsBaseProps;
export declare const EnvironmentControls: ForwardRefExoticComponent<
EnvironmentControlsProps & RefAttributes<EnvironmentControlsImpl>
>;
export declare const GlobeControls: ForwardRefExoticComponent<
GlobeControlsProps & RefAttributes<GlobeControlsImpl>
>;
@@ -0,0 +1,122 @@
import { forwardRef, useMemo, useEffect, useContext } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import { EnvironmentControls as EnvironmentControlsImpl, GlobeControls as GlobeControlsImpl } from '3d-tiles-renderer/three';
import { useShallowOptions } from '../utilities/useOptions.js';
import { TilesRendererContext } from './TilesRenderer.jsx';
import { useApplyRefs } from '../utilities/useApplyRefs.js';
// Add a base component implementation for both EnvironmentControls and GlobeControls
const ControlsBaseComponent = forwardRef( function ControlsBaseComponent( props, ref ) {
const { controlsConstructor, domElement, scene, camera, ellipsoid, ellipsoidFrame, tilesRenderer, ...rest } = props;
const [ defaultCamera ] = useThree( state => [ state.camera ] );
const [ gl ] = useThree( state => [ state.gl ] );
const [ defaultScene ] = useThree( state => [ state.scene ] );
const [ invalidate ] = useThree( state => [ state.invalidate ] );
const [ get ] = useThree( state => [ state.get ] );
const [ set ] = useThree( state => [ state.set ] );
const contextTilesRenderer = useContext( TilesRendererContext );
const appliedTilesRenderer = tilesRenderer || contextTilesRenderer;
const appliedCamera = camera || defaultCamera || null;
const appliedScene = scene || defaultScene || null;
const appliedDomElement = domElement || gl.domElement || null;
const appliedEllipsoid = ellipsoid || appliedTilesRenderer?.ellipsoid || null;
const appliedEllipsoidFrame = ellipsoidFrame || appliedTilesRenderer?.group || null;
// create a controls instance
const controls = useMemo( () => {
return new controlsConstructor();
}, [ controlsConstructor ] );
// assign / call the reference
useApplyRefs( controls, ref );
// fire invalidate callbacks
useEffect( () => {
const callback = () => invalidate();
controls.addEventListener( 'change', callback );
controls.addEventListener( 'start', callback );
controls.addEventListener( 'end', callback );
return () => {
controls.removeEventListener( 'change', callback );
controls.removeEventListener( 'start', callback );
controls.removeEventListener( 'end', callback );
};
}, [ controls, invalidate ] );
// assign the camera
useEffect( () => {
controls.setCamera( appliedCamera );
}, [ controls, appliedCamera ] );
// assign the scene
useEffect( () => {
controls.setScene( appliedScene );
}, [ controls, appliedScene ] );
// assign the tiles renderer
useEffect( () => {
if ( controls.isGlobeControls ) {
controls.setEllipsoid( appliedEllipsoid, appliedEllipsoidFrame );
}
}, [ controls, appliedEllipsoid, appliedEllipsoidFrame ] );
// attach to the dom element
useEffect( () => {
controls.attach( appliedDomElement );
return () => {
controls.detach();
};
}, [ controls, appliedDomElement ] );
// set the controls for global use
useEffect( () => {
const old = get().controls;
set( { controls } );
return () => set( { controls: old } );
}, [ controls, get, set ] );
// update the controls with a priority of - 1 so it happens before tiles renderer update
useFrame( () => {
controls.update();
}, - 1 );
useShallowOptions( controls, rest );
} );
export const EnvironmentControls = forwardRef( function EnvironmentControls( props, ref ) {
return <ControlsBaseComponent { ...props } ref={ ref } controlsConstructor={ EnvironmentControlsImpl } />;
} );
export const GlobeControls = forwardRef( function GlobeControls( props, ref ) {
return <ControlsBaseComponent { ...props } ref={ ref } controlsConstructor={ GlobeControlsImpl } />;
} );
@@ -0,0 +1,13 @@
import { Camera } from 'three';
import { CameraTransitionManager } from '3d-tiles-renderer/three';
interface CameraTransitionProps {
mode?: 'perspective' | 'orthographic';
onBeforeToggle?: ( manager: CameraTransitionManager, targetCamera: Camera ) => void;
perspectiveCamera?: Camera;
orthographicCamera?: Camera;
}
export declare const CameraTransition: React.ForwardRefExoticComponent<
CameraTransitionProps & React.RefAttributes<CameraTransitionManager>
>;
@@ -0,0 +1,194 @@
import { forwardRef, useEffect, useMemo } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { CameraTransitionManager } from '3d-tiles-renderer/three';
import { useDeepOptions } from '../utilities/useOptions.js';
import { useApplyRefs } from '../utilities/useApplyRefs.js';
export const CameraTransition = forwardRef( function CameraTransition( props, ref ) {
const {
mode = 'perspective',
onBeforeToggle,
perspectiveCamera,
orthographicCamera,
...options
} = props;
const [ set, get, invalidate, controls, camera, size ] = useThree( state => [ state.set, state.get, state.invalidate, state.controls, state.camera, state.size ] );
// create the manager
const manager = useMemo( () => {
const manager = new CameraTransitionManager();
manager.autoSync = false;
if ( camera.isOrthographicCamera ) {
manager.orthographicCamera.copy( camera );
manager.mode = 'orthographic';
} else {
manager.perspectiveCamera.copy( camera );
}
manager.syncCameras();
manager.mode = mode;
return manager;
// only respect the camera initially so the default camera settings are automatically used
}, [] ); // eslint-disable-line react-hooks/exhaustive-deps
useEffect( () => {
const { perspectiveCamera, orthographicCamera } = manager;
const aspect = size.width / size.height;
perspectiveCamera.aspect = aspect;
perspectiveCamera.updateProjectionMatrix();
orthographicCamera.left = - orthographicCamera.top * aspect;
orthographicCamera.right = - orthographicCamera.left;
perspectiveCamera.updateProjectionMatrix();
}, [ manager, size ] );
// assign ref
useApplyRefs( manager, ref );
// set the camera
useEffect( () => {
const cameraCallback = ( { camera } ) => {
set( () => ( { camera } ) );
};
set( () => ( { camera: manager.camera } ) );
manager.addEventListener( 'camera-change', cameraCallback );
return () => {
manager.removeEventListener( 'camera-change', cameraCallback );
};
}, [ manager, set ] );
// assign cameras
useEffect( () => {
const oldPerspectiveCamera = manager.perspectiveCamera;
const oldOrthographicCamera = manager.orthographicCamera;
manager.perspectiveCamera = perspectiveCamera || oldPerspectiveCamera;
manager.orthographicCamera = orthographicCamera || oldOrthographicCamera;
set( () => ( { camera: manager.camera } ) );
return () => {
manager.perspectiveCamera = oldPerspectiveCamera;
manager.orthographicCamera = oldOrthographicCamera;
};
}, [ perspectiveCamera, orthographicCamera, manager, set ] );
// toggle
useEffect( () => {
if ( mode !== manager.mode ) {
// calculate the camera being toggled to. Because "toggle" has not yet been
// called this will select the camera that is being transitioned to.
const targetCamera = mode === 'orthographic' ? manager.orthographicCamera : manager.perspectiveCamera;
if ( onBeforeToggle ) {
onBeforeToggle( manager, targetCamera );
} else if ( controls && controls.isEnvironmentControls ) {
controls.getPivotPoint( manager.fixedPoint );
manager.syncCameras();
controls.adjustCamera( manager.perspectiveCamera );
controls.adjustCamera( manager.orthographicCamera );
} else {
manager.fixedPoint
.set( 0, 0, - 1 )
.transformDirection( manager.camera.matrixWorld )
.multiplyScalar( 50 )
.add( manager.camera.position );
manager.syncCameras();
}
manager.toggle();
invalidate();
}
}, [ mode, manager, invalidate, controls, onBeforeToggle ] );
// rerender the frame when the transition animates
useEffect( () => {
const callback = () => invalidate();
manager.addEventListener( 'transition-start', callback );
manager.addEventListener( 'change', callback );
manager.addEventListener( 'transition-end', callback );
return () => {
manager.removeEventListener( 'transition-start', callback );
manager.removeEventListener( 'change', callback );
manager.removeEventListener( 'transition-end', callback );
};
}, [ manager, invalidate ] );
useDeepOptions( manager, options );
// update animation
useFrame( () => {
manager.update();
if ( controls ) {
controls.enabled = ! manager.animating;
}
// ensure the orthographic camera size is resized correctly if the user is not
// providing their own camera.
const { camera, size } = get();
if ( ! orthographicCamera && camera === manager.orthographicCamera ) {
const aspect = size.width / size.height;
const camera = manager.orthographicCamera;
if ( aspect !== camera.right ) {
camera.bottom = - 1;
camera.top = 1;
camera.left = - aspect;
camera.right = aspect;
camera.updateProjectionMatrix();
}
}
if ( manager.animating ) {
invalidate();
}
}, - 1 );
} );
@@ -0,0 +1,5 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
export interface CanvasDOMOverlayProps extends ComponentPropsWithoutRef<'div'> {
children?: ReactNode;
}
@@ -0,0 +1,58 @@
import { useMemo, useEffect, StrictMode, forwardRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { useThree } from '@react-three/fiber';
// Utility class for overlaying dom elements on top of the canvas
export const CanvasDOMOverlay = forwardRef( function CanvasDOMOverlay( { children, ...rest }, ref ) {
// create the dom element and react root
const [ gl ] = useThree( state => [ state.gl ] );
const [ root, setRoot ] = useState( null );
const container = useMemo( () => document.createElement( 'div' ), [] );
// position the container
useEffect( () => {
container.style.pointerEvents = 'none';
container.style.position = 'absolute';
container.style.width = '100%';
container.style.height = '100%';
container.style.left = 0;
container.style.top = 0;
gl.domElement.parentNode.appendChild( container );
return () => {
container.remove();
};
}, [ container, gl.domElement.parentNode ] );
// create the react render root
useEffect( () => {
const root = createRoot( container );
setRoot( root );
return () => {
root.unmount();
};
}, [ container ] );
// render the children into the container
if ( root !== null ) {
root.render(
<StrictMode>
<div { ...rest } ref={ ref }>
{ children }
</div>
</StrictMode>
);
}
} );
@@ -0,0 +1,19 @@
import type {
ReactNode,
ForwardRefExoticComponent,
RefAttributes,
} from 'react';
import type { Group } from 'three';
interface CompassGizmoProps {
children?: ReactNode;
mode?: '3d' | '2d';
visible?: boolean;
scale?: number;
margin?: number | [number, number];
overrideRenderLoop?: boolean;
}
export declare const CompassGizmo: ForwardRefExoticComponent<
CompassGizmoProps & RefAttributes<Group>
>;
@@ -0,0 +1,268 @@
import { createPortal, useFrame, useThree } from '@react-three/fiber';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { BackSide, Matrix4, OrthographicCamera, Ray, Scene, Vector3 } from 'three';
import { TilesRendererContext } from './TilesRenderer.jsx';
// Based in part on @pmndrs/drei's Gizmo component
const _vec = /*@__PURE__*/ new Vector3();
const _axis = /*@__PURE__*/ new Vector3();
const _pos = /*@__PURE__*/ new Vector3();
const _matrix = /*@__PURE__*/ new Matrix4();
const _enuMatrix = /*@__PURE__*/ new Matrix4();
const _ray = /*@__PURE__*/ new Ray();
const _cart = {};
// Returns the "focus" point that the camera is facing based on the closest point to the ellipsoid.
// Used for determining the compass orientation.
function getCameraFocusPoint( camera, ellipsoid, tilesGroup, target ) {
// get ray in globe coordinate frame
_ray.origin.copy( camera.position );
_ray.direction.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
_ray.applyMatrix4( tilesGroup.matrixWorldInverse );
// get the closest point to the ray on the globe in the global coordinate frame
ellipsoid.closestPointToRayEstimate( _ray, _pos );
_pos.applyMatrix4( tilesGroup.matrixWorld );
// get ortho camera info
_axis.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
// ensure we move the camera exactly along the forward vector to avoid shifting
// the camera in other directions due to floating point error
const dist = _pos.sub( camera.position ).dot( _axis );
target.copy( camera.position ).addScaledVector( _axis, dist );
return target;
}
// Renders the portal with an orthographic camera
function RenderPortal( props ) {
const { defaultScene, defaultCamera, overrideRenderLoop = true, renderPriority = 1 } = props;
const camera = useMemo( () => new OrthographicCamera(), [] );
const [ set, size, gl, scene ] = useThree( state => [ state.set, state.size, state.gl, state.scene ] );
useEffect( () => {
set( { camera } );
}, [ set, camera ] );
useEffect( () => {
camera.left = - size.width / 2;
camera.right = size.width / 2;
camera.top = size.height / 2;
camera.bottom = - size.height / 2;
camera.near = 0;
camera.far = 2000;
camera.position.z = camera.far / 2;
camera.updateProjectionMatrix();
}, [ camera, size ] );
useFrame( () => {
if ( overrideRenderLoop ) {
gl.render( defaultScene, defaultCamera );
}
const currentAutoClear = gl.autoClear;
gl.autoClear = false;
gl.clearDepth();
gl.render( scene, camera );
gl.autoClear = currentAutoClear;
}, renderPriority );
}
// generates an extruded box geometry
function TriangleGeometry() {
const ref = useRef();
useEffect( () => {
const geometry = ref.current;
const position = geometry.attributes.position;
for ( let i = 0, l = position.count; i < l; i ++ ) {
_vec.fromBufferAttribute( position, i );
if ( _vec.y > 0 ) {
_vec.x = 0;
position.setXYZ( i, ..._vec );
}
}
} );
return <boxGeometry ref={ ref } />;
}
// renders a typical compass graphic with red north triangle, white south, and a tinted circular background
function CompassGraphic( { northColor = 0xEF5350, southColor = 0xFFFFFF } ) {
const [ lightTarget, setLightTarget ] = useState();
const groupRef = useRef();
useEffect( () => {
setLightTarget( groupRef.current );
}, [] );
return (
<group scale={ 0.5 } ref={ groupRef }>
{/* Lights */}
<ambientLight intensity={ 1 } />
<directionalLight position={ [ 0, 2, 3 ] } intensity={ 3 } target={ lightTarget } />
<directionalLight position={ [ 0, - 2, - 3 ] } intensity={ 3 } target={ lightTarget } />
{/* Background */}
<mesh>
<sphereGeometry />
<meshBasicMaterial color={ 0 } opacity={ 0.3 } transparent={ true } side={ BackSide } />
</mesh>
{/* Compass shape */}
<group scale={ [ 0.5, 1, 0.15 ] }>
<mesh position-y={ 0.5 }>
<TriangleGeometry />
<meshStandardMaterial color={ northColor } />
</mesh>
<mesh position-y={ - 0.5 } rotation-x={ Math.PI }>
<TriangleGeometry />
<meshStandardMaterial color={ southColor } />
</mesh>
</group>
</group>
);
}
export function CompassGizmo( { children, overrideRenderLoop, mode = '3d', margin = 10, scale = 35, visible = true, ...rest } ) {
const [ defaultCamera, defaultScene, size ] = useThree( state => [ state.camera, state.scene, state.size ] );
const tiles = useContext( TilesRendererContext );
const groupRef = useRef( null );
const scene = useMemo( () => {
return new Scene();
}, [] );
let marginX, marginY;
if ( Array.isArray( margin ) ) {
marginX = margin[ 0 ];
marginY = margin[ 1 ];
} else {
marginX = margin;
marginY = margin;
}
useFrame( () => {
if ( tiles === null || groupRef.current === null ) {
return null;
}
const { ellipsoid } = tiles;
const group = groupRef.current;
// get the ENU frame in world space
getCameraFocusPoint( defaultCamera, ellipsoid, tiles.group, _pos ).applyMatrix4( tiles.group.matrixWorldInverse );
ellipsoid.getPositionToCartographic( _pos, _cart );
ellipsoid
.getEastNorthUpFrame( _cart.lat, _cart.lon, 0, _enuMatrix )
.premultiply( tiles.group.matrixWorld );
// get the camera orientation in the local ENU frame
_enuMatrix.invert();
_matrix.copy( defaultCamera.matrixWorld ).premultiply( _enuMatrix );
if ( mode.toLowerCase() === '3d' ) {
group.quaternion.setFromRotationMatrix( _matrix ).invert();
} else {
// get the projected facing direction of the camera
_vec.set( 0, 1, 0 ).transformDirection( _matrix ).normalize();
_vec.z = 0;
_vec.normalize();
if ( _vec.length() === 0 ) {
// if we're looking exactly top-down
group.quaternion.identity();
} else {
// compute the 2d looking direction
const angle = _axis.set( 0, 1, 0 ).angleTo( _vec );
_axis.cross( _vec ).normalize();
group.quaternion.setFromAxisAngle( _axis, - angle );
}
}
} );
// default to the compass graphic
if ( ! children ) {
children = <CompassGraphic />;
}
// remove the portal rendering if not present
if ( ! visible ) {
return null;
}
return (
createPortal(
<>
<group
ref={ groupRef }
scale={ scale }
position={ [
size.width / 2 - marginX - scale / 2,
- size.height / 2 + marginY + scale / 2,
0,
] }
{ ...rest }
>{ children }</group>
<RenderPortal
defaultCamera={ defaultCamera }
defaultScene={ defaultScene }
overrideRenderLoop={ overrideRenderLoop }
renderPriority={ 10 }
/>
</>,
scene,
{ events: { priority: 10 } },
)
);
}
@@ -0,0 +1,47 @@
import { ReactNode } from 'react';
import { Vector3, Ray } from 'three';
import { Camera } from '@react-three/fiber';
interface SettledObjectProps {
component?: ReactNode;
lat?: number | null;
lon?: number | null;
rayorigin?: Vector3 | null;
raydirection?: Vector3 | null;
onQueryUpdate?: ( hit: any ) => void;
[key: string]: any;
}
interface AnimatedSettledObjectProps extends SettledObjectProps {
interpolationFactor?: number;
}
interface SettledObjectsProps {
scene?: any;
children?: ReactNode;
[key: string]: any;
}
interface QueryManagerContextType {
registerLatLonQuery( lat: number, lon: number, callback: Function ): number;
unregisterQuery( index: number ): void;
registerRayQuery( ray: Ray, callback: Function ): number;
setScene( scene: any[] ): void;
addCamera( camera: Camera ): void;
setEllipsoidFromTilesRenderer( tiles: any ): void;
dispose(): void;
}
export const QueryManagerContext: React.Context<QueryManagerContextType | null>;
export const AnimatedSettledObject: React.ForwardRefExoticComponent<
AnimatedSettledObjectProps & React.RefAttributes<AnimatedSettledObjectProps>
>;
export const SettledObject: React.ForwardRefExoticComponent<
SettledObjectProps & React.RefAttributes<SettledObjectProps>
>;
export const SettledObjects: React.ForwardRefExoticComponent<
SettledObjectsProps & React.RefAttributes<SettledObjectsProps>
>;
@@ -0,0 +1,241 @@
import { cloneElement, createContext, forwardRef, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
import { OBJECT_FRAME } from '3d-tiles-renderer/three';
import { Matrix4, Ray, Vector3 } from 'three';
import { useFrame, useThree } from '@react-three/fiber';
import { useMultipleRefs } from '../utilities/useMultipleRefs.js';
import { TilesRendererContext } from './TilesRenderer.jsx';
import { QueryManager } from '../utilities/QueryManager.js';
import { useDeepOptions } from '../utilities/useOptions.js';
import { useApplyRefs } from '../utilities/useApplyRefs.js';
const QueryManagerContext = createContext( null );
const _matrix = /* @__PURE__ */ new Matrix4();
const _ray = /* @__PURE__ */ new Ray();
export const AnimatedSettledObject = forwardRef( function AnimatedSettledObject( props, ref ) {
const {
interpolationFactor = 0.025,
onQueryUpdate = null,
...rest
} = props;
const tiles = useContext( TilesRendererContext );
const queries = useContext( QueryManagerContext );
const invalidate = useThree( ( { invalidate } ) => invalidate );
const target = useMemo( () => new Vector3(), [] );
const isInitialized = useMemo( () => ( { value: false } ), [] );
const isTargetSet = useMemo( () => ( { value: false } ), [] );
const objectRef = useRef( null );
const queryCallback = useCallback( hit => {
if ( tiles === null || hit === null || objectRef.current === null ) {
return;
}
const { lat, lon, rayorigin, raydirection } = rest;
if ( lat !== null && lon !== null ) {
target.copy( hit.point );
isTargetSet.value = true;
queries.ellipsoid.getObjectFrame( lat, lon, 0, 0, 0, 0, _matrix, OBJECT_FRAME ).premultiply( tiles.group.matrixWorld );
objectRef.current.quaternion.setFromRotationMatrix( _matrix );
invalidate();
} else if ( rayorigin !== null && raydirection !== null ) {
target.copy( hit.point );
isTargetSet.value = true;
objectRef.current.quaternion.identity();
invalidate();
}
if ( onQueryUpdate ) {
onQueryUpdate( hit );
}
}, [ invalidate, isTargetSet, queries.ellipsoid, rest, target, tiles, onQueryUpdate ] );
// interpolate the point position
useFrame( ( state, delta ) => {
if ( objectRef.current ) {
objectRef.current.visible = isInitialized.value;
}
if ( objectRef.current && isTargetSet.value ) {
// jump the point to the target if it's being set for the first time
if ( isInitialized.value === false ) {
isInitialized.value = true;
objectRef.current.position.copy( target );
} else {
// framerate independent lerp by Freya Holmer
const factor = 1 - 2 ** ( - delta / interpolationFactor );
if ( objectRef.current.position.distanceToSquared( target ) > 1e-6 ) {
objectRef.current.position.lerp(
target, interpolationFactor === 0 ? 1 : factor
);
invalidate();
} else {
objectRef.current.position.copy( target );
}
}
}
} );
return (
<SettledObject
ref={ useMultipleRefs( objectRef, ref ) }
onQueryUpdate={ queryCallback }
{ ...rest }
/>
);
} );
// Object that updates its "settled" state
export const SettledObject = forwardRef( function SettledObject( props, ref ) {
const {
component = <group />,
lat = null,
lon = null,
rayorigin = null,
raydirection = null,
onQueryUpdate = null,
...rest
} = props;
const objectRef = useRef( null );
const tiles = useContext( TilesRendererContext );
const queries = useContext( QueryManagerContext );
const invalidate = useThree( ( { invalidate } ) => invalidate );
const target = useMemo( () => new Vector3(), [] );
useEffect( () => {
const callback = hit => {
if ( onQueryUpdate ) {
onQueryUpdate( hit );
} else if ( tiles && hit !== null && objectRef.current !== null ) {
if ( lat !== null && lon !== null ) {
objectRef.current.position.copy( hit.point );
queries.ellipsoid.getObjectFrame( lat, lon, 0, 0, 0, 0, _matrix, OBJECT_FRAME ).premultiply( tiles.group.matrixWorld );
objectRef.current.quaternion.setFromRotationMatrix( _matrix );
invalidate();
} else if ( rayorigin !== null && raydirection !== null ) {
objectRef.current.position.copy( hit.point );
objectRef.current.quaternion.identity();
invalidate();
}
}
};
if ( lat !== null && lon !== null ) {
const index = queries.registerLatLonQuery( lat, lon, callback );
return () => queries.unregisterQuery( index );
} else if ( rayorigin !== null && raydirection !== null ) {
_ray.origin.copy( rayorigin );
_ray.direction.copy( raydirection );
const index = queries.registerRayQuery( _ray, callback );
return () => queries.unregisterQuery( index );
}
}, [ lat, lon, rayorigin, raydirection, queries, tiles, invalidate, target, onQueryUpdate ] );
return cloneElement( component, { ...rest, ref: useMultipleRefs( objectRef, ref ), raycast: () => false } );
} );
export const SettledObjects = forwardRef( function SettledObjects( props, ref ) {
const threeScene = useThree( ( { scene } ) => scene );
const {
scene = threeScene,
children,
...rest
} = props;
const tiles = useContext( TilesRendererContext );
const queries = useMemo( () => new QueryManager(), [] );
const camera = useThree( ( { camera } ) => camera );
useDeepOptions( queries, rest );
useEffect( () => {
return () => queries.dispose();
}, [ queries ] );
useEffect( () => {
queries.setScene( ...( Array.isArray( scene ) ? scene : [ scene ] ) );
}, [ queries, scene ] );
useEffect( () => {
queries.addCamera( camera );
}, [ queries, camera ] );
useFrame( () => {
if ( tiles ) {
queries.setEllipsoidFromTilesRenderer( tiles );
}
} );
// assign ref
useApplyRefs( queries, ref );
return (
<QueryManagerContext.Provider value={ queries }>
<group matrixAutoUpdate={ false } matrixWorldAutoUpdate={ false }>
{ children }
</group>
</QueryManagerContext.Provider>
);
} );
@@ -0,0 +1,16 @@
import type { CanvasDOMOverlayProps } from './CanvasDOMOverlay.jsx';
import type { ReactNode, ForwardRefExoticComponent, RefAttributes, CSSProperties } from 'react';
type Attribution = {
type: 'string' | 'html' | 'image';
value: any;
};
interface TilesAttributionOverlayProps extends CanvasDOMOverlayProps {
style?: CSSProperties;
generateAttributions?: ( ( attributions: Attribution[], classId: string ) => ReactNode ) | null;
}
export declare const TilesAttributionOverlay: ForwardRefExoticComponent<
TilesAttributionOverlayProps & RefAttributes<TilesAttributionOverlayProps>
>;
@@ -0,0 +1,131 @@
import { useContext, useState, useEffect, useMemo } from 'react';
import { TilesRendererContext } from './TilesRenderer.jsx';
import { CanvasDOMOverlay } from './CanvasDOMOverlay.jsx';
function randomID() {
return crypto.getRandomValues( new Uint32Array( 1 ) )[ 0 ].toString( 16 );
}
// Overlay for displaying tile data set attributions
export function TilesAttributionOverlay( { children, style, generateAttributions, ...rest } ) {
const tiles = useContext( TilesRendererContext );
const [ attributions, setAttributions ] = useState( [] );
// Add events for checking when attributions may be updated
useEffect( () => {
if ( ! tiles ) {
return;
}
let queued = false;
const callback = () => {
if ( ! queued ) {
queued = true;
queueMicrotask( () => {
setAttributions( tiles.getAttributions() );
queued = false;
} );
}
};
tiles.addEventListener( 'tile-visibility-change', callback );
tiles.addEventListener( 'load-tile-set', callback );
return () => {
tiles.removeEventListener( 'tile-visibility-change', callback );
tiles.removeEventListener( 'load-tile-set', callback );
};
}, [ tiles ] );
// Generate CSS for modifying child elements implicit to the html attributions
const classId = useMemo( () => 'class_' + randomID(), [] );
const styles = useMemo( () => `
#${ classId } a {
color: white;
}
#${ classId } img {
max-width: 125px;
display: block;
margin: 5px 0;
}
`, [ classId ] );
let output;
if ( generateAttributions ) {
output = generateAttributions( attributions, classId );
} else {
// generate elements for each type of attribution
const elements = [];
attributions.forEach( ( att, i ) => {
let element = null;
if ( att.type === 'string' ) {
element = <div key={ i }>{ att.value }</div>;
} else if ( att.type === 'html' ) {
element = <div key={ i } dangerouslySetInnerHTML={ { __html: att.value } } style={ { pointerEvents: 'all' } }/>;
} else if ( att.type === 'image' ) {
element = <div key={ i }><img src={ att.value } /></div>;
}
if ( element ) {
elements.push( element );
}
} );
output = (
<>
<style>{ styles }</style>
{ elements }
</>
);
}
return (
<CanvasDOMOverlay
id={ classId }
style={ {
position: 'absolute',
bottom: 0,
left: 0,
padding: '10px',
color: 'rgba( 255, 255, 255, 0.75 )',
fontSize: '10px',
...style,
} }
{ ...rest }
>
{ children }
{ output }
</CanvasDOMOverlay>
);
}
@@ -0,0 +1,66 @@
import type { TilesRenderer as TilesRendererImpl, TilesRendererEventMap } from '3d-tiles-renderer/three';
import type {
ReactNode,
Context,
ForwardRefExoticComponent,
RefAttributes,
JSX,
} from 'react';
export declare const TilesRendererContext: Context<TilesRendererImpl | null>;
interface EastNorthUpFrameProps {
lat?: number;
lon?: number;
height?: number;
az?: number;
el?: number;
roll?: number;
children?: ReactNode;
}
export declare const EastNorthUpFrame: ForwardRefExoticComponent<
EastNorthUpFrameProps & RefAttributes<any>
>;
interface TilesPluginProps<
Plugin extends new ( ...args: any[] ) => void,
Params extends any[] = ConstructorParameters<Plugin>,
> {
plugin: Plugin;
args?: Params;
[key: string]: Params | Plugin | any;
}
export declare const TilesPlugin: <
Plugin extends new ( ...args: any[] ) => void,
Params extends any[] = ConstructorParameters<Plugin>,
>(
props: TilesPluginProps<Plugin, Params> & RefAttributes<Plugin>,
) => JSX.Element;
// dynamically mapping keys of TilesRendererEventMap to onCamelCased
type CamelCase<S extends string> = S extends `${infer T}-${infer U}`
? `${T}${Capitalize<CamelCase<U>>}`
: S;
type EventHandler<K extends keyof TilesRendererEventMap> = (
event: TilesRendererEventMap[K],
) => void;
type TilesRendererEventMapForR3f = {
[K in keyof TilesRendererEventMap as `on${Capitalize<CamelCase<K>>}`]?: EventHandler<K>;
};
interface TilesRendererProps
extends Partial<TilesRendererImpl>,
TilesRendererEventMapForR3f {
url?: string;
enabled?: boolean;
dispose?: () => void;
children?: ReactNode;
}
export declare const TilesRenderer: ForwardRefExoticComponent<
TilesRendererProps & RefAttributes<TilesRendererImpl>
>;
@@ -0,0 +1,304 @@
import { createContext, useContext, useEffect, useRef, forwardRef, useCallback, useState, useLayoutEffect, useReducer } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import { Object3D } from 'three';
import { TilesRenderer as TilesRendererImpl, WGS84_ELLIPSOID } from '3d-tiles-renderer/three';
import { useDeepOptions } from '../utilities/useOptions.js';
import { useObjectDep } from '../utilities/useObjectDep.js';
import { useApplyRefs } from '../utilities/useApplyRefs.js';
// context for accessing the tile set
export const TilesRendererContext = createContext( null );
export const TilesPluginContext = createContext( null );
// group that matches the transform of the tile set root group
function TileSetRoot( { children } ) {
const tiles = useContext( TilesRendererContext );
const ref = useRef();
useEffect( () => {
if ( tiles ) {
ref.current.matrixWorld = tiles.group.matrixWorld;
}
}, [ tiles ] );
return <group ref={ ref } matrixWorldAutoUpdate={ false } matrixAutoUpdate={ false }>{ children }</group>;
}
export function EastNorthUpFrame( props ) {
const {
lat = 0,
lon = 0,
height = 0,
az = 0,
el = 0,
roll = 0,
ellipsoid = WGS84_ELLIPSOID.clone(),
children,
} = props;
const tiles = useContext( TilesRendererContext );
const invalidate = useThree( state => state.invalidate );
const [ group, setGroup ] = useState( null );
const updateCallback = useCallback( () => {
if ( group === null ) {
return;
}
// hide the group if the tiles aren't loaded yet
const localEllipsoid = tiles && tiles.ellipsoid || ellipsoid || null;
group.matrix.identity();
group.visible = Boolean( tiles && tiles.root || ellipsoid );
if ( localEllipsoid === null ) {
return;
}
localEllipsoid.getOrientedEastNorthUpFrame( lat, lon, height, az, el, roll, group.matrix );
group.matrix.decompose( group.position, group.quaternion, group.scale );
group.updateMatrixWorld();
invalidate();
}, [ invalidate, tiles, lat, lon, height, az, el, roll, ellipsoid, group, useObjectDep( ellipsoid.radius ) ] ); // eslint-disable-line react-hooks/exhaustive-deps
// adjust the matrix world update logic if a tile set is present so that we can position the frame
// correctly regardless of the parent.
useEffect( () => {
if ( tiles !== null && group !== null ) {
group.updateMatrixWorld = function ( force ) {
if ( this.matrixAutoUpdate ) {
this.updateMatrix();
}
if ( this.matrixWorldNeedsUpdate || force ) {
this.matrixWorld.multiplyMatrices( tiles.group.matrixWorld, this.matrix );
force = true;
}
const children = this.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const child = children[ i ];
child.updateMatrixWorld( force );
}
};
return () => {
group.updateMatrixWorld = Object3D.prototype.updateMatrixWorld;
};
}
}, [ tiles, group ] );
useEffect( () => {
updateCallback();
}, [ updateCallback ] );
// update the position when a tile set is loaded since it may modify the ellipsoid
useEffect( () => {
if ( tiles === null ) {
return;
}
tiles.addEventListener( 'load-tile-set', updateCallback );
return () => {
tiles.removeEventListener( 'load-tile-set', updateCallback );
};
}, [ tiles, updateCallback ] );
return <group ref={ setGroup }>{ children }</group>;
}
// component for registering a plugin
export const TilesPlugin = forwardRef( function TilesPlugin( props, ref ) {
const { plugin, args, children, ...options } = props;
const tiles = useContext( TilesRendererContext );
const [ instance, setInstance ] = useState( null );
const [ , forceUpdate ] = useReducer( x => x + 1, 0 );
useLayoutEffect( () => {
if ( tiles === null ) {
return;
}
let instance;
if ( Array.isArray( args ) ) {
instance = new plugin( ...args );
} else {
instance = new plugin( args );
}
setInstance( instance );
return () => {
setInstance( null );
};
}, [ plugin, tiles, useObjectDep( args ) ] ); // eslint-disable-line
// assigns any provided options to the plugin
useDeepOptions( instance, options );
// register the plugin
useLayoutEffect( () => {
if ( instance === null ) {
return;
}
// force the component to rerender after registering the plugin because we don't
// include the children until the plugin is added.
tiles.registerPlugin( instance );
forceUpdate();
return () => {
tiles.unregisterPlugin( instance );
};
// "tiles" is excluded from the dependencies since this would otherwise run once with the
// new tiles renderer, resulting in an error when the instance is added to a second renderer.
}, [ instance ] ); // eslint-disable-line
// assign ref
useApplyRefs( instance, ref );
// only render out the plugin once the instance and context are ready and registered
if ( ! instance || ! tiles.plugins.includes( instance ) ) {
return;
}
return <TilesPluginContext.Provider value={ instance }>{ children }</TilesPluginContext.Provider>;
} );
// component for adding a TilesRenderer to the scene
export const TilesRenderer = forwardRef( function TilesRenderer( props, ref ) {
const { url, group = {}, enabled = true, children, ...options } = props;
const [ camera, gl, invalidate ] = useThree( state => [ state.camera, state.gl, state.invalidate ] );
const [ tiles, setTiles ] = useState( null );
// create the tile set
useEffect( () => {
const needsRender = () => invalidate();
const tiles = new TilesRendererImpl( url );
tiles.addEventListener( 'needs-render', needsRender );
tiles.addEventListener( 'needs-update', needsRender );
setTiles( tiles );
return () => {
tiles.removeEventListener( 'needs-render', needsRender );
tiles.removeEventListener( 'needs-update', needsRender );
tiles.dispose();
setTiles( null );
};
}, [ url, invalidate ] );
// update the resolution for the camera
useFrame( () => {
if ( tiles === null || ! enabled ) {
return;
}
camera.updateMatrixWorld();
tiles.setResolutionFromRenderer( camera, gl );
tiles.update();
} );
// add the camera
useLayoutEffect( () => {
if ( tiles === null ) {
return;
}
tiles.setCamera( camera );
return () => {
tiles.deleteCamera( camera );
};
}, [ tiles, camera ] );
// assign ref
useApplyRefs( tiles, ref );
// assign options recursively
useDeepOptions( tiles, options );
// only render out the tiles once the instance and context are ready
if ( ! tiles ) {
return null;
}
return <>
<primitive object={ tiles.group } { ...group } />
<TilesRendererContext.Provider value={ tiles }>
<TileSetRoot>
{ children }
</TileSetRoot>
</TilesRendererContext.Provider>
</>;
} );
@@ -0,0 +1,7 @@
export * from './components/CameraControls.jsx';
export * from './components/CameraTransition.jsx';
export * from './components/CanvasDOMOverlay.jsx';
export * from './components/CompassGizmo.jsx';
export * from './components/SettledObjects.jsx';
export * from './components/TilesAttributionOverlay.jsx';
export * from './components/TilesRenderer.jsx';
@@ -0,0 +1,7 @@
export * from './components/TilesRenderer.jsx';
export * from './components/TilesAttributionOverlay.jsx';
export * from './components/CanvasDOMOverlay.jsx';
export * from './components/CameraControls.jsx';
export * from './components/CompassGizmo.jsx';
export * from './components/CameraTransition.jsx';
export * from './components/SettledObjects.jsx';
@@ -0,0 +1,437 @@
import {
Raycaster,
Matrix4,
EventDispatcher,
Vector3,
Ray,
Line3,
Vector2,
} from 'three';
import { SceneObserver } from './SceneObserver.js';
import { Ellipsoid } from '3d-tiles-renderer/three';
const _raycaster = /* @__PURE__ */ new Raycaster();
const _line0 = /* @__PURE__ */ new Line3();
const _line1 = /* @__PURE__ */ new Line3();
const _params = /* @__PURE__ */ new Vector2();
const _direction = /* @__PURE__ */ new Vector3();
const _matrix = /* @__PURE__ */ new Matrix4();
export class QueryManager extends EventDispatcher {
constructor() {
super();
// settings
this.autoRun = true;
// queries
this.queryMap = new Map();
this.index = 0;
// jobs
this.queued = [];
this.scheduled = false;
this.duration = 1;
// scene
this.objects = [];
this.observer = new SceneObserver();
this.ellipsoid = new Ellipsoid();
this.frame = new Matrix4();
// cameras for sorting
this.cameras = new Set();
// register to mark items as dirty
const queueAll = ( () => {
let queued = false;
return () => {
if ( ! queued ) {
queued = true;
queueMicrotask( () => {
this.queryMap.forEach( item => this._enqueue( item ) );
queued = false;
} );
}
};
} )();
this.observer.addEventListener( 'childadded', queueAll );
this.observer.addEventListener( 'childremoved', queueAll );
}
// job runner
_enqueue( info ) {
if ( ! info.queued ) {
this.queued.push( info );
info.queued = true;
this._scheduleRun();
}
}
_runJobs() {
const { queued, cameras, duration } = this;
const start = performance.now();
// Iterate over all cameras
cameras.forEach( ( camera, c ) => {
_matrix.copy( camera.matrixWorldInverse ).premultiply( camera.projectionMatrix );
_direction.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
_line0.start.setFromMatrixPosition( camera.matrixWorld );
_line0.end.addVectors( _direction, _line0.start );
for ( let i = 0, l = queued.length; i < l; i ++ ) {
const info = queued[ i ];
const { ray } = info;
// save the values for sorting
let distance;
let inFrustum;
if ( info.point === null ) {
// prioritize displaying points that are from rays pointing in the same direction as
// the camera. Find the distance between camera ray and projection ray:
_line1.start.copy( ray.origin );
ray.at( 1, _line1.end );
closestPointLineToLine( _line0, _line1, _params );
info.distance = _params.x * ( 1.0 - Math.abs( _direction.dot( ray.direction ) ) );
info.inFrustum = true;
} else {
// if the point is within the frustum then prioritize it
const p = _line1.start;
p.copy( info.point ).applyMatrix4( _matrix );
if ( p.x > - 1 && p.x < 1 && p.y > - 1 && p.y < 1 && p.z > - 1 && p.z < 1 ) {
// calculate the distance to the last hit point
info.distance = p.subVectors( info.point, _line0.start ).dot( _direction );
info.inFrustum = true;
} else {
info.distance = 0;
info.inFrustum = false;
}
}
if ( c === 0 ) {
info.distance = distance;
info.inFrustum = inFrustum;
} else {
info.inFrustum = info.inFrustum || inFrustum;
info.distance = Math.min( info.distance, distance );
}
}
} );
// sort the items if necessary
if ( cameras.length !== 0 ) {
queued.sort( ( a, b ) => {
if ( ( a.point === null ) !== ( b.point === null ) ) {
return a.point === null ? 1 : - 1;
} else if ( a.inFrustum !== b.inFrustum ) {
return a.inFrustum ? 1 : - 1;
} else if ( ( a.distance < 0 ) !== ( b.distance < 0 ) ) {
return a.distance < 0 ? - 1 : 1;
} else {
return b.distance - a.distance;
}
} );
}
// update all the positions
while ( queued.length !== 0 && performance.now() - start < duration ) {
const item = queued.pop();
item.queued = false;
this._updateQuery( item );
}
if ( queued.length !== 0 ) {
this._scheduleRun();
}
}
_scheduleRun() {
if ( this.autoRun && ! this.scheduled ) {
this.scheduled = true;
requestAnimationFrame( () => {
this.scheduled = false;
this._runJobs();
} );
}
}
_updateQuery( item ) {
_raycaster.ray.copy( item.ray );
_raycaster.far = 'lat' in item ? 1e4 + Math.max( ...this.ellipsoid.radius ) : Infinity;
// save the last hit point for sorting
const hit = _raycaster.intersectObjects( this.objects )[ 0 ] || null;
if ( hit !== null ) {
if ( item.point === null ) {
item.point = hit.point.clone();
} else {
item.point.copy( hit.point );
}
}
item.callback( hit );
}
// add and remove cameras used for sorting
addCamera( camera ) {
const { queryMap, cameras } = this;
cameras.add( camera );
queryMap.forEach( o => this._enqueue( o ) );
}
deleteCamera( camera ) {
const { cameras } = this;
cameras.delete( camera );
}
// run the given item index if possible
runIfNeeded( index ) {
const { queryMap, queued } = this;
const item = queryMap.get( index );
if ( item.queued ) {
this._updateQuery( item );
item.queued = false;
queued.splice( queued.indexOf( item ), 1 );
}
}
// set the scene used for query
setScene( ...objects ) {
const { observer } = this;
observer.dispose();
objects.forEach( o => observer.observe( o ) );
this.objects = objects;
this._scheduleRun();
}
// update the ellipsoid and frame based on a tiles renderer, updating the item rays only if necessary
setEllipsoidFromTilesRenderer( tilesRenderer ) {
const { queryMap, ellipsoid, frame } = this;
if (
! ellipsoid.radius.equals( tilesRenderer.ellipsoid.radius ) ||
! frame.equals( tilesRenderer.group.matrixWorld )
) {
ellipsoid.copy( tilesRenderer.ellipsoid );
frame.copy( tilesRenderer.group.matrixWorld );
// update the query rays for any item specified via lat / lon
queryMap.forEach( o => {
if ( 'lat' in o ) {
const { lat, lon, ray } = o;
ellipsoid.getCartographicToPosition( lat, lon, 1e4, ray.origin ).applyMatrix4( frame );
ellipsoid.getCartographicToNormal( lat, lon, ray.direction ).transformDirection( frame ).multiplyScalar( - 1 );
}
this._enqueue( o );
} );
}
}
// register query callbacks
registerRayQuery( ray, callback ) {
const index = this.index ++;
const item = {
ray: ray.clone(),
callback,
queued: false,
distance: - 1,
point: null,
};
this.queryMap.set( index, item );
this._enqueue( item );
return index;
}
registerLatLonQuery( lat, lon, callback ) {
const { ellipsoid, frame } = this;
const index = this.index ++;
const ray = new Ray();
ellipsoid.getCartographicToPosition( lat, lon, 1e4, ray.origin ).applyMatrix4( frame );
ellipsoid.getCartographicToNormal( lat, lon, ray.direction ).transformDirection( frame ).multiplyScalar( - 1 );
const item = {
ray: ray.clone(),
lat, lon,
callback,
queued: false,
distance: - 1,
point: null,
};
this.queryMap.set( index, item );
this._enqueue( item );
return index;
}
unregisterQuery( index ) {
const { queued, queryMap } = this;
const item = queryMap.get( index );
queryMap.delete( index );
if ( item && item.queued ) {
item.queued = false;
queued.splice( queued.indexOf( item ), 1 );
}
}
// dispose of everything
dispose() {
this.queryMap.clear();
this.queued.length = 0;
this.objects.length = 0;
this.observer.dispose();
}
}
// copied from three-mesh-bvh
const closestPointLineToLine = ( function () {
// https://github.com/juj/MathGeoLib/blob/master/src/Geometry/Line.cpp#L56
const dir1 = new Vector3();
const dir2 = new Vector3();
const v02 = new Vector3();
return function closestPointLineToLine( l1, l2, result ) {
const v0 = l1.start;
const v10 = dir1;
const v2 = l2.start;
const v32 = dir2;
v02.subVectors( v0, v2 );
dir1.subVectors( l1.end, l1.start );
dir2.subVectors( l2.end, l2.start );
// float d0232 = v02.Dot(v32);
const d0232 = v02.dot( v32 );
// float d3210 = v32.Dot(v10);
const d3210 = v32.dot( v10 );
// float d3232 = v32.Dot(v32);
const d3232 = v32.dot( v32 );
// float d0210 = v02.Dot(v10);
const d0210 = v02.dot( v10 );
// float d1010 = v10.Dot(v10);
const d1010 = v10.dot( v10 );
// float denom = d1010*d3232 - d3210*d3210;
const denom = d1010 * d3232 - d3210 * d3210;
let d, d2;
if ( denom !== 0 ) {
d = ( d0232 * d3210 - d0210 * d3232 ) / denom;
} else {
d = 0;
}
d2 = ( d0232 + d * d3210 ) / d3232; // eslint-disable-line
result.x = d;
result.y = d2;
};
} )();
@@ -0,0 +1,99 @@
import { EventDispatcher } from 'three';
function traverse( root, callback ) {
if ( callback( root ) ) {
return;
}
root.children.forEach( c => {
traverse( c, callback );
} );
}
export class SceneObserver extends EventDispatcher {
constructor() {
super();
this.objects = new Set();
this.observed = new Set();
this._addedCallback = ( { child } ) => {
traverse( child, c => {
if ( this.observed.has( c ) ) {
return true;
} else {
this.objects.add( c );
c.addEventListener( 'childadded', this._addedCallback );
c.addEventListener( 'childremoved', this._removedCallback );
this.dispatchEvent( { type: 'childadded', child } );
return false;
}
} );
};
this._removedCallback = ( { child } ) => {
traverse( child, c => {
if ( this.observed.has( c ) ) {
return true;
} else {
this.objects.delete( c );
c.removeEventListener( 'childadded', this._addedCallback );
c.removeEventListener( 'childremoved', this._removedCallback );
this.dispatchEvent( { type: 'childremoved', child } );
return false;
}
} );
};
}
observe( root ) {
const { observed } = this;
this._addedCallback( { child: root } );
observed.add( root );
}
unobserve( root ) {
const { observed } = this;
observed.delete( root );
this._removedCallback( { child: root } );
}
dispose() {
this.observed.forEach( root => {
this.unobserve( root );
} );
}
}
@@ -0,0 +1,28 @@
import { useEffect } from 'react';
// assign a give target to the set of refs
export function useApplyRefs( target, ...refs ) {
useEffect( () => {
refs.forEach( ref => {
if ( ref ) {
if ( ref instanceof Function ) {
ref( target );
} else {
ref.current = target;
}
}
} );
}, [ target, ...refs ] ); // eslint-disable-line
}
@@ -0,0 +1,27 @@
import { useCallback } from 'react';
export function useMultipleRefs( ...refs ) {
return useCallback( target => {
refs.forEach( ref => {
if ( ref ) {
if ( typeof ref === 'function' ) {
ref( target );
} else {
ref.current = target;
}
}
} );
}, refs ); // eslint-disable-line
}
@@ -0,0 +1,59 @@
import { useRef } from 'react';
// checks if the first level of object key-values are equal
function areObjectsEqual( a, b ) {
// early check for equivalence
if ( a === b ) {
return true;
}
// if either of the objects is null or undefined, then perform a simple check
if ( ! a || ! b ) {
return a === b;
}
// check all keys and values in the first object
for ( const key in a ) {
if ( a[ key ] !== b[ key ] ) {
return false;
}
}
// check all keys and values in the second object
for ( const key in b ) {
if ( a[ key ] !== b[ key ] ) {
return false;
}
}
return true;
}
// Helper for using an object as a dependency in a useEffect or useMemo array
export function useObjectDep( object ) {
// only modify the returned object reference if it has changed
const ref = useRef();
if ( ! areObjectsEqual( ref.current, object ) ) {
ref.current = object;
}
return ref.current;
}
@@ -0,0 +1,111 @@
import { useLayoutEffect } from 'react';
import { useObjectDep } from './useObjectDep.js';
// return true if the given key is for registering an event
function isEventName( key ) {
return /^on/g.test( key );
}
// returns the event name to register for the given key
function getEventName( key ) {
return key
.replace( /^on/, '' )
.replace( /[a-z][A-Z]/g, match => `${ match[ 0 ] }-${ match[ 1 ] }` )
.toLowerCase();
}
// returns a dash-separated key as a list of tokens
function getPath( key ) {
return key.split( '-' );
}
// gets the value from the object at the given path
function getValueAtPath( object, path ) {
let curr = object;
const tokens = [ ...path ];
while ( tokens.length !== 0 ) {
const key = tokens.shift();
curr = curr[ key ];
}
return curr;
}
// sets the value of the object at the given path
function setValueAtPath( object, path, value ) {
const tokens = [ ...path ];
const finalKey = tokens.pop();
getValueAtPath( object, tokens )[ finalKey ] = value;
}
// Recursively assigns a set of options to an object, interpreting dashes as periods
export function useDeepOptions( target, options, shallow = false ) {
// assign options recursively
useLayoutEffect( () => {
if ( target === null ) {
return;
}
const previousState = {};
const events = {};
for ( const key in options ) {
if ( isEventName( key ) && target.addEventListener && ! ( key in target ) ) {
const eventName = getEventName( key );
events[ eventName ] = options[ key ];
target.addEventListener( eventName, options[ key ] );
} else {
const path = shallow ? [ key ] : getPath( key );
previousState[ key ] = getValueAtPath( target, path );
setValueAtPath( target, path, options[ key ] );
}
}
return () => {
for ( const key in events ) {
target.removeEventListener( key, events[ key ] );
}
for ( const key in previousState ) {
const path = shallow ? [ key ] : getPath( key );
setValueAtPath( target, path, previousState[ key ] );
}
};
}, [ target, useObjectDep( options ) ] ); // eslint-disable-line
}
// Assigns a set of options to an object shallowly, interpreting dashes as periods
export function useShallowOptions( instance, options ) {
useDeepOptions( instance, options, true );
}
@@ -0,0 +1,62 @@
// bundle entry point
export * from './timeline';
export * from './timelineEventsEmitter';
export * from './settings/timelineConsts';
// @ public timeline models.
export * from './models/timelineRanged';
export * from './models/timelineModel';
export * from './models/timelineRow';
export * from './models/timelineKeyframe';
// @public styles
export * from './settings/timelineOptions';
export * from './settings/styles/timelineKeyframeStyle';
export * from './settings/styles/timelineRowStyle';
export * from './settings/styles/timelineStyle';
export * from './settings/styles/timelineGroupStyle';
export * from './utils/timelineStyleUtils';
export * from './utils/timelineUtils';
export * from './utils/timelineElement';
// @private helper containers.
export * from './utils/timelineSelectable';
export * from './utils/timelineCutBoundsRectResults';
export * from './utils/timelineSelectionResults';
export * from './utils/timelinePoint';
export * from './utils/timelineMouseData';
export * from './utils/timelineElementDragState';
export * from './utils/timelineDraggableData';
// @private virtual model
export * from './viewModels/timelineGroupViewModel';
export * from './viewModels/timelineKeyframeViewModel';
export * from './viewModels/timelineRowViewModel';
export * from './viewModels/timelineViewModel';
// @public events
export * from './utils/events/timelineKeyframeChangedEvent';
export * from './utils/events/timelineTimeChangedEvent';
export * from './utils/events/timelineSelectedEvent';
export * from './utils/events/timelineScrollEvent';
export * from './utils/events/timelineClickEvent';
export * from './utils/events/timelineDragEvent';
// @public enums
export * from './enums/timelineKeyframeShape';
export * from './enums/timelineInteractionMode';
export * from './enums/timelineScrollSource';
export * from './enums/timelineElementType';
export * from './enums/timelineCursorType';
export * from './enums/timelineCapShape';
export * from './enums/timelineEventSource';
export * from './enums/timelineSelectionMode';
export * from './enums/timelineEvents';
// @private defaults are exposed:
export * from './settings/defaults/defaultTimelineStyle';
export * from './settings/defaults/defaultTimelineRowStyle';
export * from './settings/defaults/defaultTimelineOptions';
export * from './settings/defaults/defaultTimelineKeyframeStyle';
export * from './settings/defaults/defaultTimelineConsts';
export * from './settings/defaults/defaultGroupStyle';
@@ -0,0 +1,5 @@
export enum TimelineCapShape {
None = 'none',
Triangle = 'triangle',
Rect = 'rect',
}
@@ -0,0 +1,37 @@
export enum TimelineCursorType {
Alias = 'alias',
AllScroll = 'all-scroll',
Auto = 'auto',
Cell = 'cell',
ContextMenu = 'context-menu',
ColResize = 'col-resize',
Copy = 'copy',
Crosshair = 'crosshair',
Default = 'default',
EResize = 'e-resize',
EWResize = 'ew-resize',
Grab = 'grab',
Grabbing = 'grabbing',
Help = 'help',
Move = 'move',
NResize = 'n-resize',
NEResize = 'ne-resize',
NESWResize = 'nesw-resize',
NSResize = 'ns-resize',
NWResize = 'nw-resize',
NWSEResize = 'nwse-resize',
NoDrop = 'no-drop',
None = 'none',
NotAllowed = 'not-allowed',
Pointer = 'pointer',
Progress = 'progress',
RowResize = 'row-resize',
SResize = 's-resize',
SEResize = 'se-resize',
SWResize = 'sw-resize',
Text = 'text',
WResize = 'w-resize',
Wait = 'wait',
ZoomIn = 'zoom-in',
ZoomOut = 'zoom-out',
}
@@ -0,0 +1,22 @@
/**
* Internal element type.
*/
export enum TimelineElementType {
/**
* Timeline
*/
Timeline = 'timeline',
/**
* Keyframes
*/
Keyframe = 'keyframe',
/**
* Keyframes connected and presenting one group.
*/
Group = 'group',
/**
* Timeline row.
*/
Row = 'row',
None = 'none',
}
@@ -0,0 +1,14 @@
export enum TimelineEventSource {
/**
* Changed by user interaction events.
*/
User = 'user',
/**
* Changed programmatically.
*/
Programmatically = 'programmatically',
/**
* Changed by the set time function.
*/
SetTimeMethod = 'setTimeMethod',
}
@@ -0,0 +1,17 @@
/**
* Event names of the component.
*/
export enum TimelineEvents {
Selected = 'selected',
TimeChanged = 'timechanged',
KeyframeChanged = 'keyframeChanged',
DragStarted = 'dragStarted',
Drag = 'drag',
DragFinished = 'dragFinished',
Scroll = 'scroll',
ScrollFinished = 'scrollFinished',
ContextMenu = 'onContextMenu',
DoubleClick = 'doubleClick',
MouseDown = 'mouseDown',
Zoom = 'zoom',
}
@@ -0,0 +1,25 @@
export enum TimelineInteractionMode {
/**
* Keyframe selection tool selecting single or group of keyframes.
*/
Selection = 'selection',
/**
* Pan tool with the possibility to select keyframes.
*/
Pan = 'pan',
/**
* Allow only pan without any keyframes interaction.
* Timeline still can be moved and controlled by option 'timelineDraggable'.
*/
NonInteractivePan = 'nonInteractivePan',
/**
* Zoom tool.
*/
Zoom = 'zoom',
/**
* No iteraction, except moving a timeline.
* Timeline still can be moved and controlled by option 'timelineDraggable'.
*/
None = 'none',
}
@@ -0,0 +1,6 @@
export enum TimelineKeyframeShape {
None = 'none',
Rhomb = 'rhomb',
Circle = 'circle',
Rect = 'rect',
}
@@ -0,0 +1,5 @@
export enum TimelineScrollSource {
DefaultMode = 'none',
ZoomMode = 'zoom',
ScrollBySelection = 'scrollBySelection',
}
@@ -0,0 +1,9 @@
/**
* Timeline selection event type.
*/
export enum TimelineSelectionEventSource {
/**
* Keyframe selection is performed.
*/
Keyframes = 'keyframes',
}
@@ -0,0 +1,17 @@
/**
* Timeline selection mode.
*/
export enum TimelineSelectionMode {
/**
* Select new items. deselect changed.
*/
Normal = 'normal',
/**
* Append current selection.
*/
Append = 'append',
/**
* Revert selection of a specified nodes.
*/
Revert = 'revert',
}
@@ -0,0 +1,28 @@
import { TimelineKeyframeStyle } from '../settings/styles/timelineKeyframeStyle';
import { TimelineGroupStyle } from '../settings/styles/timelineGroupStyle';
/**
* Timeline group view model.
*/
export interface TimelineGroup {
/**
* Group style.
*/
style: TimelineGroupStyle;
/**
* Child keyframes style.
*/
keyframesStyle?: TimelineKeyframeStyle;
/**
* Whether group is draggable.
* Considered to be false when really set as false.
*/
draggable?: boolean;
/**
* Whether group keyframes are draggable.
*/
keyframesDraggable?: boolean;
/**
* Whether group is hidden.
*/
hidden?: boolean;
}
@@ -0,0 +1,29 @@
import { TimelineKeyframeStyle } from '../settings/styles/timelineKeyframeStyle';
import { TimelineSelectable } from '../utils/timelineSelectable';
import { TimelineRanged } from './timelineRanged';
import { TimelineGroup } from './timelineGroup';
export interface TimelineKeyframe extends TimelineSelectable, TimelineRanged {
/**
* Keyframe value.
*/
val: number;
/**
* Related keyframe group.
* Timeline keyframes groups are rendered as one instance.
*/
group?: string | TimelineGroup;
/**
* Keyframe style.
*/
style?: TimelineKeyframeStyle;
/**
* Whether keyframe is hidden.
*/
hidden?: boolean;
/**
* Whether group is draggable.
* Considered to be false when really set as false.
*/
draggable?: boolean;
}
@@ -0,0 +1,4 @@
import { TimelineRow } from './timelineRow';
export interface TimelineModel {
rows: TimelineRow[];
}
@@ -0,0 +1,10 @@
export interface TimelineRanged {
/**
* min
*/
min?: number | null;
/**
* max.
*/
max?: number | null;
}
@@ -0,0 +1,14 @@
import { TimelineKeyframe } from './timelineKeyframe';
import { TimelineRowStyle } from '../settings/styles/timelineRowStyle';
import { TimelineRanged } from './timelineRanged';
export interface TimelineRow extends TimelineRanged {
style?: TimelineRowStyle;
keyframes?: TimelineKeyframe[] | null;
hidden?: boolean;
/**
* Whether group keyframes are draggable.
*/
keyframesDraggable?: boolean;
groupsDraggable?: boolean;
}
@@ -0,0 +1,23 @@
import { TimelineGroupStyle } from '../styles/timelineGroupStyle';
export const defaultRowHeight = 24;
const margin = 4;
export const defaultGroupStyle = {
height: 'auto',
marginTop: margin,
/**
* Default group fill color.
*/
fillColor: '#094771',
/**
* 组默认文本设置
*/
text: {
label: "",
isStroke:false,
font: '10px sans-serif',
textAlign: 'center',
textBaseline: 'middle',
direction:"inherit",
fillColor: '#fff'
}
} as TimelineGroupStyle;
@@ -0,0 +1,44 @@
import { TimelineConsts } from '../timelineConsts';
export const defaultTimelineConsts: TimelineConsts = {
/**
* Private. Auto pan speed.
*/
autoPanSpeed: 50,
/**
* Private. scroll speed when mouse drag is used (from 0 to 1)
*/
scrollByDragSpeed: 0.12,
/**
* Private. Determine whether item was clicked.
*/
clickDetectionMs: 120,
/**
* Private. Timeout to detect double click.
*/
doubleClickTimeoutMs: 400,
/**
* Private. Time in ms used to refresh scrollbars when pan is finished.
*/
scrollFinishedTimeoutMs: 500,
/**
* Private. Auto pan padding
*/
autoPanByScrollPadding: 10,
/**
* Private. Click threshold
*/
clickThreshold: 3,
/**
* Private. Private.Click min radius for the elements detection.
*/
clickDetectionMinRadius: 2,
/**
* Private. Skip some auto pan/scroll actions if they are executed more rapid than this value.
*/
autoPanSpeedLimit: 10,
/**
* Private. Default auto size for the group. It's percents.
*/
defaultGroupHeight: 0.7,
} as TimelineConsts;
@@ -0,0 +1,19 @@
import { TimelineKeyframeShape } from '../../enums/timelineKeyframeShape';
import { TimelineKeyframeStyle } from '../styles/timelineKeyframeStyle';
export const defaultTimelineKeyframeStyle = {
/**
* keyframe fill color.
*/
fillColor: 'DarkOrange',
shape: TimelineKeyframeShape.Rhomb,
/**
* Selected keyframe fill color.
*/
selectedFillColor: 'red',
strokeColor: 'black',
selectedStrokeColor: 'black',
strokeThickness: 0.2,
height: 'auto',
width: 'auto',
} as TimelineKeyframeStyle;
@@ -0,0 +1,117 @@
import { TimelineOptions } from '../timelineOptions';
import { defaultGroupStyle } from './defaultGroupStyle';
import { defaultTimelineKeyframeStyle } from './defaultTimelineKeyframeStyle';
import { defaultTimelineRowStyle } from './defaultTimelineRowStyle';
import { defaultTimelineStyle } from './defaultTimelineStyle';
export const defaultTimelineOptions = {
/**
* Snap all selected keyframes as a bundle during the drag.
*/
snapAllKeyframesOnMove: false,
/**
* Check whether snapping is enabled.
*/
snapEnabled: true,
/**
* Timeline style.
*/
timelineStyle: defaultTimelineStyle,
/**
* approximate step for the timeline in pixels for 1 second
*/
stepPx: 120,
/**
* Number of units that should fit into one stepPx. (1 second by a default)
*/
stepVal: 1000,
stepSmallPx: 30,
/**
* Snap step in units. from 0 to stepVal
*/
snapStep: 200,
/**
* additional left margin in pixels to start the line gauge from.
*/
leftMargin: 25,
headerFillColor: '#101011',
fillColor: '#101011',
labelsColor: '#D5D5D5',
/**
* Header gauge tick color.
*/
tickColor: '#D5D5D5',
/**
* Selection rectangle color.
*/
selectionColor: 'White',
/**
* Default rows style.
* Can be overridden by setting style individually for each row.
*/
rowsStyle: defaultTimelineRowStyle,
/**
* Style for the all keyframes in a current row.
* Individual keyframe can have own style.
*/
keyframesStyle: defaultTimelineKeyframeStyle,
/**
* Style of the groups.
*/
groupsStyle: defaultGroupStyle,
/**
* Header height in pixels
*/
headerHeight: 30,
font: '11px sans-serif',
/**
* Default zoom level = 1. where screen pixels are equals to the corresponding stepVal stepPx.
*/
zoom: 1,
/**
* Default zoom speed.
*/
zoomSpeed: 0.1,
/**
* Max zoom value.
*/
zoomMin: 0.1,
/**
* Min zoom value.
*/
zoomMax: 8,
/**
* Set this to true in a MAC OS environment: The Meta key will be used instead of the Ctrl key.
*/
controlKeyIsMetaKey: false,
/**
* Access the scroll container via this class for e.g. scroll bar styling.
*/
scrollContainerClass: 'scroll-container',
/**
* keyframes group is draggable.
*/
groupsDraggable: true,
/**
* keyframes are draggable.
*/
keyframesDraggable: true,
/**
* Timeline can be dragged or position can be changed by user interaction. Default: true
*/
timelineDraggable: true,
/**
* Start drawing timeline from this min point.
* Bounds for the keyframe dragging.
*/
min: 0,
/**
* Max bounds timeline can navigate to.
*/
max: Number.MAX_VALUE,
} as TimelineOptions;
@@ -0,0 +1,21 @@
import { TimelineRowStyle } from '../styles/timelineRowStyle';
import { defaultGroupStyle, defaultRowHeight } from './defaultGroupStyle';
import { defaultTimelineKeyframeStyle } from './defaultTimelineKeyframeStyle';
export const defaultTimelineRowStyle = {
/**
* Row height in pixels.
*/
height: defaultRowHeight,
marginBottom: 2,
fillColor: '#252526',
/**
* Style for the all keyframes in a current row.
* Individual keyframe can have own style.
*/
keyframesStyle: defaultTimelineKeyframeStyle,
/**
* Style of the groups.
*/
groupsStyle: defaultGroupStyle,
} as TimelineRowStyle;
@@ -0,0 +1,22 @@
import { TimelineCapShape } from '../../enums/timelineCapShape';
import { TimelineCursorType } from '../../enums/timelineCursorType';
import { TimelineStyle } from '../styles/timelineStyle';
export const defaultTimelineStyle = {
width: 2,
marginTop: 15,
marginBottom: 0,
strokeColor: 'DarkOrange',
fillColor: 'DarkOrange',
capStyle: {
width: 4,
height: 10,
/**
* Draw timeline rectangular cap.
*/
capType: TimelineCapShape.Rect,
strokeColor: 'DarkOrange',
fillColor: 'DarkOrange',
},
cursor: TimelineCursorType.EWResize,
} as TimelineStyle;
@@ -0,0 +1,27 @@
import { TimelineCapShape } from '../../enums/timelineCapShape';
/**
* Timeline active/current value indicator style.
*/
export interface TimelineCapStyle {
/**
* Cap style width in pixels.
*/
width?: number;
/**
* Cap style height in pixels.
*/
height?: number;
/**
* Cap stroke color.
*/
strokeColor?: string;
/**
* Cap fill color.
*/
fillColor?: string;
/**
* Cap type
*/
capType?: TimelineCapShape;
}
@@ -0,0 +1,56 @@
import { TimelineCursorType } from '../../enums/timelineCursorType';
import { TimelineKeyframeStyle } from '../styles/timelineKeyframeStyle';
/**
* Timeline group style.
*/
export interface TimelineGroupStyle {
/**
* Keyframes style height in pixels.
* 'auto' to automatically calculate.
*/
height?: number | string;
/**
* Group stroke color.
*/
strokeColor?: string;
/**
* Group stroke thickness.
*/
strokeThickness?: number | null;
/**
* Group border radius. See canvas roundRect official documentation.
*/
radii?: number | DOMPointInit | Iterable<number | DOMPointInit>;
/**
* Group fill color.
*/
fillColor?: string;
/**
* Group mouse over cursor style.
*/
cursor?: TimelineCursorType;
/**
* Margin top in px or 'auto' to center element.
*/
marginTop?: number | string;
/**
* Style for all the keyframes in the current group.
*/
keyframesStyle?: TimelineKeyframeStyle;
/**
* 组默认文本设置
*/
text?: {
label?: string,
isStroke?: boolean,
font?: string,
textAlign?: "start" | "end" | "left" | "right" | "center",
textBaseline?: "top" | "hanging" | "middle" | "alphabetic" | "ideographic" | "bottom",
direction?: "ltr" | "rtl" | "inherit",
fillColor?: string,
}
}
@@ -0,0 +1,41 @@
import { TimelineCursorType } from '../../enums/timelineCursorType';
import { TimelineKeyframeShape } from '../../enums/timelineKeyframeShape';
export interface TimelineKeyframeStyle {
/**
* Timeline cursor style.
*/
cursor?: TimelineCursorType;
/**
* Timeline keyframe shape
*/
shape?: TimelineKeyframeShape;
/**
* keyframe size, number or text 'auto'
*/
height?: number | string;
/**
* keyframe size, number or text 'auto'
*/
width?: number | string;
/**
* Keyframe fill color
*/
fillColor?: string | null;
/**
* Keyframe selected fill color.
*/
selectedFillColor?: string | null;
/**
* Keyframe stroke color.
*/
strokeColor?: string | null;
/**
* Keyframe selected stroke color.
*/
selectedStrokeColor?: string | null;
/**
* Keyframe stroke Thickness.
*/
strokeThickness?: number | null;
}
@@ -0,0 +1,29 @@
import { TimelineGroupStyle } from './timelineGroupStyle';
import { TimelineKeyframeStyle } from './timelineKeyframeStyle';
/**
* Style of the row.
*/
export interface TimelineRowStyle {
/**
* Size of the row in pixels.
*/
height?: number;
/**
* Track fill color.
*/
fillColor?: string;
/**
* Row margin bottom in pixels between tracks/rows.
*/
marginBottom?: number;
/**
* Style for the all keyframes in a current row.
* Individual keyframe can have own style.
*/
keyframesStyle?: TimelineKeyframeStyle;
/**
* Style of the groups. Keyframe groups can be also styles separately.
*/
groupsStyle?: TimelineGroupStyle;
}
@@ -0,0 +1,33 @@
import { TimelineCursorType } from '../../enums/timelineCursorType';
import { TimelineCapStyle } from './timelineCapStyle';
/**
* Timeline active/current value indicator style.
*/
export interface TimelineStyle {
width?: number;
/**
* Margin top in pixels.
*/
marginTop?: number;
/**
* Margin bottom in pixels.
*/
marginBottom?: number;
/**
* Timeline top cap style.
*/
capStyle?: TimelineCapStyle;
/**
* Timeline indicator stroke color.
*/
strokeColor?: string;
/**
* Timeline fill color.
*/
fillColor?: string;
/**
* Timeline cursor.
*/
cursor?: TimelineCursorType;
}
@@ -0,0 +1,47 @@
/**
* Internal components consts.
* Still can be changed thru private property _consts but this is discouraged.
*/
export interface TimelineConsts {
/**
* Auto pan speed.
*/
autoPanSpeed: number;
/**
* scroll speed when mouse drag is used (from 0 to 1)
*/
scrollByDragSpeed: number;
/**
* Determine whether item was clicked.
*/
clickDetectionMs: number;
/**
* Timeout to detect double click.
*/
doubleClickTimeoutMs: number;
/**
* Time in ms used to refresh scrollbars when pan is finished.
*/
scrollFinishedTimeoutMs: number;
/**
* Auto pan padding
*/
autoPanByScrollPadding: number;
/**
* Click threshold
*/
clickThreshold: number;
/**
* Click min radius for the elements detection.
*/
clickDetectionMinRadius: number;
/**
* Default auto size for the group. It's percents.
*/
autoPanSpeedLimit: number;
/**
* Default auto size for the group. It's percents.
*/
defaultGroupHeight: number;
}
@@ -0,0 +1,116 @@
import { TimelineRowStyle } from './styles/timelineRowStyle';
import { TimelineStyle } from './styles/timelineStyle';
import { TimelineRanged } from '../models/timelineRanged';
export interface TimelineOptions extends TimelineRanged {
/**
* Id 或时间轴容器的 HTMLElement
*/
id?: string | HTMLElement | null;
/**
* 检查是否启用了捕捉
*/
snapEnabled?: boolean;
/**
* 拖动时将所有选定的关键帧合并为一个包.
*/
snapAllKeyframesOnMove?: boolean;
/**
* 以像素为单位的时间轴的近似步长为1秒
*/
stepPx?: number;
/**
* 一个步骤中应该包含的点的个数。
*/
stepVal?: number;
stepSmallPx?: number;
/**
* 以单位表示。从0到stepVal
*/
snapStep?: number;
/**
* 以像素为单位增加左页边距,从该值开始计算行距.
*/
leftMargin?: number;
/**
* 组件header背景颜色.
*/
headerFillColor?: string;
/**
* 组件背景颜色.
*/
fillColor?: string;
/**
* 标题标签颜色.
*/
labelsColor?: string;
/**
* 标题刻度条颜色.
*/
tickColor?: string;
/**
* 选择矩形颜色.
*/
selectionColor?: string;
/**
* 标题高度(以像素为单位)。
*/
headerHeight?: number;
/**
* Header ticks font
*/
font?: string;
/**
* Default zoom level = 1. where screen pixels are equals to the corresponding stepVal stepPx.
*/
zoom?: number;
/**
* Default zoom speed.
*/
zoomSpeed?: number;
/**
* Max zoom value.
*/
zoomMin?: number;
/**
* Min zoom value.
*/
zoomMax?: number;
/**
* Set this to true in a MAC OS environment: The Meta key will be used instead of the Ctrl key.
*/
controlKeyIsMetaKey?: boolean;
/**
* Access the scroll container via this class for e.g. scroll bar styling.
*/
scrollContainerClass?: string;
/**
* Default rows style.
* Can be overridden by setting style individually for each row.
*/
rowsStyle?: TimelineRowStyle;
/**
* Timeline indicator style.
*/
timelineStyle?: TimelineStyle;
/**
* keyframes group is draggable. Default: true
*/
groupsDraggable?: boolean;
/**
* keyframes group is draggable. Default: true
*/
keyframesDraggable?: boolean;
/**
* Timeline can be dragged or position can be changed by user interaction. Default: true
*/
timelineDraggable?: boolean;
/**
* Array of the denominators used to determine 'beautiful' numbers to be rendered for the gauge.
* Default: [1, 2, 5, 10];
*/
denominators?: number[];
}

Some files were not shown because too many files have changed in this diff Show More