diff --git a/docs/modules/las/formats/las.md b/docs/modules/las/formats/las.md index 9aff996954..c93d98dec2 100644 --- a/docs/modules/las/formats/las.md +++ b/docs/modules/las/formats/las.md @@ -84,6 +84,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. | | 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 2b9fd732f4..81a0fc274e 100644 --- a/modules/copc/src/copc-source-loader.ts +++ b/modules/copc/src/copc-source-loader.ts @@ -14,6 +14,7 @@ import type { GetTileDataParameters } from '@loaders.gl/loader-utils'; import { + createLAZChunkDecoderCursor, DataSource, concatenateArrayBuffersFromArray, decodeLAZChunkInBatches @@ -270,11 +271,19 @@ export class COPCTileSource return null; } + const nativeOrigin = this.getNativeTileCenter(tile.id); + const cartographicOrigin = this.projectPoint(nativeOrigin); + + if ( + this.options.copc?.decoder === 'typescript-laz' && + supportsDirectCOPCPointDataOutput(copc.header.pointDataRecordFormat) + ) { + return await this.loadTypeScriptTileContent(copc, node, nativeOrigin, cartographicOrigin); + } + const view = await this.loadPointDataView(copc, node); const pointCount = view.pointCount; const positions = new Float32Array(pointCount * 3); - const nativeOrigin = this.getNativeTileCenter(tile.id); - const cartographicOrigin = this.projectPoint(nativeOrigin); const colors = this.createColorArray(view, pointCount); this.populateTileAttributes(view, positions, colors, nativeOrigin, cartographicOrigin); @@ -282,6 +291,78 @@ export class COPCTileSource return this.createTileContentResult(pointCount, positions, colors, cartographicOrigin); } + /** Decode a COPC node directly into the typed attributes used for rendering. */ + protected async loadTypeScriptTileContent( + copc: Copc, + node: Hierarchy.Node, + nativeOrigin: number[], + cartographicOrigin: number[] + ) { + const pointCount = node.pointCount; + const compressed = await Copc.loadCompressedPointDataBuffer(this._urlOrGetter, node); + const nativePositions = new Float64Array(pointCount * 3); + const positions = new Float32Array(pointCount * 3); + const colors = pointFormatHasColor(copc.header.pointDataRecordFormat) + ? new Uint16Array(pointCount * 3) + : null; + const cursor = createLAZChunkDecoderCursor(compressed, { + pointCount, + pointDataRecordFormat: copc.header.pointDataRecordFormat, + pointDataRecordLength: copc.header.pointDataRecordLength + }); + + const decodedPointCount = cursor.decodeIntoPointData( + { + positions: nativePositions, + intensities: new Uint16Array(pointCount), + classifications: new Uint8Array(pointCount), + rawColors: colors, + pointOffset: 0, + scale: copc.header.scale, + offset: copc.header.offset + }, + pointCount + ); + if (decodedPointCount !== pointCount) { + throw new Error( + `COPC TypeScript LAZ decoder produced ${decodedPointCount} points; expected ${pointCount}` + ); + } + + this.transformTilePositions(nativePositions, positions, nativeOrigin, cartographicOrigin); + return this.createTileContentResult(pointCount, positions, colors, cartographicOrigin); + } + + /** Transform decoded native positions into tile-relative render coordinates. */ + protected transformTilePositions( + nativePositions: Float64Array, + positions: Float32Array, + nativeOrigin: number[], + cartographicOrigin: number[] + ): void { + if (!this._projection) { + for (let index = 0; index < nativePositions.length; index += 3) { + positions[index] = nativePositions[index] - nativeOrigin[0]; + positions[index + 1] = nativePositions[index + 1] - nativeOrigin[1]; + positions[index + 2] = nativePositions[index + 2] - nativeOrigin[2]; + } + return; + } + + for (let index = 0; index < nativePositions.length; index += 3) { + const nativeZ = nativePositions[index + 2]; + // projectPoint clones its input because proj4 mutates arrays. This temporary is already owned. + const cartographicPosition = this._projection.project([ + nativePositions[index], + nativePositions[index + 1], + nativeZ + ]); + positions[index] = cartographicPosition[0] - cartographicOrigin[0]; + positions[index + 1] = cartographicPosition[1] - cartographicOrigin[1]; + positions[index + 2] = nativeZ - nativeOrigin[2]; + } + } + async _initCopc(url: string) { const copc = await Copc.create(this._urlOrGetter); const hierarchy = await Copc.loadHierarchyPage(this._urlOrGetter, copc.info.rootHierarchyPage); @@ -784,6 +865,16 @@ function normalizeProjectionDefinition(projectionData: string): string { return horizontalWktMatch?.[1] || projectionData; } +/** Return whether a valid COPC point format supports direct typed point-data output. */ +function supportsDirectCOPCPointDataOutput(pointDataRecordFormat: number): boolean { + return pointDataRecordFormat >= 6 && pointDataRecordFormat <= 8; +} + +/** Return whether a LAS point format contains RGB channels. */ +function pointFormatHasColor(pointDataRecordFormat: number): boolean { + return pointDataRecordFormat === 7 || pointDataRecordFormat === 8; +} + /** Create the COPC package byte-range getter for URL/path and Blob inputs. */ function createCOPCGetter(data: string | Blob, url: string): string | Getter { if (typeof data === 'string') { diff --git a/modules/copc/test/copc-source.spec.ts b/modules/copc/test/copc-source.spec.ts index e76a7bab1e..118223b8a5 100644 --- a/modules/copc/test/copc-source.spec.ts +++ b/modules/copc/test/copc-source.spec.ts @@ -82,6 +82,49 @@ test('COPCSourceLoader#loads tile content with TypeScript LAZ decoder', async t t.end(); }); +test('COPCSourceLoader#TypeScript tile attributes match laz-perf', async t => { + if (isBrowser) { + t.comment('Skipping browser parity until laz-perf wasm is served as an asset'); + t.end(); + return; + } + + const lazPerfSource = COPCSourceLoader.createDataSource(ELLIPSOID_FILE_PATH, {}); + const typescriptSource = COPCSourceLoader.createDataSource(ELLIPSOID_FILE_PATH, { + copc: {decoder: 'typescript-laz'} + }); + await Promise.all([lazPerfSource.initialize(), typescriptSource.initialize()]); + + const rootTile = await typescriptSource.getRootTile(); + const [lazPerfContent, typescriptContent] = await Promise.all([ + lazPerfSource.loadTileContent(rootTile), + typescriptSource.loadTileContent(rootTile) + ]); + const lazPerfPositions = lazPerfContent?.data.data.getChild('POSITION'); + const typescriptPositions = typescriptContent?.data.data.getChild('POSITION'); + const lazPerfColors = lazPerfContent?.data.data.getChild('COLOR_0'); + const typescriptColors = typescriptContent?.data.data.getChild('COLOR_0'); + + t.equal(typescriptContent?.pointCount, lazPerfContent?.pointCount, 'point counts match'); + t.deepEqual( + Array.from({length: rootTile.pointCount}, (_, index) => + typescriptPositions?.get(index)?.toArray() + ), + Array.from({length: rootTile.pointCount}, (_, index) => + lazPerfPositions?.get(index)?.toArray() + ), + 'tile-relative positions match laz-perf' + ); + t.deepEqual( + Array.from({length: rootTile.pointCount}, (_, index) => + typescriptColors?.get(index)?.toArray() + ), + Array.from({length: rootTile.pointCount}, (_, index) => lazPerfColors?.get(index)?.toArray()), + 'raw colors match laz-perf' + ); + t.end(); +}); + test('COPCSourceLoader#loads tile content from a Blob', async t => { if (isBrowser) { t.comment('Skipping browser content decode until laz-perf wasm is served as an asset');