@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
694 lines (576 loc) • 22.2 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type { Dimension, Getter, Hierarchy, View } from 'copc';
import { Copc, Las } from 'copc';
import { Box3, BufferAttribute, Vector3 } from 'three';
import type { CommonOptions } from './las/CommonOptions';
import type { DimensionName } from './las/dimension';
import type { ReadViewResult } from './las/worker';
import type {
GetNodeDataOptions,
PointCloudMetadata,
PointCloudNode,
PointCloudNodeData,
} from './PointCloudSource';
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 { type DimensionFilter } from './las/filter';
import LASWorkerPool from './las/LASWorkerPool';
import { createBufferAttribute } from './las/readers';
import { createLasView, readView, type Metadata } from './las/worker';
import { PointCloudSourceBase } from './PointCloudSource';
const deduplicatedQueue = new RequestQueue();
/**
* Inject Fetcher into copc.js to perform range requests.
*/
const createGetter: (url: string) => Getter = 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);
};
};
interface PerfOptions {
decimate: number;
enableWorkers: boolean;
compressColorsToUint8: boolean;
}
async function decodeLazChunkSync(chunk: Uint8Array, metadata: Metadata): Promise<Uint8Array> {
const lp = await getLazPerf();
return Las.PointData.decompressChunk(chunk, metadata, lp);
}
async function decodeLazChunkUsingWorker(
chunk: Uint8Array,
metadata: Metadata,
): Promise<Uint8Array> {
const pool = await LASWorkerPool.get();
return pool
.queue('DecodeLazChunk', { buffer: chunk.buffer, metadata }, [chunk.buffer])
.then(res => new Uint8Array(res));
}
export interface COPCSourceOptions extends CommonOptions {
/**
* The URL to the remote COPC LAS file, or a copc.js `Getter` function to directly access the file byte range.
*/
url: string | Getter;
}
/**
* Data acquired from the remote file during initialization.
*/
interface RemoteData {
copc: Copc;
/**
* The actual volume of the LAS file. This is different from the volume of the octree.
*/
volume: Box3;
nodes: Map<string, Hierarchy.Node>;
dimensions: Dimension.Map;
}
interface NodeInternalData extends PointCloudNode {
x: number;
y: number;
z: number;
depth: number;
}
const tmpCenter = new Vector3();
const tmpSize = new Vector3();
function createChild(
sourceId: string,
nodes: Map<string, Hierarchy.Node>,
node: Octree.Octree<NodeInternalData>,
geometricError: number,
qx: 0 | 1,
qy: 0 | 1,
qz: 0 | 1,
): Octree.Octree<NodeInternalData> | undefined {
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: number): number => (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<NodeInternalData>(
{
depth,
x,
y,
z,
id,
center,
pointCount: childNode.pointCount,
geometricError,
hasData: childNode.pointCount > 0,
sourceId,
volume,
},
volume,
node,
);
return child;
}
async function loadSubtree(
getter: Getter,
root: Hierarchy.Page,
nodeMap: Map<string, Hierarchy.Node | undefined>,
): Promise<void> {
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. */
public readonly isCOPCSource = true as const;
public readonly type = 'COPCSource';
private readonly _getter: Getter;
private readonly _opCounter = new OperationCounter();
private readonly _nodeMap: Map<string, Octree.Octree<NodeInternalData>> = new Map();
private readonly _filters: DimensionFilter[] = [];
private readonly _options: PerfOptions = {
decimate: 1,
enableWorkers: true,
compressColorsToUint8: true,
};
// Available after initialization
private _data?: RemoteData;
public get loading(): boolean {
return this._opCounter.loading;
}
public get progress(): number {
return this._opCounter.progress;
}
/**
* Gets or sets the dimension filters.
* @defaultValue `[]`
*/
public get filters(): Readonly<DimensionFilter[]> {
return this._filters;
}
public set filters(v: Readonly<DimensionFilter[]> | null | undefined) {
this._filters.length = 0;
if (v != null) {
this._filters.push(...v);
}
this.dispatchEvent({ type: 'updated' });
}
public constructor(options: COPCSourceOptions) {
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;
}
protected async initializeOnce(): Promise<this> {
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<string, Hierarchy.Node>();
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;
}
public getMetadata(): Promise<PointCloudMetadata> {
const remoteData = this.ensureInitialized();
let crs = CoordinateSystem.unknown;
if (typeof remoteData.copc.wkt !== 'undefined') {
try {
crs = CoordinateSystem.fromWkt(remoteData.copc.wkt);
} catch (error: unknown) {
console.error(`Failed to parse WKT for COPC "${this.id}": `, error);
}
}
const result: PointCloudMetadata = {
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);
}
public getHierarchy(): Promise<PointCloudNode> {
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<NodeInternalData>(
{
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: Octree.Octree<NodeInternalData>,
) => Octree.ChildrenList<Octree.Octree<NodeInternalData>> | undefined = 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);
}
public async getNodeData(params: GetNodeDataOptions): Promise<PointCloudNodeData> {
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: ReadViewResult;
// 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: BufferAttribute | undefined = undefined;
let localBoundingBox: Box3 | undefined = 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,
};
}
private async loadNodeData(
copc: Copc,
node: Hierarchy.Node,
dimensions: DimensionName[],
priority: number,
signal: AbortSignal | undefined,
x: number,
y: number,
z: number,
stride: number,
params: GetNodeDataOptions,
): Promise<ReadViewResult> {
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;
}
private async loadNodeDataWithWorker(
node: Hierarchy.Node,
priority: number,
signal: AbortSignal | undefined,
copc: Copc,
metadata: {
pointCount: number;
pointDataRecordFormat: number;
pointDataRecordLength: number;
},
params: GetNodeDataOptions,
x: number,
y: number,
z: number,
dimensions: DimensionName[],
stride: number,
): Promise<ReadViewResult> {
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();
}
}
private ensureInitialized(): RemoteData {
if (!this._data) {
throw new Error('not initialized');
}
return this._data;
}
/**
* Loads a view buffer.
*/
private async loadPointDataViewBuffer(
getter: Getter,
node: Hierarchy.Node,
priority?: number,
): Promise<ArrayBufferLike> {
const { pointDataOffset, pointDataLength } = node;
const cacheKey = `${this.id}-${pointDataOffset}-${pointDataLength}`;
const cached = GlobalCache.get(cacheKey);
if (cached != null) {
const buf = cached as Uint8Array;
return buf.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.
*/
private async loadPointDataView(
getter: Getter,
copc: Copc,
node: Hierarchy.Node,
include?: string[],
priority?: number,
signal?: AbortSignal,
): Promise<View> {
const buffer = await this.loadPointDataViewBuffer(getter, node, priority);
signal?.throwIfAborted();
let decoded: Uint8Array;
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);
}
public getMemoryUsage(): void {
// No memory usage.
}
public dispose(): void {
// Nothing to dispose.
}
}
export function isCOPCSource(obj: unknown): obj is COPCSource {
return (obj as COPCSource).isCOPCSource === true;
}