@loaders.gl/tile-converter 5.0.0-alpha.10 → 5.0.0-alpha.12

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 (107) hide show
  1. package/README.md +211 -2
  2. package/bin/converter-v5.js +2 -0
  3. package/dist/converter.min.cjs +162 -163
  4. package/dist/i3s-server/bin/i3s-server.min.cjs +73 -73
  5. package/dist/index.cjs +1933 -2232
  6. package/dist/index.js +1925 -2235
  7. package/dist/v5/adapters.cjs +3186 -208
  8. package/dist/v5/adapters.d.ts +29 -2
  9. package/dist/v5/adapters.js +3202 -196
  10. package/dist/v5/archive-source.d.ts +36 -0
  11. package/dist/v5/browser-tileset-source.d.ts +55 -11
  12. package/dist/v5/browser.cjs +30 -0
  13. package/dist/v5/browser.d.ts +26 -2
  14. package/dist/v5/browser.js +45 -1
  15. package/dist/v5/conversion-api.d.ts +9 -0
  16. package/dist/v5/converter-cli.d.ts +1 -0
  17. package/dist/v5/converter-cli.js +1214 -0
  18. package/dist/v5/core.cjs +515 -50
  19. package/dist/v5/core.d.ts +2 -0
  20. package/dist/v5/core.js +515 -50
  21. package/dist/v5/i3s-mesh-conversion.d.ts +14 -4
  22. package/dist/v5/i3s-mesh-sink.d.ts +25 -0
  23. package/dist/v5/i3s-mesh-source-features.d.ts +13 -0
  24. package/dist/v5/i3s-mesh-source-material.d.ts +5 -0
  25. package/dist/v5/i3s-mesh-source.d.ts +30 -0
  26. package/dist/v5/i3s-selection-source.d.ts +70 -0
  27. package/dist/v5/index.cjs +20 -0
  28. package/dist/v5/index.d.ts +18 -2
  29. package/dist/v5/index.js +31 -1
  30. package/dist/v5/mesh-conversion.d.ts +29 -2
  31. package/dist/v5/mesh-features.d.ts +9 -0
  32. package/dist/v5/mesh-hierarchy.d.ts +47 -0
  33. package/dist/v5/mesh-source-features.d.ts +2 -1
  34. package/dist/v5/mesh-source-schema.d.ts +12 -0
  35. package/dist/v5/mesh-source-texture.d.ts +2 -0
  36. package/dist/v5/mesh-source.d.ts +13 -3
  37. package/dist/v5/mesh-tileset-sink.d.ts +5 -0
  38. package/dist/v5/mesh.d.ts +6 -1
  39. package/dist/v5/node-cli.d.ts +9 -0
  40. package/dist/v5/node-conversion.d.ts +32 -0
  41. package/dist/v5/node-feature-schema.d.ts +3 -0
  42. package/dist/v5/node-i3s-source.d.ts +41 -0
  43. package/dist/v5/node-source.d.ts +47 -0
  44. package/dist/v5/node.cjs +900 -0
  45. package/dist/v5/node.d.ts +6 -0
  46. package/dist/v5/node.js +877 -0
  47. package/dist/v5/point-cloud-file.d.ts +83 -0
  48. package/dist/v5/point-cloud-potree.d.ts +47 -0
  49. package/dist/v5/point-cloud-source-encoder.d.ts +4 -7
  50. package/dist/v5/point-cloud-spatial.d.ts +22 -0
  51. package/dist/v5/point-cloud-tileset-sink.d.ts +28 -0
  52. package/dist/v5/point-cloud-tileset.d.ts +44 -0
  53. package/dist/v5/point-cloud.d.ts +26 -1
  54. package/dist/v5/resource-fetcher.d.ts +18 -0
  55. package/dist/v5/resource-manifest.d.ts +10 -1
  56. package/dist/v5/selected-mesh-source.d.ts +53 -0
  57. package/dist/v5/spatial-conversion.d.ts +3 -1
  58. package/package.json +33 -25
  59. package/src/index.ts +4 -1
  60. package/src/v4/3d-tiles-converter/helpers/load-i3s.ts +2 -4
  61. package/src/v4/converter-cli.ts +8 -2
  62. package/src/v4/i3s-converter/helpers/load-3d-tiles.ts +3 -51
  63. package/src/v4/i3s-converter/helpers/node-index-document.ts +5 -2
  64. package/src/v4/i3s-converter/helpers/preprocess-3d-tiles.ts +1 -1
  65. package/src/v4/i3s-converter/json-templates/store.ts +2 -1
  66. package/src/v5/adapters.ts +69 -2
  67. package/src/v5/archive-source.ts +235 -0
  68. package/src/v5/browser-tileset-source.ts +418 -48
  69. package/src/v5/browser.ts +81 -2
  70. package/src/v5/conversion-api.ts +69 -9
  71. package/src/v5/converter-cli.ts +28 -0
  72. package/src/v5/core.ts +6 -0
  73. package/src/v5/i3s-mesh-conversion.ts +97 -19
  74. package/src/v5/i3s-mesh-sink.ts +351 -0
  75. package/src/v5/i3s-mesh-source-features.ts +279 -0
  76. package/src/v5/i3s-mesh-source-material.ts +157 -0
  77. package/src/v5/i3s-mesh-source.ts +228 -0
  78. package/src/v5/i3s-selection-source.ts +298 -0
  79. package/src/v5/index.ts +60 -2
  80. package/src/v5/mesh-conversion.ts +61 -11
  81. package/src/v5/mesh-draco.ts +47 -3
  82. package/src/v5/mesh-features.ts +319 -0
  83. package/src/v5/mesh-hierarchy.ts +648 -0
  84. package/src/v5/mesh-source-features.ts +73 -10
  85. package/src/v5/mesh-source-schema.ts +134 -0
  86. package/src/v5/mesh-source-texture.ts +47 -0
  87. package/src/v5/mesh-source.ts +99 -25
  88. package/src/v5/mesh-tileset-sink.ts +1 -1
  89. package/src/v5/mesh.ts +16 -7
  90. package/src/v5/node-cli.ts +197 -0
  91. package/src/v5/node-conversion.ts +345 -0
  92. package/src/v5/node-feature-schema.ts +155 -0
  93. package/src/v5/node-i3s-source.ts +181 -0
  94. package/src/v5/node-source.ts +328 -0
  95. package/src/v5/node.ts +16 -0
  96. package/src/v5/point-cloud-conversion.ts +9 -1
  97. package/src/v5/point-cloud-file.ts +306 -0
  98. package/src/v5/point-cloud-potree.ts +130 -0
  99. package/src/v5/point-cloud-source-encoder.ts +31 -20
  100. package/src/v5/point-cloud-spatial.ts +280 -0
  101. package/src/v5/point-cloud-tileset-sink.ts +234 -0
  102. package/src/v5/point-cloud-tileset.ts +137 -0
  103. package/src/v5/point-cloud.ts +159 -32
  104. package/src/v5/resource-fetcher.ts +113 -0
  105. package/src/v5/resource-manifest.ts +40 -5
  106. package/src/v5/selected-mesh-source.ts +384 -0
  107. package/src/v5/spatial-conversion.ts +161 -6
package/dist/v5/core.js CHANGED
@@ -27,6 +27,10 @@ async function convertTileset(options) {
27
27
  measureOutputBytes,
28
28
  maxInputResourceBytes = Number.POSITIVE_INFINITY,
29
29
  maxOutputResourceBytes = Number.POSITIVE_INFINITY,
30
+ maxInputBytes = Number.POSITIVE_INFINITY,
31
+ maxOutputBytes = Number.POSITIVE_INFINITY,
32
+ maxInputResources = Number.POSITIVE_INFINITY,
33
+ maxOutputResources = Number.POSITIVE_INFINITY,
30
34
  signal,
31
35
  onProgress
32
36
  } = options;
@@ -40,16 +44,26 @@ async function convertTileset(options) {
40
44
  onProgress?.({ phase, inputResources, outputResources, inputBytes, outputBytes });
41
45
  };
42
46
  try {
43
- if (maxInputResourceBytes !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(maxInputResourceBytes) || maxInputResourceBytes < 0)) {
44
- throw new TileConversionError(
45
- "INVALID_INPUT_RESOURCE_LIMIT",
46
- "maxInputResourceBytes must be a nonnegative safe integer or Infinity"
47
- );
47
+ for (const [name, limit, code] of [
48
+ ["maxInputResourceBytes", maxInputResourceBytes, "INVALID_INPUT_RESOURCE_LIMIT"],
49
+ ["maxOutputResourceBytes", maxOutputResourceBytes, "INVALID_OUTPUT_RESOURCE_LIMIT"],
50
+ ["maxInputBytes", maxInputBytes, "INVALID_INPUT_BYTES_LIMIT"],
51
+ ["maxOutputBytes", maxOutputBytes, "INVALID_OUTPUT_BYTES_LIMIT"],
52
+ ["maxInputResources", maxInputResources, "INVALID_INPUT_COUNT_LIMIT"],
53
+ ["maxOutputResources", maxOutputResources, "INVALID_OUTPUT_COUNT_LIMIT"]
54
+ ]) {
55
+ if (limit !== Infinity && (!Number.isSafeInteger(limit) || limit < 0)) {
56
+ throw new TileConversionError(
57
+ code,
58
+ name + " must be a nonnegative safe integer or Infinity"
59
+ );
60
+ }
48
61
  }
49
62
  reportProgress("inspect");
50
63
  const inspection = await inspectTileset(source, signal);
51
64
  for await (const inputResource of source.read(inspection, signal)) {
52
65
  throwIfAborted(signal);
66
+ checkConversionTotal(inputResources, 1, maxInputResources, "INPUT_COUNT_LIMIT_EXCEEDED");
53
67
  const inputResourceBytes = measureInputBytes(inputResource);
54
68
  if (!Number.isSafeInteger(inputResourceBytes) || inputResourceBytes < 0) {
55
69
  throw new TileConversionError(
@@ -63,23 +77,41 @@ async function convertTileset(options) {
63
77
  `Input resource is ${inputResourceBytes} bytes, exceeding the configured limit of ${maxInputResourceBytes} bytes`
64
78
  );
65
79
  }
80
+ checkConversionTotal(
81
+ inputBytes,
82
+ inputResourceBytes,
83
+ maxInputBytes,
84
+ "INPUT_BYTES_LIMIT_EXCEEDED"
85
+ );
66
86
  inputResources++;
67
87
  inputBytes += inputResourceBytes;
68
88
  reportProgress("convert");
69
89
  const convertedResources = await codec.convert(inputResource, inspection, signal);
70
90
  for await (const outputResource of convertedResources) {
71
91
  throwIfAborted(signal);
92
+ checkConversionTotal(outputResources, 1, maxOutputResources, "OUTPUT_COUNT_LIMIT_EXCEEDED");
72
93
  reportProgress("validate");
73
94
  const resourceDiagnostics = await codec.validateOutput?.(outputResource, signal) || [];
74
95
  diagnostics.push(...resourceDiagnostics);
75
96
  const outputResourceBytes = measureOutputBytes(outputResource);
76
- largestOutputResourceBytes = Math.max(largestOutputResourceBytes, outputResourceBytes);
97
+ if (!Number.isSafeInteger(outputResourceBytes) || outputResourceBytes < 0) {
98
+ throw new TileConversionError(
99
+ "INVALID_OUTPUT_RESOURCE_SIZE",
100
+ "measureOutputBytes must return a nonnegative safe integer"
101
+ );
102
+ }
77
103
  if (outputResourceBytes > maxOutputResourceBytes) {
78
104
  throw new TileConversionError(
79
105
  "OUTPUT_RESOURCE_TOO_LARGE",
80
106
  `Output resource is ${outputResourceBytes} bytes, exceeding the configured limit of ${maxOutputResourceBytes} bytes`
81
107
  );
82
108
  }
109
+ checkConversionTotal(
110
+ outputBytes,
111
+ outputResourceBytes,
112
+ maxOutputBytes,
113
+ "OUTPUT_BYTES_LIMIT_EXCEEDED"
114
+ );
83
115
  if (resourceDiagnostics.some((diagnostic) => diagnostic.severity === "error")) {
84
116
  throw new TileConversionError(
85
117
  "OUTPUT_VALIDATION_FAILED",
@@ -90,6 +122,7 @@ async function convertTileset(options) {
90
122
  throwIfAborted(signal);
91
123
  await sink.write(outputResource, signal);
92
124
  throwIfAborted(signal);
125
+ largestOutputResourceBytes = Math.max(largestOutputResourceBytes, outputResourceBytes);
93
126
  outputResources++;
94
127
  outputBytes += outputResourceBytes;
95
128
  reportProgress("convert");
@@ -134,6 +167,20 @@ function throwIfAborted(signal) {
134
167
  throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
135
168
  }
136
169
  }
170
+ function checkConversionTotal(current, increment, limit, code) {
171
+ if (increment > limit - current) {
172
+ throw new TileConversionError(
173
+ code,
174
+ `Conversion exceeds cumulative limit ${limit}: ${current} processed, next resource adds ${increment}`
175
+ );
176
+ }
177
+ if (increment > Number.MAX_SAFE_INTEGER - current) {
178
+ throw new TileConversionError(
179
+ "RESOURCE_TOTAL_OVERFLOW",
180
+ "Conversion totals must remain safe integers"
181
+ );
182
+ }
183
+ }
137
184
 
138
185
  // src/v5/browser-source.ts
139
186
  function createBrowserTileConversionSource(options) {
@@ -278,6 +325,12 @@ function createTiles3DConversionSpatialContext(discovered, options = {}) {
278
325
  if (spatialReference.status === "unresolved") {
279
326
  throw createSpatialConversionError(spatialReference);
280
327
  }
328
+ if (spatialReference.status !== "native" && spatialReference.coordinateEpoch !== void 0) {
329
+ throw new TileConversionError(
330
+ "SPATIAL_EPOCH_UNSUPPORTED",
331
+ "Epoch-tagged coordinates require an epoch-aware datum operation before conversion"
332
+ );
333
+ }
281
334
  const transformer = spatialReference.status === "native" ? void 0 : createSpatialTransformer(
282
335
  spatialReference,
283
336
  () => new Tiles3DSpatialTransformer(spatialReference, options)
@@ -285,8 +338,8 @@ function createTiles3DConversionSpatialContext(discovered, options = {}) {
285
338
  const outputSpatialReference = transformer ? markTilesetSpatialReferenceTransformed(spatialReference) : spatialReference;
286
339
  return {
287
340
  spatialReference: outputSpatialReference,
288
- transformPositions: (positions) => transformer ? transformer.transformPositions(positions) : Float64Array.from(positions),
289
- transformNormals: (normals, positions) => transformer ? transformer.transformNormals(normals, positions) : Float32Array.from(normals),
341
+ transformPositions: (positions) => transformer ? transformer.transformPositions(positions) : copyNativePositions(positions),
342
+ transformNormals: (normals, positions) => transformer ? transformer.transformNormals(normals, positions) : copyNativeNormals(normals, positions),
290
343
  transformBoundingVolume: (volume, sourceTransform) => transformer ? transformer.transformBoundingVolume(volume, sourceTransform) : cloneBoundingVolume(volume)
291
344
  };
292
345
  }
@@ -295,16 +348,57 @@ function createI3SConversionSpatialContext(discovered, options = {}) {
295
348
  if (spatialReference.status === "unresolved") {
296
349
  throw createSpatialConversionError(spatialReference);
297
350
  }
351
+ if (spatialReference.status !== "native" && spatialReference.coordinateEpoch !== void 0) {
352
+ throw new TileConversionError(
353
+ "SPATIAL_EPOCH_UNSUPPORTED",
354
+ "Epoch-tagged coordinates require an epoch-aware datum operation before conversion"
355
+ );
356
+ }
298
357
  const transformer = spatialReference.status === "native" ? void 0 : createSpatialTransformer(
299
358
  spatialReference,
300
359
  () => new I3SSpatialTransformer(spatialReference, options)
301
360
  );
302
361
  const outputSpatialReference = transformer ? transformer.spatialReference : spatialReference;
362
+ let nativeNormalTransformer;
363
+ const transformNativeNormals = (normals, positions, normalReferenceFrame) => {
364
+ validateNormalReferenceFrame(normalReferenceFrame);
365
+ const targetNormals = copyNativeNormals(normals, positions);
366
+ if (!spatialReference.sourceCrs || ["local", "cartesian"].includes(spatialReference.coordinateFrame)) {
367
+ return targetNormals;
368
+ }
369
+ nativeNormalTransformer ||= createSpatialTransformer(
370
+ spatialReference,
371
+ () => new I3SSpatialTransformer(
372
+ {
373
+ ...spatialReference,
374
+ status: "transformable",
375
+ targetCrs: spatialReference.sourceCrs
376
+ },
377
+ options
378
+ )
379
+ );
380
+ return nativeNormalTransformer.transformNormals(targetNormals, positions, normalReferenceFrame);
381
+ };
303
382
  return {
304
383
  spatialReference: outputSpatialReference,
305
384
  transformPositions: (positions, sourceOrigin) => transformer ? transformer.transformPositions(positions, sourceOrigin) : createNativeI3SPositions(positions, sourceOrigin, spatialReference),
385
+ transformGeometryAsync: async (positions, normals, normalReferenceFrame) => {
386
+ if (transformer)
387
+ return transformer.transformGeometryAsync(positions, normals, normalReferenceFrame);
388
+ const targetPositions = Float64Array.from(positions);
389
+ if (targetPositions.length % 3 || targetPositions.some((value) => !Number.isFinite(value)) || normals && (normals.length !== positions.length || Array.from(normals).some((value) => !Number.isFinite(value))) || normalReferenceFrame !== void 0 && !["earth-centered", "vertex-reference-frame"].includes(normalReferenceFrame)) {
390
+ throw new TileConversionError(
391
+ "SPATIAL_INPUT_INVALID",
392
+ "Conversion geometry requires finite matching xyz arrays and a supported I3S vector frame"
393
+ );
394
+ }
395
+ return {
396
+ positions: targetPositions,
397
+ normals: normals ? transformNativeNormals(normals, positions, normalReferenceFrame) : void 0
398
+ };
399
+ },
306
400
  transformPositionsAsync: (positions, sourceOrigin) => transformer ? transformer.transformPositionsAsync(positions, sourceOrigin) : Promise.resolve(createNativeI3SPositions(positions, sourceOrigin, spatialReference)),
307
- transformNormals: (normals, positions, normalReferenceFrame) => transformer ? transformer.transformNormals(normals, positions, normalReferenceFrame) : Float32Array.from(normals),
401
+ transformNormals: (normals, positions, normalReferenceFrame) => transformer ? transformer.transformNormals(normals, positions, normalReferenceFrame) : transformNativeNormals(normals, positions, normalReferenceFrame),
308
402
  transformBoundingVolume: (bounds) => transformer ? transformer.transformBoundingVolume(bounds) : cloneI3SBounds(bounds),
309
403
  transformBoundingVolumeAsync: (bounds) => transformer ? transformer.transformBoundingVolumeAsync(bounds) : Promise.resolve(cloneI3SBounds(bounds))
310
404
  };
@@ -388,21 +482,30 @@ function cloneI3SBounds(bounds) {
388
482
  };
389
483
  }
390
484
  function createNativeI3SPositions(sourcePositions, sourceOrigin, spatialReference) {
391
- if (sourcePositions.length % 3 !== 0 || sourceOrigin.length < 3) {
392
- throw new Error("I3S positions and origin must contain complete xyz coordinates");
485
+ const sourcePositions64 = copyNativePositions(sourcePositions);
486
+ if (sourceOrigin.length !== 3 || Array.from(sourceOrigin).some((value) => !Number.isFinite(value))) {
487
+ throw new TileConversionError(
488
+ "SPATIAL_INPUT_INVALID",
489
+ "I3S origins require three finite coordinates"
490
+ );
393
491
  }
394
492
  const origin = [
395
493
  Number(sourceOrigin[0]),
396
494
  Number(sourceOrigin[1]),
397
495
  Number(sourceOrigin[2])
398
496
  ];
399
- const sourcePositions64 = Float64Array.from(sourcePositions);
400
497
  const positions = new Float32Array(sourcePositions.length);
401
498
  for (let index = 0; index < sourcePositions.length; index += 3) {
402
499
  positions[index] = Number(sourcePositions[index]) - origin[0];
403
500
  positions[index + 1] = Number(sourcePositions[index + 1]) - origin[1];
404
501
  positions[index + 2] = Number(sourcePositions[index + 2]) - origin[2];
405
502
  }
503
+ if (positions.some((value) => !Number.isFinite(value))) {
504
+ throw new TileConversionError(
505
+ "SPATIAL_INPUT_INVALID",
506
+ "Native I3S offsets exceed float32 range"
507
+ );
508
+ }
406
509
  const isGeographic = spatialReference.coordinateFrame === "geographic";
407
510
  const modelMatrix = isGeographic ? new Matrix4() : new Matrix4().translate(origin);
408
511
  return {
@@ -414,6 +517,61 @@ function createNativeI3SPositions(sourcePositions, sourceOrigin, spatialReferenc
414
517
  modelMatrix
415
518
  };
416
519
  }
520
+ function copyNativePositions(positions) {
521
+ validateNativePositions(positions);
522
+ return Float64Array.from(positions);
523
+ }
524
+ function validateNativePositions(positions) {
525
+ if (positions.length % 3) {
526
+ throw new TileConversionError(
527
+ "SPATIAL_INPUT_INVALID",
528
+ "Positions require complete finite xyz coordinates"
529
+ );
530
+ }
531
+ for (let index = 0; index < positions.length; index++) {
532
+ if (!Number.isFinite(positions[index])) {
533
+ throw new TileConversionError(
534
+ "SPATIAL_INPUT_INVALID",
535
+ "Positions require complete finite xyz coordinates"
536
+ );
537
+ }
538
+ }
539
+ }
540
+ function copyNativeNormals(normals, positions) {
541
+ validateNativePositions(positions);
542
+ if (normals.length !== positions.length) {
543
+ throw new TileConversionError(
544
+ "SPATIAL_INPUT_INVALID",
545
+ "Normals require finite xyz values matching positions"
546
+ );
547
+ }
548
+ const output = new Float32Array(normals.length);
549
+ for (let index = 0; index < normals.length; index += 3) {
550
+ const scale = Math.max(
551
+ Math.abs(normals[index]),
552
+ Math.abs(normals[index + 1]),
553
+ Math.abs(normals[index + 2])
554
+ );
555
+ if (!Number.isFinite(scale)) {
556
+ throw new TileConversionError(
557
+ "SPATIAL_INPUT_INVALID",
558
+ "Normals require finite xyz values matching positions"
559
+ );
560
+ }
561
+ const length = scale ? Math.hypot(normals[index] / scale, normals[index + 1] / scale, normals[index + 2] / scale) : 0;
562
+ for (let axis = 0; axis < 3; axis++)
563
+ output[index + axis] = length ? normals[index + axis] / scale / length : 0;
564
+ }
565
+ return output;
566
+ }
567
+ function validateNormalReferenceFrame(frame) {
568
+ if (frame !== void 0 && !["earth-centered", "vertex-reference-frame"].includes(frame)) {
569
+ throw new TileConversionError(
570
+ "SPATIAL_INPUT_INVALID",
571
+ "Unsupported I3S normal reference frame"
572
+ );
573
+ }
574
+ }
417
575
 
418
576
  // src/v5/tileset-source.ts
419
577
  import { traverseTilesetContents } from "@loaders.gl/tiles";
@@ -438,10 +596,17 @@ function createTilesetConversionSource(tileset, options = {}) {
438
596
  // src/v5/resource-manifest.ts
439
597
  var TILE_RESOURCE_MANIFEST_VERSION = 1;
440
598
  async function createManifestBackedTileConversionSink(options) {
599
+ const maxResources = options.maxResources ?? Infinity;
600
+ if (maxResources !== Infinity && (!Number.isSafeInteger(maxResources) || maxResources < 0)) {
601
+ throw new TileConversionError(
602
+ "INVALID_MANIFEST_RESOURCE_LIMIT",
603
+ "maxResources must be a nonnegative safe integer or Infinity"
604
+ );
605
+ }
441
606
  const priorManifest = await options.manifestStore.load();
442
607
  const resources = /* @__PURE__ */ new Map();
443
608
  if (priorManifest) {
444
- validateManifest(priorManifest);
609
+ validateManifest(priorManifest, maxResources);
445
610
  for (const entry of priorManifest.resources) {
446
611
  resources.set(entry.resourceId, entry);
447
612
  }
@@ -467,6 +632,7 @@ async function createManifestBackedTileConversionSink(options) {
467
632
  if (writeFailed) {
468
633
  throw writeFailure;
469
634
  }
635
+ signal?.throwIfAborted();
470
636
  const entry = createManifestEntry(options, resource);
471
637
  const priorEntry = resources.get(entry.resourceId);
472
638
  if (priorEntry) {
@@ -476,7 +642,14 @@ async function createManifestBackedTileConversionSink(options) {
476
642
  `Resource "${entry.resourceId}" changed since the saved manifest`
477
643
  );
478
644
  }
479
- return;
645
+ const verified = options.verifyResource ? await options.verifyResource({ ...priorEntry }, signal) : true;
646
+ signal?.throwIfAborted();
647
+ if (verified === true) return;
648
+ } else if (resources.size >= maxResources) {
649
+ throw new TileConversionError(
650
+ "MANIFEST_RESOURCE_LIMIT_EXCEEDED",
651
+ "Resource checkpoint exceeds maxResources"
652
+ );
480
653
  }
481
654
  await options.sink.write(resource, signal);
482
655
  resources.set(entry.resourceId, entry);
@@ -525,11 +698,17 @@ function createManifestEntry(options, resource) {
525
698
  }
526
699
  return { resourceId, byteLength, fingerprint };
527
700
  }
528
- function validateManifest(manifest) {
701
+ function validateManifest(manifest, maxResources) {
529
702
  if (typeof manifest !== "object" || manifest === null || manifest.version !== TILE_RESOURCE_MANIFEST_VERSION || typeof manifest.complete !== "boolean" || !Array.isArray(manifest.resources)) {
530
703
  throw new TileConversionError(
531
704
  "UNSUPPORTED_RESOURCE_MANIFEST",
532
- `Unsupported or malformed resource manifest version: ${String(manifest.version)}`
705
+ `Unsupported or malformed resource manifest version: ${String(manifest?.version)}`
706
+ );
707
+ }
708
+ if (manifest.resources.length > maxResources) {
709
+ throw new TileConversionError(
710
+ "MANIFEST_RESOURCE_LIMIT_EXCEEDED",
711
+ "Loaded manifest exceeds maxResources"
533
712
  );
534
713
  }
535
714
  const resourceIds = /* @__PURE__ */ new Set();
@@ -621,50 +800,67 @@ function measureBlobPart(part) {
621
800
 
622
801
  // src/v5/browser-tileset-source.ts
623
802
  function createBrowserTilesetConversionSource(options) {
624
- if (!Number.isSafeInteger(options.maxInputBytes) || options.maxInputBytes < 1 || !Number.isSafeInteger(options.maxInputResources) || options.maxInputResources < 1) {
803
+ const maxTilesetDocuments = options.maxTilesetDocuments ?? 1;
804
+ if (options.inspectNestedTilesets !== void 0 && typeof options.inspectNestedTilesets !== "boolean" || !Number.isSafeInteger(maxTilesetDocuments) || maxTilesetDocuments < 1 || !Number.isSafeInteger(options.maxInputBytes) || options.maxInputBytes < 1 || !Number.isSafeInteger(options.maxInputResources) || options.maxInputResources < 1) {
625
805
  throw new TileConversionError(
626
806
  "INVALID_BROWSER_SOURCE_OPTIONS",
627
- "Browser tileset sources require positive byte and resource limits"
807
+ "Browser tileset sources require positive byte, declaration and document limits"
628
808
  );
629
809
  }
630
810
  const fetcher = options.fetcher ?? fetch;
631
811
  return {
632
812
  async inspect(signal) {
633
813
  throwIfAborted4(signal);
634
- const rootUrl = new URL(options.input).href;
635
- const response = await fetcher(rootUrl, { signal });
814
+ const requestedUrl = new URL(options.input).href;
815
+ const response = await fetcher(requestedUrl, { signal });
816
+ const rootUrl = response.url || requestedUrl;
636
817
  if (!response.ok) {
818
+ await response.body?.cancel();
637
819
  throw new TileConversionError(
638
820
  "BROWSER_SOURCE_FETCH_FAILED",
639
821
  `Could not fetch browser tileset (${response.status} ${response.statusText})`
640
822
  );
641
823
  }
642
824
  const data = await readResponseBytes2(response, options.maxInputBytes, signal);
643
- let tileset;
644
- try {
645
- tileset = JSON.parse(new TextDecoder().decode(data));
646
- } catch {
647
- throw new TileConversionError("INVALID_BROWSER_TILESET", "Tileset input is not valid JSON");
648
- }
649
- if (!isRecord(tileset) || !["1.0", "1.1"].includes(tileset.asset?.version || "") || !isRecord(tileset.root)) {
650
- throw new TileConversionError(
651
- "UNSUPPORTED_BROWSER_TILESET",
652
- "Browser tileset traversal supports explicit 3D Tiles 1.0 and 1.1 documents"
653
- );
654
- }
825
+ const tileset = parseTilesetDocument(data);
655
826
  const resources = collectResources(tileset.root, options.maxInputResources);
656
- return { rootUrl, tileset, resources, tilesetBytes: data.byteLength };
827
+ const inspection = { rootUrl, tileset, resources, tilesetBytes: data.byteLength };
828
+ return options.inspectNestedTilesets ? expandInspection(inspection, options, maxTilesetDocuments, fetcher, signal) : inspection;
657
829
  },
658
830
  async *read(inspection, signal) {
659
831
  let inputBytes = inspection.tilesetBytes;
660
- for (const descriptor of inspection.resources) {
832
+ let declarations = inspection.resources.length;
833
+ let documents = 1;
834
+ const stack = [
835
+ {
836
+ resources: inspection.resources,
837
+ index: 0,
838
+ tileset: inspection.tileset,
839
+ url: inspection.rootUrl,
840
+ ancestors: /* @__PURE__ */ new Set([getDocumentKey(inspection.rootUrl), getDocumentKey(options.input)])
841
+ }
842
+ ];
843
+ while (stack.length) {
661
844
  throwIfAborted4(signal);
662
- const resourceUrl = resolveResourceUrl(descriptor.uri, inspection.rootUrl);
845
+ const frame = stack[stack.length - 1];
846
+ if (frame.index === frame.resources.length) {
847
+ stack.pop();
848
+ continue;
849
+ }
850
+ const descriptor = frame.resources[frame.index++];
851
+ const resourceUrl = resolveResourceUrl(descriptor.uri, descriptor.tilesetUrl ?? frame.url);
852
+ if (frame.ancestors.has(getDocumentKey(resourceUrl))) {
853
+ throw new TileConversionError(
854
+ "BROWSER_TILESET_CYCLE",
855
+ "External tileset references an active ancestor document"
856
+ );
857
+ }
663
858
  const response = await fetcher(resourceUrl, { signal });
664
859
  if (!response.ok) {
860
+ await response.body?.cancel();
665
861
  throw new TileConversionError(
666
862
  "BROWSER_SOURCE_FETCH_FAILED",
667
- `Could not fetch browser tile content (${response.status} ${response.statusText})`,
863
+ "Could not fetch browser tile content (" + response.status + " " + response.statusText + ")",
668
864
  [
669
865
  {
670
866
  code: "BROWSER_SOURCE_FETCH_FAILED",
@@ -675,28 +871,280 @@ function createBrowserTilesetConversionSource(options) {
675
871
  ]
676
872
  );
677
873
  }
678
- const remainingBytes = options.maxInputBytes - inputBytes;
679
- const data = await readResponseBytes2(response, remainingBytes, signal);
874
+ const documentUrl = response.url || resourceUrl;
875
+ if (frame.ancestors.has(getDocumentKey(documentUrl))) {
876
+ await response.body?.cancel();
877
+ throw new TileConversionError(
878
+ "BROWSER_TILESET_CYCLE",
879
+ "Redirected content references an active ancestor document"
880
+ );
881
+ }
882
+ const data = await readResponseBytes2(response, options.maxInputBytes - inputBytes, signal);
680
883
  inputBytes += data.byteLength;
681
884
  const contentType = response.headers.get("content-type") ?? void 0;
682
885
  if (isJsonContent(data, contentType)) {
683
- throw new TileConversionError(
684
- "UNSUPPORTED_BROWSER_TILESET_CONTENT",
685
- `Nested tilesets and JSON tile content are not supported: ${descriptor.uri}`
686
- );
886
+ if (inspection.documents)
887
+ throw new TileConversionError(
888
+ "UNSUPPORTED_BROWSER_TILESET_CONTENT",
889
+ "Expanded inspection selections must be binary content; declare external metadata with a .json URI"
890
+ );
891
+ if (maxTilesetDocuments === 1) {
892
+ throw new TileConversionError(
893
+ "UNSUPPORTED_BROWSER_TILESET_CONTENT",
894
+ "Nested tilesets and JSON tile content require an explicit document budget"
895
+ );
896
+ }
897
+ if (documents >= maxTilesetDocuments) {
898
+ throw new TileConversionError(
899
+ "INPUT_TILESET_COUNT_EXCEEDED",
900
+ "Browser tileset exceeds its document limit"
901
+ );
902
+ }
903
+ let tile = frame.tileset.root;
904
+ for (const childIndex of descriptor.tilePath) tile = getTileChild(tile, childIndex);
905
+ if (tile.children?.length) {
906
+ throw new TileConversionError(
907
+ "INVALID_BROWSER_TILESET",
908
+ "External tileset references cannot also have child tiles"
909
+ );
910
+ }
911
+ const tileset = parseTilesetDocument(data);
912
+ const resources = collectResources(
913
+ tileset.root,
914
+ options.maxInputResources - declarations,
915
+ descriptor.transform
916
+ ).map((resource) => ({
917
+ ...resource,
918
+ resourceId: descriptor.resourceId + "/" + resource.resourceId,
919
+ tilesetUrl: documentUrl
920
+ }));
921
+ declarations += resources.length;
922
+ documents++;
923
+ stack.push({
924
+ resources,
925
+ index: 0,
926
+ tileset,
927
+ url: documentUrl,
928
+ ancestors: /* @__PURE__ */ new Set([
929
+ ...frame.ancestors,
930
+ getDocumentKey(resourceUrl),
931
+ getDocumentKey(documentUrl)
932
+ ])
933
+ });
934
+ continue;
687
935
  }
688
- yield { ...descriptor, data, ...contentType ? { contentType } : {} };
936
+ yield { ...descriptor, data, contentUrl: documentUrl, ...contentType ? { contentType } : {} };
689
937
  }
690
938
  }
691
939
  };
692
940
  }
693
- function collectResources(root, maxInputResources) {
941
+ function selectBrowserTilesetContent(inspection, descriptor) {
942
+ const placement = inspection.documents?.[descriptor.documentIndex ?? 0];
943
+ if (inspection.documents && (!Number.isSafeInteger(descriptor.documentIndex) || !placement))
944
+ throw new TileConversionError(
945
+ "INVALID_BROWSER_SELECTION",
946
+ "Content descriptor has no inspected document placement"
947
+ );
948
+ const document = structuredClone(placement?.tileset ?? inspection.tileset);
949
+ const originalRoot = document.root;
950
+ let transform = [...placement?.parentTransform ?? identityMatrix()];
951
+ let maximum = placement?.parentGeometricError ?? 0;
952
+ let tile = originalRoot;
953
+ for (let depth = 0; ; depth++) {
954
+ transform = multiplyMatrices(transform, normalizeMatrix(tile.transform));
955
+ if (typeof tile.geometricError !== "number" || !Number.isFinite(tile.geometricError) || tile.geometricError < 0)
956
+ throw new TileConversionError(
957
+ "MESH_GEOMETRIC_ERROR_INVALID",
958
+ "Every selected ancestor must declare a finite nonnegative geometricError"
959
+ );
960
+ maximum = Math.max(maximum, tile.geometricError * getTransformScale(transform));
961
+ if (depth === descriptor.tilePath.length) break;
962
+ const child = getTileChild(tile, descriptor.tilePath[depth]);
963
+ delete tile.content;
964
+ delete tile.contents;
965
+ tile.children = [child];
966
+ tile = child;
967
+ }
968
+ if (placement && placement.parentGeometricError === void 0 && descriptor.documentIndex !== 0)
969
+ throw new TileConversionError(
970
+ "MESH_GEOMETRIC_ERROR_INVALID",
971
+ "External ancestors must declare geometricError"
972
+ );
973
+ const isLeaf = !tile.children?.length;
974
+ const contentIndex = Number(descriptor.resourceId.match(/content-(\d+)$/)?.[1]);
975
+ const content = tile.contents?.[contentIndex] ?? tile.content;
976
+ if (!content || (content.uri ?? content.url) !== descriptor.uri)
977
+ throw new TileConversionError(
978
+ "INVALID_BROWSER_SELECTION",
979
+ "Content descriptor does not match its inspected document"
980
+ );
981
+ tile.content = content;
982
+ delete tile.contents;
983
+ delete tile.children;
984
+ if (placement)
985
+ originalRoot.transform = multiplyMatrices(
986
+ [...placement.parentTransform],
987
+ normalizeMatrix(originalRoot.transform)
988
+ );
989
+ if (!Number.isFinite(maximum))
990
+ throw new TileConversionError(
991
+ "MESH_GEOMETRIC_ERROR_INVALID",
992
+ "Scaled geometricError must remain finite"
993
+ );
994
+ return {
995
+ tileset: document,
996
+ contentUrl: resolveResourceUrl(descriptor.uri, placement?.url ?? inspection.rootUrl),
997
+ geometricError: maximum,
998
+ isLeaf
999
+ };
1000
+ }
1001
+ function getTileChild(tile, childIndex) {
1002
+ if (typeof childIndex !== "number" || !Number.isSafeInteger(childIndex) || childIndex < 0 || !Array.isArray(tile.children) || childIndex >= tile.children.length)
1003
+ throw new TileConversionError(
1004
+ "INVALID_BROWSER_SELECTION",
1005
+ "Tile paths require in-bounds nonnegative integer child indexes"
1006
+ );
1007
+ const child = tile.children.at(childIndex);
1008
+ if (!isRecord(child))
1009
+ throw new TileConversionError(
1010
+ "INVALID_BROWSER_SELECTION",
1011
+ "Selected child tile must be an object"
1012
+ );
1013
+ return child;
1014
+ }
1015
+ async function expandInspection(inspection, options, maxTilesetDocuments, fetcher, signal) {
1016
+ const documents = [
1017
+ {
1018
+ url: inspection.rootUrl,
1019
+ tileset: inspection.tileset,
1020
+ parentTransform: identityMatrix(),
1021
+ parentGeometricError: 0
1022
+ }
1023
+ ];
694
1024
  const resources = [];
1025
+ let bytes = inspection.tilesetBytes;
1026
+ let declarations = inspection.resources.length;
695
1027
  const stack = [
696
- { tile: root, path: [], transform: identityMatrix() }
1028
+ {
1029
+ resources: inspection.resources,
1030
+ index: 0,
1031
+ documentIndex: 0,
1032
+ ancestors: /* @__PURE__ */ new Set([getDocumentKey(inspection.rootUrl), getDocumentKey(options.input)])
1033
+ }
1034
+ ];
1035
+ while (stack.length) {
1036
+ throwIfAborted4(signal);
1037
+ const frame = stack[stack.length - 1];
1038
+ if (frame.index === frame.resources.length) {
1039
+ stack.pop();
1040
+ continue;
1041
+ }
1042
+ const descriptor = frame.resources[frame.index++];
1043
+ const placement = documents[frame.documentIndex];
1044
+ const resourceUrl = resolveResourceUrl(descriptor.uri, placement.url);
1045
+ if (!new URL(resourceUrl).pathname.toLowerCase().endsWith(".json")) {
1046
+ resources.push({ ...descriptor, documentIndex: frame.documentIndex });
1047
+ continue;
1048
+ }
1049
+ if (frame.ancestors.has(getDocumentKey(resourceUrl)))
1050
+ throw new TileConversionError(
1051
+ "BROWSER_TILESET_CYCLE",
1052
+ "External tileset references an active ancestor document"
1053
+ );
1054
+ if (documents.length >= maxTilesetDocuments)
1055
+ throw new TileConversionError(
1056
+ "INPUT_TILESET_COUNT_EXCEEDED",
1057
+ "Browser tileset exceeds its document limit"
1058
+ );
1059
+ let tile = placement.tileset.root;
1060
+ for (const childIndex of descriptor.tilePath) tile = tile.children[childIndex];
1061
+ if (tile.children?.length || tile.contents?.length)
1062
+ throw new TileConversionError(
1063
+ "INVALID_BROWSER_TILESET",
1064
+ "Inspected external tilesets require one content and no child tiles"
1065
+ );
1066
+ const response = await fetcher(resourceUrl, { signal });
1067
+ const url = response.url || resourceUrl;
1068
+ if (!response.ok || frame.ancestors.has(getDocumentKey(url))) {
1069
+ await response.body?.cancel();
1070
+ throw new TileConversionError(
1071
+ response.ok ? "BROWSER_TILESET_CYCLE" : "BROWSER_SOURCE_FETCH_FAILED",
1072
+ "Could not inspect external tileset"
1073
+ );
1074
+ }
1075
+ const data = await readResponseBytes2(response, options.maxInputBytes - bytes, signal);
1076
+ bytes += data.byteLength;
1077
+ const tileset = parseTilesetDocument(data);
1078
+ const prefix = descriptor.resourceId + "/";
1079
+ const nestedResources = collectResources(
1080
+ tileset.root,
1081
+ options.maxInputResources - declarations,
1082
+ descriptor.transform
1083
+ ).map((resource) => ({ ...resource, resourceId: prefix + resource.resourceId, tilesetUrl: url }));
1084
+ declarations += nestedResources.length;
1085
+ let parentGeometricError;
1086
+ try {
1087
+ parentGeometricError = selectBrowserTilesetContent(
1088
+ { ...inspection, documents },
1089
+ { ...descriptor, documentIndex: frame.documentIndex }
1090
+ ).geometricError;
1091
+ } catch (error) {
1092
+ if (!(error instanceof TileConversionError) || error.code !== "MESH_GEOMETRIC_ERROR_INVALID")
1093
+ throw error;
1094
+ }
1095
+ const documentIndex = documents.length;
1096
+ documents.push({
1097
+ url,
1098
+ tileset,
1099
+ parentResourceId: descriptor.resourceId,
1100
+ parentTransform: descriptor.transform,
1101
+ parentGeometricError
1102
+ });
1103
+ stack.push({
1104
+ resources: nestedResources,
1105
+ index: 0,
1106
+ documentIndex,
1107
+ ancestors: /* @__PURE__ */ new Set([...frame.ancestors, getDocumentKey(resourceUrl), getDocumentKey(url)])
1108
+ });
1109
+ }
1110
+ return { ...inspection, resources, documents, tilesetBytes: bytes };
1111
+ }
1112
+ function getTransformScale(transform) {
1113
+ const columns = [0, 1, 2].map(
1114
+ (column) => [0, 1, 2].reduce((total, row) => total + Math.abs(transform[column * 4 + row]), 0)
1115
+ );
1116
+ const rows = [0, 1, 2].map(
1117
+ (row) => [0, 1, 2].reduce((total, column) => total + Math.abs(transform[column * 4 + row]), 0)
1118
+ );
1119
+ return Math.sqrt(Math.max(...columns)) * Math.sqrt(Math.max(...rows));
1120
+ }
1121
+ function parseTilesetDocument(data) {
1122
+ let tileset;
1123
+ try {
1124
+ tileset = JSON.parse(new TextDecoder().decode(data));
1125
+ } catch {
1126
+ throw new TileConversionError("INVALID_BROWSER_TILESET", "Tileset input is not valid JSON");
1127
+ }
1128
+ if (!isRecord(tileset) || !["1.0", "1.1"].includes(tileset.asset?.version || "") || !isRecord(tileset.root)) {
1129
+ throw new TileConversionError(
1130
+ "UNSUPPORTED_BROWSER_TILESET",
1131
+ "Browser tileset traversal supports explicit 3D Tiles 1.0 and 1.1 documents"
1132
+ );
1133
+ }
1134
+ return tileset;
1135
+ }
1136
+ function getDocumentKey(url) {
1137
+ const documentUrl = new URL(url);
1138
+ documentUrl.hash = "";
1139
+ return documentUrl.href;
1140
+ }
1141
+ function collectResources(root, maxInputResources, parentTransform = identityMatrix()) {
1142
+ const resources = [];
1143
+ const stack = [
1144
+ { tile: root, path: [], transform: [...parentTransform] }
697
1145
  ];
698
1146
  while (stack.length > 0) {
699
- const { tile, path, transform: parentTransform } = stack.pop();
1147
+ const { tile, path, transform: parentTransform2 } = stack.pop();
700
1148
  if (!isRecord(tile)) {
701
1149
  throw new TileConversionError("INVALID_BROWSER_TILESET", "Tile entries must be objects");
702
1150
  }
@@ -712,7 +1160,19 @@ function collectResources(root, maxInputResources) {
712
1160
  if (tile.children !== void 0 && !Array.isArray(tile.children)) {
713
1161
  throw new TileConversionError("INVALID_BROWSER_TILESET", "Tile children must be an array");
714
1162
  }
715
- const transform = multiplyMatrices(parentTransform, normalizeMatrix(tile.transform));
1163
+ if (tile.content !== void 0 && tile.contents !== void 0) {
1164
+ throw new TileConversionError(
1165
+ "INVALID_BROWSER_TILESET",
1166
+ "Tile content and contents are mutually exclusive"
1167
+ );
1168
+ }
1169
+ const transform = multiplyMatrices(parentTransform2, normalizeMatrix(tile.transform));
1170
+ if (transform.some((value) => !Number.isFinite(value))) {
1171
+ throw new TileConversionError(
1172
+ "INVALID_BROWSER_TILESET",
1173
+ "Composed tile transforms must remain finite"
1174
+ );
1175
+ }
716
1176
  const contents = tile.contents ?? (tile.content ? [tile.content] : []);
717
1177
  for (let contentIndex = 0; contentIndex < contents.length; contentIndex++) {
718
1178
  const content = contents[contentIndex];
@@ -761,15 +1221,15 @@ async function readResponseBytes2(response, maximumBytes, signal) {
761
1221
  "Browser inputs must provide a readable response stream"
762
1222
  );
763
1223
  }
764
- const data = new Uint8Array(maximumBytes);
765
1224
  let byteLength = 0;
766
1225
  try {
1226
+ const data = new Uint8Array(maximumBytes);
767
1227
  while (true) {
768
1228
  throwIfAborted4(signal);
769
1229
  const { done, value } = await reader.read();
1230
+ throwIfAborted4(signal);
770
1231
  if (done) break;
771
1232
  if (value.byteLength > maximumBytes - byteLength) {
772
- await reader.cancel();
773
1233
  throw new TileConversionError(
774
1234
  "INPUT_RESOURCE_TOO_LARGE",
775
1235
  `Browser tileset input exceeds the configured ${maximumBytes} byte remaining limit`
@@ -778,10 +1238,14 @@ async function readResponseBytes2(response, maximumBytes, signal) {
778
1238
  data.set(value, byteLength);
779
1239
  byteLength += value.byteLength;
780
1240
  }
1241
+ return data.slice(0, byteLength);
1242
+ } catch (error) {
1243
+ await reader.cancel().catch(() => {
1244
+ });
1245
+ throw error;
781
1246
  } finally {
782
1247
  reader.releaseLock();
783
1248
  }
784
- return data.subarray(0, byteLength);
785
1249
  }
786
1250
  function isJsonContent(data, contentType) {
787
1251
  if (contentType?.includes("json")) return true;
@@ -840,6 +1304,7 @@ export {
840
1304
  createTiles3DConversionSpatialContext,
841
1305
  createTilesetConversionSource,
842
1306
  inspectTileset,
1307
+ selectBrowserTilesetContent,
843
1308
  traversePointCloudSource,
844
1309
  validateTileset
845
1310
  };