@loaders.gl/tile-converter 5.0.0-alpha.11 → 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 (83) hide show
  1. package/README.md +178 -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 +1749 -2152
  6. package/dist/index.js +1748 -2162
  7. package/dist/v5/adapters.cjs +1550 -128
  8. package/dist/v5/adapters.d.ts +16 -0
  9. package/dist/v5/adapters.js +1558 -117
  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 +18 -0
  13. package/dist/v5/browser.d.ts +14 -0
  14. package/dist/v5/browser.js +26 -0
  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 +471 -60
  19. package/dist/v5/core.d.ts +2 -0
  20. package/dist/v5/core.js +471 -60
  21. package/dist/v5/i3s-mesh-conversion.d.ts +4 -0
  22. package/dist/v5/i3s-selection-source.d.ts +70 -0
  23. package/dist/v5/index.cjs +10 -0
  24. package/dist/v5/index.d.ts +8 -0
  25. package/dist/v5/index.js +14 -0
  26. package/dist/v5/mesh-conversion.d.ts +18 -0
  27. package/dist/v5/mesh-hierarchy.d.ts +47 -0
  28. package/dist/v5/mesh-source-features.d.ts +2 -1
  29. package/dist/v5/mesh-source-schema.d.ts +12 -0
  30. package/dist/v5/mesh-source.d.ts +5 -2
  31. package/dist/v5/mesh-tileset-sink.d.ts +5 -0
  32. package/dist/v5/node-cli.d.ts +9 -0
  33. package/dist/v5/node-conversion.d.ts +32 -0
  34. package/dist/v5/node-feature-schema.d.ts +3 -0
  35. package/dist/v5/node-i3s-source.d.ts +41 -0
  36. package/dist/v5/node-source.d.ts +47 -0
  37. package/dist/v5/node.cjs +900 -0
  38. package/dist/v5/node.d.ts +6 -0
  39. package/dist/v5/node.js +877 -0
  40. package/dist/v5/point-cloud-file.d.ts +83 -0
  41. package/dist/v5/point-cloud-potree.d.ts +47 -0
  42. package/dist/v5/point-cloud-tileset.d.ts +44 -0
  43. package/dist/v5/resource-manifest.d.ts +10 -1
  44. package/dist/v5/selected-mesh-source.d.ts +53 -0
  45. package/package.json +33 -25
  46. package/src/index.ts +4 -1
  47. package/src/v4/3d-tiles-converter/helpers/load-i3s.ts +2 -4
  48. package/src/v4/converter-cli.ts +8 -2
  49. package/src/v4/i3s-converter/helpers/load-3d-tiles.ts +3 -51
  50. package/src/v4/i3s-converter/helpers/node-index-document.ts +5 -2
  51. package/src/v4/i3s-converter/helpers/preprocess-3d-tiles.ts +1 -1
  52. package/src/v4/i3s-converter/json-templates/store.ts +2 -1
  53. package/src/v5/adapters.ts +48 -0
  54. package/src/v5/archive-source.ts +235 -0
  55. package/src/v5/browser-tileset-source.ts +418 -48
  56. package/src/v5/browser.ts +45 -0
  57. package/src/v5/conversion-api.ts +69 -9
  58. package/src/v5/converter-cli.ts +28 -0
  59. package/src/v5/core.ts +6 -0
  60. package/src/v5/i3s-mesh-conversion.ts +40 -2
  61. package/src/v5/i3s-mesh-sink.ts +1 -1
  62. package/src/v5/i3s-mesh-source.ts +1 -0
  63. package/src/v5/i3s-selection-source.ts +298 -0
  64. package/src/v5/index.ts +26 -0
  65. package/src/v5/mesh-conversion.ts +20 -0
  66. package/src/v5/mesh-hierarchy.ts +648 -0
  67. package/src/v5/mesh-source-features.ts +39 -7
  68. package/src/v5/mesh-source-schema.ts +134 -0
  69. package/src/v5/mesh-source.ts +16 -4
  70. package/src/v5/mesh-tileset-sink.ts +1 -1
  71. package/src/v5/node-cli.ts +197 -0
  72. package/src/v5/node-conversion.ts +345 -0
  73. package/src/v5/node-feature-schema.ts +155 -0
  74. package/src/v5/node-i3s-source.ts +181 -0
  75. package/src/v5/node-source.ts +328 -0
  76. package/src/v5/node.ts +16 -0
  77. package/src/v5/point-cloud-file.ts +306 -0
  78. package/src/v5/point-cloud-potree.ts +130 -0
  79. package/src/v5/point-cloud-tileset.ts +137 -0
  80. package/src/v5/resource-fetcher.ts +4 -1
  81. package/src/v5/resource-manifest.ts +40 -5
  82. package/src/v5/selected-mesh-source.ts +384 -0
  83. package/src/v5/spatial-conversion.ts +88 -17
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) {
@@ -291,8 +338,8 @@ function createTiles3DConversionSpatialContext(discovered, options = {}) {
291
338
  const outputSpatialReference = transformer ? markTilesetSpatialReferenceTransformed(spatialReference) : spatialReference;
292
339
  return {
293
340
  spatialReference: outputSpatialReference,
294
- transformPositions: (positions) => transformer ? transformer.transformPositions(positions) : Float64Array.from(positions),
295
- 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),
296
343
  transformBoundingVolume: (volume, sourceTransform) => transformer ? transformer.transformBoundingVolume(volume, sourceTransform) : cloneBoundingVolume(volume)
297
344
  };
298
345
  }
@@ -314,16 +361,9 @@ function createI3SConversionSpatialContext(discovered, options = {}) {
314
361
  const outputSpatialReference = transformer ? transformer.spatialReference : spatialReference;
315
362
  let nativeNormalTransformer;
316
363
  const transformNativeNormals = (normals, positions, normalReferenceFrame) => {
364
+ validateNormalReferenceFrame(normalReferenceFrame);
365
+ const targetNormals = copyNativeNormals(normals, positions);
317
366
  if (!spatialReference.sourceCrs || ["local", "cartesian"].includes(spatialReference.coordinateFrame)) {
318
- const targetNormals = Float32Array.from(normals);
319
- for (let index = 0; index < normals.length; index += 3) {
320
- const length = Math.hypot(normals[index], normals[index + 1], normals[index + 2]);
321
- if (length) {
322
- for (let axis = 0; axis < 3; axis++) {
323
- targetNormals[index + axis] = normals[index + axis] / length;
324
- }
325
- }
326
- }
327
367
  return targetNormals;
328
368
  }
329
369
  nativeNormalTransformer ||= createSpatialTransformer(
@@ -337,7 +377,7 @@ function createI3SConversionSpatialContext(discovered, options = {}) {
337
377
  options
338
378
  )
339
379
  );
340
- return nativeNormalTransformer.transformNormals(normals, positions, normalReferenceFrame);
380
+ return nativeNormalTransformer.transformNormals(targetNormals, positions, normalReferenceFrame);
341
381
  };
342
382
  return {
343
383
  spatialReference: outputSpatialReference,
@@ -358,7 +398,7 @@ function createI3SConversionSpatialContext(discovered, options = {}) {
358
398
  };
359
399
  },
360
400
  transformPositionsAsync: (positions, sourceOrigin) => transformer ? transformer.transformPositionsAsync(positions, sourceOrigin) : Promise.resolve(createNativeI3SPositions(positions, sourceOrigin, spatialReference)),
361
- 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),
362
402
  transformBoundingVolume: (bounds) => transformer ? transformer.transformBoundingVolume(bounds) : cloneI3SBounds(bounds),
363
403
  transformBoundingVolumeAsync: (bounds) => transformer ? transformer.transformBoundingVolumeAsync(bounds) : Promise.resolve(cloneI3SBounds(bounds))
364
404
  };
@@ -442,21 +482,30 @@ function cloneI3SBounds(bounds) {
442
482
  };
443
483
  }
444
484
  function createNativeI3SPositions(sourcePositions, sourceOrigin, spatialReference) {
445
- if (sourcePositions.length % 3 !== 0 || sourceOrigin.length < 3) {
446
- 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
+ );
447
491
  }
448
492
  const origin = [
449
493
  Number(sourceOrigin[0]),
450
494
  Number(sourceOrigin[1]),
451
495
  Number(sourceOrigin[2])
452
496
  ];
453
- const sourcePositions64 = Float64Array.from(sourcePositions);
454
497
  const positions = new Float32Array(sourcePositions.length);
455
498
  for (let index = 0; index < sourcePositions.length; index += 3) {
456
499
  positions[index] = Number(sourcePositions[index]) - origin[0];
457
500
  positions[index + 1] = Number(sourcePositions[index + 1]) - origin[1];
458
501
  positions[index + 2] = Number(sourcePositions[index + 2]) - origin[2];
459
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
+ }
460
509
  const isGeographic = spatialReference.coordinateFrame === "geographic";
461
510
  const modelMatrix = isGeographic ? new Matrix4() : new Matrix4().translate(origin);
462
511
  return {
@@ -468,6 +517,61 @@ function createNativeI3SPositions(sourcePositions, sourceOrigin, spatialReferenc
468
517
  modelMatrix
469
518
  };
470
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
+ }
471
575
 
472
576
  // src/v5/tileset-source.ts
473
577
  import { traverseTilesetContents } from "@loaders.gl/tiles";
@@ -492,10 +596,17 @@ function createTilesetConversionSource(tileset, options = {}) {
492
596
  // src/v5/resource-manifest.ts
493
597
  var TILE_RESOURCE_MANIFEST_VERSION = 1;
494
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
+ }
495
606
  const priorManifest = await options.manifestStore.load();
496
607
  const resources = /* @__PURE__ */ new Map();
497
608
  if (priorManifest) {
498
- validateManifest(priorManifest);
609
+ validateManifest(priorManifest, maxResources);
499
610
  for (const entry of priorManifest.resources) {
500
611
  resources.set(entry.resourceId, entry);
501
612
  }
@@ -521,6 +632,7 @@ async function createManifestBackedTileConversionSink(options) {
521
632
  if (writeFailed) {
522
633
  throw writeFailure;
523
634
  }
635
+ signal?.throwIfAborted();
524
636
  const entry = createManifestEntry(options, resource);
525
637
  const priorEntry = resources.get(entry.resourceId);
526
638
  if (priorEntry) {
@@ -530,7 +642,14 @@ async function createManifestBackedTileConversionSink(options) {
530
642
  `Resource "${entry.resourceId}" changed since the saved manifest`
531
643
  );
532
644
  }
533
- 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
+ );
534
653
  }
535
654
  await options.sink.write(resource, signal);
536
655
  resources.set(entry.resourceId, entry);
@@ -579,11 +698,17 @@ function createManifestEntry(options, resource) {
579
698
  }
580
699
  return { resourceId, byteLength, fingerprint };
581
700
  }
582
- function validateManifest(manifest) {
701
+ function validateManifest(manifest, maxResources) {
583
702
  if (typeof manifest !== "object" || manifest === null || manifest.version !== TILE_RESOURCE_MANIFEST_VERSION || typeof manifest.complete !== "boolean" || !Array.isArray(manifest.resources)) {
584
703
  throw new TileConversionError(
585
704
  "UNSUPPORTED_RESOURCE_MANIFEST",
586
- `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"
587
712
  );
588
713
  }
589
714
  const resourceIds = /* @__PURE__ */ new Set();
@@ -675,50 +800,67 @@ function measureBlobPart(part) {
675
800
 
676
801
  // src/v5/browser-tileset-source.ts
677
802
  function createBrowserTilesetConversionSource(options) {
678
- 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) {
679
805
  throw new TileConversionError(
680
806
  "INVALID_BROWSER_SOURCE_OPTIONS",
681
- "Browser tileset sources require positive byte and resource limits"
807
+ "Browser tileset sources require positive byte, declaration and document limits"
682
808
  );
683
809
  }
684
810
  const fetcher = options.fetcher ?? fetch;
685
811
  return {
686
812
  async inspect(signal) {
687
813
  throwIfAborted4(signal);
688
- const rootUrl = new URL(options.input).href;
689
- 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;
690
817
  if (!response.ok) {
818
+ await response.body?.cancel();
691
819
  throw new TileConversionError(
692
820
  "BROWSER_SOURCE_FETCH_FAILED",
693
821
  `Could not fetch browser tileset (${response.status} ${response.statusText})`
694
822
  );
695
823
  }
696
824
  const data = await readResponseBytes2(response, options.maxInputBytes, signal);
697
- let tileset;
698
- try {
699
- tileset = JSON.parse(new TextDecoder().decode(data));
700
- } catch {
701
- throw new TileConversionError("INVALID_BROWSER_TILESET", "Tileset input is not valid JSON");
702
- }
703
- if (!isRecord(tileset) || !["1.0", "1.1"].includes(tileset.asset?.version || "") || !isRecord(tileset.root)) {
704
- throw new TileConversionError(
705
- "UNSUPPORTED_BROWSER_TILESET",
706
- "Browser tileset traversal supports explicit 3D Tiles 1.0 and 1.1 documents"
707
- );
708
- }
825
+ const tileset = parseTilesetDocument(data);
709
826
  const resources = collectResources(tileset.root, options.maxInputResources);
710
- 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;
711
829
  },
712
830
  async *read(inspection, signal) {
713
831
  let inputBytes = inspection.tilesetBytes;
714
- 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) {
715
844
  throwIfAborted4(signal);
716
- 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
+ }
717
858
  const response = await fetcher(resourceUrl, { signal });
718
859
  if (!response.ok) {
860
+ await response.body?.cancel();
719
861
  throw new TileConversionError(
720
862
  "BROWSER_SOURCE_FETCH_FAILED",
721
- `Could not fetch browser tile content (${response.status} ${response.statusText})`,
863
+ "Could not fetch browser tile content (" + response.status + " " + response.statusText + ")",
722
864
  [
723
865
  {
724
866
  code: "BROWSER_SOURCE_FETCH_FAILED",
@@ -729,28 +871,280 @@ function createBrowserTilesetConversionSource(options) {
729
871
  ]
730
872
  );
731
873
  }
732
- const remainingBytes = options.maxInputBytes - inputBytes;
733
- 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);
734
883
  inputBytes += data.byteLength;
735
884
  const contentType = response.headers.get("content-type") ?? void 0;
736
885
  if (isJsonContent(data, contentType)) {
737
- throw new TileConversionError(
738
- "UNSUPPORTED_BROWSER_TILESET_CONTENT",
739
- `Nested tilesets and JSON tile content are not supported: ${descriptor.uri}`
740
- );
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;
741
935
  }
742
- yield { ...descriptor, data, ...contentType ? { contentType } : {} };
936
+ yield { ...descriptor, data, contentUrl: documentUrl, ...contentType ? { contentType } : {} };
743
937
  }
744
938
  }
745
939
  };
746
940
  }
747
- 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
+ ];
748
1024
  const resources = [];
1025
+ let bytes = inspection.tilesetBytes;
1026
+ let declarations = inspection.resources.length;
749
1027
  const stack = [
750
- { 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] }
751
1145
  ];
752
1146
  while (stack.length > 0) {
753
- const { tile, path, transform: parentTransform } = stack.pop();
1147
+ const { tile, path, transform: parentTransform2 } = stack.pop();
754
1148
  if (!isRecord(tile)) {
755
1149
  throw new TileConversionError("INVALID_BROWSER_TILESET", "Tile entries must be objects");
756
1150
  }
@@ -766,7 +1160,19 @@ function collectResources(root, maxInputResources) {
766
1160
  if (tile.children !== void 0 && !Array.isArray(tile.children)) {
767
1161
  throw new TileConversionError("INVALID_BROWSER_TILESET", "Tile children must be an array");
768
1162
  }
769
- 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
+ }
770
1176
  const contents = tile.contents ?? (tile.content ? [tile.content] : []);
771
1177
  for (let contentIndex = 0; contentIndex < contents.length; contentIndex++) {
772
1178
  const content = contents[contentIndex];
@@ -815,15 +1221,15 @@ async function readResponseBytes2(response, maximumBytes, signal) {
815
1221
  "Browser inputs must provide a readable response stream"
816
1222
  );
817
1223
  }
818
- const data = new Uint8Array(maximumBytes);
819
1224
  let byteLength = 0;
820
1225
  try {
1226
+ const data = new Uint8Array(maximumBytes);
821
1227
  while (true) {
822
1228
  throwIfAborted4(signal);
823
1229
  const { done, value } = await reader.read();
1230
+ throwIfAborted4(signal);
824
1231
  if (done) break;
825
1232
  if (value.byteLength > maximumBytes - byteLength) {
826
- await reader.cancel();
827
1233
  throw new TileConversionError(
828
1234
  "INPUT_RESOURCE_TOO_LARGE",
829
1235
  `Browser tileset input exceeds the configured ${maximumBytes} byte remaining limit`
@@ -832,10 +1238,14 @@ async function readResponseBytes2(response, maximumBytes, signal) {
832
1238
  data.set(value, byteLength);
833
1239
  byteLength += value.byteLength;
834
1240
  }
1241
+ return data.slice(0, byteLength);
1242
+ } catch (error) {
1243
+ await reader.cancel().catch(() => {
1244
+ });
1245
+ throw error;
835
1246
  } finally {
836
1247
  reader.releaseLock();
837
1248
  }
838
- return data.subarray(0, byteLength);
839
1249
  }
840
1250
  function isJsonContent(data, contentType) {
841
1251
  if (contentType?.includes("json")) return true;
@@ -894,6 +1304,7 @@ export {
894
1304
  createTiles3DConversionSpatialContext,
895
1305
  createTilesetConversionSource,
896
1306
  inspectTileset,
1307
+ selectBrowserTilesetContent,
897
1308
  traversePointCloudSource,
898
1309
  validateTileset
899
1310
  };