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