@loaders.gl/tiles 5.0.0-alpha.5 → 5.0.0-alpha.7

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 (106) hide show
  1. package/dist/dist.dev.js +5122 -2641
  2. package/dist/dist.min.js +4 -2
  3. package/dist/index.cjs +1670 -403
  4. package/dist/index.cjs.map +3 -3
  5. package/dist/index.d.ts +8 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/spatial/format-spatial-reference.d.ts +1 -0
  10. package/dist/spatial/format-spatial-reference.d.ts.map +1 -1
  11. package/dist/spatial/format-spatial-reference.js +104 -46
  12. package/dist/spatial/format-spatial-reference.js.map +1 -1
  13. package/dist/spatial/get-spatial-coordinate-frame.d.ts +12 -1
  14. package/dist/spatial/get-spatial-coordinate-frame.d.ts.map +1 -1
  15. package/dist/spatial/get-spatial-coordinate-frame.js +105 -24
  16. package/dist/spatial/get-spatial-coordinate-frame.js.map +1 -1
  17. package/dist/spatial/normalize-crs-identifier.d.ts +15 -0
  18. package/dist/spatial/normalize-crs-identifier.d.ts.map +1 -0
  19. package/dist/spatial/normalize-crs-identifier.js +22 -0
  20. package/dist/spatial/normalize-crs-identifier.js.map +1 -0
  21. package/dist/spatial/spatial-coordinate-transformer.d.ts +4 -0
  22. package/dist/spatial/spatial-coordinate-transformer.d.ts.map +1 -1
  23. package/dist/spatial/spatial-coordinate-transformer.js +89 -12
  24. package/dist/spatial/spatial-coordinate-transformer.js.map +1 -1
  25. package/dist/spatial/tiles-3d-spatial-transformer.d.ts +32 -0
  26. package/dist/spatial/tiles-3d-spatial-transformer.d.ts.map +1 -0
  27. package/dist/spatial/tiles-3d-spatial-transformer.js +175 -0
  28. package/dist/spatial/tiles-3d-spatial-transformer.js.map +1 -0
  29. package/dist/tileset-3d/common/indexed-archive-tileset-source.d.ts +3 -3
  30. package/dist/tileset-3d/common/indexed-archive-tileset-source.d.ts.map +1 -1
  31. package/dist/tileset-3d/common/indexed-archive-tileset-source.js.map +1 -1
  32. package/dist/tileset-3d/common/tile-3d-contracts.d.ts +63 -0
  33. package/dist/tileset-3d/common/tile-3d-contracts.d.ts.map +1 -0
  34. package/dist/tileset-3d/common/tile-3d-contracts.js +51 -0
  35. package/dist/tileset-3d/common/tile-3d-contracts.js.map +1 -0
  36. package/dist/tileset-3d/common/tile-3d.d.ts +29 -5
  37. package/dist/tileset-3d/common/tile-3d.d.ts.map +1 -1
  38. package/dist/tileset-3d/common/tile-3d.js +151 -20
  39. package/dist/tileset-3d/common/tile-3d.js.map +1 -1
  40. package/dist/tileset-3d/common/tileset-3d.d.ts +44 -2
  41. package/dist/tileset-3d/common/tileset-3d.d.ts.map +1 -1
  42. package/dist/tileset-3d/common/tileset-3d.js +31 -1
  43. package/dist/tileset-3d/common/tileset-3d.js.map +1 -1
  44. package/dist/tileset-3d/common/tileset-observability.d.ts +60 -0
  45. package/dist/tileset-3d/common/tileset-observability.d.ts.map +1 -0
  46. package/dist/tileset-3d/common/tileset-observability.js +39 -0
  47. package/dist/tileset-3d/common/tileset-observability.js.map +1 -0
  48. package/dist/tileset-3d/common/tileset-source.d.ts +11 -8
  49. package/dist/tileset-3d/common/tileset-source.d.ts.map +1 -1
  50. package/dist/tileset-3d/common/tileset-source.js.map +1 -1
  51. package/dist/tileset-3d/common/tileset-traverser.d.ts +14 -0
  52. package/dist/tileset-3d/common/tileset-traverser.d.ts.map +1 -1
  53. package/dist/tileset-3d/common/tileset-traverser.js +21 -5
  54. package/dist/tileset-3d/common/tileset-traverser.js.map +1 -1
  55. package/dist/tileset-3d/format-3d-tiles/implicit-tiling.d.ts +47 -1
  56. package/dist/tileset-3d/format-3d-tiles/implicit-tiling.d.ts.map +1 -1
  57. package/dist/tileset-3d/format-3d-tiles/implicit-tiling.js +327 -30
  58. package/dist/tileset-3d/format-3d-tiles/implicit-tiling.js.map +1 -1
  59. package/dist/tileset-3d/format-3d-tiles/tiles-3d-source.d.ts +34 -2
  60. package/dist/tileset-3d/format-3d-tiles/tiles-3d-source.d.ts.map +1 -1
  61. package/dist/tileset-3d/format-3d-tiles/tiles-3d-source.js +265 -14
  62. package/dist/tileset-3d/format-3d-tiles/tiles-3d-source.js.map +1 -1
  63. package/dist/tileset-3d/format-3d-tiles/tileset-3d-traverser.d.ts +70 -3
  64. package/dist/tileset-3d/format-3d-tiles/tileset-3d-traverser.d.ts.map +1 -1
  65. package/dist/tileset-3d/format-3d-tiles/tileset-3d-traverser.js +216 -4
  66. package/dist/tileset-3d/format-3d-tiles/tileset-3d-traverser.js.map +1 -1
  67. package/dist/tileset-3d/format-i3s/i3s-source.d.ts +5 -3
  68. package/dist/tileset-3d/format-i3s/i3s-source.d.ts.map +1 -1
  69. package/dist/tileset-3d/format-i3s/i3s-source.js +14 -7
  70. package/dist/tileset-3d/format-i3s/i3s-source.js.map +1 -1
  71. package/dist/tileset-3d/helpers/bounding-volume.d.ts +7 -3
  72. package/dist/tileset-3d/helpers/bounding-volume.d.ts.map +1 -1
  73. package/dist/tileset-3d/helpers/bounding-volume.js +19 -61
  74. package/dist/tileset-3d/helpers/bounding-volume.js.map +1 -1
  75. package/dist/tileset-3d/helpers/frame-state.d.ts +4 -0
  76. package/dist/tileset-3d/helpers/frame-state.d.ts.map +1 -1
  77. package/dist/tileset-3d/helpers/frame-state.js +6 -1
  78. package/dist/tileset-3d/helpers/frame-state.js.map +1 -1
  79. package/dist/tileset-3d/helpers/s2-bounding-volume.d.ts +17 -0
  80. package/dist/tileset-3d/helpers/s2-bounding-volume.d.ts.map +1 -0
  81. package/dist/tileset-3d/helpers/s2-bounding-volume.js +20 -0
  82. package/dist/tileset-3d/helpers/s2-bounding-volume.js.map +1 -0
  83. package/dist/types.d.ts +2 -0
  84. package/dist/types.d.ts.map +1 -1
  85. package/package.json +14 -13
  86. package/src/index.ts +13 -1
  87. package/src/spatial/format-spatial-reference.ts +127 -53
  88. package/src/spatial/get-spatial-coordinate-frame.ts +126 -31
  89. package/src/spatial/normalize-crs-identifier.ts +26 -0
  90. package/src/spatial/spatial-coordinate-transformer.ts +106 -15
  91. package/src/spatial/tiles-3d-spatial-transformer.ts +206 -0
  92. package/src/tileset-3d/common/indexed-archive-tileset-source.ts +4 -9
  93. package/src/tileset-3d/common/tile-3d-contracts.ts +115 -0
  94. package/src/tileset-3d/common/tile-3d.ts +178 -20
  95. package/src/tileset-3d/common/tileset-3d.ts +63 -3
  96. package/src/tileset-3d/common/tileset-observability.ts +100 -0
  97. package/src/tileset-3d/common/tileset-source.ts +11 -12
  98. package/src/tileset-3d/common/tileset-traverser.ts +32 -5
  99. package/src/tileset-3d/format-3d-tiles/implicit-tiling.ts +481 -34
  100. package/src/tileset-3d/format-3d-tiles/tiles-3d-source.ts +390 -17
  101. package/src/tileset-3d/format-3d-tiles/tileset-3d-traverser.ts +243 -4
  102. package/src/tileset-3d/format-i3s/i3s-source.ts +25 -9
  103. package/src/tileset-3d/helpers/bounding-volume.ts +29 -62
  104. package/src/tileset-3d/helpers/frame-state.ts +6 -1
  105. package/src/tileset-3d/helpers/s2-bounding-volume.ts +35 -0
  106. package/src/types.ts +2 -0
@@ -4,6 +4,10 @@
4
4
 
5
5
  import type {ReadonlyCRSDefinition} from '@math.gl/crs';
6
6
  import {getI3SLinearUnitScale, getI3SVerticalUnitScale} from './i3s-elevation';
7
+ import {
8
+ getKnownCrsIdentifierCoordinateFrame,
9
+ getSpatialCoordinateFrame
10
+ } from './get-spatial-coordinate-frame';
7
11
  import {createTilesetSpatialReference} from './spatial-types';
8
12
  import type {TilesetElevationMode, TilesetSpatialReference} from './spatial-types';
9
13
 
@@ -40,6 +44,7 @@ type Tiles3DLike = {
40
44
  schemaUri?: string;
41
45
  metadata?: {class?: string; properties?: Record<string, unknown>};
42
46
  root?: {boundingVolume?: {region?: unknown}};
47
+ extensions?: Record<string, unknown>;
43
48
  };
44
49
 
45
50
  /**
@@ -68,8 +73,10 @@ export function getI3SSpatialReference(layer: I3SLayerLike): TilesetSpatialRefer
68
73
  const sourceIdentifier = getCrsIdentifier(spatialReference);
69
74
  const coordinateFrame =
70
75
  sourceIdentifier === undefined
71
- ? getWktCoordinateFrame(spatialReference?.wkt)
72
- : getIdentifierCoordinateFrame(sourceIdentifier);
76
+ ? sourceCrs
77
+ ? getSpatialCoordinateFrame(sourceCrs)
78
+ : 'unknown'
79
+ : getSpatialCoordinateFrame(`EPSG:${sourceIdentifier}`);
73
80
  const units = getI3SCoordinateUnits(coordinateFrame, verticalUnit, layer.ZFactor);
74
81
  const warnings: string[] = [];
75
82
  const elevationMode = getI3SElevationMode(layer.elevationInfo?.mode);
@@ -159,6 +166,10 @@ function getI3SElevationMode(mode: string | undefined): TilesetElevationMode | u
159
166
  * @returns Normalized source spatial metadata.
160
167
  */
161
168
  export function get3DTilesSpatialReference(tileset: Tiles3DLike): TilesetSpatialReference {
169
+ const draftSpatialReference = getDraft3DTilesSpatialReference(tileset);
170
+ if (draftSpatialReference) {
171
+ return draftSpatialReference;
172
+ }
162
173
  const geocentricCrs = getTilesetSemanticValue(tileset, 'TILESET_CRS_GEOCENTRIC');
163
174
  const coordinateEpoch = getTilesetSemanticValue(tileset, 'TILESET_CRS_COORDINATE_EPOCH');
164
175
  const warnings: string[] = [];
@@ -202,6 +213,120 @@ export function get3DTilesSpatialReference(tileset: Tiles3DLike): TilesetSpatial
202
213
  });
203
214
  }
204
215
 
216
+ /** Resolves the draft glTF EXT_geospatial_crs WKID or WKT2 declaration. */
217
+ function getDraft3DTilesSpatialReference(
218
+ tileset: Tiles3DLike
219
+ ): TilesetSpatialReference | undefined {
220
+ const extension = tileset.extensions?.EXT_geospatial_crs as
221
+ | {format?: unknown; extensions?: Record<string, unknown>}
222
+ | undefined;
223
+ if (!extension) {
224
+ return undefined;
225
+ }
226
+ const warnings: string[] = [];
227
+ let sourceCrs: ReadonlyCRSDefinition | undefined;
228
+ let verticalCrs: ReadonlyCRSDefinition | undefined;
229
+ let epoch: number | undefined;
230
+ let representation: 'identifier' | 'wkt' | undefined;
231
+ if (extension.format === 'wkid') {
232
+ const wkidExtension = extension.extensions?.EXT_geospatial_crs_wkid as
233
+ | {authority?: unknown; wkid?: unknown; vcsWkid?: unknown; epoch?: unknown}
234
+ | undefined;
235
+ if (typeof wkidExtension?.authority === 'string' && Number.isInteger(wkidExtension.wkid)) {
236
+ sourceCrs = `${wkidExtension.authority}:${wkidExtension.wkid}`;
237
+ representation = 'identifier';
238
+ if (Number.isInteger(wkidExtension.vcsWkid)) {
239
+ verticalCrs = `${wkidExtension.authority}:${wkidExtension.vcsWkid}`;
240
+ }
241
+ epoch = parseCoordinateEpoch(wkidExtension.epoch);
242
+ if (wkidExtension.epoch !== undefined && epoch === undefined) {
243
+ warnings.push('EXT_geospatial_crs_wkid epoch is not a finite decimal year');
244
+ }
245
+ } else {
246
+ warnings.push('EXT_geospatial_crs_wkid requires string authority and integer wkid');
247
+ }
248
+ } else if (extension.format === 'wkt2') {
249
+ const wktExtension = extension.extensions?.EXT_geospatial_crs_wkt2 as
250
+ | {wkt2?: unknown}
251
+ | undefined;
252
+ if (typeof wktExtension?.wkt2 === 'string' && wktExtension.wkt2.trim()) {
253
+ sourceCrs = wktExtension.wkt2;
254
+ representation = 'wkt';
255
+ } else {
256
+ warnings.push('EXT_geospatial_crs_wkt2 requires a nonempty wkt2 string');
257
+ }
258
+ } else {
259
+ warnings.push(`Unsupported EXT_geospatial_crs format ${String(extension.format)}`);
260
+ }
261
+ const coordinateFrame =
262
+ representation === 'wkt'
263
+ ? getSpatialCoordinateFrame(sourceCrs as string)
264
+ : getKnownCrsIdentifierCoordinateFrame(sourceCrs) || 'unknown';
265
+ const verticalSemantics = getDraftVerticalCrsSemantics(verticalCrs);
266
+ if (verticalCrs && !verticalSemantics) {
267
+ warnings.push(
268
+ `Unsupported vertical CRS ${String(verticalCrs)}; vertical unit and height reference are unknown`
269
+ );
270
+ }
271
+ const verticalUnit = verticalCrs ? verticalSemantics?.unit : 'meter';
272
+ return createTilesetSpatialReference({
273
+ sourceCrs,
274
+ sourceCrsState: sourceCrs ? 'explicit' : 'unknown',
275
+ sourceCrsRepresentation: representation,
276
+ verticalCrs,
277
+ coordinateEpoch: epoch,
278
+ units:
279
+ coordinateFrame === 'geocentric'
280
+ ? ['meter', 'meter', 'meter']
281
+ : coordinateFrame === 'geographic' && verticalUnit
282
+ ? ['degree', 'degree', verticalUnit]
283
+ : undefined,
284
+ verticalUnitScale: verticalCrs ? (verticalSemantics?.verticalUnitScale ?? Number.NaN) : 1,
285
+ heightReference: verticalCrs
286
+ ? (verticalSemantics?.heightReference ?? 'unknown')
287
+ : sourceCrs
288
+ ? 'ellipsoidal'
289
+ : 'unknown',
290
+ coordinateFrame,
291
+ axisOrder: sourceCrs ? 'xyz' : 'unknown',
292
+ provenance: 'metadata',
293
+ warnings
294
+ });
295
+ }
296
+
297
+ /** Known vertical CRS semantics needed to interpret draft 3D Tiles height values. */
298
+ type DraftVerticalCrsSemantics = {
299
+ unit: string;
300
+ verticalUnitScale: number;
301
+ heightReference: 'orthometric';
302
+ };
303
+
304
+ /** Resolves vertical units and height interpretation for known EPSG vertical CRSs. */
305
+ function getDraftVerticalCrsSemantics(
306
+ verticalCrs: ReadonlyCRSDefinition | undefined
307
+ ): DraftVerticalCrsSemantics | undefined {
308
+ if (typeof verticalCrs !== 'string') {
309
+ return undefined;
310
+ }
311
+ const epsgMatch = /^EPSG:(\d+)$/i.exec(verticalCrs);
312
+ if (!epsgMatch) {
313
+ return undefined;
314
+ }
315
+ switch (Number(epsgMatch[1])) {
316
+ case 5702: // NGVD29 height (ftUS)
317
+ case 6360: // NAVD88 height (ftUS)
318
+ return {
319
+ unit: 'us-foot',
320
+ verticalUnitScale: 1200 / 3937,
321
+ heightReference: 'orthometric'
322
+ };
323
+ case 5703: // NAVD88 height
324
+ return {unit: 'meter', verticalUnitScale: 1, heightReference: 'orthometric'};
325
+ default:
326
+ return undefined;
327
+ }
328
+ }
329
+
205
330
  /** Parse the 3D Tiles decimal-year string while tolerating legacy numeric producer output. */
206
331
  function parseCoordinateEpoch(value: unknown): number | undefined {
207
332
  if (typeof value !== 'string' && typeof value !== 'number') {
@@ -238,57 +363,6 @@ function getCrsIdentifier(spatialReference?: I3SSpatialReferenceLike): number |
238
363
  return spatialReference?.latestWkid ?? spatialReference?.wkid;
239
364
  }
240
365
 
241
- /** Classify common EPSG identifiers used by I3S into their coordinate frames. */
242
- function getIdentifierCoordinateFrame(
243
- identifier: number
244
- ): TilesetSpatialReference['coordinateFrame'] {
245
- if (identifier === 4978) {
246
- return 'geocentric';
247
- }
248
- if (identifier === 4326 || identifier === 4490 || identifier === 4979) {
249
- return 'geographic';
250
- }
251
- return 'projected';
252
- }
253
-
254
- /**
255
- * Classify a WKT root coordinate system without guessing when the declaration is ambiguous.
256
- *
257
- * WKT2 uses `GEODCRS` for both geographic and geocentric systems, so its `CS` declaration is
258
- * inspected before assigning a frame.
259
- */
260
- function getWktCoordinateFrame(wkt?: string): TilesetSpatialReference['coordinateFrame'] {
261
- if (!wkt) {
262
- return 'unknown';
263
- }
264
-
265
- const normalizedWkt = wkt.trim().toUpperCase();
266
- const rootKeyword = normalizedWkt.match(/^([A-Z][A-Z0-9_]*)\s*[\[(]/)?.[1];
267
- if (rootKeyword === 'GEOCCS') {
268
- return 'geocentric';
269
- }
270
- if (
271
- rootKeyword === 'GEOGCS' ||
272
- rootKeyword === 'GEOGRAPHICCRS' ||
273
- rootKeyword === 'GEOGRAPHIC2DCRS' ||
274
- rootKeyword === 'GEOGRAPHIC3DCRS'
275
- ) {
276
- return 'geographic';
277
- }
278
- if (rootKeyword === 'PROJCS' || rootKeyword === 'PROJCRS' || rootKeyword === 'PROJECTEDCRS') {
279
- return 'projected';
280
- }
281
- if (rootKeyword === 'GEODCRS' || rootKeyword === 'GEODETICCRS') {
282
- if (/\bCS\s*[\[(]\s*CARTESIAN\s*,\s*3\b/.test(normalizedWkt)) {
283
- return 'geocentric';
284
- }
285
- if (/\bCS\s*[\[(]\s*ELLIPSOIDAL\s*,\s*[23]\b/.test(normalizedWkt)) {
286
- return 'geographic';
287
- }
288
- }
289
- return 'unknown';
290
- }
291
-
292
366
  /** Resolve a tileset-wide structured metadata property by its standard semantic. */
293
367
  function getTilesetSemanticValue(tileset: Tiles3DLike, semantic: string): unknown {
294
368
  const classIdentifier = tileset.metadata?.class;
@@ -2,19 +2,19 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  // Copyright (c) vis.gl contributors
4
4
 
5
- import type {ReadonlyCRSDefinition, SpatialReferenceCoordinateFrame} from '@math.gl/crs';
6
-
7
- const GEOGRAPHIC_CRS = 'EPSG:4326';
8
- const GEOCENTRIC_CRS = 'EPSG:4978';
9
-
10
- /** Normalize common OGC URL and URN CRS spellings to authority identifiers. */
11
- function normalizeCrsIdentifier(identifier: string): string {
12
- const normalizedIdentifier = identifier.trim().toUpperCase();
13
- const ogcMatch = normalizedIdentifier.match(
14
- /(?:\/DEF\/CRS\/|URN:OGC:DEF:CRS:)([A-Z0-9_-]+)(?:\/|::)(?:[^/:]*[/:])?([A-Z0-9_.-]+)$/
15
- );
16
- return ogcMatch ? `${ogcMatch[1]}:${ogcMatch[2]}` : normalizedIdentifier;
17
- }
5
+ import {
6
+ inferCRSRepresentation,
7
+ parsePROJString,
8
+ parseWKTCRS,
9
+ type ReadonlyCRSDefinition,
10
+ type SpatialReferenceCoordinateFrame,
11
+ type WKTCRSNode
12
+ } from '@math.gl/crs';
13
+ import {
14
+ normalizeCrsIdentifier,
15
+ WGS84_GEOCENTRIC_CRS,
16
+ WGS84_GEOGRAPHIC_CRS
17
+ } from './normalize-crs-identifier';
18
18
 
19
19
  /**
20
20
  * Classify a CRS definition into the broad coordinate frame used by 3D format adapters.
@@ -26,25 +26,14 @@ export function getSpatialCoordinateFrame(
26
26
  definition: ReadonlyCRSDefinition
27
27
  ): SpatialReferenceCoordinateFrame {
28
28
  if (typeof definition === 'string') {
29
- const normalized = normalizeCrsIdentifier(definition);
30
- if (normalized === GEOCENTRIC_CRS || /^GEOCCS\s*[\[(]/.test(normalized)) {
31
- return 'geocentric';
29
+ const representation = inferCRSRepresentation(definition);
30
+ if (representation === 'wkt') {
31
+ return getWktCoordinateFrame(definition);
32
32
  }
33
- if (
34
- normalized === GEOGRAPHIC_CRS ||
35
- normalized === 'EPSG:4490' ||
36
- normalized === 'EPSG:4979' ||
37
- normalized === 'OGC:CRS84' ||
38
- normalized.includes('+PROJ=LONGLAT') ||
39
- normalized.includes('+PROJ=LATLONG') ||
40
- /^(?:GEOGCS|GEOGCRS|GEOGRAPHICCRS|GEOGRAPHIC2DCRS|GEOGRAPHIC3DCRS)\s*[\[(]/.test(normalized)
41
- ) {
42
- return 'geographic';
33
+ if (representation === 'proj-string') {
34
+ return getProjStringCoordinateFrame(definition);
43
35
  }
44
- if (/^(?:GEODCRS|GEODETICCRS)\s*[\[(]/.test(normalized)) {
45
- return /CS\s*[\[(]\s*CARTESIAN/.test(normalized) ? 'geocentric' : 'geographic';
46
- }
47
- return 'projected';
36
+ return getKnownCrsIdentifierCoordinateFrame(definition) || 'projected';
48
37
  }
49
38
 
50
39
  const projJsonDefinition = definition as {
@@ -70,5 +59,111 @@ export function getSpatialCoordinateFrame(
70
59
  ? 'geocentric'
71
60
  : 'geographic';
72
61
  }
73
- return 'projected';
62
+ return 'unknown';
63
+ }
64
+
65
+ /**
66
+ * Classifies authority identifiers for which the tiles runtime has explicit frame knowledge.
67
+ *
68
+ * Unlike {@link getSpatialCoordinateFrame}, this function does not assume that an unrecognized
69
+ * authority identifier is projected. Draft formats use this strict result to avoid inventing
70
+ * semantics for unknown authorities or registry codes.
71
+ *
72
+ * @param definition - Serialized CRS identifier.
73
+ * @returns A known frame, or `undefined` when registry semantics are unavailable.
74
+ */
75
+ export function getKnownCrsIdentifierCoordinateFrame(
76
+ definition: ReadonlyCRSDefinition | undefined
77
+ ): SpatialReferenceCoordinateFrame | undefined {
78
+ if (typeof definition !== 'string') {
79
+ return undefined;
80
+ }
81
+ const normalized = normalizeCrsIdentifier(definition);
82
+ if (normalized === WGS84_GEOCENTRIC_CRS) {
83
+ return 'geocentric';
84
+ }
85
+ if (normalized === WGS84_GEOGRAPHIC_CRS || normalized === 'OGC:CRS84') {
86
+ return 'geographic';
87
+ }
88
+ const epsgMatch = /^EPSG:(\d+)$/.exec(normalized);
89
+ if (!epsgMatch) {
90
+ return undefined;
91
+ }
92
+ const identifier = Number(epsgMatch[1]);
93
+ if (identifier === 7789) {
94
+ return 'geocentric';
95
+ }
96
+ if (identifier === 4490 || identifier === 4979) {
97
+ return 'geographic';
98
+ }
99
+ if (
100
+ identifier === 3857 ||
101
+ (identifier >= 32601 && identifier <= 32660) ||
102
+ (identifier >= 32701 && identifier <= 32760)
103
+ ) {
104
+ return 'projected';
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /** Classifies a WKT definition using the syntax tree parsed by `@math.gl/crs`. */
110
+ function getWktCoordinateFrame(definition: string): SpatialReferenceCoordinateFrame {
111
+ let root: WKTCRSNode;
112
+ try {
113
+ root = parseWKTCRS(definition).root;
114
+ } catch {
115
+ return 'unknown';
116
+ }
117
+ switch (root.keyword.toUpperCase()) {
118
+ case 'GEOCCS':
119
+ return 'geocentric';
120
+ case 'GEOGCS':
121
+ case 'GEOGCRS':
122
+ case 'GEOGRAPHICCRS':
123
+ case 'GEOGRAPHIC2DCRS':
124
+ case 'GEOGRAPHIC3DCRS':
125
+ return 'geographic';
126
+ case 'PROJCS':
127
+ case 'PROJCRS':
128
+ case 'PROJECTEDCRS':
129
+ return 'projected';
130
+ case 'GEODCRS':
131
+ case 'GEODETICCRS': {
132
+ const coordinateSystem = root.values.find(
133
+ (value): value is WKTCRSNode =>
134
+ value.type === 'node' && value.keyword.toUpperCase() === 'CS'
135
+ );
136
+ const subtype = coordinateSystem?.values[0];
137
+ if (subtype?.type !== 'enumeration' && subtype?.type !== 'string') {
138
+ return 'unknown';
139
+ }
140
+ return subtype.value.toUpperCase() === 'CARTESIAN'
141
+ ? 'geocentric'
142
+ : subtype.value.toUpperCase() === 'ELLIPSOIDAL'
143
+ ? 'geographic'
144
+ : 'unknown';
145
+ }
146
+ default:
147
+ return 'unknown';
148
+ }
149
+ }
150
+
151
+ /** Classifies a PROJ string using the syntax tree parsed by `@math.gl/crs`. */
152
+ function getProjStringCoordinateFrame(definition: string): SpatialReferenceCoordinateFrame {
153
+ try {
154
+ const projection = parsePROJString(definition).parameters.find(
155
+ parameter => parameter.name.toLowerCase() === 'proj'
156
+ )?.value;
157
+ switch (projection?.toLowerCase()) {
158
+ case 'longlat':
159
+ case 'latlong':
160
+ return 'geographic';
161
+ case 'geocent':
162
+ return 'geocentric';
163
+ default:
164
+ return projection ? 'projected' : 'unknown';
165
+ }
166
+ } catch {
167
+ return 'unknown';
168
+ }
74
169
  }
@@ -0,0 +1,26 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ /** Compact identifier for conventional WGS84 longitude/latitude coordinates. */
6
+ export const WGS84_GEOGRAPHIC_CRS = 'EPSG:4326';
7
+
8
+ /** Compact identifier for WGS84 Earth-centered, Earth-fixed coordinates. */
9
+ export const WGS84_GEOCENTRIC_CRS = 'EPSG:4978';
10
+
11
+ /**
12
+ * Normalizes common OGC URL and URN CRS spellings to compact authority identifiers.
13
+ *
14
+ * This intentionally performs syntax normalization only. Authority lookup and semantic
15
+ * equivalence remain outside the scope of `@math.gl/crs` and the tiles runtime.
16
+ *
17
+ * @param identifier - Serialized CRS identifier.
18
+ * @returns Uppercase compact identifier when an OGC spelling is recognized.
19
+ */
20
+ export function normalizeCrsIdentifier(identifier: string): string {
21
+ const normalizedIdentifier = identifier.trim().toUpperCase();
22
+ const ogcMatch = normalizedIdentifier.match(
23
+ /(?:\/DEF\/CRS\/|URN:OGC:DEF:CRS:)([A-Z0-9_-]+)(?:\/|::)(?:[^/:]*[/:])?([A-Z0-9_.-]+)$/
24
+ );
25
+ return ogcMatch ? `${ogcMatch[1]}:${ogcMatch[2]}` : normalizedIdentifier;
26
+ }
@@ -8,6 +8,11 @@ import {Ellipsoid} from '@math.gl/geospatial';
8
8
  import type {ReadonlyCRSDefinition} from '@math.gl/crs';
9
9
  import {Proj4Projection, toProj4CRSDefinition, type Proj4CRSDefinition} from '@math.gl/proj4';
10
10
  import {getGeoidModel} from './spatial-resource-registry';
11
+ import {
12
+ normalizeCrsIdentifier,
13
+ WGS84_GEOCENTRIC_CRS,
14
+ WGS84_GEOGRAPHIC_CRS
15
+ } from './normalize-crs-identifier';
11
16
  import type {
12
17
  TilesetHeightReference,
13
18
  TilesetSpatialOptions,
@@ -16,9 +21,6 @@ import type {
16
21
  } from './spatial-types';
17
22
  export {getSpatialCoordinateFrame} from './get-spatial-coordinate-frame';
18
23
 
19
- const GEOGRAPHIC_CRS = 'EPSG:4326';
20
- const GEOCENTRIC_CRS = 'EPSG:4978';
21
-
22
24
  /**
23
25
  * Deterministic coordinate transformer used by 3D tile format adapters.
24
26
  *
@@ -90,13 +92,13 @@ export class SpatialCoordinateTransformer {
90
92
  if (!this.sourceIsGeographic && !this.sourceIsGeocentric) {
91
93
  this.geographicProjection = new Proj4Projection({
92
94
  from: getHorizontalProj4Definition(spatialReference.sourceCrs),
93
- to: GEOGRAPHIC_CRS,
95
+ to: WGS84_GEOGRAPHIC_CRS,
94
96
  enforceAxis: false
95
97
  });
96
98
  }
97
99
  if (!this.outputIsGeographic && !this.outputIsGeocentric) {
98
100
  this.heightOutputProjection = new Proj4Projection({
99
- from: GEOGRAPHIC_CRS,
101
+ from: WGS84_GEOGRAPHIC_CRS,
100
102
  to: getHorizontalProj4Definition(outputCrs),
101
103
  enforceAxis: false
102
104
  });
@@ -157,6 +159,59 @@ export class SpatialCoordinateTransformer {
157
159
  return result;
158
160
  }
159
161
 
162
+ /** Transform a packed sequence of xyz positions, preserving the input array type only when safe. */
163
+ transformPositions(positions: ArrayLike<number>): Float64Array {
164
+ if (positions.length % 3 !== 0) {
165
+ throw new Error('Packed spatial positions must contain a multiple of three components');
166
+ }
167
+ const transformedPositions = new Float64Array(positions.length);
168
+ for (let index = 0; index < positions.length; index += 3) {
169
+ const transformed = this.transformPosition([
170
+ Number(positions[index]),
171
+ Number(positions[index + 1]),
172
+ Number(positions[index + 2])
173
+ ]);
174
+ transformedPositions[index] = transformed[0];
175
+ transformedPositions[index + 1] = transformed[1];
176
+ transformedPositions[index + 2] = transformed[2];
177
+ }
178
+ return transformedPositions;
179
+ }
180
+
181
+ /** Transform packed xyz normals using a local finite-difference tangent approximation. */
182
+ transformNormals(normals: ArrayLike<number>, positions: ArrayLike<number>): Float32Array {
183
+ if (normals.length !== positions.length || normals.length % 3 !== 0) {
184
+ throw new Error('Spatial normals and positions must have matching xyz component counts');
185
+ }
186
+ const transformedNormals = new Float32Array(normals.length);
187
+ const epsilon = 1e-5;
188
+ for (let index = 0; index < normals.length; index += 3) {
189
+ const position = [
190
+ Number(positions[index]),
191
+ Number(positions[index + 1]),
192
+ Number(positions[index + 2])
193
+ ];
194
+ const normal = [
195
+ Number(normals[index]),
196
+ Number(normals[index + 1]),
197
+ Number(normals[index + 2])
198
+ ];
199
+ const jacobian = getFiniteDifferenceJacobian(this, position, epsilon);
200
+ const transformedNormal = inverseTransposeMultiply(jacobian, normal);
201
+ const length = Math.hypot(...transformedNormal);
202
+ if (length > 0 && Number.isFinite(length)) {
203
+ transformedNormals[index] = transformedNormal[0] / length;
204
+ transformedNormals[index + 1] = transformedNormal[1] / length;
205
+ transformedNormals[index + 2] = transformedNormal[2] / length;
206
+ } else {
207
+ transformedNormals[index] = normal[0];
208
+ transformedNormals[index + 1] = normal[1];
209
+ transformedNormals[index + 2] = normal[2];
210
+ }
211
+ }
212
+ return transformedNormals;
213
+ }
214
+
160
215
  /** Convert one source coordinate to conventional WGS84 longitude, latitude, and height. */
161
216
  private toGeographic(coordinate: number[]): number[] {
162
217
  if (this.sourceIsGeocentric) {
@@ -180,6 +235,47 @@ export class SpatialCoordinateTransformer {
180
235
  }
181
236
  }
182
237
 
238
+ function getFiniteDifferenceJacobian(
239
+ transformer: SpatialCoordinateTransformer,
240
+ position: number[],
241
+ epsilon: number
242
+ ): number[][] {
243
+ const columns: number[][] = [];
244
+ for (let axis = 0; axis < 3; axis++) {
245
+ const plus = position.slice();
246
+ const minus = position.slice();
247
+ plus[axis] += epsilon;
248
+ minus[axis] -= epsilon;
249
+ const plusPosition = transformer.transformPosition(plus);
250
+ const minusPosition = transformer.transformPosition(minus);
251
+ columns.push([
252
+ (plusPosition[0] - minusPosition[0]) / (2 * epsilon),
253
+ (plusPosition[1] - minusPosition[1]) / (2 * epsilon),
254
+ (plusPosition[2] - minusPosition[2]) / (2 * epsilon)
255
+ ]);
256
+ }
257
+ return columns;
258
+ }
259
+
260
+ function inverseTransposeMultiply(jacobianColumns: number[][], vector: number[]): number[] {
261
+ const [a, b, c] = jacobianColumns;
262
+ const determinant =
263
+ a[0] * (b[1] * c[2] - b[2] * c[1]) -
264
+ b[0] * (a[1] * c[2] - a[2] * c[1]) +
265
+ c[0] * (a[1] * b[2] - a[2] * b[1]);
266
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-15) {
267
+ return vector;
268
+ }
269
+ const inverseTranspose = [
270
+ [b[1] * c[2] - b[2] * c[1], a[2] * c[1] - a[1] * c[2], a[1] * b[2] - a[2] * b[1]],
271
+ [b[2] * c[0] - b[0] * c[2], a[0] * c[2] - a[2] * c[0], a[2] * b[0] - a[0] * b[2]],
272
+ [b[0] * c[1] - b[1] * c[0], a[1] * c[0] - a[0] * c[1], a[0] * b[1] - a[1] * b[0]]
273
+ ];
274
+ return inverseTranspose.map(
275
+ row => (row[0] * vector[0] + row[1] * vector[1] + row[2] * vector[2]) / determinant
276
+ );
277
+ }
278
+
183
279
  /** Select the horizontal component that the proj4js runtime can execute. */
184
280
  function getHorizontalProj4Definition(
185
281
  definition: ReadonlyCRSDefinition | undefined
@@ -224,7 +320,9 @@ function validateTransformRequest(spatialReference: TilesetSpatialReference): vo
224
320
 
225
321
  /** Return whether a CRS definition identifies the WGS84 geocentric frame. */
226
322
  function isWgs84Geocentric(definition: unknown): boolean {
227
- return typeof definition === 'string' && normalizeCrsIdentifier(definition) === GEOCENTRIC_CRS;
323
+ return (
324
+ typeof definition === 'string' && normalizeCrsIdentifier(definition) === WGS84_GEOCENTRIC_CRS
325
+ );
228
326
  }
229
327
 
230
328
  /** Return whether a CRS definition uses conventional WGS84 longitude/latitude coordinates. */
@@ -233,16 +331,9 @@ function isWgs84Geographic(definition: unknown): boolean {
233
331
  return false;
234
332
  }
235
333
  const identifier = normalizeCrsIdentifier(definition);
236
- return identifier === GEOGRAPHIC_CRS || identifier === 'EPSG:4979' || identifier === 'OGC:CRS84';
237
- }
238
-
239
- /** Normalize common OGC URL and URN CRS spellings to authority identifiers. */
240
- function normalizeCrsIdentifier(identifier: string): string {
241
- const normalizedIdentifier = identifier.trim().toUpperCase();
242
- const ogcMatch = normalizedIdentifier.match(
243
- /(?:\/DEF\/CRS\/|URN:OGC:DEF:CRS:)([A-Z0-9_-]+)(?:\/|::)(?:[^/:]*[/:])?([A-Z0-9_.-]+)$/
334
+ return (
335
+ identifier === WGS84_GEOGRAPHIC_CRS || identifier === 'EPSG:4979' || identifier === 'OGC:CRS84'
244
336
  );
245
- return ogcMatch ? `${ogcMatch[1]}:${ogcMatch[2]}` : normalizedIdentifier;
246
337
  }
247
338
 
248
339
  /** Return whether source and target height interpretations differ. */