Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/modules/las/formats/las.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
95 changes: 93 additions & 2 deletions modules/copc/src/copc-source-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
GetTileDataParameters
} from '@loaders.gl/loader-utils';
import {
createLAZChunkDecoderCursor,
DataSource,
concatenateArrayBuffersFromArray,
decodeLAZChunkInBatches
Expand Down Expand Up @@ -270,18 +271,98 @@ 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);

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);
Expand Down Expand Up @@ -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') {
Expand Down
43 changes: 43 additions & 0 deletions modules/copc/test/copc-source.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading