@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,280 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {Matrix4} from '@math.gl/core';
6
+ import {getSpatialCoordinateFrame, createTilesetSpatialReference} from '@loaders.gl/tiles';
7
+ import {convertTableToMesh, makeMeshArrowTable} from '@loaders.gl/schema-utils';
8
+ import {TileConversionError} from '@loaders.gl/tile-converter/v5/core';
9
+ import type {
10
+ I3SConversionSpatialContext,
11
+ PointCloudSourceTile
12
+ } from '@loaders.gl/tile-converter/v5/core';
13
+ import {decodePointCloudPositions} from './point-cloud.js';
14
+
15
+ /** Explicit spatial preparation for source point positions before PNTS encoding. */
16
+ export interface PointCloudSpatialOptions {
17
+ /** Context for the decoded content's source CRS, units and elevation placement. */
18
+ readonly spatialContext: I3SConversionSpatialContext;
19
+ /** Required for point normals; prevents guessing their I3S vector basis. */
20
+ readonly normalReferenceFrame?: 'earth-centered' | 'vertex-reference-frame';
21
+ /** Cancellation checked before and after application-owned elevation sampling. */
22
+ readonly signal?: AbortSignal;
23
+ }
24
+
25
+ /**
26
+ * Resolves renderer placement and transforms one point batch to absolute target coordinates.
27
+ * Cartesian affine placement and longitude/latitude offsets are applied before the shared CRS
28
+ * operation. Ambiguous default/meter-offset representations and repeated source transforms fail
29
+ * explicitly. The caller declares source metadata through the context; metadata on content must
30
+ * agree. Source arrays and traversal headers remain unchanged. Output bounds describe decoded
31
+ * points, not the source LOD volume; archive sinks rebuild their bounds from encoded points.
32
+ * @param sourceTile - A decoded source tile with packed Arrow positions.
33
+ * @param options - Source spatial policy, optional normal frame and cancellation.
34
+ * @returns Prepared target-frame point content, or the unchanged empty tile.
35
+ */
36
+ export async function transformPointCloudSourceTile(
37
+ sourceTile: PointCloudSourceTile,
38
+ options: PointCloudSpatialOptions
39
+ ): Promise<PointCloudSourceTile> {
40
+ options.signal?.throwIfAborted();
41
+ const {content} = sourceTile;
42
+ if (!content) return sourceTile;
43
+ const reference = options.spatialContext.spatialReference;
44
+ const contentReference = content.spatialReference;
45
+ // Already transformed ECEF sources can still expose renderer-relative positions. A native
46
+ // context for their effective frame resolves placement without repeating the source operation.
47
+ const retainTransformedEcef =
48
+ contentReference?.status === 'transformed' &&
49
+ (contentReference.targetCrs || contentReference.sourceCrs) === 'EPSG:4978' &&
50
+ reference.status === 'native' &&
51
+ reference.sourceCrs === 'EPSG:4978' &&
52
+ reference.verticalUnitScale === 1 &&
53
+ (reference.units?.every(unit => unit === 'meter') ?? true);
54
+ const sourceReference = retainTransformedEcef
55
+ ? createTilesetSpatialReference({
56
+ sourceCrs: 'EPSG:4978',
57
+ coordinateFrame: 'geocentric',
58
+ axisOrder: 'xyz',
59
+ units: reference.units,
60
+ coordinateEpoch: contentReference.coordinateEpoch,
61
+ heightReference:
62
+ contentReference.targetHeightReference === 'native'
63
+ ? contentReference.heightReference
64
+ : contentReference.targetHeightReference
65
+ })
66
+ : contentReference;
67
+ if (
68
+ sourceReference &&
69
+ (sourceReference.status === 'transformed' ||
70
+ sourceReference.status === 'unresolved' ||
71
+ JSON.stringify(sourceReference.sourceCrs) !== JSON.stringify(reference.sourceCrs) ||
72
+ sourceReference.heightReference !== reference.heightReference ||
73
+ sourceReference.verticalUnitScale !== reference.verticalUnitScale ||
74
+ sourceReference.coordinateEpoch !== reference.coordinateEpoch ||
75
+ JSON.stringify(sourceReference.units) !== JSON.stringify(reference.units) ||
76
+ sourceReference.elevationMode !== reference.elevationMode ||
77
+ sourceReference.elevationOffset !== reference.elevationOffset ||
78
+ sourceReference.elevationUnitScale !== reference.elevationUnitScale)
79
+ ) {
80
+ throw new TileConversionError(
81
+ 'POINT_CLOUD_SPATIAL_REFERENCE_MISMATCH',
82
+ 'Point content and context must agree on the input CRS, units, height reference and epoch'
83
+ );
84
+ }
85
+ const sourceFrame =
86
+ reference.coordinateFrame === 'unknown' && reference.sourceCrs
87
+ ? getSpatialCoordinateFrame(reference.sourceCrs)
88
+ : reference.coordinateFrame;
89
+ const geographic = sourceFrame === 'geographic';
90
+ if (
91
+ !['xy', 'xyz', 'unknown'].includes(reference.axisOrder) ||
92
+ (geographic
93
+ ? !['lnglat', 'lnglat-offsets'].includes(content.coordinateSystem)
94
+ : content.coordinateSystem !== 'cartesian' ||
95
+ !['projected', 'geocentric', 'cartesian', 'local'].includes(sourceFrame))
96
+ ) {
97
+ throw new TileConversionError(
98
+ 'POINT_CLOUD_SOURCE_FRAME_UNSUPPORTED',
99
+ 'Spatial preparation requires explicit Cartesian or longitude/latitude positions in source xyz order'
100
+ );
101
+ }
102
+ if (
103
+ content.cartographicOrigin.length !== 3 ||
104
+ content.cartographicOrigin.some(value => !Number.isFinite(value)) ||
105
+ (content.coordinateSystem !== 'lnglat-offsets' &&
106
+ content.cartographicOrigin.some(value => value !== 0))
107
+ ) {
108
+ throw new TileConversionError(
109
+ 'POINT_CLOUD_SOURCE_ORIGIN_INVALID',
110
+ 'Only longitude/latitude offsets use a nonzero cartographic origin'
111
+ );
112
+ }
113
+ if (
114
+ content.modelMatrix &&
115
+ (content.modelMatrix.length !== 16 ||
116
+ Array.from(content.modelMatrix).some(value => !Number.isFinite(value)))
117
+ ) {
118
+ throw new TileConversionError(
119
+ 'POINT_CLOUD_SOURCE_PLACEMENT_UNSUPPORTED',
120
+ 'Point placement must contain sixteen finite matrix values'
121
+ );
122
+ }
123
+ const matrix = new Matrix4(content.modelMatrix ? Array.from(content.modelMatrix) : undefined);
124
+ const determinant = matrix.determinant();
125
+ if (
126
+ matrix[3] !== 0 ||
127
+ matrix[7] !== 0 ||
128
+ matrix[11] !== 0 ||
129
+ matrix[15] !== 1 ||
130
+ !Number.isFinite(determinant) ||
131
+ determinant === 0 ||
132
+ (geographic && !matrix.equals(new Matrix4()))
133
+ ) {
134
+ throw new TileConversionError(
135
+ 'POINT_CLOUD_SOURCE_PLACEMENT_UNSUPPORTED',
136
+ 'Cartesian placement must be finite, affine and invertible; geographic placement must be identity'
137
+ );
138
+ }
139
+ const mesh = convertTableToMesh(content.data);
140
+ const positionAttribute = mesh.attributes.POSITION;
141
+ const declaredTransform = mesh.schema?.fields.find(field => field.name === 'POSITION')
142
+ ?.metadata?.['loaders.gl.transform'];
143
+ if (declaredTransform && !positionAttribute?.transform) {
144
+ throw new TileConversionError(
145
+ 'POINT_CLOUD_POSITION_TRANSFORM_INVALID',
146
+ 'Arrow position transform metadata is unsupported or invalid'
147
+ );
148
+ }
149
+ const sourcePositions = decodePointCloudPositions(positionAttribute);
150
+ if (sourcePositions.some(value => !Number.isFinite(value))) {
151
+ throw new TileConversionError(
152
+ 'POINT_CLOUD_POSITION_INVALID',
153
+ 'Decoded point positions must be finite'
154
+ );
155
+ }
156
+ const translationOnly = Array.from(matrix).every(
157
+ (value, index) => (index >= 12 && index <= 14) || value === (index % 5 === 0 ? 1 : 0)
158
+ );
159
+ for (let index = 0; index < sourcePositions.length; index += 3) {
160
+ if (geographic || translationOnly) {
161
+ for (let axis = 0; axis < 3; axis++) {
162
+ sourcePositions[index + axis] += geographic
163
+ ? content.cartographicOrigin[axis]
164
+ : matrix[12 + axis];
165
+ }
166
+ } else {
167
+ sourcePositions.set(
168
+ matrix.transformAsPoint(Array.from(sourcePositions.subarray(index, index + 3))),
169
+ index
170
+ );
171
+ }
172
+ }
173
+ const normalAttribute = mesh.attributes.NORMAL;
174
+ let normals: Float32Array | undefined;
175
+ if (normalAttribute) {
176
+ if (
177
+ !options.normalReferenceFrame ||
178
+ normalAttribute.size !== 3 ||
179
+ normalAttribute.value.length !== sourcePositions.length ||
180
+ normalAttribute.transform ||
181
+ normalAttribute.normalized ||
182
+ normalAttribute.byteOffset ||
183
+ normalAttribute.byteStride ||
184
+ normalAttribute.componentType ||
185
+ !translationOnly
186
+ ) {
187
+ throw new TileConversionError(
188
+ 'POINT_CLOUD_NORMAL_FRAME_REQUIRED',
189
+ 'Spatial point normals require a declared vector frame and packed xyz values'
190
+ );
191
+ }
192
+ normals = new Float32Array(normalAttribute.value.length);
193
+ for (let index = 0; index < normals.length; index += 3) {
194
+ const normal = Array.from(normalAttribute.value.slice(index, index + 3));
195
+ const length = Math.hypot(...normal);
196
+ if (!Number.isFinite(length) || length === 0) {
197
+ throw new TileConversionError(
198
+ 'POINT_CLOUD_NORMAL_INVALID',
199
+ 'Spatial point normals must be finite and nonzero'
200
+ );
201
+ }
202
+ normals.set(
203
+ normal.map(value => value / length),
204
+ index
205
+ );
206
+ }
207
+ }
208
+ const transformed = await options.spatialContext.transformGeometryAsync(
209
+ sourcePositions,
210
+ normals,
211
+ options.normalReferenceFrame
212
+ );
213
+ options.signal?.throwIfAborted();
214
+ if (transformed.positions.length !== sourcePositions.length) {
215
+ throw new TileConversionError(
216
+ 'POINT_CLOUD_POSITION_INVALID',
217
+ 'Spatial conversion must return one xyz triple per point'
218
+ );
219
+ }
220
+ const minimum = [Infinity, Infinity, Infinity];
221
+ const maximum = [-Infinity, -Infinity, -Infinity];
222
+ for (let index = 0; index < transformed.positions.length; index++) {
223
+ const value = transformed.positions[index];
224
+ if (!Number.isFinite(value)) {
225
+ throw new TileConversionError(
226
+ 'POINT_CLOUD_POSITION_INVALID',
227
+ 'Transformed point positions must be finite'
228
+ );
229
+ }
230
+ const axis = index % 3;
231
+ minimum[axis] = Math.min(minimum[axis], value);
232
+ maximum[axis] = Math.max(maximum[axis], value);
233
+ }
234
+ const center = minimum.map((value, axis) => value / 2 + maximum[axis] / 2);
235
+ const targetFrame =
236
+ reference.status === 'native'
237
+ ? sourceFrame
238
+ : getSpatialCoordinateFrame(reference.targetCrs || reference.sourceCrs!);
239
+ const targetIsGeographic = targetFrame === 'geographic';
240
+ const attributes = {
241
+ ...mesh.attributes,
242
+ POSITION: {value: transformed.positions, size: 3},
243
+ ...(transformed.normals ? {NORMAL: {value: transformed.normals, size: 3}} : {})
244
+ };
245
+ return {
246
+ header: sourceTile.header,
247
+ content: {
248
+ ...content,
249
+ data: makeMeshArrowTable(attributes, {
250
+ topology: 'point-list',
251
+ boundingBox: [minimum, maximum],
252
+ schema: mesh.schema
253
+ ? {
254
+ ...mesh.schema,
255
+ metadata: {
256
+ ...mesh.schema.metadata,
257
+ topology: 'point-list',
258
+ boundingBox: JSON.stringify([minimum, maximum])
259
+ },
260
+ fields: mesh.schema.fields.map(field =>
261
+ field.name === 'POSITION' || (transformed.normals && field.name === 'NORMAL')
262
+ ? {...field, metadata: {}}
263
+ : field
264
+ )
265
+ }
266
+ : undefined
267
+ }),
268
+ coordinateSystem: targetIsGeographic ? 'lnglat' : 'cartesian',
269
+ cartographicOrigin: [0, 0, 0],
270
+ modelMatrix: undefined,
271
+ spatialReference: reference,
272
+ spatialBoundingVolume: {
273
+ cartographicBounds: [minimum, maximum],
274
+ coordinateFrame: targetIsGeographic ? 'geographic' : 'cartesian',
275
+ center,
276
+ radius: Math.hypot(...maximum.map((value, axis) => value - center[axis]))
277
+ }
278
+ }
279
+ };
280
+ }
@@ -0,0 +1,234 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {
6
+ createBoundedMemoryTileConversionSink,
7
+ TileConversionError
8
+ } from '@loaders.gl/tile-converter/v5/core';
9
+ import type {
10
+ BrowserTileConversionFile,
11
+ TileConversionSink
12
+ } from '@loaders.gl/tile-converter/v5/core';
13
+ import type {EncodedPointCloudSourceTile} from './point-cloud-source-encoder.js';
14
+
15
+ /** Output limits and geometric error for independent ECEF point tiles. */
16
+ export interface PointCloudTilesetSinkOptions {
17
+ /** Maximum retained PNTS and tileset JSON bytes, excluding peak serialization memory. */
18
+ readonly maxTotalBytes: number;
19
+ /** Maximum independent tile placements retained by the sink. */
20
+ readonly maxTiles: number;
21
+ /** Leaf error in meters, including source sampling error and measured position rounding. */
22
+ readonly geometricError: number;
23
+ }
24
+
25
+ /** Atomic flat 3D Tiles package suitable for the existing 3TZ archive adapter. */
26
+ export interface PointCloudTilesetSink extends TileConversionSink<EncodedPointCloudSourceTile> {
27
+ /** Returns named PNTS files and tileset.json after successful finalization; empty after abort. */
28
+ getFiles(): readonly BrowserTileConversionFile[];
29
+ }
30
+
31
+ /**
32
+ * Packages independent point tiles as a bounded 3D Tiles 1.0 collection with an ADD root.
33
+ * Requires unmodified encoder outputs in declared EPSG:4978 xyz meters and ellipsoidal heights,
34
+ * cartesian positions, zero source origin and no additional placement transform. PNTS RTC_CENTER
35
+ * is already encoded and is applied once to bounds. Geographic/projected/offset source frames
36
+ * must be converted before this sink. This authors a flat partial dataset, not source LOD;
37
+ * callers must select independent, non-overlapping samples and supply their sampling error.
38
+ * Use createTileConversionArchive with format 3tz to package the finalized files.
39
+ * @param options - Retained byte/tile limits and explicit leaf geometric error.
40
+ * @returns A sink exposing the completed package atomically.
41
+ */
42
+ export function createPointCloudTilesetSink(
43
+ options: PointCloudTilesetSinkOptions
44
+ ): PointCloudTilesetSink {
45
+ const {maxTiles, geometricError} = options;
46
+ if (
47
+ !Number.isSafeInteger(maxTiles) ||
48
+ maxTiles < 1 ||
49
+ !Number.isFinite(geometricError) ||
50
+ geometricError < 0
51
+ ) {
52
+ throw new TileConversionError(
53
+ 'POINT_CLOUD_TILESET_OPTIONS_INVALID',
54
+ 'Positive maxTiles and finite nonnegative geometricError are required'
55
+ );
56
+ }
57
+ const memory = createBoundedMemoryTileConversionSink(options);
58
+ const children: {
59
+ boundingVolume: {box: number[]};
60
+ geometricError: number;
61
+ content: {uri: string};
62
+ }[] = [];
63
+ const identifiers = new Set<string>();
64
+ const minimum = [Infinity, Infinity, Infinity];
65
+ const maximum = [-Infinity, -Infinity, -Infinity];
66
+ let state: 'open' | 'writing' | 'closed' = 'open';
67
+ let completed = false;
68
+
69
+ return {
70
+ /** Exposes finalized files without exposing a partial package. */
71
+ getFiles: () => (completed ? memory.getFiles() : []),
72
+ /** Captures one validated placement and encoded PNTS resource. */
73
+ async write(resource, signal) {
74
+ signal?.throwIfAborted();
75
+ if (state !== 'open')
76
+ throw new TileConversionError(
77
+ 'POINT_CLOUD_TILESET_SINK_UNAVAILABLE',
78
+ 'Point sink is busy or closed'
79
+ );
80
+ if (!resource.id || identifiers.has(resource.id) || children.length >= maxTiles) {
81
+ throw new TileConversionError(
82
+ 'POINT_CLOUD_TILESET_PLACEMENT_INVALID',
83
+ 'Placements require unique nonempty identifiers within maxTiles'
84
+ );
85
+ }
86
+ const [lower, upper] = getPointTileBounds(resource, geometricError);
87
+ const resourceId = `points/${children.length}.pnts`;
88
+ state = 'writing';
89
+ await memory.write(
90
+ {resourceId, parts: [resource.pnts], contentType: 'application/octet-stream'},
91
+ signal
92
+ );
93
+ if (state !== 'writing')
94
+ throw new TileConversionError(
95
+ 'POINT_CLOUD_TILESET_SINK_UNAVAILABLE',
96
+ 'Point sink was aborted during writing'
97
+ );
98
+ children.push({
99
+ boundingVolume: {box: createPointBoundsBox(lower, upper)},
100
+ geometricError,
101
+ content: {uri: resourceId}
102
+ });
103
+ identifiers.add(resource.id);
104
+ for (let axis = 0; axis < 3; axis++) {
105
+ minimum[axis] = Math.min(minimum[axis], lower[axis]);
106
+ maximum[axis] = Math.max(maximum[axis], upper[axis]);
107
+ }
108
+ state = 'open';
109
+ },
110
+ /** Serializes the enclosing root after all placements have completed. */
111
+ async finalize(report) {
112
+ if (state !== 'open' || !children.length)
113
+ throw new TileConversionError(
114
+ 'POINT_CLOUD_TILESET_SINK_INCOMPLETE',
115
+ 'Finalization requires successfully written point tiles'
116
+ );
117
+ state = 'writing';
118
+ const rootError = Math.max(
119
+ geometricError,
120
+ Math.hypot(...minimum.map((value, axis) => maximum[axis] - value))
121
+ );
122
+ if (!Number.isFinite(rootError))
123
+ throw new TileConversionError(
124
+ 'POINT_CLOUD_TILESET_BOUNDS_INVALID',
125
+ 'Enclosing bounds must have a finite diagonal'
126
+ );
127
+ const json = JSON.stringify({
128
+ asset: {version: '1.0'},
129
+ geometricError: rootError,
130
+ root: {
131
+ boundingVolume: {box: createPointBoundsBox(minimum, maximum)},
132
+ geometricError: rootError,
133
+ refine: 'ADD',
134
+ children
135
+ }
136
+ });
137
+ await memory.write({
138
+ resourceId: 'tileset.json',
139
+ parts: [json],
140
+ contentType: 'application/json'
141
+ });
142
+ if (state !== 'writing')
143
+ throw new TileConversionError(
144
+ 'POINT_CLOUD_TILESET_SINK_UNAVAILABLE',
145
+ 'Point sink was aborted during finalization'
146
+ );
147
+ await memory.finalize(report);
148
+ if (state !== 'writing')
149
+ throw new TileConversionError(
150
+ 'POINT_CLOUD_TILESET_SINK_UNAVAILABLE',
151
+ 'Point sink was aborted during finalization'
152
+ );
153
+ state = 'closed';
154
+ completed = true;
155
+ },
156
+ /** Clears resource and hierarchy storage on failure or cancellation. */
157
+ async abort(error) {
158
+ state = 'closed';
159
+ completed = false;
160
+ children.length = 0;
161
+ identifiers.clear();
162
+ await memory.abort(error);
163
+ }
164
+ };
165
+ }
166
+
167
+ /** Validates the declared frame and returns bounds reconstructed from trusted encoded positions. */
168
+ function getPointTileBounds(resource: EncodedPointCloudSourceTile, geometricError: number) {
169
+ const reference = resource.spatialReference;
170
+ const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
171
+ if (
172
+ !reference ||
173
+ !['native', 'transformed'].includes(reference.status) ||
174
+ (reference.targetCrs || reference.sourceCrs) !== 'EPSG:4978' ||
175
+ (reference.targetHeightReference === 'native'
176
+ ? reference.heightReference
177
+ : reference.targetHeightReference) !== 'ellipsoidal' ||
178
+ resource.coordinateSystem !== 'cartesian' ||
179
+ resource.cartographicOrigin.length !== 3 ||
180
+ resource.cartographicOrigin.some(value => value !== 0) ||
181
+ (resource.modelMatrix &&
182
+ (resource.modelMatrix.length !== 16 ||
183
+ resource.modelMatrix.some((value, index) => value !== identity[index]))) ||
184
+ (reference.status === 'native' &&
185
+ (reference.coordinateFrame !== 'geocentric' ||
186
+ reference.axisOrder !== 'xyz' ||
187
+ reference.verticalUnitScale !== 1 ||
188
+ reference.units?.some(unit => unit !== 'meter')))
189
+ ) {
190
+ throw new TileConversionError(
191
+ 'POINT_CLOUD_TILESET_FRAME_UNSUPPORTED',
192
+ 'Point tiles require declared EPSG:4978 xyz meters, ellipsoidal heights and no additional source placement'
193
+ );
194
+ }
195
+ if (
196
+ !Number.isFinite(resource.maximumPositionError) ||
197
+ resource.maximumPositionError < 0 ||
198
+ resource.maximumPositionError > geometricError
199
+ ) {
200
+ throw new TileConversionError(
201
+ 'POINT_CLOUD_GEOMETRIC_ERROR_INVALID',
202
+ 'geometricError must include measured position reconstruction error'
203
+ );
204
+ }
205
+ const [minimum, maximum] = resource.localBoundingBox;
206
+ const center = resource.rtcCenter;
207
+ if (
208
+ center.length !== 3 ||
209
+ minimum.length !== 3 ||
210
+ maximum.length !== 3 ||
211
+ [...center, ...minimum, ...maximum].some(value => !Number.isFinite(value)) ||
212
+ minimum.some((value, axis) => value > maximum[axis])
213
+ ) {
214
+ throw new TileConversionError(
215
+ 'POINT_CLOUD_TILESET_BOUNDS_INVALID',
216
+ 'RTC center and encoded xyz bounds must be finite and ordered'
217
+ );
218
+ }
219
+ const lower = minimum.map((value, axis) => value + center[axis]);
220
+ const upper = maximum.map((value, axis) => value + center[axis]);
221
+ if ([...lower, ...upper].some(value => !Number.isFinite(value)))
222
+ throw new TileConversionError(
223
+ 'POINT_CLOUD_TILESET_BOUNDS_INVALID',
224
+ 'Reconstructed ECEF bounds must be finite'
225
+ );
226
+ return [lower, upper] as const;
227
+ }
228
+
229
+ /** Creates an axis-aligned 3D Tiles box in the PNTS position frame without glTF axis rotation. */
230
+ function createPointBoundsBox(minimum: readonly number[], maximum: readonly number[]): number[] {
231
+ const center = minimum.map((value, axis) => value / 2 + maximum[axis] / 2);
232
+ const halfSize = minimum.map((value, axis) => maximum[axis] / 2 - value / 2);
233
+ return [center[0], center[1], center[2], halfSize[0], 0, 0, 0, halfSize[1], 0, 0, 0, halfSize[2]];
234
+ }
@@ -0,0 +1,137 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import type {Mesh, MeshArrowTable} from '@loaders.gl/schema';
6
+ import type {PointCloudTilerOptions} from '@loaders.gl/schema-utils';
7
+ import type {TilesetSpatialReference} from '@loaders.gl/tiles';
8
+ import {PointCloudTileSource} from '@loaders.gl/tiles/point-cloud-tile-source';
9
+ import {TileConversionError} from '@loaders.gl/tile-converter/v5/core';
10
+ import type {
11
+ BrowserTileConversionFile,
12
+ TileConversionProgress,
13
+ TileConversionReport
14
+ } from '@loaders.gl/tile-converter/v5/core';
15
+ import {convertPointCloudSource} from './point-cloud-conversion.js';
16
+ import {createPointCloudTilesetSink} from './point-cloud-tileset-sink.js';
17
+ import type {PointCloudTilesetSinkOptions} from './point-cloud-tileset-sink.js';
18
+ import {createTileConversionArchive} from './conversion-archive.js';
19
+ import type {TileConversionArchiveOptions} from './conversion-archive.js';
20
+
21
+ /** Bounded authoring of a complete decoded ECEF point dataset. */
22
+ export interface ConvertPointCloudToTilesetOptions extends PointCloudTilesetSinkOptions {
23
+ /** Explicit native EPSG:4978 XYZ meters with ellipsoidal heights. No CRS is inferred. */
24
+ readonly spatialReference: TilesetSpatialReference;
25
+ /** In-memory input, index, gathered-tile and node limits; defaults belong to PointCloudTiler. */
26
+ readonly tiling?: Omit<PointCloudTilerOptions, 'signal'>;
27
+ /** Also create an indexed 3TZ Blob within this final archive-size budget. */
28
+ readonly archive?: Pick<TileConversionArchiveOptions, 'maxArchiveBytes'>;
29
+ /** Cooperative cancellation during indexing, traversal, encoding and packaging. */
30
+ readonly signal?: AbortSignal;
31
+ /** PNTS conversion progress; excludes tileset JSON and optional archive packaging. */
32
+ readonly onProgress?: (progress: TileConversionProgress) => void;
33
+ }
34
+
35
+ /** Completed point authoring result; no partial files are returned on failure. */
36
+ export interface ConvertedPointCloudTileset {
37
+ /** Exact number of input points, each emitted once across the complete collection. */
38
+ readonly pointCount: number;
39
+ /** PNTS resource counts and bytes; input bytes count gathered Arrow column buffers. */
40
+ readonly report: TileConversionReport;
41
+ /** PNTS resources and tileset.json, including JSON in the retained output byte budget. */
42
+ readonly files: readonly BrowserTileConversionFile[];
43
+ /** Optional indexed 3TZ Blob. Applications own saving and object URL lifetimes. */
44
+ readonly archive?: Blob;
45
+ }
46
+
47
+ /**
48
+ * Tiles decoded point Mesh/Arrow data and authors a flat additive 3D Tiles 1.0 collection.
49
+ * All input rows are emitted once, including remaining rows at the tiler's maximum depth.
50
+ * Per-tile RTC centers reduce float32 rounding; geometricError gates measured reconstruction error.
51
+ * The flat collection does not preserve octree LOD or provide a sampling-error guarantee.
52
+ * Input must remain immutable until completion. Arrow normalization can copy buffers; retained
53
+ * input, row indexes, gathered tiles, output Blobs and optional ZIP temporaries are separate memory.
54
+ * This is bounded in-memory authoring, not streaming ingestion or an out-of-core converter.
55
+ * Unsupported PNTS attributes fail; callers must explicitly map or remove them beforehand.
56
+ * @param input - One decoded unindexed point-list with finite floating-point XYZ positions.
57
+ * @param options - Explicit ECEF frame, precision and resource limits, and optional 3TZ packaging.
58
+ * @returns Completed files and report after releasing the privately owned dynamic source.
59
+ */
60
+ export async function convertPointCloudToTileset(
61
+ input: Mesh | MeshArrowTable,
62
+ options: ConvertPointCloudToTilesetOptions
63
+ ): Promise<ConvertedPointCloudTileset> {
64
+ const reference = options.spatialReference;
65
+ if (
66
+ !reference ||
67
+ reference.status !== 'native' ||
68
+ reference.sourceCrs !== 'EPSG:4978' ||
69
+ (reference.targetCrs && reference.targetCrs !== 'EPSG:4978') ||
70
+ reference.coordinateFrame !== 'geocentric' ||
71
+ reference.axisOrder !== 'xyz' ||
72
+ reference.heightReference !== 'ellipsoidal' ||
73
+ reference.verticalUnitScale !== 1 ||
74
+ reference.units?.some(unit => unit !== 'meter')
75
+ ) {
76
+ throw new TileConversionError(
77
+ 'POINT_CLOUD_TILESET_FRAME_UNSUPPORTED',
78
+ 'Point authoring requires explicit native EPSG:4978 xyz meters and ellipsoidal heights'
79
+ );
80
+ }
81
+ if (
82
+ options.archive &&
83
+ (!Number.isSafeInteger(options.archive.maxArchiveBytes) || options.archive.maxArchiveBytes < 0)
84
+ ) {
85
+ throw new TileConversionError(
86
+ 'INVALID_ARCHIVE_BYTE_LIMIT',
87
+ 'Archive byte limit must be a non-negative safe integer'
88
+ );
89
+ }
90
+ options.signal?.throwIfAborted();
91
+ const sink = createPointCloudTilesetSink(options);
92
+ let source: PointCloudTileSource | undefined;
93
+ try {
94
+ source = new PointCloudTileSource(input, {
95
+ pointCloudTiler: {...options.tiling, signal: options.signal, spatialReference: reference}
96
+ });
97
+ const {pointCount} = await source.getMetadata();
98
+ const report = await convertPointCloudSource(source, {
99
+ sink,
100
+ signal: options.signal,
101
+ onProgress: options.onProgress,
102
+ measureInputBytes: tile => {
103
+ const table = tile.content?.data.data;
104
+ return table
105
+ ? table.schema.fields.reduce(
106
+ (bytes, _field, index) => bytes + (table.getChildAt(index)?.byteLength ?? 0),
107
+ 0
108
+ )
109
+ : 0;
110
+ },
111
+ getTileEncodingOptions: tile => ({
112
+ rtcCenter: [
113
+ tile.header.boundingVolume.center[0],
114
+ tile.header.boundingVolume.center[1],
115
+ tile.header.boundingVolume.center[2]
116
+ ],
117
+ maxPositionError: options.geometricError
118
+ })
119
+ });
120
+ options.signal?.throwIfAborted();
121
+ const files = sink.getFiles();
122
+ const archive = options.archive
123
+ ? await createTileConversionArchive(files, {
124
+ format: '3tz',
125
+ maxArchiveBytes: options.archive.maxArchiveBytes,
126
+ signal: options.signal
127
+ })
128
+ : undefined;
129
+ options.signal?.throwIfAborted();
130
+ return {pointCount, report, files, archive};
131
+ } catch (error) {
132
+ await sink.abort(error);
133
+ throw error;
134
+ } finally {
135
+ source?.close();
136
+ }
137
+ }