@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
381 lines (309 loc) • 13.3 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type { Binary, View } from 'copc';
import type { BufferAttribute } from 'three';
import { Las } from 'copc';
import { Header } from 'copc/lib/las';
import { Binary as BinaryUtils } from 'copc/lib/utils/binary';
import { Box3, Float32BufferAttribute, Vector3 } from 'three';
import type { GetMemoryUsageContext } from '../core/MemoryUsage';
import type { CommonOptions } from './las/CommonOptions';
import type { DimensionName } from './las/dimension';
import type {
GetNodeDataOptions,
PointCloudMetadata,
PointCloudNode,
PointCloudNodeData,
} from './PointCloudSource';
import CoordinateSystem from '../core/geographic/CoordinateSystem';
import OperationCounter from '../core/OperationCounter';
import { defer } 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 { getPerPointFilters, type DimensionFilter } from './las/filter';
import LASWorkerPool from './las/LASWorkerPool';
import { createBufferAttribute, readColor, readPosition, readScalarAttribute } from './las/readers';
import { createLasView } from './las/worker';
import { PointCloudSourceBase } from './PointCloudSource';
export type Getter = () => Promise<Uint8Array>;
/**
* Inject Fetcher into copc.js to perform range requests.
*/
const getter: (url: string) => Getter = url => {
return async () => {
const blob = await Fetcher.blob(url);
const arrayBuffer = await blob.arrayBuffer();
return new Uint8Array(arrayBuffer);
};
};
async function decodeLazFileSync(data: Uint8Array): Promise<Uint8Array> {
const lazPerf = await getLazPerf();
return Las.PointData.decompressFile(data, lazPerf);
}
async function decodeLazFileUsingWorker(data: Uint8Array): Promise<Uint8Array> {
const pool = await LASWorkerPool.get();
return pool
.queue('DecodeLazFile', { buffer: data.buffer }, [data.buffer])
.then(res => new Uint8Array(res));
}
export interface LASSourceOptions extends CommonOptions {
/**
* The URL to the remote LAS file, or a function to retrieve the remote file.
*/
url: string | Getter;
}
interface PerfOptions {
decimate: number;
enableWorkers: boolean;
compressColorsToUint8: boolean;
}
/**
* A source that reads from a LAS or LAZ file.
*
* **Note**: if you wish to read Cloud Optimized Point Cloud (COPC) LAZ files, use the COPCSource
* instead.
*
* 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 LASSourceOptions.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).
*
* ### Supported LAS version
*
* This source supports LAS 1.2 and 1.4 only.
*
* ### Decimation
*
* This source supports decimation. By passing the {@link LASSourceOptions.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). By providing filters
* in the form of callback functions to apply to various dimensions, it is possible 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 LASSource(...);
*
* source.filters = [
* { dimension: 'Classification', filter: (val) => val !== 18 },
* { dimension: 'Intensity', filter: (val) => val >= 1000 },
* ];
* ```
*/
export default class LASSource extends PointCloudSourceBase {
public readonly isLASSource = true as const;
public readonly type = 'LASSource' as const;
private readonly _getter: Getter;
private readonly _opCounter = new OperationCounter();
private readonly _filters: DimensionFilter[] = [];
private readonly _options: PerfOptions = {
decimate: 1,
enableWorkers: true,
compressColorsToUint8: true,
};
// Available after initialization
private _header: Header | null = null;
private _volume: Box3 | null = null;
/** The buffer that stores the entire LAS/LAZ file (in compressed form for LAZ files). */
private _buffer: Uint8Array | null = null;
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[]>) {
this._filters.length = 0;
this._filters.push(...v);
this.dispatchEvent({ type: 'updated' });
}
public constructor(options: LASSourceOptions) {
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' ? getter(options.url) : options.url;
}
protected async initializeOnce(): Promise<this> {
this._opCounter.increment();
this._buffer = await this._getter().finally(() => this._opCounter.decrement());
this._header = Header.parse(new Uint8Array(this._buffer));
const { min, max } = this._header;
this._volume = new Box3().set(
new Vector3(min[0], min[1], min[2]),
new Vector3(max[0], max[1], max[2]),
);
return this;
}
private async getView(include?: DimensionName[]): Promise<View> {
this._opCounter.increment();
const data = new Uint8Array(nonNull(this._buffer));
const header = nonNull(this._header);
let decompressed: Binary;
if (this._options.enableWorkers === false) {
decompressed = await decodeLazFileSync(data).finally(() => this._opCounter.decrement());
} else {
decompressed = await decodeLazFileUsingWorker(data).finally(() =>
this._opCounter.decrement(),
);
}
const view = createLasView(decompressed, header, undefined, include);
return view;
}
public async getMetadata(): Promise<PointCloudMetadata> {
const header = nonNull(this._header, 'not initialized');
const view = await this.getView();
const result: PointCloudMetadata = {
pointCount: header.pointCount,
volume: nonNull(this._volume),
attributes: extractAttributes(
view.dimensions,
nonNull(this._volume),
this._options.compressColorsToUint8,
null,
),
};
const buffer = nonNull(this._buffer, 'not initialized');
const getBufferChunk = (begin: number, end: number): Uint8Array => {
if (end >= buffer.byteLength) {
throw new Error();
}
return new Uint8Array(buffer.slice(begin, end));
};
const getBufferChunkAsync = (begin: number, end: number): Promise<Uint8Array> => {
return Promise.resolve(getBufferChunk(begin, end));
};
const vlrs = await Las.Vlr.walk(getBufferChunkAsync, header);
const wktVlr = Las.Vlr.find(vlrs, 'LASF_Projection', 2112);
// There are a few corner-case possibilities here. Although the LAS 1.4 spec
// says that this must be a null-terminated string, some files in the wild
// exist with a zero content-length. We also want to consider the case of an
// empty string which *does* include null-termination as a missing SRS.
if (wktVlr && wktVlr.contentLength) {
const wktVlrBegin = wktVlr.contentOffset;
const wktVlrEnd = wktVlrBegin + wktVlr.contentLength;
const wkt = BinaryUtils.toCString(getBufferChunk(wktVlrBegin, wktVlrEnd));
if (wkt !== null && wkt) {
try {
result.crs = CoordinateSystem.fromWkt(wkt);
} catch (error: unknown) {
console.error(`Failed to parse WKT for LAS "${this.id}": `, error);
}
}
}
return Promise.resolve(result);
}
public async getHierarchy(): Promise<PointCloudNode> {
const { min, max, pointCount } = nonNull(this._header, 'not initialized');
const volume = new Box3().set(
new Vector3(min[0], min[1], min[2]),
new Vector3(max[0], max[1], max[2]),
);
const uniqueNode: PointCloudNode = {
depth: 0,
volume,
id: 'root',
hasData: true,
geometricError: 0,
center: volume.getCenter(new Vector3()),
sourceId: this.id,
pointCount,
};
return uniqueNode;
}
public async getNodeData(params: GetNodeDataOptions): Promise<PointCloudNodeData> {
const requestedAttributes = params.attributes ?? [];
const dimensions = getDimensionsToRead(requestedAttributes, params.position, this._filters);
const view = await this.getView(dimensions);
const { min } = nonNull(this._volume);
const origin = min.clone();
const stride = this._options.decimate ?? 1;
const signal = params.signal;
const filters = getPerPointFilters(this._filters, view);
const compressColors = this._options.compressColorsToUint8;
this._opCounter.increment(requestedAttributes.length);
let positionBuffer: BufferAttribute | undefined = undefined;
let localBoundingBox: Box3 | undefined = undefined;
if (params.position) {
this._opCounter.increment();
const result = await defer(() => readPosition(view, origin, stride, filters)).finally(
() => this._opCounter.decrement(),
);
positionBuffer = new Float32BufferAttribute(new Float32Array(result.buffer), 3);
localBoundingBox = result.localBoundingBox;
}
const attributes = await Promise.all(
requestedAttributes.map(async requestedAttribute => {
let action: () => ArrayBuffer;
switch (requestedAttribute.interpretation) {
case 'color':
action = (): ArrayBuffer =>
readColor(view, stride, compressColors, filters);
break;
default:
action = (): ArrayBuffer =>
readScalarAttribute(view, requestedAttribute, stride, filters);
break;
}
const buffer = await defer(action, signal).finally(() =>
this._opCounter.decrement(),
);
return createBufferAttribute(buffer, requestedAttribute, compressColors);
}),
);
return Promise.resolve({
origin,
pointCount: positionBuffer?.count ?? attributes[0]?.count,
localBoundingBox,
position: positionBuffer,
attributes,
});
}
public getMemoryUsage(context: GetMemoryUsageContext): void {
// We have to store the whole file in memory, since there is no guarantee that the
// remote server supports range requests (which is a requirement for COPC files for example)
if (this._buffer != null) {
context.objects.set(this.id, { cpuMemory: this._buffer.byteLength, gpuMemory: 0 });
}
}
public dispose(): void {
// Nothing to do
}
}