@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
package/src/v5/index.ts CHANGED
@@ -30,8 +30,14 @@ export type {
30
30
  FeatureArrowBatchOptions,
31
31
  TileFeatureAttributes
32
32
  } from '@loaders.gl/tile-converter/v5/adapters';
33
- export {encodePointCloudTile} from '@loaders.gl/tile-converter/v5/adapters';
34
- export type {EncodePointCloudTileOptions} from '@loaders.gl/tile-converter/v5/adapters';
33
+ export {
34
+ encodePointCloudTile,
35
+ encodePointCloudTileWithMetadata
36
+ } from '@loaders.gl/tile-converter/v5/adapters';
37
+ export type {
38
+ EncodePointCloudTileOptions,
39
+ EncodedPointCloudTile
40
+ } from '@loaders.gl/tile-converter/v5/adapters';
35
41
  export {traversePointCloudSource} from '@loaders.gl/tile-converter/v5/core';
36
42
  export type {
37
43
  PointCloudSourceTile,
@@ -97,3 +103,55 @@ export type {
97
103
  I3SMeshConversionCodecOptions,
98
104
  SingleMeshI3SSink
99
105
  } from '@loaders.gl/tile-converter/v5/adapters';
106
+
107
+ export {
108
+ createI3SMeshSink,
109
+ createTileConversionResourceFetcher
110
+ } from '@loaders.gl/tile-converter/v5/adapters';
111
+ export type {
112
+ I3SMeshSink,
113
+ I3SMeshSinkOptions,
114
+ TileConversionResourceFetcherOptions,
115
+ MeshTileFeatures
116
+ } from '@loaders.gl/tile-converter/v5/adapters';
117
+
118
+ export {createPointCloudTilesetSink} from '@loaders.gl/tile-converter/v5/adapters';
119
+ export type {
120
+ PointCloudTilesetSink,
121
+ PointCloudTilesetSinkOptions
122
+ } from '@loaders.gl/tile-converter/v5/adapters';
123
+
124
+ export {transformPointCloudSourceTile} from '@loaders.gl/tile-converter/v5/adapters';
125
+ export type {PointCloudSpatialOptions} from '@loaders.gl/tile-converter/v5/adapters';
126
+
127
+ export {createI3SMeshTilesetConversionSource} from '@loaders.gl/tile-converter/v5/adapters';
128
+ export type {
129
+ I3SMeshTilesetSourceOptions,
130
+ I3SMeshSourceFeatureOptions
131
+ } from '@loaders.gl/tile-converter/v5/adapters';
132
+
133
+ export {convertPointCloudToTileset} from '@loaders.gl/tile-converter/v5/adapters';
134
+ export type {
135
+ ConvertPointCloudToTilesetOptions,
136
+ ConvertedPointCloudTileset
137
+ } from '@loaders.gl/tile-converter/v5/adapters';
138
+
139
+ export {
140
+ encodePotreeArchive,
141
+ openPointCloudFile,
142
+ extractMeshVertices
143
+ } from '@loaders.gl/tile-converter/v5/adapters';
144
+ export type {
145
+ EncodePotreeArchiveOptions,
146
+ OpenPointCloudFileOptions,
147
+ OpenedPointCloudFile,
148
+ ExtractMeshVerticesOptions
149
+ } from '@loaders.gl/tile-converter/v5/adapters';
150
+
151
+ export {createMeshSourceFeatureOptions} from '@loaders.gl/tile-converter/v5/adapters';
152
+ export type {MeshSourceSchemaOptions} from '@loaders.gl/tile-converter/v5/adapters';
153
+ export {convertPointCloudToPotree} from '@loaders.gl/tile-converter/v5/adapters';
154
+ export type {
155
+ ConvertPointCloudToPotreeOptions,
156
+ ConvertedPointCloudPotree
157
+ } from '@loaders.gl/tile-converter/v5/adapters';
@@ -8,8 +8,12 @@ import type {MeshGeometry} from '@loaders.gl/schema';
8
8
  import type {LoadLibraryOptions} from '@loaders.gl/worker-utils';
9
9
  import {TileConversionError} from '@loaders.gl/tile-converter/v5/core';
10
10
  import type {TileConversionCodec} from '@loaders.gl/tile-converter/v5/core';
11
- import type {Tiles3DConversionSpatialContext} from '@loaders.gl/tile-converter/v5/core';
11
+ import type {
12
+ Tiles3DConversionSpatialContext,
13
+ I3SConversionSpatialContext
14
+ } from '@loaders.gl/tile-converter/v5/core';
12
15
  import {encodeMeshTile, validateMeshGeometry} from './mesh.js';
16
+ import type {MeshTileFeatures} from './mesh-features.js';
13
17
  import type {MeshTileMaterial} from './mesh.js';
14
18
  import {encodeDracoMeshTile} from './mesh-draco.js';
15
19
 
@@ -17,10 +21,23 @@ import {encodeDracoMeshTile} from './mesh-draco.js';
17
21
  export interface MeshConversionInput {
18
22
  /** Caller-owned resource identifier, forwarded to the output sink. */
19
23
  readonly id: string;
24
+ /** Inspected content placement, used by the hierarchy sink independently of primitive IDs. */
25
+ readonly contentResourceId?: string;
26
+ /** Ordered primitive within a content; last marks complete extraction for hierarchy authoring. */
27
+ readonly contentPrimitive?: {
28
+ /** Zero-based primitive placement index in scene traversal order. */
29
+ readonly index: number;
30
+ /** Whether this is the final primitive placement in the content. */
31
+ readonly last: boolean;
32
+ };
20
33
  /** Triangle geometry with absolute packed Float32 or Float64 source positions. */
21
34
  readonly mesh: MeshGeometry;
35
+ /** Original I3S vector basis; overrides the codec default when supplied by a source. */
36
+ readonly normalReferenceFrame?: 'earth-centered' | 'vertex-reference-frame';
22
37
  /** Optional single material with an embedded base-color image, mapped explicitly by the source adapter. */
23
38
  readonly material?: MeshTileMaterial;
39
+ /** Explicit feature table and triangle associations, written as structural metadata. */
40
+ readonly features?: MeshTileFeatures;
24
41
  /** Finite xyz origin in the target frame, subtracted before float32 encoding. */
25
42
  readonly origin: readonly [number, number, number];
26
43
  }
@@ -29,6 +46,15 @@ export interface MeshConversionInput {
29
46
  export interface EncodedMeshConversionResource {
30
47
  /** Caller-owned resource identifier. */
31
48
  readonly id: string;
49
+ /** Inspected content placement, retained by the codec for hierarchy authoring. */
50
+ readonly contentResourceId?: string;
51
+ /** Ordered primitive within a content; last marks complete extraction for hierarchy authoring. */
52
+ readonly contentPrimitive?: {
53
+ /** Zero-based primitive placement index in scene traversal order. */
54
+ readonly index: number;
55
+ /** Whether this is the final primitive placement in the content. */
56
+ readonly last: boolean;
57
+ };
32
58
  /** Self-contained GLB with local positions and transformed normals. */
33
59
  readonly glb: ArrayBuffer;
34
60
  /** Target-frame translation to add to the encoded positions. */
@@ -46,7 +72,11 @@ export interface EncodedMeshConversionResource {
46
72
  /** Spatial policy used by the concrete mesh conversion codec. */
47
73
  export interface MeshConversionCodecOptions {
48
74
  /** Shared context that accepts absolute source positions and returns absolute target positions. */
49
- readonly spatialContext: Tiles3DConversionSpatialContext;
75
+ readonly spatialContext: Tiles3DConversionSpatialContext | I3SConversionSpatialContext;
76
+ /** I3S vector frame when using an I3S context; defaults to earth-centered. */
77
+ readonly normalReferenceFrame?: 'earth-centered' | 'vertex-reference-frame';
78
+ /** Rebase around the transformed bounds center instead of the resource's explicit origin. */
79
+ readonly autoOrigin?: boolean;
50
80
  /** Maximum Euclidean reconstruction error per vertex, in target coordinate units. */
51
81
  readonly maxPositionError: number;
52
82
  /** Lossless Draco Edge Breaker is enabled by default; false emits uncompressed GLB geometry. */
@@ -105,12 +135,6 @@ export function createMeshConversionCodec<TInspection = unknown>(
105
135
  /** Prepares and encodes one geometry while honoring cancellation. */
106
136
  async *convert(resource, _inspection, signal) {
107
137
  signal?.throwIfAborted();
108
- if ('features' in resource && resource.features) {
109
- throw new TileConversionError(
110
- 'MESH_FEATURE_OUTPUT_UNSUPPORTED',
111
- 'The GLB codec requires an explicit feature metadata writer; use the I3S codec for supported feature-bearing meshes'
112
- );
113
- }
114
138
  const geometry = validateMeshGeometry(resource.mesh, true);
115
139
  const sourcePositions = geometry.attributes.POSITION.value;
116
140
  const origin: [number, number, number] = [...resource.origin];
@@ -120,7 +144,21 @@ export function createMeshConversionCodec<TInspection = unknown>(
120
144
  'Target origin must contain three finite coordinates'
121
145
  );
122
146
  }
123
- const targetPositions = spatialContext.transformPositions(sourcePositions);
147
+ const transformed =
148
+ 'transformGeometryAsync' in spatialContext
149
+ ? await spatialContext.transformGeometryAsync(
150
+ sourcePositions,
151
+ geometry.attributes.NORMAL?.value,
152
+ resource.normalReferenceFrame ?? options.normalReferenceFrame
153
+ )
154
+ : {
155
+ positions: spatialContext.transformPositions(sourcePositions),
156
+ normals: geometry.attributes.NORMAL
157
+ ? spatialContext.transformNormals(geometry.attributes.NORMAL.value, sourcePositions)
158
+ : undefined
159
+ };
160
+ signal?.throwIfAborted();
161
+ const targetPositions = transformed.positions;
124
162
  if (
125
163
  targetPositions.length !== sourcePositions.length ||
126
164
  targetPositions.some(value => !Number.isFinite(value))
@@ -130,6 +168,16 @@ export function createMeshConversionCodec<TInspection = unknown>(
130
168
  'Spatial transformation must return one finite xyz position per vertex'
131
169
  );
132
170
  }
171
+ if (options.autoOrigin) {
172
+ const minimum = [Infinity, Infinity, Infinity];
173
+ const maximum = [-Infinity, -Infinity, -Infinity];
174
+ for (let index = 0; index < targetPositions.length; index++) {
175
+ const axis = index % 3;
176
+ minimum[axis] = Math.min(minimum[axis], targetPositions[index]);
177
+ maximum[axis] = Math.max(maximum[axis], targetPositions[index]);
178
+ }
179
+ for (let axis = 0; axis < 3; axis++) origin[axis] = minimum[axis] / 2 + maximum[axis] / 2;
180
+ }
133
181
  const localPositions = new Float32Array(targetPositions.length);
134
182
  const minimum = [Infinity, Infinity, Infinity];
135
183
  const maximum = [-Infinity, -Infinity, -Infinity];
@@ -156,11 +204,11 @@ export function createMeshConversionCodec<TInspection = unknown>(
156
204
  geometry.attributes.POSITION = {value: localPositions, size: 3};
157
205
  if (geometry.attributes.NORMAL) {
158
206
  geometry.attributes.NORMAL = {
159
- value: spatialContext.transformNormals(geometry.attributes.NORMAL.value, sourcePositions),
207
+ value: transformed.normals!,
160
208
  size: 3
161
209
  };
162
210
  }
163
- const appearance = {material: resource.material};
211
+ const appearance = {material: resource.material, features: resource.features};
164
212
  const encoded = draco
165
213
  ? await encodeDracoMeshTile(geometry, appearance, dracoLibraryOptions)
166
214
  : {glb: encodeMeshTile(geometry, appearance), positions: localPositions};
@@ -175,6 +223,8 @@ export function createMeshConversionCodec<TInspection = unknown>(
175
223
  }
176
224
  yield {
177
225
  id: resource.id,
226
+ contentResourceId: resource.contentResourceId,
227
+ contentPrimitive: resource.contentPrimitive,
178
228
  glb: encoded.glb,
179
229
  origin,
180
230
  boundingBox: [minimum, maximum],
@@ -14,6 +14,8 @@ import {
14
14
  validateMeshGeometry
15
15
  } from './mesh.js';
16
16
 
17
+ import {prepareMeshFeatureGeometry, encodeMeshFeatures} from './mesh-features.js';
18
+
17
19
  const DRACO_EXTENSION = 'KHR_draco_mesh_compression';
18
20
 
19
21
  /** Encodes the validated single-mesh profile with lossless Edge Breaker and no raw fallback. */
@@ -22,7 +24,7 @@ export async function encodeDracoMeshTile(
22
24
  options: MeshTileOptions,
23
25
  libraryOptions: LoadLibraryOptions = {}
24
26
  ): Promise<{glb: ArrayBuffer; positions: Float32Array}> {
25
- geometry = validateMeshGeometry(geometry);
27
+ geometry = prepareMeshFeatureGeometry(validateMeshGeometry(geometry), options.features);
26
28
  // Draco requires explicit faces even when the input triangle list is non-indexed.
27
29
  if (!geometry.indices) {
28
30
  geometry.indices = {
@@ -72,6 +74,12 @@ export async function encodeDracoMeshTile(
72
74
  );
73
75
  }
74
76
  }
77
+ if (JSON.stringify(getMeshTriangles(geometry)) !== JSON.stringify(getMeshTriangles(decoded))) {
78
+ throw new TileConversionError(
79
+ 'MESH_DRACO_ATTRIBUTE_CHANGED',
80
+ 'Lossless Draco must preserve oriented triangles and every attribute, including feature ownership'
81
+ );
82
+ }
75
83
  const bufferView = scenegraph.addBufferView(encoded.data);
76
84
  const attributes: Record<string, number> = {};
77
85
  const attributeIdentifiers: Record<string, number> = {};
@@ -90,9 +98,18 @@ export async function encodeDracoMeshTile(
90
98
  ? 5121
91
99
  : attribute.value instanceof Uint16Array
92
100
  ? 5123
93
- : 5126,
101
+ : attribute.value instanceof Uint32Array
102
+ ? 5125
103
+ : 5126,
94
104
  count: encoded.report.pointCount,
95
- type: attribute.size === 2 ? 'VEC2' : attribute.size === 3 ? 'VEC3' : 'VEC4',
105
+ type:
106
+ attribute.size === 1
107
+ ? 'SCALAR'
108
+ : attribute.size === 2
109
+ ? 'VEC2'
110
+ : attribute.size === 3
111
+ ? 'VEC3'
112
+ : 'VEC4',
96
113
  ...(attribute.normalized ? {normalized: true} : {})
97
114
  };
98
115
  if (name === 'POSITION') {
@@ -128,6 +145,7 @@ export async function encodeDracoMeshTile(
128
145
  ]
129
146
  }
130
147
  ];
148
+ encodeMeshFeatures(scenegraph, 0, options.features);
131
149
  scenegraph.registerRequiredExtension(DRACO_EXTENSION);
132
150
  return {glb: finalizeMeshTileScenegraph(scenegraph, 0), positions};
133
151
  }
@@ -136,3 +154,29 @@ export async function encodeDracoMeshTile(
136
154
  function getPositionKey(positions: ArrayLike<number>, index: number): string {
137
155
  return `${positions[index]},${positions[index + 1]},${positions[index + 2]}`;
138
156
  }
157
+
158
+ /** Compares oriented triangles independently of vertex/face order, retaining every attribute. */
159
+ function getMeshTriangles(mesh: Pick<MeshGeometry, 'attributes' | 'indices'>): string[] {
160
+ const names = Object.keys(mesh.attributes).sort();
161
+ const indices = mesh.indices!.value;
162
+ const triangles: string[] = [];
163
+ for (let index = 0; index < indices.length; index += 3) {
164
+ const vertices = [0, 1, 2].map(corner =>
165
+ names
166
+ .map(name => {
167
+ const attribute = mesh.attributes[name];
168
+ return Array.from(
169
+ {length: attribute.size},
170
+ (_, component) => attribute.value[indices[index + corner] * attribute.size + component]
171
+ ).join(',');
172
+ })
173
+ .join('|')
174
+ );
175
+ triangles.push(
176
+ [0, 1, 2]
177
+ .map(corner => [...vertices.slice(corner), ...vertices.slice(0, corner)].join(';'))
178
+ .sort()[0]
179
+ );
180
+ }
181
+ return triangles.sort();
182
+ }
@@ -0,0 +1,319 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {convertArrowToSchema} from '@loaders.gl/schema-utils';
6
+ import type {MeshGeometry, Field} from '@loaders.gl/schema';
7
+ import type {GLTFScenegraph} from '@loaders.gl/gltf';
8
+ import type {I3SMeshFeatures} from '@loaders.gl/i3s';
9
+ import {TileConversionError} from '@loaders.gl/tile-converter/v5/core';
10
+
11
+ /** Explicit-schema Arrow features; stable identifiers are distinct from geometry row indices. */
12
+ export type MeshTileFeatures = Pick<
13
+ I3SMeshFeatures,
14
+ 'batches' | 'triangleFeatureIndices' | 'featureIdField'
15
+ >;
16
+
17
+ /** Expands shared vertices so each complete triangle retains its own feature row through Draco. */
18
+ export function prepareMeshFeatureGeometry(
19
+ mesh: MeshGeometry,
20
+ features?: MeshTileFeatures
21
+ ): MeshGeometry {
22
+ if (!features) return mesh;
23
+ const count = features.batches.reduce((total, batch) => total + batch.data.numRows, 0);
24
+ const indices = mesh.indices?.value;
25
+ const vertexCount = indices?.length ?? mesh.attributes.POSITION.value.length / 3;
26
+ const associations = features.triangleFeatureIndices;
27
+ if (
28
+ !(associations instanceof Uint32Array) ||
29
+ associations.length !== vertexCount / 3 ||
30
+ associations.some(row => row >= count) ||
31
+ new Set(associations).size !== count
32
+ ) {
33
+ throw new TileConversionError(
34
+ 'MESH_FEATURE_ASSOCIATION_INVALID',
35
+ 'Every feature row must own a triangle and every triangle must reference a valid row'
36
+ );
37
+ }
38
+ if (count > 2 ** 24)
39
+ throw new TileConversionError(
40
+ 'MESH_FEATURE_COUNT_UNSUPPORTED',
41
+ 'Feature row indices must be exactly representable in a glTF vertex attribute'
42
+ );
43
+ const attributes: MeshGeometry['attributes'] = Object.fromEntries(
44
+ Object.entries(mesh.attributes).map(([name, attribute]) => {
45
+ const values = attribute.value;
46
+ const Constructor = values.constructor as {new (length: number): typeof values};
47
+ const output = new Constructor(vertexCount * attribute.size);
48
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
49
+ const sourceVertex = indices?.[vertex] ?? vertex;
50
+ for (let component = 0; component < attribute.size; component++)
51
+ output[vertex * attribute.size + component] = Number(
52
+ values[sourceVertex * attribute.size + component]
53
+ );
54
+ }
55
+ return [name, {...attribute, value: output}];
56
+ })
57
+ );
58
+ const featureIndices =
59
+ count <= 65536 ? new Uint16Array(vertexCount) : new Float32Array(vertexCount);
60
+ for (let vertex = 0; vertex < vertexCount; vertex++)
61
+ featureIndices[vertex] = associations[Math.floor(vertex / 3)];
62
+ attributes._FEATURE_ID_0 = {value: featureIndices, size: 1};
63
+ return {...mesh, indices: undefined, attributes};
64
+ }
65
+
66
+ /** Writes one inline structural metadata table without number coercion or nullable sentinel collisions. */
67
+ export function encodeMeshFeatures(
68
+ scenegraph: GLTFScenegraph,
69
+ meshIndex: number,
70
+ features?: MeshTileFeatures
71
+ ): void {
72
+ if (!features) return;
73
+ const {batches, featureIdField} = features;
74
+ const schema = batches[0]?.schema;
75
+ const count = batches.reduce((total, batch) => total + batch.data.numRows, 0);
76
+ const metadataClass = schema?.metadata?.['loaders.gl:feature-class'] || 'features';
77
+ if (
78
+ !schema ||
79
+ !count ||
80
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(metadataClass) ||
81
+ !schema.fields.some(field => field.name === featureIdField && field.nullable !== true) ||
82
+ new Set(schema.fields.map(field => field.name)).size !== schema.fields.length
83
+ ) {
84
+ throw new TileConversionError(
85
+ 'MESH_FEATURE_SCHEMA_INVALID',
86
+ 'Features require a single named class, unique fields, and a non-null stable identifier'
87
+ );
88
+ }
89
+ for (const batch of batches) {
90
+ const actual = convertArrowToSchema(batch.data.schema).fields;
91
+ if (
92
+ JSON.stringify(batch.schema) !== JSON.stringify(schema) ||
93
+ batch.length !== batch.data.numRows ||
94
+ JSON.stringify(actual.map(field => [field.name, field.type])) !==
95
+ JSON.stringify(schema.fields.map(field => [field.name, field.type]))
96
+ ) {
97
+ throw new TileConversionError(
98
+ 'MESH_FEATURE_SCHEMA_INVALID',
99
+ 'Declared feature schemas and Arrow column types must agree across batches'
100
+ );
101
+ }
102
+ }
103
+ const properties: Record<string, object> = Object.create(null);
104
+ const columns: Record<string, object> = Object.create(null);
105
+ for (const field of schema.fields) {
106
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(field.name))
107
+ throw new TileConversionError(
108
+ 'MESH_FEATURE_SCHEMA_INVALID',
109
+ 'Property names must be metadata identifiers'
110
+ );
111
+ const values = batches.flatMap(batch => {
112
+ const column = batch.data.getChild(field.name);
113
+ if (!column)
114
+ throw new TileConversionError(
115
+ 'MESH_FEATURE_SCHEMA_INVALID',
116
+ `Missing Arrow column ${field.name}`
117
+ );
118
+ return Array.from({length: column.length}, (_, index) => column.get(index));
119
+ });
120
+ if (
121
+ field.name === featureIdField &&
122
+ (values.some(
123
+ value =>
124
+ !(typeof value === 'string' || typeof value === 'bigint' || Number.isSafeInteger(value))
125
+ ) ||
126
+ new Set(values).size !== count)
127
+ )
128
+ throw new TileConversionError(
129
+ 'MESH_FEATURE_IDS_INVALID',
130
+ 'Stable identifiers must be unique strings or exact integers'
131
+ );
132
+ const encoded = encodeFeatureColumn(field, values);
133
+ properties[field.name] = encoded.property;
134
+ columns[field.name] = {
135
+ values: addMetadataBuffer(scenegraph, encoded.data),
136
+ ...(encoded.offsets
137
+ ? {
138
+ stringOffsets: addMetadataBuffer(scenegraph, encoded.offsets),
139
+ stringOffsetType: 'UINT32'
140
+ }
141
+ : {})
142
+ };
143
+ }
144
+ scenegraph.json.extensions ||= {};
145
+ scenegraph.json.extensions.EXT_structural_metadata = {
146
+ schema: {id: 'loaders_gl_features', classes: {[metadataClass]: {properties}}},
147
+ propertyTables: [{class: metadataClass, count, properties: columns}]
148
+ };
149
+ const primitive = scenegraph.json.meshes![meshIndex].primitives[0];
150
+ primitive.extensions ||= {};
151
+ primitive.extensions.EXT_mesh_features = {
152
+ featureIds: [{featureCount: count, attribute: 0, propertyTable: 0}]
153
+ };
154
+ scenegraph.registerUsedExtension('EXT_structural_metadata');
155
+ scenegraph.registerUsedExtension('EXT_mesh_features');
156
+ }
157
+
158
+ /** Aligns every metadata buffer to eight bytes, including INT64, UINT64 and FLOAT64 columns. */
159
+ function addMetadataBuffer(scenegraph: GLTFScenegraph, data: ArrayBufferView): number {
160
+ if (scenegraph.byteLength % 8) scenegraph.addBufferView(new Uint8Array(4));
161
+ return scenegraph.addBufferView(data);
162
+ }
163
+
164
+ /** Encodes supported scalar values and chooses an unused noData value for nullable columns. */
165
+ function encodeFeatureColumn(
166
+ field: Field,
167
+ values: unknown[]
168
+ ): {property: object; data: Uint8Array; offsets?: Uint32Array} {
169
+ const type = field.type;
170
+ const hasNull = values.includes(null);
171
+ if (hasNull && field.nullable !== true)
172
+ throw new TileConversionError(
173
+ 'MESH_FEATURE_VALUE_INVALID',
174
+ `Required field ${field.name} contains null`
175
+ );
176
+ const present = values.filter(value => value !== null);
177
+ let noData: string | number | undefined;
178
+ if (type === 'utf8') {
179
+ if (
180
+ present.some(
181
+ value =>
182
+ typeof value !== 'string' ||
183
+ new TextDecoder().decode(new TextEncoder().encode(value)) !== value
184
+ )
185
+ )
186
+ throw new TileConversionError('MESH_FEATURE_VALUE_INVALID', 'Strings require valid Unicode');
187
+ if (hasNull) {
188
+ noData = '';
189
+ const strings = new Set(present);
190
+ while (strings.has(noData)) noData += '_';
191
+ }
192
+ const encoded = values.map(value =>
193
+ new TextEncoder().encode(value === null ? (noData as string) : (value as string))
194
+ );
195
+ const offsets = new Uint32Array(values.length + 1);
196
+ for (let index = 0; index < values.length; index++)
197
+ offsets[index + 1] = offsets[index] + encoded[index].length;
198
+ const data = new Uint8Array(offsets[values.length]);
199
+ encoded.forEach((bytes, index) => data.set(bytes, offsets[index]));
200
+ return {
201
+ property: {type: 'STRING', required: field.nullable !== true, ...(hasNull ? {noData} : {})},
202
+ data,
203
+ offsets
204
+ };
205
+ }
206
+ const widths: Record<string, number> = {
207
+ int8: 1,
208
+ uint8: 1,
209
+ int16: 2,
210
+ uint16: 2,
211
+ int32: 4,
212
+ uint32: 4,
213
+ int64: 8,
214
+ uint64: 8,
215
+ float32: 4,
216
+ float64: 8
217
+ };
218
+ const width = widths[String(type)];
219
+ if (!width)
220
+ throw new TileConversionError(
221
+ 'MESH_FEATURE_TYPE_UNSUPPORTED',
222
+ `No qualified GLB property mapping for ${String(type)}`
223
+ );
224
+ const isInteger64 = type === 'int64' || type === 'uint64';
225
+ const isFloat = type === 'float32' || type === 'float64';
226
+ const minimum = isFloat
227
+ ? -Infinity
228
+ : String(type).startsWith('uint')
229
+ ? 0n
230
+ : -(1n << BigInt(width * 8 - 1));
231
+ const maximum = isFloat
232
+ ? Infinity
233
+ : (1n << BigInt(width * 8 - (String(type).startsWith('uint') ? 0 : 1))) - 1n;
234
+ if (
235
+ present.some(value =>
236
+ isInteger64
237
+ ? typeof value !== 'bigint' || value < minimum || value > maximum
238
+ : typeof value !== 'number' ||
239
+ !Number.isFinite(value) ||
240
+ (!isFloat &&
241
+ (!Number.isSafeInteger(value) || BigInt(value) < minimum || BigInt(value) > maximum))
242
+ )
243
+ )
244
+ throw new TileConversionError(
245
+ 'MESH_FEATURE_VALUE_INVALID',
246
+ `Values do not fit ${String(type)}`
247
+ );
248
+ if (hasNull) {
249
+ const used = new Set(present.map(String));
250
+ noData = 0;
251
+ while (used.has(String(noData))) noData++;
252
+ if (noData > maximum && minimum < 0) {
253
+ noData = -1;
254
+ while (used.has(String(noData))) noData--;
255
+ }
256
+ if (noData > maximum || noData < minimum)
257
+ throw new TileConversionError(
258
+ 'MESH_FEATURE_NULL_UNREPRESENTABLE',
259
+ 'No unused value remains for null without losing a real value'
260
+ );
261
+ }
262
+ const data = new Uint8Array(values.length * width);
263
+ const view = new DataView(data.buffer);
264
+ values.forEach((value, index) => {
265
+ const selected = value === null ? (isInteger64 ? BigInt(noData!) : noData) : value;
266
+ const offset = index * width;
267
+ switch (type) {
268
+ case 'int8':
269
+ view.setInt8(offset, selected as number);
270
+ break;
271
+ case 'uint8':
272
+ view.setUint8(offset, selected as number);
273
+ break;
274
+ case 'int16':
275
+ view.setInt16(offset, selected as number, true);
276
+ break;
277
+ case 'uint16':
278
+ view.setUint16(offset, selected as number, true);
279
+ break;
280
+ case 'int32':
281
+ view.setInt32(offset, selected as number, true);
282
+ break;
283
+ case 'uint32':
284
+ view.setUint32(offset, selected as number, true);
285
+ break;
286
+ case 'int64':
287
+ view.setBigInt64(offset, selected as bigint, true);
288
+ break;
289
+ case 'uint64':
290
+ view.setBigUint64(offset, selected as bigint, true);
291
+ break;
292
+ case 'float32':
293
+ view.setFloat32(offset, selected as number, true);
294
+ break;
295
+ case 'float64':
296
+ view.setFloat64(offset, selected as number, true);
297
+ break;
298
+ }
299
+ if (
300
+ isFloat &&
301
+ !Number.isFinite(
302
+ type === 'float32' ? view.getFloat32(offset, true) : view.getFloat64(offset, true)
303
+ )
304
+ )
305
+ throw new TileConversionError(
306
+ 'MESH_FEATURE_VALUE_INVALID',
307
+ 'Floating point property overflow'
308
+ );
309
+ });
310
+ return {
311
+ property: {
312
+ type: 'SCALAR',
313
+ componentType: String(type).toUpperCase(),
314
+ required: field.nullable !== true,
315
+ ...(hasNull ? {noData} : {})
316
+ },
317
+ data
318
+ };
319
+ }