@loaders.gl/splats 5.0.0-alpha.1 → 5.0.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +14 -3
  2. package/dist/dist.dev.js +36757 -17782
  3. package/dist/dist.min.js +68 -7
  4. package/dist/index.cjs +2769 -838
  5. package/dist/index.cjs.map +3 -4
  6. package/dist/index.d.ts +3 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/ksplat-loader-types.js +1 -1
  11. package/dist/ksplat-loader-types.js.map +1 -1
  12. package/dist/lib/parse-rad-chunk.js +3 -3
  13. package/dist/lib/parse-rad-chunk.js.map +1 -1
  14. package/dist/lib/parse-rad.d.ts +23 -7
  15. package/dist/lib/parse-rad.d.ts.map +1 -1
  16. package/dist/lib/parse-rad.js +10 -135
  17. package/dist/lib/parse-rad.js.map +1 -1
  18. package/dist/lib/parse-spz.d.ts.map +1 -1
  19. package/dist/lib/parse-spz.js +153 -4
  20. package/dist/lib/parse-spz.js.map +1 -1
  21. package/dist/rad-loader-types.js +1 -1
  22. package/dist/rad-loader-types.js.map +1 -1
  23. package/dist/rad-metadata.schema.json +133 -0
  24. package/dist/rad-source-loader.js +1 -1
  25. package/dist/rad-source-loader.js.map +1 -1
  26. package/dist/rad-zod-schema.cjs +81 -0
  27. package/dist/rad-zod-schema.cjs.map +6 -0
  28. package/dist/rad-zod-schema.d.ts +88 -0
  29. package/dist/rad-zod-schema.d.ts.map +1 -0
  30. package/dist/rad-zod-schema.js +70 -0
  31. package/dist/rad-zod-schema.js.map +1 -0
  32. package/dist/radc-metadata.schema.json +112 -0
  33. package/dist/splat-loader-types.js +1 -1
  34. package/dist/splat-loader-types.js.map +1 -1
  35. package/dist/spz-loader-types.js +1 -1
  36. package/dist/spz-loader-types.js.map +1 -1
  37. package/dist/types.d.ts +6 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +17 -8
  40. package/src/index.ts +4 -1
  41. package/src/lib/parse-rad-chunk.ts +3 -3
  42. package/src/lib/parse-rad.ts +46 -167
  43. package/src/lib/parse-spz.ts +190 -5
  44. package/src/rad-zod-schema.ts +83 -0
  45. package/src/types.ts +7 -0
@@ -2,9 +2,9 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  // Copyright (c) vis.gl contributors
4
4
 
5
- import {ZstdCompression} from '@loaders.gl/compression';
5
+ import {GZipDecompressor, ZstdDecompressor} from '@loaders.gl/compression';
6
6
  import type {MeshArrowTable} from '@loaders.gl/schema';
7
- import type {GaussianSplats, SplatsLoaderOptions} from '../types';
7
+ import type {GaussianSplats, SPZCoordinateSystem, SplatsLoaderOptions} from '../types';
8
8
  import {makeGaussianSplatsArrowTable} from './splats-arrow-table';
9
9
  import {normalizeQuaternion} from './splat-utils';
10
10
 
@@ -46,20 +46,205 @@ export async function parseSPZToGaussianSplats(
46
46
  data: ArrayBuffer,
47
47
  options?: SplatsLoaderOptions
48
48
  ): Promise<GaussianSplats> {
49
+ const version = detectSPZVersion(data);
50
+ if (version === 0) {
51
+ return await parseLegacySPZ(data, options);
52
+ }
53
+ if (version !== SPZ_VERSION) {
54
+ throw new Error(`SPZLoader: version ${version} is not supported.`);
55
+ }
49
56
  const header = parseSPZHeader(data);
50
57
  const streamInfos = parseSPZStreamInfos(data, header, getSPZStreamByteLengths(header));
51
58
  const compressedStreams = streamInfos.map(streamInfo =>
52
59
  data.slice(streamInfo.compressedOffset, streamInfo.compressedOffset + streamInfo.compressedSize)
53
60
  );
54
- const compression = new ZstdCompression({modules: options?.modules});
61
+ const decompressor = new ZstdDecompressor({modules: options?.modules});
55
62
  const streams = await Promise.all(
56
63
  compressedStreams.map((compressedStream, streamIndex) =>
57
- compression.decompress(compressedStream, streamInfos[streamIndex].uncompressedSize)
64
+ decompressor.decompress(compressedStream, streamInfos[streamIndex].uncompressedSize)
58
65
  )
59
66
  );
60
67
 
61
68
  validateSPZStreamLengths(streams, streamInfos);
62
- return decodeSPZStreams(header, streams, data);
69
+ return applySPZCoordinateSystem(
70
+ decodeSPZStreams(header, streams, data),
71
+ options?.splats?.sourceCoordinateSystem ?? 'RUB',
72
+ options?.splats?.targetCoordinateSystem ?? options?.splats?.sourceCoordinateSystem ?? 'RUB'
73
+ );
74
+ }
75
+
76
+ /** Detects the SPZ generation from either the legacy gzip or v4 plaintext header. */
77
+ function detectSPZVersion(data: ArrayBuffer): number {
78
+ if (data.byteLength < 8) {
79
+ throw new Error('SPZLoader: file must contain at least an 8-byte header.');
80
+ }
81
+ const bytes = new Uint8Array(data);
82
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
83
+ // Legacy files carry their 16-byte header inside the gzip stream. The
84
+ // version is therefore read by parseLegacySPZ after decompression.
85
+ return 0;
86
+ }
87
+ if (data.byteLength < SPZ_HEADER_BYTE_LENGTH) {
88
+ throw new Error('SPZLoader: file must contain a 32-byte header.');
89
+ }
90
+ return new DataView(data).getUint32(4, true);
91
+ }
92
+
93
+ /** Decodes the single-stream SPZ v2/v3 container. */
94
+ async function parseLegacySPZ(
95
+ data: ArrayBuffer,
96
+ options?: SplatsLoaderOptions
97
+ ): Promise<GaussianSplats> {
98
+ const decompressed = await new GZipDecompressor({
99
+ modules: options?.modules,
100
+ useNative: true
101
+ }).decompress(data);
102
+ if (decompressed.byteLength < 16) {
103
+ throw new Error('SPZLoader: legacy payload must contain a 16-byte header.');
104
+ }
105
+ const dataView = new DataView(decompressed);
106
+ const header = {
107
+ magic: dataView.getUint32(0, true),
108
+ version: dataView.getUint32(4, true),
109
+ numPoints: dataView.getUint32(8, true),
110
+ shDegree: dataView.getUint8(12),
111
+ fractionalBits: dataView.getUint8(13),
112
+ flags: dataView.getUint8(14)
113
+ };
114
+ if (header.magic !== SPZ_MAGIC) {
115
+ throw new Error('SPZLoader: NGSP magic header not found.');
116
+ }
117
+ if (header.version !== 2 && header.version !== 3) {
118
+ throw new Error(`SPZLoader: version ${header.version} is not supported.`);
119
+ }
120
+ if (dataView.getUint8(15) !== 0) {
121
+ throw new Error('SPZLoader: legacy header reserved byte must be zero.');
122
+ }
123
+ if (header.shDegree > 3) {
124
+ throw new Error(`SPZLoader: spherical harmonics degree ${header.shDegree} is not supported.`);
125
+ }
126
+
127
+ const count = header.numPoints;
128
+ const shComponentCount = getSphericalHarmonicsComponentCount(header.shDegree);
129
+ const positionByteLength = count * 9;
130
+ const alphaByteLength = count;
131
+ const colorByteLength = count * 3;
132
+ const scaleByteLength = count * 3;
133
+ const rotationByteLength = count * (header.version === 2 ? 3 : 4);
134
+ const shByteLength = count * shComponentCount;
135
+ const payloadByteLength =
136
+ positionByteLength +
137
+ alphaByteLength +
138
+ colorByteLength +
139
+ scaleByteLength +
140
+ rotationByteLength +
141
+ shByteLength;
142
+ if (decompressed.byteLength - 16 !== payloadByteLength) {
143
+ throw new Error(
144
+ `SPZLoader: legacy payload has ${decompressed.byteLength - 16} bytes, expected ${payloadByteLength}.`
145
+ );
146
+ }
147
+
148
+ let offset = 16;
149
+ const positions = new Float32Array(count * 3);
150
+ const opacities = new Float32Array(count);
151
+ const colors = new Uint8Array(count * 3);
152
+ const scales = new Float32Array(count * 3);
153
+ const rotations = new Float32Array(count * 4);
154
+ const sphericalHarmonicDcs = new Float32Array(count * 3);
155
+ const sphericalHarmonics = shComponentCount
156
+ ? new Float32Array(count * shComponentCount)
157
+ : undefined;
158
+ decodeSPZPositions(
159
+ new Uint8Array(decompressed, offset, positionByteLength),
160
+ positions,
161
+ header.fractionalBits
162
+ );
163
+ offset += positionByteLength;
164
+ decodeSPZOpacities(new Uint8Array(decompressed, offset, alphaByteLength), opacities);
165
+ offset += alphaByteLength;
166
+ decodeSPZColors(
167
+ new Uint8Array(decompressed, offset, colorByteLength),
168
+ colors,
169
+ sphericalHarmonicDcs
170
+ );
171
+ offset += colorByteLength;
172
+ decodeSPZScales(new Uint8Array(decompressed, offset, scaleByteLength), scales);
173
+ offset += scaleByteLength;
174
+ if (header.version === 2) {
175
+ decodeLegacySPZRotations(new Uint8Array(decompressed, offset, rotationByteLength), rotations);
176
+ } else {
177
+ decodeSPZRotations(new Uint8Array(decompressed, offset, rotationByteLength), rotations);
178
+ }
179
+ offset += rotationByteLength;
180
+ if (sphericalHarmonics) {
181
+ decodeSPZSphericalHarmonics(
182
+ new Uint8Array(decompressed, offset, shByteLength),
183
+ sphericalHarmonics
184
+ );
185
+ }
186
+
187
+ return applySPZCoordinateSystem(
188
+ {
189
+ format: 'spz',
190
+ splatCount: count,
191
+ positions,
192
+ scales,
193
+ rotations,
194
+ colors,
195
+ sphericalHarmonicDcs,
196
+ opacities,
197
+ sphericalHarmonics,
198
+ sphericalHarmonicsComponentCount: shComponentCount || undefined,
199
+ loaderData: {...header, legacy: true}
200
+ },
201
+ options?.splats?.sourceCoordinateSystem ?? 'RUB',
202
+ options?.splats?.targetCoordinateSystem ?? options?.splats?.sourceCoordinateSystem ?? 'RUB'
203
+ );
204
+ }
205
+
206
+ /** Decodes the three signed bytes used by SPZ v2 rotations. */
207
+ function decodeLegacySPZRotations(rotationStream: Uint8Array, rotations: Float32Array): void {
208
+ for (let splatIndex = 0; splatIndex < rotations.length / 4; splatIndex++) {
209
+ const byteOffset = splatIndex * 3;
210
+ const x = signedByte(rotationStream[byteOffset]) / 127;
211
+ const y = signedByte(rotationStream[byteOffset + 1]) / 127;
212
+ const z = signedByte(rotationStream[byteOffset + 2]) / 127;
213
+ const w = Math.sqrt(Math.max(0, 1 - x * x - y * y - z * z));
214
+ const normalized = normalizeQuaternion(w, x, y, z);
215
+ rotations.set(normalized, splatIndex * 4);
216
+ }
217
+ }
218
+
219
+ /** Converts an unsigned byte containing a signed int8 value. */
220
+ function signedByte(value: number): number {
221
+ return value > 127 ? value - 256 : value;
222
+ }
223
+
224
+ /** Applies the explicitly requested SPZ coordinate conversion. */
225
+ function applySPZCoordinateSystem(
226
+ splats: GaussianSplats,
227
+ source: SPZCoordinateSystem,
228
+ target: SPZCoordinateSystem
229
+ ): GaussianSplats {
230
+ if (source === target || source === 'UNSPECIFIED' || target === 'UNSPECIFIED') {
231
+ return splats;
232
+ }
233
+ if (source === 'RUB' && target === 'LUF') {
234
+ for (let index = 0; index < splats.positions.length; index += 3) {
235
+ splats.positions[index] = -splats.positions[index];
236
+ splats.positions[index + 2] = -splats.positions[index + 2];
237
+ }
238
+ for (let index = 0; index < splats.rotations.length; index += 4) {
239
+ splats.rotations[index + 1] = -splats.rotations[index + 1];
240
+ splats.rotations[index + 3] = -splats.rotations[index + 3];
241
+ }
242
+ return splats;
243
+ }
244
+ if (source === 'LUF' && target === 'RUB') {
245
+ return applySPZCoordinateSystem(splats, 'RUB', 'LUF');
246
+ }
247
+ throw new Error(`SPZLoader: coordinate conversion ${source} -> ${target} is not supported.`);
63
248
  }
64
249
 
65
250
  /** Parses and validates the SPZ v4 plaintext header. */
@@ -0,0 +1,83 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {z} from 'zod';
6
+ import type {
7
+ RADChunkMetadataJSON,
8
+ RADChunkProperty,
9
+ RADChunkRange,
10
+ RADMetadataJSON,
11
+ RADSplatEncoding
12
+ } from './lib/parse-rad';
13
+
14
+ const nonNegativeSafeIntegerSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
15
+
16
+ /** Zod schema for Spark RAD splat quantization and shader decode ranges. */
17
+ export const RADSplatEncodingSchema = z
18
+ .object({
19
+ rgbMin: z.number().finite().optional(),
20
+ rgbMax: z.number().finite().optional(),
21
+ lnScaleMin: z.number().finite().optional(),
22
+ lnScaleMax: z.number().finite().optional(),
23
+ sh1Max: z.number().finite().optional(),
24
+ sh2Max: z.number().finite().optional(),
25
+ sh3Max: z.number().finite().optional(),
26
+ lodOpacity: z.boolean().optional()
27
+ })
28
+ .passthrough() satisfies z.ZodType<RADSplatEncoding>;
29
+
30
+ /** Zod schema for one chunk-table entry in top-level RAD metadata. */
31
+ export const RADChunkRangeSchema = z
32
+ .object({
33
+ offset: nonNegativeSafeIntegerSchema,
34
+ bytes: nonNegativeSafeIntegerSchema,
35
+ base: nonNegativeSafeIntegerSchema.optional(),
36
+ count: nonNegativeSafeIntegerSchema.optional(),
37
+ filename: z.string().optional()
38
+ })
39
+ .passthrough() satisfies z.ZodType<RADChunkRange>;
40
+
41
+ /** Zod schema for raw JSON metadata stored in a Spark RAD header. */
42
+ export const RADMetadataJSONSchema = z
43
+ .object({
44
+ version: z.literal(1),
45
+ type: z.literal('gsplat'),
46
+ count: nonNegativeSafeIntegerSchema,
47
+ maxSh: nonNegativeSafeIntegerSchema.optional(),
48
+ lodTree: z.boolean().optional(),
49
+ chunkSize: nonNegativeSafeIntegerSchema.optional(),
50
+ allChunkBytes: nonNegativeSafeIntegerSchema.optional(),
51
+ chunks: z.array(RADChunkRangeSchema),
52
+ splatEncoding: RADSplatEncodingSchema.optional(),
53
+ shCodeCount: nonNegativeSafeIntegerSchema.optional(),
54
+ comment: z.string().optional()
55
+ })
56
+ .passthrough() satisfies z.ZodType<RADMetadataJSON>;
57
+
58
+ /** Zod schema for one property-table entry in RADC metadata. */
59
+ export const RADChunkPropertySchema = z
60
+ .object({
61
+ offset: nonNegativeSafeIntegerSchema,
62
+ bytes: nonNegativeSafeIntegerSchema,
63
+ property: z.string(),
64
+ encoding: z.string(),
65
+ compression: z.string().optional(),
66
+ min: z.number().finite().optional(),
67
+ max: z.number().finite().optional()
68
+ })
69
+ .passthrough() satisfies z.ZodType<RADChunkProperty>;
70
+
71
+ /** Zod schema for raw JSON metadata stored in a Spark RADC chunk header. */
72
+ export const RADChunkMetadataJSONSchema = z
73
+ .object({
74
+ version: z.literal(1),
75
+ base: nonNegativeSafeIntegerSchema,
76
+ count: nonNegativeSafeIntegerSchema,
77
+ payloadBytes: nonNegativeSafeIntegerSchema,
78
+ maxSh: nonNegativeSafeIntegerSchema.optional(),
79
+ lodTree: z.boolean().optional(),
80
+ splatEncoding: RADSplatEncodingSchema.optional(),
81
+ properties: z.array(RADChunkPropertySchema)
82
+ })
83
+ .passthrough() satisfies z.ZodType<RADChunkMetadataJSON>;
package/src/types.ts CHANGED
@@ -9,9 +9,16 @@ export type SplatsLoaderOptions = LoaderOptions & {
9
9
  splats?: {
10
10
  /** Output shape. V1 supports loaders.gl Mesh Arrow tables. */
11
11
  shape?: 'arrow-table';
12
+ /** Coordinate frame stored in the source SPZ payload. */
13
+ sourceCoordinateSystem?: SPZCoordinateSystem;
14
+ /** Coordinate frame expected by the caller. Defaults to the source frame. */
15
+ targetCoordinateSystem?: SPZCoordinateSystem;
12
16
  };
13
17
  };
14
18
 
19
+ /** Coordinate-system labels used by the SPZ container and glTF bridge. */
20
+ export type SPZCoordinateSystem = 'RUB' | 'LUF' | 'RUF' | 'UNSPECIFIED';
21
+
15
22
  /** Linear Gaussian splat values before Arrow table construction. */
16
23
  export type GaussianSplats = {
17
24
  /** Source format identifier. */