@gisatcz/deckgl-geolib 2.3.1-dev.1 → 2.4.0-dev.1

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/dist/esm/index.js CHANGED
@@ -4515,37 +4515,46 @@ async function fromArrayBuffer(arrayBuffer, signal) {
4515
4515
 
4516
4516
  // types.ts
4517
4517
  const DefaultGeoImageOptions = {
4518
+ // --- Shared / Data ---
4518
4519
  type: 'image',
4519
- tesselator: 'martini',
4520
4520
  format: undefined,
4521
+ useChannel: null,
4522
+ useChannelIndex: null,
4523
+ noDataValue: undefined,
4524
+ multiplier: 1.0,
4525
+ numOfChannels: undefined,
4526
+ planarConfig: undefined,
4527
+ // --- Mesh generation (terrain only) ---
4528
+ tesselator: 'martini',
4529
+ terrainColor: [133, 133, 133, 255],
4530
+ terrainSkirtHeight: 100,
4531
+ // Default fallback for invalid/nodata elevations. Should be configured based on the dataset's actual range.
4532
+ terrainMinValue: 0,
4533
+ // --- Texture / Visualization ---
4521
4534
  useHeatMap: true,
4522
4535
  useColorsBasedOnValues: false,
4536
+ useColorClasses: false,
4523
4537
  useAutoRange: false,
4524
4538
  useDataForOpacity: false,
4525
4539
  useSingleColor: false,
4526
- useColorClasses: false,
4527
4540
  blurredTexture: true,
4528
4541
  clipLow: null,
4529
4542
  clipHigh: null,
4530
- multiplier: 1.0,
4531
4543
  color: [255, 0, 255, 255],
4532
4544
  colorScale: chroma.brewer.YlOrRd,
4533
4545
  colorScaleValueRange: [0, 255],
4534
4546
  colorsBasedOnValues: undefined,
4535
4547
  colorClasses: undefined,
4536
4548
  alpha: 100,
4537
- useChannel: null,
4538
- useChannelIndex: null,
4539
- noDataValue: undefined,
4540
- numOfChannels: undefined,
4541
4549
  nullColor: [0, 0, 0, 0],
4542
4550
  unidentifiedColor: [0, 0, 0, 0],
4543
4551
  clippedColor: [0, 0, 0, 0],
4544
- terrainColor: [133, 133, 133, 255],
4545
- terrainSkirtHeight: 100,
4546
- // Default fallback for invalid/nodata elevations. Should be configured based on the dataset's actual range.
4547
- terrainMinValue: 0,
4548
- planarConfig: undefined,
4552
+ // --- Kernel-specific (terrain only) ---
4553
+ useSlope: false,
4554
+ useHillshade: false,
4555
+ hillshadeAzimuth: 315,
4556
+ hillshadeAltitude: 45,
4557
+ zFactor: 1,
4549
4558
  };
4550
4559
 
4551
4560
  class Martini {
@@ -5223,158 +5232,6 @@ function updateAttributesForNewEdge({ edge, edgeIndex, attributes, skirtHeight,
5223
5232
  newTriangles[triangle1Offset + 5] = positionsLength / 3 + vertex1Offset;
5224
5233
  }
5225
5234
 
5226
- class TerrainGenerator {
5227
- static generate(input, options, meshMaxError) {
5228
- const { width, height } = input;
5229
- // 1. Compute Terrain Data (Extract Elevation)
5230
- const terrain = this.computeTerrainData(input, options);
5231
- // 2. Tesselate (Generate Mesh)
5232
- const { terrainSkirtHeight } = options;
5233
- let mesh;
5234
- switch (options.tesselator) {
5235
- case 'martini':
5236
- mesh = this.getMartiniTileMesh(meshMaxError, width, terrain);
5237
- break;
5238
- case 'delatin':
5239
- mesh = this.getDelatinTileMesh(meshMaxError, width, height, terrain);
5240
- break;
5241
- default:
5242
- // Intentional: default to Martini for any unspecified or unrecognized tesselator.
5243
- mesh = this.getMartiniTileMesh(meshMaxError, width, terrain);
5244
- break;
5245
- }
5246
- const { vertices } = mesh;
5247
- let { triangles } = mesh;
5248
- let attributes = this.getMeshAttributes(vertices, terrain, width, height, input.bounds);
5249
- // Compute bounding box before adding skirt so that z values are not skewed
5250
- const boundingBox = getMeshBoundingBox(attributes);
5251
- if (terrainSkirtHeight) {
5252
- const { attributes: newAttributes, triangles: newTriangles } = addSkirt(attributes, triangles, terrainSkirtHeight);
5253
- attributes = newAttributes;
5254
- triangles = newTriangles;
5255
- }
5256
- const map = {
5257
- // Data return by this loader implementation
5258
- loaderData: {
5259
- header: {},
5260
- },
5261
- header: {
5262
- vertexCount: triangles.length,
5263
- boundingBox,
5264
- },
5265
- mode: 4, // TRIANGLES
5266
- indices: { value: Uint32Array.from(triangles), size: 1 },
5267
- attributes,
5268
- };
5269
- const gridWidth = width === 257 ? 257 : width + 1;
5270
- const gridHeight = height === 257 ? 257 : height + 1;
5271
- return {
5272
- map,
5273
- raw: terrain,
5274
- width: gridWidth,
5275
- height: gridHeight
5276
- };
5277
- }
5278
- /**
5279
- * Decodes raw raster data into a Float32Array of elevation values.
5280
- * Handles channel selection, value scaling, data type validation, and border stitching.
5281
- */
5282
- static computeTerrainData(input, options) {
5283
- const { width, height, rasters } = input;
5284
- const optionsLocal = { ...options };
5285
- optionsLocal.useChannelIndex ??= optionsLocal.useChannel == null ? null : optionsLocal.useChannel - 1;
5286
- // Detect if data is planar (multiple arrays) or interleaved (one array with multiple samples per pixel)
5287
- const isPlanar = rasters.length > 1;
5288
- const channel = isPlanar
5289
- ? (rasters[optionsLocal.useChannelIndex ?? 0] ?? rasters[0])
5290
- : rasters[0];
5291
- const terrain = new Float32Array((width === 257 ? width : width + 1) * (height === 257 ? height : height + 1));
5292
- const samplesPerPixel = isPlanar ? 1 : (channel.length / (width * height));
5293
- // If planar, we already selected the correct array, so start at index 0.
5294
- // If interleaved, start at the index of the desired channel.
5295
- let pixel = isPlanar ? 0 : (optionsLocal.useChannelIndex ?? 0);
5296
- const isStitched = width === 257;
5297
- const fallbackValue = options.terrainMinValue ?? 0;
5298
- for (let y = 0; y < height; y++) {
5299
- for (let x = 0; x < width; x++) {
5300
- const multiplier = options.multiplier ?? 1;
5301
- let elevationValue = (options.noDataValue !== undefined &&
5302
- options.noDataValue !== null &&
5303
- channel[pixel] === options.noDataValue)
5304
- ? fallbackValue
5305
- : channel[pixel] * multiplier;
5306
- // Validate that the elevation value is within the valid range for Float32.
5307
- // Extreme values (like -1.79e308) can become -Infinity when cast, causing WebGL errors.
5308
- if (Number.isNaN(elevationValue) || elevationValue < -34e37 || elevationValue > 3.4e38) {
5309
- elevationValue = fallbackValue;
5310
- }
5311
- // If stitched (257), fill linearly. If 256, fill with stride for padding.
5312
- const index = isStitched ? (y * width + x) : (y * (width + 1) + x);
5313
- terrain[index] = elevationValue;
5314
- pixel += samplesPerPixel;
5315
- }
5316
- }
5317
- if (!isStitched) {
5318
- // backfill bottom border
5319
- for (let i = (width + 1) * width, x = 0; x < width; x++, i++) {
5320
- terrain[i] = terrain[i - width - 1];
5321
- }
5322
- // backfill right border
5323
- for (let i = height, y = 0; y < height + 1; y++, i += height + 1) {
5324
- terrain[i] = terrain[i - 1];
5325
- }
5326
- }
5327
- return terrain;
5328
- }
5329
- static getMartiniTileMesh(meshMaxError, width, terrain) {
5330
- const gridSize = width === 257 ? 257 : width + 1;
5331
- const martini = new Martini(gridSize);
5332
- const tile = martini.createTile(terrain);
5333
- const { vertices, triangles } = tile.getMesh(meshMaxError);
5334
- return { vertices, triangles };
5335
- }
5336
- static getDelatinTileMesh(meshMaxError, width, height, terrain) {
5337
- const widthPlus = width === 257 ? 257 : width + 1;
5338
- const heightPlus = height === 257 ? 257 : height + 1;
5339
- const tin = new Delatin(terrain, widthPlus, heightPlus);
5340
- tin.run(meshMaxError);
5341
- // @ts-expect-error: Delatin instance properties 'coords' and 'triangles' are not explicitly typed in the library port
5342
- const { coords, triangles } = tin;
5343
- const vertices = coords;
5344
- return { vertices, triangles };
5345
- }
5346
- static getMeshAttributes(vertices, terrain, width, height, bounds) {
5347
- const gridSize = width === 257 ? 257 : width + 1;
5348
- const numOfVerticies = vertices.length / 2;
5349
- // vec3. x, y in pixels, z in meters
5350
- const positions = new Float32Array(numOfVerticies * 3);
5351
- // vec2. 1 to 1 relationship with position. represents the uv on the texture image. 0,0 to 1,1.
5352
- const texCoords = new Float32Array(numOfVerticies * 2);
5353
- const [minX, minY, maxX, maxY] = bounds || [0, 0, width, height];
5354
- // If stitched (257), the spatial extent covers 0..256 pixels, so we divide by 256.
5355
- // If standard (256), the spatial extent covers 0..256 pixels (with backfill), so we divide by 256.
5356
- const effectiveWidth = width === 257 ? width - 1 : width;
5357
- const effectiveHeight = height === 257 ? height - 1 : height;
5358
- const xScale = (maxX - minX) / effectiveWidth;
5359
- const yScale = (maxY - minY) / effectiveHeight;
5360
- for (let i = 0; i < numOfVerticies; i++) {
5361
- const x = vertices[i * 2];
5362
- const y = vertices[i * 2 + 1];
5363
- const pixelIdx = y * gridSize + x;
5364
- positions[3 * i] = x * xScale + minX;
5365
- positions[3 * i + 1] = -y * yScale + maxY;
5366
- positions[3 * i + 2] = terrain[pixelIdx];
5367
- texCoords[2 * i] = x / effectiveWidth;
5368
- texCoords[2 * i + 1] = y / effectiveHeight;
5369
- }
5370
- return {
5371
- POSITION: { value: positions, size: 3 },
5372
- TEXCOORD_0: { value: texCoords, size: 2 },
5373
- // NORMAL: {}, - optional, but creates the high poly look with lighting
5374
- };
5375
- }
5376
- }
5377
-
5378
5235
  // DataUtils.ts
5379
5236
  function scale(num, inMin, inMax, outMin, outMax) {
5380
5237
  if (inMax === inMin) {
@@ -5682,6 +5539,342 @@ class BitmapGenerator {
5682
5539
  }
5683
5540
  }
5684
5541
 
5542
+ /**
5543
+ * KernelGenerator — 3×3 neighborhood kernel calculations on elevation rasters.
5544
+ *
5545
+ * Input contract: a Float32Array of 258×258 elevation values (row-major).
5546
+ * Edge pixels (row/col 0 and 257) are used only as kernel neighbors and do
5547
+ * not appear in the output.
5548
+ * Output: Float32Array of 256×256 computed values.
5549
+ */
5550
+ class KernelGenerator {
5551
+ /**
5552
+ * Calculates slope (0–90 degrees) for each pixel using Horn's method.
5553
+ *
5554
+ * @param src Float32Array of 258×258 elevation values (row-major)
5555
+ * @param cellSize Cell size in meters per pixel
5556
+ * @param zFactor Vertical exaggeration factor (default 1)
5557
+ * @param noDataValue Elevation value treated as noData; output is NaN for those pixels
5558
+ */
5559
+ static calculateSlope(src, cellSize, zFactor = 1, noDataValue) {
5560
+ const OUT = 256;
5561
+ const IN = 258;
5562
+ const out = new Float32Array(OUT * OUT);
5563
+ for (let r = 0; r < OUT; r++) {
5564
+ for (let c = 0; c < OUT; c++) {
5565
+ // 3×3 neighborhood in the 258×258 input, centered at (r+1, c+1)
5566
+ const base = r * IN + c;
5567
+ const z5 = src[base + IN + 1]; // center pixel
5568
+ if (noDataValue !== undefined && z5 === noDataValue) {
5569
+ out[r * OUT + c] = NaN;
5570
+ continue;
5571
+ }
5572
+ const z1 = src[base]; // nw
5573
+ const z2 = src[base + 1]; // n
5574
+ const z3 = src[base + 2]; // ne
5575
+ const z4 = src[base + IN]; // w
5576
+ const z6 = src[base + IN + 2]; // e
5577
+ const z7 = src[base + 2 * IN]; // sw
5578
+ const z8 = src[base + 2 * IN + 1]; // s
5579
+ const z9 = src[base + 2 * IN + 2]; // se
5580
+ const dzdx = ((z3 + 2 * z6 + z9) - (z1 + 2 * z4 + z7)) / (8 * cellSize);
5581
+ const dzdy = ((z7 + 2 * z8 + z9) - (z1 + 2 * z2 + z3)) / (8 * cellSize);
5582
+ const slopeRad = Math.atan(zFactor * Math.sqrt(dzdx * dzdx + dzdy * dzdy));
5583
+ out[r * OUT + c] = slopeRad * (180 / Math.PI);
5584
+ }
5585
+ }
5586
+ return out;
5587
+ }
5588
+ /**
5589
+ * Calculates hillshade (0–255 grayscale) for each pixel.
5590
+ * Follows the ESRI hillshade algorithm convention.
5591
+ *
5592
+ * @param src Float32Array of 258×258 elevation values (row-major)
5593
+ * @param azimuth Sun azimuth in degrees (default 315 = NW)
5594
+ * @param altitude Sun altitude above horizon in degrees (default 45)
5595
+ * @param cellSize Cell size in meters per pixel
5596
+ * @param zFactor Vertical exaggeration factor (default 1)
5597
+ * @param noDataValue Elevation value treated as noData; output is NaN for those pixels
5598
+ */
5599
+ static calculateHillshade(src, cellSize, azimuth = 315, altitude = 45, zFactor = 1, noDataValue) {
5600
+ const OUT = 256;
5601
+ const IN = 258;
5602
+ const out = new Float32Array(OUT * OUT);
5603
+ const zenithRad = (90 - altitude) * (Math.PI / 180);
5604
+ let azimuthMath = 360 - azimuth + 90;
5605
+ if (azimuthMath >= 360)
5606
+ azimuthMath -= 360;
5607
+ const azimuthRad = azimuthMath * (Math.PI / 180);
5608
+ for (let r = 0; r < OUT; r++) {
5609
+ for (let c = 0; c < OUT; c++) {
5610
+ const base = r * IN + c;
5611
+ const z5 = src[base + IN + 1]; // center pixel
5612
+ if (noDataValue !== undefined && z5 === noDataValue) {
5613
+ out[r * OUT + c] = NaN;
5614
+ continue;
5615
+ }
5616
+ const z1 = src[base]; // nw
5617
+ const z2 = src[base + 1]; // n
5618
+ const z3 = src[base + 2]; // ne
5619
+ const z4 = src[base + IN]; // w
5620
+ const z6 = src[base + IN + 2]; // e
5621
+ const z7 = src[base + 2 * IN]; // sw
5622
+ const z8 = src[base + 2 * IN + 1]; // s
5623
+ const z9 = src[base + 2 * IN + 2]; // se
5624
+ const dzdx = ((z3 + 2 * z6 + z9) - (z1 + 2 * z4 + z7)) / (8 * cellSize);
5625
+ // dzdy: north minus south (geographic convention — top rows minus bottom rows in raster)
5626
+ const dzdy = ((z1 + 2 * z2 + z3) - (z7 + 2 * z8 + z9)) / (8 * cellSize);
5627
+ const slopeRad = Math.atan(zFactor * Math.sqrt(dzdx * dzdx + dzdy * dzdy));
5628
+ const aspectRad = Math.atan2(dzdy, -dzdx);
5629
+ const hillshade = 255 * (Math.cos(zenithRad) * Math.cos(slopeRad) +
5630
+ Math.sin(zenithRad) * Math.sin(slopeRad) * Math.cos(azimuthRad - aspectRad));
5631
+ out[r * OUT + c] = Math.max(0, Math.min(255, hillshade));
5632
+ }
5633
+ }
5634
+ return out;
5635
+ }
5636
+ }
5637
+
5638
+ class TerrainGenerator {
5639
+ static async generate(input, options, meshMaxError) {
5640
+ const { width, height } = input;
5641
+ const isKernel = width === 258;
5642
+ // 1. Compute Terrain Data (Extract Elevation)
5643
+ const terrain = this.computeTerrainData(input, options);
5644
+ // For kernel tiles, the mesh uses the inner 257×257 sub-grid (rows 1–257, cols 1–257)
5645
+ // so that row 0 / col 0 (kernel padding) is dropped while the bottom/right stitching
5646
+ // overlap is preserved.
5647
+ const meshTerrain = isKernel ? this.extractMeshRaster(terrain) : terrain;
5648
+ const meshWidth = isKernel ? 257 : width;
5649
+ const meshHeight = isKernel ? 257 : height;
5650
+ // 2. Tesselate (Generate Mesh)
5651
+ const { terrainSkirtHeight } = options;
5652
+ let mesh;
5653
+ switch (options.tesselator) {
5654
+ case 'martini':
5655
+ mesh = this.getMartiniTileMesh(meshMaxError, meshWidth, meshTerrain);
5656
+ break;
5657
+ case 'delatin':
5658
+ mesh = this.getDelatinTileMesh(meshMaxError, meshWidth, meshHeight, meshTerrain);
5659
+ break;
5660
+ default:
5661
+ // Intentional: default to Martini for any unspecified or unrecognized tesselator.
5662
+ mesh = this.getMartiniTileMesh(meshMaxError, meshWidth, meshTerrain);
5663
+ break;
5664
+ }
5665
+ const { vertices } = mesh;
5666
+ let { triangles } = mesh;
5667
+ let attributes = this.getMeshAttributes(vertices, meshTerrain, meshWidth, meshHeight, input.bounds);
5668
+ // Compute bounding box before adding skirt so that z values are not skewed
5669
+ const boundingBox = getMeshBoundingBox(attributes);
5670
+ if (terrainSkirtHeight) {
5671
+ const { attributes: newAttributes, triangles: newTriangles } = addSkirt(attributes, triangles, terrainSkirtHeight);
5672
+ attributes = newAttributes;
5673
+ triangles = newTriangles;
5674
+ }
5675
+ const map = {
5676
+ // Data return by this loader implementation
5677
+ loaderData: {
5678
+ header: {},
5679
+ },
5680
+ header: {
5681
+ vertexCount: triangles.length,
5682
+ boundingBox,
5683
+ },
5684
+ mode: 4, // TRIANGLES
5685
+ indices: { value: Uint32Array.from(triangles), size: 1 },
5686
+ attributes,
5687
+ };
5688
+ // For kernel tiles, raw holds the 257×257 mesh elevation (same as non-kernel).
5689
+ // gridWidth/gridHeight reflect the mesh dimensions.
5690
+ const gridWidth = meshWidth === 257 ? 257 : meshWidth + 1;
5691
+ const gridHeight = meshHeight === 257 ? 257 : meshHeight + 1;
5692
+ const tileResult = {
5693
+ map,
5694
+ raw: meshTerrain,
5695
+ width: gridWidth,
5696
+ height: gridHeight,
5697
+ };
5698
+ // 3. Kernel path: compute slope or hillshade, store as rawDerived, generate texture
5699
+ if (isKernel && (options.useSlope || options.useHillshade)) {
5700
+ // Use pre-computed geographic cellSize (meters/pixel) from tile indices.
5701
+ // Falls back to bounds-derived estimate if not provided.
5702
+ const cellSize = input.cellSizeMeters ?? ((input.bounds[2] - input.bounds[0]) / 256);
5703
+ const zFactor = options.zFactor ?? 1;
5704
+ if (options.useSlope && options.useHillshade) {
5705
+ console.warn('[TerrainGenerator] useSlope and useHillshade are mutually exclusive; useSlope takes precedence.');
5706
+ }
5707
+ // Build a separate raster for kernel computation that preserves noData samples.
5708
+ const kernelTerrain = new Float32Array(terrain.length);
5709
+ const sourceRaster = input.rasters[0];
5710
+ const noData = options.noDataValue;
5711
+ if (noData !== undefined &&
5712
+ noData !== null &&
5713
+ sourceRaster &&
5714
+ sourceRaster.length === terrain.length) {
5715
+ for (let i = 0; i < terrain.length; i++) {
5716
+ // If the source raster marks this sample as noData, keep it as noData for the kernel.
5717
+ // Otherwise, use the processed terrain elevation value.
5718
+ // eslint-disable-next-line eqeqeq
5719
+ kernelTerrain[i] = sourceRaster[i] == noData ? noData : terrain[i];
5720
+ }
5721
+ }
5722
+ else {
5723
+ // Fallback: no usable noData metadata or mismatched lengths; mirror existing behavior.
5724
+ kernelTerrain.set(terrain);
5725
+ }
5726
+ let kernelOutput;
5727
+ if (options.useSlope) {
5728
+ kernelOutput = KernelGenerator.calculateSlope(kernelTerrain, cellSize, zFactor, options.noDataValue);
5729
+ }
5730
+ else {
5731
+ kernelOutput = KernelGenerator.calculateHillshade(kernelTerrain, cellSize, options.hillshadeAzimuth ?? 315, options.hillshadeAltitude ?? 45, zFactor, options.noDataValue);
5732
+ }
5733
+ tileResult.rawDerived = kernelOutput;
5734
+ if (this.hasVisualizationOptions(options)) {
5735
+ const bitmapResult = await BitmapGenerator.generate({ width: 256, height: 256, rasters: [kernelOutput] }, { ...options, type: 'image' });
5736
+ tileResult.texture = bitmapResult.map;
5737
+ }
5738
+ }
5739
+ else if (this.hasVisualizationOptions(options)) {
5740
+ // 4. Non-kernel path: crop 257→256, generate texture from elevation
5741
+ const cropped = this.cropRaster(meshTerrain, gridWidth, gridHeight, 256, 256);
5742
+ const bitmapResult = await BitmapGenerator.generate({ width: 256, height: 256, rasters: [cropped] }, { ...options, type: 'image' });
5743
+ tileResult.texture = bitmapResult.map;
5744
+ }
5745
+ return tileResult;
5746
+ }
5747
+ /** Extracts rows 1–257, cols 1–257 from a 258×258 terrain array → 257×257 for mesh generation. */
5748
+ static extractMeshRaster(terrain258) {
5749
+ const MESH = 257;
5750
+ const IN = 258;
5751
+ const out = new Float32Array(MESH * MESH);
5752
+ for (let r = 0; r < MESH; r++) {
5753
+ for (let c = 0; c < MESH; c++) {
5754
+ out[r * MESH + c] = terrain258[(r + 1) * IN + (c + 1)];
5755
+ }
5756
+ }
5757
+ return out;
5758
+ }
5759
+ static hasVisualizationOptions(options) {
5760
+ return !!(options.useHeatMap ||
5761
+ options.useSingleColor ||
5762
+ options.useColorsBasedOnValues ||
5763
+ options.useColorClasses);
5764
+ }
5765
+ static cropRaster(src, srcWidth, _srcHeight, dstWidth, dstHeight) {
5766
+ const out = new Float32Array(dstWidth * dstHeight);
5767
+ for (let y = 0; y < dstHeight; y++) {
5768
+ for (let x = 0; x < dstWidth; x++) {
5769
+ out[y * dstWidth + x] = src[y * srcWidth + x];
5770
+ }
5771
+ }
5772
+ return out;
5773
+ }
5774
+ /**
5775
+ * Decodes raw raster data into a Float32Array of elevation values.
5776
+ * Handles channel selection, value scaling, data type validation, and border stitching.
5777
+ */
5778
+ static computeTerrainData(input, options) {
5779
+ const { width, height, rasters } = input;
5780
+ const optionsLocal = { ...options };
5781
+ optionsLocal.useChannelIndex ??= optionsLocal.useChannel == null ? null : optionsLocal.useChannel - 1;
5782
+ // Detect if data is planar (multiple arrays) or interleaved (one array with multiple samples per pixel)
5783
+ const isPlanar = rasters.length > 1;
5784
+ const channel = isPlanar
5785
+ ? (rasters[optionsLocal.useChannelIndex ?? 0] ?? rasters[0])
5786
+ : rasters[0];
5787
+ const isKernel = width === 258;
5788
+ const isStitched = width === 257;
5789
+ // Kernel: 258×258 flat array. Stitched: 257×257. Default: (width+1)×(height+1) with backfill.
5790
+ const outWidth = isKernel ? 258 : (isStitched ? 257 : width + 1);
5791
+ const outHeight = isKernel ? 258 : (isStitched ? 257 : height + 1);
5792
+ const terrain = new Float32Array(outWidth * outHeight);
5793
+ const samplesPerPixel = isPlanar ? 1 : (channel.length / (width * height));
5794
+ // If planar, we already selected the correct array, so start at index 0.
5795
+ // If interleaved, start at the index of the desired channel.
5796
+ let pixel = isPlanar ? 0 : (optionsLocal.useChannelIndex ?? 0);
5797
+ const fallbackValue = options.terrainMinValue ?? 0;
5798
+ for (let y = 0; y < height; y++) {
5799
+ for (let x = 0; x < width; x++) {
5800
+ const multiplier = options.multiplier ?? 1;
5801
+ let elevationValue = (options.noDataValue !== undefined &&
5802
+ options.noDataValue !== null &&
5803
+ channel[pixel] === options.noDataValue)
5804
+ ? fallbackValue
5805
+ : channel[pixel] * multiplier;
5806
+ // Validate that the elevation value is within the valid range for Float32.
5807
+ // Extreme values (like -1.79e308) can become -Infinity when cast, causing WebGL errors.
5808
+ if (Number.isNaN(elevationValue) || elevationValue < -34e37 || elevationValue > 3.4e38) {
5809
+ elevationValue = fallbackValue;
5810
+ }
5811
+ // Kernel/Stitched: fill linearly. Default (256): fill with stride for padding.
5812
+ const index = (isKernel || isStitched) ? (y * width + x) : (y * (width + 1) + x);
5813
+ terrain[index] = elevationValue;
5814
+ pixel += samplesPerPixel;
5815
+ }
5816
+ }
5817
+ if (!isKernel && !isStitched) {
5818
+ // backfill bottom border
5819
+ for (let i = (width + 1) * width, x = 0; x < width; x++, i++) {
5820
+ terrain[i] = terrain[i - width - 1];
5821
+ }
5822
+ // backfill right border
5823
+ for (let i = height, y = 0; y < height + 1; y++, i += height + 1) {
5824
+ terrain[i] = terrain[i - 1];
5825
+ }
5826
+ }
5827
+ return terrain;
5828
+ }
5829
+ static getMartiniTileMesh(meshMaxError, width, terrain) {
5830
+ const gridSize = width === 257 ? 257 : width + 1;
5831
+ const martini = new Martini(gridSize);
5832
+ const tile = martini.createTile(terrain);
5833
+ const { vertices, triangles } = tile.getMesh(meshMaxError);
5834
+ return { vertices, triangles };
5835
+ }
5836
+ static getDelatinTileMesh(meshMaxError, width, height, terrain) {
5837
+ const widthPlus = width === 257 ? 257 : width + 1;
5838
+ const heightPlus = height === 257 ? 257 : height + 1;
5839
+ const tin = new Delatin(terrain, widthPlus, heightPlus);
5840
+ tin.run(meshMaxError);
5841
+ // @ts-expect-error: Delatin instance properties 'coords' and 'triangles' are not explicitly typed in the library port
5842
+ const { coords, triangles } = tin;
5843
+ const vertices = coords;
5844
+ return { vertices, triangles };
5845
+ }
5846
+ static getMeshAttributes(vertices, terrain, width, height, bounds) {
5847
+ const gridSize = width === 257 ? 257 : width + 1;
5848
+ const numOfVerticies = vertices.length / 2;
5849
+ // vec3. x, y in pixels, z in meters
5850
+ const positions = new Float32Array(numOfVerticies * 3);
5851
+ // vec2. 1 to 1 relationship with position. represents the uv on the texture image. 0,0 to 1,1.
5852
+ const texCoords = new Float32Array(numOfVerticies * 2);
5853
+ const [minX, minY, maxX, maxY] = bounds || [0, 0, width, height];
5854
+ // If stitched (257), the spatial extent covers 0..256 pixels, so we divide by 256.
5855
+ // If standard (256), the spatial extent covers 0..256 pixels (with backfill), so we divide by 256.
5856
+ const effectiveWidth = width === 257 ? width - 1 : width;
5857
+ const effectiveHeight = height === 257 ? height - 1 : height;
5858
+ const xScale = (maxX - minX) / effectiveWidth;
5859
+ const yScale = (maxY - minY) / effectiveHeight;
5860
+ for (let i = 0; i < numOfVerticies; i++) {
5861
+ const x = vertices[i * 2];
5862
+ const y = vertices[i * 2 + 1];
5863
+ const pixelIdx = y * gridSize + x;
5864
+ positions[3 * i] = x * xScale + minX;
5865
+ positions[3 * i + 1] = -y * yScale + maxY;
5866
+ positions[3 * i + 2] = terrain[pixelIdx];
5867
+ texCoords[2 * i] = x / effectiveWidth;
5868
+ texCoords[2 * i + 1] = y / effectiveHeight;
5869
+ }
5870
+ return {
5871
+ POSITION: { value: positions, size: 3 },
5872
+ TEXCOORD_0: { value: texCoords, size: 2 },
5873
+ // NORMAL: {}, - optional, but creates the high poly look with lighting
5874
+ };
5875
+ }
5876
+ }
5877
+
5685
5878
  class GeoImage {
5686
5879
  data;
5687
5880
  async setUrl(url) {
@@ -5709,6 +5902,7 @@ class GeoImage {
5709
5902
  let width;
5710
5903
  let height;
5711
5904
  let bounds;
5905
+ let cellSizeMeters;
5712
5906
  if (typeof (input) === 'string') {
5713
5907
  // TODO not tested
5714
5908
  // input is type of object
@@ -5723,9 +5917,10 @@ class GeoImage {
5723
5917
  width = input.width;
5724
5918
  height = input.height;
5725
5919
  bounds = input.bounds;
5920
+ cellSizeMeters = input.cellSizeMeters;
5726
5921
  }
5727
5922
  // Delegate to TerrainGenerator
5728
- return TerrainGenerator.generate({ width, height, rasters, bounds }, options, meshMaxError);
5923
+ return await TerrainGenerator.generate({ width, height, rasters, bounds, cellSizeMeters }, options, meshMaxError);
5729
5924
  }
5730
5925
  async getBitmap(input, options) {
5731
5926
  let rasters = [];
@@ -6017,14 +6212,21 @@ class CogTiles {
6017
6212
  async getTile(x, y, z, bounds, meshMaxError) {
6018
6213
  let requiredSize = this.tileSize; // Default 256 for image/bitmap
6019
6214
  if (this.options.type === 'terrain') {
6020
- requiredSize = this.tileSize + 1; // 257 for stitching
6215
+ const isKernel = this.options.useSlope || this.options.useHillshade;
6216
+ requiredSize = this.tileSize + (isKernel ? 2 : 1); // 258 for kernel (3×3 border), 257 for normal stitching
6021
6217
  }
6022
6218
  const tileData = await this.getTileFromImage(x, y, z, requiredSize);
6219
+ // Compute true ground cell size in meters from tile indices.
6220
+ // Tile y in slippy-map convention → center latitude → Web Mercator distortion correction.
6221
+ const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 0.5) / Math.pow(2, z))));
6222
+ const tileWidthMeters = (EARTH_CIRCUMFERENCE / Math.pow(2, z)) * Math.cos(latRad);
6223
+ const cellSizeMeters = tileWidthMeters / this.tileSize;
6023
6224
  return this.geo.getMap({
6024
6225
  rasters: [tileData[0]],
6025
6226
  width: requiredSize,
6026
6227
  height: requiredSize,
6027
6228
  bounds,
6229
+ cellSizeMeters,
6028
6230
  }, this.options, meshMaxError);
6029
6231
  }
6030
6232
  /**
@@ -6418,6 +6620,12 @@ class CogTerrainLayer extends CompositeLayer {
6418
6620
  // Trigger a refresh of the tiles
6419
6621
  this.state.terrainCogTiles.options.useChannelIndex = null; // Clear cached index
6420
6622
  }
6623
+ // When the external cogTiles instance is swapped (e.g. mode switch), update state so
6624
+ // renderLayers picks up the new reference and the TileLayer updateTrigger fires a refetch
6625
+ // while keeping old tile content visible until new tiles are ready.
6626
+ if (props.cogTiles && props.cogTiles !== oldProps.cogTiles) {
6627
+ this.setState({ terrainCogTiles: props.cogTiles });
6628
+ }
6421
6629
  if (props.workerUrl) {
6422
6630
  log.removed('workerUrl', 'loadOptions.terrain.workerUrl')();
6423
6631
  }
@@ -6475,15 +6683,16 @@ class CogTerrainLayer extends CompositeLayer {
6475
6683
  }
6476
6684
  // const [mesh, texture] = data;
6477
6685
  const [meshResult] = data;
6478
- return new SubLayerClass({ ...props, tileSize: 256 }, {
6686
+ const tileTexture = (!this.props.disableTexture && meshResult?.texture) ? meshResult.texture : null;
6687
+ return new SubLayerClass({ ...props, tileSize: props.tileSize }, {
6479
6688
  data: DUMMY_DATA,
6480
6689
  mesh: meshResult?.map,
6481
- // texture,
6690
+ texture: tileTexture,
6482
6691
  _instanced: false,
6483
6692
  pickable: props.pickable,
6484
6693
  coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
6485
6694
  // getPosition: (d) => [0, 0, 0],
6486
- getColor: color,
6695
+ getColor: tileTexture ? [255, 255, 255] : color,
6487
6696
  wireframe,
6488
6697
  material,
6489
6698
  });
@@ -6532,6 +6741,9 @@ class CogTerrainLayer extends CompositeLayer {
6532
6741
  // texture: urlTemplateToUpdateTrigger(texture),
6533
6742
  meshMaxError,
6534
6743
  elevationDecoder,
6744
+ // When cogTiles instance is swapped (e.g. mode switch), refetch tiles.
6745
+ // deck.gl keeps old tile content visible until new tiles are ready.
6746
+ terrainCogTiles: this.state.terrainCogTiles,
6535
6747
  },
6536
6748
  },
6537
6749
  onViewportLoad: this.onViewportLoad.bind(this),