@loaders.gl/tile-converter 5.0.0-alpha.10 → 5.0.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +211 -2
  2. package/bin/converter-v5.js +2 -0
  3. package/dist/converter.min.cjs +162 -163
  4. package/dist/i3s-server/bin/i3s-server.min.cjs +73 -73
  5. package/dist/index.cjs +1933 -2232
  6. package/dist/index.js +1925 -2235
  7. package/dist/v5/adapters.cjs +3186 -208
  8. package/dist/v5/adapters.d.ts +29 -2
  9. package/dist/v5/adapters.js +3202 -196
  10. package/dist/v5/archive-source.d.ts +36 -0
  11. package/dist/v5/browser-tileset-source.d.ts +55 -11
  12. package/dist/v5/browser.cjs +30 -0
  13. package/dist/v5/browser.d.ts +26 -2
  14. package/dist/v5/browser.js +45 -1
  15. package/dist/v5/conversion-api.d.ts +9 -0
  16. package/dist/v5/converter-cli.d.ts +1 -0
  17. package/dist/v5/converter-cli.js +1214 -0
  18. package/dist/v5/core.cjs +515 -50
  19. package/dist/v5/core.d.ts +2 -0
  20. package/dist/v5/core.js +515 -50
  21. package/dist/v5/i3s-mesh-conversion.d.ts +14 -4
  22. package/dist/v5/i3s-mesh-sink.d.ts +25 -0
  23. package/dist/v5/i3s-mesh-source-features.d.ts +13 -0
  24. package/dist/v5/i3s-mesh-source-material.d.ts +5 -0
  25. package/dist/v5/i3s-mesh-source.d.ts +30 -0
  26. package/dist/v5/i3s-selection-source.d.ts +70 -0
  27. package/dist/v5/index.cjs +20 -0
  28. package/dist/v5/index.d.ts +18 -2
  29. package/dist/v5/index.js +31 -1
  30. package/dist/v5/mesh-conversion.d.ts +29 -2
  31. package/dist/v5/mesh-features.d.ts +9 -0
  32. package/dist/v5/mesh-hierarchy.d.ts +47 -0
  33. package/dist/v5/mesh-source-features.d.ts +2 -1
  34. package/dist/v5/mesh-source-schema.d.ts +12 -0
  35. package/dist/v5/mesh-source-texture.d.ts +2 -0
  36. package/dist/v5/mesh-source.d.ts +13 -3
  37. package/dist/v5/mesh-tileset-sink.d.ts +5 -0
  38. package/dist/v5/mesh.d.ts +6 -1
  39. package/dist/v5/node-cli.d.ts +9 -0
  40. package/dist/v5/node-conversion.d.ts +32 -0
  41. package/dist/v5/node-feature-schema.d.ts +3 -0
  42. package/dist/v5/node-i3s-source.d.ts +41 -0
  43. package/dist/v5/node-source.d.ts +47 -0
  44. package/dist/v5/node.cjs +900 -0
  45. package/dist/v5/node.d.ts +6 -0
  46. package/dist/v5/node.js +877 -0
  47. package/dist/v5/point-cloud-file.d.ts +83 -0
  48. package/dist/v5/point-cloud-potree.d.ts +47 -0
  49. package/dist/v5/point-cloud-source-encoder.d.ts +4 -7
  50. package/dist/v5/point-cloud-spatial.d.ts +22 -0
  51. package/dist/v5/point-cloud-tileset-sink.d.ts +28 -0
  52. package/dist/v5/point-cloud-tileset.d.ts +44 -0
  53. package/dist/v5/point-cloud.d.ts +26 -1
  54. package/dist/v5/resource-fetcher.d.ts +18 -0
  55. package/dist/v5/resource-manifest.d.ts +10 -1
  56. package/dist/v5/selected-mesh-source.d.ts +53 -0
  57. package/dist/v5/spatial-conversion.d.ts +3 -1
  58. package/package.json +33 -25
  59. package/src/index.ts +4 -1
  60. package/src/v4/3d-tiles-converter/helpers/load-i3s.ts +2 -4
  61. package/src/v4/converter-cli.ts +8 -2
  62. package/src/v4/i3s-converter/helpers/load-3d-tiles.ts +3 -51
  63. package/src/v4/i3s-converter/helpers/node-index-document.ts +5 -2
  64. package/src/v4/i3s-converter/helpers/preprocess-3d-tiles.ts +1 -1
  65. package/src/v4/i3s-converter/json-templates/store.ts +2 -1
  66. package/src/v5/adapters.ts +69 -2
  67. package/src/v5/archive-source.ts +235 -0
  68. package/src/v5/browser-tileset-source.ts +418 -48
  69. package/src/v5/browser.ts +81 -2
  70. package/src/v5/conversion-api.ts +69 -9
  71. package/src/v5/converter-cli.ts +28 -0
  72. package/src/v5/core.ts +6 -0
  73. package/src/v5/i3s-mesh-conversion.ts +97 -19
  74. package/src/v5/i3s-mesh-sink.ts +351 -0
  75. package/src/v5/i3s-mesh-source-features.ts +279 -0
  76. package/src/v5/i3s-mesh-source-material.ts +157 -0
  77. package/src/v5/i3s-mesh-source.ts +228 -0
  78. package/src/v5/i3s-selection-source.ts +298 -0
  79. package/src/v5/index.ts +60 -2
  80. package/src/v5/mesh-conversion.ts +61 -11
  81. package/src/v5/mesh-draco.ts +47 -3
  82. package/src/v5/mesh-features.ts +319 -0
  83. package/src/v5/mesh-hierarchy.ts +648 -0
  84. package/src/v5/mesh-source-features.ts +73 -10
  85. package/src/v5/mesh-source-schema.ts +134 -0
  86. package/src/v5/mesh-source-texture.ts +47 -0
  87. package/src/v5/mesh-source.ts +99 -25
  88. package/src/v5/mesh-tileset-sink.ts +1 -1
  89. package/src/v5/mesh.ts +16 -7
  90. package/src/v5/node-cli.ts +197 -0
  91. package/src/v5/node-conversion.ts +345 -0
  92. package/src/v5/node-feature-schema.ts +155 -0
  93. package/src/v5/node-i3s-source.ts +181 -0
  94. package/src/v5/node-source.ts +328 -0
  95. package/src/v5/node.ts +16 -0
  96. package/src/v5/point-cloud-conversion.ts +9 -1
  97. package/src/v5/point-cloud-file.ts +306 -0
  98. package/src/v5/point-cloud-potree.ts +130 -0
  99. package/src/v5/point-cloud-source-encoder.ts +31 -20
  100. package/src/v5/point-cloud-spatial.ts +280 -0
  101. package/src/v5/point-cloud-tileset-sink.ts +234 -0
  102. package/src/v5/point-cloud-tileset.ts +137 -0
  103. package/src/v5/point-cloud.ts +159 -32
  104. package/src/v5/resource-fetcher.ts +113 -0
  105. package/src/v5/resource-manifest.ts +40 -5
  106. package/src/v5/selected-mesh-source.ts +384 -0
  107. package/src/v5/spatial-conversion.ts +161 -6
@@ -0,0 +1,197 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {parseArgs} from 'node:util';
6
+ import {readFeatureSchema} from './node-feature-schema.js';
7
+ import {TileConversionError} from '@loaders.gl/tile-converter/v5/core';
8
+ import {createNodeTilesetConversionSource, NODE_TILESET_LIMITS} from './node-source.js';
9
+ import type {NodeTileConversionOutputFormat} from './node-conversion.js';
10
+
11
+ /** Text destinations supplied by the process wrapper or a hermetic caller. */
12
+ export interface TileConverterCLIOutput {
13
+ /** Writes a complete JSON result or help text. */
14
+ readonly stdout: (text: string) => void;
15
+ /** Writes a JSON error without contaminating stdout. */
16
+ readonly stderr: (text: string) => void;
17
+ }
18
+
19
+ /** Usage for bounded metadata inspection and selected static mesh conversion. */
20
+ const HELP = `Usage: tile-converter-v5 inspect|convert|validate <input path or URL> [options]
21
+
22
+ --input-format 3d-tiles|3tz|i3s|slpk Explicit input format (default: 3d-tiles)
23
+ --geometric-error N Required source error in meters for I3S to 3D Tiles
24
+ --integrated-mesh-policy as-3d-object Authorize I3S layer-type change
25
+ --preserve-hierarchy Retain a complete explicit 3D Tiles tree (3D Tiles/3TZ only)
26
+ --feature-schema PATH Local JSON feature class/schema mapping (at most 64 KiB)
27
+ --resource ID Selected content placement; repeat for multiple selections
28
+ --output-format 3d-tiles|3tz|slpk Target profile (default: 3tz)
29
+ --output PATH New destination (required for convert, forbidden for validate)
30
+ --max-input-bytes N Lower the 16 MiB input/read budget
31
+ --max-resources N Lower the 1,000 content-declaration limit
32
+ --max-documents N Lower the 32 metadata-document limit
33
+ --max-output-bytes N Lower the 32 MiB output/archive budget
34
+ --help Show this help
35
+
36
+ Inspect reads metadata only, following declared .json external tilesets.
37
+ Convert/validate require explicit selections; I3S uses declared CRS and shared spatial preparation.
38
+ Validate decodes, converts and packages the selected target profile without writing files.
39
+ Output defaults to a partial flat mesh collection. --preserve-hierarchy requires every declared content.
40
+ Remote archives require HTTP 206 ranges and a stable ETag or Last-Modified validator.
41
+ I3S inspect lists leaf nodes; feature-bearing conversion requires --feature-schema.
42
+ Hierarchy mode supports at most 32 document placements, 256 tiles and 64 total mesh primitive placements, including ordered 1.1 contents arrays.
43
+ `;
44
+
45
+ /** Runs a bounded v5 operation, returning an exit code after source/output cleanup. */
46
+ export async function runTileConverterCLI(
47
+ argumentsList: string[],
48
+ output: TileConverterCLIOutput,
49
+ signal?: AbortSignal
50
+ ): Promise<number> {
51
+ try {
52
+ const {values, positionals} = parseArgs({
53
+ args: argumentsList,
54
+ allowPositionals: true,
55
+ options: {
56
+ help: {type: 'boolean'},
57
+ 'preserve-hierarchy': {type: 'boolean'},
58
+ 'input-format': {type: 'string', default: '3d-tiles'},
59
+ 'max-input-bytes': {type: 'string', default: String(NODE_TILESET_LIMITS.maxInputBytes)},
60
+ 'max-resources': {type: 'string', default: String(NODE_TILESET_LIMITS.maxInputResources)},
61
+ 'max-documents': {type: 'string', default: String(NODE_TILESET_LIMITS.maxTilesetDocuments)},
62
+ 'max-output-bytes': {type: 'string', default: String(32 * 1024 * 1024)},
63
+ 'output-format': {type: 'string', default: '3tz'},
64
+ output: {type: 'string'},
65
+ resource: {type: 'string', multiple: true},
66
+ 'feature-schema': {type: 'string'},
67
+ 'geometric-error': {type: 'string'},
68
+ 'integrated-mesh-policy': {type: 'string'}
69
+ }
70
+ });
71
+ if (values.help || !argumentsList.length) {
72
+ output.stdout(HELP);
73
+ return 0;
74
+ }
75
+ if (
76
+ positionals.length !== 2 ||
77
+ !['inspect', 'convert', 'validate'].includes(positionals[0]) ||
78
+ !['3d-tiles', '3tz', 'i3s', 'slpk'].includes(values['input-format'])
79
+ )
80
+ throw new TileConversionError(
81
+ 'CLI_USAGE_ERROR',
82
+ 'Use inspect, convert or validate with one input and --input-format 3d-tiles, 3tz, i3s or slpk'
83
+ );
84
+ const format = values['input-format'] as '3d-tiles' | '3tz' | 'i3s' | 'slpk';
85
+ const operation = positionals[0];
86
+ const sourceOptions = {
87
+ input: positionals[1],
88
+ format,
89
+ signal,
90
+ maxInputBytes: parseLimit(values['max-input-bytes']),
91
+ maxInputResources: parseLimit(values['max-resources']),
92
+ maxTilesetDocuments: parseLimit(values['max-documents']),
93
+ inspectNestedTilesets: true
94
+ };
95
+ if (operation !== 'inspect') {
96
+ if (
97
+ !values.resource?.length ||
98
+ !['3d-tiles', '3tz', 'slpk'].includes(values['output-format']) ||
99
+ (operation === 'convert' ? !values.output : values.output !== undefined)
100
+ )
101
+ throw new TileConversionError(
102
+ 'CLI_USAGE_ERROR',
103
+ 'Convert/validate require --resource selections and a supported --output-format; only convert requires --output'
104
+ );
105
+ if (
106
+ values['integrated-mesh-policy'] !== undefined &&
107
+ values['integrated-mesh-policy'] !== 'as-3d-object'
108
+ )
109
+ throw new TileConversionError(
110
+ 'CLI_USAGE_ERROR',
111
+ 'Use --integrated-mesh-policy as-3d-object'
112
+ );
113
+ const features =
114
+ values['feature-schema'] === undefined
115
+ ? undefined
116
+ : await readFeatureSchema(
117
+ values['feature-schema'],
118
+ format === 'i3s' || format === 'slpk',
119
+ signal
120
+ );
121
+ const {convertNodeTileset} = await import('./node-conversion.js');
122
+ const report = await convertNodeTileset({
123
+ ...sourceOptions,
124
+ ...features,
125
+ resourceIds: values.resource,
126
+ preserveHierarchy: values['preserve-hierarchy'],
127
+ outputFormat: values['output-format'] as NodeTileConversionOutputFormat,
128
+ output: values.output,
129
+ maxOutputBytes: parseLimit(values['max-output-bytes']),
130
+ geometricError:
131
+ values['geometric-error'] === undefined
132
+ ? undefined
133
+ : parseGeometricError(values['geometric-error']),
134
+ integratedMeshPolicy: values['integrated-mesh-policy'] as 'as-3d-object' | undefined
135
+ });
136
+ output.stdout(
137
+ `${JSON.stringify({operation, input: positionals[1], inputFormat: format, outputFormat: values['output-format'], ...(values.output ? {output: values.output} : {}), report}, null, 2)}\n`
138
+ );
139
+ return 0;
140
+ }
141
+ if (
142
+ values['feature-schema'] !== undefined ||
143
+ values['preserve-hierarchy'] !== undefined ||
144
+ values['geometric-error'] !== undefined ||
145
+ values['integrated-mesh-policy'] !== undefined ||
146
+ values.resource ||
147
+ values.output ||
148
+ values['output-format'] !== '3tz' ||
149
+ values['max-output-bytes'] !== String(32 * 1024 * 1024)
150
+ )
151
+ throw new TileConversionError(
152
+ 'CLI_USAGE_ERROR',
153
+ 'Inspect does not accept conversion/output options'
154
+ );
155
+ const source =
156
+ format === 'i3s' || format === 'slpk'
157
+ ? await (await import('./node-i3s-source.js')).createNodeI3SConversionSource({
158
+ ...sourceOptions,
159
+ format
160
+ })
161
+ : await createNodeTilesetConversionSource({...sourceOptions, format});
162
+ const inspection = await source.inspect(signal).finally(() => source.close());
163
+ output.stdout(
164
+ `${JSON.stringify({operation: 'inspect', input: positionals[1], inputFormat: format, ...inspection}, null, 2)}\n`
165
+ );
166
+ return 0;
167
+ } catch (error) {
168
+ output.stderr(
169
+ `${JSON.stringify({
170
+ code: error instanceof Error && 'code' in error ? error.code : 'CONVERSION_FAILED',
171
+ message: error instanceof Error ? error.message : String(error),
172
+ diagnostics: error instanceof TileConversionError ? error.diagnostics : []
173
+ })}\n`
174
+ );
175
+ return 1;
176
+ }
177
+ }
178
+
179
+ /** Parses a positive decimal byte/count limit without accepting fractional or infinite values. */
180
+ function parseLimit(value: string): number {
181
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
182
+ throw new TileConversionError(
183
+ 'CLI_USAGE_ERROR',
184
+ 'Limits must be positive safe decimal integers'
185
+ );
186
+ return Number(value);
187
+ }
188
+
189
+ /** Parses an explicit metric source error without inferring I3S screen-size semantics. */
190
+ function parseGeometricError(value: string): number {
191
+ if (!/^(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value) || !Number.isFinite(Number(value)))
192
+ throw new TileConversionError(
193
+ 'CLI_USAGE_ERROR',
194
+ 'Geometric error must be a finite nonnegative number in meters'
195
+ );
196
+ return Number(value);
197
+ }
@@ -0,0 +1,345 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {mkdir, open, rm, link, mkdtemp} from 'node:fs/promises';
6
+ import {dirname, join, resolve} from 'node:path';
7
+ import {convertTileset, TileConversionError} from '@loaders.gl/tile-converter/v5/core';
8
+ import type {
9
+ BrowserTileConversionFile,
10
+ BrowserTilesetConversionInspection,
11
+ TileConversionReport
12
+ } from '@loaders.gl/tile-converter/v5/core';
13
+ import {
14
+ createSelectedMeshConversionSource,
15
+ measureMeshBytes,
16
+ SELECTED_MESH_LIMITS,
17
+ createMeshConversionCodec,
18
+ createI3SMeshConversionCodec,
19
+ createMeshTilesetSink,
20
+ createMeshHierarchyTilesetSink,
21
+ createI3SMeshSink,
22
+ encodeTileConversionArchiveInBatches
23
+ } from '@loaders.gl/tile-converter/v5/adapters';
24
+ import type {
25
+ I3SMeshSourceFeatureOptions,
26
+ MeshSourceFeatureOptions
27
+ } from '@loaders.gl/tile-converter/v5/adapters';
28
+ import {createNodeTilesetConversionSource} from './node-source.js';
29
+ import {createNodeI3SConversionSource} from './node-i3s-source.js';
30
+ import type {NodeTilesetConversionSourceOptions} from './node-source.js';
31
+
32
+ /** Explicit targets for the initial bounded Node mesh profile. */
33
+ export type NodeTileConversionOutputFormat = '3d-tiles' | '3tz' | 'slpk';
34
+
35
+ /** Selected static mesh conversion with application-owned Node output. */
36
+ export interface NodeTileConversionOptions
37
+ extends Omit<NodeTilesetConversionSourceOptions, 'format'> {
38
+ /** Explicit 3D Tiles, I3S layer or indexed archive source. */
39
+ readonly format?: '3d-tiles' | '3tz' | 'i3s' | 'slpk';
40
+ /** Explicit I3S attribute mapping, also supplied by the CLI feature-schema file. */
41
+ readonly i3sFeatures?: I3SMeshSourceFeatureOptions;
42
+ /** Required metric source error for I3S to 3D Tiles; no screen-size inference. */
43
+ readonly geometricError?: number;
44
+ /** Retain a complete explicit 3D Tiles tree, including inspected external documents in 3D Tiles/3TZ output. */
45
+ readonly preserveHierarchy?: boolean;
46
+ /** Authorizes IntegratedMesh to 3DObject output with a reported semantic warning. */
47
+ readonly integratedMeshPolicy?: 'as-3d-object';
48
+ /** One to 64 inspected content placement identifiers; no automatic all-content conversion. */
49
+ readonly resourceIds: readonly string[];
50
+ /** Explicit feature class/schema mapping, also supplied by the CLI feature-schema file. */
51
+ readonly features?: MeshSourceFeatureOptions;
52
+ /** Target mesh profile, independent of output filename. */
53
+ readonly outputFormat: NodeTileConversionOutputFormat;
54
+ /** New destination path; omitted for profile validation without writing. */
55
+ readonly output?: string;
56
+ /** Maximum retained files and, separately, archive bytes; at most 32 MiB. */
57
+ readonly maxOutputBytes?: number;
58
+ }
59
+
60
+ /** Runs the qualified browser mesh codecs with confined Node input and optional output. */
61
+ export async function convertNodeTileset(
62
+ options: NodeTileConversionOptions
63
+ ): Promise<TileConversionReport> {
64
+ const signal = options.signal ?? new AbortController().signal;
65
+ const maxOutputBytes = options.maxOutputBytes ?? SELECTED_MESH_LIMITS.maxOutputBytes;
66
+ if (
67
+ !['3d-tiles', '3tz', 'slpk'].includes(options.outputFormat) ||
68
+ !Number.isSafeInteger(maxOutputBytes) ||
69
+ maxOutputBytes < 1 ||
70
+ maxOutputBytes > SELECTED_MESH_LIMITS.maxOutputBytes
71
+ )
72
+ throw new TileConversionError(
73
+ 'INVALID_NODE_CONVERSION_OPTIONS',
74
+ 'Select a supported output profile and a positive output budget at most 32 MiB'
75
+ );
76
+ const i3s = options.format === 'i3s' || options.format === 'slpk';
77
+ if (
78
+ (options.preserveHierarchy !== undefined && typeof options.preserveHierarchy !== 'boolean') ||
79
+ (options.preserveHierarchy && (i3s || options.outputFormat === 'slpk'))
80
+ )
81
+ throw new TileConversionError(
82
+ 'MESH_HIERARCHY_UNSUPPORTED',
83
+ 'Hierarchy preservation requires 3D Tiles/3TZ input and output'
84
+ );
85
+ if (
86
+ i3s &&
87
+ ((options.outputFormat !== 'slpk' && options.geometricError === undefined) ||
88
+ (options.geometricError !== undefined &&
89
+ (typeof options.geometricError !== 'number' ||
90
+ !Number.isFinite(options.geometricError) ||
91
+ options.geometricError < 0)))
92
+ )
93
+ throw new TileConversionError(
94
+ 'MESH_GEOMETRIC_ERROR_INVALID',
95
+ 'I3S to 3D Tiles requires an explicit nonnegative geometric error in meters'
96
+ );
97
+ if (
98
+ i3s
99
+ ? options.features !== undefined
100
+ : options.i3sFeatures !== undefined ||
101
+ options.integratedMeshPolicy !== undefined ||
102
+ options.geometricError !== undefined
103
+ )
104
+ throw new TileConversionError(
105
+ 'INVALID_NODE_CONVERSION_OPTIONS',
106
+ 'Feature and layer policies must match the selected input format'
107
+ );
108
+ const sourceOptions = {
109
+ ...options,
110
+ signal,
111
+ inspectNestedTilesets: true,
112
+ maxTilesetDocuments: options.maxTilesetDocuments ?? SELECTED_MESH_LIMITS.maxTilesetDocuments
113
+ };
114
+ const rawSource = i3s
115
+ ? await createNodeI3SConversionSource({
116
+ ...sourceOptions,
117
+ format: options.format as 'i3s' | 'slpk'
118
+ })
119
+ : await createNodeTilesetConversionSource({
120
+ ...sourceOptions,
121
+ format: options.format as '3d-tiles' | '3tz' | undefined
122
+ });
123
+ let files: readonly BrowserTileConversionFile[];
124
+ let report: TileConversionReport;
125
+ try {
126
+ const inspection = await rawSource.inspect(signal);
127
+ await import('@loaders.gl/polyfills');
128
+ const {getDracoLibraryOptions} = await import('@loaders.gl/draco/bundled');
129
+ const dracoLibraryOptions = getDracoLibraryOptions({
130
+ decoderProfile: 'full',
131
+ useLocalLibraries: true
132
+ });
133
+ const selection =
134
+ 'select' in rawSource
135
+ ? {
136
+ ...(await rawSource.select(
137
+ inspection as Awaited<ReturnType<typeof rawSource.inspect>>,
138
+ options.resourceIds,
139
+ dracoLibraryOptions,
140
+ options.i3sFeatures
141
+ )),
142
+ geometricError: (options.geometricError ?? 0) + SELECTED_MESH_LIMITS.maxPositionError
143
+ }
144
+ : await createSelectedMeshConversionSource(
145
+ inspection as Awaited<ReturnType<typeof rawSource.inspect>>,
146
+ {
147
+ resourceIds: options.resourceIds,
148
+ preserveHierarchy: options.preserveHierarchy,
149
+ features: options.features,
150
+ signal,
151
+ source: rawSource,
152
+ fetcher: rawSource.fetcher,
153
+ dracoLibraryOptions,
154
+ maxInputBytes: options.maxInputBytes
155
+ }
156
+ );
157
+ const {source, spatialContext, geometricError} = selection;
158
+ const common = {
159
+ source,
160
+ signal,
161
+ measureInputBytes: measureMeshBytes,
162
+ maxInputBytes: options.maxInputBytes ?? SELECTED_MESH_LIMITS.maxInputBytes,
163
+ maxOutputBytes,
164
+ maxInputResources: SELECTED_MESH_LIMITS.maxMeshResources,
165
+ maxOutputResources: SELECTED_MESH_LIMITS.maxMeshResources
166
+ };
167
+ const codecOptions = {
168
+ spatialContext,
169
+ autoOrigin: i3s,
170
+ integratedMeshPolicy: options.integratedMeshPolicy,
171
+ dracoLibraryOptions,
172
+ maxPositionError: SELECTED_MESH_LIMITS.maxPositionError
173
+ };
174
+ if (options.outputFormat === 'slpk') {
175
+ const sink = createI3SMeshSink({
176
+ maxTotalBytes: maxOutputBytes,
177
+ maxMeshes: SELECTED_MESH_LIMITS.maxMeshResources,
178
+ maxResourceBytes: maxOutputBytes
179
+ });
180
+ report = await convertTileset({
181
+ ...common,
182
+ sink,
183
+ codec: createI3SMeshConversionCodec({...codecOptions, maxResourceBytes: maxOutputBytes}),
184
+ measureOutputBytes: resource =>
185
+ Object.values(resource.files).reduce((total, bytes) => total + bytes.byteLength, 0)
186
+ });
187
+ files = sink.getFiles();
188
+ } else {
189
+ const sink = options.preserveHierarchy
190
+ ? createMeshHierarchyTilesetSink({
191
+ inspection: inspection as BrowserTilesetConversionInspection,
192
+ resourceIds: options.resourceIds,
193
+ maxTotalBytes: maxOutputBytes,
194
+ maxPositionError: SELECTED_MESH_LIMITS.maxPositionError
195
+ })
196
+ : createMeshTilesetSink({
197
+ maxTotalBytes: maxOutputBytes,
198
+ maxMeshes: SELECTED_MESH_LIMITS.maxMeshResources,
199
+ geometricError
200
+ });
201
+ report = await convertTileset({
202
+ ...common,
203
+ sink,
204
+ codec: createMeshConversionCodec(codecOptions),
205
+ measureOutputBytes: resource => resource.glb.byteLength
206
+ });
207
+ files = sink.getFiles();
208
+ }
209
+ } finally {
210
+ await rawSource.close();
211
+ }
212
+ signal.throwIfAborted();
213
+ // Validate packaging as well as content, including on the write-free validation path.
214
+ if (options.outputFormat !== '3d-tiles' && !options.output) {
215
+ for await (const chunk of encodeTileConversionArchiveInBatches(files, {
216
+ format: options.outputFormat,
217
+ maxArchiveBytes: maxOutputBytes,
218
+ signal
219
+ }))
220
+ void chunk;
221
+ }
222
+ if (options.output)
223
+ await writeNodeTileConversionOutput(
224
+ files,
225
+ options.output,
226
+ options.outputFormat,
227
+ maxOutputBytes,
228
+ signal
229
+ );
230
+ return report;
231
+ }
232
+
233
+ /** Saves fixed relative resource names or an archive; existing destinations are never overwritten. */
234
+ export async function writeNodeTileConversionOutput(
235
+ files: readonly BrowserTileConversionFile[],
236
+ output: string,
237
+ format: NodeTileConversionOutputFormat,
238
+ maxOutputBytes: number,
239
+ signal: AbortSignal
240
+ ): Promise<void> {
241
+ signal.throwIfAborted();
242
+ if (
243
+ !['3d-tiles', '3tz', 'slpk'].includes(format) ||
244
+ !Number.isSafeInteger(maxOutputBytes) ||
245
+ maxOutputBytes < 1
246
+ )
247
+ throw new TileConversionError(
248
+ 'INVALID_NODE_OUTPUT',
249
+ 'Output requires a supported format and positive safe byte limit'
250
+ );
251
+ let bytes = 0;
252
+ const names = new Set<string>();
253
+ for (const file of files) {
254
+ const name = file.resourceId;
255
+ if (
256
+ !name ||
257
+ name.includes('\\') ||
258
+ name.includes('\0') ||
259
+ name.includes(':') ||
260
+ name.split('/').some(segment => !segment || segment === '.' || segment === '..') ||
261
+ names.has(name)
262
+ )
263
+ throw new TileConversionError(
264
+ 'INVALID_NODE_OUTPUT',
265
+ 'Output names must be unique confined relative resource paths'
266
+ );
267
+ names.add(name);
268
+ if (file.blob.size > maxOutputBytes - bytes)
269
+ throw new TileConversionError(
270
+ 'OUTPUT_BYTES_LIMIT_EXCEEDED',
271
+ 'Output files exceed the byte budget'
272
+ );
273
+ bytes += file.blob.size;
274
+ }
275
+ const destination = resolve(output);
276
+ if (format === '3d-tiles') {
277
+ // Exclusive directory creation makes every subsequently created child application-owned.
278
+ await mkdir(destination);
279
+ try {
280
+ for (const file of files) {
281
+ signal.throwIfAborted();
282
+ const path = join(destination, file.resourceId);
283
+ await mkdir(dirname(path), {recursive: true});
284
+ const handle = await open(path, 'wx');
285
+ try {
286
+ const reader = file.blob.stream().getReader();
287
+ try {
288
+ while (true) {
289
+ signal.throwIfAborted();
290
+ const {value, done} = await reader.read();
291
+ if (done) break;
292
+ await writeFileBytes(handle, value, signal);
293
+ }
294
+ } finally {
295
+ await reader.cancel();
296
+ reader.releaseLock();
297
+ }
298
+ } finally {
299
+ await handle.close();
300
+ }
301
+ }
302
+ signal.throwIfAborted();
303
+ } catch (error) {
304
+ await rm(destination, {recursive: true, force: true});
305
+ throw error;
306
+ }
307
+ } else {
308
+ const temporaryDirectory = await mkdtemp(join(dirname(destination), '.tile-converter-'));
309
+ try {
310
+ const path = join(temporaryDirectory, 'archive');
311
+ const handle = await open(path, 'wx');
312
+ try {
313
+ for await (const chunk of encodeTileConversionArchiveInBatches(files, {
314
+ format,
315
+ maxArchiveBytes: maxOutputBytes,
316
+ signal
317
+ }))
318
+ await writeFileBytes(handle, chunk, signal);
319
+ } finally {
320
+ await handle.close();
321
+ }
322
+ signal.throwIfAborted();
323
+ // Same-filesystem hard link publishes a complete archive and fails if the destination exists.
324
+ await link(path, destination);
325
+ } finally {
326
+ await rm(temporaryDirectory, {recursive: true, force: true});
327
+ }
328
+ }
329
+ }
330
+
331
+ /** Completes native typed-array writes, including partial writes and cooperative cancellation. */
332
+ async function writeFileBytes(
333
+ handle: Awaited<ReturnType<typeof open>>,
334
+ bytes: Uint8Array,
335
+ signal: AbortSignal
336
+ ): Promise<void> {
337
+ let offset = 0;
338
+ while (offset < bytes.byteLength) {
339
+ signal.throwIfAborted();
340
+ const result = await handle.write(bytes, offset, bytes.byteLength - offset);
341
+ if (!result.bytesWritten)
342
+ throw new TileConversionError('NODE_OUTPUT_WRITE_FAILED', 'Output write made no progress');
343
+ offset += result.bytesWritten;
344
+ }
345
+ }
@@ -0,0 +1,155 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {constants} from 'node:fs';
6
+ import {open} from 'node:fs/promises';
7
+ import {TileConversionError} from '@loaders.gl/tile-converter/v5/core';
8
+ import type {NodeTileConversionOptions} from './node-conversion.js';
9
+
10
+ /** Maximum bytes retained for a local declarative feature mapping. */
11
+ const MAXIMUM_SCHEMA_BYTES = 64 * 1024;
12
+ /** Scalar storage types shared by the qualified mesh feature readers and writers. */
13
+ const FIELD_TYPES = [
14
+ 'int8',
15
+ 'uint8',
16
+ 'int16',
17
+ 'uint16',
18
+ 'int32',
19
+ 'uint32',
20
+ 'int64',
21
+ 'uint64',
22
+ 'float32',
23
+ 'float64',
24
+ 'utf8'
25
+ ];
26
+
27
+ /** Reads a bounded local JSON mapping; source/destination adapters validate data semantics. */
28
+ export async function readFeatureSchema(
29
+ path: string,
30
+ i3s: boolean,
31
+ signal?: AbortSignal
32
+ ): Promise<Pick<NodeTileConversionOptions, 'features' | 'i3sFeatures'>> {
33
+ signal?.throwIfAborted();
34
+ // Avoid blocking on special files before the regular-file check.
35
+ const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
36
+ let mapping: unknown;
37
+ try {
38
+ if (!(await file.stat()).isFile()) rejectSchema('Feature schema must be a regular local file');
39
+ const bytes = new Uint8Array(MAXIMUM_SCHEMA_BYTES + 1);
40
+ let length = 0;
41
+ while (length < bytes.length) {
42
+ signal?.throwIfAborted();
43
+ const {bytesRead} = await file.read(bytes, length, bytes.length - length, null);
44
+ if (!bytesRead) break;
45
+ length += bytesRead;
46
+ }
47
+ signal?.throwIfAborted();
48
+ if (length > MAXIMUM_SCHEMA_BYTES) rejectSchema('Feature schema exceeds 64 KiB');
49
+ try {
50
+ mapping = JSON.parse(
51
+ new TextDecoder('utf-8', {fatal: true}).decode(bytes.subarray(0, length))
52
+ );
53
+ } catch {
54
+ rejectSchema('Feature schema must contain valid UTF-8 JSON');
55
+ }
56
+ } finally {
57
+ await file.close();
58
+ }
59
+ const options = requireRecord(mapping);
60
+ rejectUnknownKeys(options, [
61
+ 'schema',
62
+ 'metadataClass',
63
+ 'sourceFeatureIdProperty',
64
+ 'featureIdField',
65
+ 'batchSize',
66
+ 'integer64Encoding',
67
+ ...(i3s ? ['objectIdProperty', 'maxAttributeBytes'] : [])
68
+ ]);
69
+ requireName(options.metadataClass);
70
+ for (const key of ['sourceFeatureIdProperty', 'featureIdField']) {
71
+ if (options[key] !== undefined) requireName(options[key]);
72
+ }
73
+ if (
74
+ options.batchSize !== undefined &&
75
+ (!Number.isSafeInteger(options.batchSize) ||
76
+ Number(options.batchSize) < 1 ||
77
+ Number(options.batchSize) > 65536)
78
+ )
79
+ rejectSchema('batchSize must be an integer from 1 to 65536');
80
+ if (options.integer64Encoding !== undefined && options.integer64Encoding !== 'decimal-string')
81
+ rejectSchema('integer64Encoding must be decimal-string when supplied');
82
+ const schema = requireRecord(options.schema);
83
+ rejectUnknownKeys(schema, ['fields', 'metadata']);
84
+ validateMetadata(schema.metadata);
85
+ if (!Array.isArray(schema.fields) || !schema.fields.length || schema.fields.length > 256)
86
+ rejectSchema('schema.fields must declare between 1 and 256 fields');
87
+ const names = new Set<string>();
88
+ for (const value of schema.fields) {
89
+ const field = requireRecord(value);
90
+ rejectUnknownKeys(field, ['name', 'type', 'nullable', 'metadata']);
91
+ requireName(field.name);
92
+ if (names.has(field.name as string)) rejectSchema('Feature fields must have unique names');
93
+ names.add(field.name as string);
94
+ if (
95
+ typeof field.type !== 'string' ||
96
+ !FIELD_TYPES.includes(field.type) ||
97
+ (field.nullable !== undefined && typeof field.nullable !== 'boolean')
98
+ )
99
+ rejectSchema('Fields require a scalar numeric/utf8 type and an optional boolean nullable');
100
+ validateMetadata(field.metadata);
101
+ }
102
+ const identifier = schema.fields.find(
103
+ value => value.name === (options.featureIdField ?? 'feature_id')
104
+ );
105
+ if (
106
+ !identifier ||
107
+ identifier.nullable === true ||
108
+ !['utf8', ...FIELD_TYPES.filter(type => /^(u?int)/.test(type))].includes(identifier.type)
109
+ )
110
+ rejectSchema('Declare a nonnullable string or integer feature ID field');
111
+ if (i3s) {
112
+ requireName(options.objectIdProperty);
113
+ if (
114
+ !Number.isSafeInteger(options.maxAttributeBytes) ||
115
+ Number(options.maxAttributeBytes) < 1 ||
116
+ Number(options.maxAttributeBytes) > 16 * 1024 * 1024
117
+ )
118
+ rejectSchema('maxAttributeBytes must be a positive integer at most 16 MiB');
119
+ return {
120
+ i3sFeatures: options as unknown as NonNullable<NodeTileConversionOptions['i3sFeatures']>
121
+ };
122
+ }
123
+ return {features: options as unknown as NonNullable<NodeTileConversionOptions['features']>};
124
+ }
125
+
126
+ /** Rejects unknown configuration keys rather than silently ignoring misspelled controls. */
127
+ function rejectUnknownKeys(record: Record<string, unknown>, allowed: readonly string[]): void {
128
+ if (Object.keys(record).some(key => !allowed.includes(key)))
129
+ rejectSchema('Unknown feature-schema option');
130
+ }
131
+
132
+ /** Requires a JSON object before accessing its fields. */
133
+ function requireRecord(value: unknown): Record<string, unknown> {
134
+ if (!value || typeof value !== 'object' || Array.isArray(value))
135
+ rejectSchema('Feature mappings require JSON objects');
136
+ return value as Record<string, unknown>;
137
+ }
138
+
139
+ /** Requires a nonempty class, property or column name. */
140
+ function requireName(value: unknown): void {
141
+ if (typeof value !== 'string' || !value.trim())
142
+ rejectSchema('Feature names must be nonempty strings');
143
+ }
144
+
145
+ /** Retains only string-valued schema/field metadata. */
146
+ function validateMetadata(value: unknown): void {
147
+ if (value === undefined) return;
148
+ if (Object.values(requireRecord(value)).some(entry => typeof entry !== 'string'))
149
+ rejectSchema('Schema metadata values must be strings');
150
+ }
151
+
152
+ /** Reports configuration failures before source conversion or output publication. */
153
+ function rejectSchema(message: string): never {
154
+ throw new TileConversionError('CLI_FEATURE_SCHEMA_INVALID', message);
155
+ }