@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
|
@@ -7,6 +7,8 @@ import type {TileConversionSource} from './conversion-api.js';
|
|
|
7
7
|
|
|
8
8
|
type TilesetContent = {uri?: string; url?: string};
|
|
9
9
|
type TilesetTile = {
|
|
10
|
+
/** Source metric error, scaled conservatively during selected mesh extraction. */
|
|
11
|
+
geometricError?: number;
|
|
10
12
|
content?: TilesetContent;
|
|
11
13
|
contents?: TilesetContent[];
|
|
12
14
|
children?: TilesetTile[];
|
|
@@ -16,28 +18,48 @@ type TilesetTile = {
|
|
|
16
18
|
};
|
|
17
19
|
type TilesetDocument = {asset?: {version?: string}; root?: TilesetTile};
|
|
18
20
|
|
|
19
|
-
/** Input options for bounded traversal of
|
|
21
|
+
/** Input options for bounded traversal of explicit 3D Tiles documents. */
|
|
20
22
|
export interface BrowserTilesetConversionSourceOptions {
|
|
21
23
|
/** URL of the root tileset JSON document. */
|
|
22
24
|
readonly input: string;
|
|
23
|
-
/** Maximum bytes
|
|
25
|
+
/** Maximum aggregate response bytes across root/nested JSON and content resources. */
|
|
24
26
|
readonly maxInputBytes: number;
|
|
25
|
-
/** Maximum
|
|
27
|
+
/** Maximum content declarations across all documents, including external JSON references. */
|
|
26
28
|
readonly maxInputResources: number;
|
|
29
|
+
/** Maximum documents read, including the root and repeated placements; defaults to 1 (no nesting). */
|
|
30
|
+
readonly maxTilesetDocuments?: number;
|
|
31
|
+
/** Inspect declared .json external tilesets without fetching mesh content; defaults to false. */
|
|
32
|
+
readonly inspectNestedTilesets?: boolean;
|
|
27
33
|
/** Fetch implementation, defaulting to the browser global. */
|
|
28
34
|
readonly fetcher?: typeof fetch;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** Inspection result for an explicit 3D Tiles source. */
|
|
32
38
|
export interface BrowserTilesetConversionInspection {
|
|
33
|
-
/**
|
|
39
|
+
/** Final response URL of the root document, falling back to the requested URL for virtual transports. */
|
|
34
40
|
readonly rootUrl: string;
|
|
35
41
|
/** Parsed root document used for deterministic content traversal. */
|
|
36
42
|
readonly tileset: TilesetDocument;
|
|
37
|
-
/** Ordered
|
|
43
|
+
/** Ordered root declarations, or binary candidates from an expanded metadata inspection. */
|
|
38
44
|
readonly resources: readonly BrowserTilesetResourceDescriptor[];
|
|
39
|
-
/**
|
|
45
|
+
/** Inspected root/nested metadata bytes charged against the aggregate input limit. */
|
|
40
46
|
readonly tilesetBytes: number;
|
|
47
|
+
/** Inspected document placements, including repeated references; absent for root-only inspection. */
|
|
48
|
+
readonly documents?: readonly BrowserTilesetDocumentPlacement[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One inspected document at a distinct external placement. */
|
|
52
|
+
export interface BrowserTilesetDocumentPlacement {
|
|
53
|
+
/** Full external content placement introducing this document; absent for the entry root. */
|
|
54
|
+
readonly parentResourceId?: string;
|
|
55
|
+
/** Effective document URL, including inherited query credentials. */
|
|
56
|
+
readonly url: string;
|
|
57
|
+
/** Original parsed document; its metadata and glTF conventions stay scoped to this document. */
|
|
58
|
+
readonly tileset: TilesetDocument;
|
|
59
|
+
/** Combined placement above this document's root. */
|
|
60
|
+
readonly parentTransform: readonly number[];
|
|
61
|
+
/** Conservative inherited metric error, when declared by all external ancestors. */
|
|
62
|
+
readonly parentGeometricError?: number;
|
|
41
63
|
}
|
|
42
64
|
|
|
43
65
|
/** Placement metadata for one ordered content resource. */
|
|
@@ -46,9 +68,13 @@ export interface BrowserTilesetResourceDescriptor {
|
|
|
46
68
|
readonly resourceId: string;
|
|
47
69
|
/** Original URI declared by the tileset. */
|
|
48
70
|
readonly uri: string;
|
|
49
|
-
/** Tile child indexes
|
|
71
|
+
/** Tile child indexes within the declaring document. */
|
|
50
72
|
readonly tilePath: readonly number[];
|
|
51
|
-
/**
|
|
73
|
+
/** Declaring nested document URL; absent for root-document content. */
|
|
74
|
+
readonly tilesetUrl?: string;
|
|
75
|
+
/** Placement index in an expanded inspection's documents. */
|
|
76
|
+
readonly documentIndex?: number;
|
|
77
|
+
/** Combined entry-root-to-tile transform in column-major order, including external roots. */
|
|
52
78
|
readonly transform: readonly number[];
|
|
53
79
|
}
|
|
54
80
|
|
|
@@ -58,21 +84,32 @@ export interface BrowserTilesetConversionInputResource extends BrowserTilesetRes
|
|
|
58
84
|
readonly data: Uint8Array;
|
|
59
85
|
/** Optional content type reported by the response. */
|
|
60
86
|
readonly contentType?: string;
|
|
87
|
+
/** Effective content response URL used to resolve decoder dependencies after redirects. */
|
|
88
|
+
readonly contentUrl?: string;
|
|
61
89
|
}
|
|
62
90
|
|
|
63
91
|
/**
|
|
64
92
|
* Creates a bounded browser source for explicit 3D Tiles 1.0 and 1.1 tilesets.
|
|
65
93
|
*
|
|
66
|
-
* Traversal visits
|
|
67
|
-
*
|
|
94
|
+
* Traversal visits declared placements in depth-first order. Set maxTilesetDocuments above 1
|
|
95
|
+
* to follow explicit external tilesets, with shared byte/declaration limits and active-path cycle
|
|
96
|
+
* detection. Inspection reads the root by default; inspectNestedTilesets follows declared .json
|
|
97
|
+
* references without fetching binary content. Other nested metadata is discovered during reading.
|
|
98
|
+
* Implicit tiling remains unsupported; content decoding and conversion remain codec work.
|
|
99
|
+
* Bounds, metadata and LOD authoring policy remain application responsibilities.
|
|
68
100
|
*
|
|
69
101
|
* @param options - Tileset URL and aggregate byte and resource limits.
|
|
70
|
-
* @returns A source yielding
|
|
102
|
+
* @returns A source yielding raw resources and placement transforms; it does not preserve output LOD.
|
|
71
103
|
*/
|
|
72
104
|
export function createBrowserTilesetConversionSource(
|
|
73
105
|
options: BrowserTilesetConversionSourceOptions
|
|
74
106
|
): TileConversionSource<BrowserTilesetConversionInspection, BrowserTilesetConversionInputResource> {
|
|
107
|
+
const maxTilesetDocuments = options.maxTilesetDocuments ?? 1;
|
|
75
108
|
if (
|
|
109
|
+
(options.inspectNestedTilesets !== undefined &&
|
|
110
|
+
typeof options.inspectNestedTilesets !== 'boolean') ||
|
|
111
|
+
!Number.isSafeInteger(maxTilesetDocuments) ||
|
|
112
|
+
maxTilesetDocuments < 1 ||
|
|
76
113
|
!Number.isSafeInteger(options.maxInputBytes) ||
|
|
77
114
|
options.maxInputBytes < 1 ||
|
|
78
115
|
!Number.isSafeInteger(options.maxInputResources) ||
|
|
@@ -80,7 +117,7 @@ export function createBrowserTilesetConversionSource(
|
|
|
80
117
|
) {
|
|
81
118
|
throw new TileConversionError(
|
|
82
119
|
'INVALID_BROWSER_SOURCE_OPTIONS',
|
|
83
|
-
'Browser tileset sources require positive byte and
|
|
120
|
+
'Browser tileset sources require positive byte, declaration and document limits'
|
|
84
121
|
);
|
|
85
122
|
}
|
|
86
123
|
|
|
@@ -88,44 +125,62 @@ export function createBrowserTilesetConversionSource(
|
|
|
88
125
|
return {
|
|
89
126
|
async inspect(signal) {
|
|
90
127
|
throwIfAborted(signal);
|
|
91
|
-
const
|
|
92
|
-
const response = await fetcher(
|
|
128
|
+
const requestedUrl = new URL(options.input).href;
|
|
129
|
+
const response = await fetcher(requestedUrl, {signal});
|
|
130
|
+
const rootUrl = response.url || requestedUrl;
|
|
93
131
|
if (!response.ok) {
|
|
132
|
+
await response.body?.cancel();
|
|
94
133
|
throw new TileConversionError(
|
|
95
134
|
'BROWSER_SOURCE_FETCH_FAILED',
|
|
96
135
|
`Could not fetch browser tileset (${response.status} ${response.statusText})`
|
|
97
136
|
);
|
|
98
137
|
}
|
|
99
138
|
const data = await readResponseBytes(response, options.maxInputBytes, signal);
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
tileset = JSON.parse(new TextDecoder().decode(data)) as TilesetDocument;
|
|
103
|
-
} catch {
|
|
104
|
-
throw new TileConversionError('INVALID_BROWSER_TILESET', 'Tileset input is not valid JSON');
|
|
105
|
-
}
|
|
106
|
-
if (
|
|
107
|
-
!isRecord(tileset) ||
|
|
108
|
-
!['1.0', '1.1'].includes(tileset.asset?.version || '') ||
|
|
109
|
-
!isRecord(tileset.root)
|
|
110
|
-
) {
|
|
111
|
-
throw new TileConversionError(
|
|
112
|
-
'UNSUPPORTED_BROWSER_TILESET',
|
|
113
|
-
'Browser tileset traversal supports explicit 3D Tiles 1.0 and 1.1 documents'
|
|
114
|
-
);
|
|
115
|
-
}
|
|
139
|
+
const tileset = parseTilesetDocument(data);
|
|
116
140
|
const resources = collectResources(tileset.root, options.maxInputResources);
|
|
117
|
-
|
|
141
|
+
const inspection = {rootUrl, tileset, resources, tilesetBytes: data.byteLength};
|
|
142
|
+
return options.inspectNestedTilesets
|
|
143
|
+
? expandInspection(inspection, options, maxTilesetDocuments, fetcher, signal)
|
|
144
|
+
: inspection;
|
|
118
145
|
},
|
|
119
146
|
async *read(inspection, signal) {
|
|
120
147
|
let inputBytes = inspection.tilesetBytes;
|
|
121
|
-
|
|
148
|
+
let declarations = inspection.resources.length;
|
|
149
|
+
let documents = 1;
|
|
150
|
+
const stack = [
|
|
151
|
+
{
|
|
152
|
+
resources: inspection.resources,
|
|
153
|
+
index: 0,
|
|
154
|
+
tileset: inspection.tileset,
|
|
155
|
+
url: inspection.rootUrl,
|
|
156
|
+
ancestors: new Set([getDocumentKey(inspection.rootUrl), getDocumentKey(options.input)])
|
|
157
|
+
}
|
|
158
|
+
];
|
|
159
|
+
while (stack.length) {
|
|
122
160
|
throwIfAborted(signal);
|
|
123
|
-
const
|
|
161
|
+
const frame = stack[stack.length - 1];
|
|
162
|
+
if (frame.index === frame.resources.length) {
|
|
163
|
+
stack.pop();
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const descriptor = frame.resources[frame.index++];
|
|
167
|
+
const resourceUrl = resolveResourceUrl(descriptor.uri, descriptor.tilesetUrl ?? frame.url);
|
|
168
|
+
if (frame.ancestors.has(getDocumentKey(resourceUrl))) {
|
|
169
|
+
throw new TileConversionError(
|
|
170
|
+
'BROWSER_TILESET_CYCLE',
|
|
171
|
+
'External tileset references an active ancestor document'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
124
174
|
const response = await fetcher(resourceUrl, {signal});
|
|
125
175
|
if (!response.ok) {
|
|
176
|
+
await response.body?.cancel();
|
|
126
177
|
throw new TileConversionError(
|
|
127
178
|
'BROWSER_SOURCE_FETCH_FAILED',
|
|
128
|
-
|
|
179
|
+
'Could not fetch browser tile content (' +
|
|
180
|
+
response.status +
|
|
181
|
+
' ' +
|
|
182
|
+
response.statusText +
|
|
183
|
+
')',
|
|
129
184
|
[
|
|
130
185
|
{
|
|
131
186
|
code: 'BROWSER_SOURCE_FETCH_FAILED',
|
|
@@ -136,30 +191,330 @@ export function createBrowserTilesetConversionSource(
|
|
|
136
191
|
]
|
|
137
192
|
);
|
|
138
193
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
194
|
+
const documentUrl = response.url || resourceUrl;
|
|
195
|
+
if (frame.ancestors.has(getDocumentKey(documentUrl))) {
|
|
196
|
+
await response.body?.cancel();
|
|
197
|
+
throw new TileConversionError(
|
|
198
|
+
'BROWSER_TILESET_CYCLE',
|
|
199
|
+
'Redirected content references an active ancestor document'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const data = await readResponseBytes(response, options.maxInputBytes - inputBytes, signal);
|
|
141
203
|
inputBytes += data.byteLength;
|
|
142
204
|
const contentType = response.headers.get('content-type') ?? undefined;
|
|
143
205
|
if (isJsonContent(data, contentType)) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
206
|
+
if (inspection.documents)
|
|
207
|
+
throw new TileConversionError(
|
|
208
|
+
'UNSUPPORTED_BROWSER_TILESET_CONTENT',
|
|
209
|
+
'Expanded inspection selections must be binary content; declare external metadata with a .json URI'
|
|
210
|
+
);
|
|
211
|
+
if (maxTilesetDocuments === 1) {
|
|
212
|
+
throw new TileConversionError(
|
|
213
|
+
'UNSUPPORTED_BROWSER_TILESET_CONTENT',
|
|
214
|
+
'Nested tilesets and JSON tile content require an explicit document budget'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (documents >= maxTilesetDocuments) {
|
|
218
|
+
throw new TileConversionError(
|
|
219
|
+
'INPUT_TILESET_COUNT_EXCEEDED',
|
|
220
|
+
'Browser tileset exceeds its document limit'
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
let tile = frame.tileset.root!;
|
|
224
|
+
for (const childIndex of descriptor.tilePath) tile = getTileChild(tile, childIndex);
|
|
225
|
+
if (tile.children?.length) {
|
|
226
|
+
throw new TileConversionError(
|
|
227
|
+
'INVALID_BROWSER_TILESET',
|
|
228
|
+
'External tileset references cannot also have child tiles'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const tileset = parseTilesetDocument(data);
|
|
232
|
+
const resources = collectResources(
|
|
233
|
+
tileset.root!,
|
|
234
|
+
options.maxInputResources - declarations,
|
|
235
|
+
descriptor.transform
|
|
236
|
+
).map(resource => ({
|
|
237
|
+
...resource,
|
|
238
|
+
resourceId: descriptor.resourceId + '/' + resource.resourceId,
|
|
239
|
+
tilesetUrl: documentUrl
|
|
240
|
+
}));
|
|
241
|
+
declarations += resources.length;
|
|
242
|
+
documents++;
|
|
243
|
+
stack.push({
|
|
244
|
+
resources,
|
|
245
|
+
index: 0,
|
|
246
|
+
tileset,
|
|
247
|
+
url: documentUrl,
|
|
248
|
+
ancestors: new Set([
|
|
249
|
+
...frame.ancestors,
|
|
250
|
+
getDocumentKey(resourceUrl),
|
|
251
|
+
getDocumentKey(documentUrl)
|
|
252
|
+
])
|
|
253
|
+
});
|
|
254
|
+
continue;
|
|
148
255
|
}
|
|
149
|
-
yield {...descriptor, data, ...(contentType ? {contentType} : {})};
|
|
256
|
+
yield {...descriptor, data, contentUrl: documentUrl, ...(contentType ? {contentType} : {})};
|
|
150
257
|
}
|
|
151
258
|
}
|
|
152
259
|
};
|
|
153
260
|
}
|
|
154
261
|
|
|
262
|
+
/** A selected document and conservative source error for one content placement. */
|
|
263
|
+
export interface BrowserTilesetContentSelection {
|
|
264
|
+
/** Cloned document retaining its metadata, ancestor transforms and only the selected content. */
|
|
265
|
+
readonly tileset: TilesetDocument;
|
|
266
|
+
/** Resolved resource URL, carrying same-origin document query parameters. */
|
|
267
|
+
readonly contentUrl: string;
|
|
268
|
+
/** Conservative source geometric error in meters, including transform scaling. */
|
|
269
|
+
readonly geometricError: number;
|
|
270
|
+
/** Whether the original selected tile has no children. */
|
|
271
|
+
readonly isLeaf: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Selects one inspected content while keeping declaring-document conventions and placement. */
|
|
275
|
+
export function selectBrowserTilesetContent(
|
|
276
|
+
inspection: BrowserTilesetConversionInspection,
|
|
277
|
+
descriptor: BrowserTilesetResourceDescriptor
|
|
278
|
+
): BrowserTilesetContentSelection {
|
|
279
|
+
const placement = inspection.documents?.[descriptor.documentIndex ?? 0];
|
|
280
|
+
if (inspection.documents && (!Number.isSafeInteger(descriptor.documentIndex) || !placement))
|
|
281
|
+
throw new TileConversionError(
|
|
282
|
+
'INVALID_BROWSER_SELECTION',
|
|
283
|
+
'Content descriptor has no inspected document placement'
|
|
284
|
+
);
|
|
285
|
+
const document = structuredClone(placement?.tileset ?? inspection.tileset);
|
|
286
|
+
const originalRoot = document.root!;
|
|
287
|
+
let transform = [...(placement?.parentTransform ?? identityMatrix())];
|
|
288
|
+
let maximum = placement?.parentGeometricError ?? 0;
|
|
289
|
+
let tile = originalRoot;
|
|
290
|
+
for (let depth = 0; ; depth++) {
|
|
291
|
+
transform = multiplyMatrices(transform, normalizeMatrix(tile.transform));
|
|
292
|
+
if (
|
|
293
|
+
typeof tile.geometricError !== 'number' ||
|
|
294
|
+
!Number.isFinite(tile.geometricError) ||
|
|
295
|
+
tile.geometricError < 0
|
|
296
|
+
)
|
|
297
|
+
throw new TileConversionError(
|
|
298
|
+
'MESH_GEOMETRIC_ERROR_INVALID',
|
|
299
|
+
'Every selected ancestor must declare a finite nonnegative geometricError'
|
|
300
|
+
);
|
|
301
|
+
maximum = Math.max(maximum, tile.geometricError * getTransformScale(transform));
|
|
302
|
+
if (depth === descriptor.tilePath.length) break;
|
|
303
|
+
const child = getTileChild(tile, descriptor.tilePath[depth]);
|
|
304
|
+
delete tile.content;
|
|
305
|
+
delete tile.contents;
|
|
306
|
+
tile.children = [child];
|
|
307
|
+
tile = child;
|
|
308
|
+
}
|
|
309
|
+
if (placement && placement.parentGeometricError === undefined && descriptor.documentIndex !== 0)
|
|
310
|
+
throw new TileConversionError(
|
|
311
|
+
'MESH_GEOMETRIC_ERROR_INVALID',
|
|
312
|
+
'External ancestors must declare geometricError'
|
|
313
|
+
);
|
|
314
|
+
const isLeaf = !tile.children?.length;
|
|
315
|
+
const contentIndex = Number(descriptor.resourceId.match(/content-(\d+)$/)?.[1]);
|
|
316
|
+
const content = tile.contents?.[contentIndex] ?? tile.content;
|
|
317
|
+
if (!content || (content.uri ?? content.url) !== descriptor.uri)
|
|
318
|
+
throw new TileConversionError(
|
|
319
|
+
'INVALID_BROWSER_SELECTION',
|
|
320
|
+
'Content descriptor does not match its inspected document'
|
|
321
|
+
);
|
|
322
|
+
tile.content = content;
|
|
323
|
+
delete tile.contents;
|
|
324
|
+
delete tile.children;
|
|
325
|
+
if (placement)
|
|
326
|
+
originalRoot.transform = multiplyMatrices(
|
|
327
|
+
[...placement.parentTransform],
|
|
328
|
+
normalizeMatrix(originalRoot.transform)
|
|
329
|
+
);
|
|
330
|
+
if (!Number.isFinite(maximum))
|
|
331
|
+
throw new TileConversionError(
|
|
332
|
+
'MESH_GEOMETRIC_ERROR_INVALID',
|
|
333
|
+
'Scaled geometricError must remain finite'
|
|
334
|
+
);
|
|
335
|
+
return {
|
|
336
|
+
tileset: document,
|
|
337
|
+
contentUrl: resolveResourceUrl(descriptor.uri, placement?.url ?? inspection.rootUrl),
|
|
338
|
+
geometricError: maximum,
|
|
339
|
+
isLeaf
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Resolves a numeric in-bounds child without allowing property names to traverse prototypes. */
|
|
344
|
+
function getTileChild(tile: TilesetTile, childIndex: number): TilesetTile {
|
|
345
|
+
if (
|
|
346
|
+
typeof childIndex !== 'number' ||
|
|
347
|
+
!Number.isSafeInteger(childIndex) ||
|
|
348
|
+
childIndex < 0 ||
|
|
349
|
+
!Array.isArray(tile.children) ||
|
|
350
|
+
childIndex >= tile.children.length
|
|
351
|
+
)
|
|
352
|
+
throw new TileConversionError(
|
|
353
|
+
'INVALID_BROWSER_SELECTION',
|
|
354
|
+
'Tile paths require in-bounds nonnegative integer child indexes'
|
|
355
|
+
);
|
|
356
|
+
const child = tile.children.at(childIndex);
|
|
357
|
+
if (!isRecord(child))
|
|
358
|
+
throw new TileConversionError(
|
|
359
|
+
'INVALID_BROWSER_SELECTION',
|
|
360
|
+
'Selected child tile must be an object'
|
|
361
|
+
);
|
|
362
|
+
return child as TilesetTile;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Expands only declared JSON references; binary content is never requested during inspection. */
|
|
366
|
+
async function expandInspection(
|
|
367
|
+
inspection: BrowserTilesetConversionInspection,
|
|
368
|
+
options: BrowserTilesetConversionSourceOptions,
|
|
369
|
+
maxTilesetDocuments: number,
|
|
370
|
+
fetcher: typeof fetch,
|
|
371
|
+
signal?: AbortSignal
|
|
372
|
+
): Promise<BrowserTilesetConversionInspection> {
|
|
373
|
+
const documents: BrowserTilesetDocumentPlacement[] = [
|
|
374
|
+
{
|
|
375
|
+
url: inspection.rootUrl,
|
|
376
|
+
tileset: inspection.tileset,
|
|
377
|
+
parentTransform: identityMatrix(),
|
|
378
|
+
parentGeometricError: 0
|
|
379
|
+
}
|
|
380
|
+
];
|
|
381
|
+
const resources: BrowserTilesetResourceDescriptor[] = [];
|
|
382
|
+
let bytes = inspection.tilesetBytes;
|
|
383
|
+
let declarations = inspection.resources.length;
|
|
384
|
+
const stack = [
|
|
385
|
+
{
|
|
386
|
+
resources: inspection.resources,
|
|
387
|
+
index: 0,
|
|
388
|
+
documentIndex: 0,
|
|
389
|
+
ancestors: new Set([getDocumentKey(inspection.rootUrl), getDocumentKey(options.input)])
|
|
390
|
+
}
|
|
391
|
+
];
|
|
392
|
+
while (stack.length) {
|
|
393
|
+
throwIfAborted(signal);
|
|
394
|
+
const frame = stack[stack.length - 1];
|
|
395
|
+
if (frame.index === frame.resources.length) {
|
|
396
|
+
stack.pop();
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const descriptor = frame.resources[frame.index++];
|
|
400
|
+
const placement = documents[frame.documentIndex];
|
|
401
|
+
const resourceUrl = resolveResourceUrl(descriptor.uri, placement.url);
|
|
402
|
+
if (!new URL(resourceUrl).pathname.toLowerCase().endsWith('.json')) {
|
|
403
|
+
resources.push({...descriptor, documentIndex: frame.documentIndex});
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (frame.ancestors.has(getDocumentKey(resourceUrl)))
|
|
407
|
+
throw new TileConversionError(
|
|
408
|
+
'BROWSER_TILESET_CYCLE',
|
|
409
|
+
'External tileset references an active ancestor document'
|
|
410
|
+
);
|
|
411
|
+
if (documents.length >= maxTilesetDocuments)
|
|
412
|
+
throw new TileConversionError(
|
|
413
|
+
'INPUT_TILESET_COUNT_EXCEEDED',
|
|
414
|
+
'Browser tileset exceeds its document limit'
|
|
415
|
+
);
|
|
416
|
+
let tile = placement.tileset.root!;
|
|
417
|
+
for (const childIndex of descriptor.tilePath) tile = tile.children![childIndex];
|
|
418
|
+
if (tile.children?.length || tile.contents?.length)
|
|
419
|
+
throw new TileConversionError(
|
|
420
|
+
'INVALID_BROWSER_TILESET',
|
|
421
|
+
'Inspected external tilesets require one content and no child tiles'
|
|
422
|
+
);
|
|
423
|
+
const response = await fetcher(resourceUrl, {signal});
|
|
424
|
+
const url = response.url || resourceUrl;
|
|
425
|
+
if (!response.ok || frame.ancestors.has(getDocumentKey(url))) {
|
|
426
|
+
await response.body?.cancel();
|
|
427
|
+
throw new TileConversionError(
|
|
428
|
+
response.ok ? 'BROWSER_TILESET_CYCLE' : 'BROWSER_SOURCE_FETCH_FAILED',
|
|
429
|
+
'Could not inspect external tileset'
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const data = await readResponseBytes(response, options.maxInputBytes - bytes, signal);
|
|
433
|
+
bytes += data.byteLength;
|
|
434
|
+
const tileset = parseTilesetDocument(data);
|
|
435
|
+
const prefix = descriptor.resourceId + '/';
|
|
436
|
+
const nestedResources = collectResources(
|
|
437
|
+
tileset.root,
|
|
438
|
+
options.maxInputResources - declarations,
|
|
439
|
+
descriptor.transform
|
|
440
|
+
).map(resource => ({...resource, resourceId: prefix + resource.resourceId, tilesetUrl: url}));
|
|
441
|
+
declarations += nestedResources.length;
|
|
442
|
+
let parentGeometricError: number | undefined;
|
|
443
|
+
try {
|
|
444
|
+
parentGeometricError = selectBrowserTilesetContent(
|
|
445
|
+
{...inspection, documents},
|
|
446
|
+
{...descriptor, documentIndex: frame.documentIndex}
|
|
447
|
+
).geometricError;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
if (!(error instanceof TileConversionError) || error.code !== 'MESH_GEOMETRIC_ERROR_INVALID')
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
const documentIndex = documents.length;
|
|
453
|
+
documents.push({
|
|
454
|
+
url,
|
|
455
|
+
tileset,
|
|
456
|
+
parentResourceId: descriptor.resourceId,
|
|
457
|
+
parentTransform: descriptor.transform,
|
|
458
|
+
parentGeometricError
|
|
459
|
+
});
|
|
460
|
+
stack.push({
|
|
461
|
+
resources: nestedResources,
|
|
462
|
+
index: 0,
|
|
463
|
+
documentIndex,
|
|
464
|
+
ancestors: new Set([...frame.ancestors, getDocumentKey(resourceUrl), getDocumentKey(url)])
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return {...inspection, resources, documents, tilesetBytes: bytes};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Conservative spectral norm bound for a finite affine placement's linear part. */
|
|
471
|
+
function getTransformScale(transform: readonly number[]): number {
|
|
472
|
+
const columns = [0, 1, 2].map(column =>
|
|
473
|
+
[0, 1, 2].reduce((total, row) => total + Math.abs(transform[column * 4 + row]), 0)
|
|
474
|
+
);
|
|
475
|
+
const rows = [0, 1, 2].map(row =>
|
|
476
|
+
[0, 1, 2].reduce((total, column) => total + Math.abs(transform[column * 4 + row]), 0)
|
|
477
|
+
);
|
|
478
|
+
return Math.sqrt(Math.max(...columns)) * Math.sqrt(Math.max(...rows));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Parses one root or external document without inferring a profile from its filename. */
|
|
482
|
+
function parseTilesetDocument(data: Uint8Array): TilesetDocument & {root: TilesetTile} {
|
|
483
|
+
let tileset: TilesetDocument;
|
|
484
|
+
try {
|
|
485
|
+
tileset = JSON.parse(new TextDecoder().decode(data));
|
|
486
|
+
} catch {
|
|
487
|
+
throw new TileConversionError('INVALID_BROWSER_TILESET', 'Tileset input is not valid JSON');
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
!isRecord(tileset) ||
|
|
491
|
+
!['1.0', '1.1'].includes(tileset.asset?.version || '') ||
|
|
492
|
+
!isRecord(tileset.root)
|
|
493
|
+
) {
|
|
494
|
+
throw new TileConversionError(
|
|
495
|
+
'UNSUPPORTED_BROWSER_TILESET',
|
|
496
|
+
'Browser tileset traversal supports explicit 3D Tiles 1.0 and 1.1 documents'
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return tileset as TilesetDocument & {root: TilesetTile};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Normalizes URL identity for cycle checks; fragments do not identify a separate response. */
|
|
503
|
+
function getDocumentKey(url: string): string {
|
|
504
|
+
const documentUrl = new URL(url);
|
|
505
|
+
documentUrl.hash = '';
|
|
506
|
+
return documentUrl.href;
|
|
507
|
+
}
|
|
508
|
+
|
|
155
509
|
/** Collects explicit tile contents in declaration order and fails on unbounded formats. */
|
|
156
510
|
function collectResources(
|
|
157
511
|
root: TilesetTile,
|
|
158
|
-
maxInputResources: number
|
|
512
|
+
maxInputResources: number,
|
|
513
|
+
parentTransform: readonly number[] = identityMatrix()
|
|
159
514
|
): BrowserTilesetResourceDescriptor[] {
|
|
160
515
|
const resources: BrowserTilesetResourceDescriptor[] = [];
|
|
161
516
|
const stack: Array<{tile: TilesetTile; path: number[]; transform: number[]}> = [
|
|
162
|
-
{tile: root, path: [], transform:
|
|
517
|
+
{tile: root, path: [], transform: [...parentTransform]}
|
|
163
518
|
];
|
|
164
519
|
while (stack.length > 0) {
|
|
165
520
|
const {tile, path, transform: parentTransform} = stack.pop()!;
|
|
@@ -181,7 +536,19 @@ function collectResources(
|
|
|
181
536
|
if (tile.children !== undefined && !Array.isArray(tile.children)) {
|
|
182
537
|
throw new TileConversionError('INVALID_BROWSER_TILESET', 'Tile children must be an array');
|
|
183
538
|
}
|
|
539
|
+
if (tile.content !== undefined && tile.contents !== undefined) {
|
|
540
|
+
throw new TileConversionError(
|
|
541
|
+
'INVALID_BROWSER_TILESET',
|
|
542
|
+
'Tile content and contents are mutually exclusive'
|
|
543
|
+
);
|
|
544
|
+
}
|
|
184
545
|
const transform = multiplyMatrices(parentTransform, normalizeMatrix(tile.transform));
|
|
546
|
+
if (transform.some(value => !Number.isFinite(value))) {
|
|
547
|
+
throw new TileConversionError(
|
|
548
|
+
'INVALID_BROWSER_TILESET',
|
|
549
|
+
'Composed tile transforms must remain finite'
|
|
550
|
+
);
|
|
551
|
+
}
|
|
185
552
|
const contents = tile.contents ?? (tile.content ? [tile.content] : []);
|
|
186
553
|
for (let contentIndex = 0; contentIndex < contents.length; contentIndex++) {
|
|
187
554
|
const content = contents[contentIndex];
|
|
@@ -236,15 +603,15 @@ async function readResponseBytes(
|
|
|
236
603
|
'Browser inputs must provide a readable response stream'
|
|
237
604
|
);
|
|
238
605
|
}
|
|
239
|
-
const data = new Uint8Array(maximumBytes);
|
|
240
606
|
let byteLength = 0;
|
|
241
607
|
try {
|
|
608
|
+
const data = new Uint8Array(maximumBytes);
|
|
242
609
|
while (true) {
|
|
243
610
|
throwIfAborted(signal);
|
|
244
611
|
const {done, value} = await reader.read();
|
|
612
|
+
throwIfAborted(signal);
|
|
245
613
|
if (done) break;
|
|
246
614
|
if (value.byteLength > maximumBytes - byteLength) {
|
|
247
|
-
await reader.cancel();
|
|
248
615
|
throw new TileConversionError(
|
|
249
616
|
'INPUT_RESOURCE_TOO_LARGE',
|
|
250
617
|
`Browser tileset input exceeds the configured ${maximumBytes} byte remaining limit`
|
|
@@ -253,13 +620,16 @@ async function readResponseBytes(
|
|
|
253
620
|
data.set(value, byteLength);
|
|
254
621
|
byteLength += value.byteLength;
|
|
255
622
|
}
|
|
623
|
+
return data.slice(0, byteLength);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
await reader.cancel().catch(() => {});
|
|
626
|
+
throw error;
|
|
256
627
|
} finally {
|
|
257
628
|
reader.releaseLock();
|
|
258
629
|
}
|
|
259
|
-
return data.subarray(0, byteLength);
|
|
260
630
|
}
|
|
261
631
|
|
|
262
|
-
/** Detects JSON payloads that
|
|
632
|
+
/** Detects JSON payloads that require document validation before traversal. */
|
|
263
633
|
function isJsonContent(data: Uint8Array, contentType?: string): boolean {
|
|
264
634
|
if (contentType?.includes('json')) return true;
|
|
265
635
|
let byteOffset = data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf ? 3 : 0;
|
|
@@ -267,7 +637,7 @@ function isJsonContent(data: Uint8Array, contentType?: string): boolean {
|
|
|
267
637
|
return data[byteOffset] === 0x7b;
|
|
268
638
|
}
|
|
269
639
|
|
|
270
|
-
/** Resolves a tile URI and carries
|
|
640
|
+
/** Resolves a tile URI and carries declaring-document query parameters to same-origin resources. */
|
|
271
641
|
function resolveResourceUrl(uri: string, rootUrl: string): string {
|
|
272
642
|
const root = new URL(rootUrl);
|
|
273
643
|
const resource = new URL(uri, root);
|