diff --git a/src/lib/compat/data-table.ts b/src/lib/compat/data-table.ts index 64963f02..71602cd1 100644 --- a/src/lib/compat/data-table.ts +++ b/src/lib/compat/data-table.ts @@ -39,12 +39,6 @@ const GEOMETRIC_COLS = [ ] as const; const COLOR_DC_COLS = ['f_dc_0', 'f_dc_1', 'f_dc_2'] as const; -const standardColumnSet = new Set([ - ...POSITION_COLS, - ...GEOMETRIC_COLS, - ...COLOR_DC_COLS -]); - /** * Enumerate the canonical column names a source exposes, in the same order * {@link materializeToDataTable} produces its columns — derived purely from @@ -91,11 +85,15 @@ const detectShBands = (dataTable: DataTable): SHBands => { throw new Error(`dataTableToChunkSource: unrecognized f_rest_* count: ${count}`); }; -const detectExtras = (dataTable: DataTable): ExtraColumn[] => { +const detectExtras = ( + dataTable: DataTable, + standardColumns: ReadonlySet, + hasColor: boolean +): ExtraColumn[] => { const extras: ExtraColumn[] = []; for (const c of dataTable.columns) { - if (standardColumnSet.has(c.name)) continue; - if (/^f_rest_\d+$/.test(c.name)) continue; + if (standardColumns.has(c.name)) continue; + if (hasColor && /^f_rest_\d+$/.test(c.name)) continue; const type: 'float32' | 'uint32' = ( c.dataType === 'float32' || c.dataType === 'float64' ) ? 'float32' : 'uint32'; @@ -171,12 +169,17 @@ const dataTableToChunkSource = ( const count = indices ? indices.length : dataTable.numRows; const shBands = detectShBands(dataTable); const numRest = SH_REST_COUNTS[shBands]; - const extras = detectExtras(dataTable); const transform: Transform = dataTable.transform; const hasPosition = POSITION_COLS.every(c => dataTable.hasColumn(c)); const hasGeometric = GEOMETRIC_COLS.every(c => dataTable.hasColumn(c)); const hasColor = COLOR_DC_COLS.every(c => dataTable.hasColumn(c)); + const standardColumns = new Set([ + ...(hasPosition ? POSITION_COLS : []), + ...(hasGeometric ? GEOMETRIC_COLS : []), + ...(hasColor ? COLOR_DC_COLS : []) + ]); + const extras = detectExtras(dataTable, standardColumns, hasColor); const hasOther = extras.length > 0; const col = (name: string): Float32Array => dataTable.getColumnByName(name)!.data as Float32Array; diff --git a/src/lib/readers/read-ply.ts b/src/lib/readers/read-ply.ts index 55b6676b..9aec2fc1 100644 --- a/src/lib/readers/read-ply.ts +++ b/src/lib/readers/read-ply.ts @@ -632,10 +632,15 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise(['x', 'y', 'z', ...GEOMETRIC_COLS, ...COLOR_DC_COLS]); + // Complete canonical layers use their fixed layouts. Properties from an + // incomplete layer remain as `other` extras instead of being discarded. + const standard = new Set([ + ...(hasPosition ? ['x', 'y', 'z'] : []), + ...(hasGeometric ? GEOMETRIC_COLS : []), + ...(hasColor ? COLOR_DC_COLS : []) + ]); const extras: ExtraColumn[] = properties - .filter(p => !standard.has(p.name) && !/^f_rest_\d+$/.test(p.name)) + .filter(p => !standard.has(p.name) && !(hasColor && /^f_rest_\d+$/.test(p.name))) .map((p) => { const type: 'float32' | 'uint32' = p.type === 'float' || p.type === 'double' ? 'float32' : 'uint32'; return { name: p.name, type }; diff --git a/test/ply-streaming.test.mjs b/test/ply-streaming.test.mjs index eba3a326..fa386d15 100644 --- a/test/ply-streaming.test.mjs +++ b/test/ply-streaming.test.mjs @@ -28,6 +28,7 @@ import { import { mapSource } from '../src/lib/ops/index.js'; import { decodePlyToDataTable, readPly } from '../src/lib/readers/read-ply.js'; import { createChunkDataPool } from '../src/lib/chunk/index.js'; +import { materializeToDataTable } from '../src/lib/compat/data-table.js'; import { writePlyStreaming } from '../src/lib/writers/write-ply-streaming.js'; const SH_COEFFS = [0, 3, 8, 15]; @@ -159,6 +160,27 @@ describe('streaming PLY pipeline (chunked read -> transform -> streaming write)' } }); + it('preserves 2DGS geometric properties when scale_2 is absent', async () => { + const dt = makeCanonicalDataTable(5); + dt.removeColumn('scale_2'); + const pool = createChunkDataPool({ chunkSize: 2 }); + + const src = await readPly(await sourceFromBytes(encodePlyBinary(dt)), pool); + assert.ok(!src.meta.availableLayers.has('geometric')); + assert.ok(src.meta.availableLayers.has('other')); + assert.deepStrictEqual( + src.meta.extraColumns.map(column => column.name), + ['rot_0', 'rot_1', 'rot_2', 'rot_3', 'scale_0', 'scale_1', 'opacity'] + ); + + const out = await materializeToDataTable(src, pool); + assert.deepStrictEqual([...out.columnNames].sort(), [...dt.columnNames].sort()); + for (const name of dt.columnNames) { + assert.deepStrictEqual(out.getColumnByName(name).data, dt.getColumnByName(name).data, `column '${name}' value mismatch`); + } + await src.close(); + }); + it('streams from disk with a bounded, scene-size-independent memory footprint', async () => { const dir = await mkdtemp(join(tmpdir(), 'ply-streaming-')); const inPath = join(dir, 'in.ply'); diff --git a/test/source.test.mjs b/test/source.test.mjs index bef0173b..cdfead7c 100644 --- a/test/source.test.mjs +++ b/test/source.test.mjs @@ -71,6 +71,24 @@ describe('ChunkSource data model', () => { assertTablesEqual(out, dt, 'partial round-trip'); }); + it('preserves columns from an incomplete canonical layer as extras', async () => { + const dt = createTestDataTable(5); + dt.removeColumn('scale_2'); + const chunkSize = 2; + + const src = dataTableToChunkSource(dt, chunkSize); + assert.ok(!src.meta.availableLayers.has('geometric')); + assert.ok(src.meta.availableLayers.has('other')); + assert.deepStrictEqual( + src.meta.extraColumns.map(column => column.name), + ['scale_0', 'scale_1', 'opacity', 'rot_0', 'rot_1', 'rot_2', 'rot_3'] + ); + + const pool = createChunkDataPool({ chunkSize }); + const out = await materializeToDataTable(src, pool); + assertTablesEqual(out, dt, 'incomplete canonical layer round-trip'); + }); + it('compact() materializes a source identically', async () => { const dt = createTestDataTable(7, { includeSH: true, shBands: 1 }); const chunkSize = 3; // -> chunks of 3, 3, 1