@loaders.gl/tile-converter 5.0.0-alpha.11 → 5.0.0-alpha.14

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 (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +180 -4
  3. package/bin/converter-v5.js +2 -0
  4. package/dist/converter.min.cjs +172 -182
  5. package/dist/i3s-server/bin/i3s-server.min.cjs +113 -158
  6. package/dist/index.cjs +18832 -19228
  7. package/dist/index.js +19566 -19968
  8. package/dist/v5/adapters.cjs +2002 -230
  9. package/dist/v5/adapters.d.ts +20 -0
  10. package/dist/v5/adapters.js +2005 -214
  11. package/dist/v5/archive-source.d.ts +36 -0
  12. package/dist/v5/browser-tileset-source.d.ts +55 -11
  13. package/dist/v5/browser.cjs +18 -0
  14. package/dist/v5/browser.d.ts +15 -1
  15. package/dist/v5/browser.js +26 -0
  16. package/dist/v5/conversion-api.d.ts +34 -0
  17. package/dist/v5/converter-cli.d.ts +1 -0
  18. package/dist/v5/converter-cli.js +1230 -0
  19. package/dist/v5/core.cjs +547 -77
  20. package/dist/v5/core.d.ts +3 -1
  21. package/dist/v5/core.js +540 -70
  22. package/dist/v5/feature-arrow.d.ts +1 -1
  23. package/dist/v5/i3s-mesh-conversion.d.ts +5 -1
  24. package/dist/v5/i3s-mesh-source-features.d.ts +2 -2
  25. package/dist/v5/i3s-mesh-source-material.d.ts +1 -1
  26. package/dist/v5/i3s-mesh-source.d.ts +2 -2
  27. package/dist/v5/i3s-selection-source.d.ts +70 -0
  28. package/dist/v5/index.cjs +15 -0
  29. package/dist/v5/index.d.ts +13 -1
  30. package/dist/v5/index.js +22 -0
  31. package/dist/v5/mesh-conversion.d.ts +21 -3
  32. package/dist/v5/mesh-draco.d.ts +2 -2
  33. package/dist/v5/mesh-features.d.ts +2 -2
  34. package/dist/v5/mesh-hierarchy.d.ts +47 -0
  35. package/dist/v5/mesh-source-features.d.ts +6 -5
  36. package/dist/v5/mesh-source-schema.d.ts +12 -0
  37. package/dist/v5/mesh-source-texture.d.ts +3 -3
  38. package/dist/v5/mesh-source.d.ts +10 -7
  39. package/dist/v5/mesh-tileset-sink.d.ts +5 -0
  40. package/dist/v5/mesh.d.ts +1 -1
  41. package/dist/v5/node-cli.d.ts +9 -0
  42. package/dist/v5/node-conversion.d.ts +34 -0
  43. package/dist/v5/node-feature-schema.d.ts +3 -0
  44. package/dist/v5/node-i3s-source.d.ts +41 -0
  45. package/dist/v5/node-source.d.ts +47 -0
  46. package/dist/v5/node.cjs +913 -0
  47. package/dist/v5/node.d.ts +6 -0
  48. package/dist/v5/node.js +890 -0
  49. package/dist/v5/point-cloud-attributes.d.ts +19 -0
  50. package/dist/v5/point-cloud-conversion.d.ts +1 -1
  51. package/dist/v5/point-cloud-copc.d.ts +44 -0
  52. package/dist/v5/point-cloud-file.d.ts +83 -0
  53. package/dist/v5/point-cloud-potree.d.ts +47 -0
  54. package/dist/v5/point-cloud-source-encoder.d.ts +1 -1
  55. package/dist/v5/point-cloud-source.d.ts +1 -1
  56. package/dist/v5/point-cloud-tileset.d.ts +44 -0
  57. package/dist/v5/point-cloud.d.ts +1 -1
  58. package/dist/v5/resource-manifest.d.ts +10 -1
  59. package/dist/v5/selected-mesh-source.d.ts +53 -0
  60. package/dist/v5/spatial-conversion.d.ts +1 -1
  61. package/dist/v5/tileset-source.d.ts +1 -1
  62. package/package.json +25 -32
  63. package/src/index.ts +4 -1
  64. package/src/v4/3d-tiles-converter/3d-tiles-converter.ts +11 -12
  65. package/src/v4/3d-tiles-converter/helpers/3d-tiles-content-converter.ts +7 -3
  66. package/src/v4/3d-tiles-converter/helpers/i3s-obb-to-3d-tiles-obb.ts +2 -2
  67. package/src/v4/3d-tiles-converter/helpers/load-i3s.ts +4 -6
  68. package/src/v4/3d-tiles-converter/helpers/texture-atlas.ts +1 -1
  69. package/src/v4/3d-tiles-converter/helpers/validate-i3s-spatial-reference.ts +2 -2
  70. package/src/v4/3d-tiles-converter/json-templates/tileset.ts +58 -39
  71. package/src/v4/converter-cli.ts +10 -4
  72. package/src/v4/deps-installer/deps-installer.ts +3 -3
  73. package/src/v4/i3s-converter/helpers/attribute-metadata-info.ts +1 -1
  74. package/src/v4/i3s-converter/helpers/batch-ids-extensions.ts +9 -10
  75. package/src/v4/i3s-converter/helpers/conversion-gltf.ts +108 -0
  76. package/src/v4/i3s-converter/helpers/coordinate-converter.ts +6 -5
  77. package/src/v4/i3s-converter/helpers/create-scene-server-path.ts +5 -6
  78. package/src/v4/i3s-converter/helpers/feature-attributes.ts +4 -4
  79. package/src/v4/i3s-converter/helpers/geometry-attributes.ts +1 -1
  80. package/src/v4/i3s-converter/helpers/geometry-converter.ts +69 -77
  81. package/src/v4/i3s-converter/helpers/gltf-attributes.ts +58 -74
  82. package/src/v4/i3s-converter/helpers/load-3d-tiles.ts +6 -54
  83. package/src/v4/i3s-converter/helpers/node-debug.ts +7 -3
  84. package/src/v4/i3s-converter/helpers/node-index-document.ts +11 -9
  85. package/src/v4/i3s-converter/helpers/node-pages.ts +3 -4
  86. package/src/v4/i3s-converter/helpers/preprocess-3d-tiles.ts +2 -2
  87. package/src/v4/i3s-converter/helpers/tileset-traversal.ts +1 -1
  88. package/src/v4/i3s-converter/i3s-converter.ts +29 -31
  89. package/src/v4/i3s-converter/json-templates/geometry-definitions.ts +71 -66
  90. package/src/v4/i3s-converter/json-templates/layers.ts +97 -132
  91. package/src/v4/i3s-converter/json-templates/metadata.ts +31 -21
  92. package/src/v4/i3s-converter/json-templates/node.ts +60 -74
  93. package/src/v4/i3s-converter/json-templates/scene-server.ts +36 -27
  94. package/src/v4/i3s-converter/json-templates/shared-resources.ts +62 -123
  95. package/src/v4/i3s-converter/json-templates/store.ts +39 -57
  96. package/src/v4/i3s-converter/types.ts +4 -4
  97. package/src/v4/i3s-server/controllers/slpk-controller.ts +3 -3
  98. package/src/v4/i3s-server/utils/create-scene-server.ts +3 -3
  99. package/src/v4/lib/utils/builder-input.ts +8 -0
  100. package/src/v4/lib/utils/conversion-dump.ts +5 -1
  101. package/src/v4/lib/utils/file-utils.ts +1 -1
  102. package/src/v4/lib/utils/hash-string.ts +21 -0
  103. package/src/v4/lib/utils/lod-conversion-utils.ts +3 -3
  104. package/src/v4/pgm-loader.ts +2 -2
  105. package/src/v5/adapters.ts +53 -0
  106. package/src/v5/archive-source.ts +235 -0
  107. package/src/v5/browser-tileset-source.ts +418 -48
  108. package/src/v5/browser.ts +46 -0
  109. package/src/v5/conversion-api.ts +184 -17
  110. package/src/v5/conversion-archive.ts +2 -2
  111. package/src/v5/converter-cli.ts +28 -0
  112. package/src/v5/core.ts +7 -0
  113. package/src/v5/feature-arrow.ts +2 -2
  114. package/src/v5/i3s-mesh-conversion.ts +41 -3
  115. package/src/v5/i3s-mesh-sink.ts +1 -1
  116. package/src/v5/i3s-mesh-source-features.ts +3 -3
  117. package/src/v5/i3s-mesh-source-material.ts +18 -11
  118. package/src/v5/i3s-mesh-source.ts +3 -2
  119. package/src/v5/i3s-selection-source.ts +298 -0
  120. package/src/v5/index.ts +41 -0
  121. package/src/v5/mesh-conversion.ts +24 -4
  122. package/src/v5/mesh-draco.ts +2 -2
  123. package/src/v5/mesh-features.ts +3 -3
  124. package/src/v5/mesh-hierarchy.ts +648 -0
  125. package/src/v5/mesh-source-features.ts +165 -51
  126. package/src/v5/mesh-source-schema.ts +140 -0
  127. package/src/v5/mesh-source-texture.ts +49 -19
  128. package/src/v5/mesh-source.ts +80 -49
  129. package/src/v5/mesh-tileset-archive.ts +1 -1
  130. package/src/v5/mesh-tileset-sink.ts +2 -2
  131. package/src/v5/mesh.ts +1 -1
  132. package/src/v5/node-cli.ts +201 -0
  133. package/src/v5/node-conversion.ts +362 -0
  134. package/src/v5/node-feature-schema.ts +155 -0
  135. package/src/v5/node-i3s-source.ts +181 -0
  136. package/src/v5/node-source.ts +328 -0
  137. package/src/v5/node.ts +16 -0
  138. package/src/v5/point-cloud-attributes.ts +113 -0
  139. package/src/v5/point-cloud-conversion.ts +1 -1
  140. package/src/v5/point-cloud-copc.ts +289 -0
  141. package/src/v5/point-cloud-file.ts +306 -0
  142. package/src/v5/point-cloud-potree.ts +134 -0
  143. package/src/v5/point-cloud-source-encoder.ts +1 -1
  144. package/src/v5/point-cloud-source.ts +1 -1
  145. package/src/v5/point-cloud-spatial.ts +2 -2
  146. package/src/v5/point-cloud-tileset.ts +137 -0
  147. package/src/v5/point-cloud.ts +3 -3
  148. package/src/v5/resource-fetcher.ts +4 -1
  149. package/src/v5/resource-manifest.ts +40 -5
  150. package/src/v5/selected-mesh-source.ts +384 -0
  151. package/src/v5/spatial-conversion.ts +90 -19
  152. package/src/v5/tileset-source.ts +2 -2
@@ -0,0 +1,289 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {COPCWriter, type COPCWriterOptions} from '@loaders.gl/tiles-3d/copc';
6
+ import type {Mesh, MeshArrowTable} from '@loaders.gl/core/schema';
7
+ import {TileConversionError, type TileConversionCodec} from '@loaders.gl/tile-converter/v5/core';
8
+ import {
9
+ getPointCloudMesh,
10
+ mapPointCloudAttributes,
11
+ type PointCloudAttributeMapping
12
+ } from './point-cloud-attributes.js';
13
+
14
+ /** Bounded, explicitly placed COPC batch authoring. */
15
+ export interface EncodePointCloudCOPCOptions {
16
+ /** All attribute aliases and semantic omissions must be explicit. */
17
+ readonly mapping: PointCloudAttributeMapping;
18
+ /** LAS axis scales; positions must already be absolute coordinates in the declared CRS. */
19
+ readonly scale: readonly [number, number, number];
20
+ /** LAS axis offsets used for signed int32 quantization. */
21
+ readonly offset: readonly [number, number, number];
22
+ /** Explicit OGC WKT CRS declaration; no inference or reprojection is performed. */
23
+ readonly wkt: string;
24
+ /** Maximum Euclidean quantization error in source units. */
25
+ readonly maxPositionError: number;
26
+ /** Maximum distinct retained source buffer bytes; default 64 MiB. */
27
+ readonly maxInputBytes?: number;
28
+ /** Maximum rows in one batch; default 1000000. */
29
+ readonly maxPoints?: number;
30
+ /** Maximum completed encoded bytes; default 128 MiB. Writer temporary memory is additional. */
31
+ readonly maxOutputBytes?: number;
32
+ /** Explicit LAS Extra Bytes names for custom typed attributes, including exact 64-bit IDs. */
33
+ readonly extraBytes?: NonNullable<COPCWriterOptions['copc']>['extraBytes'];
34
+ /** Optional octree organization forwarded to the owning COPC writer. */
35
+ readonly organization?: Pick<
36
+ NonNullable<COPCWriterOptions['copc']>,
37
+ 'nodePointLimit' | 'maximumDepth' | 'hierarchyPageDepth' | 'spacing'
38
+ >;
39
+ /** Cancellation before validation and before/after synchronous writer execution. */
40
+ readonly signal?: AbortSignal;
41
+ }
42
+
43
+ /** A complete COPC file and its explicit precision/loss report. */
44
+ export interface EncodedPointCloudCOPC {
45
+ /** Range-readable COPC 1.0 / LAZ 1.4 bytes. */
46
+ readonly copc: ArrayBuffer;
47
+ /** Exact encoded point count. */
48
+ readonly pointCount: number;
49
+ /** Maximum reconstructed Euclidean position error. */
50
+ readonly maximumPositionError: number;
51
+ /** Authorized attribute omissions and reasons. */
52
+ readonly droppedAttributes: Readonly<Record<string, string>>;
53
+ }
54
+
55
+ /** Standard scalar attributes qualified for PDRF 6/7/8, with integer bounds. */
56
+ const SCALAR_RANGES: Readonly<Record<string, readonly [number, number]>> = {
57
+ intensity: [0, 65535],
58
+ classification: [0, 255],
59
+ nir: [0, 65535],
60
+ gpsTime: [-Infinity, Infinity],
61
+ scanAngle: [-32768, 32767],
62
+ userData: [0, 255],
63
+ pointSourceId: [0, 65535],
64
+ returnNumber: [1, 15],
65
+ numberOfReturns: [1, 15],
66
+ scannerChannel: [0, 3],
67
+ scanDirectionFlag: [0, 1],
68
+ edgeOfFlightLine: [0, 1],
69
+ synthetic: [0, 1],
70
+ keyPoint: [0, 1],
71
+ withheld: [0, 1],
72
+ overlap: [0, 1]
73
+ };
74
+
75
+ /** Encodes one decoded point batch as COPC with explicit mappings, CRS and precision gates. */
76
+ export async function encodePointCloudCOPC(
77
+ input: Mesh | MeshArrowTable,
78
+ options: EncodePointCloudCOPCOptions
79
+ ): Promise<EncodedPointCloudCOPC> {
80
+ const maxInputBytes = options.maxInputBytes ?? 64 * 1024 * 1024;
81
+ const maxPoints = options.maxPoints ?? 1000000;
82
+ const maxOutputBytes = options.maxOutputBytes ?? 128 * 1024 * 1024;
83
+ if (
84
+ [maxInputBytes, maxPoints, maxOutputBytes].some(
85
+ value => !Number.isSafeInteger(value) || value < 1
86
+ ) ||
87
+ options.scale.length !== 3 ||
88
+ options.scale.some(value => !Number.isFinite(value) || value <= 0) ||
89
+ options.offset.length !== 3 ||
90
+ options.offset.some(value => !Number.isFinite(value)) ||
91
+ typeof options.wkt !== 'string' ||
92
+ !options.wkt.trim() ||
93
+ !Number.isFinite(options.maxPositionError) ||
94
+ options.maxPositionError < 0
95
+ )
96
+ throw new TileConversionError(
97
+ 'POINT_CLOUD_COPC_OPTIONS_INVALID',
98
+ 'COPC requires explicit finite scale, offset, CRS, precision and positive budgets'
99
+ );
100
+ options.signal?.throwIfAborted();
101
+ const source = getPointCloudMesh(input, maxInputBytes);
102
+ const {mesh, droppedAttributes} = mapPointCloudAttributes(source, options.mapping);
103
+ const position = mesh.attributes.POSITION;
104
+ const pointCount = mesh.header?.vertexCount ?? position.value.length / 3;
105
+ if (!Number.isSafeInteger(pointCount) || pointCount < 1 || pointCount > maxPoints)
106
+ throw new TileConversionError(
107
+ 'POINT_CLOUD_POINT_BUDGET',
108
+ 'COPC requires a nonempty batch within the point limit'
109
+ );
110
+ if (
111
+ !(position.value instanceof Float32Array || position.value instanceof Float64Array) ||
112
+ position.size !== 3 ||
113
+ position.normalized
114
+ )
115
+ throw new TileConversionError(
116
+ 'POINT_CLOUD_POSITION_INVALID',
117
+ 'COPC requires decoded floating XYZ'
118
+ );
119
+ const extraNames = new Set(options.extraBytes?.map(field => field.attribute) || []);
120
+ const descriptorNames = new Set(
121
+ options.extraBytes?.map(field => field.name || field.attribute) || []
122
+ );
123
+ if (
124
+ extraNames.size !== (options.extraBytes?.length || 0) ||
125
+ descriptorNames.size !== extraNames.size ||
126
+ options.extraBytes?.some(field => {
127
+ const name = field.name || field.attribute;
128
+ const description = field.description || '';
129
+ return (
130
+ !mesh.attributes[field.attribute] ||
131
+ !field.attribute ||
132
+ !name.length ||
133
+ name.length > 32 ||
134
+ description.length > 32 ||
135
+ /[^\x20-\x7e]/.test(name) ||
136
+ /[^\x20-\x7e]/.test(description)
137
+ );
138
+ })
139
+ )
140
+ throw new TileConversionError(
141
+ 'POINT_CLOUD_EXTRA_BYTES_INVALID',
142
+ 'Extra Bytes descriptors require unique mapped attributes and names/descriptions within 32 printable ASCII bytes'
143
+ );
144
+ for (const name of extraNames)
145
+ if (name === 'POSITION' || name === 'COLOR_0' || Object.hasOwn(SCALAR_RANGES, name))
146
+ throw new TileConversionError(
147
+ 'POINT_CLOUD_EXTRA_BYTES_INVALID',
148
+ 'Map standard attributes directly instead of duplicating them as Extra Bytes'
149
+ );
150
+ for (const [name, attribute] of Object.entries(mesh.attributes)) {
151
+ const color = name === 'COLOR_0';
152
+ const extra = extraNames.has(name);
153
+ if (name !== 'POSITION' && !color && !extra && !Object.hasOwn(SCALAR_RANGES, name))
154
+ throw new TileConversionError(
155
+ 'POINT_CLOUD_ATTRIBUTE_UNSUPPORTED',
156
+ `COPC cannot preserve ${name}; supply an explicit supported mapping or drop reason`
157
+ );
158
+ if (
159
+ attribute.transform ||
160
+ attribute.byteOffset ||
161
+ attribute.byteStride ||
162
+ attribute.componentType === 'float16' ||
163
+ attribute.value.length !== pointCount * attribute.size ||
164
+ (!color && attribute.normalized) ||
165
+ (name !== 'POSITION' &&
166
+ !color &&
167
+ (extra ? ![1, 3].includes(attribute.size) : attribute.size !== 1))
168
+ )
169
+ throw new TileConversionError(
170
+ 'POINT_CLOUD_ATTRIBUTE_INVALID',
171
+ `COPC requires packed decoded attributes: ${name}`
172
+ );
173
+ if (color) {
174
+ if (
175
+ attribute.size !== 3 ||
176
+ !(attribute.value instanceof Uint8Array || attribute.value instanceof Uint16Array)
177
+ )
178
+ throw new TileConversionError(
179
+ 'POINT_CLOUD_COLOR_INVALID',
180
+ 'COPC requires explicitly mapped uint8/uint16 RGB'
181
+ );
182
+ } else if (extra) {
183
+ for (const value of attribute.value)
184
+ if (typeof value !== 'bigint' && !Number.isFinite(value))
185
+ throw new TileConversionError(
186
+ 'POINT_CLOUD_ATTRIBUTE_RANGE',
187
+ `Nonfinite Extra Bytes value: ${name}`
188
+ );
189
+ } else if (name !== 'POSITION') {
190
+ const [minimum, maximum] = SCALAR_RANGES[name];
191
+ for (const value of attribute.value) {
192
+ if (
193
+ typeof value !== 'number' ||
194
+ !Number.isFinite(value) ||
195
+ value < minimum ||
196
+ value > maximum ||
197
+ (name !== 'gpsTime' && !Number.isInteger(value))
198
+ )
199
+ throw new TileConversionError(
200
+ 'POINT_CLOUD_ATTRIBUTE_RANGE',
201
+ `COPC value outside the qualified range: ${name}`
202
+ );
203
+ }
204
+ }
205
+ }
206
+ let maximumPositionError = 0;
207
+ const minimum = [Infinity, Infinity, Infinity];
208
+ const maximum = [-Infinity, -Infinity, -Infinity];
209
+ const errors = [0, 0, 0];
210
+ for (let row = 0; row < pointCount; row++) {
211
+ for (let axis = 0; axis < 3; axis++) {
212
+ const value = position.value[row * 3 + axis];
213
+ const encoded = Math.round((value - options.offset[axis]) / options.scale[axis]);
214
+ if (
215
+ !Number.isFinite(value) ||
216
+ !Number.isSafeInteger(encoded) ||
217
+ encoded < -2147483648 ||
218
+ encoded > 2147483647
219
+ )
220
+ throw new TileConversionError(
221
+ 'POINT_CLOUD_POSITION_RANGE',
222
+ 'COPC position exceeds signed int32 quantization'
223
+ );
224
+ minimum[axis] = Math.min(minimum[axis], value);
225
+ maximum[axis] = Math.max(maximum[axis], value);
226
+ errors[axis] = encoded * options.scale[axis] + options.offset[axis] - value;
227
+ }
228
+ maximumPositionError = Math.max(maximumPositionError, Math.hypot(...errors));
229
+ if (maximumPositionError > options.maxPositionError)
230
+ throw new TileConversionError(
231
+ 'POINT_CLOUD_PRECISION_EXCEEDED',
232
+ 'COPC position rounding exceeds the declared error gate'
233
+ );
234
+ if (row % 16384 === 0) {
235
+ await new Promise<void>(resolve => setTimeout(resolve, 0));
236
+ options.signal?.throwIfAborted();
237
+ }
238
+ }
239
+ const width = Math.max(...maximum.map((value, axis) => value - minimum[axis]), ...options.scale);
240
+ const radius = width / 2;
241
+ if (
242
+ !Number.isFinite(Math.hypot(radius, radius, radius)) ||
243
+ minimum.some((value, axis) => {
244
+ const center = value + (maximum[axis] - value) / 2;
245
+ return (
246
+ !Number.isFinite(value + maximum[axis]) ||
247
+ !Number.isFinite(center - radius) ||
248
+ !Number.isFinite(center + radius)
249
+ );
250
+ })
251
+ )
252
+ throw new TileConversionError('POINT_CLOUD_BOUNDS_INVALID', 'COPC coordinate extent overflow');
253
+ // The owning writer executes synchronously; cancellation cannot interrupt its compression call.
254
+ options.signal?.throwIfAborted();
255
+ const color = mesh.attributes.COLOR_0;
256
+ const copc = COPCWriter.encodeSync(mesh, {
257
+ copc: {
258
+ ...options.organization,
259
+ scale: [...options.scale],
260
+ offset: [...options.offset],
261
+ wkt: options.wkt,
262
+ pointDataRecordFormat: mesh.attributes.nir ? 8 : color ? 7 : 6,
263
+ colorDepth: color?.value instanceof Uint16Array ? 16 : 8,
264
+ extraBytes: options.extraBytes
265
+ }
266
+ });
267
+ options.signal?.throwIfAborted();
268
+ if (copc.byteLength > maxOutputBytes)
269
+ throw new TileConversionError(
270
+ 'POINT_CLOUD_OUTPUT_BUDGET',
271
+ 'Completed COPC output exceeds the byte budget'
272
+ );
273
+ return {copc, pointCount, maximumPositionError, droppedAttributes};
274
+ }
275
+
276
+ /** Creates a v5 codec for one independently authored COPC file per decoded input batch. */
277
+ export function createCOPCConversionCodec(
278
+ options: EncodePointCloudCOPCOptions
279
+ ): TileConversionCodec<unknown, Mesh | MeshArrowTable, EncodedPointCloudCOPC> {
280
+ return {
281
+ async *convert(input, _inspection, signal) {
282
+ const combinedSignal =
283
+ options.signal && signal
284
+ ? AbortSignal.any([options.signal, signal])
285
+ : options.signal || signal;
286
+ yield await encodePointCloudCOPC(input, {...options, signal: combinedSignal});
287
+ }
288
+ };
289
+ }
@@ -0,0 +1,306 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {COPCSourceLoader} from '@loaders.gl/tiles-3d/copc';
6
+ import type {COPCTileSource, COPCSourceLoaderOptions} from '@loaders.gl/tiles-3d/copc';
7
+ import {PotreeSourceLoader, encodePotreeDataset} from '@loaders.gl/tiles-3d/potree';
8
+ import type {
9
+ PotreeDataset,
10
+ PotreeWriterNode,
11
+ PotreeWriterOptions,
12
+ PotreeSourceLoaderOptions
13
+ } from '@loaders.gl/tiles-3d/potree';
14
+ import type {Mesh, MeshArrowTable, MeshGeometry} from '@loaders.gl/core/schema';
15
+ import {deduceMeshSchema} from '@loaders.gl/core/schema-utils';
16
+ import {encodeZip, openZipArchive} from '@loaders.gl/archives/zip';
17
+ import type {ZipArchive, ZipEntry} from '@loaders.gl/archives/zip';
18
+
19
+ /** Explicit local-file input; no format is inferred from an extension. */
20
+ export type OpenPointCloudFileOptions = {
21
+ /** Maximum retained input Blob size. This does not bound decoded heap. */
22
+ readonly maxFileBytes: number;
23
+ /** Cancellation before/after initialization and during archived resource reads. */
24
+ readonly signal?: AbortSignal;
25
+ } & (
26
+ | {
27
+ /** COPC is a native single LAZ file. */
28
+ readonly format: 'copc';
29
+ /** Existing COPC source coordinate and decoder controls. */
30
+ readonly sourceOptions?: COPCSourceLoaderOptions;
31
+ }
32
+ | {
33
+ /** A loaders.gl STORE ZIP convention, not a native Potree archive standard. */
34
+ readonly format: 'potree-zip';
35
+ /** Maximum ZIP entry count, including metadata; default 100000. */
36
+ readonly maxEntries?: number;
37
+ /** Maximum requested ZIP resource/range bytes; default 64 MiB. */
38
+ readonly maxResourceBytes?: number;
39
+ /** Explicit metadata path when it is not at the ZIP root. */
40
+ readonly metadataPath?: string;
41
+ /** Existing Potree parser controls; the bundle owns its isolated fetch transport. */
42
+ readonly sourceOptions?: PotreeSourceLoaderOptions;
43
+ }
44
+ );
45
+
46
+ /** An initialized source with an owned local file lifetime. */
47
+ export interface OpenedPointCloudFile {
48
+ /** Lazy node access through the existing unified source APIs. */
49
+ readonly source: ReturnType<typeof PotreeSourceLoader.createDataSource> | COPCTileSource;
50
+ /** Closes the source and rejects subsequent bundle reads; idempotent. */
51
+ readonly close: () => Promise<void>;
52
+ }
53
+
54
+ /** Complete Potree dataset packaging controls. */
55
+ export interface EncodePotreeArchiveOptions {
56
+ /** Existing dataset version, precision, hierarchy and encoded-byte limits. */
57
+ readonly potree?: PotreeWriterOptions['potree'];
58
+ /** Maximum completed STORE ZIP32 bytes, checked before archive allocation. */
59
+ readonly maxArchiveBytes: number;
60
+ }
61
+
62
+ /**
63
+ * Writes a complete Potree 1.x/2.0 dataset to a deterministic uncompressed ZIP32 Blob.
64
+ * This is a loaders.gl bundle convention, not a native Potree archive standard. Metadata sits
65
+ * at the root with original relative resource paths. Writer version/attribute/precision gates
66
+ * apply. Inputs, dataset bytes, ZIP temporaries and the returned Blob are separate memory.
67
+ * @param input - Decoded points or caller-organized additive nodes; no LOD sampling is inferred.
68
+ * @param options - Dataset writer controls and final archive budget.
69
+ * @returns A downloadable ZIP Blob that openPointCloudFile can read incrementally.
70
+ */
71
+ export async function encodePotreeArchive(
72
+ input: Mesh | MeshArrowTable | readonly PotreeWriterNode[],
73
+ options: EncodePotreeArchiveOptions
74
+ ): Promise<Blob> {
75
+ validateLimit(options.maxArchiveBytes);
76
+ const dataset = await encodePotreeDataset(input, {potree: options.potree});
77
+ return encodePotreeDatasetArchive(dataset, options.maxArchiveBytes, options.potree?.signal);
78
+ }
79
+
80
+ /** Packages already encoded Potree files without encoding or sampling points a second time. */
81
+ export async function encodePotreeDatasetArchive(
82
+ dataset: PotreeDataset,
83
+ maxArchiveBytes: number,
84
+ signal?: AbortSignal
85
+ ): Promise<Blob> {
86
+ validateLimit(maxArchiveBytes);
87
+ for (const path of dataset.files.keys()) validatePath(path);
88
+ signal?.throwIfAborted();
89
+ const archive = await encodeZip(Object.fromEntries(dataset.files), {
90
+ compression: 'store',
91
+ zip64: 'never',
92
+ maxEntries: 65535,
93
+ maxArchiveBytes: BigInt(maxArchiveBytes),
94
+ signal
95
+ });
96
+ signal?.throwIfAborted();
97
+ return new Blob([archive], {type: 'application/zip'});
98
+ }
99
+
100
+ /**
101
+ * Opens a COPC Blob or a Potree STORE ZIP without extracting the complete dataset.
102
+ * ZIP directory/local headers are checked before constructing the Potree source; modern node
103
+ * requests slice only their archived byte ranges. DEFLATE, encrypted entries, ambiguous
104
+ * metadata and noncanonical paths fail explicitly. Partial reads do not verify whole-entry CRCs.
105
+ * Legacy Potree retains its existing coordinate behavior; modern Potree retains native coordinates.
106
+ * COPC uses its existing source coordinate policy. Applications own CRS preparation and conversion.
107
+ * @param input - Immutable local file/Blob, bounded by maxFileBytes.
108
+ * @param options - Explicit format, source controls and archive transport limits.
109
+ * @returns An initialized lazy source; close it when conversion finishes.
110
+ */
111
+ export async function openPointCloudFile(
112
+ input: Blob,
113
+ options: OpenPointCloudFileOptions
114
+ ): Promise<OpenedPointCloudFile> {
115
+ validateLimit(options.maxFileBytes);
116
+ if (input.size > options.maxFileBytes) throw new RangeError('Point file input budget exceeded');
117
+ options.signal?.throwIfAborted();
118
+ if (options.format === 'copc') {
119
+ const source = COPCSourceLoader.createDataSource(input, options.sourceOptions || {});
120
+ try {
121
+ await source.metadata;
122
+ options.signal?.throwIfAborted();
123
+ return {source, close: () => source.close()};
124
+ } catch (error) {
125
+ await source.close();
126
+ throw error;
127
+ }
128
+ }
129
+ if (options.format !== 'potree-zip') throw new TypeError('Unsupported point file format');
130
+ const maxEntries = options.maxEntries ?? 100000;
131
+ const maxResourceBytes = options.maxResourceBytes ?? 64 * 1024 * 1024;
132
+ validateLimit(maxEntries);
133
+ validateLimit(maxResourceBytes);
134
+ const controller = new AbortController();
135
+ const signals = [controller.signal, options.signal, options.sourceOptions?.potree?.signal].filter(
136
+ (value): value is AbortSignal => Boolean(value)
137
+ );
138
+ const signal = AbortSignal.any(signals);
139
+ let archive: ZipArchive | undefined;
140
+ const entries = new Map<string, ZipEntry>();
141
+ let source: ReturnType<typeof PotreeSourceLoader.createDataSource> | undefined;
142
+ try {
143
+ archive = await openZipArchive(input, {
144
+ maxArchiveBytes: BigInt(options.maxFileBytes),
145
+ maxEntries,
146
+ maxReadBytes: Math.max(65557, maxResourceBytes),
147
+ maxCompressedBytes: BigInt(maxResourceBytes),
148
+ maxDecodedBytes: BigInt(maxResourceBytes),
149
+ signal
150
+ });
151
+ for await (const entry of archive.entries()) {
152
+ if (entry.directory) continue;
153
+ validatePath(entry.path);
154
+ if (entry.compression !== 'store')
155
+ throw new Error('Potree bundles require unencrypted STORE entries');
156
+ entries.set(entry.path, entry);
157
+ }
158
+ const candidates = ['metadata.json', 'cloud.js'].filter(path => entries.has(path));
159
+ const metadataPath = options.metadataPath || (candidates.length === 1 ? candidates[0] : '');
160
+ validatePath(metadataPath);
161
+ if (!entries.has(metadataPath) || !/(^|\/)(metadata\.json|cloud\.js)$/.test(metadataPath))
162
+ throw new Error('Potree ZIP requires one metadata entry or an explicit metadataPath');
163
+ const prefix = 'https://point-bundle.loaders.gl.invalid/';
164
+ /** Isolated archive transport; it never falls back to network access. */
165
+ const fetchResource = async (
166
+ resource: string,
167
+ request: RequestInit = {}
168
+ ): Promise<Response> => {
169
+ signal.throwIfAborted();
170
+ request.signal?.throwIfAborted();
171
+ const url = new URL(resource);
172
+ if (url.origin !== new URL(prefix).origin || url.search || url.hash)
173
+ throw new Error('Resource outside the Potree bundle');
174
+ const path = decodeURIComponent(url.pathname.slice(1));
175
+ validatePath(path);
176
+ const entry = entries.get(path);
177
+ if (!entry) throw new Error(`Missing Potree bundle resource: ${path}`);
178
+ const range = new Headers(request.headers).get('range');
179
+ const match = range?.match(/^bytes=(\d+)-(\d+)$/);
180
+ if (range && !match) throw new RangeError('Unsupported Potree bundle byte range');
181
+ const begin = match ? Number(match[1]) : 0;
182
+ const end = match ? Number(match[2]) + 1 : Number(entry.size);
183
+ if (
184
+ !Number.isSafeInteger(begin) ||
185
+ !Number.isSafeInteger(end) ||
186
+ begin < 0 ||
187
+ end < begin ||
188
+ BigInt(end) > entry.size ||
189
+ end - begin > maxResourceBytes
190
+ )
191
+ throw new RangeError('Potree bundle resource/range budget exceeded');
192
+ const headers = new Headers({
193
+ 'content-length': String(end - begin),
194
+ 'accept-ranges': 'bytes'
195
+ });
196
+ if (match) headers.set('content-range', `bytes ${begin}-${end - 1}/${entry.size}`);
197
+ const bytes = match
198
+ ? await archive!.readRange(entry, {
199
+ start: BigInt(begin),
200
+ end: BigInt(end),
201
+ signal: request.signal ?? undefined
202
+ })
203
+ : await archive!.read(entry, {signal: request.signal ?? undefined});
204
+ const response = new Response(bytes, {
205
+ status: match ? 206 : 200,
206
+ headers
207
+ });
208
+ Object.defineProperty(response, 'url', {value: url.href});
209
+ signal.throwIfAborted();
210
+ return response;
211
+ };
212
+ source = PotreeSourceLoader.createDataSource(`${prefix}${metadataPath}`, {
213
+ ...options.sourceOptions,
214
+ core: {...options.sourceOptions?.core, fetch: fetchResource},
215
+ potree: {...options.sourceOptions?.potree, signal}
216
+ });
217
+ await source.initialize();
218
+ signal.throwIfAborted();
219
+ return {
220
+ source,
221
+ async close() {
222
+ controller.abort();
223
+ source?.close();
224
+ await archive?.close();
225
+ }
226
+ };
227
+ } catch (error) {
228
+ controller.abort();
229
+ source?.close();
230
+ await archive?.close();
231
+ throw error;
232
+ }
233
+ }
234
+
235
+ /** Explicit opt-in mesh-to-point selection. */
236
+ export interface ExtractMeshVerticesOptions {
237
+ /** Attribute names to retain in addition to POSITION; all other semantics are omitted explicitly. */
238
+ readonly attributes: readonly string[];
239
+ /** Maximum stored vertices, not expanded triangle corners. */
240
+ readonly maxPoints: number;
241
+ }
242
+
243
+ /**
244
+ * Harvests one point per stored vertex from already placed triangle geometry.
245
+ * Does not expand indices, deduplicate coincident vertices or sample triangle interiors. Output
246
+ * shares immutable attribute arrays. Materials, features, topology and unselected attributes are
247
+ * discarded by this explicit opt-in; select compatible attributes before destination encoding.
248
+ * For tiles, use the mesh source adapters first so node/RTC/tile transforms are applied once.
249
+ * @param mesh - Decoded triangle geometry in the desired absolute coordinate frame.
250
+ * @param options - Explicit retained attributes and stored-vertex count budget.
251
+ * @returns An unindexed point Mesh ready for point-cloud writers or the dynamic tiler.
252
+ */
253
+ export function extractMeshVertices(mesh: MeshGeometry, options: ExtractMeshVerticesOptions): Mesh {
254
+ validateLimit(options.maxPoints);
255
+ const position = mesh.attributes.POSITION;
256
+ const pointCount = position?.value.length / 3;
257
+ if (
258
+ mesh.topology !== 'triangle-list' ||
259
+ mesh.mode !== 4 ||
260
+ !position ||
261
+ position.size !== 3 ||
262
+ position.normalized ||
263
+ position.transform ||
264
+ position.byteStride ||
265
+ position.byteOffset ||
266
+ !(position.value instanceof Float32Array || position.value instanceof Float64Array) ||
267
+ !Number.isSafeInteger(pointCount) ||
268
+ pointCount > options.maxPoints ||
269
+ position.value.some(value => !Number.isFinite(value))
270
+ )
271
+ throw new Error(
272
+ 'Mesh vertex harvesting requires packed finite triangle XYZ within the point budget'
273
+ );
274
+ const attributes: MeshGeometry['attributes'] = {POSITION: position};
275
+ for (const name of options.attributes) {
276
+ const attribute = mesh.attributes[name];
277
+ if (
278
+ !attribute ||
279
+ attribute.transform ||
280
+ attribute.byteStride ||
281
+ attribute.byteOffset ||
282
+ !Number.isSafeInteger(attribute.size) ||
283
+ attribute.size < 1 ||
284
+ attribute.value.length !== pointCount * attribute.size
285
+ )
286
+ throw new Error(`Invalid selected mesh vertex attribute: ${name}`);
287
+ attributes[name] = attribute;
288
+ }
289
+ return {topology: 'point-list', mode: 0, attributes, schema: deduceMeshSchema(attributes)};
290
+ }
291
+
292
+ /** Rejects unsafe or unsupported archive paths before URL resolution. */
293
+ function validatePath(path: string): void {
294
+ if (
295
+ !path ||
296
+ !/^[A-Za-z0-9_.\/-]+$/.test(path) ||
297
+ path.split('/').some(part => !part || part === '.' || part === '..')
298
+ )
299
+ throw new Error('Potree bundle paths must be canonical relative ASCII paths');
300
+ }
301
+
302
+ /** Validates caller budgets before file reads or output allocation. */
303
+ function validateLimit(value: number): void {
304
+ if (!Number.isSafeInteger(value) || value < 1)
305
+ throw new RangeError('Point file limits must be positive safe integers');
306
+ }