@gisatcz/deckgl-geolib 2.5.0-dev.5 → 2.5.0

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