From 1723aab1009e872ba2b8f75fc9a18623517b8342 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 7 Apr 2026 11:43:35 +0100 Subject: [PATCH 01/13] latest --- src/lib/data-table/combine.ts | 44 ++- src/lib/data-table/data-table.ts | 10 +- src/lib/data-table/transform.ts | 271 +++++++++++++++++-- src/lib/index.ts | 3 +- src/lib/process.ts | 33 ++- src/lib/readers/read-ksplat.ts | 3 +- src/lib/readers/read-lcc.ts | 5 +- src/lib/readers/read-ply.ts | 19 +- src/lib/readers/read-sog.ts | 3 +- src/lib/readers/read-splat.ts | 3 +- src/lib/readers/read-spz.ts | 3 +- src/lib/readers/read-voxel.ts | 3 +- src/lib/utils/math.ts | 136 +++++++++- src/lib/write.ts | 24 +- src/lib/writers/write-lod.ts | 17 +- src/lib/writers/write-sog.ts | 16 +- src/lib/writers/write-voxel.ts | 30 +-- test/decimate.test.mjs | 8 +- test/source-transform.test.mjs | 445 +++++++++++++++++++++++++++++++ test/transforms.test.mjs | 111 +++++--- 20 files changed, 1067 insertions(+), 120 deletions(-) create mode 100644 test/source-transform.test.mjs diff --git a/src/lib/data-table/combine.ts b/src/lib/data-table/combine.ts index e8e6a63e..05df096a 100644 --- a/src/lib/data-table/combine.ts +++ b/src/lib/data-table/combine.ts @@ -1,4 +1,6 @@ import { Column, DataTable, TypedArray } from './data-table'; +import { computeWriteTransform, Transform, transformColumns } from './transform'; +import { logger } from '../utils/logger'; /** * Combines multiple DataTables into a single DataTable. @@ -7,6 +9,9 @@ import { Column, DataTable, TypedArray } from './data-table'; * columns that don't exist in all tables will have undefined values for * rows from tables lacking that column. * + * If tables have differing source transforms, all data is first converted + * to engine coordinate space (identity transform) before combining. + * * @param dataTables - Array of DataTables to combine. * @returns A new DataTable containing all rows from all input tables. * @@ -18,10 +23,31 @@ import { Column, DataTable, TypedArray } from './data-table'; */ const combine = (dataTables: DataTable[]) : DataTable => { if (dataTables.length === 1) { - // nothing to combine return dataTables[0]; } + // Check if all transforms match the first table's transform + const refTransform = dataTables[0].transform; + const allMatch = dataTables.every((dt) => { + const delta = computeWriteTransform(dt.transform, refTransform); + return !delta; + }); + + let tables = dataTables; + let resultTransform = refTransform; + + if (!allMatch) { + logger.warn('Combining DataTables with different source transforms; converting to engine space.'); + tables = dataTables.map((dt) => { + const delta = computeWriteTransform(dt.transform, Transform.IDENTITY); + if (!delta) return dt; + const allNames = dt.columnNames; + const cols = transformColumns(dt, allNames, delta); + return new DataTable(allNames.map(name => new Column(name, cols.get(name)!))); + }); + resultTransform = Transform.IDENTITY; + } + const findMatchingColumn = (columns: Column[], column: Column) => { for (let i = 0; i < columns.length; ++i) { if (columns[i].name === column.name && @@ -32,10 +58,10 @@ const combine = (dataTables: DataTable[]) : DataTable => { return null; }; - // make unique list of columns where name and type much match - const columns = dataTables[0].columns.slice(); - for (let i = 1; i < dataTables.length; ++i) { - const dataTable = dataTables[i]; + // make unique list of columns where name and type must match + const columns = tables[0].columns.slice(); + for (let i = 1; i < tables.length; ++i) { + const dataTable = tables[i]; for (let j = 0; j < dataTable.columns.length; ++j) { if (!findMatchingColumn(columns, dataTable.columns[j])) { columns.push(dataTable.columns[j]); @@ -44,19 +70,19 @@ const combine = (dataTables: DataTable[]) : DataTable => { } // count total number of rows - const totalRows = dataTables.reduce((sum, dataTable) => sum + dataTable.numRows, 0); + const totalRows = tables.reduce((sum, dataTable) => sum + dataTable.numRows, 0); // construct output dataTable const resultColumns = columns.map((column) => { const constructor = column.data.constructor as new (length: number) => TypedArray; return new Column(column.name, new constructor(totalRows)); }); - const result = new DataTable(resultColumns); + const result = new DataTable(resultColumns, resultTransform); // copy data let rowOffset = 0; - for (let i = 0; i < dataTables.length; ++i) { - const dataTable = dataTables[i]; + for (let i = 0; i < tables.length; ++i) { + const dataTable = tables[i]; for (let j = 0; j < dataTable.columns.length; ++j) { const column = dataTable.columns[j]; diff --git a/src/lib/data-table/data-table.ts b/src/lib/data-table/data-table.ts index 882ba0d3..d73cd838 100644 --- a/src/lib/data-table/data-table.ts +++ b/src/lib/data-table/data-table.ts @@ -1,3 +1,5 @@ +import { Transform } from '../utils/math'; + /** * Union of all typed array types supported for column data. */ @@ -84,8 +86,9 @@ type Row = { */ class DataTable { columns: Column[]; + transform: Transform; - constructor(columns: Column[]) { + constructor(columns: Column[], transform?: Transform) { if (columns.length === 0) { throw new Error('DataTable must have at least one column'); } @@ -98,6 +101,7 @@ class DataTable { } this.columns = columns; + this.transform = transform ? transform.clone() : Transform.IDENTITY; } // rows @@ -215,13 +219,13 @@ class DataTable { } if (!rows) { - return new DataTable(srcColumns.map(c => c.clone())); + return new DataTable(srcColumns.map(c => c.clone()), this.transform); } const result = new DataTable(srcColumns.map((c) => { const constructor = c.data.constructor as new (length: number) => TypedArray; return new Column(c.name, new constructor(rows.length)); - })); + }), this.transform); for (let i = 0; i < result.numColumns; ++i) { const src = srcColumns[i].data; diff --git a/src/lib/data-table/transform.ts b/src/lib/data-table/transform.ts index f5f856ed..1d0179a0 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -1,12 +1,246 @@ import { Mat3, Mat4, Quat, Vec3 } from 'playcanvas'; -import { DataTable } from './data-table'; +import { DataTable, TypedArray } from './data-table'; +import { Transform } from '../utils/math'; import { RotateSH } from '../utils/rotate-sh'; const shNames = new Array(45).fill('').map((_, i) => `f_rest_${i}`); -const v = new Vec3(); -const q = new Quat(); +const _v = new Vec3(); +const _q = new Quat(); + +// -- Helpers for on-demand column generation -- + +/** + * Computes the delta transform needed to convert raw data from its current + * coordinate system into the output format's coordinate system. + * + * @param transform - The DataTable's current source transform. + * @param outputFormatTransform - The output format's expected transform. + * @returns The delta transform to apply to raw data, or null if it is identity. + */ +const computeWriteTransform = (transform: Transform, outputFormatTransform: Transform): Transform | null => { + const delta = new Transform().invert(outputFormatTransform).mul(transform); + return delta.isIdentity() ? null : delta; +}; + +/** + * Detects how many SH bands (0-3) the DataTable has. + * @ignore + */ +const detectSHBands = (dataTable: DataTable): number => { + return ({ '9': 1, '24': 2, '-1': 3 } as Record)[String(shNames.findIndex(n => !dataTable.hasColumn(n)))] ?? 0; +}; + +/** + * Generates transformed typed arrays for requested columns, applying the given + * transform. Columns unaffected by the transform return references to the + * original arrays (zero copy). + * + * @param dataTable - The source DataTable. + * @param columnNames - Which columns to produce. + * @param delta - The transform to apply. If identity or null, original arrays are returned. + * @returns A map of column name to typed array. + */ +const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Transform | null): Map => { + const result = new Map(); + + if (!delta || delta.isIdentity()) { + for (const name of columnNames) { + const col = dataTable.getColumnByName(name); + if (col) result.set(name, col.data); + } + return result; + } + + const numRows = dataTable.numRows; + const mat = new Mat4(); + delta.getMatrix(mat); + + const r = delta.rotation; + const s = delta.scale; + + // Categorize requested columns + const posNames = ['x', 'y', 'z']; + const rotNames = ['rot_0', 'rot_1', 'rot_2', 'rot_3']; + const scaleNames = ['scale_0', 'scale_1', 'scale_2']; + + const needPos = posNames.every(n => columnNames.includes(n) && dataTable.hasColumn(n)); + const needRot = rotNames.every(n => columnNames.includes(n) && dataTable.hasColumn(n)); + const needScale = scaleNames.some(n => columnNames.includes(n) && dataTable.hasColumn(n)) && s !== 1; + + const shBands = detectSHBands(dataTable); + const shCoeffsPerChannel = [0, 3, 8, 15][shBands]; + const requestedSH = shBands > 0 && shNames.slice(0, shCoeffsPerChannel * 3).some(n => columnNames.includes(n)); + + // Position columns + if (needPos) { + const srcX = dataTable.getColumnByName('x')!.data; + const srcY = dataTable.getColumnByName('y')!.data; + const srcZ = dataTable.getColumnByName('z')!.data; + const dstX = new Float32Array(numRows); + const dstY = new Float32Array(numRows); + const dstZ = new Float32Array(numRows); + + for (let i = 0; i < numRows; ++i) { + _v.set(srcX[i], srcY[i], srcZ[i]); + mat.transformPoint(_v, _v); + dstX[i] = _v.x; + dstY[i] = _v.y; + dstZ[i] = _v.z; + } + + result.set('x', dstX); + result.set('y', dstY); + result.set('z', dstZ); + } + + // Rotation columns + if (needRot) { + const src0 = dataTable.getColumnByName('rot_0')!.data; + const src1 = dataTable.getColumnByName('rot_1')!.data; + const src2 = dataTable.getColumnByName('rot_2')!.data; + const src3 = dataTable.getColumnByName('rot_3')!.data; + const dst0 = new Float32Array(numRows); + const dst1 = new Float32Array(numRows); + const dst2 = new Float32Array(numRows); + const dst3 = new Float32Array(numRows); + + for (let i = 0; i < numRows; ++i) { + _q.set(src1[i], src2[i], src3[i], src0[i]).mul2(r, _q); + dst0[i] = _q.w; + dst1[i] = _q.x; + dst2[i] = _q.y; + dst3[i] = _q.z; + } + + result.set('rot_0', dst0); + result.set('rot_1', dst1); + result.set('rot_2', dst2); + result.set('rot_3', dst3); + } + + // Scale columns (only affected when uniform scale != 1) + if (needScale) { + const logS = Math.log(s); + for (const name of scaleNames) { + if (!columnNames.includes(name) || !dataTable.hasColumn(name)) continue; + const src = dataTable.getColumnByName(name)!.data; + const dst = new Float32Array(numRows); + for (let i = 0; i < numRows; ++i) { + dst[i] = src[i] + logS; + } + result.set(name, dst); + } + } + + // SH columns + if (requestedSH) { + const mat3 = new Mat3().setFromQuat(r); + const rotateSH = new RotateSH(mat3); + const shCoeffs = new Float32Array(shCoeffsPerChannel); + + const shData: Float32Array[][] = []; + for (let j = 0; j < 3; ++j) { + const channelSrc: Float32Array[] = []; + const channelDst: Float32Array[] = []; + for (let k = 0; k < shCoeffsPerChannel; ++k) { + const name = shNames[k + j * shCoeffsPerChannel]; + channelSrc.push(dataTable.getColumnByName(name)!.data as Float32Array); + channelDst.push(new Float32Array(numRows)); + } + shData.push(channelSrc); + shData.push(channelDst); + } + + for (let i = 0; i < numRows; ++i) { + for (let j = 0; j < 3; ++j) { + const channelSrc = shData[j * 2]; + const channelDst = shData[j * 2 + 1]; + for (let k = 0; k < shCoeffsPerChannel; ++k) { + shCoeffs[k] = channelSrc[k][i]; + } + rotateSH.apply(shCoeffs); + for (let k = 0; k < shCoeffsPerChannel; ++k) { + channelDst[k][i] = shCoeffs[k]; + } + } + } + + for (let j = 0; j < 3; ++j) { + const channelDst = shData[j * 2 + 1]; + for (let k = 0; k < shCoeffsPerChannel; ++k) { + const name = shNames[k + j * shCoeffsPerChannel]; + if (columnNames.includes(name)) { + result.set(name, channelDst[k]); + } + } + } + } + + // All remaining requested columns: return original array references + for (const name of columnNames) { + if (!result.has(name)) { + const col = dataTable.getColumnByName(name); + if (col) result.set(name, col.data); + } + } + + return result; +}; + +/** + * Transforms a point from engine space to raw data space using the inverse + * of the given source transform. + * + * @param t - The source transform (source -> engine). + * @param point - The point in engine space (modified in-place). + * @returns The point in raw data space. + */ +const inverseTransformPoint = (t: Transform, point: Vec3): Vec3 => { + const inv = new Transform().invert(t); + const mat = new Mat4(); + inv.getMatrix(mat); + mat.transformPoint(point, point); + return point; +}; + +/** + * Transforms an AABB from engine space to raw data space. The result is + * a (possibly conservative) AABB that contains the transformed box. + * + * @param t - The source transform (source -> engine). + * @param min - The AABB minimum in engine space (modified in-place to output min). + * @param max - The AABB maximum in engine space (modified in-place to output max). + */ +const inverseTransformAABB = (t: Transform, min: Vec3, max: Vec3): void => { + const inv = new Transform().invert(t); + const mat = new Mat4(); + inv.getMatrix(mat); + + const corners = [ + new Vec3(min.x, min.y, min.z), + new Vec3(max.x, min.y, min.z), + new Vec3(min.x, max.y, min.z), + new Vec3(max.x, max.y, min.z), + new Vec3(min.x, min.y, max.z), + new Vec3(max.x, min.y, max.z), + new Vec3(min.x, max.y, max.z), + new Vec3(max.x, max.y, max.z) + ]; + + mat.transformPoint(corners[0], corners[0]); + min.copy(corners[0]); + max.copy(corners[0]); + + for (let i = 1; i < 8; ++i) { + mat.transformPoint(corners[i], corners[i]); + min.min(corners[i]); + max.max(corners[i]); + } +}; + +// -- Legacy in-place transform function -- /** * Applies a spatial transformation to splat data in-place. @@ -35,7 +269,7 @@ const transform = (dataTable: DataTable, t: Vec3, r: Quat, s: number): void => { const hasTranslation = ['x', 'y', 'z'].every(c => dataTable.hasColumn(c)); const hasRotation = ['rot_0', 'rot_1', 'rot_2', 'rot_3'].every(c => dataTable.hasColumn(c)); const hasScale = ['scale_0', 'scale_1', 'scale_2'].every(c => dataTable.hasColumn(c)); - const shBands = { '9': 1, '24': 2, '-1': 3 }[shNames.findIndex(v => !dataTable.hasColumn(v))] ?? 0; + const shBands = detectSHBands(dataTable); const shCoeffs = new Float32Array([0, 3, 8, 15][shBands]); const row: any = {}; @@ -43,19 +277,19 @@ const transform = (dataTable: DataTable, t: Vec3, r: Quat, s: number): void => { dataTable.getRow(i, row); if (hasTranslation) { - v.set(row.x, row.y, row.z); - mat.transformPoint(v, v); - row.x = v.x; - row.y = v.y; - row.z = v.z; + _v.set(row.x, row.y, row.z); + mat.transformPoint(_v, _v); + row.x = _v.x; + row.y = _v.y; + row.z = _v.z; } if (hasRotation) { - q.set(row.rot_1, row.rot_2, row.rot_3, row.rot_0).mul2(r, q); - row.rot_0 = q.w; - row.rot_1 = q.x; - row.rot_2 = q.y; - row.rot_3 = q.z; + _q.set(row.rot_1, row.rot_2, row.rot_3, row.rot_0).mul2(r, _q); + row.rot_0 = _q.w; + row.rot_1 = _q.x; + row.rot_2 = _q.y; + row.rot_3 = _q.z; } if (hasScale && s !== 1) { @@ -82,4 +316,11 @@ const transform = (dataTable: DataTable, t: Vec3, r: Quat, s: number): void => { } }; -export { transform }; +export { + Transform, + transform, + transformColumns, + computeWriteTransform, + inverseTransformPoint, + inverseTransformAABB +}; diff --git a/src/lib/index.ts b/src/lib/index.ts index 8a34a99f..69d6e687 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -2,7 +2,8 @@ export { Column, DataTable } from './data-table/data-table'; export type { TypedArray, ColumnType, Row } from './data-table/data-table'; export { combine } from './data-table/combine'; -export { transform } from './data-table/transform'; +export { Transform } from './utils/math'; +export { transform, transformColumns, computeWriteTransform, inverseTransformPoint, inverseTransformAABB } from './data-table/transform'; export { computeSummary } from './data-table/summary'; export type { ColumnStats, SummaryData } from './data-table/summary'; export { sortMortonOrder } from './data-table/morton-order'; diff --git a/src/lib/process.ts b/src/lib/process.ts index d132ae77..905e80df 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -4,7 +4,7 @@ import { Column, DataTable } from './data-table/data-table'; import { simplifyGaussians } from './data-table/decimate'; import { sortMortonOrder } from './data-table/morton-order'; import { computeSummary, type SummaryData } from './data-table/summary'; -import { transform } from './data-table/transform'; +import { Transform, inverseTransformPoint, inverseTransformAABB } from './data-table/transform'; import { logger } from './utils/logger'; /** @@ -306,17 +306,17 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) switch (processAction.kind) { case 'translate': - transform(result, processAction.value, Quat.IDENTITY, 1); + result.transform = new Transform(processAction.value).mul(result.transform); break; case 'rotate': - transform(result, Vec3.ZERO, new Quat().setFromEulerAngles( + result.transform = new Transform().fromEulers( processAction.value.x, processAction.value.y, processAction.value.z - ), 1); + ).mul(result.transform); break; case 'scale': - transform(result, Vec3.ZERO, Quat.IDENTITY, processAction.value); + result.transform = new Transform(undefined, undefined, processAction.value).mul(result.transform); break; case 'filterNaN': { const infOk = new Set(['opacity']); @@ -375,6 +375,7 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) } } + const prevTransform = result.transform; result = new DataTable(result.columns.map((column) => { if (map.hasOwnProperty(column.name)) { const name = map[column.name]; @@ -383,24 +384,34 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) return column; }).filter(c => c !== null)); + result.transform = prevTransform; } break; } case 'filterBox': { - const { min, max } = processAction; + const rawMin = processAction.min.clone(); + const rawMax = processAction.max.clone(); + if (!result.transform.isIdentity()) { + inverseTransformAABB(result.transform, rawMin, rawMax); + } const predicate = (row: any, rowIndex: number) => { const { x, y, z } = row; - return x >= min.x && x <= max.x && y >= min.y && y <= max.y && z >= min.z && z <= max.z; + return x >= rawMin.x && x <= rawMax.x && y >= rawMin.y && y <= rawMax.y && z >= rawMin.z && z <= rawMax.z; }; result = filter(result, predicate); break; } case 'filterSphere': { - const { center, radius } = processAction; - const radiusSq = radius * radius; + const rawCenter = processAction.center.clone(); + let rawRadius = processAction.radius; + if (!result.transform.isIdentity()) { + inverseTransformPoint(result.transform, rawCenter); + rawRadius /= result.transform.scale; + } + const radiusSq = rawRadius * rawRadius; const predicate = (row: any, rowIndex: number) => { const { x, y, z } = row; - return (x - center.x) ** 2 + (y - center.y) ** 2 + (z - center.z) ** 2 < radiusSq; + return (x - rawCenter.x) ** 2 + (y - rawCenter.y) ** 2 + (z - rawCenter.z) ** 2 < radiusSq; }; result = filter(result, predicate); break; @@ -440,7 +451,9 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) } keepCount = Math.max(0, keepCount); + const prevTransform = result.transform; result = simplifyGaussians(result, keepCount); + result.transform = prevTransform; break; } } diff --git a/src/lib/readers/read-ksplat.ts b/src/lib/readers/read-ksplat.ts index 88daf8b7..57fe3160 100644 --- a/src/lib/readers/read-ksplat.ts +++ b/src/lib/readers/read-ksplat.ts @@ -1,5 +1,6 @@ import { Column, DataTable } from '../data-table/data-table'; import { ReadSource } from '../io/read'; +import { Transform } from '../utils/math'; // Format configuration for different compression modes interface CompressionConfig { @@ -370,7 +371,7 @@ const readKsplat = async (source: ReadSource): Promise => { throw new Error(`Splat count mismatch: expected ${numSplats}, processed ${splatIndex}`); } - return new DataTable(columns); + return new DataTable(columns, new Transform().fromEulers(0, 0, 180)); }; export { readKsplat }; diff --git a/src/lib/readers/read-lcc.ts b/src/lib/readers/read-lcc.ts index 5418ec47..145bb7f3 100644 --- a/src/lib/readers/read-lcc.ts +++ b/src/lib/readers/read-lcc.ts @@ -3,6 +3,7 @@ import { Vec3 } from 'playcanvas'; import { Column, DataTable } from '../data-table/data-table'; import { dirname, join, ReadFileSystem, ReadSource, readFile } from '../io/read'; import { Options } from '../types'; +import { Transform } from '../utils/math'; const kSH_C0 = 0.28209479177387814; const SQRT_2 = 1.414213562373095; @@ -470,13 +471,15 @@ const readLcc = async (fileSystem: ReadFileSystem, filename: string, options: Op new Column('lod', lodColumn) ]; - const result: DataTable[] = [new DataTable(columns)]; + const mainTable = new DataTable(columns, new Transform().fromEulers(90, 0, 180)); + const result: DataTable[] = [mainTable]; // load environment and tag as lod -1 try { const envData = await readFile(fileSystem, relatedFilename('environment.bin')); const envDataTable = deserializeEnvironment(envData, compressInfo, hasSH); envDataTable.addColumn(new Column('lod', new Float32Array(envDataTable.numRows).fill(-1))); + envDataTable.transform = new Transform().fromEulers(90, 0, 180); result.push(envDataTable); } catch (err) { console.warn('Failed to load environment.bin', err); diff --git a/src/lib/readers/read-ply.ts b/src/lib/readers/read-ply.ts index 5b073ced..3aeed23c 100644 --- a/src/lib/readers/read-ply.ts +++ b/src/lib/readers/read-ply.ts @@ -1,6 +1,7 @@ import { isCompressedPly, decompressPly } from './decompress-ply'; import { Column, DataTable } from '../data-table/data-table'; import { ReadSource, ReadStream } from '../io/read'; +import { Transform } from '../utils/math'; type PlyProperty = { name: string; // 'x', f_dc_0', etc @@ -287,16 +288,20 @@ const readPly = async (source: ReadSource): Promise => { elements }; - if (isCompressedPly(plyData)) { - return decompressPly(plyData); - } + let result: DataTable; - const vertexElement = plyData.elements.find(e => e.name === 'vertex'); - if (!vertexElement) { - throw new Error('PLY file does not contain vertex element'); + if (isCompressedPly(plyData)) { + result = decompressPly(plyData); + } else { + const vertexElement = plyData.elements.find(e => e.name === 'vertex'); + if (!vertexElement) { + throw new Error('PLY file does not contain vertex element'); + } + result = vertexElement.dataTable; } - return vertexElement.dataTable; + result.transform = new Transform().fromEulers(0, 0, 180); + return result; }; export { PlyData, readPly }; diff --git a/src/lib/readers/read-sog.ts b/src/lib/readers/read-sog.ts index 2b16912a..e6481cb2 100644 --- a/src/lib/readers/read-sog.ts +++ b/src/lib/readers/read-sog.ts @@ -1,5 +1,6 @@ import { Column, DataTable } from '../data-table/data-table'; import { dirname, join, ReadFileSystem, readFile } from '../io/read'; +import { Transform } from '../utils/math'; import { WebPCodec } from '../utils/webp-codec'; type Meta = { @@ -215,7 +216,7 @@ const readSog = async (fileSystem: ReadFileSystem, filename: string): Promise= 3 ? undefined : new Transform().fromEulers(0, 0, 180)); }; export { readSog }; diff --git a/src/lib/readers/read-splat.ts b/src/lib/readers/read-splat.ts index 7d0cd7e2..d2678311 100644 --- a/src/lib/readers/read-splat.ts +++ b/src/lib/readers/read-splat.ts @@ -1,5 +1,6 @@ import { Column, DataTable } from '../data-table/data-table'; import { ReadSource } from '../io/read'; +import { Transform } from '../utils/math'; /** * Reads an Antimatter15 .splat file containing Gaussian splat data. @@ -125,7 +126,7 @@ const readSplat = async (source: ReadSource): Promise => { } } - return new DataTable(columns); + return new DataTable(columns, new Transform().fromEulers(0, 0, 180)); }; export { readSplat }; diff --git a/src/lib/readers/read-spz.ts b/src/lib/readers/read-spz.ts index 40476d90..4b782a18 100644 --- a/src/lib/readers/read-spz.ts +++ b/src/lib/readers/read-spz.ts @@ -1,5 +1,6 @@ import { Column, DataTable } from '../data-table/data-table'; import { ReadSource } from '../io/read'; +import { Transform } from '../utils/math'; // See https://github.com/nianticlabs/spz for reference implementation @@ -226,7 +227,7 @@ const readSpz = async (source: ReadSource): Promise => { } } - return new DataTable(columns); + return new DataTable(columns, new Transform().fromEulers(0, 0, 180)); }; export { readSpz }; diff --git a/src/lib/readers/read-voxel.ts b/src/lib/readers/read-voxel.ts index fe6eacc6..77446e43 100644 --- a/src/lib/readers/read-voxel.ts +++ b/src/lib/readers/read-voxel.ts @@ -344,7 +344,7 @@ const readVoxel = async ( opacityArr[i] = 5.0; } - return new DataTable([ + const result = new DataTable([ new Column('x', xArr), new Column('y', yArr), new Column('z', zArr), @@ -360,6 +360,7 @@ const readVoxel = async ( new Column('f_dc_2', fdc2), new Column('opacity', opacityArr) ]); + return result; }; export { readVoxel }; diff --git a/src/lib/utils/math.ts b/src/lib/utils/math.ts index b8717541..90bc008c 100644 --- a/src/lib/utils/math.ts +++ b/src/lib/utils/math.ts @@ -1,3 +1,137 @@ +import { Mat4, Quat, Vec3 } from 'playcanvas'; + const sigmoid = (v: number) => 1 / (1 + Math.exp(-v)); -export { sigmoid }; +const _tv = new Vec3(); + +/** + * A source-to-engine coordinate transform comprising translation, rotation + * and uniform scale. Lives alongside a DataTable to describe how raw + * column data maps to PlayCanvas engine coordinates. + * + * @example + * ```ts + * const t = new Transform().fromEulers(0, 0, 180); + * console.log(t.isIdentity()); // false + * + * const inv = new Transform().invert(t); + * console.log(t.mul(inv).isIdentity()); // true + * ``` + */ +class Transform { + translation: Vec3; + rotation: Quat; + scale: number; + + constructor(translation?: Vec3, rotation?: Quat, scale?: number) { + this.translation = translation ? translation.clone() : new Vec3(); + this.rotation = rotation ? rotation.clone() : new Quat(); + this.scale = scale ?? 1; + } + + /** + * Fills the provided Mat4 with the TRS matrix for this transform. + * + * @param result - The Mat4 to fill. + * @returns The filled Mat4. + */ + getMatrix(result: Mat4): Mat4 { + return result.setTRS(this.translation, this.rotation, new Vec3(this.scale, this.scale, this.scale)); + } + + /** + * Tests whether this transform is effectively identity within the given tolerance. + * + * @param epsilon - Floating-point tolerance. Defaults to 1e-6. + * @returns True if identity within the tolerance. + */ + isIdentity(epsilon = 1e-6): boolean { + const t = this.translation; + const r = this.rotation; + if (Math.abs(t.x) > epsilon || Math.abs(t.y) > epsilon || Math.abs(t.z) > epsilon) { + return false; + } + // identity quaternion is (0, 0, 0, 1) or (0, 0, 0, -1) + if (Math.abs(r.x) > epsilon || Math.abs(r.y) > epsilon || Math.abs(r.z) > epsilon || Math.abs(Math.abs(r.w) - 1) > epsilon) { + return false; + } + if (Math.abs(this.scale - 1) > epsilon) { + return false; + } + return true; + } + + /** + * Creates a deep copy of this transform. + * + * @returns A new Transform with the same values. + */ + clone(): Transform { + return new Transform(this.translation, this.rotation, this.scale); + } + + /** + * Sets this transform to the inverse of the given source transform. + * + * @param src - The transform to invert. Defaults to this (in-place). + * @returns This transform (for chaining). + */ + invert(src: Transform = this): Transform { + this.scale = 1 / src.scale; + this.rotation.copy(src.rotation).invert(); + this.translation.copy(src.translation).mulScalar(-this.scale); + this.rotation.transformVector(this.translation, this.translation); + return this; + } + + /** + * Sets this transform to the composition of a * b. Handles aliasing + * (either a or b may be this). + * + * @param a - The first (left) transform. + * @param b - The second (right) transform. + * @returns This transform (for chaining). + */ + mul2(a: Transform, b: Transform): Transform { + // Translation must be computed first using original a.rotation + a.rotation.transformVector(b.translation, _tv); + _tv.mulScalar(a.scale).add(a.translation); + + this.rotation.mul2(a.rotation, b.rotation); + this.scale = a.scale * b.scale; + this.translation.copy(_tv); + return this; + } + + /** + * Sets this transform to this * other. + * + * @param other - The transform to multiply with. + * @returns This transform (for chaining). + */ + mul(other: Transform): Transform { + return this.mul2(this, other); + } + + /** + * Sets this transform to a rotation-only transform from Euler angles in degrees. + * + * @param x - Rotation around X axis in degrees. + * @param y - Rotation around Y axis in degrees. + * @param z - Rotation around Z axis in degrees. + * @returns This transform (for chaining). + */ + fromEulers(x: number, y: number, z: number): Transform { + this.translation.set(0, 0, 0); + this.rotation.setFromEulerAngles(x, y, z); + this.scale = 1; + return this; + } + + /** + * Shared identity instance. Do not mutate. + */ + static IDENTITY = new Transform(); +} + +export { sigmoid, Transform }; diff --git a/src/lib/write.ts b/src/lib/write.ts index 25502995..9dd9e447 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -1,7 +1,9 @@ -import { DataTable } from './data-table/data-table'; +import { Column, DataTable } from './data-table/data-table'; +import { computeWriteTransform, transformColumns } from './data-table/transform'; import { type FileSystem } from './io/write'; import { Options } from './types'; import { logger } from './utils/logger'; +import { Transform } from './utils/math'; import { writeCompressedPly } from './writers/write-compressed-ply'; import { writeCsv } from './writers/write-csv'; import { writeGlb } from './writers/write-glb'; @@ -106,6 +108,20 @@ const getOutputFormat = (filename: string, options: Options): OutputFormat => { * }, fs); * ``` */ +/** + * @param dataTable - The source DataTable. + * @param formatTransform - The target format's expected transform. + * @returns A DataTable with column data in the target format's coordinate space. + * @ignore + */ +const applyWriteTransform = (dataTable: DataTable, formatTransform: Transform): DataTable => { + const delta = computeWriteTransform(dataTable.transform, formatTransform); + if (!delta) return dataTable; + const allNames = dataTable.columnNames; + const cols = transformColumns(dataTable, allNames, delta); + return new DataTable(allNames.map(name => new Column(name, cols.get(name)!))); +}; + const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { const { filename, outputFormat, dataTable, envDataTable, options, createDevice } = writeOptions; @@ -138,7 +154,7 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { }, fs); break; case 'compressed-ply': - await writeCompressedPly({ filename, dataTable }, fs); + await writeCompressedPly({ filename, dataTable: applyWriteTransform(dataTable, new Transform().fromEulers(0, 0, 180)) }, fs); break; case 'ply': await writePly({ @@ -147,13 +163,13 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { comments: [], elements: [{ name: 'vertex', - dataTable: dataTable + dataTable: applyWriteTransform(dataTable, new Transform().fromEulers(0, 0, 180)) }] } }, fs); break; case 'glb': - await writeGlb({ filename, dataTable }, fs); + await writeGlb({ filename, dataTable: applyWriteTransform(dataTable, Transform.IDENTITY) }, fs); break; case 'html': case 'html-bundle': diff --git a/src/lib/writers/write-lod.ts b/src/lib/writers/write-lod.ts index 43115e60..b9f7c910 100644 --- a/src/lib/writers/write-lod.ts +++ b/src/lib/writers/write-lod.ts @@ -2,8 +2,9 @@ import { dirname, resolve } from 'pathe'; import { BoundingBox, Mat4, Quat, Vec3 } from 'playcanvas'; import { writeSog, type DeviceCreator } from './write-sog.js'; -import { TypedArray, DataTable } from '../data-table/data-table'; +import { Column, TypedArray, DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; +import { computeWriteTransform, Transform, transformColumns } from '../data-table/transform'; import { type FileSystem } from '../io/write'; import { BTreeNode, BTree } from '../spatial/b-tree'; import { logger } from '../utils/logger'; @@ -156,7 +157,19 @@ type WriteLodOptions = { * @ignore */ const writeLod = async (options: WriteLodOptions, fs: FileSystem) => { - const { filename, dataTable, envDataTable, iterations, createDevice, chunkCount, chunkExtent } = options; + const { filename, iterations, createDevice, chunkCount, chunkExtent } = options; + + // Transform data into engine space for spatial operations and SOG output + const toEngine = (dt: DataTable): DataTable => { + const delta = computeWriteTransform(dt.transform, Transform.IDENTITY); + if (!delta) return dt; + const allNames = dt.columnNames; + const cols = transformColumns(dt, allNames, delta); + return new DataTable(allNames.map(name => new Column(name, cols.get(name)!))); + }; + + const dataTable = toEngine(options.dataTable); + const envDataTable = options.envDataTable ? toEngine(options.envDataTable) : null; const outputDir = dirname(filename); diff --git a/src/lib/writers/write-sog.ts b/src/lib/writers/write-sog.ts index e734a464..78485dfc 100644 --- a/src/lib/writers/write-sog.ts +++ b/src/lib/writers/write-sog.ts @@ -4,11 +4,12 @@ import { GraphicsDevice } from 'playcanvas'; import { version } from '../../../package.json'; import { Column, DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; +import { computeWriteTransform, transformColumns } from '../data-table/transform'; import { type FileSystem, writeFile, ZipFileSystem } from '../io/write'; import { kmeans } from '../spatial/k-means'; import { quantize1d } from '../spatial/quantize-1d'; import { logger } from '../utils/logger'; -import { sigmoid } from '../utils/math'; +import { sigmoid, Transform } from '../utils/math'; import { WebPCodec } from '../utils/webp-codec'; /** @@ -82,7 +83,16 @@ type WriteSogOptions = { * @ignore */ const writeSog = async (options: WriteSogOptions, fs: FileSystem) => { - const { filename: outputFilename, bundle, dataTable, iterations, createDevice } = options; + const { filename: outputFilename, bundle, iterations, createDevice } = options; + let { dataTable } = options; + + // Transform data into engine coordinate space for SOG output + const delta = computeWriteTransform(dataTable.transform, Transform.IDENTITY); + if (delta) { + const allNames = dataTable.columnNames; + const cols = transformColumns(dataTable, allNames, delta); + dataTable = new DataTable(allNames.map(name => new Column(name, cols.get(name)!))); + } // initialize output stream - use ZipFileSystem for bundled output const zipFs = bundle ? new ZipFileSystem(await fs.createWriter(outputFilename)) : null; @@ -341,7 +351,7 @@ const writeSog = async (options: WriteSogOptions, fs: FileSystem) => { // construct meta.json const meta: any = { - version: 2, + version: 3, asset: { generator: `splat-transform v${version}` }, diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index 8bf6e988..a450cce7 100644 --- a/src/lib/writers/write-voxel.ts +++ b/src/lib/writers/write-voxel.ts @@ -1,9 +1,9 @@ import { MeshoptSimplifier } from 'meshoptimizer/simplifier'; -import { Quat, Vec3 } from 'playcanvas'; +import { Vec3 } from 'playcanvas'; import type { DeviceCreator } from './write-sog'; -import { DataTable } from '../data-table/data-table'; -import { transform } from '../data-table/transform'; +import { Column, DataTable } from '../data-table/data-table'; +import { computeWriteTransform, Transform, transformColumns } from '../data-table/transform'; import { type FileSystem, writeFile } from '../io/write'; import { logger } from '../utils/logger'; import { buildCollisionGlb } from '../voxel/collision-glb'; @@ -206,19 +206,17 @@ const writeVoxel = async (options: WriteVoxelOptions, fs: FileSystem): Promise new Column(name, cols.get(name)!))); const extentsResult = computeGaussianExtents(pcDataTable); const bounds = extentsResult.sceneBounds; diff --git a/test/decimate.test.mjs b/test/decimate.test.mjs index cf390412..c5aa5d4b 100644 --- a/test/decimate.test.mjs +++ b/test/decimate.test.mjs @@ -570,10 +570,10 @@ describe('decimate Integration', () => { assert.strictEqual(result.numRows, 8, 'Should have 8 rows after filtering'); - const xCol = result.getColumnByName('x').data; - for (let i = 0; i < result.numRows; i++) { - assert(xCol[i] > 10, `x[${i}] should be > 10 after transforms`); - } + // Raw data is unchanged by translate/scale (they compose into transform) + // scale(2) from origin after translate(10) doubles the translation too + assertClose(result.transform.translation.x, 20, 1e-5, 'tx'); + assertClose(result.transform.scale, 2.0, 1e-5, 'scale'); }); it('should preserve all columns after merging', () => { diff --git a/test/source-transform.test.mjs b/test/source-transform.test.mjs new file mode 100644 index 00000000..410dda25 --- /dev/null +++ b/test/source-transform.test.mjs @@ -0,0 +1,445 @@ +/** + * Tests for the Transform class, transformColumns, computeWriteTransform, + * inverse helpers, combine with different transforms, and clone behavior. + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert'; + +import { + Column, + DataTable, + Transform, + transformColumns, + computeWriteTransform, + inverseTransformPoint, + inverseTransformAABB, + combine, + processDataTable +} from '../src/lib/index.js'; + +import { createMinimalTestData } from './helpers/test-utils.mjs'; +import { assertClose } from './helpers/summary-compare.mjs'; + +import { Mat4, Quat, Vec3 } from 'playcanvas'; + +// -- Transform class -- + +describe('Transform class', () => { + it('constructor defaults to identity', () => { + const t = new Transform(); + assertClose(t.translation.x, 0, 1e-10, 'tx'); + assertClose(t.translation.y, 0, 1e-10, 'ty'); + assertClose(t.translation.z, 0, 1e-10, 'tz'); + assertClose(t.rotation.x, 0, 1e-10, 'rx'); + assertClose(t.rotation.y, 0, 1e-10, 'ry'); + assertClose(t.rotation.z, 0, 1e-10, 'rz'); + assertClose(t.rotation.w, 1, 1e-10, 'rw'); + assertClose(t.scale, 1, 1e-10, 'scale'); + }); + + it('constructor clones inputs', () => { + const pos = new Vec3(1, 2, 3); + const rot = new Quat().setFromEulerAngles(0, 90, 0); + const t = new Transform(pos, rot, 2); + + pos.set(999, 999, 999); + rot.set(0, 0, 0, 1); + + assertClose(t.translation.x, 1, 1e-10, 'tx'); + assertClose(t.translation.y, 2, 1e-10, 'ty'); + assertClose(t.translation.z, 3, 1e-10, 'tz'); + assertClose(t.scale, 2, 1e-10, 'scale'); + }); + + it('IDENTITY is identity', () => { + assert.ok(Transform.IDENTITY.isIdentity(), 'IDENTITY should be identity'); + }); + + it('fromEulers creates rotation-only transform', () => { + const t = new Transform().fromEulers(0, 0, 180); + assert.ok(!t.isIdentity(), 'euler(0,0,180) should not be identity'); + assertClose(t.translation.x, 0, 1e-10, 'tx'); + assertClose(t.translation.y, 0, 1e-10, 'ty'); + assertClose(t.translation.z, 0, 1e-10, 'tz'); + assertClose(t.scale, 1, 1e-10, 'scale'); + }); + + it('isIdentity with epsilon', () => { + const t = new Transform(new Vec3(1e-7, 0, 0)); + assert.ok(t.isIdentity(1e-6), 'should be identity within epsilon'); + assert.ok(!t.isIdentity(1e-8), 'should not be identity with tight epsilon'); + }); + + it('clone creates independent copy', () => { + const t = new Transform().fromEulers(0, 0, 180); + const c = t.clone(); + + t.translation.set(999, 999, 999); + + assertClose(c.translation.x, 0, 1e-10, 'cloned tx'); + assert.ok(!c.isIdentity(), 'cloned should still have rotation'); + }); + + it('invert produces correct inverse', () => { + const t = new Transform(new Vec3(1, 2, 3), new Quat().setFromEulerAngles(30, 45, 60), 2); + const inv = new Transform().invert(t); + const composed = t.mul(inv); + assert.ok(composed.isIdentity(1e-5), 'T * T^-1 should be identity'); + }); + + it('invert of identity is identity', () => { + const inv = new Transform().invert(Transform.IDENTITY); + assert.ok(inv.isIdentity(), 'inverse of identity should be identity'); + }); + + it('mul composes correctly', () => { + const translate = new Transform(new Vec3(10, 0, 0)); + const scale = new Transform(undefined, undefined, 2); + + // scale then translate: point * scale * translate? No... + // mul(A, B) = A * B. Applied to point: A * (B * point). + // So scale.mul(translate) applies translate first then scale. + // We want: engine = translate * scale * raw + // = translate.mul(scale) applied to point + + const composed = translate.mul(scale); + + // Apply to a point (1, 0, 0): + const mat = new Mat4(); + composed.getMatrix(mat); + const p = new Vec3(1, 0, 0); + mat.transformPoint(p, p); + + // Expected: scale first (1*2=2), then translate (2+10=12) + assertClose(p.x, 12, 1e-5, 'composed point x'); + }); + + it('getMatrix matches TRS', () => { + const t = new Transform(new Vec3(1, 2, 3), new Quat().setFromEulerAngles(0, 90, 0), 2); + const mat = new Mat4(); + t.getMatrix(mat); + + const expected = new Mat4().setTRS( + new Vec3(1, 2, 3), + new Quat().setFromEulerAngles(0, 90, 0), + new Vec3(2, 2, 2) + ); + + for (let i = 0; i < 16; i++) { + assertClose(mat.data[i], expected.data[i], 1e-5, `mat[${i}]`); + } + }); + + it('euler(0,0,180) is self-inverse', () => { + const t = new Transform().fromEulers(0, 0, 180); + const inv = new Transform().invert(t); + const composed = t.clone().mul(inv); + assert.ok(composed.isIdentity(1e-5), 'euler(0,0,180) * inverse should be identity'); + + // Also: applying euler(0,0,180) twice should be identity + const doubled = t.clone().mul(t); + assert.ok(doubled.isIdentity(1e-5), 'euler(0,0,180)^2 should be identity'); + }); +}); + +// -- computeWriteTransform -- + +describe('computeWriteTransform', () => { + it('same-to-same returns null (identity delta)', () => { + const plyTransform = new Transform().fromEulers(0, 0, 180); + const result = computeWriteTransform(plyTransform, new Transform().fromEulers(0, 0, 180)); + assert.strictEqual(result, null, 'same-to-same delta should be null'); + }); + + it('PLY-to-engine returns non-null (needs transform)', () => { + const plyTransform = new Transform().fromEulers(0, 0, 180); + const result = computeWriteTransform(plyTransform, Transform.IDENTITY); + assert.notStrictEqual(result, null, 'PLY-to-engine delta should not be null'); + }); + + it('identity-to-identity returns null', () => { + const result = computeWriteTransform(Transform.IDENTITY, Transform.IDENTITY); + assert.strictEqual(result, null, 'identity-to-identity should be null'); + }); +}); + +// -- transformColumns -- + +describe('transformColumns', () => { + let testData; + + before(() => { + testData = createMinimalTestData(); + }); + + it('returns original arrays when delta is null', () => { + const cols = transformColumns(testData, ['x', 'y', 'z'], null); + assert.strictEqual(cols.get('x'), testData.getColumnByName('x').data, 'should be same reference'); + assert.strictEqual(cols.get('y'), testData.getColumnByName('y').data, 'should be same reference'); + }); + + it('returns original arrays when delta is identity', () => { + const cols = transformColumns(testData, ['x', 'y', 'z'], Transform.IDENTITY); + assert.strictEqual(cols.get('x'), testData.getColumnByName('x').data, 'should be same reference'); + }); + + it('transforms positions with euler(0,0,180)', () => { + const delta = new Transform().fromEulers(0, 0, 180); + const cols = transformColumns(testData, ['x', 'y', 'z'], delta); + const rawX = testData.getColumnByName('x').data; + const rawY = testData.getColumnByName('y').data; + const rawZ = testData.getColumnByName('z').data; + + // euler(0,0,180) negates x and y + for (let i = 0; i < testData.numRows; i++) { + assertClose(cols.get('x')[i], -rawX[i], 1e-5, `x[${i}]`); + assertClose(cols.get('y')[i], -rawY[i], 1e-5, `y[${i}]`); + assertClose(cols.get('z')[i], rawZ[i], 1e-5, `z[${i}]`); + } + }); + + it('returns original arrays for unaffected columns', () => { + const delta = new Transform().fromEulers(0, 0, 180); + const cols = transformColumns(testData, ['opacity', 'f_dc_0'], delta); + assert.strictEqual(cols.get('opacity'), testData.getColumnByName('opacity').data, 'opacity should be same reference'); + assert.strictEqual(cols.get('f_dc_0'), testData.getColumnByName('f_dc_0').data, 'f_dc_0 should be same reference'); + }); + + it('transforms rotation columns', () => { + const delta = new Transform().fromEulers(0, 0, 180); + const cols = transformColumns(testData, ['rot_0', 'rot_1', 'rot_2', 'rot_3'], delta); + + // Should return new arrays (not same reference) + assert.notStrictEqual(cols.get('rot_0'), testData.getColumnByName('rot_0').data, 'rot_0 should be new array'); + }); + + it('transforms scale columns when scale != 1', () => { + const delta = new Transform(undefined, undefined, 2); + const cols = transformColumns(testData, ['scale_0', 'scale_1', 'scale_2'], delta); + const rawScale0 = testData.getColumnByName('scale_0').data; + const logS = Math.log(2); + + for (let i = 0; i < testData.numRows; i++) { + assertClose(cols.get('scale_0')[i], rawScale0[i] + logS, 1e-5, `scale_0[${i}]`); + } + }); + + it('does not transform scale columns when scale == 1', () => { + const delta = new Transform().fromEulers(0, 0, 180); + const cols = transformColumns(testData, ['scale_0'], delta); + assert.strictEqual(cols.get('scale_0'), testData.getColumnByName('scale_0').data, 'scale_0 should be same reference when scale=1'); + }); +}); + +// -- inverseTransformPoint & inverseTransformAABB -- + +describe('inverseTransformPoint', () => { + it('with identity transform is no-op', () => { + const point = new Vec3(1, 2, 3); + inverseTransformPoint(Transform.IDENTITY, point); + assertClose(point.x, 1, 1e-10, 'x'); + assertClose(point.y, 2, 1e-10, 'y'); + assertClose(point.z, 3, 1e-10, 'z'); + }); + + it('with euler(0,0,180) negates x and y', () => { + const t = new Transform().fromEulers(0, 0, 180); + const point = new Vec3(1, 2, 3); + inverseTransformPoint(t, point); + assertClose(point.x, -1, 1e-5, 'x'); + assertClose(point.y, -2, 1e-5, 'y'); + assertClose(point.z, 3, 1e-5, 'z'); + }); +}); + +describe('inverseTransformAABB', () => { + it('with identity transform is no-op', () => { + const min = new Vec3(-1, -2, -3); + const max = new Vec3(1, 2, 3); + inverseTransformAABB(Transform.IDENTITY, min, max); + assertClose(min.x, -1, 1e-10, 'min.x'); + assertClose(max.x, 1, 1e-10, 'max.x'); + }); + + it('with euler(0,0,180) swaps min/max for x and y', () => { + const t = new Transform().fromEulers(0, 0, 180); + const min = new Vec3(-1, -2, -3); + const max = new Vec3(3, 4, 5); + inverseTransformAABB(t, min, max); + + // After negating x and y, then computing AABB: + // x: [-3, 1] -> negated -> [-1, 3] -> AABB min=-3, max=1 + assertClose(min.x, -3, 1e-5, 'min.x'); + assertClose(max.x, 1, 1e-5, 'max.x'); + assertClose(min.y, -4, 1e-5, 'min.y'); + assertClose(max.y, 2, 1e-5, 'max.y'); + assertClose(min.z, -3, 1e-5, 'min.z'); + assertClose(max.z, 5, 1e-5, 'max.z'); + }); +}); + +// -- DataTable transform -- + +describe('DataTable transform', () => { + it('defaults to identity', () => { + const dt = new DataTable([new Column('x', new Float32Array([1]))]); + assert.ok(dt.transform.isIdentity(), 'default should be identity'); + }); + + it('clone preserves transform', () => { + const dt = createMinimalTestData(); + dt.transform = new Transform().fromEulers(0, 0, 180); + + const cloned = dt.clone(); + assert.ok(!cloned.transform.isIdentity(), 'cloned should have non-identity transform'); + + // Verify it's a deep copy + dt.transform.translation.set(999, 999, 999); + assertClose(cloned.transform.translation.x, 0, 1e-10, 'cloned should be independent'); + }); + + it('clone with row selection preserves transform', () => { + const dt = createMinimalTestData(); + dt.transform = new Transform().fromEulers(90, 0, 180); + + const cloned = dt.clone({ rows: [0, 1, 2] }); + assert.ok(!cloned.transform.isIdentity(), 'row-subset clone should have non-identity transform'); + assert.strictEqual(cloned.numRows, 3); + }); +}); + +// -- combine with different transforms -- + +describe('combine with transforms', () => { + it('preserves transform when all tables match', () => { + const dt1 = createMinimalTestData(); + const dt2 = createMinimalTestData(); + dt1.transform = new Transform().fromEulers(0, 0, 180); + dt2.transform = new Transform().fromEulers(0, 0, 180); + + const result = combine([dt1, dt2]); + assert.ok(!result.transform.isIdentity(), 'combined should keep shared transform'); + assert.strictEqual(result.numRows, dt1.numRows + dt2.numRows); + }); + + it('converts to engine space when transforms differ', () => { + const dt1 = createMinimalTestData(); + const dt2 = createMinimalTestData(); + dt1.transform = new Transform().fromEulers(0, 0, 180); + dt2.transform = Transform.IDENTITY; + + const result = combine([dt1, dt2]); + assert.ok(result.transform.isIdentity(), 'combined should have identity transform'); + assert.strictEqual(result.numRows, dt1.numRows + dt2.numRows); + }); +}); + +// -- processDataTable spatial filters with transform -- + +describe('processDataTable spatial filters with transform', () => { + it('filterBox works correctly with non-identity transform', () => { + const dt = createMinimalTestData(); + dt.transform = new Transform().fromEulers(0, 0, 180); + + // In engine space, euler(0,0,180) negates x and y. + // Raw x values are centered around 0 (range approx -1.5 to 1.5). + // Engine x = -raw_x. So filtering engine x >= 0 keeps raw x <= 0. + const result = processDataTable(dt, [{ + kind: 'filterBox', + min: new Vec3(0, -1e6, -1e6), + max: new Vec3(1e6, 1e6, 1e6) + }]); + + assert.ok(result.numRows > 0, 'should have some rows'); + assert.ok(result.numRows < dt.numRows, 'should have fewer rows'); + + // Raw x values should all be <= 0 (since engine x >= 0 means raw x <= 0) + const xCol = result.getColumnByName('x').data; + for (let i = 0; i < result.numRows; i++) { + assert.ok(xCol[i] <= 1e-5, `raw x[${i}] should be <= 0, got ${xCol[i]}`); + } + }); + + it('filterSphere works correctly with non-identity transform', () => { + const dt = createMinimalTestData(); + dt.transform = new Transform().fromEulers(0, 0, 180); + + // Engine (0,0,0) maps to raw (0,0,0) for euler(0,0,180) + const result = processDataTable(dt, [{ + kind: 'filterSphere', + center: new Vec3(0, 0, 0), + radius: 1.0 + }]); + + assert.ok(result.numRows > 0, 'should have some rows'); + + // Remaining splats should be within radius 1 of raw origin + const xCol = result.getColumnByName('x').data; + const yCol = result.getColumnByName('y').data; + const zCol = result.getColumnByName('z').data; + for (let i = 0; i < result.numRows; i++) { + const distSq = xCol[i] ** 2 + yCol[i] ** 2 + zCol[i] ** 2; + assert.ok(distSq < 1.0 + 1e-5, `splat ${i} should be within radius`); + } + }); + + it('filterBands preserves transform', () => { + const dt = createMinimalTestData({ includeSH: true, shBands: 2 }); + dt.transform = new Transform().fromEulers(0, 0, 180); + + const result = processDataTable(dt, [{ + kind: 'filterBands', + value: 1 + }]); + + assert.ok(!result.transform.isIdentity(), 'should preserve non-identity transform'); + }); +}); + +// -- Round-trip transform verification -- + +describe('Round-trip transform scenarios', () => { + it('PLY round-trip: computeWriteTransform returns null', () => { + const plyTransform = new Transform().fromEulers(0, 0, 180); + const delta = computeWriteTransform(plyTransform, new Transform().fromEulers(0, 0, 180)); + assert.strictEqual(delta, null, 'PLY->PLY write should need no transform'); + }); + + it('PLY->engine: data is correctly transformed to engine space', () => { + const dt = createMinimalTestData(); + dt.transform = new Transform().fromEulers(0, 0, 180); + + const delta = computeWriteTransform(dt.transform, Transform.IDENTITY); + assert.notStrictEqual(delta, null, 'should need a transform'); + + const cols = transformColumns(dt, ['x', 'y', 'z'], delta); + const rawX = dt.getColumnByName('x').data; + const rawY = dt.getColumnByName('y').data; + + // euler(0,0,180) negates x and y + for (let i = 0; i < dt.numRows; i++) { + assertClose(cols.get('x')[i], -rawX[i], 1e-5, `engine x[${i}]`); + assertClose(cols.get('y')[i], -rawY[i], 1e-5, `engine y[${i}]`); + } + }); + + it('engine -> PLY: data is correctly transformed to PLY space', () => { + const dt = createMinimalTestData(); + dt.transform = Transform.IDENTITY; + + const delta = computeWriteTransform(dt.transform, new Transform().fromEulers(0, 0, 180)); + assert.notStrictEqual(delta, null, 'should need a transform'); + + const cols = transformColumns(dt, ['x', 'y', 'z'], delta); + const rawX = dt.getColumnByName('x').data; + const rawY = dt.getColumnByName('y').data; + + // inverse(euler(0,0,180)) * identity = euler(0,0,180)^-1 = euler(0,0,180) + // So it also negates x and y + for (let i = 0; i < dt.numRows; i++) { + assertClose(cols.get('x')[i], -rawX[i], 1e-5, `ply x[${i}]`); + assertClose(cols.get('y')[i], -rawY[i], 1e-5, `ply y[${i}]`); + } + }); +}); diff --git a/test/transforms.test.mjs b/test/transforms.test.mjs index ab3e762f..e4f6495e 100644 --- a/test/transforms.test.mjs +++ b/test/transforms.test.mjs @@ -10,14 +10,16 @@ import { computeSummary, processDataTable, Column, - DataTable + DataTable, + Transform, + transformColumns, + computeWriteTransform } from '../src/lib/index.js'; import { createMinimalTestData } from './helpers/test-utils.mjs'; import { assertClose } from './helpers/summary-compare.mjs'; -// Import Vec3 from playcanvas (used in actions) -import { Vec3 } from 'playcanvas'; +import { Mat4, Quat, Vec3 } from 'playcanvas'; describe('Translate Transform', () => { let testData; @@ -26,7 +28,7 @@ describe('Translate Transform', () => { testData = createMinimalTestData(); }); - it('should translate positions by specified offset', () => { + it('should compose translation into transform without modifying raw data', () => { const originalSummary = computeSummary(testData); const clonedData = testData.clone(); @@ -37,14 +39,29 @@ describe('Translate Transform', () => { const newSummary = computeSummary(result); - // Positions should be shifted - assertClose(newSummary.columns.x.mean, originalSummary.columns.x.mean + 10, 1e-5, 'x mean'); - assertClose(newSummary.columns.y.mean, originalSummary.columns.y.mean + 20, 1e-5, 'y mean'); - assertClose(newSummary.columns.z.mean, originalSummary.columns.z.mean + 30, 1e-5, 'z mean'); - - // Other properties should be unchanged + // Raw data should be unchanged + assertClose(newSummary.columns.x.mean, originalSummary.columns.x.mean, 1e-5, 'raw x mean'); + assertClose(newSummary.columns.y.mean, originalSummary.columns.y.mean, 1e-5, 'raw y mean'); + assertClose(newSummary.columns.z.mean, originalSummary.columns.z.mean, 1e-5, 'raw z mean'); assertClose(newSummary.columns.scale_0.mean, originalSummary.columns.scale_0.mean, 1e-5, 'scale_0'); assertClose(newSummary.columns.opacity.mean, originalSummary.columns.opacity.mean, 1e-5, 'opacity'); + + // transform should have the translation composed in + const t = result.transform; + assertClose(t.translation.x, 10, 1e-5, 'transform tx'); + assertClose(t.translation.y, 20, 1e-5, 'transform ty'); + assertClose(t.translation.z, 30, 1e-5, 'transform tz'); + + // transformColumns should produce shifted engine-space positions + const cols = transformColumns(result, ['x', 'y', 'z'], result.transform); + const engineX = cols.get('x'); + const engineY = cols.get('y'); + const rawX = result.getColumnByName('x').data; + const rawY = result.getColumnByName('y').data; + for (let i = 0; i < result.numRows; i++) { + assertClose(engineX[i], rawX[i] + 10, 1e-4, `engine x[${i}]`); + assertClose(engineY[i], rawY[i] + 20, 1e-4, `engine y[${i}]`); + } }); it('should handle zero translation', () => { @@ -72,7 +89,7 @@ describe('Scale Transform', () => { testData = createMinimalTestData(); }); - it('should scale positions and scales by factor', () => { + it('should compose scale into transform without modifying raw data', () => { const originalSummary = computeSummary(testData); const clonedData = testData.clone(); @@ -84,15 +101,27 @@ describe('Scale Transform', () => { const newSummary = computeSummary(result); - // Positions should be scaled - assertClose(newSummary.columns.x.min, originalSummary.columns.x.min * scaleFactor, 1e-5, 'x.min'); - assertClose(newSummary.columns.x.max, originalSummary.columns.x.max * scaleFactor, 1e-5, 'x.max'); - assertClose(newSummary.columns.z.min, originalSummary.columns.z.min * scaleFactor, 1e-5, 'z.min'); - assertClose(newSummary.columns.z.max, originalSummary.columns.z.max * scaleFactor, 1e-5, 'z.max'); + // Raw data should be unchanged + assertClose(newSummary.columns.x.min, originalSummary.columns.x.min, 1e-5, 'raw x.min'); + assertClose(newSummary.columns.x.max, originalSummary.columns.x.max, 1e-5, 'raw x.max'); + assertClose(newSummary.columns.scale_0.mean, originalSummary.columns.scale_0.mean, 1e-5, 'raw scale_0'); + + // transform should have scale = 2 + assertClose(result.transform.scale, 2.0, 1e-5, 'transform scale'); + + // transformColumns should produce scaled engine-space positions + const cols = transformColumns(result, ['x', 'y', 'z', 'scale_0'], result.transform); + const rawX = result.getColumnByName('x').data; + for (let i = 0; i < result.numRows; i++) { + assertClose(cols.get('x')[i], rawX[i] * scaleFactor, 1e-4, `engine x[${i}]`); + } - // Log-encoded scales should shift by log(factor) + // Scale columns should be shifted by log(factor) const logFactor = Math.log(scaleFactor); - assertClose(newSummary.columns.scale_0.mean, originalSummary.columns.scale_0.mean + logFactor, 1e-5, 'scale_0'); + const rawScale = result.getColumnByName('scale_0').data; + for (let i = 0; i < result.numRows; i++) { + assertClose(cols.get('scale_0')[i], rawScale[i] + logFactor, 1e-4, `engine scale_0[${i}]`); + } }); it('should handle scale factor of 1 (no change)', () => { @@ -111,7 +140,6 @@ describe('Scale Transform', () => { }); it('should handle fractional scale factor', () => { - const originalSummary = computeSummary(testData); const clonedData = testData.clone(); const scaleFactor = 0.5; @@ -120,10 +148,7 @@ describe('Scale Transform', () => { value: scaleFactor }]); - const newSummary = computeSummary(result); - - // Positions should be scaled down - assertClose(newSummary.columns.x.max, originalSummary.columns.x.max * scaleFactor, 1e-5, 'x.max'); + assertClose(result.transform.scale, 0.5, 1e-5, 'transform scale'); }); }); @@ -134,7 +159,7 @@ describe('Rotate Transform', () => { testData = createMinimalTestData(); }); - it('should rotate positions around Y axis', () => { + it('should compose rotation into transform without modifying raw data', () => { const originalSummary = computeSummary(testData); const clonedData = testData.clone(); @@ -146,15 +171,23 @@ describe('Rotate Transform', () => { const newSummary = computeSummary(result); - // After 90 degree Y rotation (counter-clockwise when looking down Y): - // x' = z - // z' = -x - // So new z range = -old_x_range (reversed) - assertClose(newSummary.columns.z.min, -originalSummary.columns.x.max, 1e-4, 'z.min after rotation'); - assertClose(newSummary.columns.z.max, -originalSummary.columns.x.min, 1e-4, 'z.max after rotation'); - - // Row count should be unchanged + // Raw data should be unchanged + assertClose(newSummary.columns.x.min, originalSummary.columns.x.min, 1e-5, 'raw x.min'); + assertClose(newSummary.columns.x.max, originalSummary.columns.x.max, 1e-5, 'raw x.max'); assert.strictEqual(newSummary.rowCount, originalSummary.rowCount); + + // transform should have a rotation + assert.ok(!result.transform.isIdentity(), 'transform should not be identity'); + + // transformColumns should produce rotated engine-space positions + // After 90° Y rotation: x' = z, z' = -x + const cols = transformColumns(result, ['x', 'y', 'z'], result.transform); + const rawX = result.getColumnByName('x').data; + const rawZ = result.getColumnByName('z').data; + for (let i = 0; i < result.numRows; i++) { + assertClose(cols.get('x')[i], rawZ[i], 1e-4, `engine x[${i}]`); + assertClose(cols.get('z')[i], -rawX[i], 1e-4, `engine z[${i}]`); + } }); it('should handle zero rotation', () => { @@ -418,21 +451,21 @@ describe('Filter SH Bands', () => { }); describe('Chained Transforms', () => { - it('should apply multiple transforms in order', () => { + it('should compose multiple transforms into transform', () => { const testData = createMinimalTestData(); - const originalSummary = computeSummary(testData); const result = processDataTable(testData, [ { kind: 'scale', value: 2.0 }, { kind: 'translate', value: new Vec3(100, 0, 0) } ]); - const newSummary = computeSummary(result); - // After scale(2) + translate(100,0,0): - // x_new = x_old * 2 + 100 - const expectedXMean = originalSummary.columns.x.mean * 2 + 100; - assertClose(newSummary.columns.x.mean, expectedXMean, 1e-4, 'x mean after transforms'); + // engine_x = raw_x * 2 + 100 + const cols = transformColumns(result, ['x', 'y', 'z'], result.transform); + const rawX = result.getColumnByName('x').data; + for (let i = 0; i < result.numRows; i++) { + assertClose(cols.get('x')[i], rawX[i] * 2 + 100, 1e-4, `engine x[${i}]`); + } }); it('should handle filter followed by transform', () => { From de77614737df9d9d1609f7b58a28fd2e6ef2a106 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 7 Apr 2026 12:01:46 +0100 Subject: [PATCH 02/13] latest --- src/lib/data-table/data-table.ts | 2 +- src/lib/data-table/decimate.ts | 2 +- src/lib/process.ts | 6 +--- src/lib/readers/read-ksplat.ts | 2 +- src/lib/readers/read-ply.ts | 2 +- src/lib/readers/read-sog.ts | 2 +- src/lib/readers/read-splat.ts | 2 +- src/lib/readers/read-spz.ts | 2 +- src/lib/utils/math.ts | 13 ++++++-- src/lib/write.ts | 4 +-- test/source-transform.test.mjs | 51 +++++++++++++------------------- 11 files changed, 42 insertions(+), 46 deletions(-) diff --git a/src/lib/data-table/data-table.ts b/src/lib/data-table/data-table.ts index d73cd838..02a8240d 100644 --- a/src/lib/data-table/data-table.ts +++ b/src/lib/data-table/data-table.ts @@ -101,7 +101,7 @@ class DataTable { } this.columns = columns; - this.transform = transform ? transform.clone() : Transform.IDENTITY; + this.transform = transform ? transform.clone() : new Transform(); } // rows diff --git a/src/lib/data-table/decimate.ts b/src/lib/data-table/decimate.ts index be132483..69b41c21 100644 --- a/src/lib/data-table/decimate.ts +++ b/src/lib/data-table/decimate.ts @@ -777,7 +777,7 @@ const simplifyGaussians = (dataTable: DataTable, targetCount: number): DataTable const c = cols[ci]; newColumns.push(new Column(c.name, new (c.data.constructor as any)(outCount))); } - const newTable = new DataTable(newColumns); + const newTable = new DataTable(newColumns, dataTable.transform); // Copy unmerged splats let dst = 0; diff --git a/src/lib/process.ts b/src/lib/process.ts index 905e80df..99b1240e 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -375,7 +375,6 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) } } - const prevTransform = result.transform; result = new DataTable(result.columns.map((column) => { if (map.hasOwnProperty(column.name)) { const name = map[column.name]; @@ -383,8 +382,7 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) } return column; - }).filter(c => c !== null)); - result.transform = prevTransform; + }).filter(c => c !== null), result.transform); } break; } @@ -451,9 +449,7 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) } keepCount = Math.max(0, keepCount); - const prevTransform = result.transform; result = simplifyGaussians(result, keepCount); - result.transform = prevTransform; break; } } diff --git a/src/lib/readers/read-ksplat.ts b/src/lib/readers/read-ksplat.ts index 57fe3160..be48ce12 100644 --- a/src/lib/readers/read-ksplat.ts +++ b/src/lib/readers/read-ksplat.ts @@ -371,7 +371,7 @@ const readKsplat = async (source: ReadSource): Promise => { throw new Error(`Splat count mismatch: expected ${numSplats}, processed ${splatIndex}`); } - return new DataTable(columns, new Transform().fromEulers(0, 0, 180)); + return new DataTable(columns, Transform.PLY); }; export { readKsplat }; diff --git a/src/lib/readers/read-ply.ts b/src/lib/readers/read-ply.ts index 3aeed23c..742ace10 100644 --- a/src/lib/readers/read-ply.ts +++ b/src/lib/readers/read-ply.ts @@ -300,7 +300,7 @@ const readPly = async (source: ReadSource): Promise => { result = vertexElement.dataTable; } - result.transform = new Transform().fromEulers(0, 0, 180); + result.transform = Transform.PLY.clone(); return result; }; diff --git a/src/lib/readers/read-sog.ts b/src/lib/readers/read-sog.ts index e6481cb2..9078357f 100644 --- a/src/lib/readers/read-sog.ts +++ b/src/lib/readers/read-sog.ts @@ -216,7 +216,7 @@ const readSog = async (fileSystem: ReadFileSystem, filename: string): Promise= 3 ? undefined : new Transform().fromEulers(0, 0, 180)); + return new DataTable(columns, meta.version >= 3 ? undefined : Transform.PLY); }; export { readSog }; diff --git a/src/lib/readers/read-splat.ts b/src/lib/readers/read-splat.ts index d2678311..2a99514e 100644 --- a/src/lib/readers/read-splat.ts +++ b/src/lib/readers/read-splat.ts @@ -126,7 +126,7 @@ const readSplat = async (source: ReadSource): Promise => { } } - return new DataTable(columns, new Transform().fromEulers(0, 0, 180)); + return new DataTable(columns, Transform.PLY); }; export { readSplat }; diff --git a/src/lib/readers/read-spz.ts b/src/lib/readers/read-spz.ts index 4b782a18..77fa2f2e 100644 --- a/src/lib/readers/read-spz.ts +++ b/src/lib/readers/read-spz.ts @@ -227,7 +227,7 @@ const readSpz = async (source: ReadSource): Promise => { } } - return new DataTable(columns, new Transform().fromEulers(0, 0, 180)); + return new DataTable(columns, Transform.PLY); }; export { readSpz }; diff --git a/src/lib/utils/math.ts b/src/lib/utils/math.ts index 90bc008c..72d46286 100644 --- a/src/lib/utils/math.ts +++ b/src/lib/utils/math.ts @@ -128,10 +128,19 @@ class Transform { return this; } + static freeze(t: Transform): Readonly { + Object.freeze(t.translation); + Object.freeze(t.rotation); + return Object.freeze(t); + } + + static IDENTITY = Transform.freeze(new Transform()); + /** - * Shared identity instance. Do not mutate. + * PLY coordinate convention: 180-degree rotation around Z. + * Used by PLY, splat, KSplat, SPZ, and legacy SOG formats. */ - static IDENTITY = new Transform(); + static PLY = Transform.freeze(new Transform().fromEulers(0, 0, 180)); } export { sigmoid, Transform }; diff --git a/src/lib/write.ts b/src/lib/write.ts index 9dd9e447..04d760b3 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -154,7 +154,7 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { }, fs); break; case 'compressed-ply': - await writeCompressedPly({ filename, dataTable: applyWriteTransform(dataTable, new Transform().fromEulers(0, 0, 180)) }, fs); + await writeCompressedPly({ filename, dataTable: applyWriteTransform(dataTable, Transform.PLY) }, fs); break; case 'ply': await writePly({ @@ -163,7 +163,7 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { comments: [], elements: [{ name: 'vertex', - dataTable: applyWriteTransform(dataTable, new Transform().fromEulers(0, 0, 180)) + dataTable: applyWriteTransform(dataTable, Transform.PLY) }] } }, fs); diff --git a/test/source-transform.test.mjs b/test/source-transform.test.mjs index 410dda25..2618f9e0 100644 --- a/test/source-transform.test.mjs +++ b/test/source-transform.test.mjs @@ -147,14 +147,12 @@ describe('Transform class', () => { describe('computeWriteTransform', () => { it('same-to-same returns null (identity delta)', () => { - const plyTransform = new Transform().fromEulers(0, 0, 180); - const result = computeWriteTransform(plyTransform, new Transform().fromEulers(0, 0, 180)); + const result = computeWriteTransform(Transform.PLY, Transform.PLY); assert.strictEqual(result, null, 'same-to-same delta should be null'); }); it('PLY-to-engine returns non-null (needs transform)', () => { - const plyTransform = new Transform().fromEulers(0, 0, 180); - const result = computeWriteTransform(plyTransform, Transform.IDENTITY); + const result = computeWriteTransform(Transform.PLY, Transform.IDENTITY); assert.notStrictEqual(result, null, 'PLY-to-engine delta should not be null'); }); @@ -184,9 +182,8 @@ describe('transformColumns', () => { assert.strictEqual(cols.get('x'), testData.getColumnByName('x').data, 'should be same reference'); }); - it('transforms positions with euler(0,0,180)', () => { - const delta = new Transform().fromEulers(0, 0, 180); - const cols = transformColumns(testData, ['x', 'y', 'z'], delta); + it('transforms positions with PLY transform', () => { + const cols = transformColumns(testData, ['x', 'y', 'z'], Transform.PLY); const rawX = testData.getColumnByName('x').data; const rawY = testData.getColumnByName('y').data; const rawZ = testData.getColumnByName('z').data; @@ -200,15 +197,13 @@ describe('transformColumns', () => { }); it('returns original arrays for unaffected columns', () => { - const delta = new Transform().fromEulers(0, 0, 180); - const cols = transformColumns(testData, ['opacity', 'f_dc_0'], delta); + const cols = transformColumns(testData, ['opacity', 'f_dc_0'], Transform.PLY); assert.strictEqual(cols.get('opacity'), testData.getColumnByName('opacity').data, 'opacity should be same reference'); assert.strictEqual(cols.get('f_dc_0'), testData.getColumnByName('f_dc_0').data, 'f_dc_0 should be same reference'); }); it('transforms rotation columns', () => { - const delta = new Transform().fromEulers(0, 0, 180); - const cols = transformColumns(testData, ['rot_0', 'rot_1', 'rot_2', 'rot_3'], delta); + const cols = transformColumns(testData, ['rot_0', 'rot_1', 'rot_2', 'rot_3'], Transform.PLY); // Should return new arrays (not same reference) assert.notStrictEqual(cols.get('rot_0'), testData.getColumnByName('rot_0').data, 'rot_0 should be new array'); @@ -226,8 +221,7 @@ describe('transformColumns', () => { }); it('does not transform scale columns when scale == 1', () => { - const delta = new Transform().fromEulers(0, 0, 180); - const cols = transformColumns(testData, ['scale_0'], delta); + const cols = transformColumns(testData, ['scale_0'], Transform.PLY); assert.strictEqual(cols.get('scale_0'), testData.getColumnByName('scale_0').data, 'scale_0 should be same reference when scale=1'); }); }); @@ -243,10 +237,9 @@ describe('inverseTransformPoint', () => { assertClose(point.z, 3, 1e-10, 'z'); }); - it('with euler(0,0,180) negates x and y', () => { - const t = new Transform().fromEulers(0, 0, 180); + it('with PLY transform negates x and y', () => { const point = new Vec3(1, 2, 3); - inverseTransformPoint(t, point); + inverseTransformPoint(Transform.PLY, point); assertClose(point.x, -1, 1e-5, 'x'); assertClose(point.y, -2, 1e-5, 'y'); assertClose(point.z, 3, 1e-5, 'z'); @@ -262,11 +255,10 @@ describe('inverseTransformAABB', () => { assertClose(max.x, 1, 1e-10, 'max.x'); }); - it('with euler(0,0,180) swaps min/max for x and y', () => { - const t = new Transform().fromEulers(0, 0, 180); + it('with PLY transform swaps min/max for x and y', () => { const min = new Vec3(-1, -2, -3); const max = new Vec3(3, 4, 5); - inverseTransformAABB(t, min, max); + inverseTransformAABB(Transform.PLY, min, max); // After negating x and y, then computing AABB: // x: [-3, 1] -> negated -> [-1, 3] -> AABB min=-3, max=1 @@ -289,7 +281,7 @@ describe('DataTable transform', () => { it('clone preserves transform', () => { const dt = createMinimalTestData(); - dt.transform = new Transform().fromEulers(0, 0, 180); + dt.transform = Transform.PLY.clone(); const cloned = dt.clone(); assert.ok(!cloned.transform.isIdentity(), 'cloned should have non-identity transform'); @@ -315,8 +307,8 @@ describe('combine with transforms', () => { it('preserves transform when all tables match', () => { const dt1 = createMinimalTestData(); const dt2 = createMinimalTestData(); - dt1.transform = new Transform().fromEulers(0, 0, 180); - dt2.transform = new Transform().fromEulers(0, 0, 180); + dt1.transform = Transform.PLY; + dt2.transform = Transform.PLY; const result = combine([dt1, dt2]); assert.ok(!result.transform.isIdentity(), 'combined should keep shared transform'); @@ -326,7 +318,7 @@ describe('combine with transforms', () => { it('converts to engine space when transforms differ', () => { const dt1 = createMinimalTestData(); const dt2 = createMinimalTestData(); - dt1.transform = new Transform().fromEulers(0, 0, 180); + dt1.transform = Transform.PLY; dt2.transform = Transform.IDENTITY; const result = combine([dt1, dt2]); @@ -340,7 +332,7 @@ describe('combine with transforms', () => { describe('processDataTable spatial filters with transform', () => { it('filterBox works correctly with non-identity transform', () => { const dt = createMinimalTestData(); - dt.transform = new Transform().fromEulers(0, 0, 180); + dt.transform = Transform.PLY; // In engine space, euler(0,0,180) negates x and y. // Raw x values are centered around 0 (range approx -1.5 to 1.5). @@ -363,7 +355,7 @@ describe('processDataTable spatial filters with transform', () => { it('filterSphere works correctly with non-identity transform', () => { const dt = createMinimalTestData(); - dt.transform = new Transform().fromEulers(0, 0, 180); + dt.transform = Transform.PLY; // Engine (0,0,0) maps to raw (0,0,0) for euler(0,0,180) const result = processDataTable(dt, [{ @@ -386,7 +378,7 @@ describe('processDataTable spatial filters with transform', () => { it('filterBands preserves transform', () => { const dt = createMinimalTestData({ includeSH: true, shBands: 2 }); - dt.transform = new Transform().fromEulers(0, 0, 180); + dt.transform = Transform.PLY; const result = processDataTable(dt, [{ kind: 'filterBands', @@ -401,14 +393,13 @@ describe('processDataTable spatial filters with transform', () => { describe('Round-trip transform scenarios', () => { it('PLY round-trip: computeWriteTransform returns null', () => { - const plyTransform = new Transform().fromEulers(0, 0, 180); - const delta = computeWriteTransform(plyTransform, new Transform().fromEulers(0, 0, 180)); + const delta = computeWriteTransform(Transform.PLY, Transform.PLY); assert.strictEqual(delta, null, 'PLY->PLY write should need no transform'); }); it('PLY->engine: data is correctly transformed to engine space', () => { const dt = createMinimalTestData(); - dt.transform = new Transform().fromEulers(0, 0, 180); + dt.transform = Transform.PLY; const delta = computeWriteTransform(dt.transform, Transform.IDENTITY); assert.notStrictEqual(delta, null, 'should need a transform'); @@ -428,7 +419,7 @@ describe('Round-trip transform scenarios', () => { const dt = createMinimalTestData(); dt.transform = Transform.IDENTITY; - const delta = computeWriteTransform(dt.transform, new Transform().fromEulers(0, 0, 180)); + const delta = computeWriteTransform(dt.transform, Transform.PLY); assert.notStrictEqual(delta, null, 'should need a transform'); const cols = transformColumns(dt, ['x', 'y', 'z'], delta); From bdf899b9fc3468639b66baf899d859e9beb2680b Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 7 Apr 2026 21:48:04 +0100 Subject: [PATCH 03/13] latest --- src/lib/data-table/combine.ts | 15 ++------- src/lib/data-table/transform.ts | 43 +++++++++++++++++-------- src/lib/index.ts | 2 +- src/lib/readers/read-voxel.ts | 3 +- src/lib/utils/math.ts | 33 +++++++++++++------ src/lib/write.ts | 24 +++----------- src/lib/writers/write-compressed-ply.ts | 4 ++- src/lib/writers/write-glb.ts | 4 ++- src/lib/writers/write-lod.ts | 17 +++------- src/lib/writers/write-ply.ts | 10 +++++- src/lib/writers/write-sog.ts | 12 ++----- test/formats.test.mjs | 3 ++ test/source-transform.test.mjs | 11 ++++--- test/transforms.test.mjs | 7 ++-- 14 files changed, 98 insertions(+), 90 deletions(-) diff --git a/src/lib/data-table/combine.ts b/src/lib/data-table/combine.ts index 05df096a..abd2f3ec 100644 --- a/src/lib/data-table/combine.ts +++ b/src/lib/data-table/combine.ts @@ -1,5 +1,5 @@ import { Column, DataTable, TypedArray } from './data-table'; -import { computeWriteTransform, Transform, transformColumns } from './transform'; +import { convertToSpace, Transform } from './transform'; import { logger } from '../utils/logger'; /** @@ -28,23 +28,14 @@ const combine = (dataTables: DataTable[]) : DataTable => { // Check if all transforms match the first table's transform const refTransform = dataTables[0].transform; - const allMatch = dataTables.every((dt) => { - const delta = computeWriteTransform(dt.transform, refTransform); - return !delta; - }); + const allMatch = dataTables.every(dt => dt.transform.equals(refTransform)); let tables = dataTables; let resultTransform = refTransform; if (!allMatch) { logger.warn('Combining DataTables with different source transforms; converting to engine space.'); - tables = dataTables.map((dt) => { - const delta = computeWriteTransform(dt.transform, Transform.IDENTITY); - if (!delta) return dt; - const allNames = dt.columnNames; - const cols = transformColumns(dt, allNames, delta); - return new DataTable(allNames.map(name => new Column(name, cols.get(name)!))); - }); + tables = dataTables.map(dt => convertToSpace(dt, Transform.IDENTITY)); resultTransform = Transform.IDENTITY; } diff --git a/src/lib/data-table/transform.ts b/src/lib/data-table/transform.ts index 1d0179a0..85b15931 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -1,6 +1,6 @@ import { Mat3, Mat4, Quat, Vec3 } from 'playcanvas'; -import { DataTable, TypedArray } from './data-table'; +import { Column, DataTable, TypedArray } from './data-table'; import { Transform } from '../utils/math'; import { RotateSH } from '../utils/rotate-sh'; @@ -140,39 +140,37 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr const rotateSH = new RotateSH(mat3); const shCoeffs = new Float32Array(shCoeffsPerChannel); - const shData: Float32Array[][] = []; + const shSrc: Float32Array[][] = []; + const shDst: Float32Array[][] = []; for (let j = 0; j < 3; ++j) { - const channelSrc: Float32Array[] = []; - const channelDst: Float32Array[] = []; + const src: Float32Array[] = []; + const dst: Float32Array[] = []; for (let k = 0; k < shCoeffsPerChannel; ++k) { const name = shNames[k + j * shCoeffsPerChannel]; - channelSrc.push(dataTable.getColumnByName(name)!.data as Float32Array); - channelDst.push(new Float32Array(numRows)); + src.push(dataTable.getColumnByName(name)!.data as Float32Array); + dst.push(new Float32Array(numRows)); } - shData.push(channelSrc); - shData.push(channelDst); + shSrc.push(src); + shDst.push(dst); } for (let i = 0; i < numRows; ++i) { for (let j = 0; j < 3; ++j) { - const channelSrc = shData[j * 2]; - const channelDst = shData[j * 2 + 1]; for (let k = 0; k < shCoeffsPerChannel; ++k) { - shCoeffs[k] = channelSrc[k][i]; + shCoeffs[k] = shSrc[j][k][i]; } rotateSH.apply(shCoeffs); for (let k = 0; k < shCoeffsPerChannel; ++k) { - channelDst[k][i] = shCoeffs[k]; + shDst[j][k][i] = shCoeffs[k]; } } } for (let j = 0; j < 3; ++j) { - const channelDst = shData[j * 2 + 1]; for (let k = 0; k < shCoeffsPerChannel; ++k) { const name = shNames[k + j * shCoeffsPerChannel]; if (columnNames.includes(name)) { - result.set(name, channelDst[k]); + result.set(name, shDst[j][k]); } } } @@ -240,6 +238,22 @@ const inverseTransformAABB = (t: Transform, min: Vec3, max: Vec3): void => { } }; +/** + * Returns a new DataTable with column data converted to the target coordinate + * space. If the DataTable is already in that space, returns it unchanged. + * + * @param dataTable - The source DataTable. + * @param targetTransform - The desired coordinate-space transform. + * @returns A DataTable whose raw data is in the target coordinate space. + */ +const convertToSpace = (dataTable: DataTable, targetTransform: Transform): DataTable => { + const delta = computeWriteTransform(dataTable.transform, targetTransform); + if (!delta) return dataTable; + const allNames = dataTable.columnNames; + const cols = transformColumns(dataTable, allNames, delta); + return new DataTable(allNames.map(name => new Column(name, cols.get(name)!)), targetTransform); +}; + // -- Legacy in-place transform function -- /** @@ -321,6 +335,7 @@ export { transform, transformColumns, computeWriteTransform, + convertToSpace, inverseTransformPoint, inverseTransformAABB }; diff --git a/src/lib/index.ts b/src/lib/index.ts index 69d6e687..eac1e979 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -3,7 +3,7 @@ export { Column, DataTable } from './data-table/data-table'; export type { TypedArray, ColumnType, Row } from './data-table/data-table'; export { combine } from './data-table/combine'; export { Transform } from './utils/math'; -export { transform, transformColumns, computeWriteTransform, inverseTransformPoint, inverseTransformAABB } from './data-table/transform'; +export { convertToSpace } from './data-table/transform'; export { computeSummary } from './data-table/summary'; export type { ColumnStats, SummaryData } from './data-table/summary'; export { sortMortonOrder } from './data-table/morton-order'; diff --git a/src/lib/readers/read-voxel.ts b/src/lib/readers/read-voxel.ts index 77446e43..fe6eacc6 100644 --- a/src/lib/readers/read-voxel.ts +++ b/src/lib/readers/read-voxel.ts @@ -344,7 +344,7 @@ const readVoxel = async ( opacityArr[i] = 5.0; } - const result = new DataTable([ + return new DataTable([ new Column('x', xArr), new Column('y', yArr), new Column('z', zArr), @@ -360,7 +360,6 @@ const readVoxel = async ( new Column('f_dc_2', fdc2), new Column('opacity', opacityArr) ]); - return result; }; export { readVoxel }; diff --git a/src/lib/utils/math.ts b/src/lib/utils/math.ts index 72d46286..94805bb9 100644 --- a/src/lib/utils/math.ts +++ b/src/lib/utils/math.ts @@ -40,27 +40,42 @@ class Transform { } /** - * Tests whether this transform is effectively identity within the given tolerance. + * Tests whether this transform equals another within the given tolerance. + * Quaternion comparison accounts for double-cover (q and -q represent + * the same rotation). * + * @param other - The transform to compare against. * @param epsilon - Floating-point tolerance. Defaults to 1e-6. - * @returns True if identity within the tolerance. + * @returns True if the transforms are equal within the tolerance. */ - isIdentity(epsilon = 1e-6): boolean { - const t = this.translation; - const r = this.rotation; - if (Math.abs(t.x) > epsilon || Math.abs(t.y) > epsilon || Math.abs(t.z) > epsilon) { + equals(other: Transform, epsilon = 1e-6): boolean { + const ta = this.translation; + const tb = other.translation; + if (Math.abs(ta.x - tb.x) > epsilon || Math.abs(ta.y - tb.y) > epsilon || Math.abs(ta.z - tb.z) > epsilon) { return false; } - // identity quaternion is (0, 0, 0, 1) or (0, 0, 0, -1) - if (Math.abs(r.x) > epsilon || Math.abs(r.y) > epsilon || Math.abs(r.z) > epsilon || Math.abs(Math.abs(r.w) - 1) > epsilon) { + const ra = this.rotation; + const rb = other.rotation; + const dot = ra.x * rb.x + ra.y * rb.y + ra.z * rb.z + ra.w * rb.w; + if (Math.abs(dot) < 1 - epsilon) { return false; } - if (Math.abs(this.scale - 1) > epsilon) { + if (Math.abs(this.scale - other.scale) > epsilon) { return false; } return true; } + /** + * Tests whether this transform is effectively identity within the given tolerance. + * + * @param epsilon - Floating-point tolerance. Defaults to 1e-6. + * @returns True if identity within the tolerance. + */ + isIdentity(epsilon = 1e-6): boolean { + return this.equals(Transform.IDENTITY, epsilon); + } + /** * Creates a deep copy of this transform. * diff --git a/src/lib/write.ts b/src/lib/write.ts index 04d760b3..4b845c4c 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -1,9 +1,7 @@ -import { Column, DataTable } from './data-table/data-table'; -import { computeWriteTransform, transformColumns } from './data-table/transform'; +import { DataTable } from './data-table/data-table'; import { type FileSystem } from './io/write'; import { Options } from './types'; import { logger } from './utils/logger'; -import { Transform } from './utils/math'; import { writeCompressedPly } from './writers/write-compressed-ply'; import { writeCsv } from './writers/write-csv'; import { writeGlb } from './writers/write-glb'; @@ -108,20 +106,6 @@ const getOutputFormat = (filename: string, options: Options): OutputFormat => { * }, fs); * ``` */ -/** - * @param dataTable - The source DataTable. - * @param formatTransform - The target format's expected transform. - * @returns A DataTable with column data in the target format's coordinate space. - * @ignore - */ -const applyWriteTransform = (dataTable: DataTable, formatTransform: Transform): DataTable => { - const delta = computeWriteTransform(dataTable.transform, formatTransform); - if (!delta) return dataTable; - const allNames = dataTable.columnNames; - const cols = transformColumns(dataTable, allNames, delta); - return new DataTable(allNames.map(name => new Column(name, cols.get(name)!))); -}; - const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { const { filename, outputFormat, dataTable, envDataTable, options, createDevice } = writeOptions; @@ -154,7 +138,7 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { }, fs); break; case 'compressed-ply': - await writeCompressedPly({ filename, dataTable: applyWriteTransform(dataTable, Transform.PLY) }, fs); + await writeCompressedPly({ filename, dataTable }, fs); break; case 'ply': await writePly({ @@ -163,13 +147,13 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { comments: [], elements: [{ name: 'vertex', - dataTable: applyWriteTransform(dataTable, Transform.PLY) + dataTable }] } }, fs); break; case 'glb': - await writeGlb({ filename, dataTable: applyWriteTransform(dataTable, Transform.IDENTITY) }, fs); + await writeGlb({ filename, dataTable }, fs); break; case 'html': case 'html-bundle': diff --git a/src/lib/writers/write-compressed-ply.ts b/src/lib/writers/write-compressed-ply.ts index 2745b2a9..19de92ad 100644 --- a/src/lib/writers/write-compressed-ply.ts +++ b/src/lib/writers/write-compressed-ply.ts @@ -2,6 +2,7 @@ import { CompressedChunk } from './compressed-chunk'; import { version } from '../../../package.json'; import { DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; +import { convertToSpace, Transform } from '../data-table/transform'; import { type FileSystem } from '../io/write'; const generatedByString = `Generated by splat-transform ${version}`; @@ -44,7 +45,8 @@ type WriteCompressedPlyOptions = { * @ignore */ const writeCompressedPly = async (options: WriteCompressedPlyOptions, fs: FileSystem) => { - const { filename, dataTable } = options; + const { filename } = options; + const dataTable = convertToSpace(options.dataTable, Transform.PLY); const shBands = { '9': 1, '24': 2, '-1': 3 }[shNames.findIndex(v => !dataTable.hasColumn(v))] ?? 0; const outputSHCoeffs = [0, 3, 8, 15][shBands]; diff --git a/src/lib/writers/write-glb.ts b/src/lib/writers/write-glb.ts index 4a390df7..83286838 100644 --- a/src/lib/writers/write-glb.ts +++ b/src/lib/writers/write-glb.ts @@ -1,5 +1,6 @@ import { version } from '../../../package.json'; import { DataTable } from '../data-table/data-table'; +import { convertToSpace, Transform } from '../data-table/transform'; import { type FileSystem } from '../io/write'; import { sigmoid } from '../utils/math'; @@ -278,7 +279,8 @@ const buildBinaryBuffer = (dataTable: DataTable, numSplats: number, shBands: num * @ignore */ const writeGlb = async (options: WriteGlbOptions, fs: FileSystem) => { - const { filename, dataTable } = options; + const { filename } = options; + const dataTable = convertToSpace(options.dataTable, Transform.IDENTITY); const numSplats = dataTable.numRows; const shBands = getSHBands(dataTable); diff --git a/src/lib/writers/write-lod.ts b/src/lib/writers/write-lod.ts index b9f7c910..9eefc5ac 100644 --- a/src/lib/writers/write-lod.ts +++ b/src/lib/writers/write-lod.ts @@ -2,9 +2,9 @@ import { dirname, resolve } from 'pathe'; import { BoundingBox, Mat4, Quat, Vec3 } from 'playcanvas'; import { writeSog, type DeviceCreator } from './write-sog.js'; -import { Column, TypedArray, DataTable } from '../data-table/data-table'; +import { TypedArray, DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; -import { computeWriteTransform, Transform, transformColumns } from '../data-table/transform'; +import { convertToSpace, Transform } from '../data-table/transform'; import { type FileSystem } from '../io/write'; import { BTreeNode, BTree } from '../spatial/b-tree'; import { logger } from '../utils/logger'; @@ -159,17 +159,8 @@ type WriteLodOptions = { const writeLod = async (options: WriteLodOptions, fs: FileSystem) => { const { filename, iterations, createDevice, chunkCount, chunkExtent } = options; - // Transform data into engine space for spatial operations and SOG output - const toEngine = (dt: DataTable): DataTable => { - const delta = computeWriteTransform(dt.transform, Transform.IDENTITY); - if (!delta) return dt; - const allNames = dt.columnNames; - const cols = transformColumns(dt, allNames, delta); - return new DataTable(allNames.map(name => new Column(name, cols.get(name)!))); - }; - - const dataTable = toEngine(options.dataTable); - const envDataTable = options.envDataTable ? toEngine(options.envDataTable) : null; + const dataTable = convertToSpace(options.dataTable, Transform.IDENTITY); + const envDataTable = options.envDataTable ? convertToSpace(options.envDataTable, Transform.IDENTITY) : null; const outputDir = dirname(filename); diff --git a/src/lib/writers/write-ply.ts b/src/lib/writers/write-ply.ts index 02041f9d..648c84c3 100644 --- a/src/lib/writers/write-ply.ts +++ b/src/lib/writers/write-ply.ts @@ -1,3 +1,4 @@ +import { convertToSpace, Transform } from '../data-table/transform'; import { type FileSystem } from '../io/write'; import { PlyData } from '../readers/read-ply'; @@ -30,7 +31,14 @@ type WritePlyOptions = { * @ignore */ const writePly = async (options: WritePlyOptions, fs: FileSystem) => { - const { filename, plyData } = options; + const { filename } = options; + const plyData: PlyData = { + ...options.plyData, + elements: options.plyData.elements.map(e => ({ + ...e, + dataTable: convertToSpace(e.dataTable, Transform.PLY) + })) + }; const header = [ 'ply', diff --git a/src/lib/writers/write-sog.ts b/src/lib/writers/write-sog.ts index 78485dfc..a9bbfa75 100644 --- a/src/lib/writers/write-sog.ts +++ b/src/lib/writers/write-sog.ts @@ -4,7 +4,7 @@ import { GraphicsDevice } from 'playcanvas'; import { version } from '../../../package.json'; import { Column, DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; -import { computeWriteTransform, transformColumns } from '../data-table/transform'; +import { convertToSpace } from '../data-table/transform'; import { type FileSystem, writeFile, ZipFileSystem } from '../io/write'; import { kmeans } from '../spatial/k-means'; import { quantize1d } from '../spatial/quantize-1d'; @@ -84,15 +84,7 @@ type WriteSogOptions = { */ const writeSog = async (options: WriteSogOptions, fs: FileSystem) => { const { filename: outputFilename, bundle, iterations, createDevice } = options; - let { dataTable } = options; - - // Transform data into engine coordinate space for SOG output - const delta = computeWriteTransform(dataTable.transform, Transform.IDENTITY); - if (delta) { - const allNames = dataTable.columnNames; - const cols = transformColumns(dataTable, allNames, delta); - dataTable = new DataTable(allNames.map(name => new Column(name, cols.get(name)!))); - } + const dataTable = convertToSpace(options.dataTable, Transform.IDENTITY); // initialize output stream - use ZipFileSystem for bundled output const zipFs = bundle ? new ZipFileSystem(await fs.createWriter(outputFilename)) : null; diff --git a/test/formats.test.mjs b/test/formats.test.mjs index 3130ad9f..0fee8d1c 100644 --- a/test/formats.test.mjs +++ b/test/formats.test.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'; import { Column, DataTable, + Transform, computeSummary, getInputFormat, readFile, @@ -172,6 +173,7 @@ describe('PLY Format', () => { before(() => { testData = createMinimalTestData(); + testData.transform = Transform.PLY.clone(); plyBytes = encodePlyBinary(testData); expectedSummary = computeSummary(testData); }); @@ -217,6 +219,7 @@ describe('Compressed PLY Format', () => { before(() => { testData = createMinimalTestData(); + testData.transform = Transform.PLY.clone(); expectedSummary = computeSummary(testData); }); diff --git a/test/source-transform.test.mjs b/test/source-transform.test.mjs index 2618f9e0..275f2ce8 100644 --- a/test/source-transform.test.mjs +++ b/test/source-transform.test.mjs @@ -10,14 +10,17 @@ import { Column, DataTable, Transform, - transformColumns, - computeWriteTransform, - inverseTransformPoint, - inverseTransformAABB, combine, processDataTable } from '../src/lib/index.js'; +import { + transformColumns, + computeWriteTransform, + inverseTransformPoint, + inverseTransformAABB +} from '../src/lib/data-table/transform.js'; + import { createMinimalTestData } from './helpers/test-utils.mjs'; import { assertClose } from './helpers/summary-compare.mjs'; diff --git a/test/transforms.test.mjs b/test/transforms.test.mjs index e4f6495e..6c3ba459 100644 --- a/test/transforms.test.mjs +++ b/test/transforms.test.mjs @@ -11,10 +11,13 @@ import { processDataTable, Column, DataTable, - Transform, + Transform +} from '../src/lib/index.js'; + +import { transformColumns, computeWriteTransform -} from '../src/lib/index.js'; +} from '../src/lib/data-table/transform.js'; import { createMinimalTestData } from './helpers/test-utils.mjs'; import { assertClose } from './helpers/summary-compare.mjs'; From a2afb96c670ca8f84e7baea231761710a729057c Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 7 Apr 2026 22:02:06 +0100 Subject: [PATCH 04/13] latest --- src/lib/data-table/transform.ts | 53 +++++++---------------- src/lib/process.ts | 6 +-- src/lib/utils/math.ts | 75 +++++++++++++++++++-------------- test/source-transform.test.mjs | 29 ++++++------- 4 files changed, 75 insertions(+), 88 deletions(-) diff --git a/src/lib/data-table/transform.ts b/src/lib/data-table/transform.ts index 85b15931..7fe6cb35 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -1,4 +1,4 @@ -import { Mat3, Mat4, Quat, Vec3 } from 'playcanvas'; +import { Mat3, Quat, Vec3 } from 'playcanvas'; import { Column, DataTable, TypedArray } from './data-table'; import { Transform } from '../utils/math'; @@ -20,7 +20,7 @@ const _q = new Quat(); * @returns The delta transform to apply to raw data, or null if it is identity. */ const computeWriteTransform = (transform: Transform, outputFormatTransform: Transform): Transform | null => { - const delta = new Transform().invert(outputFormatTransform).mul(transform); + const delta = outputFormatTransform.clone().invert().mul(transform); return delta.isIdentity() ? null : delta; }; @@ -54,9 +54,6 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr } const numRows = dataTable.numRows; - const mat = new Mat4(); - delta.getMatrix(mat); - const r = delta.rotation; const s = delta.scale; @@ -84,7 +81,7 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr for (let i = 0; i < numRows; ++i) { _v.set(srcX[i], srcY[i], srcZ[i]); - mat.transformPoint(_v, _v); + delta.transformPoint(_v, _v); dstX[i] = _v.x; dstY[i] = _v.y; dstZ[i] = _v.z; @@ -188,34 +185,15 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr }; /** - * Transforms a point from engine space to raw data space using the inverse - * of the given source transform. + * Transforms an AABB by the given transform. The result is a + * (possibly conservative) axis-aligned bounding box that contains + * the transformed box. * - * @param t - The source transform (source -> engine). - * @param point - The point in engine space (modified in-place). - * @returns The point in raw data space. + * @param t - The transform to apply. + * @param min - The AABB minimum (modified in-place to output min). + * @param max - The AABB maximum (modified in-place to output max). */ -const inverseTransformPoint = (t: Transform, point: Vec3): Vec3 => { - const inv = new Transform().invert(t); - const mat = new Mat4(); - inv.getMatrix(mat); - mat.transformPoint(point, point); - return point; -}; - -/** - * Transforms an AABB from engine space to raw data space. The result is - * a (possibly conservative) AABB that contains the transformed box. - * - * @param t - The source transform (source -> engine). - * @param min - The AABB minimum in engine space (modified in-place to output min). - * @param max - The AABB maximum in engine space (modified in-place to output max). - */ -const inverseTransformAABB = (t: Transform, min: Vec3, max: Vec3): void => { - const inv = new Transform().invert(t); - const mat = new Mat4(); - inv.getMatrix(mat); - +const transformAABB = (t: Transform, min: Vec3, max: Vec3): void => { const corners = [ new Vec3(min.x, min.y, min.z), new Vec3(max.x, min.y, min.z), @@ -227,12 +205,12 @@ const inverseTransformAABB = (t: Transform, min: Vec3, max: Vec3): void => { new Vec3(max.x, max.y, max.z) ]; - mat.transformPoint(corners[0], corners[0]); + t.transformPoint(corners[0], corners[0]); min.copy(corners[0]); max.copy(corners[0]); for (let i = 1; i < 8; ++i) { - mat.transformPoint(corners[i], corners[i]); + t.transformPoint(corners[i], corners[i]); min.min(corners[i]); max.max(corners[i]); } @@ -276,7 +254,7 @@ const convertToSpace = (dataTable: DataTable, targetTransform: Transform): DataT * ``` */ const transform = (dataTable: DataTable, t: Vec3, r: Quat, s: number): void => { - const mat = new Mat4().setTRS(t, r, new Vec3(s, s, s)); + const xform = new Transform(t, r, s); const mat3 = new Mat3().setFromQuat(r); const rotateSH = new RotateSH(mat3); @@ -292,7 +270,7 @@ const transform = (dataTable: DataTable, t: Vec3, r: Quat, s: number): void => { if (hasTranslation) { _v.set(row.x, row.y, row.z); - mat.transformPoint(_v, _v); + xform.transformPoint(_v, _v); row.x = _v.x; row.y = _v.y; row.z = _v.z; @@ -336,6 +314,5 @@ export { transformColumns, computeWriteTransform, convertToSpace, - inverseTransformPoint, - inverseTransformAABB + transformAABB }; diff --git a/src/lib/process.ts b/src/lib/process.ts index 99b1240e..f250b1c8 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -4,7 +4,7 @@ import { Column, DataTable } from './data-table/data-table'; import { simplifyGaussians } from './data-table/decimate'; import { sortMortonOrder } from './data-table/morton-order'; import { computeSummary, type SummaryData } from './data-table/summary'; -import { Transform, inverseTransformPoint, inverseTransformAABB } from './data-table/transform'; +import { Transform, transformAABB } from './data-table/transform'; import { logger } from './utils/logger'; /** @@ -390,7 +390,7 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) const rawMin = processAction.min.clone(); const rawMax = processAction.max.clone(); if (!result.transform.isIdentity()) { - inverseTransformAABB(result.transform, rawMin, rawMax); + transformAABB(result.transform.clone().invert(), rawMin, rawMax); } const predicate = (row: any, rowIndex: number) => { const { x, y, z } = row; @@ -403,7 +403,7 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) const rawCenter = processAction.center.clone(); let rawRadius = processAction.radius; if (!result.transform.isIdentity()) { - inverseTransformPoint(result.transform, rawCenter); + result.transform.clone().invert().transformPoint(rawCenter, rawCenter); rawRadius /= result.transform.scale; } const radiusSq = rawRadius * rawRadius; diff --git a/src/lib/utils/math.ts b/src/lib/utils/math.ts index 94805bb9..500f38f8 100644 --- a/src/lib/utils/math.ts +++ b/src/lib/utils/math.ts @@ -14,7 +14,7 @@ const _tv = new Vec3(); * const t = new Transform().fromEulers(0, 0, 180); * console.log(t.isIdentity()); // false * - * const inv = new Transform().invert(t); + * const inv = t.clone().invert(); * console.log(t.mul(inv).isIdentity()); // true * ``` */ @@ -30,13 +30,27 @@ class Transform { } /** - * Fills the provided Mat4 with the TRS matrix for this transform. + * Sets this transform to a rotation-only transform from Euler angles in degrees. * - * @param result - The Mat4 to fill. - * @returns The filled Mat4. + * @param x - Rotation around X axis in degrees. + * @param y - Rotation around Y axis in degrees. + * @param z - Rotation around Z axis in degrees. + * @returns This transform (for chaining). */ - getMatrix(result: Mat4): Mat4 { - return result.setTRS(this.translation, this.rotation, new Vec3(this.scale, this.scale, this.scale)); + fromEulers(x: number, y: number, z: number): Transform { + this.translation.set(0, 0, 0); + this.rotation.setFromEulerAngles(x, y, z); + this.scale = 1; + return this; + } + + /** + * Creates a deep copy of this transform. + * + * @returns A new Transform with the same values. + */ + clone(): Transform { + return new Transform(this.translation, this.rotation, this.scale); } /** @@ -77,24 +91,14 @@ class Transform { } /** - * Creates a deep copy of this transform. + * Inverts this transform in-place. * - * @returns A new Transform with the same values. - */ - clone(): Transform { - return new Transform(this.translation, this.rotation, this.scale); - } - - /** - * Sets this transform to the inverse of the given source transform. - * - * @param src - The transform to invert. Defaults to this (in-place). * @returns This transform (for chaining). */ - invert(src: Transform = this): Transform { - this.scale = 1 / src.scale; - this.rotation.copy(src.rotation).invert(); - this.translation.copy(src.translation).mulScalar(-this.scale); + invert(): Transform { + this.scale = 1 / this.scale; + this.rotation.invert(); + this.translation.mulScalar(-this.scale); this.rotation.transformVector(this.translation, this.translation); return this; } @@ -129,18 +133,27 @@ class Transform { } /** - * Sets this transform to a rotation-only transform from Euler angles in degrees. + * Transforms a point by this TRS transform: result = translation + rotation * (scale * point). * - * @param x - Rotation around X axis in degrees. - * @param y - Rotation around Y axis in degrees. - * @param z - Rotation around Z axis in degrees. - * @returns This transform (for chaining). + * @param point - The input point. + * @param result - The Vec3 to write the result into (may alias point). + * @returns The transformed point. */ - fromEulers(x: number, y: number, z: number): Transform { - this.translation.set(0, 0, 0); - this.rotation.setFromEulerAngles(x, y, z); - this.scale = 1; - return this; + transformPoint(point: Vec3, result: Vec3): Vec3 { + result.copy(point).mulScalar(this.scale); + this.rotation.transformVector(result, result); + result.add(this.translation); + return result; + } + + /** + * Fills the provided Mat4 with the TRS matrix for this transform. + * + * @param result - The Mat4 to fill. + * @returns The filled Mat4. + */ + getMatrix(result: Mat4): Mat4 { + return result.setTRS(this.translation, this.rotation, new Vec3(this.scale, this.scale, this.scale)); } static freeze(t: Transform): Readonly { diff --git a/test/source-transform.test.mjs b/test/source-transform.test.mjs index 275f2ce8..7889336a 100644 --- a/test/source-transform.test.mjs +++ b/test/source-transform.test.mjs @@ -17,8 +17,7 @@ import { import { transformColumns, computeWriteTransform, - inverseTransformPoint, - inverseTransformAABB + transformAABB } from '../src/lib/data-table/transform.js'; import { createMinimalTestData } from './helpers/test-utils.mjs'; @@ -86,13 +85,13 @@ describe('Transform class', () => { it('invert produces correct inverse', () => { const t = new Transform(new Vec3(1, 2, 3), new Quat().setFromEulerAngles(30, 45, 60), 2); - const inv = new Transform().invert(t); + const inv = t.clone().invert(); const composed = t.mul(inv); assert.ok(composed.isIdentity(1e-5), 'T * T^-1 should be identity'); }); it('invert of identity is identity', () => { - const inv = new Transform().invert(Transform.IDENTITY); + const inv = Transform.IDENTITY.clone().invert(); assert.ok(inv.isIdentity(), 'inverse of identity should be identity'); }); @@ -136,7 +135,7 @@ describe('Transform class', () => { it('euler(0,0,180) is self-inverse', () => { const t = new Transform().fromEulers(0, 0, 180); - const inv = new Transform().invert(t); + const inv = t.clone().invert(); const composed = t.clone().mul(inv); assert.ok(composed.isIdentity(1e-5), 'euler(0,0,180) * inverse should be identity'); @@ -229,42 +228,40 @@ describe('transformColumns', () => { }); }); -// -- inverseTransformPoint & inverseTransformAABB -- +// -- Transform.transformPoint & transformAABB -- -describe('inverseTransformPoint', () => { +describe('Transform.transformPoint', () => { it('with identity transform is no-op', () => { const point = new Vec3(1, 2, 3); - inverseTransformPoint(Transform.IDENTITY, point); + Transform.IDENTITY.transformPoint(point, point); assertClose(point.x, 1, 1e-10, 'x'); assertClose(point.y, 2, 1e-10, 'y'); assertClose(point.z, 3, 1e-10, 'z'); }); - it('with PLY transform negates x and y', () => { + it('inverse of PLY transform negates x and y', () => { const point = new Vec3(1, 2, 3); - inverseTransformPoint(Transform.PLY, point); + Transform.PLY.clone().invert().transformPoint(point, point); assertClose(point.x, -1, 1e-5, 'x'); assertClose(point.y, -2, 1e-5, 'y'); assertClose(point.z, 3, 1e-5, 'z'); }); }); -describe('inverseTransformAABB', () => { +describe('transformAABB', () => { it('with identity transform is no-op', () => { const min = new Vec3(-1, -2, -3); const max = new Vec3(1, 2, 3); - inverseTransformAABB(Transform.IDENTITY, min, max); + transformAABB(Transform.IDENTITY, min, max); assertClose(min.x, -1, 1e-10, 'min.x'); assertClose(max.x, 1, 1e-10, 'max.x'); }); - it('with PLY transform swaps min/max for x and y', () => { + it('with inverse PLY transform swaps min/max for x and y', () => { const min = new Vec3(-1, -2, -3); const max = new Vec3(3, 4, 5); - inverseTransformAABB(Transform.PLY, min, max); + transformAABB(Transform.PLY.clone().invert(), min, max); - // After negating x and y, then computing AABB: - // x: [-3, 1] -> negated -> [-1, 3] -> AABB min=-3, max=1 assertClose(min.x, -3, 1e-5, 'min.x'); assertClose(max.x, 1, 1e-5, 'max.x'); assertClose(min.y, -4, 1e-5, 'min.y'); From 4c16b16f21b897fd4960108027fdd438110eb640 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 7 Apr 2026 22:18:33 +0100 Subject: [PATCH 05/13] latest --- src/lib/data-table/combine.ts | 3 +- src/lib/data-table/transform.ts | 106 +++--------------------- src/lib/process.ts | 3 +- src/lib/utils/math.ts | 4 +- src/lib/writers/write-compressed-ply.ts | 3 +- src/lib/writers/write-glb.ts | 3 +- src/lib/writers/write-lod.ts | 3 +- src/lib/writers/write-ply.ts | 3 +- src/lib/writers/write-voxel.ts | 3 +- 9 files changed, 29 insertions(+), 102 deletions(-) diff --git a/src/lib/data-table/combine.ts b/src/lib/data-table/combine.ts index abd2f3ec..696de4d5 100644 --- a/src/lib/data-table/combine.ts +++ b/src/lib/data-table/combine.ts @@ -1,5 +1,6 @@ import { Column, DataTable, TypedArray } from './data-table'; -import { convertToSpace, Transform } from './transform'; +import { convertToSpace } from './transform'; +import { Transform } from '../utils/math'; import { logger } from '../utils/logger'; /** diff --git a/src/lib/data-table/transform.ts b/src/lib/data-table/transform.ts index 7fe6cb35..d9b96200 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -193,26 +193,22 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr * @param min - The AABB minimum (modified in-place to output min). * @param max - The AABB maximum (modified in-place to output max). */ +const _aabbCorner = new Vec3(); +const _aabbResult = new Vec3(); + const transformAABB = (t: Transform, min: Vec3, max: Vec3): void => { - const corners = [ - new Vec3(min.x, min.y, min.z), - new Vec3(max.x, min.y, min.z), - new Vec3(min.x, max.y, min.z), - new Vec3(max.x, max.y, min.z), - new Vec3(min.x, min.y, max.z), - new Vec3(max.x, min.y, max.z), - new Vec3(min.x, max.y, max.z), - new Vec3(max.x, max.y, max.z) - ]; + const extents = [min.x, max.x, min.y, max.y, min.z, max.z]; - t.transformPoint(corners[0], corners[0]); - min.copy(corners[0]); - max.copy(corners[0]); + _aabbCorner.set(extents[0], extents[2], extents[4]); + t.transformPoint(_aabbCorner, _aabbResult); + min.copy(_aabbResult); + max.copy(_aabbResult); for (let i = 1; i < 8; ++i) { - t.transformPoint(corners[i], corners[i]); - min.min(corners[i]); - max.max(corners[i]); + _aabbCorner.set(extents[i & 1], extents[2 + ((i >> 1) & 1)], extents[4 + ((i >> 2) & 1)]); + t.transformPoint(_aabbCorner, _aabbResult); + min.min(_aabbResult); + max.max(_aabbResult); } }; @@ -232,85 +228,7 @@ const convertToSpace = (dataTable: DataTable, targetTransform: Transform): DataT return new DataTable(allNames.map(name => new Column(name, cols.get(name)!)), targetTransform); }; -// -- Legacy in-place transform function -- - -/** - * Applies a spatial transformation to splat data in-place. - * - * Transforms position, rotation, scale, and spherical harmonics data. - * The transformation is applied as: scale first, then rotation, then translation. - * - * @param dataTable - The DataTable to transform (modified in-place). - * @param t - Translation vector. - * @param r - Rotation quaternion. - * @param s - Uniform scale factor. - * - * @example - * ```ts - * import { Vec3, Quat } from 'playcanvas'; - * - * // Scale by 2x, rotate 90° around Y, translate up - * transform(dataTable, new Vec3(0, 5, 0), new Quat().setFromEulerAngles(0, 90, 0), 2.0); - * ``` - */ -const transform = (dataTable: DataTable, t: Vec3, r: Quat, s: number): void => { - const xform = new Transform(t, r, s); - const mat3 = new Mat3().setFromQuat(r); - const rotateSH = new RotateSH(mat3); - - const hasTranslation = ['x', 'y', 'z'].every(c => dataTable.hasColumn(c)); - const hasRotation = ['rot_0', 'rot_1', 'rot_2', 'rot_3'].every(c => dataTable.hasColumn(c)); - const hasScale = ['scale_0', 'scale_1', 'scale_2'].every(c => dataTable.hasColumn(c)); - const shBands = detectSHBands(dataTable); - const shCoeffs = new Float32Array([0, 3, 8, 15][shBands]); - - const row: any = {}; - for (let i = 0; i < dataTable.numRows; ++i) { - dataTable.getRow(i, row); - - if (hasTranslation) { - _v.set(row.x, row.y, row.z); - xform.transformPoint(_v, _v); - row.x = _v.x; - row.y = _v.y; - row.z = _v.z; - } - - if (hasRotation) { - _q.set(row.rot_1, row.rot_2, row.rot_3, row.rot_0).mul2(r, _q); - row.rot_0 = _q.w; - row.rot_1 = _q.x; - row.rot_2 = _q.y; - row.rot_3 = _q.z; - } - - if (hasScale && s !== 1) { - row.scale_0 = Math.log(Math.exp(row.scale_0) * s); - row.scale_1 = Math.log(Math.exp(row.scale_1) * s); - row.scale_2 = Math.log(Math.exp(row.scale_2) * s); - } - - if (shBands > 0) { - for (let j = 0; j < 3; ++j) { - for (let k = 0; k < shCoeffs.length; ++k) { - shCoeffs[k] = row[shNames[k + j * shCoeffs.length]]; - } - - rotateSH.apply(shCoeffs); - - for (let k = 0; k < shCoeffs.length; ++k) { - row[shNames[k + j * shCoeffs.length]] = shCoeffs[k]; - } - } - } - - dataTable.setRow(i, row); - } -}; - export { - Transform, - transform, transformColumns, computeWriteTransform, convertToSpace, diff --git a/src/lib/process.ts b/src/lib/process.ts index f250b1c8..b1eaabbc 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -4,7 +4,8 @@ import { Column, DataTable } from './data-table/data-table'; import { simplifyGaussians } from './data-table/decimate'; import { sortMortonOrder } from './data-table/morton-order'; import { computeSummary, type SummaryData } from './data-table/summary'; -import { Transform, transformAABB } from './data-table/transform'; +import { transformAABB } from './data-table/transform'; +import { Transform } from './utils/math'; import { logger } from './utils/logger'; /** diff --git a/src/lib/utils/math.ts b/src/lib/utils/math.ts index 500f38f8..e84f5877 100644 --- a/src/lib/utils/math.ts +++ b/src/lib/utils/math.ts @@ -3,6 +3,7 @@ import { Mat4, Quat, Vec3 } from 'playcanvas'; const sigmoid = (v: number) => 1 / (1 + Math.exp(-v)); const _tv = new Vec3(); +const _sv = new Vec3(); /** * A source-to-engine coordinate transform comprising translation, rotation @@ -153,7 +154,8 @@ class Transform { * @returns The filled Mat4. */ getMatrix(result: Mat4): Mat4 { - return result.setTRS(this.translation, this.rotation, new Vec3(this.scale, this.scale, this.scale)); + _sv.set(this.scale, this.scale, this.scale); + return result.setTRS(this.translation, this.rotation, _sv); } static freeze(t: Transform): Readonly { diff --git a/src/lib/writers/write-compressed-ply.ts b/src/lib/writers/write-compressed-ply.ts index 19de92ad..44203cef 100644 --- a/src/lib/writers/write-compressed-ply.ts +++ b/src/lib/writers/write-compressed-ply.ts @@ -2,7 +2,8 @@ import { CompressedChunk } from './compressed-chunk'; import { version } from '../../../package.json'; import { DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; -import { convertToSpace, Transform } from '../data-table/transform'; +import { convertToSpace } from '../data-table/transform'; +import { Transform } from '../utils/math'; import { type FileSystem } from '../io/write'; const generatedByString = `Generated by splat-transform ${version}`; diff --git a/src/lib/writers/write-glb.ts b/src/lib/writers/write-glb.ts index 83286838..e651c9c5 100644 --- a/src/lib/writers/write-glb.ts +++ b/src/lib/writers/write-glb.ts @@ -1,6 +1,7 @@ import { version } from '../../../package.json'; import { DataTable } from '../data-table/data-table'; -import { convertToSpace, Transform } from '../data-table/transform'; +import { convertToSpace } from '../data-table/transform'; +import { Transform } from '../utils/math'; import { type FileSystem } from '../io/write'; import { sigmoid } from '../utils/math'; diff --git a/src/lib/writers/write-lod.ts b/src/lib/writers/write-lod.ts index 9eefc5ac..2466b0e6 100644 --- a/src/lib/writers/write-lod.ts +++ b/src/lib/writers/write-lod.ts @@ -4,7 +4,8 @@ import { BoundingBox, Mat4, Quat, Vec3 } from 'playcanvas'; import { writeSog, type DeviceCreator } from './write-sog.js'; import { TypedArray, DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; -import { convertToSpace, Transform } from '../data-table/transform'; +import { convertToSpace } from '../data-table/transform'; +import { Transform } from '../utils/math'; import { type FileSystem } from '../io/write'; import { BTreeNode, BTree } from '../spatial/b-tree'; import { logger } from '../utils/logger'; diff --git a/src/lib/writers/write-ply.ts b/src/lib/writers/write-ply.ts index 648c84c3..9b232f0e 100644 --- a/src/lib/writers/write-ply.ts +++ b/src/lib/writers/write-ply.ts @@ -1,4 +1,5 @@ -import { convertToSpace, Transform } from '../data-table/transform'; +import { convertToSpace } from '../data-table/transform'; +import { Transform } from '../utils/math'; import { type FileSystem } from '../io/write'; import { PlyData } from '../readers/read-ply'; diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index a450cce7..e590935d 100644 --- a/src/lib/writers/write-voxel.ts +++ b/src/lib/writers/write-voxel.ts @@ -3,7 +3,8 @@ import { Vec3 } from 'playcanvas'; import type { DeviceCreator } from './write-sog'; import { Column, DataTable } from '../data-table/data-table'; -import { computeWriteTransform, Transform, transformColumns } from '../data-table/transform'; +import { computeWriteTransform, transformColumns } from '../data-table/transform'; +import { Transform } from '../utils/math'; import { type FileSystem, writeFile } from '../io/write'; import { logger } from '../utils/logger'; import { buildCollisionGlb } from '../voxel/collision-glb'; From 8205c388cb89ca2c7350ad8cdb27d5e29605192f Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 7 Apr 2026 23:31:13 +0100 Subject: [PATCH 06/13] latest --- src/lib/data-table/combine.ts | 2 +- src/lib/process.ts | 38 ++++++++++++++++++------- src/lib/writers/write-compressed-ply.ts | 2 +- src/lib/writers/write-glb.ts | 3 +- src/lib/writers/write-lod.ts | 2 +- src/lib/writers/write-ply.ts | 2 +- src/lib/writers/write-voxel.ts | 2 +- test/transforms.test.mjs | 36 +++++++++++++++++++++++ 8 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/lib/data-table/combine.ts b/src/lib/data-table/combine.ts index 696de4d5..88f978a9 100644 --- a/src/lib/data-table/combine.ts +++ b/src/lib/data-table/combine.ts @@ -1,7 +1,7 @@ import { Column, DataTable, TypedArray } from './data-table'; import { convertToSpace } from './transform'; -import { Transform } from '../utils/math'; import { logger } from '../utils/logger'; +import { Transform } from '../utils/math'; /** * Combines multiple DataTables into a single DataTable. diff --git a/src/lib/process.ts b/src/lib/process.ts index b1eaabbc..3e064c23 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -4,9 +4,9 @@ import { Column, DataTable } from './data-table/data-table'; import { simplifyGaussians } from './data-table/decimate'; import { sortMortonOrder } from './data-table/morton-order'; import { computeSummary, type SummaryData } from './data-table/summary'; -import { transformAABB } from './data-table/transform'; -import { Transform } from './utils/math'; +import { convertToSpace, transformAABB } from './data-table/transform'; import { logger } from './utils/logger'; +import { Transform } from './utils/math'; /** * Translate splats by a 3D vector offset. @@ -53,8 +53,12 @@ type FilterNaN = { * (transformed) space: linear opacity (0-1), linear scale, and linear color (0-1). * The value is automatically converted to raw PLY space before comparison. * - * To compare against raw PLY values directly, use the `_raw` suffix - * (e.g. `opacity_raw`, `scale_0_raw`, `f_dc_0_raw`). + * To compare against raw PLY values directly (without the user-friendly conversion), + * use the `_raw` suffix (e.g. `opacity_raw`, `scale_0_raw`, `f_dc_0_raw`). + * + * If the DataTable has a pending spatial transform and the column is affected by it + * (position, rotation, scale, or SH columns), the transform is applied (baked in) + * before comparison. This applies to both regular and `_raw` columns. */ type FilterByValue = { /** Action type identifier. */ @@ -212,6 +216,14 @@ const rawColumnMap: Record = { 'f_dc_2_raw': 'f_dc_2' }; +const transformColumnNames = new Set([ + 'x', 'y', 'z', + 'rot_0', 'rot_1', 'rot_2', 'rot_3', + 'scale_0', 'scale_1', 'scale_2' +]); + +const isTransformColumn = (name: string): boolean => transformColumnNames.has(name) || /^f_rest_\d+$/.test(name); + const formatMarkdown = (summary: SummaryData): string => { const lines: string[] = []; @@ -348,15 +360,19 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) value = inverseTransforms[columnName](value); } + if (!result.transform.isIdentity() && isTransformColumn(columnName)) { + result = convertToSpace(result, Transform.IDENTITY); + } + const Predicates = { - 'lt': (row: any, rowIndex: number) => row[columnName] < value, - 'lte': (row: any, rowIndex: number) => row[columnName] <= value, - 'gt': (row: any, rowIndex: number) => row[columnName] > value, - 'gte': (row: any, rowIndex: number) => row[columnName] >= value, - 'eq': (row: any, rowIndex: number) => row[columnName] === value, - 'neq': (row: any, rowIndex: number) => row[columnName] !== value + 'lt': (row: any) => row[columnName] < value, + 'lte': (row: any) => row[columnName] <= value, + 'gt': (row: any) => row[columnName] > value, + 'gte': (row: any) => row[columnName] >= value, + 'eq': (row: any) => row[columnName] === value, + 'neq': (row: any) => row[columnName] !== value }; - const predicate = Predicates[comparator] ?? ((row: any, rowIndex: number) => true); + const predicate = Predicates[comparator] ?? ((row: any) => true); result = filter(result, predicate); break; } diff --git a/src/lib/writers/write-compressed-ply.ts b/src/lib/writers/write-compressed-ply.ts index 44203cef..afdfee13 100644 --- a/src/lib/writers/write-compressed-ply.ts +++ b/src/lib/writers/write-compressed-ply.ts @@ -3,8 +3,8 @@ import { version } from '../../../package.json'; import { DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; import { convertToSpace } from '../data-table/transform'; -import { Transform } from '../utils/math'; import { type FileSystem } from '../io/write'; +import { Transform } from '../utils/math'; const generatedByString = `Generated by splat-transform ${version}`; diff --git a/src/lib/writers/write-glb.ts b/src/lib/writers/write-glb.ts index e651c9c5..4300dded 100644 --- a/src/lib/writers/write-glb.ts +++ b/src/lib/writers/write-glb.ts @@ -1,9 +1,8 @@ import { version } from '../../../package.json'; import { DataTable } from '../data-table/data-table'; import { convertToSpace } from '../data-table/transform'; -import { Transform } from '../utils/math'; import { type FileSystem } from '../io/write'; -import { sigmoid } from '../utils/math'; +import { Transform, sigmoid } from '../utils/math'; const SH_C0 = 0.2820947917738781; diff --git a/src/lib/writers/write-lod.ts b/src/lib/writers/write-lod.ts index 2466b0e6..bcef1b38 100644 --- a/src/lib/writers/write-lod.ts +++ b/src/lib/writers/write-lod.ts @@ -5,10 +5,10 @@ import { writeSog, type DeviceCreator } from './write-sog.js'; import { TypedArray, DataTable } from '../data-table/data-table'; import { sortMortonOrder } from '../data-table/morton-order'; import { convertToSpace } from '../data-table/transform'; -import { Transform } from '../utils/math'; import { type FileSystem } from '../io/write'; import { BTreeNode, BTree } from '../spatial/b-tree'; import { logger } from '../utils/logger'; +import { Transform } from '../utils/math'; type Aabb = { diff --git a/src/lib/writers/write-ply.ts b/src/lib/writers/write-ply.ts index 9b232f0e..97824513 100644 --- a/src/lib/writers/write-ply.ts +++ b/src/lib/writers/write-ply.ts @@ -1,7 +1,7 @@ import { convertToSpace } from '../data-table/transform'; -import { Transform } from '../utils/math'; import { type FileSystem } from '../io/write'; import { PlyData } from '../readers/read-ply'; +import { Transform } from '../utils/math'; const columnTypeToPlyType = (type: string): string => { switch (type) { diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index e590935d..8a4e7198 100644 --- a/src/lib/writers/write-voxel.ts +++ b/src/lib/writers/write-voxel.ts @@ -4,9 +4,9 @@ import { Vec3 } from 'playcanvas'; import type { DeviceCreator } from './write-sog'; import { Column, DataTable } from '../data-table/data-table'; import { computeWriteTransform, transformColumns } from '../data-table/transform'; -import { Transform } from '../utils/math'; import { type FileSystem, writeFile } from '../io/write'; import { logger } from '../utils/logger'; +import { Transform } from '../utils/math'; import { buildCollisionGlb } from '../voxel/collision-glb'; import { computeGaussianExtents, diff --git a/test/transforms.test.mjs b/test/transforms.test.mjs index 6c3ba459..38b1fd21 100644 --- a/test/transforms.test.mjs +++ b/test/transforms.test.mjs @@ -382,6 +382,42 @@ describe('Filter By Value', () => { assert.strictEqual(result.numRows, 0, 'Should have no rows'); }); + + it('should apply transform before filtering transform-sensitive columns', () => { + const data = new DataTable([ + new Column('x', new Float32Array([0, 1, 2])), + new Column('y', new Float32Array([0, 0, 0])), + new Column('z', new Float32Array([0, 0, 0])) + ]); + + const result = processDataTable(data, [ + { kind: 'translate', value: new Vec3(10, 0, 0) }, + { kind: 'filterByValue', columnName: 'x', comparator: 'gt', value: 11 } + ]); + + assert.strictEqual(result.numRows, 1, 'Should keep one row after transformed-space filtering'); + assert.strictEqual(result.getColumnByName('x').data[0], 12, 'Transform should be baked into column data'); + assert.ok(result.transform.isIdentity(), 'Transform should be identity after baking'); + }); + + it('should apply spatial transform but skip inverse transform for _raw suffix', () => { + const logVal = Math.log(2); + const data = new DataTable([ + new Column('x', new Float32Array([0, 0, 0])), + new Column('y', new Float32Array([0, 0, 0])), + new Column('z', new Float32Array([0, 0, 0])), + new Column('scale_0', new Float32Array([0, logVal, logVal * 2])), + new Column('scale_1', new Float32Array([0, 0, 0])), + new Column('scale_2', new Float32Array([0, 0, 0])) + ]); + + const result = processDataTable(data, [ + { kind: 'scale', value: 2 }, + { kind: 'filterByValue', columnName: 'scale_0_raw', comparator: 'gt', value: logVal * 1.5 } + ]); + + assert.strictEqual(result.numRows, 2, 'Spatial transform should be applied before raw comparison'); + }); }); describe('Filter NaN', () => { From fa4906017ba1a5dae739fd18e6f3a8935f44308c Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Wed, 8 Apr 2026 10:45:16 +0100 Subject: [PATCH 07/13] latest --- src/lib/data-table/transform.ts | 12 +++++++----- src/lib/utils/math.ts | 3 +++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lib/data-table/transform.ts b/src/lib/data-table/transform.ts index d9b96200..16d9669d 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -62,13 +62,15 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr const rotNames = ['rot_0', 'rot_1', 'rot_2', 'rot_3']; const scaleNames = ['scale_0', 'scale_1', 'scale_2']; - const needPos = posNames.every(n => columnNames.includes(n) && dataTable.hasColumn(n)); + const hasPos = posNames.every(n => dataTable.hasColumn(n)); + const needPos = hasPos && posNames.some(n => columnNames.includes(n)); const needRot = rotNames.every(n => columnNames.includes(n) && dataTable.hasColumn(n)); const needScale = scaleNames.some(n => columnNames.includes(n) && dataTable.hasColumn(n)) && s !== 1; const shBands = detectSHBands(dataTable); const shCoeffsPerChannel = [0, 3, 8, 15][shBands]; - const requestedSH = shBands > 0 && shNames.slice(0, shCoeffsPerChannel * 3).some(n => columnNames.includes(n)); + const rotIsIdentity = Math.abs(Math.abs(r.w) - 1) < 1e-6; + const requestedSH = shBands > 0 && !rotIsIdentity && shNames.slice(0, shCoeffsPerChannel * 3).some(n => columnNames.includes(n)); // Position columns if (needPos) { @@ -87,9 +89,9 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr dstZ[i] = _v.z; } - result.set('x', dstX); - result.set('y', dstY); - result.set('z', dstZ); + if (columnNames.includes('x')) result.set('x', dstX); + if (columnNames.includes('y')) result.set('y', dstY); + if (columnNames.includes('z')) result.set('z', dstZ); } // Rotation columns diff --git a/src/lib/utils/math.ts b/src/lib/utils/math.ts index e84f5877..7c88479d 100644 --- a/src/lib/utils/math.ts +++ b/src/lib/utils/math.ts @@ -97,6 +97,9 @@ class Transform { * @returns This transform (for chaining). */ invert(): Transform { + if (this.scale === 0) { + throw new Error('Cannot invert a Transform with scale 0'); + } this.scale = 1 / this.scale; this.rotation.invert(); this.translation.mulScalar(-this.scale); From 79f8d7afe9742456fec5f9bdc9081ba72718007b Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Wed, 8 Apr 2026 10:54:12 +0100 Subject: [PATCH 08/13] latest --- src/lib/readers/read-sog.ts | 2 +- src/lib/writers/write-sog.ts | 4 ++-- test/formats.test.mjs | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/readers/read-sog.ts b/src/lib/readers/read-sog.ts index 9078357f..defefa5e 100644 --- a/src/lib/readers/read-sog.ts +++ b/src/lib/readers/read-sog.ts @@ -216,7 +216,7 @@ const readSog = async (fileSystem: ReadFileSystem, filename: string): Promise= 3 ? undefined : Transform.PLY); + return new DataTable(columns, Transform.PLY); }; export { readSog }; diff --git a/src/lib/writers/write-sog.ts b/src/lib/writers/write-sog.ts index a9bbfa75..b11b7f10 100644 --- a/src/lib/writers/write-sog.ts +++ b/src/lib/writers/write-sog.ts @@ -84,7 +84,7 @@ type WriteSogOptions = { */ const writeSog = async (options: WriteSogOptions, fs: FileSystem) => { const { filename: outputFilename, bundle, iterations, createDevice } = options; - const dataTable = convertToSpace(options.dataTable, Transform.IDENTITY); + const dataTable = convertToSpace(options.dataTable, Transform.PLY); // initialize output stream - use ZipFileSystem for bundled output const zipFs = bundle ? new ZipFileSystem(await fs.createWriter(outputFilename)) : null; @@ -343,7 +343,7 @@ const writeSog = async (options: WriteSogOptions, fs: FileSystem) => { // construct meta.json const meta: any = { - version: 3, + version: 2, asset: { generator: `splat-transform v${version}` }, diff --git a/test/formats.test.mjs b/test/formats.test.mjs index 0fee8d1c..b57eb0d6 100644 --- a/test/formats.test.mjs +++ b/test/formats.test.mjs @@ -253,6 +253,7 @@ describe('SOG Format (Bundled)', () => { before(() => { testData = createMinimalTestData(); + testData.transform = Transform.PLY.clone(); expectedSummary = computeSummary(testData); }); @@ -300,6 +301,7 @@ describe('SOG Format (Unbundled)', () => { before(() => { testData = createMinimalTestData(); + testData.transform = Transform.PLY.clone(); expectedSummary = computeSummary(testData); }); From ec4fd0bab30127d07cbd8d5358e721e5d92962a2 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Wed, 8 Apr 2026 11:00:14 +0100 Subject: [PATCH 09/13] latesr --- src/lib/process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/process.ts b/src/lib/process.ts index 3e064c23..446811d8 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -1,4 +1,4 @@ -import { Quat, Vec3 } from 'playcanvas'; +import { Vec3 } from 'playcanvas'; import { Column, DataTable } from './data-table/data-table'; import { simplifyGaussians } from './data-table/decimate'; From 56f6bf21f57b1b00a6b3167e1df0b22087f098fd Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Thu, 9 Apr 2026 11:52:48 +0100 Subject: [PATCH 10/13] latest --- src/lib/utils/rotate-sh.ts | 151 +++++++++++++++++------ test/rotate-sh.test.mjs | 243 +++++++++++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+), 35 deletions(-) create mode 100644 test/rotate-sh.test.mjs diff --git a/src/lib/utils/rotate-sh.ts b/src/lib/utils/rotate-sh.ts index 7bab47cd..679f2675 100644 --- a/src/lib/utils/rotate-sh.ts +++ b/src/lib/utils/rotate-sh.ts @@ -39,6 +39,45 @@ const dp = (n: number, start: number, a: number[] | Float32Array, b: number[] | const coeffsIn = new Float32Array(15); +// Build a sparse representation of the SH rotation matrices. For axis-aligned +// rotations the matrices are highly sparse, so iterating only over non-zero +// entries is significantly faster than the full dot-product approach. +const buildSparse = (sh1: number[][], sh2: number[][], sh3: number[][]) => { + const counts: number[] = []; + const indices: number[] = []; + const values: number[] = []; + + const addBand = (matrix: number[][], size: number, base: number) => { + for (let i = 0; i < size; i++) { + let count = 0; + for (let j = 0; j < size; j++) { + if (Math.abs(matrix[i][j]) > 1e-10) { + indices.push(base + j); + values.push(matrix[i][j]); + count++; + } + } + counts.push(count); + } + }; + + addBand(sh1, 3, 0); + addBand(sh2, 5, 3); + addBand(sh3, 7, 8); + + return { counts, indices, values }; +}; + +// Returns true if the rotation matrix is a signed permutation (every entry is 0 or ±1), +// i.e. the rotation maps each axis to ±another axis (multiples of 90°). +const isAxisAligned = (rot: Float32Array) => { + for (let i = 0; i < 9; i++) { + const a = Math.abs(rot[i]); + if (a > 0.01 && Math.abs(a - 1) > 0.01) return false; + } + return true; +}; + // Rotate spherical harmonics up to band 3 based on https://github.com/andrewwillmott/sh-lib // // This implementation calculates the rotation factors during construction which can then @@ -148,43 +187,85 @@ class RotateSH { kSqrt01_04 * ((sh1[2][2] * sh2[4][4] - sh1[2][0] * sh2[4][0]) - (sh1[0][2] * sh2[0][4] - sh1[0][0] * sh2[0][0])) ]]; - // rotate spherical harmonic coefficients, up to band 3 - this.apply = (result: Float32Array | number[], src?: Float32Array | number[]) => { - if (!src || src === result) { - coeffsIn.set(result); - src = coeffsIn; - } + if (isAxisAligned(rot)) { + const { counts, indices, values } = buildSparse(sh1, sh2, sh3); - // band 1 - if (result.length < 3) { - return; - } - result[0] = dp(3, 0, src, sh1[0]); - result[1] = dp(3, 0, src, sh1[1]); - result[2] = dp(3, 0, src, sh1[2]); + this.apply = (result: Float32Array | number[], src?: Float32Array | number[]) => { + if (!src || src === result) { + coeffsIn.set(result); + src = coeffsIn; + } - // band 2 - if (result.length < 8) { - return; - } - result[3] = dp(5, 3, src, sh2[0]); - result[4] = dp(5, 3, src, sh2[1]); - result[5] = dp(5, 3, src, sh2[2]); - result[6] = dp(5, 3, src, sh2[3]); - result[7] = dp(5, 3, src, sh2[4]); - - // band 3 - if (result.length < 15) { - return; - } - result[8] = dp(7, 8, src, sh3[0]); - result[9] = dp(7, 8, src, sh3[1]); - result[10] = dp(7, 8, src, sh3[2]); - result[11] = dp(7, 8, src, sh3[3]); - result[12] = dp(7, 8, src, sh3[4]); - result[13] = dp(7, 8, src, sh3[5]); - result[14] = dp(7, 8, src, sh3[6]); - }; + let vp = 0; + + if (result.length < 3) return; + for (let i = 0; i < 3; i++) { + let sum = 0; + for (let k = 0; k < counts[i]; k++) { + sum += values[vp] * src[indices[vp]]; + vp++; + } + result[i] = sum; + } + + if (result.length < 8) return; + for (let i = 0; i < 5; i++) { + let sum = 0; + for (let k = 0; k < counts[3 + i]; k++) { + sum += values[vp] * src[indices[vp]]; + vp++; + } + result[3 + i] = sum; + } + + if (result.length < 15) return; + for (let i = 0; i < 7; i++) { + let sum = 0; + for (let k = 0; k < counts[8 + i]; k++) { + sum += values[vp] * src[indices[vp]]; + vp++; + } + result[8 + i] = sum; + } + }; + } else { + this.apply = (result: Float32Array | number[], src?: Float32Array | number[]) => { + if (!src || src === result) { + coeffsIn.set(result); + src = coeffsIn; + } + + // band 1 + if (result.length < 3) { + return; + } + result[0] = dp(3, 0, src, sh1[0]); + result[1] = dp(3, 0, src, sh1[1]); + result[2] = dp(3, 0, src, sh1[2]); + + // band 2 + if (result.length < 8) { + return; + } + result[3] = dp(5, 3, src, sh2[0]); + result[4] = dp(5, 3, src, sh2[1]); + result[5] = dp(5, 3, src, sh2[2]); + result[6] = dp(5, 3, src, sh2[3]); + result[7] = dp(5, 3, src, sh2[4]); + + // band 3 + if (result.length < 15) { + return; + } + result[8] = dp(7, 8, src, sh3[0]); + result[9] = dp(7, 8, src, sh3[1]); + result[10] = dp(7, 8, src, sh3[2]); + result[11] = dp(7, 8, src, sh3[3]); + result[12] = dp(7, 8, src, sh3[4]); + result[13] = dp(7, 8, src, sh3[5]); + result[14] = dp(7, 8, src, sh3[6]); + }; + } } } diff --git a/test/rotate-sh.test.mjs b/test/rotate-sh.test.mjs new file mode 100644 index 00000000..2de4cd93 --- /dev/null +++ b/test/rotate-sh.test.mjs @@ -0,0 +1,243 @@ +/** + * Tests for RotateSH, with focus on the axis-aligned (signed permutation) fast path. + * + * Axis-aligned rotations (multiples of 90° around coordinate axes) produce signed + * permutation rotation matrices, enabling a fast path where each output SH coefficient + * is ±1 × a single input coefficient instead of a full dot product. + */ + +import { describe, it } from 'node:test'; + +import { Mat3, Quat } from 'playcanvas'; + +import { RotateSH } from '../src/lib/utils/rotate-sh.js'; +import { assertClose } from './helpers/summary-compare.mjs'; + +const mat3FromEulers = (x, y, z) => { + return new Mat3().setFromQuat(new Quat().setFromEulerAngles(x, y, z)); +}; + +const testCoeffs = () => new Float32Array([ + 0.5, -1.3, 2.1, + -0.7, 1.8, -0.3, 0.9, -1.1, + 0.4, -0.6, 1.2, -1.5, 0.8, -0.2, 1.7 +]); + +const bandNorm = (c, start, count) => { + let sum = 0; + for (let i = start; i < start + count; i++) sum += c[i] * c[i]; + return sum; +}; + +describe('RotateSH axis-aligned fast path', () => { + + describe('band 1 known values', () => { + it('90° around Z', () => { + const r = new RotateSH(mat3FromEulers(0, 0, 90)); + const c = new Float32Array([1, 2, 3]); + r.apply(c); + assertClose(c[0], 3, 1e-6, 'c0'); + assertClose(c[1], 2, 1e-6, 'c1'); + assertClose(c[2], -1, 1e-6, 'c2'); + }); + + it('90° around Y', () => { + const r = new RotateSH(mat3FromEulers(0, 90, 0)); + const c = new Float32Array([1, 2, 3]); + r.apply(c); + assertClose(c[0], 1, 1e-6, 'c0'); + assertClose(c[1], 3, 1e-6, 'c1'); + assertClose(c[2], -2, 1e-6, 'c2'); + }); + + it('90° around X', () => { + const r = new RotateSH(mat3FromEulers(90, 0, 0)); + const c = new Float32Array([1, 2, 3]); + r.apply(c); + assertClose(c[0], 2, 1e-6, 'c0'); + assertClose(c[1], -1, 1e-6, 'c1'); + assertClose(c[2], 3, 1e-6, 'c2'); + }); + + it('180° around Z (PLY convention)', () => { + const r = new RotateSH(mat3FromEulers(0, 0, 180)); + const c = new Float32Array([1, 2, 3]); + r.apply(c); + assertClose(c[0], -1, 1e-6, 'c0'); + assertClose(c[1], 2, 1e-6, 'c1'); + assertClose(c[2], -3, 1e-6, 'c2'); + }); + }); + + describe('round-trip (rotation then inverse)', () => { + const rotations = [ + [90, 0, 0], [0, 90, 0], [0, 0, 90], + [180, 0, 0], [0, 180, 0], [0, 0, 180], + [270, 0, 0], [0, 270, 0], [0, 0, 270], + [90, 90, 0], [90, 0, 90], [0, 90, 90], + [90, 90, 90], [90, 180, 0], [180, 0, 90] + ]; + + for (const eulers of rotations) { + it(`euler(${eulers}) round-trip preserves all bands`, () => { + const q = new Quat().setFromEulerAngles(eulers[0], eulers[1], eulers[2]); + const qInv = q.clone().invert(); + const r = new RotateSH(new Mat3().setFromQuat(q)); + const rInv = new RotateSH(new Mat3().setFromQuat(qInv)); + + const original = testCoeffs(); + const c = new Float32Array(original); + r.apply(c); + rInv.apply(c); + + for (let i = 0; i < 15; i++) { + assertClose(c[i], original[i], 1e-5, `coeff ${i}`); + } + }); + } + }); + + describe('repeated application', () => { + it('180° applied twice equals identity', () => { + for (const eulers of [[180, 0, 0], [0, 180, 0], [0, 0, 180]]) { + const r = new RotateSH(mat3FromEulers(eulers[0], eulers[1], eulers[2])); + const original = testCoeffs(); + const c = new Float32Array(original); + r.apply(c); + r.apply(c); + for (let i = 0; i < 15; i++) { + assertClose(c[i], original[i], 1e-5, `euler(${eulers}) coeff ${i}`); + } + } + }); + + it('90° applied four times equals identity', () => { + for (const eulers of [[90, 0, 0], [0, 90, 0], [0, 0, 90]]) { + const r = new RotateSH(mat3FromEulers(eulers[0], eulers[1], eulers[2])); + const original = testCoeffs(); + const c = new Float32Array(original); + r.apply(c); + r.apply(c); + r.apply(c); + r.apply(c); + for (let i = 0; i < 15; i++) { + assertClose(c[i], original[i], 1e-5, `euler(${eulers}) coeff ${i}`); + } + } + }); + }); + + describe('norm preservation', () => { + for (const eulers of [[90, 0, 0], [0, 90, 0], [0, 0, 90], [90, 90, 0], [90, 90, 90]]) { + it(`euler(${eulers}) preserves L2 norm of each band`, () => { + const r = new RotateSH(mat3FromEulers(eulers[0], eulers[1], eulers[2])); + const original = testCoeffs(); + const c = new Float32Array(original); + r.apply(c); + + assertClose(bandNorm(c, 0, 3), bandNorm(original, 0, 3), 1e-5, 'band 1'); + assertClose(bandNorm(c, 3, 5), bandNorm(original, 3, 5), 1e-5, 'band 2'); + assertClose(bandNorm(c, 8, 7), bandNorm(original, 8, 7), 1e-5, 'band 3'); + }); + } + }); + + describe('composition', () => { + it('sequential R(A) then R(B) equals single R(A*B)', () => { + const qA = new Quat().setFromEulerAngles(90, 0, 0); + const qB = new Quat().setFromEulerAngles(0, 90, 0); + const qAB = new Quat().mul2(qA, qB); + + const rA = new RotateSH(new Mat3().setFromQuat(qA)); + const rB = new RotateSH(new Mat3().setFromQuat(qB)); + const rAB = new RotateSH(new Mat3().setFromQuat(qAB)); + + const original = testCoeffs(); + + const cSeq = new Float32Array(original); + rB.apply(cSeq); + rA.apply(cSeq); + + const cComp = new Float32Array(original); + rAB.apply(cComp); + + for (let i = 0; i < 15; i++) { + assertClose(cSeq[i], cComp[i], 1e-5, `coeff ${i}`); + } + }); + + it('three composed 90° rotations match combined rotation', () => { + const qX = new Quat().setFromEulerAngles(90, 0, 0); + const qY = new Quat().setFromEulerAngles(0, 90, 0); + const qZ = new Quat().setFromEulerAngles(0, 0, 90); + const qAll = new Quat().mul2(qZ, new Quat().mul2(qY, qX)); + + const rX = new RotateSH(new Mat3().setFromQuat(qX)); + const rY = new RotateSH(new Mat3().setFromQuat(qY)); + const rZ = new RotateSH(new Mat3().setFromQuat(qZ)); + const rAll = new RotateSH(new Mat3().setFromQuat(qAll)); + + const original = testCoeffs(); + + const cSeq = new Float32Array(original); + rX.apply(cSeq); + rY.apply(cSeq); + rZ.apply(cSeq); + + const cComp = new Float32Array(original); + rAll.apply(cComp); + + for (let i = 0; i < 15; i++) { + assertClose(cSeq[i], cComp[i], 1e-5, `coeff ${i}`); + } + }); + }); + + describe('partial bands', () => { + it('correctly handles band-1-only input (3 coefficients)', () => { + const r = new RotateSH(mat3FromEulers(0, 0, 90)); + const rInv = new RotateSH(mat3FromEulers(0, 0, -90)); + const original = new Float32Array([1, -2, 3]); + const c = new Float32Array(original); + r.apply(c); + rInv.apply(c); + for (let i = 0; i < 3; i++) { + assertClose(c[i], original[i], 1e-6, `coeff ${i}`); + } + }); + + it('correctly handles band-1+2 input (8 coefficients)', () => { + const r = new RotateSH(mat3FromEulers(90, 0, 0)); + const rInv = new RotateSH(mat3FromEulers(-90, 0, 0)); + const original = new Float32Array([1, -2, 3, -4, 5, -6, 7, -8]); + const c = new Float32Array(original); + r.apply(c); + rInv.apply(c); + for (let i = 0; i < 8; i++) { + assertClose(c[i], original[i], 1e-5, `coeff ${i}`); + } + }); + }); + + describe('band 2 known values', () => { + it('180° around Z negates xy and xz terms, preserves others', () => { + const r = new RotateSH(mat3FromEulers(0, 0, 180)); + const original = testCoeffs(); + const c = new Float32Array(original); + r.apply(c); + + // 180° around Z: x→-x, y→-y, z→z + // Band 2 basis: xy, yz, (3z²-r²)/2, xz, (x²-y²)/2 + // xy → (-x)(-y) = xy → same (index 3) + // yz → (-y)(z) = -yz → negate (index 4) + // (3z²-r²)/2 → unchanged (index 5) + // xz → (-x)(z) = -xz → negate (index 6) + // (x²-y²)/2 → (x²-y²)/2 → same (index 7) + assertClose(c[3], original[3], 1e-6, 'xy unchanged'); + assertClose(c[4], -original[4], 1e-6, 'yz negated'); + assertClose(c[5], original[5], 1e-6, 'z² unchanged'); + assertClose(c[6], -original[6], 1e-6, 'xz negated'); + assertClose(c[7], original[7], 1e-6, 'x²-y² unchanged'); + }); + }); +}); From f83a0ebdb6ff6464c11dfc7c949d041b66e9de2c Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Thu, 9 Apr 2026 15:39:27 +0100 Subject: [PATCH 11/13] latest --- src/lib/data-table/transform.ts | 31 +------------------- src/lib/process.ts | 51 ++++++++++++++++++++++++++------- test/source-transform.test.mjs | 28 ++---------------- test/transforms.test.mjs | 21 ++++++++++++++ 4 files changed, 65 insertions(+), 66 deletions(-) diff --git a/src/lib/data-table/transform.ts b/src/lib/data-table/transform.ts index 16d9669d..9de204ac 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -186,34 +186,6 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr return result; }; -/** - * Transforms an AABB by the given transform. The result is a - * (possibly conservative) axis-aligned bounding box that contains - * the transformed box. - * - * @param t - The transform to apply. - * @param min - The AABB minimum (modified in-place to output min). - * @param max - The AABB maximum (modified in-place to output max). - */ -const _aabbCorner = new Vec3(); -const _aabbResult = new Vec3(); - -const transformAABB = (t: Transform, min: Vec3, max: Vec3): void => { - const extents = [min.x, max.x, min.y, max.y, min.z, max.z]; - - _aabbCorner.set(extents[0], extents[2], extents[4]); - t.transformPoint(_aabbCorner, _aabbResult); - min.copy(_aabbResult); - max.copy(_aabbResult); - - for (let i = 1; i < 8; ++i) { - _aabbCorner.set(extents[i & 1], extents[2 + ((i >> 1) & 1)], extents[4 + ((i >> 2) & 1)]); - t.transformPoint(_aabbCorner, _aabbResult); - min.min(_aabbResult); - max.max(_aabbResult); - } -}; - /** * Returns a new DataTable with column data converted to the target coordinate * space. If the DataTable is already in that space, returns it unchanged. @@ -233,6 +205,5 @@ const convertToSpace = (dataTable: DataTable, targetTransform: Transform): DataT export { transformColumns, computeWriteTransform, - convertToSpace, - transformAABB + convertToSpace }; diff --git a/src/lib/process.ts b/src/lib/process.ts index 446811d8..cc42be14 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -4,7 +4,7 @@ import { Column, DataTable } from './data-table/data-table'; import { simplifyGaussians } from './data-table/decimate'; import { sortMortonOrder } from './data-table/morton-order'; import { computeSummary, type SummaryData } from './data-table/summary'; -import { convertToSpace, transformAABB } from './data-table/transform'; +import { convertToSpace } from './data-table/transform'; import { logger } from './utils/logger'; import { Transform } from './utils/math'; @@ -404,16 +404,47 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) break; } case 'filterBox': { - const rawMin = processAction.min.clone(); - const rawMax = processAction.max.clone(); - if (!result.transform.isIdentity()) { - transformAABB(result.transform.clone().invert(), rawMin, rawMax); + const { min, max } = processAction; + + if (result.transform.isIdentity()) { + const predicate = (row: any) => { + const { x, y, z } = row; + return x >= min.x && x <= max.x && + y >= min.y && y <= max.y && + z >= min.z && z <= max.z; + }; + result = filter(result, predicate); + } else { + const { translation, scale } = result.transform; + const invRot = result.transform.rotation.clone().invert(); + + const axes = [new Vec3(1, 0, 0), new Vec3(0, 1, 0), new Vec3(0, 0, 1)]; + const rawAxes = axes.map((a) => { + const r = new Vec3(); + invRot.transformVector(a, r); + return r; + }); + + const minArr = [min.x, min.y, min.z]; + const maxArr = [max.x, max.y, max.z]; + const rawMin = new Array(3); + const rawMax = new Array(3); + for (let j = 0; j < 3; j++) { + const dot = axes[j].dot(translation); + rawMin[j] = (minArr[j] - dot) / scale; + rawMax[j] = (maxArr[j] - dot) / scale; + } + + const predicate = (row: any) => { + const { x, y, z } = row; + for (let j = 0; j < 3; j++) { + const proj = rawAxes[j].x * x + rawAxes[j].y * y + rawAxes[j].z * z; + if (proj < rawMin[j] || proj > rawMax[j]) return false; + } + return true; + }; + result = filter(result, predicate); } - const predicate = (row: any, rowIndex: number) => { - const { x, y, z } = row; - return x >= rawMin.x && x <= rawMax.x && y >= rawMin.y && y <= rawMax.y && z >= rawMin.z && z <= rawMax.z; - }; - result = filter(result, predicate); break; } case 'filterSphere': { diff --git a/test/source-transform.test.mjs b/test/source-transform.test.mjs index 7889336a..fe24140a 100644 --- a/test/source-transform.test.mjs +++ b/test/source-transform.test.mjs @@ -16,8 +16,7 @@ import { import { transformColumns, - computeWriteTransform, - transformAABB + computeWriteTransform } from '../src/lib/data-table/transform.js'; import { createMinimalTestData } from './helpers/test-utils.mjs'; @@ -228,7 +227,7 @@ describe('transformColumns', () => { }); }); -// -- Transform.transformPoint & transformAABB -- +// -- Transform.transformPoint -- describe('Transform.transformPoint', () => { it('with identity transform is no-op', () => { @@ -248,29 +247,6 @@ describe('Transform.transformPoint', () => { }); }); -describe('transformAABB', () => { - it('with identity transform is no-op', () => { - const min = new Vec3(-1, -2, -3); - const max = new Vec3(1, 2, 3); - transformAABB(Transform.IDENTITY, min, max); - assertClose(min.x, -1, 1e-10, 'min.x'); - assertClose(max.x, 1, 1e-10, 'max.x'); - }); - - it('with inverse PLY transform swaps min/max for x and y', () => { - const min = new Vec3(-1, -2, -3); - const max = new Vec3(3, 4, 5); - transformAABB(Transform.PLY.clone().invert(), min, max); - - assertClose(min.x, -3, 1e-5, 'min.x'); - assertClose(max.x, 1, 1e-5, 'max.x'); - assertClose(min.y, -4, 1e-5, 'min.y'); - assertClose(max.y, 2, 1e-5, 'max.y'); - assertClose(min.z, -3, 1e-5, 'min.z'); - assertClose(max.z, 5, 1e-5, 'max.z'); - }); -}); - // -- DataTable transform -- describe('DataTable transform', () => { diff --git a/test/transforms.test.mjs b/test/transforms.test.mjs index 38b1fd21..d53a1b72 100644 --- a/test/transforms.test.mjs +++ b/test/transforms.test.mjs @@ -260,6 +260,27 @@ describe('Filter Box', () => { assert.strictEqual(result.numRows, 0, 'Should have no rows'); }); + + it('should use exact oriented box test with non-axis-aligned rotation', () => { + const dt = new DataTable([ + new Column('x', new Float32Array([0, 1, 0, 0.9, -0.9])), + new Column('y', new Float32Array([0, 0, 0, 0, 0])), + new Column('z', new Float32Array([0, 0, 1, 0.9, 0.9])) + ]); + + dt.transform = new Transform().fromEulers(0, 45, 0); + + // Engine-space box [-0.8, 0.8] on x and z. + // Points 0,1,2 map inside; points 3,4 map outside (engine x=1.27 and z=1.27). + // A conservative AABB approach would incorrectly include points 3 and 4. + const result = processDataTable(dt, [{ + kind: 'filterBox', + min: new Vec3(-0.8, -Infinity, -0.8), + max: new Vec3(0.8, Infinity, 0.8) + }]); + + assert.strictEqual(result.numRows, 3, 'Should keep exactly 3 points (exact OBB, not conservative AABB)'); + }); }); describe('Filter Sphere', () => { From 3c537acf403fe4993d7cd86924a0dc9208df8928 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Thu, 9 Apr 2026 16:34:14 +0100 Subject: [PATCH 12/13] latest --- src/lib/data-table/transform.ts | 39 ++++++++++++++++++++++----------- src/lib/writers/write-lod.ts | 4 ++-- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/lib/data-table/transform.ts b/src/lib/data-table/transform.ts index 9de204ac..e6f88475 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -40,9 +40,10 @@ const detectSHBands = (dataTable: DataTable): number => { * @param dataTable - The source DataTable. * @param columnNames - Which columns to produce. * @param delta - The transform to apply. If identity or null, original arrays are returned. + * @param inPlace - If true, mutate the DataTable's existing column arrays instead of allocating new ones. * @returns A map of column name to typed array. */ -const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Transform | null): Map => { +const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Transform | null, inPlace = false): Map => { const result = new Map(); if (!delta || delta.isIdentity()) { @@ -77,9 +78,9 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr const srcX = dataTable.getColumnByName('x')!.data; const srcY = dataTable.getColumnByName('y')!.data; const srcZ = dataTable.getColumnByName('z')!.data; - const dstX = new Float32Array(numRows); - const dstY = new Float32Array(numRows); - const dstZ = new Float32Array(numRows); + const dstX = inPlace ? srcX : new Float32Array(numRows); + const dstY = inPlace ? srcY : new Float32Array(numRows); + const dstZ = inPlace ? srcZ : new Float32Array(numRows); for (let i = 0; i < numRows; ++i) { _v.set(srcX[i], srcY[i], srcZ[i]); @@ -100,10 +101,10 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr const src1 = dataTable.getColumnByName('rot_1')!.data; const src2 = dataTable.getColumnByName('rot_2')!.data; const src3 = dataTable.getColumnByName('rot_3')!.data; - const dst0 = new Float32Array(numRows); - const dst1 = new Float32Array(numRows); - const dst2 = new Float32Array(numRows); - const dst3 = new Float32Array(numRows); + const dst0 = inPlace ? src0 : new Float32Array(numRows); + const dst1 = inPlace ? src1 : new Float32Array(numRows); + const dst2 = inPlace ? src2 : new Float32Array(numRows); + const dst3 = inPlace ? src3 : new Float32Array(numRows); for (let i = 0; i < numRows; ++i) { _q.set(src1[i], src2[i], src3[i], src0[i]).mul2(r, _q); @@ -125,7 +126,7 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr for (const name of scaleNames) { if (!columnNames.includes(name) || !dataTable.hasColumn(name)) continue; const src = dataTable.getColumnByName(name)!.data; - const dst = new Float32Array(numRows); + const dst = inPlace ? src : new Float32Array(numRows); for (let i = 0; i < numRows; ++i) { dst[i] = src[i] + logS; } @@ -146,8 +147,9 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr const dst: Float32Array[] = []; for (let k = 0; k < shCoeffsPerChannel; ++k) { const name = shNames[k + j * shCoeffsPerChannel]; - src.push(dataTable.getColumnByName(name)!.data as Float32Array); - dst.push(new Float32Array(numRows)); + const colData = dataTable.getColumnByName(name)!.data as Float32Array; + src.push(colData); + dst.push(inPlace ? colData : new Float32Array(numRows)); } shSrc.push(src); shDst.push(dst); @@ -187,16 +189,27 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr }; /** - * Returns a new DataTable with column data converted to the target coordinate + * Returns a DataTable with column data converted to the target coordinate * space. If the DataTable is already in that space, returns it unchanged. * * @param dataTable - The source DataTable. * @param targetTransform - The desired coordinate-space transform. + * @param inPlace - If true, mutate the DataTable's column arrays and transform + * in place instead of allocating a new DataTable. The caller must ensure no + * other code depends on the original column data. * @returns A DataTable whose raw data is in the target coordinate space. */ -const convertToSpace = (dataTable: DataTable, targetTransform: Transform): DataTable => { +const convertToSpace = (dataTable: DataTable, targetTransform: Transform, inPlace = false): DataTable => { const delta = computeWriteTransform(dataTable.transform, targetTransform); if (!delta) return dataTable; + + if (inPlace) { + const allNames = dataTable.columnNames; + transformColumns(dataTable, allNames, delta, true); + dataTable.transform = targetTransform.clone(); + return dataTable; + } + const allNames = dataTable.columnNames; const cols = transformColumns(dataTable, allNames, delta); return new DataTable(allNames.map(name => new Column(name, cols.get(name)!)), targetTransform); diff --git a/src/lib/writers/write-lod.ts b/src/lib/writers/write-lod.ts index bcef1b38..6ac69957 100644 --- a/src/lib/writers/write-lod.ts +++ b/src/lib/writers/write-lod.ts @@ -160,8 +160,8 @@ type WriteLodOptions = { const writeLod = async (options: WriteLodOptions, fs: FileSystem) => { const { filename, iterations, createDevice, chunkCount, chunkExtent } = options; - const dataTable = convertToSpace(options.dataTable, Transform.IDENTITY); - const envDataTable = options.envDataTable ? convertToSpace(options.envDataTable, Transform.IDENTITY) : null; + const dataTable = convertToSpace(options.dataTable, Transform.IDENTITY, true); + const envDataTable = options.envDataTable ? convertToSpace(options.envDataTable, Transform.IDENTITY, true) : null; const outputDir = dirname(filename); From ae48eab8a4b083d6b46b3de4d28e74563e52ca70 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Thu, 9 Apr 2026 17:25:55 +0100 Subject: [PATCH 13/13] latest --- src/lib/data-table/transform.ts | 11 ++++++----- src/lib/process.ts | 3 +++ src/lib/writers/write-voxel.ts | 4 ++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/lib/data-table/transform.ts b/src/lib/data-table/transform.ts index e6f88475..e93b250a 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -65,7 +65,8 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr const hasPos = posNames.every(n => dataTable.hasColumn(n)); const needPos = hasPos && posNames.some(n => columnNames.includes(n)); - const needRot = rotNames.every(n => columnNames.includes(n) && dataTable.hasColumn(n)); + const hasRot = rotNames.every(n => dataTable.hasColumn(n)); + const needRot = hasRot && rotNames.some(n => columnNames.includes(n)); const needScale = scaleNames.some(n => columnNames.includes(n) && dataTable.hasColumn(n)) && s !== 1; const shBands = detectSHBands(dataTable); @@ -114,10 +115,10 @@ const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Tr dst3[i] = _q.z; } - result.set('rot_0', dst0); - result.set('rot_1', dst1); - result.set('rot_2', dst2); - result.set('rot_3', dst3); + if (columnNames.includes('rot_0')) result.set('rot_0', dst0); + if (columnNames.includes('rot_1')) result.set('rot_1', dst1); + if (columnNames.includes('rot_2')) result.set('rot_2', dst2); + if (columnNames.includes('rot_3')) result.set('rot_3', dst3); } // Scale columns (only affected when uniform scale != 1) diff --git a/src/lib/process.ts b/src/lib/process.ts index cc42be14..5a5c4480 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -416,6 +416,9 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) result = filter(result, predicate); } else { const { translation, scale } = result.transform; + if (scale === 0) { + throw new Error('Cannot apply filterBox with scale 0'); + } const invRot = result.transform.rotation.clone().invert(); const axes = [new Vec3(1, 0, 0), new Vec3(0, 1, 0), new Vec3(0, 0, 1)]; diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index 8a4e7198..897a7c75 100644 --- a/src/lib/writers/write-voxel.ts +++ b/src/lib/writers/write-voxel.ts @@ -215,6 +215,10 @@ const writeVoxel = async (options: WriteVoxelOptions, fs: FileSystem): Promise !dataTable.hasColumn(name)); + if (missingColumns.length > 0) { + throw new Error(`writeVoxel: missing required column(s): ${missingColumns.join(', ')}`); + } const delta = computeWriteTransform(dataTable.transform, Transform.IDENTITY); const cols = transformColumns(dataTable, voxelColumns, delta); const pcDataTable = new DataTable(voxelColumns.map(name => new Column(name, cols.get(name)!)));