@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
@@ -7,6 +7,8 @@ import type {TileConversionSource} from './conversion-api.js';
7
7
 
8
8
  type TilesetContent = {uri?: string; url?: string};
9
9
  type TilesetTile = {
10
+ /** Source metric error, scaled conservatively during selected mesh extraction. */
11
+ geometricError?: number;
10
12
  content?: TilesetContent;
11
13
  contents?: TilesetContent[];
12
14
  children?: TilesetTile[];
@@ -16,28 +18,48 @@ type TilesetTile = {
16
18
  };
17
19
  type TilesetDocument = {asset?: {version?: string}; root?: TilesetTile};
18
20
 
19
- /** Input options for bounded traversal of one explicit 3D Tiles tileset. */
21
+ /** Input options for bounded traversal of explicit 3D Tiles documents. */
20
22
  export interface BrowserTilesetConversionSourceOptions {
21
23
  /** URL of the root tileset JSON document. */
22
24
  readonly input: string;
23
- /** Maximum bytes retained across the tileset JSON and all content resources. */
25
+ /** Maximum aggregate response bytes across root/nested JSON and content resources. */
24
26
  readonly maxInputBytes: number;
25
- /** Maximum number of content resources to visit. */
27
+ /** Maximum content declarations across all documents, including external JSON references. */
26
28
  readonly maxInputResources: number;
29
+ /** Maximum documents read, including the root and repeated placements; defaults to 1 (no nesting). */
30
+ readonly maxTilesetDocuments?: number;
31
+ /** Inspect declared .json external tilesets without fetching mesh content; defaults to false. */
32
+ readonly inspectNestedTilesets?: boolean;
27
33
  /** Fetch implementation, defaulting to the browser global. */
28
34
  readonly fetcher?: typeof fetch;
29
35
  }
30
36
 
31
37
  /** Inspection result for an explicit 3D Tiles source. */
32
38
  export interface BrowserTilesetConversionInspection {
33
- /** Absolute URL of the root tileset document. */
39
+ /** Final response URL of the root document, falling back to the requested URL for virtual transports. */
34
40
  readonly rootUrl: string;
35
41
  /** Parsed root document used for deterministic content traversal. */
36
42
  readonly tileset: TilesetDocument;
37
- /** Ordered content descriptors. */
43
+ /** Ordered root declarations, or binary candidates from an expanded metadata inspection. */
38
44
  readonly resources: readonly BrowserTilesetResourceDescriptor[];
39
- /** Root document bytes charged against the input limit. */
45
+ /** Inspected root/nested metadata bytes charged against the aggregate input limit. */
40
46
  readonly tilesetBytes: number;
47
+ /** Inspected document placements, including repeated references; absent for root-only inspection. */
48
+ readonly documents?: readonly BrowserTilesetDocumentPlacement[];
49
+ }
50
+
51
+ /** One inspected document at a distinct external placement. */
52
+ export interface BrowserTilesetDocumentPlacement {
53
+ /** Full external content placement introducing this document; absent for the entry root. */
54
+ readonly parentResourceId?: string;
55
+ /** Effective document URL, including inherited query credentials. */
56
+ readonly url: string;
57
+ /** Original parsed document; its metadata and glTF conventions stay scoped to this document. */
58
+ readonly tileset: TilesetDocument;
59
+ /** Combined placement above this document's root. */
60
+ readonly parentTransform: readonly number[];
61
+ /** Conservative inherited metric error, when declared by all external ancestors. */
62
+ readonly parentGeometricError?: number;
41
63
  }
42
64
 
43
65
  /** Placement metadata for one ordered content resource. */
@@ -46,9 +68,13 @@ export interface BrowserTilesetResourceDescriptor {
46
68
  readonly resourceId: string;
47
69
  /** Original URI declared by the tileset. */
48
70
  readonly uri: string;
49
- /** Tile child indexes from the tileset root. */
71
+ /** Tile child indexes within the declaring document. */
50
72
  readonly tilePath: readonly number[];
51
- /** Combined root-to-tile transform in column-major order. */
73
+ /** Declaring nested document URL; absent for root-document content. */
74
+ readonly tilesetUrl?: string;
75
+ /** Placement index in an expanded inspection's documents. */
76
+ readonly documentIndex?: number;
77
+ /** Combined entry-root-to-tile transform in column-major order, including external roots. */
52
78
  readonly transform: readonly number[];
53
79
  }
54
80
 
@@ -58,21 +84,32 @@ export interface BrowserTilesetConversionInputResource extends BrowserTilesetRes
58
84
  readonly data: Uint8Array;
59
85
  /** Optional content type reported by the response. */
60
86
  readonly contentType?: string;
87
+ /** Effective content response URL used to resolve decoder dependencies after redirects. */
88
+ readonly contentUrl?: string;
61
89
  }
62
90
 
63
91
  /**
64
92
  * Creates a bounded browser source for explicit 3D Tiles 1.0 and 1.1 tilesets.
65
93
  *
66
- * Traversal visits each declared content placement once in depth-first order. Implicit tiling and
67
- * nested external tilesets are rejected; content decoding and conversion remain codec work.
94
+ * Traversal visits declared placements in depth-first order. Set maxTilesetDocuments above 1
95
+ * to follow explicit external tilesets, with shared byte/declaration limits and active-path cycle
96
+ * detection. Inspection reads the root by default; inspectNestedTilesets follows declared .json
97
+ * references without fetching binary content. Other nested metadata is discovered during reading.
98
+ * Implicit tiling remains unsupported; content decoding and conversion remain codec work.
99
+ * Bounds, metadata and LOD authoring policy remain application responsibilities.
68
100
  *
69
101
  * @param options - Tileset URL and aggregate byte and resource limits.
70
- * @returns A source yielding ordered raw tile resources with placement transforms.
102
+ * @returns A source yielding raw resources and placement transforms; it does not preserve output LOD.
71
103
  */
72
104
  export function createBrowserTilesetConversionSource(
73
105
  options: BrowserTilesetConversionSourceOptions
74
106
  ): TileConversionSource<BrowserTilesetConversionInspection, BrowserTilesetConversionInputResource> {
107
+ const maxTilesetDocuments = options.maxTilesetDocuments ?? 1;
75
108
  if (
109
+ (options.inspectNestedTilesets !== undefined &&
110
+ typeof options.inspectNestedTilesets !== 'boolean') ||
111
+ !Number.isSafeInteger(maxTilesetDocuments) ||
112
+ maxTilesetDocuments < 1 ||
76
113
  !Number.isSafeInteger(options.maxInputBytes) ||
77
114
  options.maxInputBytes < 1 ||
78
115
  !Number.isSafeInteger(options.maxInputResources) ||
@@ -80,7 +117,7 @@ export function createBrowserTilesetConversionSource(
80
117
  ) {
81
118
  throw new TileConversionError(
82
119
  'INVALID_BROWSER_SOURCE_OPTIONS',
83
- 'Browser tileset sources require positive byte and resource limits'
120
+ 'Browser tileset sources require positive byte, declaration and document limits'
84
121
  );
85
122
  }
86
123
 
@@ -88,44 +125,62 @@ export function createBrowserTilesetConversionSource(
88
125
  return {
89
126
  async inspect(signal) {
90
127
  throwIfAborted(signal);
91
- const rootUrl = new URL(options.input).href;
92
- const response = await fetcher(rootUrl, {signal});
128
+ const requestedUrl = new URL(options.input).href;
129
+ const response = await fetcher(requestedUrl, {signal});
130
+ const rootUrl = response.url || requestedUrl;
93
131
  if (!response.ok) {
132
+ await response.body?.cancel();
94
133
  throw new TileConversionError(
95
134
  'BROWSER_SOURCE_FETCH_FAILED',
96
135
  `Could not fetch browser tileset (${response.status} ${response.statusText})`
97
136
  );
98
137
  }
99
138
  const data = await readResponseBytes(response, options.maxInputBytes, signal);
100
- let tileset: TilesetDocument;
101
- try {
102
- tileset = JSON.parse(new TextDecoder().decode(data)) as TilesetDocument;
103
- } catch {
104
- throw new TileConversionError('INVALID_BROWSER_TILESET', 'Tileset input is not valid JSON');
105
- }
106
- if (
107
- !isRecord(tileset) ||
108
- !['1.0', '1.1'].includes(tileset.asset?.version || '') ||
109
- !isRecord(tileset.root)
110
- ) {
111
- throw new TileConversionError(
112
- 'UNSUPPORTED_BROWSER_TILESET',
113
- 'Browser tileset traversal supports explicit 3D Tiles 1.0 and 1.1 documents'
114
- );
115
- }
139
+ const tileset = parseTilesetDocument(data);
116
140
  const resources = collectResources(tileset.root, options.maxInputResources);
117
- return {rootUrl, tileset, resources, tilesetBytes: data.byteLength};
141
+ const inspection = {rootUrl, tileset, resources, tilesetBytes: data.byteLength};
142
+ return options.inspectNestedTilesets
143
+ ? expandInspection(inspection, options, maxTilesetDocuments, fetcher, signal)
144
+ : inspection;
118
145
  },
119
146
  async *read(inspection, signal) {
120
147
  let inputBytes = inspection.tilesetBytes;
121
- for (const descriptor of inspection.resources) {
148
+ let declarations = inspection.resources.length;
149
+ let documents = 1;
150
+ const stack = [
151
+ {
152
+ resources: inspection.resources,
153
+ index: 0,
154
+ tileset: inspection.tileset,
155
+ url: inspection.rootUrl,
156
+ ancestors: new Set([getDocumentKey(inspection.rootUrl), getDocumentKey(options.input)])
157
+ }
158
+ ];
159
+ while (stack.length) {
122
160
  throwIfAborted(signal);
123
- const resourceUrl = resolveResourceUrl(descriptor.uri, inspection.rootUrl);
161
+ const frame = stack[stack.length - 1];
162
+ if (frame.index === frame.resources.length) {
163
+ stack.pop();
164
+ continue;
165
+ }
166
+ const descriptor = frame.resources[frame.index++];
167
+ const resourceUrl = resolveResourceUrl(descriptor.uri, descriptor.tilesetUrl ?? frame.url);
168
+ if (frame.ancestors.has(getDocumentKey(resourceUrl))) {
169
+ throw new TileConversionError(
170
+ 'BROWSER_TILESET_CYCLE',
171
+ 'External tileset references an active ancestor document'
172
+ );
173
+ }
124
174
  const response = await fetcher(resourceUrl, {signal});
125
175
  if (!response.ok) {
176
+ await response.body?.cancel();
126
177
  throw new TileConversionError(
127
178
  'BROWSER_SOURCE_FETCH_FAILED',
128
- `Could not fetch browser tile content (${response.status} ${response.statusText})`,
179
+ 'Could not fetch browser tile content (' +
180
+ response.status +
181
+ ' ' +
182
+ response.statusText +
183
+ ')',
129
184
  [
130
185
  {
131
186
  code: 'BROWSER_SOURCE_FETCH_FAILED',
@@ -136,30 +191,330 @@ export function createBrowserTilesetConversionSource(
136
191
  ]
137
192
  );
138
193
  }
139
- const remainingBytes = options.maxInputBytes - inputBytes;
140
- const data = await readResponseBytes(response, remainingBytes, signal);
194
+ const documentUrl = response.url || resourceUrl;
195
+ if (frame.ancestors.has(getDocumentKey(documentUrl))) {
196
+ await response.body?.cancel();
197
+ throw new TileConversionError(
198
+ 'BROWSER_TILESET_CYCLE',
199
+ 'Redirected content references an active ancestor document'
200
+ );
201
+ }
202
+ const data = await readResponseBytes(response, options.maxInputBytes - inputBytes, signal);
141
203
  inputBytes += data.byteLength;
142
204
  const contentType = response.headers.get('content-type') ?? undefined;
143
205
  if (isJsonContent(data, contentType)) {
144
- throw new TileConversionError(
145
- 'UNSUPPORTED_BROWSER_TILESET_CONTENT',
146
- `Nested tilesets and JSON tile content are not supported: ${descriptor.uri}`
147
- );
206
+ if (inspection.documents)
207
+ throw new TileConversionError(
208
+ 'UNSUPPORTED_BROWSER_TILESET_CONTENT',
209
+ 'Expanded inspection selections must be binary content; declare external metadata with a .json URI'
210
+ );
211
+ if (maxTilesetDocuments === 1) {
212
+ throw new TileConversionError(
213
+ 'UNSUPPORTED_BROWSER_TILESET_CONTENT',
214
+ 'Nested tilesets and JSON tile content require an explicit document budget'
215
+ );
216
+ }
217
+ if (documents >= maxTilesetDocuments) {
218
+ throw new TileConversionError(
219
+ 'INPUT_TILESET_COUNT_EXCEEDED',
220
+ 'Browser tileset exceeds its document limit'
221
+ );
222
+ }
223
+ let tile = frame.tileset.root!;
224
+ for (const childIndex of descriptor.tilePath) tile = getTileChild(tile, childIndex);
225
+ if (tile.children?.length) {
226
+ throw new TileConversionError(
227
+ 'INVALID_BROWSER_TILESET',
228
+ 'External tileset references cannot also have child tiles'
229
+ );
230
+ }
231
+ const tileset = parseTilesetDocument(data);
232
+ const resources = collectResources(
233
+ tileset.root!,
234
+ options.maxInputResources - declarations,
235
+ descriptor.transform
236
+ ).map(resource => ({
237
+ ...resource,
238
+ resourceId: descriptor.resourceId + '/' + resource.resourceId,
239
+ tilesetUrl: documentUrl
240
+ }));
241
+ declarations += resources.length;
242
+ documents++;
243
+ stack.push({
244
+ resources,
245
+ index: 0,
246
+ tileset,
247
+ url: documentUrl,
248
+ ancestors: new Set([
249
+ ...frame.ancestors,
250
+ getDocumentKey(resourceUrl),
251
+ getDocumentKey(documentUrl)
252
+ ])
253
+ });
254
+ continue;
148
255
  }
149
- yield {...descriptor, data, ...(contentType ? {contentType} : {})};
256
+ yield {...descriptor, data, contentUrl: documentUrl, ...(contentType ? {contentType} : {})};
150
257
  }
151
258
  }
152
259
  };
153
260
  }
154
261
 
262
+ /** A selected document and conservative source error for one content placement. */
263
+ export interface BrowserTilesetContentSelection {
264
+ /** Cloned document retaining its metadata, ancestor transforms and only the selected content. */
265
+ readonly tileset: TilesetDocument;
266
+ /** Resolved resource URL, carrying same-origin document query parameters. */
267
+ readonly contentUrl: string;
268
+ /** Conservative source geometric error in meters, including transform scaling. */
269
+ readonly geometricError: number;
270
+ /** Whether the original selected tile has no children. */
271
+ readonly isLeaf: boolean;
272
+ }
273
+
274
+ /** Selects one inspected content while keeping declaring-document conventions and placement. */
275
+ export function selectBrowserTilesetContent(
276
+ inspection: BrowserTilesetConversionInspection,
277
+ descriptor: BrowserTilesetResourceDescriptor
278
+ ): BrowserTilesetContentSelection {
279
+ const placement = inspection.documents?.[descriptor.documentIndex ?? 0];
280
+ if (inspection.documents && (!Number.isSafeInteger(descriptor.documentIndex) || !placement))
281
+ throw new TileConversionError(
282
+ 'INVALID_BROWSER_SELECTION',
283
+ 'Content descriptor has no inspected document placement'
284
+ );
285
+ const document = structuredClone(placement?.tileset ?? inspection.tileset);
286
+ const originalRoot = document.root!;
287
+ let transform = [...(placement?.parentTransform ?? identityMatrix())];
288
+ let maximum = placement?.parentGeometricError ?? 0;
289
+ let tile = originalRoot;
290
+ for (let depth = 0; ; depth++) {
291
+ transform = multiplyMatrices(transform, normalizeMatrix(tile.transform));
292
+ if (
293
+ typeof tile.geometricError !== 'number' ||
294
+ !Number.isFinite(tile.geometricError) ||
295
+ tile.geometricError < 0
296
+ )
297
+ throw new TileConversionError(
298
+ 'MESH_GEOMETRIC_ERROR_INVALID',
299
+ 'Every selected ancestor must declare a finite nonnegative geometricError'
300
+ );
301
+ maximum = Math.max(maximum, tile.geometricError * getTransformScale(transform));
302
+ if (depth === descriptor.tilePath.length) break;
303
+ const child = getTileChild(tile, descriptor.tilePath[depth]);
304
+ delete tile.content;
305
+ delete tile.contents;
306
+ tile.children = [child];
307
+ tile = child;
308
+ }
309
+ if (placement && placement.parentGeometricError === undefined && descriptor.documentIndex !== 0)
310
+ throw new TileConversionError(
311
+ 'MESH_GEOMETRIC_ERROR_INVALID',
312
+ 'External ancestors must declare geometricError'
313
+ );
314
+ const isLeaf = !tile.children?.length;
315
+ const contentIndex = Number(descriptor.resourceId.match(/content-(\d+)$/)?.[1]);
316
+ const content = tile.contents?.[contentIndex] ?? tile.content;
317
+ if (!content || (content.uri ?? content.url) !== descriptor.uri)
318
+ throw new TileConversionError(
319
+ 'INVALID_BROWSER_SELECTION',
320
+ 'Content descriptor does not match its inspected document'
321
+ );
322
+ tile.content = content;
323
+ delete tile.contents;
324
+ delete tile.children;
325
+ if (placement)
326
+ originalRoot.transform = multiplyMatrices(
327
+ [...placement.parentTransform],
328
+ normalizeMatrix(originalRoot.transform)
329
+ );
330
+ if (!Number.isFinite(maximum))
331
+ throw new TileConversionError(
332
+ 'MESH_GEOMETRIC_ERROR_INVALID',
333
+ 'Scaled geometricError must remain finite'
334
+ );
335
+ return {
336
+ tileset: document,
337
+ contentUrl: resolveResourceUrl(descriptor.uri, placement?.url ?? inspection.rootUrl),
338
+ geometricError: maximum,
339
+ isLeaf
340
+ };
341
+ }
342
+
343
+ /** Resolves a numeric in-bounds child without allowing property names to traverse prototypes. */
344
+ function getTileChild(tile: TilesetTile, childIndex: number): TilesetTile {
345
+ if (
346
+ typeof childIndex !== 'number' ||
347
+ !Number.isSafeInteger(childIndex) ||
348
+ childIndex < 0 ||
349
+ !Array.isArray(tile.children) ||
350
+ childIndex >= tile.children.length
351
+ )
352
+ throw new TileConversionError(
353
+ 'INVALID_BROWSER_SELECTION',
354
+ 'Tile paths require in-bounds nonnegative integer child indexes'
355
+ );
356
+ const child = tile.children.at(childIndex);
357
+ if (!isRecord(child))
358
+ throw new TileConversionError(
359
+ 'INVALID_BROWSER_SELECTION',
360
+ 'Selected child tile must be an object'
361
+ );
362
+ return child as TilesetTile;
363
+ }
364
+
365
+ /** Expands only declared JSON references; binary content is never requested during inspection. */
366
+ async function expandInspection(
367
+ inspection: BrowserTilesetConversionInspection,
368
+ options: BrowserTilesetConversionSourceOptions,
369
+ maxTilesetDocuments: number,
370
+ fetcher: typeof fetch,
371
+ signal?: AbortSignal
372
+ ): Promise<BrowserTilesetConversionInspection> {
373
+ const documents: BrowserTilesetDocumentPlacement[] = [
374
+ {
375
+ url: inspection.rootUrl,
376
+ tileset: inspection.tileset,
377
+ parentTransform: identityMatrix(),
378
+ parentGeometricError: 0
379
+ }
380
+ ];
381
+ const resources: BrowserTilesetResourceDescriptor[] = [];
382
+ let bytes = inspection.tilesetBytes;
383
+ let declarations = inspection.resources.length;
384
+ const stack = [
385
+ {
386
+ resources: inspection.resources,
387
+ index: 0,
388
+ documentIndex: 0,
389
+ ancestors: new Set([getDocumentKey(inspection.rootUrl), getDocumentKey(options.input)])
390
+ }
391
+ ];
392
+ while (stack.length) {
393
+ throwIfAborted(signal);
394
+ const frame = stack[stack.length - 1];
395
+ if (frame.index === frame.resources.length) {
396
+ stack.pop();
397
+ continue;
398
+ }
399
+ const descriptor = frame.resources[frame.index++];
400
+ const placement = documents[frame.documentIndex];
401
+ const resourceUrl = resolveResourceUrl(descriptor.uri, placement.url);
402
+ if (!new URL(resourceUrl).pathname.toLowerCase().endsWith('.json')) {
403
+ resources.push({...descriptor, documentIndex: frame.documentIndex});
404
+ continue;
405
+ }
406
+ if (frame.ancestors.has(getDocumentKey(resourceUrl)))
407
+ throw new TileConversionError(
408
+ 'BROWSER_TILESET_CYCLE',
409
+ 'External tileset references an active ancestor document'
410
+ );
411
+ if (documents.length >= maxTilesetDocuments)
412
+ throw new TileConversionError(
413
+ 'INPUT_TILESET_COUNT_EXCEEDED',
414
+ 'Browser tileset exceeds its document limit'
415
+ );
416
+ let tile = placement.tileset.root!;
417
+ for (const childIndex of descriptor.tilePath) tile = tile.children![childIndex];
418
+ if (tile.children?.length || tile.contents?.length)
419
+ throw new TileConversionError(
420
+ 'INVALID_BROWSER_TILESET',
421
+ 'Inspected external tilesets require one content and no child tiles'
422
+ );
423
+ const response = await fetcher(resourceUrl, {signal});
424
+ const url = response.url || resourceUrl;
425
+ if (!response.ok || frame.ancestors.has(getDocumentKey(url))) {
426
+ await response.body?.cancel();
427
+ throw new TileConversionError(
428
+ response.ok ? 'BROWSER_TILESET_CYCLE' : 'BROWSER_SOURCE_FETCH_FAILED',
429
+ 'Could not inspect external tileset'
430
+ );
431
+ }
432
+ const data = await readResponseBytes(response, options.maxInputBytes - bytes, signal);
433
+ bytes += data.byteLength;
434
+ const tileset = parseTilesetDocument(data);
435
+ const prefix = descriptor.resourceId + '/';
436
+ const nestedResources = collectResources(
437
+ tileset.root,
438
+ options.maxInputResources - declarations,
439
+ descriptor.transform
440
+ ).map(resource => ({...resource, resourceId: prefix + resource.resourceId, tilesetUrl: url}));
441
+ declarations += nestedResources.length;
442
+ let parentGeometricError: number | undefined;
443
+ try {
444
+ parentGeometricError = selectBrowserTilesetContent(
445
+ {...inspection, documents},
446
+ {...descriptor, documentIndex: frame.documentIndex}
447
+ ).geometricError;
448
+ } catch (error) {
449
+ if (!(error instanceof TileConversionError) || error.code !== 'MESH_GEOMETRIC_ERROR_INVALID')
450
+ throw error;
451
+ }
452
+ const documentIndex = documents.length;
453
+ documents.push({
454
+ url,
455
+ tileset,
456
+ parentResourceId: descriptor.resourceId,
457
+ parentTransform: descriptor.transform,
458
+ parentGeometricError
459
+ });
460
+ stack.push({
461
+ resources: nestedResources,
462
+ index: 0,
463
+ documentIndex,
464
+ ancestors: new Set([...frame.ancestors, getDocumentKey(resourceUrl), getDocumentKey(url)])
465
+ });
466
+ }
467
+ return {...inspection, resources, documents, tilesetBytes: bytes};
468
+ }
469
+
470
+ /** Conservative spectral norm bound for a finite affine placement's linear part. */
471
+ function getTransformScale(transform: readonly number[]): number {
472
+ const columns = [0, 1, 2].map(column =>
473
+ [0, 1, 2].reduce((total, row) => total + Math.abs(transform[column * 4 + row]), 0)
474
+ );
475
+ const rows = [0, 1, 2].map(row =>
476
+ [0, 1, 2].reduce((total, column) => total + Math.abs(transform[column * 4 + row]), 0)
477
+ );
478
+ return Math.sqrt(Math.max(...columns)) * Math.sqrt(Math.max(...rows));
479
+ }
480
+
481
+ /** Parses one root or external document without inferring a profile from its filename. */
482
+ function parseTilesetDocument(data: Uint8Array): TilesetDocument & {root: TilesetTile} {
483
+ let tileset: TilesetDocument;
484
+ try {
485
+ tileset = JSON.parse(new TextDecoder().decode(data));
486
+ } catch {
487
+ throw new TileConversionError('INVALID_BROWSER_TILESET', 'Tileset input is not valid JSON');
488
+ }
489
+ if (
490
+ !isRecord(tileset) ||
491
+ !['1.0', '1.1'].includes(tileset.asset?.version || '') ||
492
+ !isRecord(tileset.root)
493
+ ) {
494
+ throw new TileConversionError(
495
+ 'UNSUPPORTED_BROWSER_TILESET',
496
+ 'Browser tileset traversal supports explicit 3D Tiles 1.0 and 1.1 documents'
497
+ );
498
+ }
499
+ return tileset as TilesetDocument & {root: TilesetTile};
500
+ }
501
+
502
+ /** Normalizes URL identity for cycle checks; fragments do not identify a separate response. */
503
+ function getDocumentKey(url: string): string {
504
+ const documentUrl = new URL(url);
505
+ documentUrl.hash = '';
506
+ return documentUrl.href;
507
+ }
508
+
155
509
  /** Collects explicit tile contents in declaration order and fails on unbounded formats. */
156
510
  function collectResources(
157
511
  root: TilesetTile,
158
- maxInputResources: number
512
+ maxInputResources: number,
513
+ parentTransform: readonly number[] = identityMatrix()
159
514
  ): BrowserTilesetResourceDescriptor[] {
160
515
  const resources: BrowserTilesetResourceDescriptor[] = [];
161
516
  const stack: Array<{tile: TilesetTile; path: number[]; transform: number[]}> = [
162
- {tile: root, path: [], transform: identityMatrix()}
517
+ {tile: root, path: [], transform: [...parentTransform]}
163
518
  ];
164
519
  while (stack.length > 0) {
165
520
  const {tile, path, transform: parentTransform} = stack.pop()!;
@@ -181,7 +536,19 @@ function collectResources(
181
536
  if (tile.children !== undefined && !Array.isArray(tile.children)) {
182
537
  throw new TileConversionError('INVALID_BROWSER_TILESET', 'Tile children must be an array');
183
538
  }
539
+ if (tile.content !== undefined && tile.contents !== undefined) {
540
+ throw new TileConversionError(
541
+ 'INVALID_BROWSER_TILESET',
542
+ 'Tile content and contents are mutually exclusive'
543
+ );
544
+ }
184
545
  const transform = multiplyMatrices(parentTransform, normalizeMatrix(tile.transform));
546
+ if (transform.some(value => !Number.isFinite(value))) {
547
+ throw new TileConversionError(
548
+ 'INVALID_BROWSER_TILESET',
549
+ 'Composed tile transforms must remain finite'
550
+ );
551
+ }
185
552
  const contents = tile.contents ?? (tile.content ? [tile.content] : []);
186
553
  for (let contentIndex = 0; contentIndex < contents.length; contentIndex++) {
187
554
  const content = contents[contentIndex];
@@ -236,15 +603,15 @@ async function readResponseBytes(
236
603
  'Browser inputs must provide a readable response stream'
237
604
  );
238
605
  }
239
- const data = new Uint8Array(maximumBytes);
240
606
  let byteLength = 0;
241
607
  try {
608
+ const data = new Uint8Array(maximumBytes);
242
609
  while (true) {
243
610
  throwIfAborted(signal);
244
611
  const {done, value} = await reader.read();
612
+ throwIfAborted(signal);
245
613
  if (done) break;
246
614
  if (value.byteLength > maximumBytes - byteLength) {
247
- await reader.cancel();
248
615
  throw new TileConversionError(
249
616
  'INPUT_RESOURCE_TOO_LARGE',
250
617
  `Browser tileset input exceeds the configured ${maximumBytes} byte remaining limit`
@@ -253,13 +620,16 @@ async function readResponseBytes(
253
620
  data.set(value, byteLength);
254
621
  byteLength += value.byteLength;
255
622
  }
623
+ return data.slice(0, byteLength);
624
+ } catch (error) {
625
+ await reader.cancel().catch(() => {});
626
+ throw error;
256
627
  } finally {
257
628
  reader.releaseLock();
258
629
  }
259
- return data.subarray(0, byteLength);
260
630
  }
261
631
 
262
- /** Detects JSON payloads that may declare an unsupported external tileset. */
632
+ /** Detects JSON payloads that require document validation before traversal. */
263
633
  function isJsonContent(data: Uint8Array, contentType?: string): boolean {
264
634
  if (contentType?.includes('json')) return true;
265
635
  let byteOffset = data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf ? 3 : 0;
@@ -267,7 +637,7 @@ function isJsonContent(data: Uint8Array, contentType?: string): boolean {
267
637
  return data[byteOffset] === 0x7b;
268
638
  }
269
639
 
270
- /** Resolves a tile URI and carries root query parameters to same-origin relative resources. */
640
+ /** Resolves a tile URI and carries declaring-document query parameters to same-origin resources. */
271
641
  function resolveResourceUrl(uri: string, rootUrl: string): string {
272
642
  const root = new URL(rootUrl);
273
643
  const resource = new URL(uri, root);
package/src/v5/browser.ts CHANGED
@@ -137,3 +137,48 @@ export type {
137
137
  I3SMeshTilesetSourceOptions,
138
138
  I3SMeshSourceFeatureOptions
139
139
  } from '@loaders.gl/tile-converter/v5/adapters';
140
+
141
+ export {convertPointCloudToTileset} from '@loaders.gl/tile-converter/v5/adapters';
142
+ export type {
143
+ ConvertPointCloudToTilesetOptions,
144
+ ConvertedPointCloudTileset
145
+ } from '@loaders.gl/tile-converter/v5/adapters';
146
+
147
+ export {
148
+ encodePotreeArchive,
149
+ openPointCloudFile,
150
+ extractMeshVertices
151
+ } from '@loaders.gl/tile-converter/v5/adapters';
152
+ export type {
153
+ EncodePotreeArchiveOptions,
154
+ OpenPointCloudFileOptions,
155
+ OpenedPointCloudFile,
156
+ ExtractMeshVerticesOptions
157
+ } from '@loaders.gl/tile-converter/v5/adapters';
158
+
159
+ export {createMeshSourceFeatureOptions} from '@loaders.gl/tile-converter/v5/adapters';
160
+ export type {MeshSourceSchemaOptions} from '@loaders.gl/tile-converter/v5/adapters';
161
+ export {convertPointCloudToPotree} from '@loaders.gl/tile-converter/v5/adapters';
162
+ export type {
163
+ ConvertPointCloudToPotreeOptions,
164
+ ConvertedPointCloudPotree
165
+ } from '@loaders.gl/tile-converter/v5/adapters';
166
+
167
+ export {selectBrowserTilesetContent} from '@loaders.gl/tile-converter/v5/core';
168
+ export type {
169
+ BrowserTilesetDocumentPlacement,
170
+ BrowserTilesetContentSelection
171
+ } from '@loaders.gl/tile-converter/v5/core';
172
+
173
+ export {
174
+ createSelectedMeshConversionSource,
175
+ measureMeshBytes,
176
+ SELECTED_MESH_LIMITS
177
+ } from '@loaders.gl/tile-converter/v5/adapters';
178
+ export type {
179
+ SelectedMeshSourceOptions,
180
+ SelectedMeshConversionSource
181
+ } from '@loaders.gl/tile-converter/v5/adapters';
182
+
183
+ export {createMeshHierarchyTilesetSink} from '@loaders.gl/tile-converter/v5/adapters';
184
+ export type {MeshHierarchyTilesetSinkOptions} from '@loaders.gl/tile-converter/v5/adapters';