@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
@@ -0,0 +1,384 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {prepareMeshHierarchy} from './mesh-hierarchy.js';
6
+ import {coreApi, parse} from '@loaders.gl/core';
7
+ import type {Loader, LoaderOptions} from '@loaders.gl/loader-utils';
8
+ import {Tiles3DLoader} from '@loaders.gl/3d-tiles';
9
+ import {Tiles3DSource, Tileset3D} from '@loaders.gl/tiles';
10
+ import type {
11
+ TilesetSourceInput,
12
+ TilesetSourceMetadata,
13
+ TilesetSpatialReference
14
+ } from '@loaders.gl/tiles';
15
+ import type {DracoLibraryOptions} from '@loaders.gl/draco/bundled';
16
+ import {
17
+ createBrowserTilesetConversionSource,
18
+ selectBrowserTilesetContent,
19
+ createTiles3DConversionSpatialContext,
20
+ TileConversionError
21
+ } from '@loaders.gl/tile-converter/v5/core';
22
+ import type {
23
+ BrowserTilesetConversionInspection,
24
+ BrowserTilesetConversionInputResource,
25
+ TileConversionSource
26
+ } from '@loaders.gl/tile-converter/v5/core';
27
+ import {
28
+ createMeshTilesetConversionSource,
29
+ createTileConversionResourceFetcher
30
+ } from '@loaders.gl/tile-converter/v5/adapters';
31
+ import type {
32
+ MeshSourceResource,
33
+ MeshSourceFeatureOptions
34
+ } from '@loaders.gl/tile-converter/v5/adapters';
35
+
36
+ /** Required transport, decoded geometry, retained output, and archive budgets for this demo. */
37
+ export const SELECTED_MESH_LIMITS = {
38
+ /** Root JSON plus selected content transport bytes; also the decoded geometry/image/feature byte gate. */
39
+ maxInputBytes: 16 * 1024 * 1024,
40
+ /** Maximum declared content placements inspected. */
41
+ maxInputResources: 1000,
42
+ /** Maximum inspected root/external document placements. */
43
+ maxTilesetDocuments: 32,
44
+ /** Maximum explicitly selected content placements in one partial archive. */
45
+ maxSelectedResources: 64,
46
+ /** Maximum primitive placements authored across all selected contents. */
47
+ maxMeshResources: 64,
48
+ /** Maximum retained file bytes and final archive bytes. */
49
+ maxOutputBytes: 32 * 1024 * 1024,
50
+ /** Maximum measured reconstruction error in meters. */
51
+ maxPositionError: 0.01
52
+ } as const;
53
+
54
+ /** Injected transport and decoder assets for the bounded selected mesh profile. */
55
+ export interface SelectedMeshSourceOptions {
56
+ /** Require a complete qualified explicit tree; pair with createMeshHierarchyTilesetSink. */
57
+ readonly preserveHierarchy?: boolean;
58
+ /** Inspected content placement identifiers; one to 64 distinct selections. */
59
+ readonly resourceIds: readonly string[];
60
+ /** Cancellation across qualification, raw reads and decoding. */
61
+ readonly signal: AbortSignal;
62
+ /** Application-owned decoder asset locations or modules. */
63
+ readonly dracoLibraryOptions: DracoLibraryOptions;
64
+ /** Transport for decoded buffers/images, confined by a platform adapter when local. */
65
+ readonly fetcher?: typeof fetch;
66
+ /** Optional source owning the raw input lifetime, such as a Node file/archive adapter. */
67
+ readonly source?: TileConversionSource<
68
+ BrowserTilesetConversionInspection,
69
+ BrowserTilesetConversionInputResource
70
+ >;
71
+ /** Explicit feature mapping, or the qualified inline structural metadata profile. */
72
+ readonly features?: MeshSourceFeatureOptions;
73
+ /** Lower the shared 16 MiB transport and decoded mesh limit. */
74
+ readonly maxInputBytes?: number;
75
+ }
76
+
77
+ /** Qualified selected-mesh decoding and source error policy. */
78
+ export interface SelectedMeshConversionSource {
79
+ /** Lazy source that releases each dedicated mesh runtime after use. */
80
+ readonly source: TileConversionSource<BrowserTilesetConversionInspection, MeshSourceResource>;
81
+ /** Native ECEF coordinate context, qualified before reading mesh content. */
82
+ readonly spatialContext: ReturnType<typeof createTiles3DConversionSpatialContext>;
83
+ /** Conservative metric source error plus the position reconstruction allowance. */
84
+ readonly geometricError: number;
85
+ }
86
+
87
+ /** Qualifies and constructs a lazy selected mesh source without platform I/O or output ownership. */
88
+ export async function createSelectedMeshConversionSource(
89
+ inspection: BrowserTilesetConversionInspection,
90
+ options: SelectedMeshSourceOptions
91
+ ): Promise<SelectedMeshConversionSource> {
92
+ const {
93
+ resourceIds,
94
+ signal,
95
+ dracoLibraryOptions,
96
+ features,
97
+ preserveHierarchy,
98
+ fetcher = fetch
99
+ } = options;
100
+ const maxInputBytes = options.maxInputBytes ?? SELECTED_MESH_LIMITS.maxInputBytes;
101
+ if (
102
+ !Number.isSafeInteger(maxInputBytes) ||
103
+ maxInputBytes < 1 ||
104
+ maxInputBytes > SELECTED_MESH_LIMITS.maxInputBytes
105
+ )
106
+ throw new TileConversionError(
107
+ 'INVALID_SELECTED_MESH_OPTIONS',
108
+ 'Selected mesh input budget must be a positive safe integer at most 16 MiB'
109
+ );
110
+ if (
111
+ !resourceIds.length ||
112
+ resourceIds.length > SELECTED_MESH_LIMITS.maxSelectedResources ||
113
+ new Set(resourceIds).size !== resourceIds.length ||
114
+ resourceIds.some(
115
+ identifier => !inspection.resources.some(resource => resource.resourceId === identifier)
116
+ )
117
+ )
118
+ throw new Error('Select between 1 and 64 distinct content placements.');
119
+ if (preserveHierarchy !== undefined && typeof preserveHierarchy !== 'boolean')
120
+ throw new TileConversionError(
121
+ 'MESH_HIERARCHY_UNSUPPORTED',
122
+ 'preserveHierarchy must be a boolean'
123
+ );
124
+ if (preserveHierarchy) prepareMeshHierarchy(inspection, resourceIds);
125
+ const descriptors = inspection.resources.filter(resource =>
126
+ resourceIds.includes(resource.resourceId)
127
+ );
128
+ const selections = descriptors.map(descriptor =>
129
+ selectBrowserTilesetContent(inspection, descriptor)
130
+ );
131
+ const selectedContents = new Map(
132
+ descriptors.map((descriptor, index) => [descriptor.resourceId, selections[index]])
133
+ );
134
+ if (
135
+ !preserveHierarchy &&
136
+ resourceIds.length > 1 &&
137
+ selections.some(selection => !selection.isLeaf)
138
+ )
139
+ throw new Error(
140
+ 'Multi-selection requires leaf contents; source LOD hierarchy is not exported.'
141
+ );
142
+ signal.throwIfAborted();
143
+ const boundedFetcher = createTileConversionResourceFetcher({
144
+ maxInputBytes,
145
+ initialInputBytes: inspection.tilesetBytes,
146
+ fetcher,
147
+ signal
148
+ });
149
+ const qualification = createSelectedRuntime(
150
+ inspection.tileset,
151
+ new Uint8Array(),
152
+ signal,
153
+ boundedFetcher,
154
+ inspection.rootUrl,
155
+ dracoLibraryOptions
156
+ );
157
+ let spatialContext: ReturnType<typeof createTiles3DConversionSpatialContext>;
158
+ try {
159
+ await qualification.tilesetInitializationPromise;
160
+ const metadata = await createMeshTilesetConversionSource(qualification).inspect(signal);
161
+ spatialContext = createTiles3DConversionSpatialContext(metadata.spatialReference!);
162
+ } finally {
163
+ qualification.destroy();
164
+ }
165
+ const rawSource =
166
+ options.source ??
167
+ createBrowserTilesetConversionSource({
168
+ input: inspection.rootUrl,
169
+ ...SELECTED_MESH_LIMITS,
170
+ maxInputBytes,
171
+ fetcher: boundedFetcher
172
+ });
173
+ const selectedInspection = {...inspection, resources: descriptors};
174
+ const source = {
175
+ /** Reuses inspected declarations without fetching the root again. */
176
+ inspect: async (inspectionSignal?: AbortSignal) => {
177
+ signal.throwIfAborted();
178
+ inspectionSignal?.throwIfAborted();
179
+ return selectedInspection;
180
+ },
181
+ /** Decodes and releases each selected placement in declaration order. */
182
+ async *read(_inspection: BrowserTilesetConversionInspection, readSignal?: AbortSignal) {
183
+ const signal = readSignal ? AbortSignal.any([options.signal, readSignal]) : options.signal;
184
+ signal.throwIfAborted();
185
+ let decodedBytes = 0;
186
+ let primitiveCount = 0;
187
+ // A 3D Tiles contents array has one tile transform, so its GLBs need a shared origin.
188
+ const tileOrigins = new Map<string, readonly [number, number, number]>();
189
+ for await (const raw of rawSource.read(selectedInspection, signal)) {
190
+ const selection = selectedContents.get(raw.resourceId);
191
+ if (!selection)
192
+ throw new TileConversionError(
193
+ 'INVALID_BROWSER_SELECTION',
194
+ 'Source yielded an uninspected content placement'
195
+ );
196
+ const contentUrl = raw.contentUrl ?? selection.contentUrl;
197
+ const runtime = createSelectedRuntime(
198
+ selection.tileset,
199
+ raw.data,
200
+ signal,
201
+ boundedFetcher,
202
+ contentUrl,
203
+ dracoLibraryOptions,
204
+ spatialContext.spatialReference
205
+ );
206
+ try {
207
+ await runtime.tilesetInitializationPromise;
208
+ const meshSource = createMeshTilesetConversionSource(runtime, {
209
+ unloadContent: true,
210
+ features,
211
+ readExternalResource: async (uri, _contentUri, resourceSignal) => {
212
+ const response = await boundedFetcher(new URL(uri, contentUrl), {
213
+ signal: resourceSignal
214
+ });
215
+ return new Uint8Array(await response.arrayBuffer());
216
+ }
217
+ });
218
+ const metadata = await meshSource.inspect(signal);
219
+ const selectedContext = createTiles3DConversionSpatialContext(metadata.spatialReference!);
220
+ if (
221
+ selectedContext.spatialReference.coordinateEpoch !==
222
+ spatialContext.spatialReference.coordinateEpoch
223
+ )
224
+ throw new TileConversionError(
225
+ 'MESH_SOURCE_EPOCH_MISMATCH',
226
+ 'Selected content coordinate epoch must match the root; epoch transformations are unsupported'
227
+ );
228
+ let meshCount = 0;
229
+ for await (const resource of meshSource.read(metadata, signal)) {
230
+ meshCount++;
231
+ if (++primitiveCount > SELECTED_MESH_LIMITS.maxMeshResources)
232
+ throw new Error('Selected contents exceed the mesh primitive placement limit.');
233
+ decodedBytes += measureMeshBytes(resource);
234
+ if (decodedBytes > maxInputBytes)
235
+ throw new TileConversionError(
236
+ 'INPUT_RESOURCE_TOO_LARGE',
237
+ 'Selected decoded geometry and encoded images exceed the aggregate input byte limit.'
238
+ );
239
+ const tileIdentifier = raw.resourceId.replace(/content-\d+$/, '');
240
+ if (preserveHierarchy && !tileOrigins.has(tileIdentifier))
241
+ tileOrigins.set(tileIdentifier, [...resource.origin]);
242
+ yield {
243
+ ...resource,
244
+ origin: preserveHierarchy ? tileOrigins.get(tileIdentifier)! : resource.origin,
245
+ id: `${raw.resourceId}/${resource.id}`,
246
+ contentResourceId: raw.resourceId
247
+ };
248
+ }
249
+ if (!meshCount) throw new Error('Selected content has no mesh primitive.');
250
+ } finally {
251
+ runtime.destroy();
252
+ }
253
+ }
254
+ }
255
+ };
256
+ return {
257
+ source,
258
+ spatialContext,
259
+ geometricError:
260
+ Math.max(...selections.map(selection => selection.geometricError)) +
261
+ SELECTED_MESH_LIMITS.maxPositionError
262
+ };
263
+ }
264
+
265
+ /** Dedicated runtime source that inherits an absent external-document CRS from the qualified root. */
266
+ class SelectedTiles3DSource extends Tiles3DSource {
267
+ /** Parent frame; explicit, unknown or unresolved child declarations are never replaced. */
268
+ private readonly inheritedSpatialReference?: TilesetSpatialReference;
269
+
270
+ /** Creates a selected source with optional external-document frame inheritance. */
271
+ constructor(
272
+ input: TilesetSourceInput,
273
+ options: LoaderOptions,
274
+ inheritedSpatialReference?: TilesetSpatialReference
275
+ ) {
276
+ super(input, options);
277
+ this.inheritedSpatialReference = inheritedSpatialReference;
278
+ }
279
+
280
+ /** Returns source metadata with the same absent-CRS inheritance used by external tilesets. */
281
+ override getMetadata(): TilesetSourceMetadata {
282
+ const metadata = super.getMetadata();
283
+ const reference = metadata.spatialReference;
284
+ if (
285
+ this.inheritedSpatialReference &&
286
+ reference?.crs.state === 'absent' &&
287
+ reference.coordinateEpoch === undefined &&
288
+ !reference.verticalCrs &&
289
+ !reference.warnings.length
290
+ )
291
+ return {...metadata, spatialReference: this.inheritedSpatialReference};
292
+ return metadata;
293
+ }
294
+ }
295
+
296
+ /** Creates a dedicated runtime whose only content response is the bounded selected payload. */
297
+ function createSelectedRuntime(
298
+ document: BrowserTilesetConversionInspection['tileset'],
299
+ data: Uint8Array,
300
+ signal: AbortSignal,
301
+ boundedFetcher: typeof fetch,
302
+ contentUrl: string,
303
+ dracoLibraryOptions: DracoLibraryOptions,
304
+ inheritedSpatialReference?: TilesetSpatialReference
305
+ ): Tileset3D {
306
+ const {
307
+ modules,
308
+ decoderProfile,
309
+ CDN: contentDeliveryNetwork,
310
+ useLocalLibraries
311
+ } = dracoLibraryOptions;
312
+ const loadOptions = {
313
+ modules,
314
+ core: {
315
+ worker: false,
316
+ fetch: boundedFetcher,
317
+ CDN: contentDeliveryNetwork,
318
+ useLocalLibraries
319
+ },
320
+ // Reuse the full decoder already bundled for output verification.
321
+ draco: {decoderProfile},
322
+ // Preserve authored UVs and the transform for the converter's qualified GLB writer.
323
+ gltf: {
324
+ loadImages: false,
325
+ decompressMeshes: true,
326
+ excludeExtensions: {
327
+ KHR_texture_transform: false,
328
+ // Only Draco input compression is qualified by this conversion profile.
329
+ EXT_meshopt_compression: false,
330
+ KHR_meshopt_compression: false
331
+ }
332
+ },
333
+ '3d-tiles': {loadGLTF: true}
334
+ };
335
+ return new Tileset3D(
336
+ new SelectedTiles3DSource(
337
+ {
338
+ url: contentUrl,
339
+ loader: Tiles3DLoader,
340
+ resolver: {
341
+ /** Normalizes the selected hierarchy through the format loader, retaining ancestor transforms. */
342
+ loadRoot: async (_url: string, loader: Loader, options: LoaderOptions) =>
343
+ parse(new TextEncoder().encode(JSON.stringify(document)), loader, options),
344
+ /** Decodes only the selected bounded payload; no sibling content can be requested. */
345
+ loadResource: async (_url: string, loader: Loader, options: LoaderOptions) => {
346
+ signal.throwIfAborted();
347
+ return parse(data, loader, options, {
348
+ url: contentUrl,
349
+ baseUrl: contentUrl.startsWith('blob:') ? contentUrl : new URL('.', contentUrl).href,
350
+ fetch: boundedFetcher,
351
+ coreApi,
352
+ _parse: parse
353
+ });
354
+ }
355
+ }
356
+ },
357
+ loadOptions,
358
+ inheritedSpatialReference
359
+ )
360
+ );
361
+ }
362
+
363
+ /** Charges geometry, encoded image bytes, triangle associations and Arrow columns after extraction. */
364
+ export function measureMeshBytes(resource: MeshSourceResource): number {
365
+ const featureBytes = resource.features
366
+ ? resource.features.triangleFeatureIndices.byteLength +
367
+ resource.features.batches.reduce(
368
+ (bytes, batch) =>
369
+ bytes +
370
+ batch.data.schema.fields.reduce(
371
+ (columnBytes, field) =>
372
+ columnBytes + (batch.data.getChild(field.name)?.byteLength ?? 0),
373
+ 0
374
+ ),
375
+ 0
376
+ )
377
+ : 0;
378
+ return Object.values(resource.mesh.attributes).reduce(
379
+ (bytes, attribute) => bytes + attribute.value.byteLength,
380
+ (resource.mesh.indices?.value.byteLength ?? 0) +
381
+ (resource.material?.baseColorTexture?.data.byteLength ?? 0) +
382
+ featureBytes
383
+ );
384
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  CreateTilesetSpatialReferenceOptions,
14
14
  I3SSpatialBounds,
15
15
  I3STransformedPositions,
16
+ I3STransformedGeometry,
16
17
  Tiles3DSpatialBoundingVolume,
17
18
  TilesetSpatialOptions,
18
19
  TilesetSpatialReference
@@ -44,6 +45,12 @@ export interface I3SConversionSpatialContext {
44
45
  positions: ArrayLike<number>,
45
46
  sourceOrigin: ArrayLike<number>
46
47
  ): I3STransformedPositions;
48
+ /** Transform absolute conversion geometry without intermediate float32 rounding. */
49
+ transformGeometryAsync(
50
+ positions: ArrayLike<number>,
51
+ normals?: ArrayLike<number>,
52
+ normalReferenceFrame?: string
53
+ ): Promise<I3STransformedGeometry>;
47
54
  /** Transform absolute I3S positions when terrain or scene sampling is asynchronous. */
48
55
  transformPositionsAsync(
49
56
  positions: ArrayLike<number>,
@@ -87,6 +94,13 @@ export function createTiles3DConversionSpatialContext(
87
94
  throw createSpatialConversionError(spatialReference);
88
95
  }
89
96
 
97
+ if (spatialReference.status !== 'native' && spatialReference.coordinateEpoch !== undefined) {
98
+ throw new TileConversionError(
99
+ 'SPATIAL_EPOCH_UNSUPPORTED',
100
+ 'Epoch-tagged coordinates require an epoch-aware datum operation before conversion'
101
+ );
102
+ }
103
+
90
104
  const transformer =
91
105
  spatialReference.status === 'native'
92
106
  ? undefined
@@ -101,9 +115,11 @@ export function createTiles3DConversionSpatialContext(
101
115
  return {
102
116
  spatialReference: outputSpatialReference,
103
117
  transformPositions: positions =>
104
- transformer ? transformer.transformPositions(positions) : Float64Array.from(positions),
118
+ transformer ? transformer.transformPositions(positions) : copyNativePositions(positions),
105
119
  transformNormals: (normals, positions) =>
106
- transformer ? transformer.transformNormals(normals, positions) : Float32Array.from(normals),
120
+ transformer
121
+ ? transformer.transformNormals(normals, positions)
122
+ : copyNativeNormals(normals, positions),
107
123
  transformBoundingVolume: (volume, sourceTransform) =>
108
124
  transformer
109
125
  ? transformer.transformBoundingVolume(volume, sourceTransform)
@@ -131,6 +147,13 @@ export function createI3SConversionSpatialContext(
131
147
  throw createSpatialConversionError(spatialReference);
132
148
  }
133
149
 
150
+ if (spatialReference.status !== 'native' && spatialReference.coordinateEpoch !== undefined) {
151
+ throw new TileConversionError(
152
+ 'SPATIAL_EPOCH_UNSUPPORTED',
153
+ 'Epoch-tagged coordinates require an epoch-aware datum operation before conversion'
154
+ );
155
+ }
156
+
134
157
  const transformer =
135
158
  spatialReference.status === 'native'
136
159
  ? undefined
@@ -139,6 +162,36 @@ export function createI3SConversionSpatialContext(
139
162
  () => new I3SSpatialTransformer(spatialReference, options)
140
163
  );
141
164
  const outputSpatialReference = transformer ? transformer.spatialReference : spatialReference;
165
+ // I3S vector bases can differ from the coordinate basis even when positions retain their CRS.
166
+ let nativeNormalTransformer: I3SSpatialTransformer | undefined;
167
+ /** Maps native I3S vectors into the writer basis without reprojecting its positions. */
168
+ const transformNativeNormals = (
169
+ normals: ArrayLike<number>,
170
+ positions: ArrayLike<number>,
171
+ normalReferenceFrame?: string
172
+ ): Float32Array => {
173
+ validateNormalReferenceFrame(normalReferenceFrame);
174
+ const targetNormals = copyNativeNormals(normals, positions);
175
+ if (
176
+ !spatialReference.sourceCrs ||
177
+ ['local', 'cartesian'].includes(spatialReference.coordinateFrame)
178
+ ) {
179
+ return targetNormals;
180
+ }
181
+ nativeNormalTransformer ||= createSpatialTransformer(
182
+ spatialReference,
183
+ () =>
184
+ new I3SSpatialTransformer(
185
+ {
186
+ ...spatialReference,
187
+ status: 'transformable',
188
+ targetCrs: spatialReference.sourceCrs
189
+ },
190
+ options
191
+ )
192
+ );
193
+ return nativeNormalTransformer.transformNormals(targetNormals, positions, normalReferenceFrame);
194
+ };
142
195
 
143
196
  return {
144
197
  spatialReference: outputSpatialReference,
@@ -146,6 +199,31 @@ export function createI3SConversionSpatialContext(
146
199
  transformer
147
200
  ? transformer.transformPositions(positions, sourceOrigin)
148
201
  : createNativeI3SPositions(positions, sourceOrigin, spatialReference),
202
+ transformGeometryAsync: async (positions, normals, normalReferenceFrame) => {
203
+ if (transformer)
204
+ return transformer.transformGeometryAsync(positions, normals, normalReferenceFrame);
205
+ const targetPositions = Float64Array.from(positions);
206
+ if (
207
+ targetPositions.length % 3 ||
208
+ targetPositions.some(value => !Number.isFinite(value)) ||
209
+ (normals &&
210
+ (normals.length !== positions.length ||
211
+ Array.from(normals).some(value => !Number.isFinite(value)))) ||
212
+ (normalReferenceFrame !== undefined &&
213
+ !['earth-centered', 'vertex-reference-frame'].includes(normalReferenceFrame))
214
+ ) {
215
+ throw new TileConversionError(
216
+ 'SPATIAL_INPUT_INVALID',
217
+ 'Conversion geometry requires finite matching xyz arrays and a supported I3S vector frame'
218
+ );
219
+ }
220
+ return {
221
+ positions: targetPositions,
222
+ normals: normals
223
+ ? transformNativeNormals(normals, positions, normalReferenceFrame)
224
+ : undefined
225
+ };
226
+ },
149
227
  transformPositionsAsync: (positions, sourceOrigin) =>
150
228
  transformer
151
229
  ? transformer.transformPositionsAsync(positions, sourceOrigin)
@@ -153,7 +231,7 @@ export function createI3SConversionSpatialContext(
153
231
  transformNormals: (normals, positions, normalReferenceFrame) =>
154
232
  transformer
155
233
  ? transformer.transformNormals(normals, positions, normalReferenceFrame)
156
- : Float32Array.from(normals),
234
+ : transformNativeNormals(normals, positions, normalReferenceFrame),
157
235
  transformBoundingVolume: bounds =>
158
236
  transformer ? transformer.transformBoundingVolume(bounds) : cloneI3SBounds(bounds),
159
237
  transformBoundingVolumeAsync: bounds =>
@@ -277,21 +355,33 @@ function createNativeI3SPositions(
277
355
  sourceOrigin: ArrayLike<number>,
278
356
  spatialReference: TilesetSpatialReference
279
357
  ): I3STransformedPositions {
280
- if (sourcePositions.length % 3 !== 0 || sourceOrigin.length < 3) {
281
- throw new Error('I3S positions and origin must contain complete xyz coordinates');
358
+ const sourcePositions64 = copyNativePositions(sourcePositions);
359
+ if (
360
+ sourceOrigin.length !== 3 ||
361
+ Array.from(sourceOrigin).some(value => !Number.isFinite(value))
362
+ ) {
363
+ throw new TileConversionError(
364
+ 'SPATIAL_INPUT_INVALID',
365
+ 'I3S origins require three finite coordinates'
366
+ );
282
367
  }
283
368
  const origin: [number, number, number] = [
284
369
  Number(sourceOrigin[0]),
285
370
  Number(sourceOrigin[1]),
286
371
  Number(sourceOrigin[2])
287
372
  ];
288
- const sourcePositions64 = Float64Array.from(sourcePositions);
289
373
  const positions = new Float32Array(sourcePositions.length);
290
374
  for (let index = 0; index < sourcePositions.length; index += 3) {
291
375
  positions[index] = Number(sourcePositions[index]) - origin[0];
292
376
  positions[index + 1] = Number(sourcePositions[index + 1]) - origin[1];
293
377
  positions[index + 2] = Number(sourcePositions[index + 2]) - origin[2];
294
378
  }
379
+ if (positions.some(value => !Number.isFinite(value))) {
380
+ throw new TileConversionError(
381
+ 'SPATIAL_INPUT_INVALID',
382
+ 'Native I3S offsets exceed float32 range'
383
+ );
384
+ }
295
385
  const isGeographic = spatialReference.coordinateFrame === 'geographic';
296
386
  const modelMatrix = isGeographic ? new Matrix4() : new Matrix4().translate(origin);
297
387
  return {
@@ -303,3 +393,68 @@ function createNativeI3SPositions(
303
393
  modelMatrix
304
394
  };
305
395
  }
396
+
397
+ /** Validates and copies native xyz coordinates without invoking a CRS transformer. */
398
+ function copyNativePositions(positions: ArrayLike<number>): Float64Array {
399
+ validateNativePositions(positions);
400
+ return Float64Array.from(positions);
401
+ }
402
+
403
+ /** Checks native coordinates without allocating an additional position buffer. */
404
+ function validateNativePositions(positions: ArrayLike<number>): void {
405
+ if (positions.length % 3) {
406
+ throw new TileConversionError(
407
+ 'SPATIAL_INPUT_INVALID',
408
+ 'Positions require complete finite xyz coordinates'
409
+ );
410
+ }
411
+ for (let index = 0; index < positions.length; index++) {
412
+ if (!Number.isFinite(positions[index])) {
413
+ throw new TileConversionError(
414
+ 'SPATIAL_INPUT_INVALID',
415
+ 'Positions require complete finite xyz coordinates'
416
+ );
417
+ }
418
+ }
419
+ }
420
+
421
+ /** Validates matching native normals and normalizes them without changing their basis. */
422
+ function copyNativeNormals(normals: ArrayLike<number>, positions: ArrayLike<number>): Float32Array {
423
+ validateNativePositions(positions);
424
+ if (normals.length !== positions.length) {
425
+ throw new TileConversionError(
426
+ 'SPATIAL_INPUT_INVALID',
427
+ 'Normals require finite xyz values matching positions'
428
+ );
429
+ }
430
+ const output = new Float32Array(normals.length);
431
+ for (let index = 0; index < normals.length; index += 3) {
432
+ const scale = Math.max(
433
+ Math.abs(normals[index]),
434
+ Math.abs(normals[index + 1]),
435
+ Math.abs(normals[index + 2])
436
+ );
437
+ if (!Number.isFinite(scale)) {
438
+ throw new TileConversionError(
439
+ 'SPATIAL_INPUT_INVALID',
440
+ 'Normals require finite xyz values matching positions'
441
+ );
442
+ }
443
+ const length = scale
444
+ ? Math.hypot(normals[index] / scale, normals[index + 1] / scale, normals[index + 2] / scale)
445
+ : 0;
446
+ for (let axis = 0; axis < 3; axis++)
447
+ output[index + axis] = length ? normals[index + axis] / scale / length : 0;
448
+ }
449
+ return output;
450
+ }
451
+
452
+ /** Rejects unknown I3S vector frames even when no position reprojection is requested. */
453
+ function validateNormalReferenceFrame(frame?: string): void {
454
+ if (frame !== undefined && !['earth-centered', 'vertex-reference-frame'].includes(frame)) {
455
+ throw new TileConversionError(
456
+ 'SPATIAL_INPUT_INVALID',
457
+ 'Unsupported I3S normal reference frame'
458
+ );
459
+ }
460
+ }