@loaders.gl/tile-converter 5.0.0-alpha.8 → 5.0.0-alpha.9
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 +234 -0
- package/dist/converter.min.cjs +122 -122
- package/dist/index.cjs +3852 -1970
- package/dist/index.js +3670 -1789
- package/dist/v5/browser-sink.d.ts +1 -1
- package/dist/v5/browser.cjs +8845 -642
- package/dist/v5/browser.d.ts +12 -0
- package/dist/v5/browser.js +8848 -635
- package/dist/v5/conversion-archive.d.ts +18 -0
- package/dist/v5/index.cjs +9705 -2234
- package/dist/v5/index.d.ts +10 -0
- package/dist/v5/index.js +9715 -2233
- package/dist/v5/mesh-conversion.d.ts +55 -0
- package/dist/v5/mesh-tileset-archive.d.ts +16 -0
- package/dist/v5/mesh-tileset-sink.d.ts +31 -0
- package/dist/v5/mesh.d.ts +73 -0
- package/package.json +23 -23
- package/src/v5/browser-sink.ts +5 -4
- package/src/v5/browser.ts +23 -0
- package/src/v5/conversion-archive.ts +51 -0
- package/src/v5/index.ts +21 -0
- package/src/v5/mesh-conversion.ts +177 -0
- package/src/v5/mesh-tileset-archive.ts +48 -0
- package/src/v5/mesh-tileset-sink.ts +178 -0
- package/src/v5/mesh.ts +503 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// loaders.gl
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import type {Tiles3DTilesetJSON, Tiles3DTileJSON} from '@loaders.gl/3d-tiles';
|
|
6
|
+
import {TileConversionError} from './conversion-api.js';
|
|
7
|
+
import type {TileConversionSink} from './conversion-api.js';
|
|
8
|
+
import {createBoundedMemoryTileConversionSink} from './browser-sink.js';
|
|
9
|
+
import type {BrowserTileConversionFile} from './browser-sink.js';
|
|
10
|
+
import type {EncodedMeshConversionResource} from './mesh-conversion.js';
|
|
11
|
+
|
|
12
|
+
/** Limits and fidelity policy for one ECEF mesh tileset. */
|
|
13
|
+
export interface SingleMeshTilesetSinkOptions {
|
|
14
|
+
/** Maximum combined UTF-8 tileset JSON and GLB Blob bytes retained by the sink. */
|
|
15
|
+
readonly maxTotalBytes: number;
|
|
16
|
+
/** Explicit geometric error in meters, including source error and position rounding. */
|
|
17
|
+
readonly geometricError: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A single-tile output package retained as two named browser-native Blobs. */
|
|
21
|
+
export interface SingleMeshTilesetSink extends TileConversionSink<EncodedMeshConversionResource> {
|
|
22
|
+
/** Returns mesh.glb and tileset.json only after successful finalization; empty after abort. */
|
|
23
|
+
getFiles(): readonly BrowserTileConversionFile[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Packages exactly one `createMeshConversionCodec` output as a 3D Tiles 1.1 tileset.
|
|
28
|
+
*
|
|
29
|
+
* Requires resolved EPSG:4978 output identified by the compact string, with ellipsoidal heights.
|
|
30
|
+
* The tile transform cancels the standard glTF y-up to z-up rotation and adds the ECEF origin.
|
|
31
|
+
* Bounds come from encoded local vertices, avoiding subtraction of large absolute coordinates.
|
|
32
|
+
* Fixed relative names keep the package deterministic. Save both files together or pass them to
|
|
33
|
+
* createSingleMeshTilesetArchive for 3TZ packaging. Applications own downloads, workers, and
|
|
34
|
+
* source-scene extraction.
|
|
35
|
+
* The byte limit bounds retained Blobs, not peak conversion/serialization memory. Input GLB and
|
|
36
|
+
* local bounds must be unmodified codec outputs; this sink does not independently validate GLB.
|
|
37
|
+
*
|
|
38
|
+
* @param options - Required retained-output budget and geometric error in meters.
|
|
39
|
+
* @returns A bounded sink that exposes files on finalization and clears them on abort.
|
|
40
|
+
*/
|
|
41
|
+
export function createSingleMeshTilesetSink(
|
|
42
|
+
options: SingleMeshTilesetSinkOptions
|
|
43
|
+
): SingleMeshTilesetSink {
|
|
44
|
+
const {geometricError} = options;
|
|
45
|
+
if (!Number.isFinite(geometricError) || geometricError < 0) {
|
|
46
|
+
throw new TileConversionError(
|
|
47
|
+
'MESH_GEOMETRIC_ERROR_INVALID',
|
|
48
|
+
'geometricError must be finite and nonnegative, in meters'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const memorySink = createBoundedMemoryTileConversionSink(options);
|
|
52
|
+
let state: 'empty' | 'writing' | 'written' | 'closed' = 'empty';
|
|
53
|
+
let completed = false;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
getFiles() {
|
|
57
|
+
return completed ? memorySink.getFiles() : [];
|
|
58
|
+
},
|
|
59
|
+
async write(resource, signal) {
|
|
60
|
+
signal?.throwIfAborted();
|
|
61
|
+
if (state !== 'empty') {
|
|
62
|
+
throw new TileConversionError(
|
|
63
|
+
'SINGLE_MESH_SINK_UNAVAILABLE',
|
|
64
|
+
'The sink accepts exactly one mesh before finalization or abort'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const json = JSON.stringify(createMeshTileset(resource, geometricError));
|
|
68
|
+
state = 'writing';
|
|
69
|
+
await memorySink.write(
|
|
70
|
+
{resourceId: 'mesh.glb', parts: [resource.glb], contentType: 'model/gltf-binary'},
|
|
71
|
+
signal
|
|
72
|
+
);
|
|
73
|
+
await memorySink.write(
|
|
74
|
+
{resourceId: 'tileset.json', parts: [json], contentType: 'application/json'},
|
|
75
|
+
signal
|
|
76
|
+
);
|
|
77
|
+
state = 'written';
|
|
78
|
+
},
|
|
79
|
+
async finalize(report) {
|
|
80
|
+
if (state !== 'written') {
|
|
81
|
+
throw new TileConversionError(
|
|
82
|
+
'SINGLE_MESH_SINK_INCOMPLETE',
|
|
83
|
+
'Finalization requires exactly one successfully written mesh'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
await memorySink.finalize(report);
|
|
87
|
+
state = 'closed';
|
|
88
|
+
completed = true;
|
|
89
|
+
},
|
|
90
|
+
async abort(error) {
|
|
91
|
+
state = 'closed';
|
|
92
|
+
completed = false;
|
|
93
|
+
await memorySink.abort(error);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Builds placement and local bounds for a trusted single-mesh codec output. */
|
|
99
|
+
function createMeshTileset(
|
|
100
|
+
resource: EncodedMeshConversionResource,
|
|
101
|
+
geometricError: number
|
|
102
|
+
): Omit<Tiles3DTilesetJSON, 'root'> & {root: Omit<Tiles3DTileJSON, 'children'>} {
|
|
103
|
+
const reference = resource.spatialReference;
|
|
104
|
+
const outputCrs = reference.targetCrs || reference.sourceCrs;
|
|
105
|
+
const heightReference =
|
|
106
|
+
reference.targetHeightReference === 'native'
|
|
107
|
+
? reference.heightReference
|
|
108
|
+
: reference.targetHeightReference;
|
|
109
|
+
if (
|
|
110
|
+
!['native', 'transformed'].includes(reference.status) ||
|
|
111
|
+
outputCrs !== 'EPSG:4978' ||
|
|
112
|
+
heightReference !== 'ellipsoidal' ||
|
|
113
|
+
(reference.status === 'native' &&
|
|
114
|
+
(reference.coordinateFrame !== 'geocentric' ||
|
|
115
|
+
reference.axisOrder !== 'xyz' ||
|
|
116
|
+
reference.verticalUnitScale !== 1 ||
|
|
117
|
+
reference.units?.some(unit => unit !== 'meter')))
|
|
118
|
+
) {
|
|
119
|
+
throw new TileConversionError(
|
|
120
|
+
'MESH_TILESET_FRAME_UNSUPPORTED',
|
|
121
|
+
'Single-mesh tilesets require resolved EPSG:4978 xyz coordinates in meters with ellipsoidal heights'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
!Number.isFinite(resource.maximumPositionError) ||
|
|
126
|
+
resource.maximumPositionError < 0 ||
|
|
127
|
+
resource.maximumPositionError > geometricError
|
|
128
|
+
) {
|
|
129
|
+
throw new TileConversionError(
|
|
130
|
+
'MESH_GEOMETRIC_ERROR_INVALID',
|
|
131
|
+
'geometricError must include the measured position reconstruction error'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const origin = [...resource.origin];
|
|
135
|
+
const [minimum, maximum] = resource.localBoundingBox.map(bound => [...bound]);
|
|
136
|
+
if (
|
|
137
|
+
origin.length !== 3 ||
|
|
138
|
+
minimum.length !== 3 ||
|
|
139
|
+
maximum.length !== 3 ||
|
|
140
|
+
[...origin, ...minimum, ...maximum].some(value => !Number.isFinite(value)) ||
|
|
141
|
+
minimum.some((value, axis) => value > maximum[axis])
|
|
142
|
+
) {
|
|
143
|
+
throw new TileConversionError(
|
|
144
|
+
'MESH_TILESET_BOUNDS_INVALID',
|
|
145
|
+
'Origin and local bounds must be finite xyz coordinates with ordered bounds'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const center = minimum.map((value, axis) => value / 2 + maximum[axis] / 2);
|
|
149
|
+
const halfSize = minimum.map((value, axis) => maximum[axis] / 2 - value / 2);
|
|
150
|
+
return {
|
|
151
|
+
asset: {version: '1.1'},
|
|
152
|
+
geometricError,
|
|
153
|
+
root: {
|
|
154
|
+
// 3D Tiles applies y-up -> z-up to GLB content before the tile transform.
|
|
155
|
+
transform: [1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, ...origin, 1],
|
|
156
|
+
boundingVolume: {
|
|
157
|
+
// Bounds use the same y-up -> z-up rotation before the inverse tile rotation.
|
|
158
|
+
box: [
|
|
159
|
+
center[0],
|
|
160
|
+
-center[2],
|
|
161
|
+
center[1],
|
|
162
|
+
halfSize[0],
|
|
163
|
+
0,
|
|
164
|
+
0,
|
|
165
|
+
0,
|
|
166
|
+
0,
|
|
167
|
+
halfSize[1],
|
|
168
|
+
0,
|
|
169
|
+
-halfSize[2],
|
|
170
|
+
0
|
|
171
|
+
]
|
|
172
|
+
},
|
|
173
|
+
geometricError,
|
|
174
|
+
refine: 'REPLACE',
|
|
175
|
+
content: {uri: 'mesh.glb'}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/v5/mesh.ts
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// loaders.gl
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import {GLTFScenegraph, GLTFWriter} from '@loaders.gl/gltf';
|
|
6
|
+
import {getBinaryImageMetadata} from '@loaders.gl/images';
|
|
7
|
+
import type {MeshAttribute, MeshGeometry} from '@loaders.gl/schema';
|
|
8
|
+
import {TileConversionError} from './conversion-api.js';
|
|
9
|
+
|
|
10
|
+
/** Explicit glTF wrapping and filtering for the selected base-color image. */
|
|
11
|
+
export interface MeshTileSampler {
|
|
12
|
+
/** Horizontal wrapping: CLAMP_TO_EDGE, MIRRORED_REPEAT, or REPEAT; omitted values use REPEAT. */
|
|
13
|
+
readonly wrapS?: 33071 | 33648 | 10497;
|
|
14
|
+
/** Vertical wrapping: CLAMP_TO_EDGE, MIRRORED_REPEAT, or REPEAT; omitted values use REPEAT. */
|
|
15
|
+
readonly wrapT?: 33071 | 33648 | 10497;
|
|
16
|
+
/** Magnification: NEAREST or LINEAR; omission leaves filtering to the renderer. */
|
|
17
|
+
readonly magFilter?: 9728 | 9729;
|
|
18
|
+
/** Minification: NEAREST, LINEAR, or a glTF mipmap filter; omission leaves filtering to the renderer. */
|
|
19
|
+
readonly minFilter?: 9728 | 9729 | 9984 | 9985 | 9986 | 9987;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Selected KHR_texture_transform controls for TEXCOORD_0. */
|
|
23
|
+
export interface MeshTileTextureTransform {
|
|
24
|
+
/** Finite UV translation; omitted values use [0, 0]. */
|
|
25
|
+
readonly offset?: readonly [number, number];
|
|
26
|
+
/** Finite counterclockwise rotation in radians around the UV origin; omission uses 0. */
|
|
27
|
+
readonly rotation?: number;
|
|
28
|
+
/** Finite UV scale; zero and negative values are allowed, and omission uses [1, 1]. */
|
|
29
|
+
readonly scale?: readonly [number, number];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One already encoded base-color image using TEXCOORD_0. */
|
|
33
|
+
export interface MeshTileTexture {
|
|
34
|
+
/** Encoded PNG/JPEG bytes; a typed array subview selects only its own bytes. */
|
|
35
|
+
readonly data: Uint8Array;
|
|
36
|
+
/** Declared image format, checked against the encoded header. */
|
|
37
|
+
readonly mimeType: 'image/png' | 'image/jpeg';
|
|
38
|
+
/** Optional wrapping/filtering; omission retains glTF's implicit sampler. */
|
|
39
|
+
readonly sampler?: MeshTileSampler;
|
|
40
|
+
/** Optional UV transform, preserved as a required KHR_texture_transform extension. */
|
|
41
|
+
readonly transform?: MeshTileTextureTransform;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One glTF metallic-roughness material, shared by every triangle in the mesh. */
|
|
45
|
+
export interface MeshTileMaterial {
|
|
46
|
+
/** Linear RGBA base-color multiplier; components must be finite in [0, 1]. */
|
|
47
|
+
readonly baseColorFactor?: readonly [number, number, number, number];
|
|
48
|
+
/** Optional embedded image, multiplied by baseColorFactor and COLOR_0; requires TEXCOORD_0. */
|
|
49
|
+
readonly baseColorTexture?: MeshTileTexture;
|
|
50
|
+
/** Metallic weight in [0, 1]; omitted values use the glTF default of 1. */
|
|
51
|
+
readonly metallicFactor?: number;
|
|
52
|
+
/** Roughness weight in [0, 1]; omitted values use the glTF default of 1. */
|
|
53
|
+
readonly roughnessFactor?: number;
|
|
54
|
+
/** Alpha interpretation; omitted values use OPAQUE. */
|
|
55
|
+
readonly alphaMode?: 'OPAQUE' | 'MASK' | 'BLEND';
|
|
56
|
+
/** Finite nonnegative threshold, allowed only with MASK; omitted values use 0.5. */
|
|
57
|
+
readonly alphaCutoff?: number;
|
|
58
|
+
/** Whether both sides are rendered; omitted values use false. */
|
|
59
|
+
readonly doubleSided?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Optional appearance for the single-mesh GLB profile. */
|
|
63
|
+
export interface MeshTileOptions {
|
|
64
|
+
/** Explicitly selected material; other texture maps and material extensions are unsupported. */
|
|
65
|
+
readonly material?: MeshTileMaterial;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Encodes one triangle mesh as a self-contained glTF 2.0 GLB resource.
|
|
70
|
+
*
|
|
71
|
+
* POSITION and optional NORMAL must contain packed finite Float32 xyz triples. COLOR_0 accepts
|
|
72
|
+
* packed Float32 linear RGB/RGBA in [0, 1] or normalized Uint8/Uint16 colors per vertex.
|
|
73
|
+
* Normals must have unit length within 0.0001. TEXCOORD_0 accepts packed finite Float32
|
|
74
|
+
* or normalized Uint8/Uint16 UV pairs per vertex. Optional indices use packed unsigned
|
|
75
|
+
* 8-, 16-, or 32-bit values.
|
|
76
|
+
* Unsupported attributes, layouts, and encoded transforms fail rather than being discarded.
|
|
77
|
+
* Coordinates are preserved: callers own CRS conversion, local origins, placement, source material mapping,
|
|
78
|
+
* feature mappings, and tileset packaging. Input arrays are never modified.
|
|
79
|
+
*
|
|
80
|
+
* @param mesh - Triangle-list geometry in the caller's selected local coordinate frame.
|
|
81
|
+
* @param options - Optional single material with an already encoded base-color image.
|
|
82
|
+
* @returns An embedded-buffer GLB containing one mesh, node, and default scene.
|
|
83
|
+
*/
|
|
84
|
+
export function encodeMeshTile(mesh: MeshGeometry, options: MeshTileOptions = {}): ArrayBuffer {
|
|
85
|
+
const geometry = validateMeshGeometry(mesh);
|
|
86
|
+
const scenegraph = new GLTFScenegraph({json: {asset: {version: '2.0', generator: 'loaders.gl'}}});
|
|
87
|
+
const material =
|
|
88
|
+
options.material === undefined
|
|
89
|
+
? undefined
|
|
90
|
+
: validateMeshMaterial(options.material, scenegraph, 'TEXCOORD_0' in geometry.attributes);
|
|
91
|
+
const meshIndex = scenegraph.addMesh({
|
|
92
|
+
attributes: geometry.attributes,
|
|
93
|
+
indices: geometry.indices?.value,
|
|
94
|
+
material: material ? scenegraph.addMaterial(material) : undefined,
|
|
95
|
+
mode: 4
|
|
96
|
+
});
|
|
97
|
+
const nodeIndex = scenegraph.addNode({meshIndex});
|
|
98
|
+
const sceneIndex = scenegraph.addScene({nodeIndices: [nodeIndex]});
|
|
99
|
+
scenegraph.setDefaultScene(sceneIndex);
|
|
100
|
+
scenegraph.createBinaryChunk();
|
|
101
|
+
return GLTFWriter.encodeSync!(scenegraph.gltf);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Validates the shared mesh profile; spatial preparation may retain Float64 positions internally. */
|
|
105
|
+
export function validateMeshGeometry(
|
|
106
|
+
mesh: MeshGeometry,
|
|
107
|
+
allowFloat64Positions = false
|
|
108
|
+
): MeshGeometry {
|
|
109
|
+
if (mesh.topology !== 'triangle-list' || mesh.mode !== 4) {
|
|
110
|
+
throw new TileConversionError(
|
|
111
|
+
'MESH_TOPOLOGY_UNSUPPORTED',
|
|
112
|
+
'Only triangle-list mode 4 is supported'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
for (const name of Object.keys(mesh.attributes)) {
|
|
116
|
+
if (name !== 'POSITION' && name !== 'NORMAL' && name !== 'COLOR_0' && name !== 'TEXCOORD_0') {
|
|
117
|
+
throw new TileConversionError(
|
|
118
|
+
'MESH_ATTRIBUTE_UNSUPPORTED',
|
|
119
|
+
`Mesh attribute ${name} is unsupported`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const positions = getFloatAttribute(mesh.attributes.POSITION, 'POSITION', allowFloat64Positions);
|
|
124
|
+
const attributes: Record<string, MeshAttribute> = {
|
|
125
|
+
POSITION: {value: positions, size: 3}
|
|
126
|
+
};
|
|
127
|
+
if (mesh.attributes.NORMAL) {
|
|
128
|
+
const normals = getFloatAttribute(mesh.attributes.NORMAL, 'NORMAL');
|
|
129
|
+
if (normals.length !== positions.length) {
|
|
130
|
+
throw new TileConversionError(
|
|
131
|
+
'MESH_NORMAL_COUNT_MISMATCH',
|
|
132
|
+
'NORMAL count must match POSITION count'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
for (let index = 0; index < normals.length; index += 3) {
|
|
136
|
+
if (
|
|
137
|
+
Math.abs(Math.hypot(normals[index], normals[index + 1], normals[index + 2]) - 1) > 0.0001
|
|
138
|
+
) {
|
|
139
|
+
throw new TileConversionError(
|
|
140
|
+
'MESH_NORMAL_INVALID',
|
|
141
|
+
'NORMAL vectors must have unit length'
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
attributes.NORMAL = {value: normals, size: 3};
|
|
146
|
+
}
|
|
147
|
+
if ('COLOR_0' in mesh.attributes) {
|
|
148
|
+
const colors = mesh.attributes.COLOR_0;
|
|
149
|
+
const normalized = isNormalizedUnsignedAttribute(colors);
|
|
150
|
+
if (
|
|
151
|
+
!colors ||
|
|
152
|
+
!(colors.value instanceof Float32Array || normalized) ||
|
|
153
|
+
(colors.size !== 3 && colors.size !== 4) ||
|
|
154
|
+
colors.value.length / colors.size !== positions.length / 3 ||
|
|
155
|
+
(colors.value instanceof Float32Array &&
|
|
156
|
+
colors.value.some(value => !Number.isFinite(value) || value < 0 || value > 1))
|
|
157
|
+
) {
|
|
158
|
+
throw new TileConversionError(
|
|
159
|
+
'MESH_COLOR_INVALID',
|
|
160
|
+
'COLOR_0 requires packed linear RGB/RGBA: Float32 in [0, 1] or normalized Uint8/Uint16, one per vertex'
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
validateAttributeLayout(colors, 'COLOR_0', normalized);
|
|
164
|
+
attributes.COLOR_0 = {
|
|
165
|
+
value: colors.value,
|
|
166
|
+
size: colors.size,
|
|
167
|
+
...(normalized ? {normalized: true} : {})
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if ('TEXCOORD_0' in mesh.attributes) {
|
|
171
|
+
const textureCoordinates = mesh.attributes.TEXCOORD_0;
|
|
172
|
+
const normalized = isNormalizedUnsignedAttribute(textureCoordinates);
|
|
173
|
+
if (
|
|
174
|
+
!textureCoordinates ||
|
|
175
|
+
!(textureCoordinates.value instanceof Float32Array || normalized) ||
|
|
176
|
+
textureCoordinates.size !== 2 ||
|
|
177
|
+
textureCoordinates.value.length / 2 !== positions.length / 3 ||
|
|
178
|
+
textureCoordinates.value.some(value => !Number.isFinite(value))
|
|
179
|
+
) {
|
|
180
|
+
throw new TileConversionError(
|
|
181
|
+
'MESH_TEXCOORD_INVALID',
|
|
182
|
+
'TEXCOORD_0 requires packed finite Float32 or normalized Uint8/Uint16 UV pairs, one per vertex'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
validateAttributeLayout(textureCoordinates, 'TEXCOORD_0', normalized);
|
|
186
|
+
attributes.TEXCOORD_0 = {
|
|
187
|
+
value: textureCoordinates.value,
|
|
188
|
+
size: 2,
|
|
189
|
+
...(normalized ? {normalized: true} : {})
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const indices = getMeshIndices(mesh.indices, positions.length / 3);
|
|
193
|
+
if (!indices && positions.length % 9 !== 0) {
|
|
194
|
+
throw new TileConversionError(
|
|
195
|
+
'MESH_TRIANGLE_COUNT_INVALID',
|
|
196
|
+
'Nonindexed meshes require complete triangles'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
topology: 'triangle-list',
|
|
201
|
+
mode: 4,
|
|
202
|
+
attributes,
|
|
203
|
+
indices: indices ? {value: indices, size: 1} : undefined
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Reads one finite packed xyz attribute without changing its storage. */
|
|
208
|
+
function getFloatAttribute(
|
|
209
|
+
attribute: MeshAttribute | undefined,
|
|
210
|
+
name: string,
|
|
211
|
+
allowFloat64 = false
|
|
212
|
+
): Float32Array | Float64Array {
|
|
213
|
+
if (
|
|
214
|
+
!attribute ||
|
|
215
|
+
!(
|
|
216
|
+
attribute.value instanceof Float32Array ||
|
|
217
|
+
(allowFloat64 && attribute.value instanceof Float64Array)
|
|
218
|
+
) ||
|
|
219
|
+
attribute.size !== 3 ||
|
|
220
|
+
attribute.value.length === 0 ||
|
|
221
|
+
attribute.value.length % 3 !== 0
|
|
222
|
+
) {
|
|
223
|
+
throw new TileConversionError(
|
|
224
|
+
'MESH_ATTRIBUTE_INVALID',
|
|
225
|
+
`${name} must contain packed ${allowFloat64 ? 'Float32 or Float64' : 'Float32'} xyz triples`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
validateAttributeLayout(attribute, name);
|
|
229
|
+
if (attribute.value.some(value => !Number.isFinite(value))) {
|
|
230
|
+
throw new TileConversionError('MESH_ATTRIBUTE_NONFINITE', `${name} must contain finite values`);
|
|
231
|
+
}
|
|
232
|
+
return attribute.value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Reads optional packed triangle indices and checks glTF range and primitive-restart rules. */
|
|
236
|
+
function getMeshIndices(
|
|
237
|
+
attribute: MeshAttribute | undefined,
|
|
238
|
+
vertexCount: number
|
|
239
|
+
): Uint8Array | Uint16Array | Uint32Array | undefined {
|
|
240
|
+
if (!attribute) return undefined;
|
|
241
|
+
validateAttributeLayout(attribute, 'indices');
|
|
242
|
+
const values = attribute.value;
|
|
243
|
+
if (
|
|
244
|
+
attribute.size !== 1 ||
|
|
245
|
+
!(
|
|
246
|
+
values instanceof Uint8Array ||
|
|
247
|
+
values instanceof Uint16Array ||
|
|
248
|
+
values instanceof Uint32Array
|
|
249
|
+
)
|
|
250
|
+
) {
|
|
251
|
+
throw new TileConversionError(
|
|
252
|
+
'MESH_INDICES_INVALID',
|
|
253
|
+
'Indices must contain packed unsigned integer scalars'
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (values.length === 0 || values.length % 3 !== 0) {
|
|
257
|
+
throw new TileConversionError(
|
|
258
|
+
'MESH_TRIANGLE_COUNT_INVALID',
|
|
259
|
+
'Indices must contain complete triangles'
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const restartIndex = 2 ** (values.BYTES_PER_ELEMENT * 8) - 1;
|
|
263
|
+
if (values.some(value => value >= vertexCount || value === restartIndex)) {
|
|
264
|
+
throw new TileConversionError(
|
|
265
|
+
'MESH_INDEX_OUT_OF_RANGE',
|
|
266
|
+
'Indices must reference existing vertices and exclude primitive-restart values'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return values;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Identifies explicitly normalized unsigned storage supported by the appearance profile. */
|
|
273
|
+
function isNormalizedUnsignedAttribute(attribute: MeshAttribute | undefined): boolean {
|
|
274
|
+
return (
|
|
275
|
+
attribute?.normalized === true &&
|
|
276
|
+
(attribute.value instanceof Uint8Array || attribute.value instanceof Uint16Array)
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Rejects descriptor layouts and transforms the initial mesh profile cannot represent. */
|
|
281
|
+
function validateAttributeLayout(
|
|
282
|
+
attribute: MeshAttribute,
|
|
283
|
+
name: string,
|
|
284
|
+
allowNormalized = false
|
|
285
|
+
): void {
|
|
286
|
+
if (
|
|
287
|
+
(attribute.byteOffset ?? 0) !== 0 ||
|
|
288
|
+
(attribute.byteStride ?? 0) !== 0 ||
|
|
289
|
+
(attribute.normalized && !allowNormalized) ||
|
|
290
|
+
attribute.transform ||
|
|
291
|
+
attribute.componentType
|
|
292
|
+
) {
|
|
293
|
+
throw new TileConversionError(
|
|
294
|
+
'MESH_ATTRIBUTE_LAYOUT_UNSUPPORTED',
|
|
295
|
+
`${name} must use packed contiguous storage with supported normalization`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Validates and maps the deliberately limited material profile without changing caller objects. */
|
|
301
|
+
function validateMeshMaterial(
|
|
302
|
+
material: MeshTileMaterial,
|
|
303
|
+
scenegraph: GLTFScenegraph,
|
|
304
|
+
hasTextureCoordinates: boolean
|
|
305
|
+
): object {
|
|
306
|
+
if (!material || typeof material !== 'object' || Array.isArray(material)) {
|
|
307
|
+
throw new TileConversionError('MESH_MATERIAL_INVALID', 'Material must be an object');
|
|
308
|
+
}
|
|
309
|
+
const allowedProperties = [
|
|
310
|
+
'baseColorFactor',
|
|
311
|
+
'baseColorTexture',
|
|
312
|
+
'metallicFactor',
|
|
313
|
+
'roughnessFactor',
|
|
314
|
+
'alphaMode',
|
|
315
|
+
'alphaCutoff',
|
|
316
|
+
'doubleSided'
|
|
317
|
+
];
|
|
318
|
+
for (const name of Object.keys(material)) {
|
|
319
|
+
if (!allowedProperties.includes(name)) {
|
|
320
|
+
throw new TileConversionError(
|
|
321
|
+
'MESH_MATERIAL_UNSUPPORTED',
|
|
322
|
+
`Material property ${name} is unsupported`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const {baseColorFactor, metallicFactor, roughnessFactor, alphaMode, alphaCutoff, doubleSided} =
|
|
327
|
+
material;
|
|
328
|
+
if (
|
|
329
|
+
(baseColorFactor !== undefined &&
|
|
330
|
+
(!Array.isArray(baseColorFactor) ||
|
|
331
|
+
baseColorFactor.length !== 4 ||
|
|
332
|
+
Array.from(baseColorFactor).some(
|
|
333
|
+
value => !Number.isFinite(value) || value < 0 || value > 1
|
|
334
|
+
))) ||
|
|
335
|
+
[metallicFactor, roughnessFactor].some(
|
|
336
|
+
value => value !== undefined && (!Number.isFinite(value) || value < 0 || value > 1)
|
|
337
|
+
) ||
|
|
338
|
+
(alphaMode !== undefined && !['OPAQUE', 'MASK', 'BLEND'].includes(alphaMode)) ||
|
|
339
|
+
(alphaCutoff !== undefined &&
|
|
340
|
+
(alphaMode !== 'MASK' || !Number.isFinite(alphaCutoff) || alphaCutoff < 0)) ||
|
|
341
|
+
(doubleSided !== undefined && typeof doubleSided !== 'boolean')
|
|
342
|
+
) {
|
|
343
|
+
throw new TileConversionError(
|
|
344
|
+
'MESH_MATERIAL_INVALID',
|
|
345
|
+
'Material values must satisfy the selected glTF profile'
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
pbrMetallicRoughness: {
|
|
350
|
+
baseColorFactor: baseColorFactor ? [...baseColorFactor] : undefined,
|
|
351
|
+
metallicFactor,
|
|
352
|
+
roughnessFactor,
|
|
353
|
+
baseColorTexture:
|
|
354
|
+
'baseColorTexture' in material
|
|
355
|
+
? encodeBaseColorTexture(material.baseColorTexture, scenegraph, hasTextureCoordinates)
|
|
356
|
+
: undefined
|
|
357
|
+
},
|
|
358
|
+
alphaMode,
|
|
359
|
+
alphaCutoff,
|
|
360
|
+
doubleSided
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Embeds an explicitly selected image without decoding pixels or changing sampler/UV semantics. */
|
|
365
|
+
function encodeBaseColorTexture(
|
|
366
|
+
texture: MeshTileTexture | undefined,
|
|
367
|
+
scenegraph: GLTFScenegraph,
|
|
368
|
+
hasTextureCoordinates: boolean
|
|
369
|
+
): {index: number; extensions?: {KHR_texture_transform: MeshTileTextureTransform}} {
|
|
370
|
+
if (
|
|
371
|
+
!texture ||
|
|
372
|
+
Object.keys(texture).some(
|
|
373
|
+
name => name !== 'data' && name !== 'mimeType' && name !== 'sampler' && name !== 'transform'
|
|
374
|
+
) ||
|
|
375
|
+
!(texture.data instanceof Uint8Array) ||
|
|
376
|
+
!['image/png', 'image/jpeg'].includes(texture.mimeType)
|
|
377
|
+
) {
|
|
378
|
+
throw new TileConversionError(
|
|
379
|
+
'MESH_TEXTURE_INVALID',
|
|
380
|
+
'Base-color texture must supply Uint8Array data, image/png or image/jpeg mimeType, and optional sampling/transform controls'
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (!hasTextureCoordinates) {
|
|
384
|
+
throw new TileConversionError(
|
|
385
|
+
'MESH_TEXCOORD_REQUIRED',
|
|
386
|
+
'Base-color texture requires TEXCOORD_0'
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const image = new DataView(texture.data.buffer, texture.data.byteOffset, texture.data.byteLength);
|
|
390
|
+
let metadata;
|
|
391
|
+
try {
|
|
392
|
+
metadata = getBinaryImageMetadata(image);
|
|
393
|
+
} catch {
|
|
394
|
+
// Malformed encoded headers must produce the same typed diagnostic as unrecognized headers.
|
|
395
|
+
}
|
|
396
|
+
if (
|
|
397
|
+
!metadata ||
|
|
398
|
+
metadata.mimeType !== texture.mimeType ||
|
|
399
|
+
metadata.width <= 0 ||
|
|
400
|
+
metadata.height <= 0
|
|
401
|
+
) {
|
|
402
|
+
throw new TileConversionError(
|
|
403
|
+
'MESH_TEXTURE_INVALID',
|
|
404
|
+
'Image header must match mimeType and declare positive dimensions'
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const samplerIndex =
|
|
408
|
+
'sampler' in texture ? scenegraph.addSampler(validateMeshSampler(texture.sampler)) : undefined;
|
|
409
|
+
const transform =
|
|
410
|
+
'transform' in texture ? validateMeshTextureTransform(texture.transform) : undefined;
|
|
411
|
+
if (transform) scenegraph.registerRequiredExtension('KHR_texture_transform');
|
|
412
|
+
return {
|
|
413
|
+
...(transform ? {extensions: {KHR_texture_transform: transform}} : {}),
|
|
414
|
+
index: scenegraph.addTexture({
|
|
415
|
+
imageIndex: scenegraph.addImage(image, texture.mimeType),
|
|
416
|
+
samplerIndex
|
|
417
|
+
})
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Validates the four sampling controls without silently discarding unsupported properties. */
|
|
422
|
+
function validateMeshSampler(sampler: MeshTileSampler | undefined): MeshTileSampler {
|
|
423
|
+
if (!sampler || typeof sampler !== 'object' || Array.isArray(sampler)) {
|
|
424
|
+
throw new TileConversionError('MESH_SAMPLER_INVALID', 'Sampler must be an object');
|
|
425
|
+
}
|
|
426
|
+
const allowedValues = {
|
|
427
|
+
wrapS: [33071, 33648, 10497],
|
|
428
|
+
wrapT: [33071, 33648, 10497],
|
|
429
|
+
magFilter: [9728, 9729],
|
|
430
|
+
minFilter: [9728, 9729, 9984, 9985, 9986, 9987]
|
|
431
|
+
};
|
|
432
|
+
for (const name in sampler) {
|
|
433
|
+
if (!Object.hasOwn(allowedValues, name)) {
|
|
434
|
+
throw new TileConversionError(
|
|
435
|
+
'MESH_SAMPLER_UNSUPPORTED',
|
|
436
|
+
`Sampler property ${name} is unsupported`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const selectedSampler = {
|
|
441
|
+
wrapS: sampler.wrapS,
|
|
442
|
+
wrapT: sampler.wrapT,
|
|
443
|
+
minFilter: sampler.minFilter,
|
|
444
|
+
magFilter: sampler.magFilter
|
|
445
|
+
};
|
|
446
|
+
for (const name of Object.keys(allowedValues)) {
|
|
447
|
+
const property = name as keyof MeshTileSampler;
|
|
448
|
+
if (
|
|
449
|
+
selectedSampler[property] !== undefined &&
|
|
450
|
+
!allowedValues[property].includes(selectedSampler[property]!)
|
|
451
|
+
) {
|
|
452
|
+
throw new TileConversionError(
|
|
453
|
+
'MESH_SAMPLER_INVALID',
|
|
454
|
+
`Sampler ${name} must use a glTF wrapping/filtering value`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return selectedSampler;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Copies finite UV controls and rejects unmapped extension semantics. */
|
|
462
|
+
function validateMeshTextureTransform(
|
|
463
|
+
transform: MeshTileTextureTransform | undefined
|
|
464
|
+
): MeshTileTextureTransform {
|
|
465
|
+
if (!transform || typeof transform !== 'object' || Array.isArray(transform)) {
|
|
466
|
+
throw new TileConversionError(
|
|
467
|
+
'MESH_TEXTURE_TRANSFORM_INVALID',
|
|
468
|
+
'Texture transform must be an object'
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
for (const name in transform) {
|
|
472
|
+
if (name !== 'offset' && name !== 'rotation' && name !== 'scale') {
|
|
473
|
+
throw new TileConversionError(
|
|
474
|
+
'MESH_TEXTURE_TRANSFORM_UNSUPPORTED',
|
|
475
|
+
`Texture transform property ${name} is unsupported`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const {offset, rotation, scale} = transform;
|
|
480
|
+
const offsetValues =
|
|
481
|
+
Array.isArray(offset) && offset.length === 2 ? [offset[0], offset[1]] : offset;
|
|
482
|
+
const scaleValues = Array.isArray(scale) && scale.length === 2 ? [scale[0], scale[1]] : scale;
|
|
483
|
+
if (
|
|
484
|
+
[offsetValues, scaleValues].some(
|
|
485
|
+
value =>
|
|
486
|
+
value !== undefined &&
|
|
487
|
+
(!Array.isArray(value) ||
|
|
488
|
+
value.length !== 2 ||
|
|
489
|
+
value.some(component => !Number.isFinite(component)))
|
|
490
|
+
) ||
|
|
491
|
+
(rotation !== undefined && !Number.isFinite(rotation))
|
|
492
|
+
) {
|
|
493
|
+
throw new TileConversionError(
|
|
494
|
+
'MESH_TEXTURE_TRANSFORM_INVALID',
|
|
495
|
+
'Texture transform requires finite UV pairs and a finite rotation'
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
offset: offsetValues === undefined ? undefined : [offsetValues[0], offsetValues[1]],
|
|
500
|
+
rotation,
|
|
501
|
+
scale: scaleValues === undefined ? undefined : [scaleValues[0], scaleValues[1]]
|
|
502
|
+
};
|
|
503
|
+
}
|