@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
package/dist/v5/node.js
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
// src/v5/node-source.ts
|
|
2
|
+
import { open, realpath } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parse3DTilesArchive as parse3DTilesArchive2 } from "@loaders.gl/3d-tiles";
|
|
6
|
+
import {
|
|
7
|
+
createBrowserTilesetConversionSource,
|
|
8
|
+
TileConversionError as TileConversionError3
|
|
9
|
+
} from "@loaders.gl/tile-converter/v5/core";
|
|
10
|
+
|
|
11
|
+
// src/v5/conversion-api.ts
|
|
12
|
+
var TileConversionError = class extends Error {
|
|
13
|
+
/** Stable machine-readable failure code. */
|
|
14
|
+
code;
|
|
15
|
+
/** Diagnostics associated with this failure. */
|
|
16
|
+
diagnostics;
|
|
17
|
+
/** Creates a typed conversion failure. */
|
|
18
|
+
constructor(code, message, diagnostics = []) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "TileConversionError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.diagnostics = diagnostics;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/v5/resource-fetcher.ts
|
|
27
|
+
function createTileConversionResourceFetcher(options) {
|
|
28
|
+
let consumed = options.initialInputBytes ?? 0;
|
|
29
|
+
if (!Number.isSafeInteger(options.maxInputBytes) || options.maxInputBytes < 1 || !Number.isSafeInteger(consumed) || consumed < 0 || consumed > options.maxInputBytes)
|
|
30
|
+
throw new TileConversionError(
|
|
31
|
+
"INVALID_INPUT_MEMORY_LIMIT",
|
|
32
|
+
"Resource fetch requires a positive aggregate byte budget and valid initial bytes"
|
|
33
|
+
);
|
|
34
|
+
const fetcher = options.fetcher ?? fetch;
|
|
35
|
+
return async (input, requestOptions) => {
|
|
36
|
+
options.signal?.throwIfAborted();
|
|
37
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
38
|
+
if (!["http:", "https:", "blob:", "data:"].includes(url.protocol))
|
|
39
|
+
throw new TileConversionError(
|
|
40
|
+
"EXTERNAL_RESOURCE_UNSUPPORTED",
|
|
41
|
+
"Conversion transport requires HTTP(S), Blob or embedded data resources"
|
|
42
|
+
);
|
|
43
|
+
const requestSignal = requestOptions?.signal ?? (input instanceof Request ? input.signal : void 0);
|
|
44
|
+
const signal = options.signal && requestSignal ? AbortSignal.any([options.signal, requestSignal]) : options.signal ?? requestSignal;
|
|
45
|
+
const response = await fetcher(input, { ...requestOptions, signal });
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
await response.body?.cancel();
|
|
48
|
+
throw new TileConversionError(
|
|
49
|
+
"BROWSER_SOURCE_FETCH_FAILED",
|
|
50
|
+
`Could not fetch conversion resource (${response.status})`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const reader = response.body?.getReader();
|
|
54
|
+
if (!reader)
|
|
55
|
+
throw new TileConversionError(
|
|
56
|
+
"BROWSER_SOURCE_STREAM_UNAVAILABLE",
|
|
57
|
+
"Conversion resources require readable response streams"
|
|
58
|
+
);
|
|
59
|
+
const cancelRead = () => {
|
|
60
|
+
void reader.cancel(signal?.reason).catch(() => {
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
signal?.addEventListener("abort", cancelRead, { once: true });
|
|
64
|
+
const parts = [];
|
|
65
|
+
let byteLength = 0;
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
const { done, value } = await reader.read();
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
if (done) break;
|
|
72
|
+
if (value.byteLength > options.maxInputBytes - consumed)
|
|
73
|
+
throw new TileConversionError(
|
|
74
|
+
"INPUT_RESOURCE_TOO_LARGE",
|
|
75
|
+
"Selected contents and external dependencies exceed the aggregate input byte budget"
|
|
76
|
+
);
|
|
77
|
+
consumed += value.byteLength;
|
|
78
|
+
byteLength += value.byteLength;
|
|
79
|
+
parts.push(value);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await reader.cancel(error);
|
|
83
|
+
throw error;
|
|
84
|
+
} finally {
|
|
85
|
+
signal?.removeEventListener("abort", cancelRead);
|
|
86
|
+
reader.releaseLock();
|
|
87
|
+
}
|
|
88
|
+
const bytes = new Uint8Array(byteLength);
|
|
89
|
+
let offset = 0;
|
|
90
|
+
for (const part of parts) {
|
|
91
|
+
bytes.set(part, offset);
|
|
92
|
+
offset += part.byteLength;
|
|
93
|
+
}
|
|
94
|
+
const boundedResponse = new Response(bytes, {
|
|
95
|
+
status: response.status,
|
|
96
|
+
statusText: response.statusText,
|
|
97
|
+
headers: response.headers
|
|
98
|
+
});
|
|
99
|
+
Object.defineProperty(boundedResponse, "url", { value: response.url });
|
|
100
|
+
return boundedResponse;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/v5/archive-source.ts
|
|
105
|
+
import { BlobFile, HttpFile } from "@loaders.gl/loader-utils";
|
|
106
|
+
import { parseSLPKArchive } from "@loaders.gl/i3s";
|
|
107
|
+
import { parse3DTilesArchive } from "@loaders.gl/3d-tiles";
|
|
108
|
+
import { TileConversionError as TileConversionError2 } from "@loaders.gl/tile-converter/v5/core";
|
|
109
|
+
async function openTileConversionArchive(descriptor, maxInputBytes, signal, fetcher = fetch) {
|
|
110
|
+
signal.throwIfAborted();
|
|
111
|
+
if (!["slpk", "3tz"].includes(descriptor.format))
|
|
112
|
+
throw new TileConversionError2("INVALID_ARCHIVE_SOURCE", "Select SLPK or 3TZ archive input");
|
|
113
|
+
validateArchiveEntityTag(descriptor.identity?.etag);
|
|
114
|
+
const transport = createTileConversionResourceFetcher({
|
|
115
|
+
maxInputBytes,
|
|
116
|
+
signal,
|
|
117
|
+
fetcher: async (url, options) => {
|
|
118
|
+
const response = await fetcher(url, options);
|
|
119
|
+
if (response.status !== 206) {
|
|
120
|
+
await response.body?.cancel();
|
|
121
|
+
throw new Error("Remote conversion archives require HTTP 206 byte-range responses.");
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
validateArchiveResponseValidator(response.headers);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
await response.body?.cancel(error);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
return response;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
let file;
|
|
133
|
+
let closePromise;
|
|
134
|
+
async function closeFile() {
|
|
135
|
+
if (file) {
|
|
136
|
+
closePromise ??= file.close();
|
|
137
|
+
await closePromise;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
if (typeof descriptor.input === "string") {
|
|
142
|
+
if (!["http:", "https:"].includes(new URL(descriptor.input).protocol))
|
|
143
|
+
throw new Error("Use an HTTP(S) archive URL.");
|
|
144
|
+
const remote = new HttpFile(descriptor.input, {
|
|
145
|
+
fetch: (url, options) => transport(url, options),
|
|
146
|
+
consistency: "strict",
|
|
147
|
+
...descriptor.identity
|
|
148
|
+
});
|
|
149
|
+
file = remote;
|
|
150
|
+
await remote.open(signal);
|
|
151
|
+
} else file = new BlobFile(descriptor.input);
|
|
152
|
+
const ownedFile = file;
|
|
153
|
+
file = void 0;
|
|
154
|
+
return await createTileConversionArchiveTransport(
|
|
155
|
+
ownedFile,
|
|
156
|
+
descriptor.format,
|
|
157
|
+
maxInputBytes,
|
|
158
|
+
signal,
|
|
159
|
+
descriptor
|
|
160
|
+
);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await closeFile();
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function createTileConversionArchiveTransport(file, format, maxInputBytes, signal, descriptor) {
|
|
167
|
+
const rootUrl = `https://conversion.invalid/input.${format}`;
|
|
168
|
+
let closed = false;
|
|
169
|
+
let releasePromise;
|
|
170
|
+
let closePromise;
|
|
171
|
+
const closeFile = () => {
|
|
172
|
+
closed = true;
|
|
173
|
+
closePromise ??= file.close();
|
|
174
|
+
return closePromise;
|
|
175
|
+
};
|
|
176
|
+
const checkOpen = () => {
|
|
177
|
+
signal.throwIfAborted();
|
|
178
|
+
if (closed)
|
|
179
|
+
throw new TileConversionError2("ARCHIVE_SOURCE_CLOSED", "Conversion archive is closed");
|
|
180
|
+
};
|
|
181
|
+
try {
|
|
182
|
+
checkOpen();
|
|
183
|
+
if (!["slpk", "3tz"].includes(format) || !Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1)
|
|
184
|
+
throw new TileConversionError2(
|
|
185
|
+
"INVALID_ARCHIVE_SOURCE",
|
|
186
|
+
"Archive source requires a supported format and positive safe byte budget"
|
|
187
|
+
);
|
|
188
|
+
if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > maxInputBytes)
|
|
189
|
+
throw new Error("Conversion archive input must be at most 16 MiB.");
|
|
190
|
+
let readBytes = 0;
|
|
191
|
+
const boundedFile = {
|
|
192
|
+
handle: file.handle,
|
|
193
|
+
size: file.size,
|
|
194
|
+
bigsize: file.bigsize,
|
|
195
|
+
url: file.url,
|
|
196
|
+
...file.stat ? { stat: () => file.stat() } : {},
|
|
197
|
+
close: closeFile,
|
|
198
|
+
/** Checks indexed reads before allocation and forwards operation cancellation. */
|
|
199
|
+
read: async (start, length) => {
|
|
200
|
+
checkOpen();
|
|
201
|
+
if (!Number.isSafeInteger(length) || length < 0 || length > maxInputBytes - readBytes)
|
|
202
|
+
throw new TileConversionError2(
|
|
203
|
+
"INPUT_RESOURCE_TOO_LARGE",
|
|
204
|
+
"Archive indexed reads exceed the input budget"
|
|
205
|
+
);
|
|
206
|
+
readBytes += length;
|
|
207
|
+
const bytes = await file.read(start, length, signal);
|
|
208
|
+
checkOpen();
|
|
209
|
+
return bytes;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const archive = format === "slpk" ? await parseSLPKArchive(boundedFile) : await parse3DTilesArchive(boundedFile);
|
|
213
|
+
const root = new URL(rootUrl);
|
|
214
|
+
return {
|
|
215
|
+
rootUrl: format === "3tz" ? `${rootUrl}/tileset.json` : rootUrl,
|
|
216
|
+
descriptor: descriptor ? {
|
|
217
|
+
...descriptor,
|
|
218
|
+
...file instanceof HttpFile ? { identity: file.getIdentitySnapshot() } : {}
|
|
219
|
+
} : void 0,
|
|
220
|
+
/** Resolves only resources belonging to this archive, without external network fallback. */
|
|
221
|
+
fetcher: (async (input, options) => {
|
|
222
|
+
checkOpen();
|
|
223
|
+
options?.signal?.throwIfAborted();
|
|
224
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
225
|
+
if (url.origin !== root.origin || url.pathname !== root.pathname && !url.pathname.startsWith(`${root.pathname}/`))
|
|
226
|
+
throw new Error("Conversion dependencies must resolve inside the selected archive.");
|
|
227
|
+
const path = decodeURIComponent(
|
|
228
|
+
url.pathname.slice(root.pathname.length).replace(/^\//, "")
|
|
229
|
+
);
|
|
230
|
+
if (path.includes("\\") || path.split("/").some((segment) => segment === ".." || segment === "."))
|
|
231
|
+
throw new Error("Archive dependency path cannot escape its root.");
|
|
232
|
+
const bytes = format === "slpk" ? await archive.getFile(path, "http") : await archive.getFile(path);
|
|
233
|
+
checkOpen();
|
|
234
|
+
options?.signal?.throwIfAborted();
|
|
235
|
+
return new Response(bytes);
|
|
236
|
+
}),
|
|
237
|
+
/** Releases the readable file on success, failure or cancellation. */
|
|
238
|
+
close: async () => {
|
|
239
|
+
closed = true;
|
|
240
|
+
releasePromise ??= archive.close();
|
|
241
|
+
await releasePromise;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
} catch (error) {
|
|
245
|
+
await closeFile();
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function validateArchiveEntityTag(entityTag) {
|
|
250
|
+
if (entityTag !== void 0 && entityTag !== null && !/^"[\x21\x23-\x7e\x80-\xff]*"$/.test(entityTag))
|
|
251
|
+
throw new TileConversionError2(
|
|
252
|
+
"ARCHIVE_VALIDATOR_UNSUPPORTED",
|
|
253
|
+
"Remote archives require a strong quoted ETag, or Last-Modified without an ETag; weak/malformed ETags are unsupported"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
function validateArchiveResponseValidator(headers) {
|
|
257
|
+
const entityTag = headers.get("etag");
|
|
258
|
+
validateArchiveEntityTag(entityTag);
|
|
259
|
+
if (entityTag !== null) return;
|
|
260
|
+
const lastModified = headers.get("last-modified");
|
|
261
|
+
const responseDate = headers.get("date");
|
|
262
|
+
const modifiedTime = parseArchiveHttpDate(lastModified);
|
|
263
|
+
const responseTime = parseArchiveHttpDate(responseDate);
|
|
264
|
+
if (!Number.isFinite(modifiedTime) || !Number.isFinite(responseTime) || responseTime - modifiedTime < 6e4)
|
|
265
|
+
throw new TileConversionError2(
|
|
266
|
+
"ARCHIVE_VALIDATOR_UNSUPPORTED",
|
|
267
|
+
"Last-Modified archive identity requires a valid Date at least 60 seconds later; otherwise supply a strong ETag"
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
function parseArchiveHttpDate(value) {
|
|
271
|
+
const milliseconds = value === null ? NaN : Date.parse(value);
|
|
272
|
+
return Number.isFinite(milliseconds) && new Date(milliseconds).toUTCString() === value ? milliseconds : NaN;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/v5/node-source.ts
|
|
276
|
+
var NODE_TILESET_LIMITS = {
|
|
277
|
+
/** Aggregate response bytes and, separately, indexed archive read bytes. */
|
|
278
|
+
maxInputBytes: 16 * 1024 * 1024,
|
|
279
|
+
/** Declared content placements in the explicit root document. */
|
|
280
|
+
maxInputResources: 1e3,
|
|
281
|
+
/** Maximum root/external document placements inspected or traversed. */
|
|
282
|
+
maxTilesetDocuments: 32
|
|
283
|
+
};
|
|
284
|
+
async function createNodeTilesetConversionSource(options) {
|
|
285
|
+
const maxInputBytes = options.maxInputBytes ?? NODE_TILESET_LIMITS.maxInputBytes;
|
|
286
|
+
const maxInputResources = options.maxInputResources ?? NODE_TILESET_LIMITS.maxInputResources;
|
|
287
|
+
const maxTilesetDocuments = options.maxTilesetDocuments ?? 1;
|
|
288
|
+
if (!Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1 || maxInputBytes > NODE_TILESET_LIMITS.maxInputBytes || !Number.isSafeInteger(maxInputResources) || maxInputResources < 1 || maxInputResources > NODE_TILESET_LIMITS.maxInputResources || !Number.isSafeInteger(maxTilesetDocuments) || maxTilesetDocuments < 1 || maxTilesetDocuments > NODE_TILESET_LIMITS.maxTilesetDocuments || !["3d-tiles", "3tz"].includes(options.format ?? "3d-tiles"))
|
|
289
|
+
throw new TileConversionError3(
|
|
290
|
+
"INVALID_NODE_SOURCE_OPTIONS",
|
|
291
|
+
"Node sources require positive byte/resource budgets within the initial profile limits"
|
|
292
|
+
);
|
|
293
|
+
options.signal?.throwIfAborted();
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const lifetimeSignal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
|
|
296
|
+
let archiveFile;
|
|
297
|
+
let closeArchive;
|
|
298
|
+
let consumedReadBytes = 0;
|
|
299
|
+
const reserveRead = (length) => {
|
|
300
|
+
if (length > maxInputBytes - consumedReadBytes)
|
|
301
|
+
throw new TileConversionError3(
|
|
302
|
+
"INPUT_RESOURCE_TOO_LARGE",
|
|
303
|
+
"Local reads exceed the aggregate input budget"
|
|
304
|
+
);
|
|
305
|
+
consumedReadBytes += length;
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
const inputUrl = /^(?:https?|file):/i.test(options.input) ? new URL(options.input) : void 0;
|
|
309
|
+
if (!inputUrl && /^[a-z][a-z\d+.-]*:/i.test(options.input) && !/^[a-z]:[\\/]/i.test(options.input))
|
|
310
|
+
throw new TileConversionError3(
|
|
311
|
+
"UNSUPPORTED_NODE_SOURCE",
|
|
312
|
+
"Use a local path, file URL or HTTP(S) tileset URL"
|
|
313
|
+
);
|
|
314
|
+
const remote = inputUrl && inputUrl.protocol !== "file:";
|
|
315
|
+
let rootUrl;
|
|
316
|
+
let transport;
|
|
317
|
+
let virtualRoot;
|
|
318
|
+
if (remote && options.format === "3tz") {
|
|
319
|
+
const archive = await openTileConversionArchive(
|
|
320
|
+
{ input: inputUrl.href, format: "3tz" },
|
|
321
|
+
maxInputBytes,
|
|
322
|
+
lifetimeSignal,
|
|
323
|
+
options.fetcher
|
|
324
|
+
);
|
|
325
|
+
rootUrl = archive.rootUrl;
|
|
326
|
+
transport = archive.fetcher;
|
|
327
|
+
closeArchive = archive.close;
|
|
328
|
+
} else if (remote) {
|
|
329
|
+
rootUrl = inputUrl.href;
|
|
330
|
+
transport = options.fetcher ?? fetch;
|
|
331
|
+
} else {
|
|
332
|
+
const inputPath = await realpath(inputUrl ? fileURLToPath(inputUrl) : resolve(options.input));
|
|
333
|
+
lifetimeSignal.throwIfAborted();
|
|
334
|
+
const directory = dirname(inputPath);
|
|
335
|
+
virtualRoot = new URL("https://conversion.invalid/input/");
|
|
336
|
+
rootUrl = new URL(
|
|
337
|
+
options.format === "3tz" ? "tileset.json" : encodeURIComponent(basename(inputPath)),
|
|
338
|
+
virtualRoot
|
|
339
|
+
).href;
|
|
340
|
+
if (options.format === "3tz") {
|
|
341
|
+
archiveFile = await openLocalFile(inputPath, maxInputBytes, lifetimeSignal, reserveRead);
|
|
342
|
+
const archive = await parse3DTilesArchive2(archiveFile);
|
|
343
|
+
transport = async (input, requestOptions) => {
|
|
344
|
+
lifetimeSignal.throwIfAborted();
|
|
345
|
+
requestOptions?.signal?.throwIfAborted();
|
|
346
|
+
const path = getLocalResourcePath(input, virtualRoot);
|
|
347
|
+
const bytes = await archive.getFile(path);
|
|
348
|
+
lifetimeSignal.throwIfAborted();
|
|
349
|
+
requestOptions?.signal?.throwIfAborted();
|
|
350
|
+
return new Response(bytes);
|
|
351
|
+
};
|
|
352
|
+
} else {
|
|
353
|
+
transport = async (input, requestOptions) => {
|
|
354
|
+
const path = getLocalResourcePath(input, virtualRoot);
|
|
355
|
+
const resolvedPath = await realpath(resolve(directory, path));
|
|
356
|
+
const relativePath = relative(directory, resolvedPath);
|
|
357
|
+
if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath))
|
|
358
|
+
throw new TileConversionError3(
|
|
359
|
+
"EXTERNAL_RESOURCE_UNSUPPORTED",
|
|
360
|
+
"Local dependencies must remain inside the tileset directory"
|
|
361
|
+
);
|
|
362
|
+
const signal = requestOptions?.signal ? AbortSignal.any([lifetimeSignal, requestOptions.signal]) : lifetimeSignal;
|
|
363
|
+
const file = await openLocalFile(resolvedPath, maxInputBytes, signal, reserveRead);
|
|
364
|
+
try {
|
|
365
|
+
return new Response(await file.read());
|
|
366
|
+
} finally {
|
|
367
|
+
await file.close();
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const resourceFetcher = createTileConversionResourceFetcher({
|
|
373
|
+
maxInputBytes,
|
|
374
|
+
signal: lifetimeSignal,
|
|
375
|
+
fetcher: transport
|
|
376
|
+
});
|
|
377
|
+
const source = createBrowserTilesetConversionSource({
|
|
378
|
+
input: rootUrl,
|
|
379
|
+
maxInputBytes,
|
|
380
|
+
maxInputResources,
|
|
381
|
+
maxTilesetDocuments,
|
|
382
|
+
inspectNestedTilesets: options.inspectNestedTilesets,
|
|
383
|
+
fetcher: resourceFetcher
|
|
384
|
+
});
|
|
385
|
+
return {
|
|
386
|
+
fetcher: resourceFetcher,
|
|
387
|
+
/** Inspects declarations without opening tile payloads or decoding coordinates. */
|
|
388
|
+
async inspect(signal) {
|
|
389
|
+
const inspection = await source.inspect(
|
|
390
|
+
signal ? AbortSignal.any([lifetimeSignal, signal]) : lifetimeSignal
|
|
391
|
+
);
|
|
392
|
+
if (virtualRoot)
|
|
393
|
+
for (const resource of inspection.resources)
|
|
394
|
+
getLocalResourcePath(
|
|
395
|
+
new URL(resource.uri, resource.tilesetUrl ?? inspection.rootUrl),
|
|
396
|
+
virtualRoot
|
|
397
|
+
);
|
|
398
|
+
return inspection;
|
|
399
|
+
},
|
|
400
|
+
/** Reads each declared placement with the same lifetime-wide input budget. */
|
|
401
|
+
read: (inspection, signal) => source.read(
|
|
402
|
+
inspection,
|
|
403
|
+
signal ? AbortSignal.any([lifetimeSignal, signal]) : lifetimeSignal
|
|
404
|
+
),
|
|
405
|
+
/** Cancels future reads and releases the archive handle. */
|
|
406
|
+
async close() {
|
|
407
|
+
controller.abort(new TileConversionError3("NODE_SOURCE_CLOSED", "Node source is closed"));
|
|
408
|
+
const file = archiveFile;
|
|
409
|
+
archiveFile = void 0;
|
|
410
|
+
await closeArchive?.();
|
|
411
|
+
await file?.close();
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
} catch (error) {
|
|
415
|
+
await closeArchive?.();
|
|
416
|
+
await archiveFile?.close();
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function getLocalResourcePath(input, root) {
|
|
421
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
422
|
+
if (url.origin !== root.origin || !url.pathname.startsWith(root.pathname))
|
|
423
|
+
throw new TileConversionError3(
|
|
424
|
+
"EXTERNAL_RESOURCE_UNSUPPORTED",
|
|
425
|
+
"Dependencies must resolve inside the selected local input"
|
|
426
|
+
);
|
|
427
|
+
const path = decodeURIComponent(url.pathname.slice(root.pathname.length));
|
|
428
|
+
if (!path || path.startsWith("/") || path.includes("\\") || path.split("/").some((segment) => segment === ".." || segment === "."))
|
|
429
|
+
throw new TileConversionError3(
|
|
430
|
+
"EXTERNAL_RESOURCE_UNSUPPORTED",
|
|
431
|
+
"Dependency paths cannot escape the selected input"
|
|
432
|
+
);
|
|
433
|
+
return path;
|
|
434
|
+
}
|
|
435
|
+
async function openLocalFile(path, maxInputBytes, signal, reserveRead) {
|
|
436
|
+
signal.throwIfAborted();
|
|
437
|
+
const handle = await open(path, "r");
|
|
438
|
+
try {
|
|
439
|
+
signal.throwIfAborted();
|
|
440
|
+
const information = await handle.stat();
|
|
441
|
+
if (!information.isFile() || !Number.isSafeInteger(information.size) || information.size > maxInputBytes)
|
|
442
|
+
throw new TileConversionError3(
|
|
443
|
+
"INPUT_RESOURCE_TOO_LARGE",
|
|
444
|
+
"Local input must be a regular file within the byte budget"
|
|
445
|
+
);
|
|
446
|
+
const size = information.size;
|
|
447
|
+
return {
|
|
448
|
+
handle,
|
|
449
|
+
size,
|
|
450
|
+
bigsize: BigInt(size),
|
|
451
|
+
url: path,
|
|
452
|
+
/** Reads a bounded range, checking cancellation and unexpected EOF between native reads. */
|
|
453
|
+
async read(start = 0, length = size - Number(start), readSignal) {
|
|
454
|
+
const operationSignal = readSignal ? AbortSignal.any([signal, readSignal]) : signal;
|
|
455
|
+
operationSignal.throwIfAborted();
|
|
456
|
+
const offset = Number(start);
|
|
457
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 0 || offset > size - length)
|
|
458
|
+
throw new TileConversionError3(
|
|
459
|
+
"INVALID_INPUT_RANGE",
|
|
460
|
+
"Local file reads require a range inside the file"
|
|
461
|
+
);
|
|
462
|
+
reserveRead(length);
|
|
463
|
+
const bytes = new Uint8Array(length);
|
|
464
|
+
let readBytes = 0;
|
|
465
|
+
while (readBytes < length) {
|
|
466
|
+
operationSignal.throwIfAborted();
|
|
467
|
+
const result = await handle.read(
|
|
468
|
+
bytes,
|
|
469
|
+
readBytes,
|
|
470
|
+
length - readBytes,
|
|
471
|
+
offset + readBytes
|
|
472
|
+
);
|
|
473
|
+
if (result.bytesRead === 0)
|
|
474
|
+
throw new TileConversionError3(
|
|
475
|
+
"INPUT_FILE_TRUNCATED",
|
|
476
|
+
"Local input ended before its declared size"
|
|
477
|
+
);
|
|
478
|
+
readBytes += result.bytesRead;
|
|
479
|
+
}
|
|
480
|
+
operationSignal.throwIfAborted();
|
|
481
|
+
return bytes.buffer;
|
|
482
|
+
},
|
|
483
|
+
/** Releases the Node file handle. */
|
|
484
|
+
close: () => handle.close()
|
|
485
|
+
};
|
|
486
|
+
} catch (error) {
|
|
487
|
+
await handle.close();
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/v5/node-conversion.ts
|
|
493
|
+
import { mkdir, open as open2, rm, link, mkdtemp } from "node:fs/promises";
|
|
494
|
+
import { dirname as dirname2, join, resolve as resolve3 } from "node:path";
|
|
495
|
+
import { convertTileset, TileConversionError as TileConversionError5 } from "@loaders.gl/tile-converter/v5/core";
|
|
496
|
+
import {
|
|
497
|
+
createSelectedMeshConversionSource,
|
|
498
|
+
measureMeshBytes,
|
|
499
|
+
SELECTED_MESH_LIMITS,
|
|
500
|
+
createMeshConversionCodec,
|
|
501
|
+
createI3SMeshConversionCodec,
|
|
502
|
+
createMeshTilesetSink,
|
|
503
|
+
createMeshHierarchyTilesetSink,
|
|
504
|
+
createI3SMeshSink,
|
|
505
|
+
encodeTileConversionArchiveInBatches
|
|
506
|
+
} from "@loaders.gl/tile-converter/v5/adapters";
|
|
507
|
+
|
|
508
|
+
// src/v5/node-i3s-source.ts
|
|
509
|
+
import { realpath as realpath2 } from "node:fs/promises";
|
|
510
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
511
|
+
import { resolve as resolve2 } from "node:path";
|
|
512
|
+
import { TileConversionError as TileConversionError4 } from "@loaders.gl/tile-converter/v5/core";
|
|
513
|
+
import {
|
|
514
|
+
createSelectedI3SMeshConversionSource,
|
|
515
|
+
inspectI3SMeshConversionSource,
|
|
516
|
+
createTileConversionArchiveTransport as createTileConversionArchiveTransport2,
|
|
517
|
+
openTileConversionArchive as openTileConversionArchive2
|
|
518
|
+
} from "@loaders.gl/tile-converter/v5/adapters";
|
|
519
|
+
async function createNodeI3SConversionSource(options) {
|
|
520
|
+
const maxInputBytes = options.maxInputBytes ?? NODE_TILESET_LIMITS.maxInputBytes;
|
|
521
|
+
const maxInputResources = options.maxInputResources ?? NODE_TILESET_LIMITS.maxInputResources;
|
|
522
|
+
if (!["i3s", "slpk"].includes(options.format) || !Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1 || maxInputBytes > NODE_TILESET_LIMITS.maxInputBytes || !Number.isSafeInteger(maxInputResources) || maxInputResources < 1 || maxInputResources > NODE_TILESET_LIMITS.maxInputResources)
|
|
523
|
+
throw new TileConversionError4(
|
|
524
|
+
"INVALID_NODE_SOURCE_OPTIONS",
|
|
525
|
+
"I3S sources require positive budgets within the bounded input profile"
|
|
526
|
+
);
|
|
527
|
+
const controller = new AbortController();
|
|
528
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
529
|
+
signal.throwIfAborted();
|
|
530
|
+
let release;
|
|
531
|
+
let closePromise;
|
|
532
|
+
const close = async () => {
|
|
533
|
+
controller.abort(new TileConversionError4("NODE_SOURCE_CLOSED", "Node source is closed"));
|
|
534
|
+
closePromise ??= release?.() ?? Promise.resolve();
|
|
535
|
+
await closePromise;
|
|
536
|
+
};
|
|
537
|
+
try {
|
|
538
|
+
const remote = /^https?:/i.test(options.input);
|
|
539
|
+
if (options.format === "i3s" && !remote)
|
|
540
|
+
throw new TileConversionError4(
|
|
541
|
+
"UNSUPPORTED_NODE_SOURCE",
|
|
542
|
+
"I3S input requires an HTTP(S) layer URL; use slpk for a local archive"
|
|
543
|
+
);
|
|
544
|
+
let rootUrl;
|
|
545
|
+
let transport;
|
|
546
|
+
if (options.format === "slpk") {
|
|
547
|
+
const archive = remote ? await openTileConversionArchive2(
|
|
548
|
+
{ input: options.input, format: "slpk" },
|
|
549
|
+
maxInputBytes,
|
|
550
|
+
signal,
|
|
551
|
+
options.fetcher
|
|
552
|
+
) : await createTileConversionArchiveTransport2(
|
|
553
|
+
await openLocalFile(
|
|
554
|
+
await realpath2(
|
|
555
|
+
/^file:/i.test(options.input) ? fileURLToPath2(options.input) : resolve2(options.input)
|
|
556
|
+
),
|
|
557
|
+
maxInputBytes,
|
|
558
|
+
signal,
|
|
559
|
+
() => {
|
|
560
|
+
}
|
|
561
|
+
),
|
|
562
|
+
"slpk",
|
|
563
|
+
maxInputBytes,
|
|
564
|
+
signal
|
|
565
|
+
);
|
|
566
|
+
release = archive.close;
|
|
567
|
+
rootUrl = archive.rootUrl;
|
|
568
|
+
transport = archive.fetcher;
|
|
569
|
+
} else {
|
|
570
|
+
rootUrl = new URL(options.input).href;
|
|
571
|
+
transport = options.fetcher ?? fetch;
|
|
572
|
+
}
|
|
573
|
+
const bounded = createTileConversionResourceFetcher({
|
|
574
|
+
maxInputBytes,
|
|
575
|
+
signal,
|
|
576
|
+
fetcher: transport
|
|
577
|
+
});
|
|
578
|
+
let metadataBytes = 0;
|
|
579
|
+
const root = new URL(rootUrl);
|
|
580
|
+
const fetcher = async (input, requestOptions) => {
|
|
581
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
582
|
+
if (url.origin === root.origin && (url.pathname === root.pathname || url.pathname.startsWith(`${root.pathname}/`))) {
|
|
583
|
+
for (const [key, value] of root.searchParams)
|
|
584
|
+
if (!url.searchParams.has(key)) url.searchParams.set(key, value);
|
|
585
|
+
}
|
|
586
|
+
const response = await bounded(
|
|
587
|
+
input instanceof Request ? new Request(url, input) : url,
|
|
588
|
+
requestOptions
|
|
589
|
+
);
|
|
590
|
+
if (response.url && response.url !== url.href)
|
|
591
|
+
throw new TileConversionError4(
|
|
592
|
+
"I3S_REDIRECT_UNSUPPORTED",
|
|
593
|
+
"Use the final I3S resource URL; redirected resource bases are not qualified"
|
|
594
|
+
);
|
|
595
|
+
const bytes = await response.arrayBuffer();
|
|
596
|
+
metadataBytes += bytes.byteLength;
|
|
597
|
+
const result = new Response(bytes, { status: response.status, headers: response.headers });
|
|
598
|
+
Object.defineProperty(result, "url", { value: response.url });
|
|
599
|
+
return result;
|
|
600
|
+
};
|
|
601
|
+
const selectionOptions = {
|
|
602
|
+
rootUrl,
|
|
603
|
+
fetcher,
|
|
604
|
+
signal,
|
|
605
|
+
maxInputBytes,
|
|
606
|
+
maxInputResources,
|
|
607
|
+
dracoLibraryOptions: { decoderProfile: "full" }
|
|
608
|
+
};
|
|
609
|
+
return {
|
|
610
|
+
close,
|
|
611
|
+
/** Reads only layer and node metadata, under the same lifetime-wide budget as conversion. */
|
|
612
|
+
async inspect(operationSignal) {
|
|
613
|
+
signal.throwIfAborted();
|
|
614
|
+
operationSignal?.throwIfAborted();
|
|
615
|
+
const activeSignal = operationSignal ? AbortSignal.any([signal, operationSignal]) : signal;
|
|
616
|
+
const operationFetcher = (input, requestOptions) => fetcher(input, {
|
|
617
|
+
...requestOptions,
|
|
618
|
+
signal: requestOptions?.signal ? AbortSignal.any([activeSignal, requestOptions.signal]) : activeSignal
|
|
619
|
+
});
|
|
620
|
+
const inspection = await inspectI3SMeshConversionSource({
|
|
621
|
+
...selectionOptions,
|
|
622
|
+
fetcher: operationFetcher,
|
|
623
|
+
signal: activeSignal
|
|
624
|
+
});
|
|
625
|
+
return { ...inspection, metadataBytes };
|
|
626
|
+
},
|
|
627
|
+
/** Creates an original-coordinate selected mesh source using application-provided Draco assets. */
|
|
628
|
+
select: (inspection, resourceIds, dracoLibraryOptions, features) => {
|
|
629
|
+
signal.throwIfAborted();
|
|
630
|
+
return createSelectedI3SMeshConversionSource(inspection, {
|
|
631
|
+
...selectionOptions,
|
|
632
|
+
resourceIds,
|
|
633
|
+
dracoLibraryOptions,
|
|
634
|
+
features
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
} catch (error) {
|
|
639
|
+
await close();
|
|
640
|
+
throw error;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/v5/node-conversion.ts
|
|
645
|
+
async function convertNodeTileset(options) {
|
|
646
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
647
|
+
const maxOutputBytes = options.maxOutputBytes ?? SELECTED_MESH_LIMITS.maxOutputBytes;
|
|
648
|
+
if (!["3d-tiles", "3tz", "slpk"].includes(options.outputFormat) || !Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > SELECTED_MESH_LIMITS.maxOutputBytes)
|
|
649
|
+
throw new TileConversionError5(
|
|
650
|
+
"INVALID_NODE_CONVERSION_OPTIONS",
|
|
651
|
+
"Select a supported output profile and a positive output budget at most 32 MiB"
|
|
652
|
+
);
|
|
653
|
+
const i3s = options.format === "i3s" || options.format === "slpk";
|
|
654
|
+
if (options.preserveHierarchy !== void 0 && typeof options.preserveHierarchy !== "boolean" || options.preserveHierarchy && (i3s || options.outputFormat === "slpk"))
|
|
655
|
+
throw new TileConversionError5(
|
|
656
|
+
"MESH_HIERARCHY_UNSUPPORTED",
|
|
657
|
+
"Hierarchy preservation requires 3D Tiles/3TZ input and output"
|
|
658
|
+
);
|
|
659
|
+
if (i3s && (options.outputFormat !== "slpk" && options.geometricError === void 0 || options.geometricError !== void 0 && (typeof options.geometricError !== "number" || !Number.isFinite(options.geometricError) || options.geometricError < 0)))
|
|
660
|
+
throw new TileConversionError5(
|
|
661
|
+
"MESH_GEOMETRIC_ERROR_INVALID",
|
|
662
|
+
"I3S to 3D Tiles requires an explicit nonnegative geometric error in meters"
|
|
663
|
+
);
|
|
664
|
+
if (i3s ? options.features !== void 0 : options.i3sFeatures !== void 0 || options.integratedMeshPolicy !== void 0 || options.geometricError !== void 0)
|
|
665
|
+
throw new TileConversionError5(
|
|
666
|
+
"INVALID_NODE_CONVERSION_OPTIONS",
|
|
667
|
+
"Feature and layer policies must match the selected input format"
|
|
668
|
+
);
|
|
669
|
+
const sourceOptions = {
|
|
670
|
+
...options,
|
|
671
|
+
signal,
|
|
672
|
+
inspectNestedTilesets: true,
|
|
673
|
+
maxTilesetDocuments: options.maxTilesetDocuments ?? SELECTED_MESH_LIMITS.maxTilesetDocuments
|
|
674
|
+
};
|
|
675
|
+
const rawSource = i3s ? await createNodeI3SConversionSource({
|
|
676
|
+
...sourceOptions,
|
|
677
|
+
format: options.format
|
|
678
|
+
}) : await createNodeTilesetConversionSource({
|
|
679
|
+
...sourceOptions,
|
|
680
|
+
format: options.format
|
|
681
|
+
});
|
|
682
|
+
let files;
|
|
683
|
+
let report;
|
|
684
|
+
try {
|
|
685
|
+
const inspection = await rawSource.inspect(signal);
|
|
686
|
+
await import("@loaders.gl/polyfills");
|
|
687
|
+
const { getDracoLibraryOptions } = await import("@loaders.gl/draco/bundled");
|
|
688
|
+
const dracoLibraryOptions = getDracoLibraryOptions({
|
|
689
|
+
decoderProfile: "full",
|
|
690
|
+
useLocalLibraries: true
|
|
691
|
+
});
|
|
692
|
+
const selection = "select" in rawSource ? {
|
|
693
|
+
...await rawSource.select(
|
|
694
|
+
inspection,
|
|
695
|
+
options.resourceIds,
|
|
696
|
+
dracoLibraryOptions,
|
|
697
|
+
options.i3sFeatures
|
|
698
|
+
),
|
|
699
|
+
geometricError: (options.geometricError ?? 0) + SELECTED_MESH_LIMITS.maxPositionError
|
|
700
|
+
} : await createSelectedMeshConversionSource(
|
|
701
|
+
inspection,
|
|
702
|
+
{
|
|
703
|
+
resourceIds: options.resourceIds,
|
|
704
|
+
preserveHierarchy: options.preserveHierarchy,
|
|
705
|
+
features: options.features,
|
|
706
|
+
signal,
|
|
707
|
+
source: rawSource,
|
|
708
|
+
fetcher: rawSource.fetcher,
|
|
709
|
+
dracoLibraryOptions,
|
|
710
|
+
maxInputBytes: options.maxInputBytes
|
|
711
|
+
}
|
|
712
|
+
);
|
|
713
|
+
const { source, spatialContext, geometricError } = selection;
|
|
714
|
+
const common = {
|
|
715
|
+
source,
|
|
716
|
+
signal,
|
|
717
|
+
measureInputBytes: measureMeshBytes,
|
|
718
|
+
maxInputBytes: options.maxInputBytes ?? SELECTED_MESH_LIMITS.maxInputBytes,
|
|
719
|
+
maxOutputBytes,
|
|
720
|
+
maxInputResources: SELECTED_MESH_LIMITS.maxMeshResources,
|
|
721
|
+
maxOutputResources: SELECTED_MESH_LIMITS.maxMeshResources
|
|
722
|
+
};
|
|
723
|
+
const codecOptions = {
|
|
724
|
+
spatialContext,
|
|
725
|
+
autoOrigin: i3s,
|
|
726
|
+
integratedMeshPolicy: options.integratedMeshPolicy,
|
|
727
|
+
dracoLibraryOptions,
|
|
728
|
+
maxPositionError: SELECTED_MESH_LIMITS.maxPositionError
|
|
729
|
+
};
|
|
730
|
+
if (options.outputFormat === "slpk") {
|
|
731
|
+
const sink = createI3SMeshSink({
|
|
732
|
+
maxTotalBytes: maxOutputBytes,
|
|
733
|
+
maxMeshes: SELECTED_MESH_LIMITS.maxMeshResources,
|
|
734
|
+
maxResourceBytes: maxOutputBytes
|
|
735
|
+
});
|
|
736
|
+
report = await convertTileset({
|
|
737
|
+
...common,
|
|
738
|
+
sink,
|
|
739
|
+
codec: createI3SMeshConversionCodec({ ...codecOptions, maxResourceBytes: maxOutputBytes }),
|
|
740
|
+
measureOutputBytes: (resource) => Object.values(resource.files).reduce((total, bytes) => total + bytes.byteLength, 0)
|
|
741
|
+
});
|
|
742
|
+
files = sink.getFiles();
|
|
743
|
+
} else {
|
|
744
|
+
const sink = options.preserveHierarchy ? createMeshHierarchyTilesetSink({
|
|
745
|
+
inspection,
|
|
746
|
+
resourceIds: options.resourceIds,
|
|
747
|
+
maxTotalBytes: maxOutputBytes,
|
|
748
|
+
maxPositionError: SELECTED_MESH_LIMITS.maxPositionError
|
|
749
|
+
}) : createMeshTilesetSink({
|
|
750
|
+
maxTotalBytes: maxOutputBytes,
|
|
751
|
+
maxMeshes: SELECTED_MESH_LIMITS.maxMeshResources,
|
|
752
|
+
geometricError
|
|
753
|
+
});
|
|
754
|
+
report = await convertTileset({
|
|
755
|
+
...common,
|
|
756
|
+
sink,
|
|
757
|
+
codec: createMeshConversionCodec(codecOptions),
|
|
758
|
+
measureOutputBytes: (resource) => resource.glb.byteLength
|
|
759
|
+
});
|
|
760
|
+
files = sink.getFiles();
|
|
761
|
+
}
|
|
762
|
+
} finally {
|
|
763
|
+
await rawSource.close();
|
|
764
|
+
}
|
|
765
|
+
signal.throwIfAborted();
|
|
766
|
+
if (options.outputFormat !== "3d-tiles" && !options.output) {
|
|
767
|
+
for await (const chunk of encodeTileConversionArchiveInBatches(files, {
|
|
768
|
+
format: options.outputFormat,
|
|
769
|
+
maxArchiveBytes: maxOutputBytes,
|
|
770
|
+
signal
|
|
771
|
+
}))
|
|
772
|
+
void chunk;
|
|
773
|
+
}
|
|
774
|
+
if (options.output)
|
|
775
|
+
await writeNodeTileConversionOutput(
|
|
776
|
+
files,
|
|
777
|
+
options.output,
|
|
778
|
+
options.outputFormat,
|
|
779
|
+
maxOutputBytes,
|
|
780
|
+
signal
|
|
781
|
+
);
|
|
782
|
+
return report;
|
|
783
|
+
}
|
|
784
|
+
async function writeNodeTileConversionOutput(files, output, format, maxOutputBytes, signal) {
|
|
785
|
+
signal.throwIfAborted();
|
|
786
|
+
if (!["3d-tiles", "3tz", "slpk"].includes(format) || !Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1)
|
|
787
|
+
throw new TileConversionError5(
|
|
788
|
+
"INVALID_NODE_OUTPUT",
|
|
789
|
+
"Output requires a supported format and positive safe byte limit"
|
|
790
|
+
);
|
|
791
|
+
let bytes = 0;
|
|
792
|
+
const names = /* @__PURE__ */ new Set();
|
|
793
|
+
for (const file of files) {
|
|
794
|
+
const name = file.resourceId;
|
|
795
|
+
if (!name || name.includes("\\") || name.includes("\0") || name.includes(":") || name.split("/").some((segment) => !segment || segment === "." || segment === "..") || names.has(name))
|
|
796
|
+
throw new TileConversionError5(
|
|
797
|
+
"INVALID_NODE_OUTPUT",
|
|
798
|
+
"Output names must be unique confined relative resource paths"
|
|
799
|
+
);
|
|
800
|
+
names.add(name);
|
|
801
|
+
if (file.blob.size > maxOutputBytes - bytes)
|
|
802
|
+
throw new TileConversionError5(
|
|
803
|
+
"OUTPUT_BYTES_LIMIT_EXCEEDED",
|
|
804
|
+
"Output files exceed the byte budget"
|
|
805
|
+
);
|
|
806
|
+
bytes += file.blob.size;
|
|
807
|
+
}
|
|
808
|
+
const destination = resolve3(output);
|
|
809
|
+
if (format === "3d-tiles") {
|
|
810
|
+
await mkdir(destination);
|
|
811
|
+
try {
|
|
812
|
+
for (const file of files) {
|
|
813
|
+
signal.throwIfAborted();
|
|
814
|
+
const path = join(destination, file.resourceId);
|
|
815
|
+
await mkdir(dirname2(path), { recursive: true });
|
|
816
|
+
const handle = await open2(path, "wx");
|
|
817
|
+
try {
|
|
818
|
+
const reader = file.blob.stream().getReader();
|
|
819
|
+
try {
|
|
820
|
+
while (true) {
|
|
821
|
+
signal.throwIfAborted();
|
|
822
|
+
const { value, done } = await reader.read();
|
|
823
|
+
if (done) break;
|
|
824
|
+
await writeFileBytes(handle, value, signal);
|
|
825
|
+
}
|
|
826
|
+
} finally {
|
|
827
|
+
await reader.cancel();
|
|
828
|
+
reader.releaseLock();
|
|
829
|
+
}
|
|
830
|
+
} finally {
|
|
831
|
+
await handle.close();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
signal.throwIfAborted();
|
|
835
|
+
} catch (error) {
|
|
836
|
+
await rm(destination, { recursive: true, force: true });
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
} else {
|
|
840
|
+
const temporaryDirectory = await mkdtemp(join(dirname2(destination), ".tile-converter-"));
|
|
841
|
+
try {
|
|
842
|
+
const path = join(temporaryDirectory, "archive");
|
|
843
|
+
const handle = await open2(path, "wx");
|
|
844
|
+
try {
|
|
845
|
+
for await (const chunk of encodeTileConversionArchiveInBatches(files, {
|
|
846
|
+
format,
|
|
847
|
+
maxArchiveBytes: maxOutputBytes,
|
|
848
|
+
signal
|
|
849
|
+
}))
|
|
850
|
+
await writeFileBytes(handle, chunk, signal);
|
|
851
|
+
} finally {
|
|
852
|
+
await handle.close();
|
|
853
|
+
}
|
|
854
|
+
signal.throwIfAborted();
|
|
855
|
+
await link(path, destination);
|
|
856
|
+
} finally {
|
|
857
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
async function writeFileBytes(handle, bytes, signal) {
|
|
862
|
+
let offset = 0;
|
|
863
|
+
while (offset < bytes.byteLength) {
|
|
864
|
+
signal.throwIfAborted();
|
|
865
|
+
const result = await handle.write(bytes, offset, bytes.byteLength - offset);
|
|
866
|
+
if (!result.bytesWritten)
|
|
867
|
+
throw new TileConversionError5("NODE_OUTPUT_WRITE_FAILED", "Output write made no progress");
|
|
868
|
+
offset += result.bytesWritten;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
export {
|
|
872
|
+
NODE_TILESET_LIMITS,
|
|
873
|
+
convertNodeTileset,
|
|
874
|
+
createNodeI3SConversionSource,
|
|
875
|
+
createNodeTilesetConversionSource,
|
|
876
|
+
writeNodeTileConversionOutput
|
|
877
|
+
};
|