@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.
- package/README.md +211 -2
- package/bin/converter-v5.js +2 -0
- package/dist/converter.min.cjs +162 -163
- package/dist/i3s-server/bin/i3s-server.min.cjs +73 -73
- package/dist/index.cjs +1933 -2232
- package/dist/index.js +1925 -2235
- package/dist/v5/adapters.cjs +3186 -208
- package/dist/v5/adapters.d.ts +29 -2
- package/dist/v5/adapters.js +3202 -196
- package/dist/v5/archive-source.d.ts +36 -0
- package/dist/v5/browser-tileset-source.d.ts +55 -11
- package/dist/v5/browser.cjs +30 -0
- package/dist/v5/browser.d.ts +26 -2
- package/dist/v5/browser.js +45 -1
- package/dist/v5/conversion-api.d.ts +9 -0
- package/dist/v5/converter-cli.d.ts +1 -0
- package/dist/v5/converter-cli.js +1214 -0
- package/dist/v5/core.cjs +515 -50
- package/dist/v5/core.d.ts +2 -0
- package/dist/v5/core.js +515 -50
- package/dist/v5/i3s-mesh-conversion.d.ts +14 -4
- package/dist/v5/i3s-mesh-sink.d.ts +25 -0
- package/dist/v5/i3s-mesh-source-features.d.ts +13 -0
- package/dist/v5/i3s-mesh-source-material.d.ts +5 -0
- package/dist/v5/i3s-mesh-source.d.ts +30 -0
- package/dist/v5/i3s-selection-source.d.ts +70 -0
- package/dist/v5/index.cjs +20 -0
- package/dist/v5/index.d.ts +18 -2
- package/dist/v5/index.js +31 -1
- package/dist/v5/mesh-conversion.d.ts +29 -2
- package/dist/v5/mesh-features.d.ts +9 -0
- package/dist/v5/mesh-hierarchy.d.ts +47 -0
- package/dist/v5/mesh-source-features.d.ts +2 -1
- package/dist/v5/mesh-source-schema.d.ts +12 -0
- package/dist/v5/mesh-source-texture.d.ts +2 -0
- package/dist/v5/mesh-source.d.ts +13 -3
- package/dist/v5/mesh-tileset-sink.d.ts +5 -0
- package/dist/v5/mesh.d.ts +6 -1
- package/dist/v5/node-cli.d.ts +9 -0
- package/dist/v5/node-conversion.d.ts +32 -0
- package/dist/v5/node-feature-schema.d.ts +3 -0
- package/dist/v5/node-i3s-source.d.ts +41 -0
- package/dist/v5/node-source.d.ts +47 -0
- package/dist/v5/node.cjs +900 -0
- package/dist/v5/node.d.ts +6 -0
- package/dist/v5/node.js +877 -0
- package/dist/v5/point-cloud-file.d.ts +83 -0
- package/dist/v5/point-cloud-potree.d.ts +47 -0
- package/dist/v5/point-cloud-source-encoder.d.ts +4 -7
- package/dist/v5/point-cloud-spatial.d.ts +22 -0
- package/dist/v5/point-cloud-tileset-sink.d.ts +28 -0
- package/dist/v5/point-cloud-tileset.d.ts +44 -0
- package/dist/v5/point-cloud.d.ts +26 -1
- package/dist/v5/resource-fetcher.d.ts +18 -0
- package/dist/v5/resource-manifest.d.ts +10 -1
- package/dist/v5/selected-mesh-source.d.ts +53 -0
- package/dist/v5/spatial-conversion.d.ts +3 -1
- package/package.json +33 -25
- package/src/index.ts +4 -1
- package/src/v4/3d-tiles-converter/helpers/load-i3s.ts +2 -4
- package/src/v4/converter-cli.ts +8 -2
- package/src/v4/i3s-converter/helpers/load-3d-tiles.ts +3 -51
- package/src/v4/i3s-converter/helpers/node-index-document.ts +5 -2
- package/src/v4/i3s-converter/helpers/preprocess-3d-tiles.ts +1 -1
- package/src/v4/i3s-converter/json-templates/store.ts +2 -1
- package/src/v5/adapters.ts +69 -2
- package/src/v5/archive-source.ts +235 -0
- package/src/v5/browser-tileset-source.ts +418 -48
- package/src/v5/browser.ts +81 -2
- package/src/v5/conversion-api.ts +69 -9
- package/src/v5/converter-cli.ts +28 -0
- package/src/v5/core.ts +6 -0
- package/src/v5/i3s-mesh-conversion.ts +97 -19
- package/src/v5/i3s-mesh-sink.ts +351 -0
- package/src/v5/i3s-mesh-source-features.ts +279 -0
- package/src/v5/i3s-mesh-source-material.ts +157 -0
- package/src/v5/i3s-mesh-source.ts +228 -0
- package/src/v5/i3s-selection-source.ts +298 -0
- package/src/v5/index.ts +60 -2
- package/src/v5/mesh-conversion.ts +61 -11
- package/src/v5/mesh-draco.ts +47 -3
- package/src/v5/mesh-features.ts +319 -0
- package/src/v5/mesh-hierarchy.ts +648 -0
- package/src/v5/mesh-source-features.ts +73 -10
- package/src/v5/mesh-source-schema.ts +134 -0
- package/src/v5/mesh-source-texture.ts +47 -0
- package/src/v5/mesh-source.ts +99 -25
- package/src/v5/mesh-tileset-sink.ts +1 -1
- package/src/v5/mesh.ts +16 -7
- package/src/v5/node-cli.ts +197 -0
- package/src/v5/node-conversion.ts +345 -0
- package/src/v5/node-feature-schema.ts +155 -0
- package/src/v5/node-i3s-source.ts +181 -0
- package/src/v5/node-source.ts +328 -0
- package/src/v5/node.ts +16 -0
- package/src/v5/point-cloud-conversion.ts +9 -1
- package/src/v5/point-cloud-file.ts +306 -0
- package/src/v5/point-cloud-potree.ts +130 -0
- package/src/v5/point-cloud-source-encoder.ts +31 -20
- package/src/v5/point-cloud-spatial.ts +280 -0
- package/src/v5/point-cloud-tileset-sink.ts +234 -0
- package/src/v5/point-cloud-tileset.ts +137 -0
- package/src/v5/point-cloud.ts +159 -32
- package/src/v5/resource-fetcher.ts +113 -0
- package/src/v5/resource-manifest.ts +40 -5
- package/src/v5/selected-mesh-source.ts +384 -0
- package/src/v5/spatial-conversion.ts +161 -6
|
@@ -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/copc';
|
|
6
|
+
import type {COPCTileSource, COPCSourceLoaderOptions} from '@loaders.gl/copc';
|
|
7
|
+
import {PotreeSourceLoader, encodePotreeDataset} from '@loaders.gl/potree';
|
|
8
|
+
import type {
|
|
9
|
+
PotreeDataset,
|
|
10
|
+
PotreeWriterNode,
|
|
11
|
+
PotreeWriterOptions,
|
|
12
|
+
PotreeSourceLoaderOptions
|
|
13
|
+
} from '@loaders.gl/potree';
|
|
14
|
+
import type {Mesh, MeshArrowTable, MeshGeometry} from '@loaders.gl/schema';
|
|
15
|
+
import {deduceMeshSchema} from '@loaders.gl/schema-utils';
|
|
16
|
+
import {encodeZip, openZipArchive} from '@loaders.gl/zip';
|
|
17
|
+
import type {ZipArchive, ZipEntry} from '@loaders.gl/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
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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 {PointCloudTiler, type PointCloudTilerOptions} from '@loaders.gl/schema-utils';
|
|
7
|
+
import {encodePotreeDataset} from '@loaders.gl/potree';
|
|
8
|
+
import type {PotreeDataset, PotreeWriterNode, PotreeWriterOptions} from '@loaders.gl/potree';
|
|
9
|
+
import {encodePotreeDatasetArchive} from './point-cloud-file.js';
|
|
10
|
+
|
|
11
|
+
/** Bounded authoring of one decoded dataset in its already placed native coordinates. */
|
|
12
|
+
export interface ConvertPointCloudToPotreeOptions {
|
|
13
|
+
/** Explicit CRS declaration written verbatim; coordinates are not reprojected. */
|
|
14
|
+
readonly projection: string;
|
|
15
|
+
/** Maximum bytes retained across all gathered node attribute buffers. */
|
|
16
|
+
readonly maxGatheredBytes: number;
|
|
17
|
+
/** Maximum complete encoded dataset bytes, including metadata and hierarchy. */
|
|
18
|
+
readonly maxOutputBytes: number;
|
|
19
|
+
/** Input, index, per-node and node-count limits of the shared dynamic tiler. */
|
|
20
|
+
readonly tiling?: Omit<PointCloudTilerOptions, 'signal'>;
|
|
21
|
+
/** Existing version, precision, spacing and hierarchy controls. */
|
|
22
|
+
readonly potree?: Omit<
|
|
23
|
+
NonNullable<PotreeWriterOptions['potree']>,
|
|
24
|
+
'projection' | 'signal' | 'maxOutputBytes'
|
|
25
|
+
>;
|
|
26
|
+
/** Also package the completed files as a loaders.gl STORE ZIP32 bundle. */
|
|
27
|
+
readonly archive?: {readonly maxArchiveBytes: number};
|
|
28
|
+
/** Cooperative cancellation during indexing, gathering, writing and packaging. */
|
|
29
|
+
readonly signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Complete Potree files; no partial dataset is returned after a failure. */
|
|
33
|
+
export interface ConvertedPointCloudPotree {
|
|
34
|
+
/** Exact input row count, emitted once across the additive octree. */
|
|
35
|
+
readonly pointCount: number;
|
|
36
|
+
/** Number of connected octree nodes, including an empty root for empty input. */
|
|
37
|
+
readonly nodeCount: number;
|
|
38
|
+
/** Metadata and encoded resources; applications own storage. */
|
|
39
|
+
readonly dataset: PotreeDataset;
|
|
40
|
+
/** Optional downloadable bundle; applications own object URL and saving lifetimes. */
|
|
41
|
+
readonly archive?: Blob;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Connects the portable point tiler to the existing Potree 1.x/2.0 dataset writer.
|
|
46
|
+
* Retains additive octree addresses and every source row exactly once, including terminal
|
|
47
|
+
* overflow. Input must be immutable, unindexed decoded points already placed in the declared
|
|
48
|
+
* CRS. No origins, transforms, reprojection, attribute mappings or surface sampling are inferred.
|
|
49
|
+
* The writer rejects attributes or precision that the selected version cannot represent.
|
|
50
|
+
* Gathered nodes are bounded separately from source buffers, indexes, encoded output and ZIP
|
|
51
|
+
* temporaries. This is in-memory authoring, not streaming ingestion or a peak-heap guarantee.
|
|
52
|
+
* The shared tiler's sample coverage bound is not a qualified viewer LOD/error contract.
|
|
53
|
+
* @param input - One decoded point Mesh or MeshArrowTable, including loader output.
|
|
54
|
+
* @param options - Explicit native CRS, resource budgets and existing writer controls.
|
|
55
|
+
* @returns A complete dataset and optional bundle after releasing the owned tiler.
|
|
56
|
+
*/
|
|
57
|
+
export async function convertPointCloudToPotree(
|
|
58
|
+
input: Mesh | MeshArrowTable,
|
|
59
|
+
options: ConvertPointCloudToPotreeOptions
|
|
60
|
+
): Promise<ConvertedPointCloudPotree> {
|
|
61
|
+
if (typeof options.projection !== 'string' || !options.projection.trim()) {
|
|
62
|
+
throw new Error('Potree authoring requires an explicit native CRS declaration');
|
|
63
|
+
}
|
|
64
|
+
for (const limit of [
|
|
65
|
+
options.maxGatheredBytes,
|
|
66
|
+
options.maxOutputBytes,
|
|
67
|
+
options.archive?.maxArchiveBytes
|
|
68
|
+
]) {
|
|
69
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
|
|
70
|
+
throw new RangeError('Potree authoring byte limits must be positive safe integers');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Required budgets must remain required for JavaScript callers too.
|
|
74
|
+
if (options.maxGatheredBytes === undefined || options.maxOutputBytes === undefined) {
|
|
75
|
+
throw new RangeError('Potree authoring requires gathered and encoded byte limits');
|
|
76
|
+
}
|
|
77
|
+
options.signal?.throwIfAborted();
|
|
78
|
+
const tiler = new PointCloudTiler(input, {
|
|
79
|
+
...options.tiling,
|
|
80
|
+
maxNodes: Math.min(options.tiling?.maxNodes ?? 100000, options.potree?.maxNodes ?? 100000),
|
|
81
|
+
maxTileBytes: Math.min(
|
|
82
|
+
options.tiling?.maxTileBytes ?? 64 * 1024 * 1024,
|
|
83
|
+
options.maxGatheredBytes
|
|
84
|
+
),
|
|
85
|
+
signal: options.signal
|
|
86
|
+
});
|
|
87
|
+
const nodes: PotreeWriterNode[] = [];
|
|
88
|
+
let gatheredBytes = 0;
|
|
89
|
+
let pointByteLength = 0;
|
|
90
|
+
let pointCount: number;
|
|
91
|
+
try {
|
|
92
|
+
const pending = [await tiler.getRootNode()];
|
|
93
|
+
pointCount = tiler.pointCount;
|
|
94
|
+
while (pending.length) {
|
|
95
|
+
options.signal?.throwIfAborted();
|
|
96
|
+
const node = pending.pop()!;
|
|
97
|
+
// Root gathering is bounded by maxTileBytes; its packed layout sizes later allocations.
|
|
98
|
+
if (node.pointCount * pointByteLength > options.maxGatheredBytes - gatheredBytes) {
|
|
99
|
+
throw new RangeError('Potree gathered node byte budget exceeded');
|
|
100
|
+
}
|
|
101
|
+
const mesh = await tiler.getTileMesh(node.id);
|
|
102
|
+
const nodeBytes = Array.from(
|
|
103
|
+
new Set(Object.values(mesh.attributes).map(attribute => attribute.value.buffer))
|
|
104
|
+
).reduce((bytes, buffer) => bytes + buffer.byteLength, 0);
|
|
105
|
+
if (nodeBytes > options.maxGatheredBytes - gatheredBytes) {
|
|
106
|
+
throw new RangeError('Potree gathered node byte budget exceeded');
|
|
107
|
+
}
|
|
108
|
+
if (node.pointCount) pointByteLength = nodeBytes / node.pointCount;
|
|
109
|
+
gatheredBytes += nodeBytes;
|
|
110
|
+
nodes.push({id: node.id, mesh});
|
|
111
|
+
const children = await tiler.getChildNodes(node.id);
|
|
112
|
+
for (let index = children.length - 1; index >= 0; index--) pending.push(children[index]);
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
tiler.close();
|
|
116
|
+
}
|
|
117
|
+
const dataset = await encodePotreeDataset(nodes, {
|
|
118
|
+
potree: {
|
|
119
|
+
...options.potree,
|
|
120
|
+
projection: options.projection,
|
|
121
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
122
|
+
signal: options.signal
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
const archive = options.archive
|
|
126
|
+
? await encodePotreeDatasetArchive(dataset, options.archive.maxArchiveBytes, options.signal)
|
|
127
|
+
: undefined;
|
|
128
|
+
options.signal?.throwIfAborted();
|
|
129
|
+
return {pointCount, nodeCount: nodes.length, dataset, archive};
|
|
130
|
+
}
|
|
@@ -8,32 +8,31 @@ import type {
|
|
|
8
8
|
PointCloudTilesetSource,
|
|
9
9
|
TilesetSpatialReference
|
|
10
10
|
} from '@loaders.gl/tiles';
|
|
11
|
-
import {
|
|
12
|
-
import type {
|
|
13
|
-
import {
|
|
11
|
+
import {transformPointCloudSourceTile} from './point-cloud-spatial.js';
|
|
12
|
+
import type {PointCloudSpatialOptions} from './point-cloud-spatial.js';
|
|
13
|
+
import {encodePointCloudTileWithMetadata} from './point-cloud.js';
|
|
14
|
+
import type {EncodePointCloudTileOptions, EncodedPointCloudTile} from './point-cloud.js';
|
|
15
|
+
import {traversePointCloudSource, TileConversionError} from '@loaders.gl/tile-converter/v5/core';
|
|
14
16
|
import type {
|
|
15
17
|
PointCloudSourceTile,
|
|
16
18
|
TraversePointCloudSourceOptions
|
|
17
19
|
} from '@loaders.gl/tile-converter/v5/core';
|
|
18
20
|
|
|
19
21
|
/** Traversal and per-tile encoding options for a point-cloud source. */
|
|
20
|
-
export type EncodePointCloudSourceOptions = TraversePointCloudSourceOptions &
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
export type EncodePointCloudSourceOptions = TraversePointCloudSourceOptions &
|
|
23
|
+
Partial<Pick<PointCloudSpatialOptions, 'spatialContext' | 'normalReferenceFrame'>> & {
|
|
24
|
+
/** Select encoder options for each source tile with content, using its header and decoded data. */
|
|
25
|
+
readonly getTileEncodingOptions?: (
|
|
26
|
+
sourceTile: PointCloudSourceTile
|
|
27
|
+
) => EncodePointCloudTileOptions;
|
|
28
|
+
};
|
|
26
29
|
|
|
27
30
|
/** A PNTS resource paired with the source placement metadata needed to package its tile. */
|
|
28
|
-
export type EncodedPointCloudSourceTile = {
|
|
31
|
+
export type EncodedPointCloudSourceTile = EncodedPointCloudTile & {
|
|
29
32
|
/** Stable source tile identifier. */
|
|
30
33
|
readonly id: string;
|
|
31
34
|
/** Original source hierarchy and bounds. */
|
|
32
35
|
readonly header: PointCloudSourceTile['header'];
|
|
33
|
-
/** Encoded point-cloud tile content. */
|
|
34
|
-
readonly pnts: ArrayBuffer;
|
|
35
|
-
/** Number of point records encoded. */
|
|
36
|
-
readonly pointCount: number;
|
|
37
36
|
/** Coordinate system used by the returned point positions. */
|
|
38
37
|
readonly coordinateSystem: PointCloudCoordinateSystem;
|
|
39
38
|
/** Origin to add to source-relative point positions. */
|
|
@@ -57,7 +56,13 @@ export async function* encodePointCloudSource(
|
|
|
57
56
|
source: PointCloudTilesetSource,
|
|
58
57
|
options: EncodePointCloudSourceOptions = {}
|
|
59
58
|
): AsyncIterableIterator<EncodedPointCloudSourceTile> {
|
|
60
|
-
for await (const
|
|
59
|
+
for await (const inputTile of traversePointCloudSource(source, options)) {
|
|
60
|
+
const sourceTile = options.spatialContext
|
|
61
|
+
? await transformPointCloudSourceTile(inputTile, {
|
|
62
|
+
...options,
|
|
63
|
+
spatialContext: options.spatialContext
|
|
64
|
+
})
|
|
65
|
+
: inputTile;
|
|
61
66
|
const encodingOptions = sourceTile.content
|
|
62
67
|
? options.getTileEncodingOptions?.(sourceTile)
|
|
63
68
|
: undefined;
|
|
@@ -88,14 +93,20 @@ export function encodePointCloudSourceTile(
|
|
|
88
93
|
return null;
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
const encoded = encodePointCloudTileWithMetadata(content.data, {
|
|
97
|
+
...options,
|
|
98
|
+
constantRGBA: options.constantRGBA ?? content.constantRGBA
|
|
99
|
+
});
|
|
100
|
+
if (content.pointCount !== encoded.pointCount) {
|
|
101
|
+
throw new TileConversionError(
|
|
102
|
+
'POINT_CLOUD_SOURCE_COUNT_INVALID',
|
|
103
|
+
'Source pointCount must match the encoded Arrow point rows'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
91
106
|
return {
|
|
92
107
|
id: header.id,
|
|
93
108
|
header,
|
|
94
|
-
|
|
95
|
-
...options,
|
|
96
|
-
constantRGBA: options.constantRGBA ?? content.constantRGBA
|
|
97
|
-
}),
|
|
98
|
-
pointCount: content.pointCount,
|
|
109
|
+
...encoded,
|
|
99
110
|
coordinateSystem: content.coordinateSystem,
|
|
100
111
|
cartographicOrigin: [...content.cartographicOrigin],
|
|
101
112
|
modelMatrix: content.modelMatrix ? Array.from(content.modelMatrix) : undefined,
|