@gisatcz/deckgl-geolib 2.5.0-dev.4 → 2.5.0-dev.6

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
@@ -4928,6 +4928,9 @@ const DefaultGeoImageOptions = {
4928
4928
  useDataForOpacity: false,
4929
4929
  useSingleColor: false,
4930
4930
  blurredTexture: true,
4931
+ skipTexture: false,
4932
+ /** Strategy for noData detection: 'full' | 'border+center' */
4933
+ noDataCheck: 'full',
4931
4934
  clipLow: null,
4932
4935
  clipHigh: null,
4933
4936
  color: [255, 0, 255, 255],
@@ -5655,6 +5658,19 @@ function scale(num, inMin, inMax, outMin, outMax) {
5655
5658
  return ((num - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
5656
5659
  }
5657
5660
 
5661
+ function toF32(n) {
5662
+ return Math.fround(n);
5663
+ }
5664
+ function isF32NoData(val, noData) {
5665
+ if (noData === undefined || noData === null)
5666
+ return false;
5667
+ const a = toF32(val);
5668
+ const b = toF32(noData);
5669
+ if (Number.isNaN(b))
5670
+ return Number.isNaN(a);
5671
+ return a === b;
5672
+ }
5673
+
5658
5674
  class BitmapGenerator {
5659
5675
  /**
5660
5676
  * Cache for Swiss relief color LUTs to avoid regenerating on every tile.
@@ -5828,9 +5844,7 @@ class BitmapGenerator {
5828
5844
  for (let i = 0, sampleIndex = (options.useChannelIndex ?? 0); i < arrayLength; i += 4, sampleIndex += samplesPerPixel) {
5829
5845
  const elevationVal = primaryBuffer[sampleIndex];
5830
5846
  // NaN-aware noData check for Swiss relief
5831
- const isNoData = options.noDataValue !== undefined && (Number.isNaN(options.noDataValue)
5832
- ? Number.isNaN(elevationVal)
5833
- : elevationVal === options.noDataValue);
5847
+ const isNoData = isF32NoData(elevationVal, options.noDataValue);
5834
5848
  if (Number.isNaN(elevationVal) || isNoData) {
5835
5849
  colorsArray.set(options.nullColor, i);
5836
5850
  continue;
@@ -6038,7 +6052,7 @@ class BitmapGenerator {
6038
6052
  return colorsArray;
6039
6053
  }
6040
6054
  static isInvalid(val, options) {
6041
- return Number.isNaN(val) || (options.noDataValue !== undefined && val === options.noDataValue);
6055
+ return Number.isNaN(val) || isF32NoData(val, options.noDataValue);
6042
6056
  }
6043
6057
  static getInvalidColor(val, options) {
6044
6058
  return options.nullColor;
@@ -6060,7 +6074,7 @@ class BitmapGenerator {
6060
6074
  return (!Array.isArray(color) || color.length !== 4) ? [...chroma(color).rgb(), alpha] : color;
6061
6075
  }
6062
6076
  static hasPixelsNoData(pixels, noData) {
6063
- return noData !== undefined && pixels.every(p => p === noData);
6077
+ return noData !== undefined && pixels.every(p => isF32NoData(p, noData));
6064
6078
  }
6065
6079
  }
6066
6080
 
@@ -6104,13 +6118,12 @@ class KernelGenerator {
6104
6118
  const cellSizeFactor = 1 / (8 * cellSize);
6105
6119
  // Cache constant for radians to degrees conversion
6106
6120
  const RAD_TO_DEG = 180 / Math.PI;
6107
- const isNaNNoData = noDataValue !== undefined && Number.isNaN(noDataValue);
6108
6121
  for (let r = 0; r < OUT; r++) {
6109
6122
  for (let c = 0; c < OUT; c++) {
6110
6123
  // 3×3 neighborhood in the 258×258 input, centered at (r+1, c+1)
6111
6124
  const base = r * IN + c;
6112
6125
  const z5 = src[base + IN + 1]; // center pixel
6113
- const isNoData = noDataValue !== undefined && (isNaNNoData ? Number.isNaN(z5) : z5 === noDataValue);
6126
+ const isNoData = isF32NoData(z5, noDataValue);
6114
6127
  if (isNoData) {
6115
6128
  out[r * OUT + c] = NaN;
6116
6129
  continue;
@@ -6152,12 +6165,11 @@ class KernelGenerator {
6152
6165
  const azimuthRad = azimuthMath * (Math.PI / 180);
6153
6166
  // Hoist division out of loop: multiplication is ~2-3x faster than division
6154
6167
  const cellSizeFactor = 1 / (8 * cellSize);
6155
- const isNaNNoData = noDataValue !== undefined && Number.isNaN(noDataValue);
6156
6168
  for (let r = 0; r < OUT; r++) {
6157
6169
  for (let c = 0; c < OUT; c++) {
6158
6170
  const base = r * IN + c;
6159
6171
  const z5 = src[base + IN + 1]; // center pixel
6160
- const isNoData = noDataValue !== undefined && (isNaNNoData ? Number.isNaN(z5) : z5 === noDataValue);
6172
+ const isNoData = isF32NoData(z5, noDataValue);
6161
6173
  if (isNoData) {
6162
6174
  out[r * OUT + c] = NaN;
6163
6175
  continue;
@@ -6190,7 +6202,6 @@ class KernelGenerator {
6190
6202
  const out = new Float32Array(OUT * OUT);
6191
6203
  // Hoist division out of loop: multiplication is ~2-3x faster than division
6192
6204
  const cellSizeFactor = 1 / (8 * cellSize);
6193
- const isNaNNoData = noDataValue !== undefined && Number.isNaN(noDataValue);
6194
6205
  // Setup 3 light sources: NW (Main), W (Fill), N (Fill)
6195
6206
  const lights = [
6196
6207
  { az: 315, alt: 45, weight: 0.60 }, // Primary NW
@@ -6212,7 +6223,7 @@ class KernelGenerator {
6212
6223
  for (let c = 0; c < OUT; c++) {
6213
6224
  const base = r * IN + c;
6214
6225
  const z5 = src[base + IN + 1];
6215
- const isNoData = noDataValue !== undefined && (isNaNNoData ? Number.isNaN(z5) : z5 === noDataValue);
6226
+ const isNoData = isF32NoData(z5, noDataValue);
6216
6227
  if (isNoData) {
6217
6228
  out[r * OUT + c] = NaN;
6218
6229
  continue;
@@ -6380,6 +6391,7 @@ class TerrainGenerator {
6380
6391
  height: gridHeight,
6381
6392
  };
6382
6393
  // 3. Kernel path: compute slope or hillshade, store as rawDerived, generate texture
6394
+ const shouldSkipTexture = !!options.skipTexture;
6383
6395
  if (isKernel && options.useSwissRelief) {
6384
6396
  const cellSize = input.cellSizeMeters ?? ((input.bounds[2] - input.bounds[0]) / 256);
6385
6397
  // Build a separate raster for kernel computation that preserves noData samples.
@@ -6387,7 +6399,7 @@ class TerrainGenerator {
6387
6399
  // Compose Swiss relief using ReliefCompositor
6388
6400
  const swissReliefResult = ReliefCompositor.composeSwissRelief(kernelTerrain, options, cellSize, 256, 256);
6389
6401
  tileResult.rawDerived = swissReliefResult;
6390
- if (this.hasVisualizationOptions(options)) {
6402
+ if (!shouldSkipTexture && this.hasVisualizationOptions(options)) {
6391
6403
  const cropped = this.cropRaster(meshTerrain, gridWidth, gridHeight, 256, 256);
6392
6404
  const bitmapResult = await BitmapGenerator.generate({ width: 256, height: 256, rasters: [cropped, swissReliefResult] }, { ...options, type: 'image' });
6393
6405
  tileResult.texture = bitmapResult.map;
@@ -6412,15 +6424,38 @@ class TerrainGenerator {
6412
6424
  kernelOutput = KernelGenerator.calculateHillshade(kernelTerrain, cellSize, options.hillshadeAzimuth ?? 315, options.hillshadeAltitude ?? 45, zFactor, options.noDataValue);
6413
6425
  }
6414
6426
  tileResult.rawDerived = kernelOutput;
6415
- if (this.hasVisualizationOptions(options)) {
6427
+ if (!shouldSkipTexture && this.hasVisualizationOptions(options)) {
6416
6428
  const bitmapResult = await BitmapGenerator.generate({ width: 256, height: 256, rasters: [kernelOutput] }, { ...options, type: 'image' });
6417
6429
  tileResult.texture = bitmapResult.map;
6418
6430
  }
6419
6431
  }
6420
- else if (this.hasVisualizationOptions(options)) {
6421
- // 4. Non-kernel path: crop 257→256, generate texture from elevation
6422
- const cropped = this.cropRaster(meshTerrain, gridWidth, gridHeight, 256, 256);
6423
- const bitmapResult = await BitmapGenerator.generate({ width: 256, height: 256, rasters: [cropped] }, { ...options, type: 'image' });
6432
+ else if (!shouldSkipTexture && this.hasVisualizationOptions(options)) {
6433
+ // 4. Non-kernel path: build texture raster from the ORIGINAL source data, preserving
6434
+ // noData sentinels so BitmapGenerator renders those pixels as transparent (via nullColor).
6435
+ // meshTerrain substitutes noData with terrainMinValue for mesh stability we intentionally
6436
+ // avoid using it for texture generation to prevent fill values being coloured.
6437
+ const multiplier = options.multiplier ?? 1;
6438
+ const noDataValue = options.noDataValue;
6439
+ const srcRaster = input.rasters[0];
6440
+ const srcWidth = input.width;
6441
+ const srcHeight = input.height;
6442
+ // Determine samplesPerPixel for interleaved buffers (fallback to 1)
6443
+ const samplesPerPixel = Math.max(1, Math.round((srcRaster.length) / (srcWidth * srcHeight)));
6444
+ const channelIndex = options.useChannelIndex ?? (options.useChannel != null ? options.useChannel - 1 : 0);
6445
+ const textureRaster = new Float32Array(256 * 256);
6446
+ for (let ty = 0; ty < 256; ty++) {
6447
+ for (let tx = 0; tx < 256; tx++) {
6448
+ // Guard: if srcWidth < 256 (shouldn't happen), clamp indices
6449
+ const srcX = Math.min(tx, srcWidth - 1);
6450
+ const srcY = Math.min(ty, srcHeight - 1);
6451
+ const srcIdx = (srcY * srcWidth + srcX) * samplesPerPixel + channelIndex;
6452
+ const v = srcRaster[srcIdx];
6453
+ const isNoData = isF32NoData(v, noDataValue);
6454
+ textureRaster[ty * 256 + tx] = isNoData ? noDataValue * multiplier : v * multiplier;
6455
+ }
6456
+ }
6457
+ const bitmapOptions = { ...options, type: 'image', useChannelIndex: 0, numOfChannels: 1, noDataValue: noDataValue !== undefined ? noDataValue * multiplier : undefined };
6458
+ const bitmapResult = await BitmapGenerator.generate({ width: 256, height: 256, rasters: [textureRaster] }, bitmapOptions);
6424
6459
  tileResult.texture = bitmapResult.map;
6425
6460
  }
6426
6461
  return tileResult;
@@ -6454,12 +6489,9 @@ class TerrainGenerator {
6454
6489
  noDataValue !== null &&
6455
6490
  sourceRaster &&
6456
6491
  sourceRaster.length === terrain.length) {
6457
- const preserveNaNNoData = Number.isNaN(noDataValue);
6458
6492
  for (let i = 0; i < terrain.length; i++) {
6459
6493
  const sourceValue = sourceRaster[i];
6460
- const isNoData = preserveNaNNoData
6461
- ? Number.isNaN(sourceValue)
6462
- : sourceValue === noDataValue;
6494
+ const isNoData = isF32NoData(sourceValue, noDataValue);
6463
6495
  kernelTerrain[i] = isNoData ? noDataValue : terrain[i];
6464
6496
  }
6465
6497
  }
@@ -6505,9 +6537,7 @@ class TerrainGenerator {
6505
6537
  for (let y = 0; y < height; y++) {
6506
6538
  for (let x = 0; x < width; x++) {
6507
6539
  const multiplier = options.multiplier ?? 1;
6508
- let elevationValue = (options.noDataValue !== undefined &&
6509
- options.noDataValue !== null &&
6510
- channel[pixel] === options.noDataValue)
6540
+ let elevationValue = isF32NoData(channel[pixel], options.noDataValue)
6511
6541
  ? fallbackValue
6512
6542
  : channel[pixel] * multiplier;
6513
6543
  // Validate that the elevation value is within the valid range for Float32.
@@ -6640,15 +6670,16 @@ class GeoImage {
6640
6670
  else if (mergedOptions.type === 'terrain') {
6641
6671
  // Terrain with no explicit coloring mode.
6642
6672
  const hasKernelMode = userOptions.useSwissRelief || userOptions.useSlope || userOptions.useHillshade;
6643
- if (!hasKernelMode) {
6644
- // No kernel mode: enable useSingleColor with terrainColor as the default.
6673
+ // If skipTexture is requested, force single-color mesh rendering regardless of kernel modes.
6674
+ if (!hasKernelMode || mergedOptions.skipTexture) {
6675
+ // No kernel mode OR skipTexture: enable useSingleColor with terrainColor as the default.
6645
6676
  // This renders the mesh in the documented colour without a data-driven texture.
6646
6677
  resolved.useHeatMap = false;
6647
6678
  resolved.useSingleColor = true;
6648
6679
  resolved.color = mergedOptions.terrainColor;
6649
6680
  }
6650
6681
  // When a kernel mode is present without an explicit coloring mode, keep
6651
- // useHeatMap: true from defaults so the kernel output is colourised.
6682
+ // useHeatMap: true from defaults so the kernel output is colourised (unless skipTexture).
6652
6683
  }
6653
6684
  // For 'image' with no explicit coloring mode: keep useHeatMap: true from DefaultGeoImageOptions.
6654
6685
  return resolved;
@@ -6712,26 +6743,83 @@ class CogTiles {
6712
6743
  cog;
6713
6744
  cogZoomLookup = [];
6714
6745
  cogResolutionLookup = [];
6746
+ cogMeshMaxErrorLookup = [];
6715
6747
  cogOrigin = [0, 0];
6716
6748
  zoomRange = [0, 0];
6717
6749
  tileSize = 256;
6718
6750
  bounds = [0, 0, 0, 0];
6719
6751
  geo = new GeoImage();
6720
6752
  options;
6753
+ // TileResult cache — keyed by z/x/y/meshMaxError.
6754
+ // Each entry owns an AbortController and a caller reference count.
6755
+ // The pipeline is aborted only when ALL callers have cancelled (ref-count → 0),
6756
+ // so concurrent deck.gl requests for the same tile share one in-flight fetch/tessellation
6757
+ // and individual tile cancellations (e.g. from panning) do not poison other callers.
6758
+ // Once the promise settles (resolved or rejected), controller/callerCount are irrelevant;
6759
+ // future cache hits just await the already-resolved promise directly.
6760
+ tileResultCache = new Map();
6761
+ tileResultCacheMaxSize = 32;
6762
+ getTileResultCacheKey(x, y, z, meshMaxError, skipTexture) {
6763
+ return `${z}/${x}/${y}/${meshMaxError}/${skipTexture ? '1' : '0'}`;
6764
+ }
6765
+ /** Clears the TileResult cache. Call when the COG URL or meshMaxError changes. */
6766
+ clearTileResultCache() {
6767
+ // Abort any in-flight pipelines so their network requests are cancelled
6768
+ for (const entry of this.tileResultCache.values()) {
6769
+ if (!entry.settled)
6770
+ entry.controller.abort();
6771
+ }
6772
+ this.tileResultCache.clear();
6773
+ }
6774
+ // Raw raster cache for ordinary bitmap layers — saves network fetch + decompression on revisit.
6775
+ // BitmapGenerator is cheap to re-run from cached raster; no need to hold ImageBitmaps in memory.
6776
+ rasterCache = new Map();
6777
+ rasterCacheMaxSize = 64;
6778
+ // Relief mask cache for bitmap + glaze layers — saves network fetch + kernel convolution on revisit.
6779
+ // Stores the Float32Array output of composeSwissRelief; BitmapGenerator re-runs from it cheaply.
6780
+ reliefMaskCache = new Map();
6781
+ reliefMaskCacheMaxSize = 64;
6782
+ getTileCacheKey(x, y, z) {
6783
+ return `${z}/${x}/${y}`;
6784
+ }
6721
6785
  // Cache GeoTIFFImage Promises by index to prevent redundant HTTP requests from geotiff 3.0.4+ eager loading
6722
6786
  // Stores Promises (not resolved values) so concurrent requests share the same getImage() call
6723
6787
  imageCache = new Map();
6724
6788
  // Store initialization promise to prevent concurrent duplicate initializations
6725
6789
  initializePromise;
6790
+ // Track the last successfully initialized URL to detect URL changes
6791
+ lastInitializedUrl;
6726
6792
  constructor(options) {
6727
6793
  this.options = { ...CogTilesGeoImageOptionsDefaults, ...options };
6728
6794
  }
6729
6795
  async initializeCog(url) {
6730
- // Return existing initialization promise if already in progress (prevents concurrent duplicates)
6731
- if (this.initializePromise)
6796
+ // Reuse existing initialization while it is in progress, or when the same URL
6797
+ // was already initialized on this instance.
6798
+ if (this.initializePromise && (!this.cog || this.lastInitializedUrl === url)) {
6732
6799
  return this.initializePromise;
6733
- if (this.cog)
6734
- return;
6800
+ }
6801
+ // Fully reset COG-derived state when the URL changes so the instance can be
6802
+ // safely reinitialized against a different source.
6803
+ if (this.lastInitializedUrl !== undefined && this.lastInitializedUrl !== url) {
6804
+ this.clearTileResultCache();
6805
+ this.rasterCache.clear();
6806
+ this.reliefMaskCache.clear();
6807
+ this.imageCache.clear();
6808
+ this.cog = undefined;
6809
+ this.cogOrigin = [0, 0];
6810
+ this.cogZoomLookup = [];
6811
+ this.cogResolutionLookup = [];
6812
+ this.cogMeshMaxErrorLookup = [];
6813
+ this.tileSize = 256;
6814
+ this.zoomRange = [0, 0];
6815
+ this.bounds = [0, 0, 0, 0];
6816
+ this.initializePromise = undefined;
6817
+ this.lastInitializedUrl = undefined;
6818
+ }
6819
+ // If COG already loaded and URL matches, return the existing promise
6820
+ if (this.cog && this.lastInitializedUrl === url) {
6821
+ return this.initializePromise ?? Promise.resolve();
6822
+ }
6735
6823
  this.initializePromise = (async () => {
6736
6824
  try {
6737
6825
  // fromUrl's type declaration only exposes RemoteSourceOptions, but the implementation
@@ -6751,6 +6839,10 @@ class CogTiles {
6751
6839
  this.options.numOfChannels = fileDirectory.getValue('SamplesPerPixel');
6752
6840
  this.options.planarConfig = fileDirectory.getValue('PlanarConfiguration');
6753
6841
  [this.cogZoomLookup, this.cogResolutionLookup] = await this.buildCogZoomResolutionLookup(this.cog);
6842
+ // Only compute quantized meshMaxError lookup for terrain COGs
6843
+ if (this.options.type === 'terrain') {
6844
+ this.computeMeshMaxErrorLookup();
6845
+ }
6754
6846
  this.tileSize = image.getTileWidth();
6755
6847
  // 1. Validation: Ensure the image is tiled
6756
6848
  if (!this.tileSize || !image.getTileHeight()) {
@@ -6759,6 +6851,8 @@ class CogTiles {
6759
6851
  }
6760
6852
  this.zoomRange = this.calculateZoomRange(this.tileSize, image.getResolution()[0], await this.cog.getImageCount());
6761
6853
  this.bounds = this.calculateBoundsAsLatLon(image.getBoundingBox());
6854
+ // Mark initialization complete for this URL (used to detect URL changes)
6855
+ this.lastInitializedUrl = url;
6762
6856
  }
6763
6857
  catch (error) {
6764
6858
  // Reset initialization promise on error so retry can be attempted
@@ -6801,8 +6895,39 @@ class CogTiles {
6801
6895
  lat = (180 / Math.PI) * (2 * Math.atan(Math.exp((lat * Math.PI) / 180)) - Math.PI / 2);
6802
6896
  return [lon, lat];
6803
6897
  }
6804
- // return cartographicPositionAdjusted;
6805
- // }
6898
+ /**
6899
+ * Calculates the error multiplier based on zoom level and COG zoom range.
6900
+ * - z >= maxZ: multiplier = 0.5 (fine meshes at high zoom, maximum precision)
6901
+ * - z <= minZ: multiplier = 3.0 (coarse meshes at low zoom, maximum performance)
6902
+ * - Otherwise: linear interpolation between 3.0 and 0.5
6903
+ */
6904
+ getErrorMultiplierForZoom(z, minZ, maxZ) {
6905
+ if (z >= maxZ)
6906
+ return 0.5;
6907
+ if (z <= minZ)
6908
+ return 3.0;
6909
+ // Linear interpolation: 3.0 - ((z - minZ) / (maxZ - minZ)) * 2.5
6910
+ return 3.0 - ((z - minZ) / (maxZ - minZ)) * 2.5;
6911
+ }
6912
+ /**
6913
+ * Calculates dynamic meshMaxError for a given zoom level and resolution.
6914
+ * Formula: resolution * errorMultiplier, where multiplier scales from 3.0 (low zoom) to 0.5 (high zoom).
6915
+ * Results are clamped to 0.5–100 meters to prevent pathological tessellation:
6916
+ * - Min 0.5m ensures simplification always happens (no rounding to 0 with sub-meter pixels)
6917
+ * - Max 100m prevents excessive simplification at low zoom with low-resolution COGs
6918
+ */
6919
+ calculateDynamicMeshMaxError(z, resolution, minZ, maxZ) {
6920
+ const multiplier = this.getErrorMultiplierForZoom(z, minZ, maxZ);
6921
+ const errorValue = resolution * multiplier;
6922
+ return Math.max(0.5, Math.min(100, errorValue));
6923
+ }
6924
+ /**
6925
+ * Gets the auto meshMaxError for a given overview index.
6926
+ * Returns undefined if auto lookup has not been computed.
6927
+ */
6928
+ getMeshMaxErrorForImageIndex(imageIndex) {
6929
+ return this.cogMeshMaxErrorLookup[imageIndex];
6930
+ }
6806
6931
  /**
6807
6932
  * Builds lookup tables for zoom levels and estimated resolutions from a Cloud Optimized GeoTIFF (COG) object.
6808
6933
  *
@@ -6843,6 +6968,20 @@ class CogTiles {
6843
6968
  }
6844
6969
  return [zoomLookup, resolutionLookup];
6845
6970
  }
6971
+ /**
6972
+ * Computes dynamic meshMaxError values for each overview based on COG resolution and zoom level.
6973
+ * Called only for terrain COGs after buildCogZoomResolutionLookup() completes.
6974
+ * Each overview's meshMaxError is calculated as: resolution * zoom-based multiplier, rounded to nearest integer.
6975
+ * Multiplier ranges from 3.0 at minZ (coarse meshes) to 0.5 at maxZ (fine meshes).
6976
+ */
6977
+ computeMeshMaxErrorLookup() {
6978
+ const minZ = this.cogZoomLookup[this.cogZoomLookup.length - 1];
6979
+ const maxZ = this.cogZoomLookup[0];
6980
+ this.cogMeshMaxErrorLookup = this.cogResolutionLookup.map((resolution, idx) => {
6981
+ const zoom = this.cogZoomLookup[idx];
6982
+ return this.calculateDynamicMeshMaxError(zoom, resolution, minZ, maxZ);
6983
+ });
6984
+ }
6846
6985
  /**
6847
6986
  * Determines the appropriate image index from the Cloud Optimized GeoTIFF (COG)
6848
6987
  * that best matches a given zoom level.
@@ -6864,12 +7003,20 @@ class CogTiles {
6864
7003
  return this.cogZoomLookup.length - 1;
6865
7004
  // For zoom levels within the available range, find the exact or closest matching index.
6866
7005
  const exactMatchIndex = this.cogZoomLookup.indexOf(zoom);
6867
- if (exactMatchIndex === -1) {
6868
- // TO DO improve the condition if the match index is not found
6869
- /* eslint-disable no-console */
6870
- console.log('getImageIndexForZoomLevel: error in retrieving image by zoom index');
7006
+ if (exactMatchIndex !== -1) {
7007
+ return exactMatchIndex;
6871
7008
  }
6872
- return exactMatchIndex;
7009
+ // No exact match: find the closest zoom level
7010
+ let closestIndex = 0;
7011
+ let minDistance = Math.abs(this.cogZoomLookup[0] - zoom);
7012
+ for (let i = 1; i < this.cogZoomLookup.length; i += 1) {
7013
+ const distance = Math.abs(this.cogZoomLookup[i] - zoom);
7014
+ if (distance < minDistance) {
7015
+ minDistance = distance;
7016
+ closestIndex = i;
7017
+ }
7018
+ }
7019
+ return closestIndex;
6873
7020
  }
6874
7021
  async getTileFromImage(tileX, tileY, zoom, fetchSize, signal) {
6875
7022
  // Create a fresh local AbortController for this specific fetch.
@@ -6973,8 +7120,7 @@ class CogTiles {
6973
7120
  tileBuffer[destRowOffset + destCol] = validRasterData[band][srcRowOffset + col];
6974
7121
  }
6975
7122
  else {
6976
- /* eslint-disable no-console */
6977
- console.log(`error in assigning data to tile buffer: destRow ${destRow}, destCol ${destCol}, FETCH_SIZE ${FETCH_SIZE}`);
7123
+ console.error(`[CogTiles] tile buffer bounds exceeded: destRow ${destRow}, destCol ${destCol}, FETCH_SIZE ${FETCH_SIZE}`);
6978
7124
  }
6979
7125
  }
6980
7126
  }
@@ -6990,16 +7136,34 @@ class CogTiles {
6990
7136
  return [tileData];
6991
7137
  }
6992
7138
  catch (error) {
6993
- // If the signal was aborted (or geotiff.js threw AggregateError wrapping an abort),
6994
- // re-throw as a standard AbortError so deck.gl handles tile cancellation gracefully
6995
- // and suppressGlobalAbortErrors() can suppress the unhandled rejection noise.
7139
+ // If it's a single-error AggregateError, unwrap to the inner error for clearer diagnostics
7140
+ if (error instanceof AggregateError && error.errors.length === 1) {
7141
+ const innerError = error.errors[0];
7142
+ // Check if the unwrapped error is an abort — if so, throw it as AbortError
7143
+ if (innerError instanceof DOMException && innerError.name === 'AbortError') {
7144
+ throw innerError;
7145
+ }
7146
+ if (innerError instanceof Error && innerError.message === 'Request was aborted') {
7147
+ throw new DOMException('Tile request aborted', 'AbortError');
7148
+ }
7149
+ // Unwrap single error for better diagnostics (throw the real error, not the wrapper)
7150
+ throw innerError;
7151
+ }
7152
+ // Handle regular abort cases
6996
7153
  const isAbortRelated = localSignal.aborted
6997
- || (error instanceof AggregateError && error.errors?.some((e) => e?.name === 'AbortError' || e?.message?.includes('aborted') || e?.message?.includes('abort')))
6998
7154
  || (error instanceof DOMException && error.name === 'AbortError')
6999
7155
  || (error instanceof Error && error.message === 'Request was aborted');
7000
7156
  if (isAbortRelated) {
7001
7157
  throw new DOMException('Tile request aborted', 'AbortError');
7002
7158
  }
7159
+ // For multi-error AggregateError, check if ANY error is abort-related
7160
+ if (error instanceof AggregateError) {
7161
+ const hasAbort = error.errors.some((e) => (e instanceof DOMException && e.name === 'AbortError')
7162
+ || (e instanceof Error && e.message === 'Request was aborted'));
7163
+ if (hasAbort) {
7164
+ throw new DOMException('Tile request aborted', 'AbortError');
7165
+ }
7166
+ }
7003
7167
  throw error;
7004
7168
  }
7005
7169
  }
@@ -7017,40 +7181,285 @@ class CogTiles {
7017
7181
  }
7018
7182
  return tileData;
7019
7183
  }
7020
- async getTile(x, y, z, bounds, meshMaxError, signal) {
7021
- let requiredSize = this.tileSize; // Default 256 for image/bitmap
7022
- if (this.options.type === 'terrain') {
7023
- const isKernel = this.options.useSlope || this.options.useHillshade || this.options.useSwissRelief;
7024
- requiredSize = this.tileSize + (isKernel ? 2 : 1); // 258 for kernel (3×3 border), 257 for normal stitching
7025
- }
7026
- else if (this.options.type === 'image' && this.options.useReliefGlaze) {
7027
- // Bitmap layer with relief glaze mode needs kernel padding for slope/hillshade computation
7028
- requiredSize = this.tileSize + 2; // 258 for kernel
7029
- }
7030
- const tileData = await this.getTileFromImage(x, y, z, requiredSize, signal);
7031
- // Compute true ground cell size in meters from tile indices.
7032
- // Tile y in slippy-map convention → center latitude → Web Mercator distortion correction.
7184
+ async getTile(x, y, z, bounds, meshMaxError, signal, skipTexture) {
7185
+ // cellSizeMeters is derived purely from tile coordinates — compute once for all paths
7033
7186
  const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 0.5) / Math.pow(2, z))));
7034
7187
  const tileWidthMeters = (EARTH_CIRCUMFERENCE / Math.pow(2, z)) * Math.cos(latRad);
7035
7188
  const cellSizeMeters = tileWidthMeters / this.tileSize;
7036
- let rasters = [tileData[0]];
7037
- let tileWidth = requiredSize;
7038
- let tileHeight = requiredSize;
7039
- // Relief glaze computation for bitmap layers
7040
- // Note: For multi-band support (band selection via useChannelIndex), see issue #98
7041
- if (this.options.type === 'image' && this.options.useReliefGlaze) {
7042
- const elevation = tileData[0];
7043
- // Pass full 258×258 padded elevation directly — KernelGenerator expects IN=258 and outputs 256×256
7044
- const reliefMask = ReliefCompositor.composeSwissRelief(elevation, this.options, cellSizeMeters, this.tileSize, this.tileSize);
7045
- // For glaze-only mode, pass ONLY the 256×256 relief mask
7046
- rasters = [reliefMask];
7047
- tileWidth = this.tileSize;
7048
- tileHeight = this.tileSize;
7189
+ const isTerrain = this.options.type === 'terrain';
7190
+ const isGlaze = this.options.type === 'image' && this.options.useReliefGlaze;
7191
+ // Resolve meshMaxError: if not provided or 0, use auto quantized value; otherwise use explicit value
7192
+ let resolvedMeshMaxError = meshMaxError;
7193
+ if (isTerrain && (!meshMaxError || meshMaxError === 0)) {
7194
+ const imageIndex = this.getImageIndexForZoomLevel(z);
7195
+ const autoMeshMaxError = this.getMeshMaxErrorForImageIndex(imageIndex);
7196
+ resolvedMeshMaxError = autoMeshMaxError ?? 4.0;
7197
+ }
7198
+ else {
7199
+ resolvedMeshMaxError = meshMaxError ?? 4.0;
7200
+ }
7201
+ // ── PATH A: Terrain ──────────────────────────────────────────────────────────
7202
+ // Full TileResult (mesh + raw + texture) cached with ref-counted abort so that
7203
+ // panning cancels in-flight fetches only when ALL callers have cancelled.
7204
+ if (isTerrain) {
7205
+ const skipTextureFlag = skipTexture ?? this.options.skipTexture ?? false;
7206
+ const cacheKey = this.getTileResultCacheKey(x, y, z, resolvedMeshMaxError, skipTextureFlag);
7207
+ const existing = this.tileResultCache.get(cacheKey);
7208
+ if (existing) {
7209
+ // LRU touch — move the key to the end of the Map to mark as recently used
7210
+ this.tileResultCache.delete(cacheKey);
7211
+ this.tileResultCache.set(cacheKey, existing);
7212
+ }
7213
+ if (existing) {
7214
+ if (existing.settled) {
7215
+ if (signal?.aborted)
7216
+ return null;
7217
+ return existing.promise;
7218
+ }
7219
+ existing.callerCount += 1;
7220
+ if (signal && !signal.aborted) {
7221
+ signal.addEventListener('abort', () => {
7222
+ existing.callerCount -= 1;
7223
+ if (existing.callerCount <= 0 && !existing.settled) {
7224
+ existing.controller.abort();
7225
+ }
7226
+ }, { once: true });
7227
+ }
7228
+ const result = await existing.promise;
7229
+ if (signal?.aborted)
7230
+ return null;
7231
+ return result;
7232
+ }
7233
+ const controller = new AbortController();
7234
+ const pipeline = (async () => {
7235
+ const isKernel = this.options.useSlope || this.options.useHillshade || this.options.useSwissRelief;
7236
+ const requiredSize = this.tileSize + (isKernel ? 2 : 1);
7237
+ const tileData = await this.getTileFromImage(x, y, z, requiredSize, controller.signal);
7238
+ // === Step F: detect all-noData tiles before tessellation ===
7239
+ const raster = tileData[0];
7240
+ const noData = this.options.noDataValue;
7241
+ if (noData !== undefined && raster) {
7242
+ const numChannels = this.options.numOfChannels || 1;
7243
+ let useChannelIndex = this.options.useChannelIndex ?? (this.options.useChannel ? (this.options.useChannel - 1) : 0);
7244
+ if (useChannelIndex == null)
7245
+ useChannelIndex = 0;
7246
+ const checkStrategy = this.options.noDataCheck ?? 'full';
7247
+ const width = requiredSize;
7248
+ const height = requiredSize;
7249
+ const isNoValue = (v) => (Number.isNaN(noData) ? Number.isNaN(v) : v === noData);
7250
+ let allNoData = true;
7251
+ if (checkStrategy === 'full') {
7252
+ // Full linear scan (safe)
7253
+ if (numChannels > 1) {
7254
+ for (let i = useChannelIndex; i < raster.length; i += numChannels) {
7255
+ const v = raster[i];
7256
+ if (!isNoValue(v)) {
7257
+ allNoData = false;
7258
+ break;
7259
+ }
7260
+ }
7261
+ }
7262
+ else {
7263
+ for (let i = 0; i < raster.length; i++) {
7264
+ const v = raster[i];
7265
+ if (!isNoValue(v)) {
7266
+ allNoData = false;
7267
+ break;
7268
+ }
7269
+ }
7270
+ }
7271
+ }
7272
+ else if (checkStrategy === 'border+center') {
7273
+ // Border scan: iterate over top/bottom rows and left/right cols
7274
+ const stepX = numChannels;
7275
+ // Top row
7276
+ for (let x = 0; x < width; x++) {
7277
+ const idx = x * stepX + useChannelIndex;
7278
+ const v = raster[idx];
7279
+ if (!isNoValue(v)) {
7280
+ allNoData = false;
7281
+ break;
7282
+ }
7283
+ }
7284
+ // Bottom row
7285
+ if (allNoData) {
7286
+ for (let x = 0; x < width; x++) {
7287
+ const idx = ((height - 1) * width + x) * stepX + useChannelIndex;
7288
+ const v = raster[idx];
7289
+ if (!isNoValue(v)) {
7290
+ allNoData = false;
7291
+ break;
7292
+ }
7293
+ }
7294
+ }
7295
+ // Left/Right cols
7296
+ if (allNoData) {
7297
+ for (let y = 1; y < height - 1; y++) {
7298
+ const leftIdx = (y * width) * stepX + useChannelIndex;
7299
+ const rightIdx = (y * width + (width - 1)) * stepX + useChannelIndex;
7300
+ const vl = raster[leftIdx];
7301
+ const vr = raster[rightIdx];
7302
+ if (!isNoValue(vl) || !isNoValue(vr)) {
7303
+ allNoData = false;
7304
+ break;
7305
+ }
7306
+ }
7307
+ }
7308
+ // Center probe + 4 quadrant probes
7309
+ if (allNoData) {
7310
+ const probes = [
7311
+ [Math.floor(width / 2), Math.floor(height / 2)],
7312
+ [Math.floor(width / 4), Math.floor(height / 4)],
7313
+ [Math.floor((3 * width) / 4), Math.floor(height / 4)],
7314
+ [Math.floor(width / 4), Math.floor((3 * height) / 4)],
7315
+ [Math.floor((3 * width) / 4), Math.floor((3 * height) / 4)],
7316
+ ];
7317
+ for (const [px, py] of probes) {
7318
+ const idx = (py * width + px) * stepX + useChannelIndex;
7319
+ const v = raster[idx];
7320
+ if (!isNoValue(v)) {
7321
+ allNoData = false;
7322
+ break;
7323
+ }
7324
+ }
7325
+ }
7326
+ }
7327
+ else {
7328
+ // Unknown strategy — fallback to full
7329
+ for (let i = 0; i < raster.length; i++) {
7330
+ const v = raster[i];
7331
+ if (!isNoValue(v)) {
7332
+ allNoData = false;
7333
+ break;
7334
+ }
7335
+ }
7336
+ }
7337
+ if (allNoData) {
7338
+ // Do not cache all-noData result; remove cache entry so future requests re-evaluate if COG/metadata changes.
7339
+ this.tileResultCache.delete(cacheKey);
7340
+ return null;
7341
+ }
7342
+ }
7343
+ // Create generator options with skipTextureFlag applied (don't mutate shared this.options)
7344
+ const generatorOptions = {
7345
+ ...this.options,
7346
+ skipTexture: skipTextureFlag,
7347
+ };
7348
+ return this.geo.getMap({
7349
+ rasters: [tileData[0]],
7350
+ width: requiredSize,
7351
+ height: requiredSize,
7352
+ bounds: bounds ?? [0, 0, 0, 0],
7353
+ cellSizeMeters,
7354
+ }, generatorOptions, resolvedMeshMaxError);
7355
+ })();
7356
+ const entry = {
7357
+ promise: pipeline,
7358
+ controller,
7359
+ callerCount: 1,
7360
+ settled: false,
7361
+ };
7362
+ if (signal && !signal.aborted) {
7363
+ signal.addEventListener('abort', () => {
7364
+ entry.callerCount -= 1;
7365
+ if (entry.callerCount <= 0 && !entry.settled) {
7366
+ entry.controller.abort();
7367
+ }
7368
+ }, { once: true });
7369
+ }
7370
+ entry.promise = pipeline;
7371
+ this.tileResultCache.set(cacheKey, entry);
7372
+ if (this.tileResultCache.size > this.tileResultCacheMaxSize) {
7373
+ const oldestKey = this.tileResultCache.keys().next().value;
7374
+ if (typeof oldestKey === 'string') {
7375
+ const evicted = this.tileResultCache.get(oldestKey);
7376
+ if (evicted && !evicted.settled)
7377
+ evicted.controller.abort();
7378
+ this.tileResultCache.delete(oldestKey);
7379
+ }
7380
+ }
7381
+ try {
7382
+ const result = await pipeline;
7383
+ entry.settled = true;
7384
+ if (signal?.aborted)
7385
+ return null;
7386
+ return result;
7387
+ }
7388
+ catch (error) {
7389
+ entry.settled = true;
7390
+ this.tileResultCache.delete(cacheKey);
7391
+ throw error;
7392
+ }
7049
7393
  }
7394
+ // ── PATH B: Bitmap + glaze ────────────────────────────────────────────────────
7395
+ // Relief mask (output of composeSwissRelief) cached — saves fetch + kernel on revisit.
7396
+ // BitmapGenerator re-runs cheaply from the cached Float32Array.
7397
+ // Signal is passed so cancelled tiles abort cleanly; cache entry is deleted on abort/error
7398
+ // so the next request retries fresh.
7399
+ if (isGlaze) {
7400
+ const maskKey = this.getTileCacheKey(x, y, z);
7401
+ let maskPromise = this.reliefMaskCache.get(maskKey);
7402
+ if (maskPromise) {
7403
+ // LRU touch — move the key to the end of the Map to mark as recently used
7404
+ this.reliefMaskCache.delete(maskKey);
7405
+ this.reliefMaskCache.set(maskKey, maskPromise);
7406
+ }
7407
+ if (!maskPromise) {
7408
+ maskPromise = (async () => {
7409
+ const tileData = await this.getTileFromImage(x, y, z, this.tileSize + 2, signal);
7410
+ return ReliefCompositor.composeSwissRelief(tileData[0], this.options, cellSizeMeters, this.tileSize, this.tileSize);
7411
+ })();
7412
+ this.reliefMaskCache.set(maskKey, maskPromise);
7413
+ maskPromise.catch(() => this.reliefMaskCache.delete(maskKey));
7414
+ if (this.reliefMaskCache.size > this.reliefMaskCacheMaxSize) {
7415
+ const oldestKey = this.reliefMaskCache.keys().next().value;
7416
+ if (typeof oldestKey === 'string')
7417
+ this.reliefMaskCache.delete(oldestKey);
7418
+ }
7419
+ }
7420
+ if (signal?.aborted)
7421
+ return null;
7422
+ const reliefMask = await maskPromise;
7423
+ if (signal?.aborted)
7424
+ return null;
7425
+ return this.geo.getMap({
7426
+ rasters: [reliefMask],
7427
+ width: this.tileSize,
7428
+ height: this.tileSize,
7429
+ bounds: bounds ?? [0, 0, 0, 0],
7430
+ cellSizeMeters,
7431
+ }, this.options, meshMaxError ?? 4.0);
7432
+ }
7433
+ // ── PATH C: Ordinary bitmap ───────────────────────────────────────────────────
7434
+ // Raw raster cached — saves fetch + decompression on revisit.
7435
+ // BitmapGenerator re-runs cheaply from the cached TypedArray.
7436
+ // Signal is passed so cancelled tiles abort cleanly; cache entry deleted on abort/error.
7437
+ const rasterKey = this.getTileCacheKey(x, y, z);
7438
+ let rasterPromise = this.rasterCache.get(rasterKey);
7439
+ if (rasterPromise) {
7440
+ // LRU touch — move the key to the end of the Map to mark as recently used
7441
+ this.rasterCache.delete(rasterKey);
7442
+ this.rasterCache.set(rasterKey, rasterPromise);
7443
+ }
7444
+ if (!rasterPromise) {
7445
+ rasterPromise = this.getTileFromImage(x, y, z, this.tileSize, signal);
7446
+ this.rasterCache.set(rasterKey, rasterPromise);
7447
+ rasterPromise.catch(() => this.rasterCache.delete(rasterKey));
7448
+ if (this.rasterCache.size > this.rasterCacheMaxSize) {
7449
+ const oldestKey = this.rasterCache.keys().next().value;
7450
+ if (typeof oldestKey === 'string')
7451
+ this.rasterCache.delete(oldestKey);
7452
+ }
7453
+ }
7454
+ if (signal?.aborted)
7455
+ return null;
7456
+ const tileData = await rasterPromise;
7457
+ if (signal?.aborted)
7458
+ return null;
7050
7459
  return this.geo.getMap({
7051
- rasters,
7052
- width: tileWidth,
7053
- height: tileHeight,
7460
+ rasters: [tileData[0]],
7461
+ width: this.tileSize,
7462
+ height: this.tileSize,
7054
7463
  bounds: bounds ?? [0, 0, 0, 0],
7055
7464
  cellSizeMeters,
7056
7465
  }, this.options, meshMaxError ?? 4.0);
@@ -7263,7 +7672,16 @@ class CogBitmapLayer extends CompositeLayer {
7263
7672
  }
7264
7673
  }
7265
7674
  async getTiledBitmapData(tile) {
7266
- const resolvedTileData = await this.state.bitmapCogTiles.getTile(tile.index.x, tile.index.y, tile.index.z, undefined, undefined, tile.signal);
7675
+ let resolvedTileData;
7676
+ try {
7677
+ resolvedTileData = await this.state.bitmapCogTiles.getTile(tile.index.x, tile.index.y, tile.index.z, undefined, undefined, tile.signal);
7678
+ }
7679
+ catch (error) {
7680
+ if (error instanceof DOMException && error.name === 'AbortError') {
7681
+ return null;
7682
+ }
7683
+ throw error;
7684
+ }
7267
7685
  if (resolvedTileData && !this.props.pickable) {
7268
7686
  resolvedTileData.raw = null;
7269
7687
  }
@@ -7360,6 +7778,12 @@ const urlType = {
7360
7778
  return true;
7361
7779
  },
7362
7780
  };
7781
+ const meshMaxErrorValidation = {
7782
+ type: 'object',
7783
+ value: 'auto',
7784
+ validate: (value) => typeof value === 'number' || value === 'auto',
7785
+ equal: (v1, v2) => v1 === v2,
7786
+ };
7363
7787
  const DUMMY_DATA = [1];
7364
7788
  const defaultProps = {
7365
7789
  ...TileLayer.defaultProps,
@@ -7368,7 +7792,9 @@ const defaultProps = {
7368
7792
  // Image url to use as texture
7369
7793
  texture: { ...urlType, optional: true },
7370
7794
  // Martini error tolerance in meters, smaller number -> more detailed mesh
7371
- meshMaxError: { type: 'number', value: 4.0 },
7795
+ // Set to a number for fixed tessellation across all zooms, or 'auto' (default)
7796
+ // for zoom-adaptive meshMaxError based on COG resolution
7797
+ meshMaxError: meshMaxErrorValidation,
7372
7798
  // Bounding box of the terrain image, [minX, minY, maxX, maxY] in world coordinates
7373
7799
  bounds: {
7374
7800
  type: 'array', value: null, optional: true, compare: true,
@@ -7446,6 +7872,11 @@ class CogTerrainLayer extends CompositeLayer {
7446
7872
  || props.meshMaxError !== oldProps.meshMaxError
7447
7873
  || props.elevationDecoder !== oldProps.elevationDecoder
7448
7874
  || props.bounds !== oldProps.bounds;
7875
+ // When meshMaxError changes, cached meshes are stale — clear so new tiles are tessellated
7876
+ // at the correct error tolerance
7877
+ if (props.meshMaxError !== oldProps.meshMaxError && this.state.terrainCogTiles) {
7878
+ this.state.terrainCogTiles.clearTileResultCache();
7879
+ }
7449
7880
  if (!this.state.isTiled && shouldReload) ;
7450
7881
  // Update the useChannel option for terrainCogTiles when terrainOptions.useChannel changes.
7451
7882
  if (props?.terrainOptions?.useChannel !== oldProps.terrainOptions?.useChannel) {
@@ -7453,6 +7884,13 @@ class CogTerrainLayer extends CompositeLayer {
7453
7884
  // Trigger a refresh of the tiles
7454
7885
  this.state.terrainCogTiles.options.useChannelIndex = null; // Clear cached index
7455
7886
  }
7887
+ // Update skipTexture when wireframe/operation/disableTexture changes so cache keys are correct
7888
+ const newSkipTexture = !!(props?.wireframe || props?.operation === 'terrain' || props?.disableTexture);
7889
+ const oldSkipTexture = !!(oldProps?.wireframe || oldProps?.operation === 'terrain' || oldProps?.disableTexture);
7890
+ if (newSkipTexture !== oldSkipTexture && this.state.terrainCogTiles) {
7891
+ this.state.terrainCogTiles.options.skipTexture = newSkipTexture;
7892
+ this.state.terrainCogTiles.clearTileResultCache();
7893
+ }
7456
7894
  // When the external cogTiles instance is swapped (e.g. mode switch), update state so
7457
7895
  // renderLayers picks up the new reference and the TileLayer updateTrigger fires a refetch
7458
7896
  // while keeping old tile content visible until new tiles are ready.
@@ -7499,11 +7937,25 @@ class CogTerrainLayer extends CompositeLayer {
7499
7937
  topRight = [bbox.right, bbox.top];
7500
7938
  }
7501
7939
  const bounds = [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]];
7502
- const resolvedTerrain = await this.state.terrainCogTiles.getTile(tile.index.x, tile.index.y, tile.index.z, bounds, this.props.meshMaxError, tile.signal);
7940
+ let resolvedTerrain = null;
7941
+ try {
7942
+ const skipTexture = !!(this.props.wireframe || this.props.operation === 'terrain' || this.props.disableTexture);
7943
+ // Convert 'auto' to undefined so CogTiles.getTile uses the quantized meshMaxError for the zoom level
7944
+ const meshMaxErrorValue = this.props.meshMaxError === 'auto' ? undefined : this.props.meshMaxError;
7945
+ resolvedTerrain = await this.state.terrainCogTiles.getTile(tile.index.x, tile.index.y, tile.index.z, bounds, meshMaxErrorValue, tile.signal, skipTexture);
7946
+ }
7947
+ catch (error) {
7948
+ // Tile was cancelled (AbortError) — return null so deck.gl discards it cleanly
7949
+ if (error instanceof DOMException && error.name === 'AbortError') {
7950
+ return null;
7951
+ }
7952
+ throw error;
7953
+ }
7503
7954
  if (resolvedTerrain && !this.props.pickable) {
7504
7955
  resolvedTerrain.raw = null;
7505
7956
  }
7506
- return Promise.all([resolvedTerrain, null]);
7957
+ // Return a tuple [TileResult|null, Texture|null] when data is available, otherwise null
7958
+ return resolvedTerrain ? [resolvedTerrain, null] : null;
7507
7959
  }
7508
7960
  renderSubLayers(props) {
7509
7961
  const SubLayerClass = this.getSubLayerClass('mesh', SimpleMeshLayer);
@@ -7587,6 +8039,7 @@ class CogTerrainLayer extends CompositeLayer {
7587
8039
  meshMaxError,
7588
8040
  elevationDecoder,
7589
8041
  terrainCogTiles: this.state.terrainCogTiles,
8042
+ skipTexture: !!(this.props.wireframe || this.props.operation === 'terrain' || this.props.disableTexture),
7590
8043
  },
7591
8044
  renderSubLayers: {
7592
8045
  disableTexture: this.props.disableTexture,