UNPKG

itowns

Version:

A JS/WebGL framework for 3D geospatial data visualization

543 lines (518 loc) 20 kB
import * as THREE from 'three'; import { TilesRenderer } from '3d-tiles-renderer'; import { GLTFStructuralMetadataExtension, GLTFMeshFeaturesExtension, GLTFCesiumRTCExtension, CesiumIonAuthPlugin, GoogleCloudAuthPlugin, ImplicitTilingPlugin // eslint-disable-next-line import/no-unresolved } from '3d-tiles-renderer/plugins'; import GeometryLayer from "./GeometryLayer.js"; import iGLTFLoader from "../Parser/iGLTFLoader.js"; import { DRACOLoader } from "../ThreeExtended/loaders/DRACOLoader.js"; import { KTX2Loader } from "../ThreeExtended/loaders/KTX2Loader.js"; import ReferLayerProperties from "./ReferencingLayerProperties.js"; import PointsMaterial, { PNTS_MODE, PNTS_SHAPE, PNTS_SIZE_MODE, ClassificationScheme } from "../Renderer/PointsMaterial.js"; import { VIEW_EVENTS } from "../Core/View.js"; const _raycaster = new THREE.Raycaster(); // Stores lruCache, downloadQueue and parseQueue for each id of view {@link View} // every time a tileset has been added // https://github.com/iTowns/itowns/issues/2426 const viewers = {}; // Internal instance of GLTFLoader, passed to 3d-tiles-renderer-js to support GLTF 1.0 and 2.0 // Temporary exported to be used in deprecated B3dmParser export const itownsGLTFLoader = new iGLTFLoader(); itownsGLTFLoader.register(() => new GLTFMeshFeaturesExtension()); itownsGLTFLoader.register(() => new GLTFStructuralMetadataExtension()); itownsGLTFLoader.register(() => new GLTFCesiumRTCExtension()); export const OGC3DTILES_LAYER_EVENTS = { /** * Fired when a new root or child tile set is loaded * @event OGC3DTilesLayer#load-tile-set * @type {Object} * @property {Object} tileset - the tileset json parsed in an Object * @property {String} url - tileset url */ LOAD_TILE_SET: 'load-tile-set', /** * Fired when a tile model is loaded * @event OGC3DTilesLayer#load-model * @type {Object} * @property {THREE.Group} scene - the model (tile content) parsed in a THREE.GROUP * @property {Object} tile - the tile metadata from the tileset */ LOAD_MODEL: 'load-model', /** * Fired when a tile model is disposed * @event OGC3DTilesLayer#dispose-model * @type {Object} * @property {THREE.Group} scene - the model (tile content) that is disposed * @property {Object} tile - the tile metadata from the tileset */ DISPOSE_MODEL: 'dispose-model', /** * Fired when a tiles visibility changes * @event OGC3DTilesLayer#tile-visibility-change * @type {Object} * @property {THREE.Group} scene - the model (tile content) parsed in a THREE.GROUP * @property {Object} tile - the tile metadata from the tileset * @property {boolean} visible - the tile visible state */ TILE_VISIBILITY_CHANGE: 'tile-visibility-change', /** * Fired when a new batch of tiles start loading (can be fired multiple times, e.g. when the camera moves and new tiles * start loading) * @event OGC3DTilesLayer#tiles-load-start */ TILES_LOAD_START: 'tiles-load-start', /** * Fired when all visible tiles are loaded (can be fired multiple times, e.g. when the camera moves and new tiles * are loaded) * @event OGC3DTilesLayer#tiles-load-end */ TILES_LOAD_END: 'tiles-load-end' }; /** * Enable loading 3D Tiles with [Draco](https://google.github.io/draco/) geometry extension. * * @param {String} path path to draco library folder containing the JS and WASM decoder libraries. They can be found in * [itowns examples](https://github.com/iTowns/itowns/tree/master/examples/libs/draco). * @param {Object} [config] optional configuration for Draco decoder (see threejs' * [setDecoderConfig](https://threejs.org/docs/index.html?q=draco#examples/en/loaders/DRACOLoader.setDecoderConfig) that * is called under the hood with this configuration for details. */ export function enableDracoLoader(path, config) { if (!path) { throw new Error('Path to draco folder is mandatory'); } const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath(path); if (config) { dracoLoader.setDecoderConfig(config); } itownsGLTFLoader.setDRACOLoader(dracoLoader); } /** * Enable loading 3D Tiles with [KTX2](https://www.khronos.org/ktx/) texture extension. * * @param {String} path path to ktx2 library folder containing the JS and WASM decoder libraries. They can be found in * [itowns examples](https://github.com/iTowns/itowns/tree/master/examples/libs/basis). * @param {THREE.WebGLRenderer} renderer the threejs renderer */ export function enableKtx2Loader(path, renderer) { if (!path || !renderer) { throw new Error('Path to ktx2 folder and renderer are mandatory'); } const ktx2Loader = new KTX2Loader(); ktx2Loader.setTranscoderPath(path); ktx2Loader.detectSupport(renderer); itownsGLTFLoader.setKTX2Loader(ktx2Loader); } /** * Enable loading 3D Tiles and GLTF with * [meshopt](https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Vendor/EXT_meshopt_compression/README.md) compression extension. * * @param {MeshOptDecoder.constructor} MeshOptDecoder - The Meshopt decoder * module. * * @example * import * as itowns from 'itowns'; * import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; * * // Enable support of EXT_meshopt_compression * itowns.enableMeshoptDecoder(MeshoptDecoder); */ export function enableMeshoptDecoder(MeshOptDecoder) { if (!MeshOptDecoder) { throw new Error('MeshOptDecoder module is mandatory'); } itownsGLTFLoader.setMeshoptDecoder(MeshOptDecoder); } async function getMeshFeatures(meshFeatures, options) { const { faceIndex, barycoord } = options; const features = await meshFeatures.getFeaturesAsync(faceIndex, barycoord); return { features, featureIds: meshFeatures.getFeatureInfo() }; } function getStructuralMetadata(structuralMetadata, options) { const { index, faceIndex, barycoord, tableIndices, features } = options; const tableData = []; if (tableIndices !== undefined && features !== undefined) { structuralMetadata.getPropertyTableData(tableIndices, features, tableData); } const attributeData = []; if (index !== undefined) { structuralMetadata.getPropertyAttributeData(index, attributeData); } const textureData = []; if (faceIndex !== undefined) { structuralMetadata.getPropertyTextureData(faceIndex, barycoord, textureData); } const metadata = [...tableData, ...textureData, ...attributeData]; return metadata; } async function getMetadataFromIntersection(intersection) { const { point, object, face, faceIndex } = intersection; const { meshFeatures, structuralMetadata } = object.userData; const barycoord = new THREE.Vector3(); if (face) { const position = object.geometry.getAttribute('position'); const triangle = new THREE.Triangle().setFromAttributeAndIndices(position, face.a, face.b, face.c); triangle.a.applyMatrix4(object.matrixWorld); triangle.b.applyMatrix4(object.matrixWorld); triangle.c.applyMatrix4(object.matrixWorld); triangle.getBarycoord(point, barycoord); } else { barycoord.set(0, 0, 0); } // EXT_mesh_features const { features, featureIds } = meshFeatures ? await getMeshFeatures(meshFeatures, { faceIndex, barycoord }) : {}; const tableIndices = featureIds?.map(p => p.propertyTable); // EXT_structural_metadata const metadata = structuralMetadata ? getStructuralMetadata(structuralMetadata, { ...intersection, barycoord, tableIndices, features }) : []; return metadata; } class OGC3DTilesLayer extends GeometryLayer { /** * Layer for [3D Tiles](https://www.ogc.org/standard/3dtiles/) datasets. * * Advanced configuration note: 3D Tiles rendering is delegated to 3DTilesRendererJS that exposes several * configuration options accessible through the tilesRenderer property of this class. see the * [3DTilesRendererJS doc](https://github.com/NASA-AMMOS/3DTilesRendererJS/blob/master/README.md). Also note that * the cache is shared amongst 3D tiles layers and can be configured through tilesRenderer.lruCache (see the * [following documentation](https://github.com/NASA-AMMOS/3DTilesRendererJS/blob/master/README.md#lrucache-1). * * @extends Layer * * @param {String} id - unique layer id. * @param {Object} config - layer specific configuration * @param {OGC3DTilesSource} config.source - data source configuration * @param {String} [config.pntsMode = PNTS_MODE.COLOR] Point cloud coloring mode (passed to {@link PointsMaterial}). * Only 'COLOR' or 'CLASSIFICATION' are possible. COLOR uses RGB colors of the points, * CLASSIFICATION uses a classification property of the batch table to color points. * @param {ClassificationScheme} [config.classificationScheme = ClassificationScheme.DEFAULT] {@link PointsMaterial} classification scheme * @param {String} [config.pntsShape = PNTS_SHAPE.CIRCLE] Point cloud point shape. Only 'CIRCLE' or 'SQUARE' are possible. * (passed to {@link PointsMaterial}). * @param {String} [config.pntsSizeMode = PNTS_SIZE_MODE.VALUE] {@link PointsMaterial} Point cloud size mode (passed to {@link PointsMaterial}). * Only 'VALUE' or 'ATTENUATED' are possible. VALUE use constant size, ATTENUATED compute size depending on distance * from point to camera. * @param {Number} [config.pntsMinAttenuatedSize = 3] Minimum scale used by 'ATTENUATED' size mode. * @param {Number} [config.pntsMaxAttenuatedSize = 10] Maximum scale used by 'ATTENUATED' size mode. */ constructor(id, config) { const { pntsMode = PNTS_MODE.COLOR, pntsShape = PNTS_SHAPE.CIRCLE, classification = ClassificationScheme.DEFAULT, pntsSizeMode = PNTS_SIZE_MODE.VALUE, pntsMinAttenuatedSize = 3, pntsMaxAttenuatedSize = 10, ...geometryLayerConfig } = config; super(id, new THREE.Group(), geometryLayerConfig); this.isOGC3DTilesLayer = true; // Store points material config so they can be used later to substitute points tiles material // by our own PointsMaterial. These properties should eventually be managed through the Style API // (see https://github.com/iTowns/itowns/issues/2336) this.pntsMode = pntsMode; this.pntsShape = pntsShape; this.classification = classification; this.pntsSizeMode = pntsSizeMode; this.pntsMinAttenuatedSize = pntsMinAttenuatedSize; this.pntsMaxAttenuatedSize = pntsMaxAttenuatedSize; this.tilesRenderer = new TilesRenderer(this.source.url); if (config.source.isOGC3DTilesIonSource) { this.tilesRenderer.registerPlugin(new CesiumIonAuthPlugin({ apiToken: config.source.accessToken, assetId: config.source.assetId, autoRefreshToken: true })); } else if (config.source.isOGC3DTilesGoogleSource) { this.tilesRenderer.registerPlugin(new GoogleCloudAuthPlugin({ apiToken: config.source.key, autoRefreshToken: true })); } this.tilesRenderer.registerPlugin(new ImplicitTilingPlugin()); this.tilesRenderer.manager.addHandler(/\.gltf$/, itownsGLTFLoader); this.object3d.add(this.tilesRenderer.group); // Add an initialization step that is resolved when the root tileset is loaded (see this._setup below), meaning // that the layer will be marked ready when the tileset has been loaded. this._res = this.addInitializationStep(); /** * @type {number} */ this.sseThreshold = this.tilesRenderer.errorTarget; Object.defineProperty(this, 'sseThreshold', { get() { return this.tilesRenderer.errorTarget; }, set(value) { this.tilesRenderer.errorTarget = value; } }); if (config.sseThreshold) { this.sseThreshold = config.sseThreshold; } } /** * Sets the lruCache and download and parse queues so they are shared amongst * all tilesets from a same {@link View} view. * @param {View} view - view associated to this layer. * @private */ _setupCacheAndQueues(view) { const id = view.id; if (viewers[id]) { this.tilesRenderer.lruCache = viewers[id].lruCache; this.tilesRenderer.downloadQueue = viewers[id].downloadQueue; this.tilesRenderer.parseQueue = viewers[id].parseQueue; } else { viewers[id] = { lruCache: this.tilesRenderer.lruCache, downloadQueue: this.tilesRenderer.downloadQueue, parseQueue: this.tilesRenderer.parseQueue }; view.addEventListener(VIEW_EVENTS.DISPOSED, evt => { delete viewers[evt.target.id]; }); } } /** * Binds 3d-tiles-renderer events to this layer. * @private */ _setupEvents() { for (const ev of Object.values(OGC3DTILES_LAYER_EVENTS)) { this.tilesRenderer.addEventListener(ev, e => { this.dispatchEvent(e); }); } } /** * Setup 3D tiles renderer js TilesRenderer with the camera, binds events and start updating. Executed when the * layer has been added to the view. * @param {View} view - the view the layer has been added to. * @private */ _setup(view) { this.tilesRenderer.setCamera(view.camera3D); this.tilesRenderer.setResolutionFromRenderer(view.camera3D, view.renderer); // Setup whenReady to be fullfiled when the root tileset has been loaded let rootTilesetLoaded = false; this.tilesRenderer.addEventListener('load-tile-set', () => { view.notifyChange(this); if (!rootTilesetLoaded) { rootTilesetLoaded = true; this._res(); } }); this.tilesRenderer.addEventListener('load-model', e => { const { scene } = e; scene.traverse(obj => { this._assignFinalMaterial(obj); this._assignFinalAttributes(obj); }); view.notifyChange(this); }); this._setupCacheAndQueues(view); this._setupEvents(); // Start loading tileset and tiles this.tilesRenderer.update(); } /** * Replace materials from GLTFLoader by our own custom materials. Note that * the replaced materials are not compiled yet and will be disposed by the * GC. * @param {Object3D} model * @private */ _assignFinalMaterial(model) { let material = model.material; if (model.isPoints) { const pointsMaterial = new PointsMaterial({ mode: this.pntsMode, shape: this.pntsShape, classificationScheme: this.classification, sizeMode: this.pntsSizeMode, minAttenuatedSize: this.pntsMinAttenuatedSize, maxAttenuatedSize: this.pntsMaxAttenuatedSize }); pointsMaterial.copy(material); material = pointsMaterial; } if (material) { ReferLayerProperties(material, this); } model.material = material; } /** * @param {Object3D} model * @private */ _assignFinalAttributes(model) { const geometry = model.geometry; const batchTable = model.batchTable; // Setup classification bufferAttribute if (model.isPoints) { const classificationData = batchTable?.getPropertyArray('Classification'); if (classificationData) { geometry.setAttribute('classification', new THREE.BufferAttribute(classificationData, 1)); } } } preUpdate(context) { this.scale = context.camera._preSSE; this.tilesRenderer.update(); return null; // don't return any element because 3d-tiles-renderer already updates them } update() { // empty, elements are updated by 3d-tiles-renderer } /** * Deletes the layer and frees associated memory */ delete() { this.tilesRenderer.dispose(); } /** * Get the [metadata](https://github.com/CesiumGS/3d-tiles/tree/main/specification/Metadata) * of the closest intersected object from a list of intersections. * * This method retrieves structured metadata stored in GLTF 2.0 assets using * the [`EXT_structural_metadata`](https://github.com/CesiumGS/glTF/tree/3d-tiles-next/extensions/2.0/Vendor/EXT_structural_metadata) * extension. * * Internally, it uses the closest intersected point to index metadata * stored in property attributes and textures. * * If present in GLTF 2.0 assets, this method leverages the * [`EXT_mesh_features`](`https://github.com/CesiumGS/glTF/tree/3d-tiles-next/extensions/2.0/Vendor/EXT_mesh_features) * extension and the returned featured to index metadata stored in property tables. * * @param {Array<THREE.Intersection>} intersections * @returns {Promise<Object | null>} - the intersected object's metadata */ async getMetadataFromIntersections(intersections) { if (!intersections.length) { return null; } const metadata = await getMetadataFromIntersection(intersections[0]); return metadata; } /** * Get the attributes for the closest intersection from a list of * intersects. * @param {Array<THREE.Intersection>} intersects - An array containing all * objects picked under mouse coordinates computed with view.pickObjectsAt(..). * @returns {Object | null} - An object containing */ getC3DTileFeatureFromIntersectsArray(intersects) { if (!intersects.length) { return null; } const { face, index, object, instanceId } = intersects[0]; /** @type{number|null} */ let batchId; if (object.isPoints && index) { batchId = object.geometry.getAttribute('_BATCHID')?.getX(index) ?? index; } else if (object.isMesh && face) { batchId = object.geometry.getAttribute('_BATCHID')?.getX(face.a) ?? instanceId; } if (batchId === undefined) { return null; } let tileObject = object; while (!tileObject.batchTable) { tileObject = tileObject.parent; } return tileObject.batchTable.getDataFromId(batchId); } /** * Get all 3D objects (mesh and points primitives) as intersects at the * given non-normalized screen coordinates. * @param {View} view - The view instance. * @param {THREE.Vector2} coords - The coordinates to pick in the view. It * should have at least `x` and `y` properties. * @param {number} radius - Radius of the picking circle. * @param {Array} [target=[]] - Target array to push results too * @returns {Array} Array containing all target objects picked under the * specified coordinates. */ pickObjectsAt(view, coords) { let target = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : []; const camera = view.camera.camera3D; _raycaster.setFromCamera(view.viewToNormalizedCoords(coords), camera); _raycaster.near = camera.near; _raycaster.far = camera.far; _raycaster.firstHitOnly = true; const picked = _raycaster.intersectObject(this.tilesRenderer.group, true); // Store the layer of the picked object to conform to the interface of what's returned by Picking.js (used for // other GeometryLayers picked.forEach(p => { p.layer = this; }); target.push(...picked); return target; } // eslint-disable-next-line no-unused-vars attach() { console.warn('[OGC3DTilesLayer]: Attaching / detaching layers is not yet implemented for OGC3DTilesLayer.'); } // eslint-disable-next-line no-unused-vars detach() { console.warn('[OGC3DTilesLayer]: Attaching / detaching layers is not yet implemented for OGC3DTilesLayer.'); return true; } // eslint-disable-next-line no-unused-vars getObjectToUpdateForAttachedLayers() { return null; } /** * Executes a callback for each tile of this layer tileset. * * @param {Function} callback The callback to execute for each tile. Has the following two parameters: * 1. tile (Object) - the JSON tile * 2. scene (THREE.Object3D | null) - The tile content. Contains a `batchTable` property. Can be null if the tile * has not yet been loaded. */ forEachTile(callback) { this.tilesRenderer.traverse(tile => { callback(tile, tile.cached.scene); }); } } export default OGC3DTilesLayer;