UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

465 lines (450 loc) 16.4 kB
/* * Copyright (c) 2015-2018, IGN France. * Copyright (c) 2018-2026, Giro3D team. * SPDX-License-Identifier: MIT */ import { Copc, Las } from 'copc'; import { Box3, BufferAttribute, Vector3 } from 'three'; import { GlobalCache } from '../core/Cache'; import CoordinateSystem from '../core/geographic/CoordinateSystem'; import * as Octree from '../core/Octree'; import OperationCounter from '../core/OperationCounter'; import RequestQueue from '../core/RequestQueue'; import Fetcher from '../utils/Fetcher'; import { nonNull } from '../utils/tsutils'; import { getLazPerf } from './las/config'; import { extractAttributes, getDimensionsToRead } from './las/dimension'; import LASWorkerPool from './las/LASWorkerPool'; import { createBufferAttribute } from './las/readers'; import { createLasView, readView } from './las/worker'; import { PointCloudSourceBase } from './PointCloudSource'; const deduplicatedQueue = new RequestQueue(); /** * Inject Fetcher into copc.js to perform range requests. */ const createGetter = url => { return async (begin, end) => { const blob = await Fetcher.blob(url, { headers: { Range: `bytes=${begin}-${end - 1}` } }); const arrayBuffer = await blob.arrayBuffer(); return new Uint8Array(arrayBuffer); }; }; async function decodeLazChunkSync(chunk, metadata) { const lp = await getLazPerf(); return Las.PointData.decompressChunk(chunk, metadata, lp); } async function decodeLazChunkUsingWorker(chunk, metadata) { const pool = await LASWorkerPool.get(); return pool.queue('DecodeLazChunk', { buffer: chunk.buffer, metadata }, [chunk.buffer]).then(res => new Uint8Array(res)); } /** * Data acquired from the remote file during initialization. */ const tmpCenter = new Vector3(); const tmpSize = new Vector3(); function createChild(sourceId, nodes, node, geometricError, qx, qy, qz) { const depth = node.depth + 1; const x = node.x * 2 + qx; const y = node.y * 2 + qy; const z = node.z * 2 + qz; const id = `${depth}-${x}-${y}-${z}`; const childNode = nodes.get(id); if (!childNode) { return undefined; } const parentCenter = node.volume.getCenter(tmpCenter); const halfSize = node.volume.getSize(tmpSize).divideScalar(2); const sign = v => v === 0 ? -1 : 0; const minx = parentCenter.x + halfSize.x * sign(qx); const miny = parentCenter.y + halfSize.y * sign(qy); const minz = parentCenter.z + halfSize.z * sign(qz); const min = new Vector3(minx, miny, minz); const max = min.clone().add(halfSize); const volume = new Box3(min, max); const center = volume.getCenter(new Vector3()); const child = Octree.create({ depth, x, y, z, id, center, pointCount: childNode.pointCount, geometricError, hasData: childNode.pointCount > 0, sourceId, volume }, volume, node); return child; } async function loadSubtree(getter, root, nodeMap) { const { nodes, pages } = await Copc.loadHierarchyPage(getter, root); for (const [id, node] of Object.entries(nodes)) { nodeMap.set(id, node); } for (const page of Object.values(pages)) { if (page) { await loadSubtree(getter, page, nodeMap); } } } /** * A source that reads from a remote [Cloud Optimized Point Cloud (COPC)](https://copc.io/) LAS file. * * LAZ decompression is done in background threads using workers. If you wish to disable workers * (for a noticeable cost in performance), you can set {@link COPCSourceOptions.enableWorkers} to * `false` in constructor options. * * > [!note] * > This source uses the **laz-perf** package to perform decoding of point cloud data. This * > package uses WebAssembly. If you wish to override the path to the required .wasm file, use * > [setLazPerfPath()](../functions/sources_las_config.setLazPerfPath.html) before using this source. * > The default path is [DEFAULT_LAZPERF_PATH](../variables/sources_las_config.DEFAULT_LAZPERF_PATH.html). * * ### Decimation * * This source supports decimation. By passing the {@link COPCSourceOptions.decimate} argument to * a value other than 1, every Nth point will be kept and other points will be discarded during * read operations. * * ### Dimensions filtering * * This source supports filtering over dimensions (also known as attributes) to eliminate points * during reads. For example, it is possible to remove unwanted classifications such as noise * from the output points. * * Note that dimension filtering is independent from the selected attribute. In other words, it is * possible to select the dimension `"Intensity"`, while filtering on dimensions `"Classification"` * and `"ReturnNumber"` for example. * * For example, if we wish to remove all points that have the dimension "High noise" (dimension 18 * in the ASPRS classification list), as well as removing all points whose intensity is lower than * 1000: * * ```ts * const source = new COPCSource(...); * * source.filters = [ * { dimension: 'Classification', operator: 'not', value: 18 }, * { dimension: 'Intensity', operator: 'greaterequal', value: 1000 }, * ]; * ``` */ export default class COPCSource extends PointCloudSourceBase { /** Readonly flag to indicate that this object is a COPCSource. */ isCOPCSource = true; type = 'COPCSource'; _opCounter = new OperationCounter(); _nodeMap = new Map(); _filters = []; _options = { decimate: 1, enableWorkers: true, compressColorsToUint8: true }; // Available after initialization get loading() { return this._opCounter.loading; } get progress() { return this._opCounter.progress; } /** * Gets or sets the dimension filters. * @defaultValue `[]` */ get filters() { return this._filters; } set filters(v) { this._filters.length = 0; if (v != null) { this._filters.push(...v); } this.dispatchEvent({ type: 'updated' }); } constructor(options) { super(); this._opCounter.addEventListener('changed', () => this.dispatchEvent({ type: 'progress' })); this._options.compressColorsToUint8 = options.compressColorsTo8Bit ?? this._options.compressColorsToUint8; this._options.decimate = options.decimate ?? 1; if (this._options.decimate < 1) { throw new Error('decimate should be at least 1'); } this._options.enableWorkers = options.enableWorkers ?? true; if (options.filters != null && options.filters.length > 0) { this._filters.push(...options.filters); } this._getter = typeof options.url === 'string' ? createGetter(options.url) : options.url; } async initializeOnce() { const counter = this._opCounter; // Pre-increment for the upcoming operations counter.increment(3); const copc = await Copc.create(this._getter).finally(() => counter.decrement()); const [minx, miny, minz] = copc.header.min; const [maxx, maxy, maxz] = copc.header.max; const volume = new Box3(new Vector3(minx, miny, minz), new Vector3(maxx, maxy, maxz)); const nodes = new Map(); await loadSubtree(this._getter, copc.info.rootHierarchyPage, nodes).finally(() => counter.decrement()); const rootNode = nonNull(nodes.get('0-0-0-0'), 'FATAL: no root node in the LAS file.'); const rootView = await this.loadPointDataView(this._getter, copc, rootNode).finally(() => counter.decrement()); this._data = { copc, nodes, volume, dimensions: rootView.dimensions }; return this; } getMetadata() { const remoteData = this.ensureInitialized(); let crs = CoordinateSystem.unknown; if (typeof remoteData.copc.wkt !== 'undefined') { try { crs = CoordinateSystem.fromWkt(remoteData.copc.wkt); } catch (error) { console.error(`Failed to parse WKT for COPC "${this.id}": `, error); } } const result = { pointCount: remoteData.copc.header.pointCount, attributes: extractAttributes(remoteData.dimensions, remoteData.volume, this._options.compressColorsToUint8, remoteData.copc.info.gpsTimeRange), volume: remoteData.volume, crs }; return Promise.resolve(result); } getHierarchy() { const { copc, nodes } = this.ensureInitialized(); const [xmin, ymin, zmin, xmax, ymax, zmax] = copc.info.cube; const volume = new Box3(new Vector3(xmin, ymin, zmin), new Vector3(xmax, ymax, zmax)); const rootNode = nonNull(nodes.get('0-0-0-0')); const rootGeometricError = copc.info.spacing; const root = Octree.create({ depth: 0, x: 0, y: 0, z: 0, id: '0-0-0-0', volume, center: volume.getCenter(new Vector3()), pointCount: rootNode.pointCount, geometricError: rootGeometricError, hasData: rootNode.pointCount > 0, sourceId: this.id }, volume); const createChildren = node => { const geometricError = rootGeometricError / 2 ** (node.depth + 1); return [ // bottom nodes createChild(this.id, nodes, node, geometricError, 0, 0, 0), createChild(this.id, nodes, node, geometricError, 1, 0, 0), createChild(this.id, nodes, node, geometricError, 1, 1, 0), createChild(this.id, nodes, node, geometricError, 0, 1, 0), // top nodes createChild(this.id, nodes, node, geometricError, 0, 0, 1), createChild(this.id, nodes, node, geometricError, 1, 0, 1), createChild(this.id, nodes, node, geometricError, 1, 1, 1), createChild(this.id, nodes, node, geometricError, 0, 1, 1)]; }; Octree.populate(root, createChildren); Octree.traverse(root, n => { this._nodeMap.set(n.id, n); return true; }); return Promise.resolve(root); } async getNodeData(params) { const { nodes, copc } = this.ensureInitialized(); const id = params.node.id; const priority = -params.node.depth; const node = nodes.get(id); if (!node) { throw new Error('no such node: ' + id); } const signal = params.signal; signal?.throwIfAborted(); const paramsAttributes = params.attributes ?? []; const dimensions = getDimensionsToRead(paramsAttributes, params.position, this._filters); const octree = nonNull(this._nodeMap.get(id)); const stride = this._options.decimate; const { x, y, z } = params.node.center; const metadata = { pointCount: node.pointCount, pointDataRecordFormat: copc.header.pointDataRecordFormat, pointDataRecordLength: copc.header.pointDataRecordLength }; let result; // Note: this source is heavily optimized to avoid loading unnecessary data, such // as position buffers when only attribute buffers are requested. // This means that some position-related metadata, such as bounding box, are not available // when position is not requested. // Generally, position data will be requested once, when the point cloud is being created // for the first time. Switching the optional attribute should not require the recomputation // of the position buffer, as they are completely independent. // However, keep in mind that changing the _filters_ must recreate everything, position // buffer included, as it can change the total number of points returned by the source. // Note 2: since the view buffer is stored in the cache, requesting another attribute for // the same node should be very fast, as no HTTP request should be emitted (provided of // course that the cache has not been cleared in the mean time). if (this._options.enableWorkers) { result = await this.loadNodeDataWithWorker(node, priority, signal, copc, metadata, params, x, y, z, dimensions, stride); } else { result = await this.loadNodeData(copc, node, dimensions, priority, signal, x, y, z, stride, params); } signal?.throwIfAborted(); let position = undefined; let localBoundingBox = undefined; if (result.position) { position = new BufferAttribute(new Float32Array(result.position.buffer), 3); const [minx, miny, minz, maxx, maxy, maxz] = result.position.localBoundingBox; localBoundingBox = new Box3(new Vector3(minx, miny, minz), new Vector3(maxx, maxy, maxz)); } const bufferAttributes = paramsAttributes.map((paramAttribute, index) => { const resultAttribute = result.attributes[index]; if (resultAttribute != null) { return createBufferAttribute(resultAttribute, paramAttribute, this._options.compressColorsToUint8); } }); return { pointCount: position?.count ?? bufferAttributes[0]?.count, origin: octree.center, localBoundingBox, position, attributes: bufferAttributes }; } async loadNodeData(copc, node, dimensions, priority, signal, x, y, z, stride, params) { const view = await this._opCounter.wrap(this.loadPointDataView(this._getter, copc, node, dimensions, priority, signal)); signal?.throwIfAborted(); const result = readView({ view, origin: { x, y, z }, stride, position: params.position, attributes: params.attributes ?? [], compressColors: this._options.compressColorsToUint8, filters: this._filters }); return result; } async loadNodeDataWithWorker(node, priority, signal, copc, metadata, params, x, y, z, dimensions, stride) { const buffer = await this._opCounter.wrap(this.loadPointDataViewBuffer(this._getter, node, priority)); signal?.throwIfAborted(); // We have to clone the buffer to avoid poisoning the cache with an unuseable detached buffer const actualBuffer = buffer.slice(0); try { this._opCounter.increment(); const pool = await LASWorkerPool.get(); return await pool.queue('ReadView', { buffer: actualBuffer, header: copc.header, metadata, position: params.position, origin: { x, y, z }, include: dimensions, filters: this._filters, eb: copc.eb, stride, attributes: params.attributes ?? [], compressColors: this._options.compressColorsToUint8 }, [actualBuffer]); } finally { this._opCounter.decrement(); } } ensureInitialized() { if (!this._data) { throw new Error('not initialized'); } return this._data; } /** * Loads a view buffer. */ async loadPointDataViewBuffer(getter, node, priority) { const { pointDataOffset, pointDataLength } = node; const cacheKey = `${this.id}-${pointDataOffset}-${pointDataLength}`; const cached = GlobalCache.get(cacheKey); if (cached != null) { return cached.buffer; } return deduplicatedQueue.enqueue({ id: cacheKey, priority, request: async () => { const chunk = await getter(pointDataOffset, pointDataOffset + pointDataLength); GlobalCache.set(cacheKey, chunk, { size: chunk.byteLength }); return chunk.buffer; } }); } /** * Loads a view and delegate LAZ decoding into a worker. */ async loadPointDataView(getter, copc, node, include, priority, signal) { const buffer = await this.loadPointDataViewBuffer(getter, node, priority); signal?.throwIfAborted(); let decoded; if (this._options.enableWorkers) { // Note that we have to clone the buffer since we send it to the worker // and we want this buffer to be reusable for subsequent requests if necessary const chunk = new Uint8Array(buffer.slice(0)); decoded = await decodeLazChunkUsingWorker(chunk, { pointCount: node.pointCount, pointDataRecordFormat: copc.header.pointDataRecordFormat, pointDataRecordLength: copc.header.pointDataRecordLength }); } else { const chunk = new Uint8Array(buffer); decoded = await decodeLazChunkSync(chunk, { pointCount: node.pointCount, pointDataRecordFormat: copc.header.pointDataRecordFormat, pointDataRecordLength: copc.header.pointDataRecordLength }); } signal?.throwIfAborted(); return createLasView(decoded, copc.header, copc.eb, include); } getMemoryUsage() { // No memory usage. } dispose() { // Nothing to dispose. } } export function isCOPCSource(obj) { return obj.isCOPCSource === true; }