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

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.
@@ -2,6 +2,8 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  // Copyright (c) vis.gl contributors
4
4
 
5
+ import {RADChunkMetadataJSONSchema, RADMetadataJSONSchema} from '../rad-zod-schema';
6
+
5
7
  const RAD_MAGIC = 0x30444152;
6
8
  const RAD_CHUNK_MAGIC = 0x43444152;
7
9
  const RAD_HEADER_BYTE_LENGTH = 8;
@@ -26,6 +28,8 @@ export type RADSplatEncoding = {
26
28
  sh3Max?: number;
27
29
  /** Whether opacity is encoded for LoD blending. */
28
30
  lodOpacity?: boolean;
31
+ /** Additional splat-encoding properties are preserved verbatim. */
32
+ [key: string]: unknown;
29
33
  };
30
34
 
31
35
  /** One RAD chunk location in the top-level RAD chunk table. */
@@ -40,14 +44,16 @@ export type RADChunkRange = {
40
44
  count?: number;
41
45
  /** Optional sidecar `.radc` filename relative to the RAD file URL. */
42
46
  filename?: string;
47
+ /** Additional chunk-range properties are preserved verbatim. */
48
+ [key: string]: unknown;
43
49
  };
44
50
 
45
- /** Parsed Spark RAD top-level metadata with loader-derived byte offsets. */
46
- export type RADMetadata = {
51
+ /** Handwritten type for the JSON metadata stored in a Spark RAD header. */
52
+ export type RADMetadataJSON = {
47
53
  /** RAD container version. Version 1 is currently supported. */
48
- version: number;
54
+ version: 1;
49
55
  /** RAD payload type. Spark currently writes `gsplat`. */
50
- type: string;
56
+ type: 'gsplat';
51
57
  /** Total splat count represented by the RAD source. */
52
58
  count: number;
53
59
  /** Maximum spherical harmonics degree present in the chunks. */
@@ -66,6 +72,12 @@ export type RADMetadata = {
66
72
  shCodeCount?: number;
67
73
  /** Optional RAD writer comment. */
68
74
  comment?: string;
75
+ /** Additional RAD metadata properties are preserved verbatim. */
76
+ [key: string]: unknown;
77
+ };
78
+
79
+ /** Parsed Spark RAD top-level metadata with loader-derived byte offsets. */
80
+ export type RADMetadata = RADMetadataJSON & {
69
81
  /** Byte length of the JSON metadata block. */
70
82
  headerJsonByteLength: number;
71
83
  /** Byte offset where inline RAD chunks begin. */
@@ -124,12 +136,14 @@ export type RADChunkProperty = {
124
136
  min?: number;
125
137
  /** Optional decode maximum for quantized properties. */
126
138
  max?: number;
139
+ /** Additional chunk-property metadata is preserved verbatim. */
140
+ [key: string]: unknown;
127
141
  };
128
142
 
129
- /** Parsed Spark RADC chunk metadata with loader-derived byte offsets. */
130
- export type RADChunkMetadata = {
143
+ /** Handwritten type for the JSON metadata stored in a Spark RADC chunk header. */
144
+ export type RADChunkMetadataJSON = {
131
145
  /** RAD chunk version. Version 1 is currently supported. */
132
- version: number;
146
+ version: 1;
133
147
  /** First global splat index represented by this chunk. */
134
148
  base: number;
135
149
  /** Number of splats represented by this chunk. */
@@ -144,6 +158,12 @@ export type RADChunkMetadata = {
144
158
  splatEncoding?: RADSplatEncoding;
145
159
  /** Property table for the chunk payload. */
146
160
  properties: RADChunkProperty[];
161
+ /** Additional RADC metadata properties are preserved verbatim. */
162
+ [key: string]: unknown;
163
+ };
164
+
165
+ /** Parsed Spark RADC chunk metadata with loader-derived byte offsets. */
166
+ export type RADChunkMetadata = RADChunkMetadataJSON & {
147
167
  /** Byte length of the chunk JSON metadata block. */
148
168
  headerJsonByteLength: number;
149
169
  /** Byte offset where the chunk payload begins. */
@@ -191,9 +211,11 @@ export function tryParseRADHeader(data: ArrayBuffer | ArrayBufferView): RADMetad
191
211
  return null;
192
212
  }
193
213
 
194
- const rawMetadata = parseJSONRecord(
195
- bytes.subarray(RAD_HEADER_BYTE_LENGTH, headerJsonEnd),
196
- 'RADLoader: failed to parse RAD metadata JSON.'
214
+ const rawMetadata = RADMetadataJSONSchema.parse(
215
+ parseJSON(
216
+ bytes.subarray(RAD_HEADER_BYTE_LENGTH, headerJsonEnd),
217
+ 'RADLoader: failed to parse RAD metadata JSON.'
218
+ )
197
219
  );
198
220
  return normalizeRADMetadata(rawMetadata, headerJsonByteLength);
199
221
  }
@@ -219,9 +241,11 @@ export function parseRADChunkHeader(data: ArrayBuffer | ArrayBufferView): RADChu
219
241
  throw new Error('RADLoader: RADC chunk must contain a complete metadata header.');
220
242
  }
221
243
 
222
- const rawMetadata = parseJSONRecord(
223
- bytes.subarray(RAD_HEADER_BYTE_LENGTH, headerJsonEnd),
224
- 'RADLoader: failed to parse RADC metadata JSON.'
244
+ const rawMetadata = RADChunkMetadataJSONSchema.parse(
245
+ parseJSON(
246
+ bytes.subarray(RAD_HEADER_BYTE_LENGTH, headerJsonEnd),
247
+ 'RADLoader: failed to parse RADC metadata JSON.'
248
+ )
225
249
  );
226
250
  const payloadBytes = readSafeUint64(
227
251
  dataView,
@@ -242,188 +266,43 @@ export function roundUpToEight(byteLength: number): number {
242
266
  }
243
267
 
244
268
  function normalizeRADMetadata(
245
- rawMetadata: Record<string, unknown>,
269
+ rawMetadata: RADMetadataJSON,
246
270
  headerJsonByteLength: number
247
271
  ): RADMetadata {
248
- const version = requireSafeInteger(rawMetadata.version, 'RAD version');
249
- if (version !== 1) {
250
- throw new Error(`RADLoader: version ${version} is not supported.`);
251
- }
252
-
253
- const type = requireString(rawMetadata.type, 'RAD type');
254
- if (type !== 'gsplat') {
255
- throw new Error(`RADLoader: RAD type ${type} is not supported.`);
256
- }
257
-
258
- const chunks = requireArray(rawMetadata.chunks, 'RAD chunks').map((chunk, chunkIndex) =>
259
- normalizeRADChunkRange(chunk, chunkIndex)
260
- );
261
- const metadata: RADMetadata = {
262
- version,
263
- type,
264
- count: requireSafeInteger(rawMetadata.count, 'RAD count'),
265
- maxSh: optionalSafeInteger(rawMetadata.maxSh, 'RAD maxSh'),
266
- lodTree: optionalBoolean(rawMetadata.lodTree, 'RAD lodTree'),
267
- chunkSize: optionalSafeInteger(rawMetadata.chunkSize, 'RAD chunkSize'),
268
- allChunkBytes: optionalSafeInteger(rawMetadata.allChunkBytes, 'RAD allChunkBytes'),
269
- chunks,
270
- splatEncoding: optionalSplatEncoding(rawMetadata.splatEncoding, 'RAD splatEncoding'),
271
- shCodeCount: optionalSafeInteger(rawMetadata.shCodeCount, 'RAD shCodeCount'),
272
- comment: optionalString(rawMetadata.comment, 'RAD comment'),
272
+ return {
273
+ ...rawMetadata,
273
274
  headerJsonByteLength,
274
275
  chunksByteOffset: RAD_HEADER_BYTE_LENGTH + roundUpToEight(headerJsonByteLength)
275
276
  };
276
-
277
- return metadata;
278
- }
279
-
280
- function normalizeRADChunkRange(rawChunk: unknown, chunkIndex: number): RADChunkRange {
281
- const chunk = requireRecord(rawChunk, `RAD chunk ${chunkIndex}`);
282
- return {
283
- offset: requireSafeInteger(chunk.offset, `RAD chunk ${chunkIndex} offset`),
284
- bytes: requireSafeInteger(chunk.bytes, `RAD chunk ${chunkIndex} bytes`),
285
- base: optionalSafeInteger(chunk.base, `RAD chunk ${chunkIndex} base`),
286
- count: optionalSafeInteger(chunk.count, `RAD chunk ${chunkIndex} count`),
287
- filename: optionalString(chunk.filename, `RAD chunk ${chunkIndex} filename`)
288
- };
289
277
  }
290
278
 
291
279
  function normalizeRADChunkMetadata(
292
- rawMetadata: Record<string, unknown>,
280
+ rawMetadata: RADChunkMetadataJSON,
293
281
  headerJsonByteLength: number,
294
282
  payloadByteOffset: number,
295
283
  payloadBytes: number
296
284
  ): RADChunkMetadata {
297
- const version = requireSafeInteger(rawMetadata.version, 'RADC version');
298
- if (version !== 1) {
299
- throw new Error(`RADLoader: RADC version ${version} is not supported.`);
300
- }
301
-
302
- const metadataPayloadBytes = requireSafeInteger(rawMetadata.payloadBytes, 'RADC payloadBytes');
303
- if (metadataPayloadBytes !== payloadBytes) {
285
+ if (rawMetadata.payloadBytes !== payloadBytes) {
304
286
  throw new Error('RADLoader: RADC metadata payload byte length does not match binary header.');
305
287
  }
306
288
 
307
289
  return {
308
- version,
309
- base: requireSafeInteger(rawMetadata.base, 'RADC base'),
310
- count: requireSafeInteger(rawMetadata.count, 'RADC count'),
290
+ ...rawMetadata,
311
291
  payloadBytes,
312
- maxSh: optionalSafeInteger(rawMetadata.maxSh, 'RADC maxSh'),
313
- lodTree: optionalBoolean(rawMetadata.lodTree, 'RADC lodTree'),
314
- splatEncoding: optionalSplatEncoding(rawMetadata.splatEncoding, 'RADC splatEncoding'),
315
- properties: requireArray(rawMetadata.properties, 'RADC properties').map((property, index) =>
316
- normalizeRADChunkProperty(property, index)
317
- ),
318
292
  headerJsonByteLength,
319
293
  payloadByteOffset,
320
294
  chunkByteLength: payloadByteOffset + payloadBytes
321
295
  };
322
296
  }
323
297
 
324
- function normalizeRADChunkProperty(rawProperty: unknown, propertyIndex: number): RADChunkProperty {
325
- const property = requireRecord(rawProperty, `RADC property ${propertyIndex}`);
326
- return {
327
- offset: requireSafeInteger(property.offset, `RADC property ${propertyIndex} offset`),
328
- bytes: requireSafeInteger(property.bytes, `RADC property ${propertyIndex} bytes`),
329
- property: requireString(property.property, `RADC property ${propertyIndex} name`),
330
- encoding: requireString(property.encoding, `RADC property ${propertyIndex} encoding`),
331
- compression: optionalString(property.compression, `RADC property ${propertyIndex} compression`),
332
- min: optionalFiniteNumber(property.min, `RADC property ${propertyIndex} min`),
333
- max: optionalFiniteNumber(property.max, `RADC property ${propertyIndex} max`)
334
- };
335
- }
336
-
337
- function optionalSplatEncoding(value: unknown, fieldName: string): RADSplatEncoding | undefined {
338
- if (value === undefined) {
339
- return undefined;
340
- }
341
- const encoding = requireRecord(value, fieldName);
342
- return {
343
- rgbMin: optionalFiniteNumber(encoding.rgbMin, `${fieldName}.rgbMin`),
344
- rgbMax: optionalFiniteNumber(encoding.rgbMax, `${fieldName}.rgbMax`),
345
- lnScaleMin: optionalFiniteNumber(encoding.lnScaleMin, `${fieldName}.lnScaleMin`),
346
- lnScaleMax: optionalFiniteNumber(encoding.lnScaleMax, `${fieldName}.lnScaleMax`),
347
- sh1Max: optionalFiniteNumber(encoding.sh1Max, `${fieldName}.sh1Max`),
348
- sh2Max: optionalFiniteNumber(encoding.sh2Max, `${fieldName}.sh2Max`),
349
- sh3Max: optionalFiniteNumber(encoding.sh3Max, `${fieldName}.sh3Max`),
350
- lodOpacity: optionalBoolean(encoding.lodOpacity, `${fieldName}.lodOpacity`)
351
- };
352
- }
353
-
354
- function parseJSONRecord(bytes: Uint8Array, message: string): Record<string, unknown> {
298
+ function parseJSON(bytes: Uint8Array, message: string): unknown {
355
299
  try {
356
- return requireRecord(JSON.parse(TEXT_DECODER.decode(bytes)), message);
357
- } catch (error) {
358
- if (error instanceof Error && error.message.startsWith('RADLoader:')) {
359
- throw error;
360
- }
300
+ return JSON.parse(TEXT_DECODER.decode(bytes));
301
+ } catch {
361
302
  throw new Error(message);
362
303
  }
363
304
  }
364
305
 
365
- function requireRecord(value: unknown, fieldName: string): Record<string, unknown> {
366
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
367
- throw new Error(`RADLoader: ${fieldName} must be an object.`);
368
- }
369
- return value as Record<string, unknown>;
370
- }
371
-
372
- function requireArray(value: unknown, fieldName: string): unknown[] {
373
- if (!Array.isArray(value)) {
374
- throw new Error(`RADLoader: ${fieldName} must be an array.`);
375
- }
376
- return value;
377
- }
378
-
379
- function requireString(value: unknown, fieldName: string): string {
380
- if (typeof value !== 'string') {
381
- throw new Error(`RADLoader: ${fieldName} must be a string.`);
382
- }
383
- return value;
384
- }
385
-
386
- function optionalString(value: unknown, fieldName: string): string | undefined {
387
- if (value === undefined) {
388
- return undefined;
389
- }
390
- return requireString(value, fieldName);
391
- }
392
-
393
- function optionalBoolean(value: unknown, fieldName: string): boolean | undefined {
394
- if (value === undefined) {
395
- return undefined;
396
- }
397
- if (typeof value !== 'boolean') {
398
- throw new Error(`RADLoader: ${fieldName} must be a boolean.`);
399
- }
400
- return value;
401
- }
402
-
403
- function requireSafeInteger(value: unknown, fieldName: string): number {
404
- if (!Number.isSafeInteger(value) || Number(value) < 0) {
405
- throw new Error(`RADLoader: ${fieldName} must be a non-negative safe integer.`);
406
- }
407
- return Number(value);
408
- }
409
-
410
- function optionalSafeInteger(value: unknown, fieldName: string): number | undefined {
411
- if (value === undefined) {
412
- return undefined;
413
- }
414
- return requireSafeInteger(value, fieldName);
415
- }
416
-
417
- function optionalFiniteNumber(value: unknown, fieldName: string): number | undefined {
418
- if (value === undefined) {
419
- return undefined;
420
- }
421
- if (typeof value !== 'number' || !Number.isFinite(value)) {
422
- throw new Error(`RADLoader: ${fieldName} must be a finite number.`);
423
- }
424
- return value;
425
- }
426
-
427
306
  function readSafeUint64(dataView: DataView, byteOffset: number, fieldName: string): number {
428
307
  const value = dataView.getBigUint64(byteOffset, true);
429
308
  if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  // Copyright (c) vis.gl contributors
4
4
 
5
- import {ZstdCompression} from '@loaders.gl/compression';
5
+ import {ZstdDecompressor} from '@loaders.gl/compression';
6
6
  import type {MeshArrowTable} from '@loaders.gl/schema';
7
7
  import type {GaussianSplats, SplatsLoaderOptions} from '../types';
8
8
  import {makeGaussianSplatsArrowTable} from './splats-arrow-table';
@@ -51,10 +51,10 @@ export async function parseSPZToGaussianSplats(
51
51
  const compressedStreams = streamInfos.map(streamInfo =>
52
52
  data.slice(streamInfo.compressedOffset, streamInfo.compressedOffset + streamInfo.compressedSize)
53
53
  );
54
- const compression = new ZstdCompression({modules: options?.modules});
54
+ const decompressor = new ZstdDecompressor({modules: options?.modules});
55
55
  const streams = await Promise.all(
56
56
  compressedStreams.map((compressedStream, streamIndex) =>
57
- compression.decompress(compressedStream, streamInfos[streamIndex].uncompressedSize)
57
+ decompressor.decompress(compressedStream, streamInfos[streamIndex].uncompressedSize)
58
58
  )
59
59
  );
60
60
 
@@ -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>;