diff --git a/src/lib/data-table/combine.ts b/src/lib/data-table/combine.ts index e8e6a63e..88f978a9 100644 --- a/src/lib/data-table/combine.ts +++ b/src/lib/data-table/combine.ts @@ -1,4 +1,7 @@ import { Column, DataTable, TypedArray } from './data-table'; +import { convertToSpace } from './transform'; +import { logger } from '../utils/logger'; +import { Transform } from '../utils/math'; /** * Combines multiple DataTables into a single DataTable. @@ -7,6 +10,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 +24,22 @@ 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 => 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 => convertToSpace(dt, Transform.IDENTITY)); + 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 +50,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 +62,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..02a8240d 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() : new Transform(); } // 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/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/data-table/transform.ts b/src/lib/data-table/transform.ts index f5f856ed..e93b250a 100644 --- a/src/lib/data-table/transform.ts +++ b/src/lib/data-table/transform.ts @@ -1,85 +1,223 @@ -import { Mat3, Mat4, Quat, Vec3 } from 'playcanvas'; +import { Mat3, Quat, Vec3 } from 'playcanvas'; -import { DataTable } from './data-table'; +import { Column, 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 -- /** - * 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. + * Computes the delta transform needed to convert raw data from its current + * coordinate system into the output format's coordinate system. * - * @example - * ```ts - * import { Vec3, Quat } from 'playcanvas'; + * @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 = outputFormatTransform.clone().invert().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). * - * // Scale by 2x, rotate 90° around Y, translate up - * transform(dataTable, new Vec3(0, 5, 0), new Quat().setFromEulerAngles(0, 90, 0), 2.0); - * ``` + * @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 transform = (dataTable: DataTable, t: Vec3, r: Quat, s: number): void => { - const mat = new Mat4().setTRS(t, r, new Vec3(s, s, 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 = { '9': 1, '24': 2, '-1': 3 }[shNames.findIndex(v => !dataTable.hasColumn(v))] ?? 0; - 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); - mat.transformPoint(v, v); - row.x = v.x; - row.y = v.y; - row.z = v.z; +const transformColumns = (dataTable: DataTable, columnNames: string[], delta: Transform | null, inPlace = false): 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 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 hasPos = posNames.every(n => dataTable.hasColumn(n)); + const needPos = hasPos && posNames.some(n => columnNames.includes(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); + const shCoeffsPerChannel = [0, 3, 8, 15][shBands]; + 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) { + const srcX = dataTable.getColumnByName('x')!.data; + const srcY = dataTable.getColumnByName('y')!.data; + const srcZ = dataTable.getColumnByName('z')!.data; + const dstX = inPlace ? srcX : new Float32Array(numRows); + const dstY = inPlace ? srcY : new Float32Array(numRows); + const dstZ = inPlace ? srcZ : new Float32Array(numRows); - 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; + for (let i = 0; i < numRows; ++i) { + _v.set(srcX[i], srcY[i], srcZ[i]); + delta.transformPoint(_v, _v); + dstX[i] = _v.x; + dstY[i] = _v.y; + dstZ[i] = _v.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 (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 + 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 = 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); + dst0[i] = _q.w; + dst1[i] = _q.x; + dst2[i] = _q.y; + dst3[i] = _q.z; } - if (shBands > 0) { + 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) + 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 = inPlace ? src : 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 shSrc: Float32Array[][] = []; + const shDst: Float32Array[][] = []; + for (let j = 0; j < 3; ++j) { + const src: Float32Array[] = []; + const dst: Float32Array[] = []; + for (let k = 0; k < shCoeffsPerChannel; ++k) { + const name = shNames[k + j * shCoeffsPerChannel]; + const colData = dataTable.getColumnByName(name)!.data as Float32Array; + src.push(colData); + dst.push(inPlace ? colData : new Float32Array(numRows)); + } + shSrc.push(src); + shDst.push(dst); + } + + for (let i = 0; i < numRows; ++i) { for (let j = 0; j < 3; ++j) { - for (let k = 0; k < shCoeffs.length; ++k) { - shCoeffs[k] = row[shNames[k + j * shCoeffs.length]]; + for (let k = 0; k < shCoeffsPerChannel; ++k) { + shCoeffs[k] = shSrc[j][k][i]; } - rotateSH.apply(shCoeffs); + for (let k = 0; k < shCoeffsPerChannel; ++k) { + shDst[j][k][i] = shCoeffs[k]; + } + } + } - for (let k = 0; k < shCoeffs.length; ++k) { - row[shNames[k + j * shCoeffs.length]] = shCoeffs[k]; + for (let j = 0; j < 3; ++j) { + for (let k = 0; k < shCoeffsPerChannel; ++k) { + const name = shNames[k + j * shCoeffsPerChannel]; + if (columnNames.includes(name)) { + result.set(name, shDst[j][k]); } } } + } - dataTable.setRow(i, row); + // 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; }; -export { transform }; +/** + * 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, 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); +}; + +export { + transformColumns, + computeWriteTransform, + convertToSpace +}; diff --git a/src/lib/index.ts b/src/lib/index.ts index 8a34a99f..eac1e979 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 { 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/process.ts b/src/lib/process.ts index d132ae77..5a5c4480 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -1,11 +1,12 @@ -import { Quat, Vec3 } from 'playcanvas'; +import { Vec3 } from 'playcanvas'; 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 { convertToSpace } from './data-table/transform'; import { logger } from './utils/logger'; +import { Transform } from './utils/math'; /** * Translate splats by a 3D vector offset. @@ -52,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. */ @@ -211,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[] = []; @@ -306,17 +319,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']); @@ -347,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; } @@ -382,25 +399,68 @@ const processDataTable = (dataTable: DataTable, processActions: ProcessAction[]) } return column; - }).filter(c => c !== null)); + }).filter(c => c !== null), result.transform); } break; } case 'filterBox': { const { min, max } = processAction; - 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; - }; - result = filter(result, predicate); + + 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; + 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)]; + 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); + } break; } case 'filterSphere': { - const { center, radius } = processAction; - const radiusSq = radius * radius; + const rawCenter = processAction.center.clone(); + let rawRadius = processAction.radius; + if (!result.transform.isIdentity()) { + result.transform.clone().invert().transformPoint(rawCenter, 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; diff --git a/src/lib/readers/read-ksplat.ts b/src/lib/readers/read-ksplat.ts index 88daf8b7..be48ce12 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, Transform.PLY); }; 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..742ace10 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 = Transform.PLY.clone(); + return result; }; export { PlyData, readPly }; diff --git a/src/lib/readers/read-sog.ts b/src/lib/readers/read-sog.ts index 2b16912a..defefa5e 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 => { } } - return new DataTable(columns); + 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 40476d90..77fa2f2e 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, Transform.PLY); }; export { readSpz }; diff --git a/src/lib/utils/math.ts b/src/lib/utils/math.ts index b8717541..7c88479d 100644 --- a/src/lib/utils/math.ts +++ b/src/lib/utils/math.ts @@ -1,3 +1,179 @@ +import { Mat4, Quat, Vec3 } from 'playcanvas'; + const sigmoid = (v: number) => 1 / (1 + Math.exp(-v)); -export { sigmoid }; +const _tv = new Vec3(); +const _sv = 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 = t.clone().invert(); + * 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; + } + + /** + * 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; + } + + /** + * 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); + } + + /** + * 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 the transforms are equal within the tolerance. + */ + 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; + } + 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 - 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); + } + + /** + * Inverts this transform in-place. + * + * @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); + 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); + } + + /** + * Transforms a point by this TRS transform: result = translation + rotation * (scale * point). + * + * @param point - The input point. + * @param result - The Vec3 to write the result into (may alias point). + * @returns The transformed point. + */ + 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 { + _sv.set(this.scale, this.scale, this.scale); + return result.setTRS(this.translation, this.rotation, _sv); + } + + static freeze(t: Transform): Readonly { + Object.freeze(t.translation); + Object.freeze(t.rotation); + return Object.freeze(t); + } + + static IDENTITY = Transform.freeze(new Transform()); + + /** + * PLY coordinate convention: 180-degree rotation around Z. + * Used by PLY, splat, KSplat, SPZ, and legacy SOG formats. + */ + static PLY = Transform.freeze(new Transform().fromEulers(0, 0, 180)); +} + +export { sigmoid, Transform }; 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/src/lib/write.ts b/src/lib/write.ts index 25502995..4b845c4c 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -147,7 +147,7 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { comments: [], elements: [{ name: 'vertex', - dataTable: dataTable + dataTable }] } }, fs); diff --git a/src/lib/writers/write-compressed-ply.ts b/src/lib/writers/write-compressed-ply.ts index 2745b2a9..afdfee13 100644 --- a/src/lib/writers/write-compressed-ply.ts +++ b/src/lib/writers/write-compressed-ply.ts @@ -2,7 +2,9 @@ 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 } from '../data-table/transform'; import { type FileSystem } from '../io/write'; +import { Transform } from '../utils/math'; const generatedByString = `Generated by splat-transform ${version}`; @@ -44,7 +46,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..4300dded 100644 --- a/src/lib/writers/write-glb.ts +++ b/src/lib/writers/write-glb.ts @@ -1,7 +1,8 @@ import { version } from '../../../package.json'; import { DataTable } from '../data-table/data-table'; +import { convertToSpace } from '../data-table/transform'; import { type FileSystem } from '../io/write'; -import { sigmoid } from '../utils/math'; +import { Transform, sigmoid } from '../utils/math'; const SH_C0 = 0.2820947917738781; @@ -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 43115e60..6ac69957 100644 --- a/src/lib/writers/write-lod.ts +++ b/src/lib/writers/write-lod.ts @@ -4,9 +4,11 @@ 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 } from '../data-table/transform'; 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 = { @@ -156,7 +158,10 @@ 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; + + const dataTable = convertToSpace(options.dataTable, Transform.IDENTITY, true); + const envDataTable = options.envDataTable ? convertToSpace(options.envDataTable, Transform.IDENTITY, true) : null; const outputDir = dirname(filename); diff --git a/src/lib/writers/write-ply.ts b/src/lib/writers/write-ply.ts index 02041f9d..97824513 100644 --- a/src/lib/writers/write-ply.ts +++ b/src/lib/writers/write-ply.ts @@ -1,5 +1,7 @@ +import { convertToSpace } from '../data-table/transform'; import { type FileSystem } from '../io/write'; import { PlyData } from '../readers/read-ply'; +import { Transform } from '../utils/math'; const columnTypeToPlyType = (type: string): string => { switch (type) { @@ -30,7 +32,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 e734a464..b11b7f10 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 { 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'; 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,8 @@ 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; + 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; diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index 8bf6e988..897a7c75 100644 --- a/src/lib/writers/write-voxel.ts +++ b/src/lib/writers/write-voxel.ts @@ -1,11 +1,12 @@ 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, transformColumns } from '../data-table/transform'; 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, @@ -206,19 +207,21 @@ 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)!))); 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/formats.test.mjs b/test/formats.test.mjs index 3130ad9f..b57eb0d6 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); }); @@ -250,6 +253,7 @@ describe('SOG Format (Bundled)', () => { before(() => { testData = createMinimalTestData(); + testData.transform = Transform.PLY.clone(); expectedSummary = computeSummary(testData); }); @@ -297,6 +301,7 @@ describe('SOG Format (Unbundled)', () => { before(() => { testData = createMinimalTestData(); + testData.transform = Transform.PLY.clone(); expectedSummary = computeSummary(testData); }); 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'); + }); + }); +}); diff --git a/test/source-transform.test.mjs b/test/source-transform.test.mjs new file mode 100644 index 00000000..fe24140a --- /dev/null +++ b/test/source-transform.test.mjs @@ -0,0 +1,412 @@ +/** + * 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, + combine, + processDataTable +} from '../src/lib/index.js'; + +import { + transformColumns, + computeWriteTransform +} from '../src/lib/data-table/transform.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 = 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 = Transform.IDENTITY.clone().invert(); + 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 = t.clone().invert(); + 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 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 result = computeWriteTransform(Transform.PLY, 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 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; + + // 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 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 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'); + }); + + 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 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'); + }); +}); + +// -- Transform.transformPoint -- + +describe('Transform.transformPoint', () => { + it('with identity transform is no-op', () => { + const point = new Vec3(1, 2, 3); + 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('inverse of PLY transform negates x and y', () => { + const point = new Vec3(1, 2, 3); + 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'); + }); +}); + +// -- 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 = Transform.PLY.clone(); + + 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 = Transform.PLY; + dt2.transform = Transform.PLY; + + 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 = Transform.PLY; + 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 = 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). + // 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 = Transform.PLY; + + // 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 = Transform.PLY; + + 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 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 = Transform.PLY; + + 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, Transform.PLY); + 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..d53a1b72 100644 --- a/test/transforms.test.mjs +++ b/test/transforms.test.mjs @@ -10,14 +10,19 @@ import { computeSummary, processDataTable, Column, - DataTable + DataTable, + Transform } from '../src/lib/index.js'; +import { + transformColumns, + computeWriteTransform +} from '../src/lib/data-table/transform.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 +31,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 +42,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 +92,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 +104,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'); - // Log-encoded scales should shift by log(factor) + // 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}]`); + } + + // 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 +143,6 @@ describe('Scale Transform', () => { }); it('should handle fractional scale factor', () => { - const originalSummary = computeSummary(testData); const clonedData = testData.clone(); const scaleFactor = 0.5; @@ -120,10 +151,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 +162,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 +174,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', () => { @@ -224,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', () => { @@ -346,6 +403,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', () => { @@ -418,21 +511,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', () => {