diff --git a/docs/modules/las/README.md b/docs/modules/las/README.md index a0d0a72a1b..d383ed7866 100644 --- a/docs/modules/las/README.md +++ b/docs/modules/las/README.md @@ -2,7 +2,7 @@ The `@loaders.gl/las` module supports the [LASER file format](/docs/modules/las/formats/las) (LAS) and its compressed version (LAZ). -LAZ point formats 0-10 are supported by the opt-in `typescript` backend. Legacy Point10/GPS/RGB/Byte items require LASzip version 2, legacy WavePacket13 uses version 1, and modern items support versions 2-4; support in the `copc` and `laz-rs` backends depends on their bundled decoder versions. The default `laz-perf` backend remains limited to LAS 1.3 and point formats 0-3. See the [LAS/LAZ implementation limits](/docs/modules/las/formats/las#current-implementation-limits) for backend and point-format details. +LAZ point formats 0-10 are supported by the opt-in `typescript` backend for documented LASzip codec combinations. Legacy Point10/GPS/RGB/Byte items require item version 2, WavePacket13 requires version 1, and modern items support versions 2-4; PDRF 9/10 WavePacket14 supports versions 3-4. Arrow output exposes only positions, intensity, classification, and RGB, while the raw APIs preserve complete supported point records. Support in the `copc` and `laz-rs` backends depends on their bundled decoder versions. The default `laz-perf` backend remains limited to LAS 1.3 and point formats 0-3. See the [LAS/LAZ implementation limits](/docs/modules/las/formats/las#current-implementation-limits) for exact codec, point-format, fixture, and streaming details. ## Installation diff --git a/docs/modules/las/api-reference/las-loader.md b/docs/modules/las/api-reference/las-loader.md index 49a8d6e43f..46ab924ff2 100644 --- a/docs/modules/las/api-reference/las-loader.md +++ b/docs/modules/las/api-reference/las-loader.md @@ -45,6 +45,6 @@ const table = await load(url, LASLoader, { ## TypeScript LAZ Streaming -With `las.backend: 'typescript'`, `parseInBatches` consumes compressed LAZ input incrementally. Legacy interleaved PDRF 0-5 chunks can emit complete Arrow batches before the current compressed chunk or file has finished arriving. PDRF 6-10 table parsing emits after complete layered LAZ chunks are available and selectively decodes the field layers represented by the returned Arrow schema without copying compressed field layers into temporary readers. Legacy Point10/GPS/RGB/Byte item version 2, WavePacket13 item version 1, and modern item versions 2-4 are supported. Legacy LASzip item version 1 uses a different codec and is rejected. NIR, PDRF 4/5/9/10 waveform packet references, and Extra Bytes remain available only through raw-record decoding because they are not yet represented in the returned Arrow schema. Waveform sample payloads referenced by those records are not loaded. The raw chunk decoder remains available when complete LAS point records, including currently unexposed fields, are required. +With `las.backend: 'typescript'`, `parseInBatches` consumes compressed LAZ input incrementally. Legacy interleaved PDRF 0-5 chunks can emit complete Arrow batches before the current compressed chunk or file has finished arriving. PDRF 6-10 table parsing emits after complete layered LAZ chunks are available and selectively decodes only the field layers represented by the returned Arrow schema. Complete-buffer `parse` uses the same direct-column path instead of allocating and reparsing complete raw records. Legacy Point10/GPS/RGB/Byte item version 2, WavePacket13 item version 1, and modern item versions 2-4 are supported. Legacy LASzip item version 1 uses a different codec and is rejected. NIR, PDRF 4/5/9/10 waveform packet references, and Extra Bytes remain available only through raw-record decoding because they are not yet represented in the returned Arrow schema. Waveform sample payloads referenced by those records are not loaded. The raw chunk decoder remains available when complete LAS point records, including currently unexposed fields, are required. Legacy point-level progress uses bounded geometric replay: when more compressed bytes arrive, the decoder replays previously emitted points through a record-sized scratch buffer and writes only new points into reusable output batches. This avoids committing partially mutated arithmetic state and bounds retry work to a linear factor of the compressed chunk size, but it retains the compressed chunk until completion. Layered PDRF 6-10 remains chunk-streaming because its independent field ranges must be available before complete rows can be assembled. Variable-size chunk point counts are stored in the LASzip chunk table at EOF, so a forward-only variable-chunk input is buffered until the table is available. See the [LAS/LAZ format implementation limits](/docs/modules/las/formats/las#current-implementation-limits) for supported point formats and remaining limitations. diff --git a/docs/modules/las/api-reference/las-writer.md b/docs/modules/las/api-reference/las-writer.md index 8b6f230215..1efde5d1ba 100644 --- a/docs/modules/las/api-reference/las-writer.md +++ b/docs/modules/las/api-reference/las-writer.md @@ -30,13 +30,17 @@ const arrayBuffer = await encode(pointCloud, LASWriter, { ## Data Format -`LASWriter` accepts Mesh Arrow tables and legacy Mesh objects. Legacy Mesh input is normalized through the Mesh Arrow table conversion path before LAS binary data is encoded. +`LASWriter` accepts Mesh Arrow tables and legacy Mesh objects. Arrow table input is normalized to the writer's Mesh representation before LAS binary data is encoded. -The writer requires a `POSITION` attribute. It writes `COLOR_0`, `intensity`, and `classification` attributes when present. `LASWriter` writes uncompressed LAS 1.2 output; it does not write LAZ-compressed output. +The writer requires a `POSITION` attribute. It writes `COLOR_0`, `intensity`, and `classification` attributes when present. It can select LAS versions 1.0-1.4 and PDRF 0-8, but fields without a corresponding input attribute, including GPS time, waveform references, NIR, return flags, and scanner metadata, are zero-filled. Current round-trip coverage targets default LAS 1.2 and LAS 1.4/PDRF 7; full conformance for every selectable version/PDRF combination is not claimed. `LASWriter` does not write LAZ-compressed or COPC output. ## Options -| Option | Type | Default | Description | -| ------------ | -------------------------- | ----------------------- | --------------------------------------------------------------------------- | -| `las.scale` | `[number, number, number]` | `[0.001, 0.001, 0.001]` | Coordinate scale factors used to quantize positions into LAS integer coordinates. | -| `las.offset` | `[number, number, number]` | Mesh minimum position | Coordinate offsets used to quantize positions into LAS integer coordinates. | +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `las.format` | `'las' \| 'laz' \| 'copc'` | `'las'` | Output container. Only `'las'` is implemented; compressed formats throw. | +| `las.version` | `'1.0'` through `'1.4'` | `'1.2'` or `'1.4'` | LAS header version. The default is 1.4 for modern PDRFs and 1.2 otherwise. | +| `las.pointDataRecordFormat` | `0` through `8` | Derived | Point record layout. The default depends on version and whether `COLOR_0` is present. | +| `las.scale` | `[number, number, number]` | `[0.001, 0.001, 0.001]` | Coordinate scale factors used to quantize positions into LAS integer coordinates. | +| `las.offset` | `[number, number, number]` | Mesh minimum position | Coordinate offsets used to quantize positions into LAS integer coordinates. | +| `las.colorDepth` | `number \| string` | - | Declares the source color component depth. | diff --git a/docs/modules/las/formats/las.md b/docs/modules/las/formats/las.md index c93d98dec2..09fd3b9369 100644 --- a/docs/modules/las/formats/las.md +++ b/docs/modules/las/formats/las.md @@ -23,21 +23,21 @@ LAS is the uncompressed exchange format. LAZ is the losslessly compressed form d `@loaders.gl/las` has mature WASM-backed and Rust-backed LAZ paths. The TypeScript-only backend is opt-in and is still incomplete. It is intended to remove the runtime WASM requirement over time, but it should not yet be treated as a complete LAS, LAZ, or COPC implementation. +LAS file versions and LASzip codec versions are independent. A claim such as "LAZ 1.4 support" is therefore not precise enough: compatibility also depends on the PDRF, LASzip compressor and coder, item codec versions, chunk-table mode, and whether the caller needs complete raw records or only the fields exposed as Arrow columns. + ### TypeScript LAS Parser | Capability | TypeScript backend status | | --- | --- | -| Uncompressed LAS 1.0-1.4 | Partial. Reads the public header and common point fields. | -| LAS 1.5 | Partial read/header compatibility only. LAS 1.5-specific semantics are not complete. | -| Point data record formats 0-10 | Partial. Common fields are read, but not every field is exposed. | -| XYZ, intensity, classification | Supported. | -| RGB | Supported for RGB point formats. | +| Uncompressed LAS 1.0-1.4 | Partial. Reads common public-header fields and PDRF 0-10 record layouts. Dedicated conformance fixtures do not yet cover every header version/PDRF combination. | +| LAS 1.5 | Partial. Extended point counts and PDRF 9/10 files are fixture-tested; LAS 1.5 metadata and conformance rules are not complete. | +| Arrow columns | `POSITION`, `intensity`, `classification`, and `COLOR_0` for RGB formats. | | GPS time | Parsed only indirectly by format layout today; not exposed as a first-class attribute by the TypeScript path. | | NIR | Not exposed as a first-class attribute. | | Return flags, scanner channel, scan angle, point source ID, user data | Incomplete. | | Waveform packet fields | PDRF 4/5/9/10 packet references are preserved by the raw TypeScript LAZ APIs but are not exposed as Arrow columns. Waveform payload handling is not implemented. | | Extra bytes | Raw record length is respected, but Extra Bytes VLR metadata is not mapped to output attributes. | -| VLRs, EVLRs, CRS, WKT, GeoTIFF records | Not fully parsed or preserved. | +| VLRs, EVLRs, CRS, WKT, GeoTIFF records | The LASzip VLR is parsed for decompression. General metadata records are not fully parsed or preserved. | | `parseInBatches` | Incremental for uncompressed LAS point records after enough header/point bytes are available. | ### TypeScript LAS Writer @@ -45,8 +45,8 @@ LAS is the uncompressed exchange format. LAZ is the losslessly compressed form d | Capability | TypeScript backend status | | --- | --- | | Uncompressed LAS writing | Partial. Supports LAS output for represented mesh/table fields. | -| LAS versions | Writes LAS 1.0-1.4; LAS 1.5 writing is not supported. | -| Point data record formats | PDRF 0-8 are selectable, but unsupported fields are zero-filled or omitted. | +| LAS versions | Versions 1.0-1.4 are selectable and LAS 1.5 is rejected. Round-trip coverage currently targets default LAS 1.2 and LAS 1.4/PDRF 7; full version conformance is not claimed. | +| Point data record formats | PDRF 0-8 are selectable. Only position, intensity, classification, and RGB input attributes are represented; other fields are zero-filled. | | LAZ writing | Not implemented. | | COPC writing | Not implemented. | | VLRs, EVLRs, CRS, Extra Bytes VLRs | Not complete. | @@ -54,26 +54,55 @@ LAS is the uncompressed exchange format. LAZ is the losslessly compressed form d ### TypeScript LAZ Decoder -| Capability | TypeScript backend status | +#### LASzip Codec Options + +| LASzip feature | Supported TypeScript combinations | | --- | --- | -| Full LAZ file parsing | Partial. Fixed-size LASzip chunks for supported TypeScript LAZ point formats can be decoded incrementally by `parseInBatches`. Variable-size chunk files are supported after their chunk table is available at EOF. | -| LASzip VLR parsing | Partial. Compression mode, arithmetic coder, fixed or variable chunking, chunk-size metadata, and the point format's item order, sizes, and versions are parsed incrementally and validated before decoding. | -| Chunk table parsing | LASzip chunk-table version 0 is supported for fixed-size and variable-size chunks. Decoded point counts and byte ranges are validated before decompression. | -| Single compressed chunk decode | Supported when metadata is supplied by the caller. | -| LAZ point formats 0-5 | Supported for legacy fixed-size LASzip v2 chunks, including GPS time, RGB, and PDRF 4/5 WavePacket13 v1 decoding. LASzip v1 Point10/GPS/RGB/Byte items are rejected because their codec differs. Complete batches can be emitted before a compressed chunk has fully arrived. | -| LAZ 1.4 point formats 6-10 | Supported for fixed-size full-file LAZ chunks. PDRF 9/10 raw decoding preserves the complete 29-byte waveform packet reference, including lossless 64-bit offsets. COPC remains limited by specification to PDRF 6-8. | -| Extra bytes in LAZ 1.4 chunks | Supported at the raw byte level when metadata supplies the record length. | -| Selective decompression | Supported for PDRF 6-10 Arrow output. The decoder skips independent LAZ 1.4 layers that are not represented in the returned table, including PDRF 9/10 waveform references, while preserving complete raw-record decoding through the chunk APIs. | -| True streaming decode | Partial. Legacy fixed-size PDRF 0-5 chunks emit complete batches before the current chunk or file ends using bounded geometric replay. Layered PDRF 6-10 emits after complete chunks arrive. Variable-size chunk point counts are stored in the table at EOF, so a forward-only input is buffered until that table is available. | -| LAZ encoding | Not implemented. | +| Legacy PDRF 0-3 items | Compressor 2, arithmetic coder 0, Point10/GPS/RGB/Byte item version 2. Legacy item version 1 is rejected because it uses a different codec. | +| Legacy waveform PDRF 4-5 | Compressor 2, arithmetic coder 0, Point10/GPS/RGB/Byte item version 2, and WavePacket13 item version 1. | +| Modern PDRF 6-8 items | Layered compressor 3, arithmetic coder 0, and Point14/RGB14/RGBNIR14/Byte14 item versions 2, 3, or 4. | +| Modern waveform PDRF 9-10 | Layered compressor 3, arithmetic coder 0, Point14/RGB14/RGBNIR14/Byte14 item versions 2-4, and WavePacket14 item version 3 or 4. | +| Extra Bytes | Byte10 version 2 and Byte14 versions 2-4 are losslessly preserved in raw records. Extra Bytes VLR definitions are not exposed as typed columns. | +| Chunk table | Version 0, fixed-size and variable-size chunks. Other chunk-table versions are rejected. | +| Unsupported modes | Pointwise compressor 1, coders other than 0, legacy item version 1, and LAZ encoding. | + +#### Point Record Formats + +"Raw" below means that `decodeLAZChunk()` and raw file-batch APIs reproduce every byte in each LAS point record. Arrow output is intentionally narrower. + +| PDRF | Valid LAS versions | Raw LAZ decode | Arrow output | Dedicated fixture coverage | +| --- | --- | --- | --- | --- | +| 0 | 1.0-1.4 | Supported for the legacy codec combination above. | XYZ, intensity, classification. | Synthetic chunk unit test. | +| 1 | 1.1-1.4 | Supported for the legacy codec combination above. | XYZ, intensity, classification; GPS time omitted. | Synthetic chunk unit test. | +| 2 | 1.2-1.4 | Supported for the legacy codec combination above. | XYZ, intensity, classification, RGB. | Synthetic chunk unit test. | +| 3 | 1.2-1.4 | Supported for the legacy codec combination above. | XYZ, intensity, classification, RGB; GPS time omitted. | Full-file parity with laz-rs on two LAS 1.2 files. | +| 4 | 1.3-1.4 | Supported, including WavePacket13 and Extra Bytes. | XYZ, intensity, classification; GPS time and waveform reference omitted. | Byte-for-byte paired LAS/LAZ fixture. | +| 5 | 1.3-1.4 | Supported, including RGB, WavePacket13, and Extra Bytes. | XYZ, intensity, classification, RGB; GPS time and waveform reference omitted. | Byte-for-byte paired LAS/LAZ fixture. | +| 6 | 1.4-1.5 | Supported for Point14 item versions 2-4. | XYZ, intensity, classification. | LAS 1.4 byte parity plus COPC backend parity. | +| 7 | 1.4-1.5 | Supported for Point14/RGB14 item versions 2-4. | XYZ, intensity, classification, RGB. | LAS 1.4 v3 backend parity and v4 byte parity across all scanner channels. | +| 8 | 1.4-1.5 | Supported for Point14/RGBNIR14 item versions 2-4. | XYZ, intensity, classification, RGB; NIR omitted. | LAS 1.4 byte parity including NIR and Extra Bytes. | +| 9 | 1.4-1.5 | Supported, including WavePacket14 versions 3-4 and exact 64-bit offsets. | XYZ, intensity, classification; waveform reference omitted. | LAS 1.4/v3 and LAS 1.5/v4 byte parity. | +| 10 | 1.4-1.5 | Supported, including RGB, NIR, WavePacket14 versions 3-4, and exact 64-bit offsets. | XYZ, intensity, classification, RGB; NIR and waveform reference omitted. | LAS 1.4/v3 and LAS 1.5/v4 byte parity. | + +#### Streaming Granularity + +| Input case | First output can be emitted | Retained input / limitation | +| --- | --- | --- | +| Uncompressed LAS | After the header and enough complete point records arrive. | Only incomplete framing and the current output batch are retained. | +| Fixed-chunk legacy LAZ PDRF 0-5 | Before the current compressed chunk is complete, after enough bytes decode complete rows. | Uses bounded geometric replay and retains the current compressed chunk. | +| Fixed-chunk layered LAZ PDRF 6-10 | After one complete compressed LAZ chunk arrives. | Independent field ranges are located by per-chunk size metadata; the whole file is not required. | +| Variable-chunk LAZ | After the EOF chunk table is available. | A forward-only source is buffered because per-chunk point counts are stored at EOF. | +| COPC node | After the selected node byte range has been fetched. | Current COPC integration fetches and decodes one complete node chunk; it does not emit partial node rows. | #### Selective LAZ 1.4 Decoding -The TypeScript parser writes positions, intensity, classification, and RGB values directly into Arrow column buffers. PDRF 6-10 store groups of fields in independent compressed layers. The parser therefore avoids arithmetic decoding for scan flags, scan angle, user data, point source ID, GPS time, NIR, waveform packet references, and Extra Bytes layers that are not currently exposed in the returned table, using specialized batch loops for their common output fields. RGB remains enabled for PDRF 7, 8, and 10. The selective decoder also avoids constructing arithmetic models for skipped layers. +For complete-buffer `parse` and streaming `parseInBatches`, the TypeScript parser writes positions, intensity, classification, and RGB values directly into Arrow column buffers. PDRF 6-10 store groups of fields in independent compressed layers. The parser therefore avoids arithmetic decoding for scan flags, scan angle, user data, point source ID, GPS time, NIR, waveform packet references, and Extra Bytes layers that are not represented in the returned table. + +The direct chunk target is more selective: intensity, classification, and RGB arrays are optional, and omitted layers are skipped without constructing their arithmetic decoders or models. The COPC rendering path uses this to request positions and RGB only, avoiding unused intensity and classification allocations and decompression. Compressed field layers are decoded as bounded ranges of the original chunk instead of copied into per-layer readers. LASzip v3's historical item-context channel behavior is preserved separately from each point's actual scanner channel, while LASzip v4 uses the corrected context-switch behavior declared by each item in the LASzip VLR. These details reduce temporary allocation and property lookup overhead without changing the complete-record APIs. -This optimization applies only to table parsing. `decodeLAZChunk()` and the raw cursor API continue to decode every field and return complete LAS point records byte-for-byte. A cursor cannot switch between selective table output and raw-record output after decoding starts because skipped arithmetic streams cannot be resumed at the corresponding point. +`decodeLAZChunk()` and the raw cursor API continue to decode every field and return complete LAS point records byte-for-byte. A cursor cannot switch between raw and selective output, or change its selected fields, after decoding starts because skipped arithmetic streams cannot be resumed at the corresponding point. Dedicated PDRF 4-10 fixtures compare every raw decoded byte and every represented Arrow attribute against matching uncompressed LAS records. LASzip-generated PDRF 4/5 fixtures validate legacy WavePacket13 and PDRF 5 field ordering. A current-LASzip PDRF 7 fixture verifies Point14, RGB14, and Byte14 item version 4 across all four scanner-channel contexts while retaining a LAS 1.4 header. The bundled COPC decoder misdecodes that fixture's RGB values after scanner-channel changes, and bundled laz-rs rejects item version 4, so current LASzip plus uncompressed LAS provide its correctness oracle. PDRF 8 coverage includes NIR, Extra Bytes, and scanner-channel transitions. PDRF 9/10 coverage exercises all waveform-offset predictor modes, exact 64-bit offsets above `Number.MAX_SAFE_INTEGER`, packet sizes, float vector fields, and Extra Bytes. LASzip-generated LAS 1.5 PDRF 9/10 fixtures additionally verify item version 4 across all four scanner-channel contexts. NIR, waveform references, and Extra Bytes are preserved by raw decoding but are not yet exposed as Arrow columns. @@ -84,7 +113,7 @@ Dedicated PDRF 4-10 fixtures compare every raw decoded byte and every represente | COPC hierarchy and range selection | Uses the existing COPC path; not a standalone TypeScript COPC parser yet. | | Node range fetching | Supported. Each selected node's compressed byte range is fetched as a complete chunk. | | Point decoding | Supported for COPC nodes using LAZ 1.4 PDRF 6, 7, or 8. | -| Render attribute output | The TypeScript path decodes positions and RGB directly into typed Arrow attributes, avoiding an intermediate raw-record buffer and second point traversal. | +| Render attribute output | The TypeScript path decodes positions and RGB directly into typed Arrow attributes, skipping intensity/classification layers and avoiding their arrays, an intermediate raw-record buffer, and a second point traversal. | | Progressive point output while range data arrives | Not implemented. | | COPC writer | Not implemented. | diff --git a/modules/copc/src/copc-source-loader.ts b/modules/copc/src/copc-source-loader.ts index 81a0fc274e..ac8b39f366 100644 --- a/modules/copc/src/copc-source-loader.ts +++ b/modules/copc/src/copc-source-loader.ts @@ -314,8 +314,6 @@ export class COPCTileSource const decodedPointCount = cursor.decodeIntoPointData( { positions: nativePositions, - intensities: new Uint16Array(pointCount), - classifications: new Uint8Array(pointCount), rawColors: colors, pointOffset: 0, scale: copc.header.scale, diff --git a/modules/las/src/lib/typescript/parse-las.ts b/modules/las/src/lib/typescript/parse-las.ts index 5c68bce197..f990ff2b51 100644 --- a/modules/las/src/lib/typescript/parse-las.ts +++ b/modules/las/src/lib/typescript/parse-las.ts @@ -121,7 +121,13 @@ type LAZChunkByteLengthMetadata = { export function parseLAS(arrayBuffer: ArrayBuffer, options: LASLoaderOptions = {}): LASArrowTable { const header = parseLASHeader(arrayBuffer); if (header.isCompressed) { - const rawPointData = decodeLAZFileToRawPointData(arrayBuffer, header); + const bytes = new Uint8Array(arrayBuffer); + const laszip = parseLASZipVLR(bytes, header); + validateTypeScriptLAZSupport(header, laszip); + if (header.pointsFormatId >= 6 && header.pointsFormatId <= 10) { + return parseCompleteLAZFileToArrowTable(bytes, header, laszip, options); + } + const rawPointData = decodeLAZFileToRawPointData(arrayBuffer, header, laszip); const totalPointCount = header.pointsCount; return parseLASArrowTableBatch( rawPointData, @@ -144,6 +150,106 @@ export function parseLAS(arrayBuffer: ArrayBuffer, options: LASLoaderOptions = { ); } +/** Decode one complete modern LAZ file directly into its represented Arrow columns. */ +function parseCompleteLAZFileToArrowTable( + bytes: Uint8Array, + header: LASHeader, + laszip: LASZipVLR, + options: LASLoaderOptions +): LASArrowTable { + const pointCount = header.pointsCount; + const state = createPointDataBatchState(pointCount, header, options); + let decodedPointCount = 0; + let byteOffset = header.pointsOffset + LAZ_CHUNK_TABLE_POINTER_LENGTH; + + if (laszip.variableChunks) { + const chunkTableOffset = readUint64( + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), + header.pointsOffset + ); + const chunkTable = readLAZChunkTable(bytes, header, laszip, chunkTableOffset); + for (const chunk of chunkTable) { + decodeCompleteLAZChunkToPointData( + bytes, + byteOffset, + chunk.byteLength, + chunk.pointCount, + header, + laszip, + state, + decodedPointCount + ); + byteOffset += chunk.byteLength; + decodedPointCount += chunk.pointCount; + } + } else { + while (decodedPointCount < pointCount) { + const chunkPointCount = Math.min(laszip.chunkSize, pointCount - decodedPointCount); + const metadata = createLAZChunkMetadata(header, laszip, chunkPointCount); + const compressed = bytes.subarray(byteOffset); + const chunkByteLength = getLAZChunkByteLength(compressed, metadata); + decodeCompleteLAZChunkToPointData( + bytes, + byteOffset, + chunkByteLength, + chunkPointCount, + header, + laszip, + state, + decodedPointCount + ); + byteOffset += chunkByteLength; + decodedPointCount += chunkPointCount; + } + } + + if (decodedPointCount !== pointCount) { + throw new Error( + `LASLoader: decoded ${decodedPointCount} modern LAZ points; expected ${pointCount}` + ); + } + const colors = state.colors + ? state.colors + : state.rawColors + ? convertRawColorsToUint8(state.rawColors, pointCount, options) + : null; + return makeLASArrowTableFromAttributes( + {...header, pointsOffset: 0, totalRead: pointCount}, + state.positions, + colors, + state.intensities, + state.classifications + ); +} + +/** Decode one complete modern LAZ chunk into preallocated Arrow column buffers. */ +function decodeCompleteLAZChunkToPointData( + bytes: Uint8Array, + byteOffset: number, + byteLength: number, + pointCount: number, + header: LASHeader, + laszip: LASZipVLR, + state: PointDataBatchState, + targetPointOffset: number +): void { + const compressed = bytes.subarray(byteOffset, byteOffset + byteLength); + if (compressed.byteLength !== byteLength) { + throw new NeedsMoreData('LASLoader: truncated modern LAZ chunk'); + } + const cursor = createLAZChunkDecoderCursor( + compressed, + createLAZChunkMetadata(header, laszip, pointCount) + ); + state.target.pointOffset = targetPointOffset; + const decodedPointCount = cursor.decodeIntoPointData(state.target, pointCount); + if (decodedPointCount !== pointCount) { + throw new Error( + `LASLoader: decoded ${decodedPointCount} points from a ${pointCount}-point LAZ chunk` + ); + } +} + /** Parse LAS data from an incoming byte iterator into point batches. */ export async function* parseLASInBatches( arrayBufferIterator: @@ -531,9 +637,10 @@ async function* parsePendingLAZFileInArrowBatches( function* parseLAZChunkedIterator( arrayBuffer: ArrayBuffer, header: LASHeader, - batchSize: number + batchSize: number, + parsedLASZipVLR?: LASZipVLR ): Iterable { - const laszip = parseLASZipVLR(new Uint8Array(arrayBuffer), header); + const laszip = parsedLASZipVLR || parseLASZipVLR(new Uint8Array(arrayBuffer), header); validateTypeScriptLAZSupport(header, laszip); if (header.pointsFormatId <= 5 || laszip.variableChunks) { @@ -614,12 +721,16 @@ function* decodeLAZFileFromCompleteBytes( } } -function decodeLAZFileToRawPointData(arrayBuffer: ArrayBuffer, header: LASHeader): Uint8Array { +function decodeLAZFileToRawPointData( + arrayBuffer: ArrayBuffer, + header: LASHeader, + laszip: LASZipVLR +): Uint8Array { const totalPointCount = header.pointsCount; const rawPointData = new Uint8Array(totalPointCount * header.pointsStructSize); let byteOffset = 0; - for (const batch of parseLAZChunkedIterator(arrayBuffer, header, DEFAULT_BATCH_SIZE)) { + for (const batch of parseLAZChunkedIterator(arrayBuffer, header, DEFAULT_BATCH_SIZE, laszip)) { const source = new Uint8Array(batch.arrayBuffer); rawPointData.set(source, byteOffset); byteOffset += source.byteLength; diff --git a/modules/las/test/las-loader.bench.ts b/modules/las/test/las-loader.bench.ts index 8bd12ed4ca..c5bd4634fe 100644 --- a/modules/las/test/las-loader.bench.ts +++ b/modules/las/test/las-loader.bench.ts @@ -179,6 +179,30 @@ export default async function lasLoaderBench(bench) { } ); + bench.add( + 'decodeLAZChunk cursor render-data LAZ 1.4 PDRF 7 backend=typescript', + { + multiplier: laz14FirstChunk.metadata.pointCount, + unit: 'output points', + minIterations: 3 + }, + () => { + const target = createLAZRenderDataBenchmarkTarget( + laz14FirstChunk.metadata.pointCount, + laz14Header + ); + const cursor = createLAZChunkDecoderCursor( + laz14FirstChunk.compressed, + laz14FirstChunk.metadata + ); + while (cursor.remainingPointCount > 0) { + const targetPointOffset = laz14FirstChunk.metadata.pointCount - cursor.remainingPointCount; + target.pointOffset = targetPointOffset; + cursor.decodeIntoPointData(target, Math.min(BATCH_SIZE, cursor.remainingPointCount)); + } + } + ); + bench.groupSorted('LASWriter'); bench.add('LASWriter LAS 1.2 backend=typescript', benchmarkOptions, () => { @@ -308,6 +332,25 @@ function createLAZPointDataBenchmarkTarget( }; } +/** + * Creates the positions and RGB target used by the COPC rendering path. + * @param pointCount Number of points to decode. + * @param header LAS header with scale and offset metadata. + * @returns Direct render-data decode target. + */ +function createLAZRenderDataBenchmarkTarget( + pointCount: number, + header: ReturnType +): LAZPointDataTarget { + return { + positions: new Float64Array(pointCount * 3), + rawColors: new Uint16Array(pointCount * 3), + pointOffset: 0, + scale: header.scale, + offset: header.offset + }; +} + /** * Reads the fixed LASzip chunk size from the LASzip VLR. * @param bytes Complete LAS/LAZ file bytes diff --git a/modules/las/test/las-loader.spec.ts b/modules/las/test/las-loader.spec.ts index 81aadd95ac..badf550bc4 100644 --- a/modules/las/test/las-loader.spec.ts +++ b/modules/las/test/las-loader.spec.ts @@ -1296,6 +1296,86 @@ test('TypeScriptLAZ#cursor point-data output matches full PDRF 7 records', async t.end(); }); +test('TypeScriptLAZ#cursor skips unrequested PDRF 7 field layers', async t => { + const {compressed, metadata} = await getCOPCRootChunk(); + const rawPointData = decodeLAZChunk(compressed, metadata); + const rawPointDataView = new DataView( + rawPointData.buffer, + rawPointData.byteOffset, + rawPointData.byteLength + ); + const positions = new Float64Array(metadata.pointCount * 3); + const rawColors = new Uint16Array(metadata.pointCount * 3); + const selectedTarget = { + positions, + rawColors, + pointOffset: 0, + scale: [1, 1, 1] as [number, number, number], + offset: [0, 0, 0] as [number, number, number] + }; + const selectedCursor = createLAZChunkDecoderCursor(compressed, metadata); + + while (selectedCursor.remainingPointCount > 0) { + selectedTarget.pointOffset = metadata.pointCount - selectedCursor.remainingPointCount; + selectedCursor.decodeIntoPointData(selectedTarget, 17); + } + + const expectedPositions = new Float64Array(metadata.pointCount * 3); + const expectedRawColors = new Uint16Array(metadata.pointCount * 3); + for (let pointIndex = 0; pointIndex < metadata.pointCount; pointIndex++) { + const pointOffset = pointIndex * metadata.pointDataRecordLength; + const positionOffset = pointIndex * 3; + expectedPositions[positionOffset] = rawPointDataView.getInt32(pointOffset, true); + expectedPositions[positionOffset + 1] = rawPointDataView.getInt32(pointOffset + 4, true); + expectedPositions[positionOffset + 2] = rawPointDataView.getInt32(pointOffset + 8, true); + expectedRawColors[positionOffset] = rawPointDataView.getUint16(pointOffset + 30, true); + expectedRawColors[positionOffset + 1] = rawPointDataView.getUint16(pointOffset + 32, true); + expectedRawColors[positionOffset + 2] = rawPointDataView.getUint16(pointOffset + 34, true); + } + + t.deepEqual( + positions, + expectedPositions, + 'positions match while intensity and class are skipped' + ); + t.deepEqual( + rawColors, + expectedRawColors, + 'RGB matches while unrelated Point14 layers are skipped' + ); + + const positionsOnlyTarget = { + positions: new Float64Array(metadata.pointCount * 3), + pointOffset: 0, + scale: [1, 1, 1] as [number, number, number], + offset: [0, 0, 0] as [number, number, number] + }; + const positionsOnlyCursor = createLAZChunkDecoderCursor(compressed, metadata); + t.equal( + positionsOnlyCursor.decodeIntoPointData(positionsOnlyTarget, metadata.pointCount), + metadata.pointCount, + 'positions-only output skips every optional independent layer' + ); + t.deepEqual( + positionsOnlyTarget.positions, + expectedPositions, + 'positions remain correct while RGB is skipped' + ); + + const lockedCursor = createLAZChunkDecoderCursor(compressed, metadata); + lockedCursor.decodeIntoPointData(positionsOnlyTarget, 1); + t.throws( + () => + lockedCursor.decodeIntoPointData( + {...positionsOnlyTarget, classifications: new Uint8Array(metadata.pointCount)}, + 1 + ), + /Cannot change selected point-data fields/, + 'cursor rejects changing independent field selection after decoding starts' + ); + t.end(); +}); + test('TypeScriptLAZ#decodes single-point legacy point format 0 chunk', t => { const expected = createPointFormat0Record(); const compressed = new Uint8Array(expected.byteLength + 4); diff --git a/modules/loader-utils/src/lib/laz/laz-chunk-decoder.ts b/modules/loader-utils/src/lib/laz/laz-chunk-decoder.ts index d25ec8a56d..ac499fa4f1 100644 --- a/modules/loader-utils/src/lib/laz/laz-chunk-decoder.ts +++ b/modules/loader-utils/src/lib/laz/laz-chunk-decoder.ts @@ -27,10 +27,10 @@ export type LAZChunkDecoderOptions = { export type LAZPointDataTarget = { /** XYZ positions populated with LAS scale and offset applied. */ positions: Float32Array | Float64Array; - /** Point intensity values. */ - intensities: Uint16Array; - /** Point classification values. */ - classifications: Uint8Array; + /** Optional point intensity values. Omit to skip the independent Point14 intensity layer. */ + intensities?: Uint16Array | null; + /** Optional point classification values. Omit to skip the independent Point14 class layer. */ + classifications?: Uint8Array | null; /** Optional final RGBA colors as 8-bit channel values. */ colors?: Uint8Array | null; /** Optional raw RGB colors as 16-bit LAS channel values. */ @@ -54,6 +54,16 @@ export type LAZChunkTableEntry = { /** Output mode selected for one stateful LAZ chunk decoder. */ type LAZChunkDecoderOutputMode = 'raw' | 'point-data'; +/** Independent LAZ 1.4 field layers requested by a direct point-data target. */ +type LAZPointDataSelection = { + /** Decode point intensity values. */ + intensity: boolean; + /** Decode point classification values. */ + classification: boolean; + /** Decode RGB values when the point format has an RGB item. */ + color: boolean; +}; + /** Error raised when a feedable decoder needs more compressed bytes. */ export class NeedsMoreData extends Error { constructor(message: string = 'LAZ chunk decoder needs more compressed data') { @@ -169,6 +179,8 @@ export class LAZChunkDecoderCursor { private pointIndex = 0; /** Output mode selected by the first decode call. */ private outputMode: LAZChunkDecoderOutputMode | null = null; + /** Direct-output fields selected by the first point-data decode call. */ + private pointDataSelectionKey: number | null = null; constructor(compressed: ArrayBuffer | ArrayBufferView, metadata: LAZChunkMetadata) { this.metadata = metadata; @@ -211,7 +223,8 @@ export class LAZChunkDecoderCursor { `TypeScript LAZ decoder does not support direct point-data output for point format ${this.metadata.pointDataRecordFormat}` ); } - const decoder = this.selectOutputMode('point-data', pointsToDecode); + const selection = getLAZPointDataSelection(target); + const decoder = this.selectOutputMode('point-data', pointsToDecode, selection); if (!decoder) { return 0; } @@ -231,7 +244,8 @@ export class LAZChunkDecoderCursor { /** Lock this cursor to one output mode so skipped layered streams cannot be read later. */ private selectOutputMode( mode: LAZChunkDecoderOutputMode, - pointCount: number + pointCount: number, + selection: LAZPointDataSelection | null = null ): PointDecompressor | null { if (pointCount === 0) { return null; @@ -239,12 +253,35 @@ export class LAZChunkDecoderCursor { if (this.outputMode && this.outputMode !== mode) { throw new Error('Cannot mix raw and point-data decoding in one LAZ chunk cursor'); } + if (mode === 'point-data') { + const selectionKey = getLAZPointDataSelectionKey(selection!); + if (this.pointDataSelectionKey !== null && this.pointDataSelectionKey !== selectionKey) { + throw new Error('Cannot change selected point-data fields in one LAZ chunk cursor'); + } + this.pointDataSelectionKey = selectionKey; + } this.outputMode = mode; - this.decoder ||= createPointDecompressor(this.stream, this.metadata, mode); + this.decoder ||= createPointDecompressor(this.stream, this.metadata, mode, selection); return this.decoder; } } +/** Return the independent LAZ 1.4 field layers requested by a direct output target. */ +function getLAZPointDataSelection(target: LAZPointDataTarget): LAZPointDataSelection { + return { + intensity: Boolean(target.intensities), + classification: Boolean(target.classifications), + color: Boolean(target.colors || target.rawColors) + }; +} + +/** Encode a direct-output field selection as a compact cursor compatibility key. */ +function getLAZPointDataSelectionKey(selection: LAZPointDataSelection): number { + return ( + (selection.intensity ? 1 : 0) | (selection.classification ? 2 : 0) | (selection.color ? 4 : 0) + ); +} + /** Create a stateful TypeScript LAZ chunk decoder cursor. */ export function createLAZChunkDecoderCursor( compressed: ArrayBuffer | ArrayBufferView, @@ -1262,7 +1299,7 @@ class Point14Context { returnNumberGpsSameModel = new ArithmeticModel(13); numberReturnsModel = createModels(16, 16); returnNumberModel = createModels(16, 16); - classModel = createModels(64, 256); + classModel: ArithmeticModel[] = []; flagModel: ArithmeticModel[] = []; userDataModel: ArithmeticModel[] = []; gpsTimeMultiModel!: ArithmeticModel; @@ -1270,13 +1307,13 @@ class Point14Context { dx = createIntegerDecompressor(32, 2); dy = createIntegerDecompressor(32, 22); z = createIntegerDecompressor(32, 20); - intensity = createIntegerDecompressor(16, 4); + intensity: IntegerDecompressor | null = null; scanAngle!: IntegerDecompressor; pointSourceId!: IntegerDecompressor; gpsTime!: IntegerDecompressor; haveLast = false; last = createPoint14(); - lastIntensity = new Array(8).fill(0); + lastIntensity: number[] = []; lastZ = new Array(8).fill(0); lastXDiffMedian = Array.from({length: 12}, () => new StreamingMedian()); lastYDiffMedian = Array.from({length: 12}, () => new StreamingMedian()); @@ -1288,7 +1325,14 @@ class Point14Context { gpsTimeChange = false; /** Construct context models required by the selected output mode. */ - constructor(mode: Point14DecompressionMode) { + constructor(mode: Point14DecompressionMode, selection: LAZPointDataSelection | null) { + if (mode === Point14DecompressionMode.Full || selection?.classification) { + this.classModel = createModels(64, 256); + } + if (mode === Point14DecompressionMode.Full || selection?.intensity) { + this.intensity = createIntegerDecompressor(16, 4); + this.lastIntensity = new Array(8).fill(0); + } if (mode === Point14DecompressionMode.Full) { this.flagModel = createModels(64, 64); this.userDataModel = createModels(64, 256); @@ -1313,9 +1357,9 @@ class Point14Decompressor { private decompressOptionalFields: boolean; private xy = new ArithmeticDecoder(); private z = new ArithmeticDecoder(); - private classification = new ArithmeticDecoder(); + private classification: ArithmeticDecoder | null; private flags: ArithmeticDecoder | null; - private intensity = new ArithmeticDecoder(); + private intensity: ArithmeticDecoder | null; private scanAngle: ArithmeticDecoder | null; private userData: ArithmeticDecoder | null; private pointSourceId: ArithmeticDecoder | null; @@ -1331,13 +1375,18 @@ class Point14Decompressor { constructor( stream: ByteReader, mode: Point14DecompressionMode = Point14DecompressionMode.Full, - itemVersion: 2 | 3 | 4 = 3 + itemVersion: 2 | 3 | 4 = 3, + selection: LAZPointDataSelection | null = null ) { this.stream = stream; this.itemVersion = itemVersion; - this.contexts = Array.from({length: 4}, () => new Point14Context(mode)); + this.contexts = Array.from({length: 4}, () => new Point14Context(mode, selection)); this.decompressOptionalFields = mode === Point14DecompressionMode.Full; + this.classification = + this.decompressOptionalFields || selection?.classification ? new ArithmeticDecoder() : null; this.flags = this.decompressOptionalFields ? new ArithmeticDecoder() : null; + this.intensity = + this.decompressOptionalFields || selection?.intensity ? new ArithmeticDecoder() : null; this.scanAngle = this.decompressOptionalFields ? new ArithmeticDecoder() : null; this.userData = this.decompressOptionalFields ? new ArithmeticDecoder() : null; this.pointSourceId = this.decompressOptionalFields ? new ArithmeticDecoder() : null; @@ -1355,9 +1404,9 @@ class Point14Decompressor { let index = 0; this.xy.initStream(this.stream, this.sizes[index++]); this.z.initStream(this.stream, this.sizes[index++]); - this.classification.initStream(this.stream, this.sizes[index++]); + this.initializeOptionalStream(this.classification, this.sizes[index++]); this.initializeOptionalStream(this.flags, this.sizes[index++]); - this.intensity.initStream(this.stream, this.sizes[index++]); + this.initializeOptionalStream(this.intensity, this.sizes[index++]); this.initializeOptionalStream(this.scanAngle, this.sizes[index++]); this.initializeOptionalStream(this.userData, this.sizes[index++]); this.initializeOptionalStream(this.pointSourceId, this.sizes[index++]); @@ -1401,7 +1450,9 @@ class Point14Decompressor { } this.activeContext = context; context.lastZ.fill(context.last.z); - context.lastIntensity.fill(context.last.intensity); + if (context.lastIntensity.length) { + context.lastIntensity.fill(context.last.intensity); + } return context.last; } @@ -1443,7 +1494,9 @@ class Point14Decompressor { context.haveLast = true; copyPoint14(context.last, previous.last); context.lastZ.fill(previous.last.z); - context.lastIntensity.fill(previous.last.intensity); + if (context.lastIntensity.length) { + context.lastIntensity.fill(previous.last.intensity); + } if (decompressOptionalFields) { context.lastGpsTime[0] = previous.last.gpsTime; } @@ -1491,7 +1544,7 @@ class Point14Decompressor { context.lastZ[zContext] = z; } - if (this.classification.valid) { + if (this.classification?.valid) { const classContext = (returnNumber === 1 && returnNumber >= numberOfReturns ? 1 : 0) | ((context.last.classification & 0x1f) << 1); @@ -1508,12 +1561,12 @@ class Point14Decompressor { setClassFlags(context.last, flags & 0x0f); } - if (this.intensity.valid) { + if (this.intensity?.valid) { const intensityContext = (gpsTimeChanged ? 1 : 0) | ((returnNumber >= numberOfReturns ? 1 : 0) << 1) | ((returnNumber === 1 ? 1 : 0) << 2); - const intensity = context.intensity.decompress( + const intensity = context.intensity!.decompress( this.intensity, context.lastIntensity[intensityContext], intensityContext >> 1 @@ -2170,7 +2223,8 @@ type PointDecompressor = { function createPointDecompressor( stream: ByteReader, metadata: LAZChunkMetadata, - outputMode: LAZChunkDecoderOutputMode + outputMode: LAZChunkDecoderOutputMode, + selection: LAZPointDataSelection | null ): PointDecompressor { const extraByteCount = getExtraByteCount(metadata); switch (metadata.pointDataRecordFormat) { @@ -2187,15 +2241,15 @@ function createPointDecompressor( case 5: return new PointFormat5Decompressor(stream, extraByteCount); case 6: - return new PointFormat6Decompressor(stream, extraByteCount, outputMode, metadata); + return new PointFormat6Decompressor(stream, extraByteCount, outputMode, metadata, selection); case 7: - return new PointFormat7Decompressor(stream, extraByteCount, outputMode, metadata); + return new PointFormat7Decompressor(stream, extraByteCount, outputMode, metadata, selection); case 8: - return new PointFormat8Decompressor(stream, extraByteCount, outputMode, metadata); + return new PointFormat8Decompressor(stream, extraByteCount, outputMode, metadata, selection); case 9: - return new PointFormat9Decompressor(stream, extraByteCount, outputMode, metadata); + return new PointFormat9Decompressor(stream, extraByteCount, outputMode, metadata, selection); case 10: - return new PointFormat10Decompressor(stream, extraByteCount, outputMode, metadata); + return new PointFormat10Decompressor(stream, extraByteCount, outputMode, metadata, selection); default: throw new Error( `TypeScript LAZ decoder does not support point format ${metadata.pointDataRecordFormat}` @@ -2650,14 +2704,16 @@ class PointFormat6Decompressor implements PointDecompressor { stream: ByteReader, extraByteCount: number, outputMode: LAZChunkDecoderOutputMode, - metadata: LAZChunkMetadata + metadata: LAZChunkMetadata, + selection: LAZPointDataSelection | null ) { this.stream = stream; this.extraByteCount = extraByteCount; this.point = new Point14Decompressor( stream, outputMode === 'raw' ? Point14DecompressionMode.Full : Point14DecompressionMode.PointData, - metadata.point14ItemVersion ?? 3 + metadata.point14ItemVersion ?? 3, + selection ); this.bytes = outputMode === 'raw' && extraByteCount @@ -2735,7 +2791,7 @@ class PointFormat6Decompressor implements PointDecompressor { class PointFormat7Decompressor implements PointDecompressor { private stream: ByteReader; private point: Point14Decompressor; - private rgb: RGB14Decompressor; + private rgb: RGB14Decompressor | null; private bytes: Byte14Decompressor | null; /** Extra bytes stored with the first point and in independent later layers. */ private extraByteCount: number; @@ -2746,16 +2802,21 @@ class PointFormat7Decompressor implements PointDecompressor { stream: ByteReader, extraByteCount: number, outputMode: LAZChunkDecoderOutputMode, - metadata: LAZChunkMetadata + metadata: LAZChunkMetadata, + selection: LAZPointDataSelection | null ) { this.stream = stream; this.extraByteCount = extraByteCount; this.point = new Point14Decompressor( stream, outputMode === 'raw' ? Point14DecompressionMode.Full : Point14DecompressionMode.PointData, - metadata.point14ItemVersion ?? 3 + metadata.point14ItemVersion ?? 3, + selection ); - this.rgb = new RGB14Decompressor(stream, metadata.rgb14ItemVersion ?? 3); + this.rgb = + outputMode === 'raw' || selection?.color + ? new RGB14Decompressor(stream, metadata.rgb14ItemVersion ?? 3) + : null; this.bytes = outputMode === 'raw' && extraByteCount ? new Byte14Decompressor(stream, extraByteCount, metadata.byte14ItemVersion ?? 3) @@ -2764,7 +2825,7 @@ class PointFormat7Decompressor implements PointDecompressor { decompress(output: Uint8Array, outputOffset: number): number { outputOffset = this.point.decompress(output, outputOffset); - outputOffset = this.rgb.decompress(output, outputOffset, this.point.itemContextChannel); + outputOffset = this.rgb!.decompress(output, outputOffset, this.point.itemContextChannel); if (this.bytes) { outputOffset = this.bytes.decompress(output, outputOffset, this.point.itemContextChannel); } @@ -2774,20 +2835,26 @@ class PointFormat7Decompressor implements PointDecompressor { decompressPointData(target: LAZPointDataTarget, targetPointIndex: number): void { const point = this.point.decompressPoint(this.pointScratch, 0); - this.rgb.decompressRgb(this.point.itemContextChannel); + if (this.rgb) { + this.rgb.decompressRgb(this.point.itemContextChannel); + } else if (this.first) { + this.stream.consume(6); + } // The first point stores extra bytes before the layered stream metadata. if (this.first && this.extraByteCount) { this.stream.consume(this.extraByteCount); } this.readFirstMetadata(); writePoint14ToPointDataTarget(point, target, targetPointIndex); - writeRgbToPointDataTarget( - this.rgb.decodedRed, - this.rgb.decodedGreen, - this.rgb.decodedBlue, - target, - targetPointIndex - ); + if (this.rgb) { + writeRgbToPointDataTarget( + this.rgb.decodedRed, + this.rgb.decodedGreen, + this.rgb.decodedBlue, + target, + targetPointIndex + ); + } } decompressPointDataBatch(target: LAZPointDataTarget, pointCount: number): void { @@ -2803,7 +2870,11 @@ class PointFormat7Decompressor implements PointDecompressor { // The first point stores extra bytes before the layered stream metadata. for (let pointIndex = 0; pointIndex < pointCount; pointIndex++) { const point = this.point.decompressPoint(this.pointScratch, 0); - this.rgb.decompressRgb(this.point.itemContextChannel); + if (this.rgb) { + this.rgb.decompressRgb(this.point.itemContextChannel); + } else if (this.first) { + this.stream.consume(6); + } if (this.first && this.extraByteCount) { this.stream.consume(this.extraByteCount); } @@ -2817,13 +2888,13 @@ class PointFormat7Decompressor implements PointDecompressor { offset, targetPointIndex ); - if (colors) { + if (colors && this.rgb) { const colorOffset = targetPointIndex * 4; colors[colorOffset] = this.rgb.decodedRed & 0xff; colors[colorOffset + 1] = this.rgb.decodedGreen & 0xff; colors[colorOffset + 2] = this.rgb.decodedBlue & 0xff; colors[colorOffset + 3] = 255; - } else if (rawColors) { + } else if (rawColors && this.rgb) { const colorOffset = targetPointIndex * 3; rawColors[colorOffset] = this.rgb.decodedRed; rawColors[colorOffset + 1] = this.rgb.decodedGreen; @@ -2837,7 +2908,8 @@ class PointFormat7Decompressor implements PointDecompressor { if (this.first) { this.stream.getUint32(); this.point.readSizes(); - this.rgb.readSizes(); + const skippedRgbByteLength = this.rgb ? 0 : this.stream.getUint32(); + this.rgb?.readSizes(); let skippedExtraByteLength = 0; if (this.bytes) { this.bytes.readSizes(); @@ -2847,7 +2919,11 @@ class PointFormat7Decompressor implements PointDecompressor { } } this.point.readData(); - this.rgb.readData(); + if (this.rgb) { + this.rgb.readData(); + } else { + this.stream.consume(skippedRgbByteLength); + } if (this.bytes) { this.bytes.readData(); } else { @@ -2861,7 +2937,7 @@ class PointFormat7Decompressor implements PointDecompressor { class PointFormat8Decompressor implements PointDecompressor { private stream: ByteReader; private point: Point14Decompressor; - private rgb: RGB14Decompressor; + private rgb: RGB14Decompressor | null; private nir: NIR14Decompressor | null; private bytes: Byte14Decompressor | null; /** Extra bytes stored with the first point and in independent later layers. */ @@ -2872,16 +2948,21 @@ class PointFormat8Decompressor implements PointDecompressor { stream: ByteReader, extraByteCount: number, outputMode: LAZChunkDecoderOutputMode, - metadata: LAZChunkMetadata + metadata: LAZChunkMetadata, + selection: LAZPointDataSelection | null ) { this.stream = stream; this.extraByteCount = extraByteCount; this.point = new Point14Decompressor( stream, outputMode === 'raw' ? Point14DecompressionMode.Full : Point14DecompressionMode.PointData, - metadata.point14ItemVersion ?? 3 + metadata.point14ItemVersion ?? 3, + selection ); - this.rgb = new RGB14Decompressor(stream, metadata.rgb14ItemVersion ?? 3); + this.rgb = + outputMode === 'raw' || selection?.color + ? new RGB14Decompressor(stream, metadata.rgb14ItemVersion ?? 3) + : null; this.nir = outputMode === 'raw' ? new NIR14Decompressor(stream, metadata.rgb14ItemVersion ?? 3) : null; this.bytes = @@ -2892,7 +2973,7 @@ class PointFormat8Decompressor implements PointDecompressor { decompress(output: Uint8Array, outputOffset: number): number { outputOffset = this.point.decompress(output, outputOffset); - outputOffset = this.rgb.decompress(output, outputOffset, this.point.itemContextChannel); + outputOffset = this.rgb!.decompress(output, outputOffset, this.point.itemContextChannel); outputOffset = this.nir!.decompress(output, outputOffset, this.point.itemContextChannel); if (this.bytes) { outputOffset = this.bytes.decompress(output, outputOffset, this.point.itemContextChannel); @@ -2903,19 +2984,25 @@ class PointFormat8Decompressor implements PointDecompressor { decompressPointData(target: LAZPointDataTarget, targetPointIndex: number): void { const point = this.point.decompressPoint(); - this.rgb.decompressRgb(this.point.itemContextChannel); + if (this.rgb) { + this.rgb.decompressRgb(this.point.itemContextChannel); + } else if (this.first) { + this.stream.consume(6); + } if (this.first) { this.stream.consume(2 + this.extraByteCount); } this.readFirstMetadata(); writePoint14ToPointDataTarget(point, target, targetPointIndex); - writeRgbToPointDataTarget( - this.rgb.decodedRed, - this.rgb.decodedGreen, - this.rgb.decodedBlue, - target, - targetPointIndex - ); + if (this.rgb) { + writeRgbToPointDataTarget( + this.rgb.decodedRed, + this.rgb.decodedGreen, + this.rgb.decodedBlue, + target, + targetPointIndex + ); + } } decompressPointDataBatch(target: LAZPointDataTarget, pointCount: number): void { @@ -2930,7 +3017,11 @@ class PointFormat8Decompressor implements PointDecompressor { for (let pointIndex = 0; pointIndex < pointCount; pointIndex++) { const point = this.point.decompressPoint(); - this.rgb.decompressRgb(this.point.itemContextChannel); + if (this.rgb) { + this.rgb.decompressRgb(this.point.itemContextChannel); + } else if (this.first) { + this.stream.consume(6); + } if (this.first) { this.stream.consume(2 + this.extraByteCount); } @@ -2944,13 +3035,13 @@ class PointFormat8Decompressor implements PointDecompressor { offset, targetPointIndex ); - if (colors) { + if (colors && this.rgb) { const colorOffset = targetPointIndex * 4; colors[colorOffset] = this.rgb.decodedRed & 0xff; colors[colorOffset + 1] = this.rgb.decodedGreen & 0xff; colors[colorOffset + 2] = this.rgb.decodedBlue & 0xff; colors[colorOffset + 3] = 255; - } else if (rawColors) { + } else if (rawColors && this.rgb) { const colorOffset = targetPointIndex * 3; rawColors[colorOffset] = this.rgb.decodedRed; rawColors[colorOffset + 1] = this.rgb.decodedGreen; @@ -2964,7 +3055,8 @@ class PointFormat8Decompressor implements PointDecompressor { if (this.first) { this.stream.getUint32(); this.point.readSizes(); - this.rgb.readSizes(); + const skippedRgbByteLength = this.rgb ? 0 : this.stream.getUint32(); + this.rgb?.readSizes(); const skippedNirByteLength = this.nir ? 0 : this.stream.getUint32(); this.nir?.readSizes(); let skippedExtraByteLength = 0; @@ -2976,7 +3068,11 @@ class PointFormat8Decompressor implements PointDecompressor { } } this.point.readData(); - this.rgb.readData(); + if (this.rgb) { + this.rgb.readData(); + } else { + this.stream.consume(skippedRgbByteLength); + } if (this.nir) { this.nir.readData(); } else { @@ -3011,14 +3107,16 @@ class PointFormat9Decompressor implements PointDecompressor { stream: ByteReader, extraByteCount: number, outputMode: LAZChunkDecoderOutputMode, - metadata: LAZChunkMetadata + metadata: LAZChunkMetadata, + selection: LAZPointDataSelection | null ) { this.stream = stream; this.extraByteCount = extraByteCount; this.point = new Point14Decompressor( stream, outputMode === 'raw' ? Point14DecompressionMode.Full : Point14DecompressionMode.PointData, - metadata.point14ItemVersion ?? 3 + metadata.point14ItemVersion ?? 3, + selection ); this.wavePacket = outputMode === 'raw' @@ -3115,8 +3213,8 @@ class PointFormat10Decompressor implements PointDecompressor { private stream: ByteReader; /** Core LAS 1.4 point decoder. */ private point: Point14Decompressor; - /** RGB decoder retained for raw and Arrow output. */ - private rgb: RGB14Decompressor; + /** RGB decoder retained for raw output or when direct output requests color. */ + private rgb: RGB14Decompressor | null; /** NIR decoder, omitted for selective Arrow output. */ private nir: NIR14Decompressor | null; /** Waveform packet reference decoder, omitted for selective Arrow output. */ @@ -3132,16 +3230,21 @@ class PointFormat10Decompressor implements PointDecompressor { stream: ByteReader, extraByteCount: number, outputMode: LAZChunkDecoderOutputMode, - metadata: LAZChunkMetadata + metadata: LAZChunkMetadata, + selection: LAZPointDataSelection | null ) { this.stream = stream; this.extraByteCount = extraByteCount; this.point = new Point14Decompressor( stream, outputMode === 'raw' ? Point14DecompressionMode.Full : Point14DecompressionMode.PointData, - metadata.point14ItemVersion ?? 3 + metadata.point14ItemVersion ?? 3, + selection ); - this.rgb = new RGB14Decompressor(stream, metadata.rgb14ItemVersion ?? 3); + this.rgb = + outputMode === 'raw' || selection?.color + ? new RGB14Decompressor(stream, metadata.rgb14ItemVersion ?? 3) + : null; this.nir = outputMode === 'raw' ? new NIR14Decompressor(stream, metadata.rgb14ItemVersion ?? 3) : null; this.wavePacket = @@ -3157,7 +3260,7 @@ class PointFormat10Decompressor implements PointDecompressor { /** Decode one complete PDRF 10 point record. */ decompress(output: Uint8Array, outputOffset: number): number { outputOffset = this.point.decompress(output, outputOffset); - outputOffset = this.rgb.decompress(output, outputOffset, this.point.itemContextChannel); + outputOffset = this.rgb!.decompress(output, outputOffset, this.point.itemContextChannel); outputOffset = this.nir!.decompress(output, outputOffset, this.point.itemContextChannel); outputOffset = this.wavePacket!.decompress(output, outputOffset, this.point.itemContextChannel); if (this.bytes) { @@ -3170,19 +3273,25 @@ class PointFormat10Decompressor implements PointDecompressor { /** Decode one PDRF 10 point directly into represented Arrow columns. */ decompressPointData(target: LAZPointDataTarget, targetPointIndex: number): void { const point = this.point.decompressPoint(); - this.rgb.decompressRgb(this.point.itemContextChannel); + if (this.rgb) { + this.rgb.decompressRgb(this.point.itemContextChannel); + } else if (this.first) { + this.stream.consume(6); + } if (this.first) { this.stream.consume(2 + 29 + this.extraByteCount); } this.readFirstMetadata(); writePoint14ToPointDataTarget(point, target, targetPointIndex); - writeRgbToPointDataTarget( - this.rgb.decodedRed, - this.rgb.decodedGreen, - this.rgb.decodedBlue, - target, - targetPointIndex - ); + if (this.rgb) { + writeRgbToPointDataTarget( + this.rgb.decodedRed, + this.rgb.decodedGreen, + this.rgb.decodedBlue, + target, + targetPointIndex + ); + } } /** Decode PDRF 10 points directly into represented Arrow columns. */ @@ -3198,7 +3307,11 @@ class PointFormat10Decompressor implements PointDecompressor { for (let pointIndex = 0; pointIndex < pointCount; pointIndex++) { const point = this.point.decompressPoint(); - this.rgb.decompressRgb(this.point.itemContextChannel); + if (this.rgb) { + this.rgb.decompressRgb(this.point.itemContextChannel); + } else if (this.first) { + this.stream.consume(6); + } if (this.first) { this.stream.consume(2 + 29 + this.extraByteCount); } @@ -3212,13 +3325,13 @@ class PointFormat10Decompressor implements PointDecompressor { offset, targetPointIndex ); - if (colors) { + if (colors && this.rgb) { const colorOffset = targetPointIndex * 4; colors[colorOffset] = this.rgb.decodedRed & 0xff; colors[colorOffset + 1] = this.rgb.decodedGreen & 0xff; colors[colorOffset + 2] = this.rgb.decodedBlue & 0xff; colors[colorOffset + 3] = 255; - } else if (rawColors) { + } else if (rawColors && this.rgb) { const colorOffset = targetPointIndex * 3; rawColors[colorOffset] = this.rgb.decodedRed; rawColors[colorOffset + 1] = this.rgb.decodedGreen; @@ -3233,7 +3346,8 @@ class PointFormat10Decompressor implements PointDecompressor { if (this.first) { this.stream.getUint32(); this.point.readSizes(); - this.rgb.readSizes(); + const skippedRgbByteLength = this.rgb ? 0 : this.stream.getUint32(); + this.rgb?.readSizes(); const skippedNirByteLength = this.nir ? 0 : this.stream.getUint32(); this.nir?.readSizes(); const skippedWavePacketByteLength = this.wavePacket ? 0 : this.stream.getUint32(); @@ -3247,7 +3361,11 @@ class PointFormat10Decompressor implements PointDecompressor { } } this.point.readData(); - this.rgb.readData(); + if (this.rgb) { + this.rgb.readData(); + } else { + this.stream.consume(skippedRgbByteLength); + } if (this.nir) { this.nir.readData(); } else { @@ -3484,8 +3602,8 @@ function writePoint14ToPointDataTarget( function writePoint14ToPointDataArrays( point: Point14, positions: Float32Array | Float64Array, - intensities: Uint16Array, - classifications: Uint8Array, + intensities: Uint16Array | null | undefined, + classifications: Uint8Array | null | undefined, scale: [number, number, number], offset: [number, number, number], targetPointIndex: number @@ -3494,8 +3612,12 @@ function writePoint14ToPointDataArrays( positions[positionOffset] = point.x * scale[0] + offset[0]; positions[positionOffset + 1] = point.y * scale[1] + offset[1]; positions[positionOffset + 2] = point.z * scale[2] + offset[2]; - intensities[targetPointIndex] = point.intensity; - classifications[targetPointIndex] = point.classification; + if (intensities) { + intensities[targetPointIndex] = point.intensity; + } + if (classifications) { + classifications[targetPointIndex] = point.classification; + } } function writeRgbToPointDataTarget(