• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

keplergl / kepler.gl / 24858321999

23 Apr 2026 08:53PM UTC coverage: 59.418% (-0.06%) from 59.477%
24858321999

push

github

web-flow
fix: fixes related to deck.gl upgrade (#3380)

* fix: polygon tool regression after deck.gl upgrade

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix tile 3d layer crash; don't render polygon tool

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* revert fragile injection

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix stuck tiles

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix polygon tool again

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* pbr lighting to simple phong - eliminate radial specular/diffuse

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* more fixes

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* nit comments

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

---------

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>
Co-authored-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

6836 of 13787 branches covered (49.58%)

Branch coverage included in aggregate %.

8 of 31 new or added lines in 2 files covered. (25.81%)

19 existing lines in 2 files now uncovered.

14068 of 21394 relevant lines covered (65.76%)

75.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

5.88
/src/layers/src/tile3d-layer/tile3d-layer.ts
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import {Tile3DLayer as DeckTile3DLayer} from '@deck.gl/geo-layers';
5
import {LightingEffect as DeckLightingEffect} from '@deck.gl/core';
6
import {Tiles3DLoader, CesiumIonLoader} from '@loaders.gl/3d-tiles';
7
import {I3SLoader} from '@loaders.gl/i3s';
8
import {Tileset3D, Tile3D} from '@loaders.gl/tiles';
9
import {parsePBRMaterial} from '@luma.gl/gltf';
10

11
import Layer from '../base-layer';
12
import Tile3DLayerIcon from './tile3d-layer-icon';
13
import {FindDefaultLayerPropsReturnValue} from '../layer-utils';
14
import {
15
  LAYER_VIS_CONFIGS,
16
  LAYER_TYPES,
17
  DatasetType,
18
  TILE3D_PROVIDERS,
19
  Tile3DDatasetMetadata
20
} from '@kepler.gl/constants';
21
import {KeplerTable as KeplerDataset, Datasets as KeplerDatasets} from '@kepler.gl/table';
22
import {VisConfigNumber, BindedLayerCallbacks} from '@kepler.gl/types';
23

24
// Lazily created patched sublayer classes (see getSubLayerClass below).
25
let _PatchedMeshLayer: any = null;
13✔
26
let _PatchedScenegraphLayer: any = null;
13✔
27

28
/**
29
 * Returns true when the deck.gl instance has an active LightingEffect with
30
 * shadow rendering enabled (i.e. the kepler Light & Shadow effect is on).
31
 * Used by patched sub-layers to decide whether to override the material's
32
 * unlit flag: when no lighting effect is active, meshes stay unlit.
33
 */
34
function hasActiveLightingEffect(layer: any): boolean {
35
  const effects: any[] | undefined = layer.context?.deck?.props?.effects;
×
36
  if (!effects) return false;
×
37
  return effects.some(e => e instanceof DeckLightingEffect && (e as any).shadow);
×
38
}
39

40
/**
41
 * Same check but from the top-level KeplerTile3DLayer's own context.
42
 * Used in updateState to detect lighting on/off transitions.
43
 */
44
function _checkLightingActive(context: any): boolean {
45
  const effects: any[] | undefined = context?.deck?.props?.effects;
×
46
  if (!effects) return false;
×
47
  return effects.some(e => e instanceof DeckLightingEffect && (e as any).shadow);
×
48
}
49

50
/**
51
 * Custom DeckTile3DLayer that catches exceptions in _loadTileset and
52
 * _updateTileset (which calls selectTiles – an unhandled-promise path
53
 * where errors like "boundingVolume must contain …" escape).
54
 * See: https://github.com/visgl/deck.gl/issues/8755
55
 */
56
// @ts-expect-error Types have separate declarations of a private property '_loadTileset'.
57
class KeplerTile3DLayer extends DeckTile3DLayer {
58
  shouldUpdateState(params: any): boolean {
NEW
59
    if (super.shouldUpdateState(params)) return true;
×
NEW
60
    const lightingActive = _checkLightingActive(this.context);
×
NEW
61
    return lightingActive !== ((this.state as any)?._lightingWasActive ?? false);
×
62
  }
63

64
  // deck.gl Tile3DLayer.updateState only sets `needsUpdate` on cached tile
65
  // sub-layers when `propsChanged` fires, but NOT when `extensionsChanged`
66
  // fires (e.g. the shadow module being added/removed). The LayerManager sets
67
  // `extensionsChanged` only on top-level layers, and it never reaches the
68
  // MeshLayer / ScenegraphLayer sub-layers inside the tile cache. Override
69
  // updateState to propagate the flag so every cached tile gets re-rendered
70
  // with the correct material (lit vs. unlit).
71
  //
72
  // Additionally, when the Light & Shadow effect is disabled (but kept in
73
  // the effects array to avoid cleanup/shader-module removal), no
74
  // extensionsChanged fires. Detect the lighting active→inactive transition
75
  // and force sublayer re-creation so models revert to their default (unlit)
76
  // materials.
77
  updateState(params: any): void {
78
    super.updateState(params);
×
79

80
    const lightingActive = _checkLightingActive(this.context);
×
81
    const prevLightingActive = (this.state as any)?._lightingWasActive ?? false;
×
82

83
    const lightingChanged = lightingActive !== prevLightingActive;
×
84
    if (lightingChanged) {
×
85
      this.setState({_lightingWasActive: lightingActive});
×
86
    }
87

88
    if (params.changeFlags.extensionsChanged || lightingChanged) {
×
89
      const {layerMap} = this.state as any;
×
90
      if (layerMap) {
×
91
        for (const key in layerMap) {
×
NEW
92
          layerMap[key].needsUpdate = true;
×
93
        }
94
      }
95
    }
96
  }
97

98
  // @ts-ignore override of private method called by deck.gl internals
99
  private async _loadTileset(tilesetUrl: string) {
100
    try {
×
101
      // @ts-expect-error _loadTileset is private in DeckTile3DLayer
102
      await super._loadTileset(tilesetUrl);
×
103
    } catch (error: any) {
104
      if (error?.message?.includes("reading 'refine'")) {
×
105
        this.raiseError(new Error('Bad tileset format or invalid API key'), '_loadTileset');
×
106
      } else {
107
        console.error('Tile3DLayer: tileset load error', error);
×
108
      }
109
    }
110
  }
111

112
  /**
113
   * Override _updateTileset to catch errors from selectTiles(),
114
   * which deck.gl calls with .then() but no .catch().
115
   * Errors from tilesetInitializationPromise (e.g. invalid boundingVolume
116
   * in child tiles) surface here as unhandled promise rejections.
117
   */
118
  // @ts-ignore override of private method
119
  private _updateTileset(viewports: Record<string, any> | null): void {
120
    if (!viewports) return;
×
121
    const {tileset3d} = this.state as any;
×
122
    const {timeline} = this.context;
×
123
    const viewportsNumber = Object.keys(viewports).length;
×
124
    if (!timeline || !viewportsNumber || !tileset3d) return;
×
125

126
    // We want higher detail for video/image export.
NEW
127
    const isExporting = (this.context as any)?.deck?.props?._isExport;
×
NEW
128
    if (isExporting) {
×
NEW
129
      const baseSSE: number = tileset3d.options?.maximumScreenSpaceError ?? 8;
×
NEW
130
      tileset3d.memoryAdjustedScreenSpaceError = baseSSE / 2;
×
131
    }
132

UNCOV
133
    tileset3d
×
134
      .selectTiles(Object.values(viewports))
135
      .then((frameNumber: number) => {
136
        const tilesetChanged = (this.state as any).frameNumber !== frameNumber;
×
137
        if (tilesetChanged) {
×
138
          this.setState({frameNumber});
×
139
        }
140
      })
141
      .catch((error: any) => {
142
        console.error('Tile3DLayer: selectTiles error', error);
×
143
      });
144
  }
145

146
  // deck.gl's ScenegraphLayer and MeshLayer do not correctly handle
147
  // defaultShaderModule changes (extensionsChanged flag):
148
  //
149
  // ScenegraphLayer (Google/Cesium 3D tiles): models are created by
150
  // luma.gl's GLTF pipeline with shader modules baked in.
151
  // updateState only recreates models when the scenegraph *prop*
152
  // changes, completely ignoring extensionsChanged. When the shadow
153
  // module is added/removed, existing models keep their old shaders.
154
  //
155
  // MeshLayer (ArcGIS/I3S): getModel() creates the GPU model with PBR
156
  // shader defines (HAS_BASECOLORMAP etc.) but does not set the
157
  // corresponding texture bindings (pbr_baseColorSampler etc.).
158
  // updateState only calls updatePbrMaterialUniforms when pbrMaterial
159
  // *prop* changes, not when the model is recreated via
160
  // extensionsChanged. luma.gl skips rendering ("Binding … not found").
161
  //
162
  // Fix: override getSubLayerClass to return patched sublayer classes.
163
  // @ts-ignore protected override
164
  protected getSubLayerClass(id: string, DefaultClass: any): any {
165
    if (id === 'mesh') {
×
166
      if (!_PatchedMeshLayer) {
×
167
        _PatchedMeshLayer = class extends DefaultClass {
×
168
          shouldUpdateState(params) {
169
            if (super.shouldUpdateState(params)) return true;
×
170
            return hasActiveLightingEffect(this) !== (this.state?._lightingWasActive ?? false);
×
171
          }
172

173
          updateState(params) {
174
            super.updateState(params);
×
175

176
            const lightingActive = hasActiveLightingEffect(this);
×
177
            const prevLighting = this.state?._lightingWasActive ?? false;
×
178
            if (lightingActive !== prevLighting) {
×
179
              this.setState({_lightingWasActive: lightingActive});
×
180
              // Re-parse the PBR material so that parseMaterial's
181
              // lit/unlit decision is re-evaluated with the new
182
              // lighting state. This updates shader inputs and
183
              // textures without rebuilding the entire GPU model.
184
              if (this.state?.model) {
×
185
                this.updatePbrMaterialUniforms(this.props.pbrMaterial);
×
186
              }
187
            }
188

189
            if (params.changeFlags.extensionsChanged && this.state?.model) {
×
190
              this.updatePbrMaterialUniforms(this.props.pbrMaterial);
×
191
            }
192
          }
193

194
          // deck.gl MeshLayer.parseMaterial forces `unlit = true` whenever
195
          // the material has a baseColorTexture. This incorrectly disables
196
          // all lighting for textured I3S (ArcGIS) tiles. When the Light &
197
          // Shadow effect is active, respect the material's own `unlit`
198
          // property instead; otherwise fall back to the default (unlit) behavior.
199
          parseMaterial(material, mesh) {
200
            if (
×
201
              !hasActiveLightingEffect(this) ||
×
202
              !material ||
203
              !mesh?.attributes ||
204
              !mesh.attributes.normals
205
            ) {
206
              return super.parseMaterial(material, mesh);
×
207
            }
208
            const unlit = Boolean(material.unlit);
×
209
            return parsePBRMaterial(
×
210
              this.context.device,
211
              {unlit, ...material},
212
              {NORMAL: mesh.attributes.normals, TEXCOORD_0: mesh.attributes.texCoords},
213
              {pbrDebug: false, lights: true, useTangents: false}
214
            );
215
          }
216
        };
217
        (_PatchedMeshLayer as any).layerName = DefaultClass.layerName || 'MeshLayer';
×
218
        (_PatchedMeshLayer as any).defaultProps = DefaultClass.defaultProps;
×
219
      }
220
      return _PatchedMeshLayer;
×
221
    }
222
    if (id === 'scenegraph') {
×
223
      if (!_PatchedScenegraphLayer) {
×
224
        _PatchedScenegraphLayer = class extends DefaultClass {
×
225
          shouldUpdateState(params) {
226
            if (super.shouldUpdateState(params)) return true;
×
227
            return hasActiveLightingEffect(this) !== (this.state?._lightingWasActive ?? false);
×
228
          }
229

230
          _setUnlitOnModels(unlit: number) {
231
            const models = this.state?.models;
×
232
            if (!models) return;
×
233
            for (const model of models) {
×
234
              if (model.shaderInputs?.props?.pbrMaterial !== undefined) {
×
235
                model.shaderInputs.setProps({pbrMaterial: {unlit}});
×
236
              }
237
            }
238
          }
239

240
          updateState(params) {
241
            super.updateState(params);
×
242

243
            const lightingActive = hasActiveLightingEffect(this);
×
244
            const prevLighting = this.state?._lightingWasActive ?? false;
×
245
            if (lightingActive !== prevLighting) {
×
246
              this.setState({_lightingWasActive: lightingActive});
×
247
              // When lighting is turned off, reset models to their
248
              // default unlit state (1); when turned on, enable
249
              // lighting (0). Avoids rebuilding the scenegraph.
250
              this._setUnlitOnModels(lightingActive ? 0 : 1);
×
251
            }
252

253
            // extensionsChanged fires when the shadow shader module is
254
            // added/removed from defaultShaderModules. Models must be
255
            // rebuilt so the new module is compiled into their shaders.
256
            if (params.changeFlags.extensionsChanged && this.state?.scenegraph) {
×
257
              this._updateScenegraph();
×
258
            }
259
          }
260

261
          // Google Photorealistic 3D Tiles (and some other glTF sources)
262
          // mark materials with KHR_materials_unlit, which causes the PBR
263
          // shader to skip all lighting. When the Light & Shadow effect is
264
          // active, override model options to force unlit=0 so that the
265
          // effect can influence the scene. Without the effect, keep the
266
          // default unlit behavior.
267
          _getModelOptions() {
268
            const opts = super._getModelOptions();
×
269
            if (!hasActiveLightingEffect(this)) {
×
270
              return opts;
×
271
            }
272
            opts.modelOptions = {
×
273
              ...opts.modelOptions,
274
              uniforms: {...(opts.modelOptions?.uniforms || {}), unlit: 0}
×
275
            };
276
            return opts;
×
277
          }
278
        };
279
        (_PatchedScenegraphLayer as any).layerName = DefaultClass.layerName || 'ScenegraphLayer';
×
280
        (_PatchedScenegraphLayer as any).defaultProps = DefaultClass.defaultProps;
×
281
      }
282
      return _PatchedScenegraphLayer;
×
283
    }
284
    return super.getSubLayerClass(id, DefaultClass);
×
285
  }
286

287
  /**
288
   * During video/image export, report the layer as not loaded until every
289
   * selected tile has a renderable sublayer.  Hubble.gl's DeckAdapter and
290
   * PlotContainer check layer.isLoaded before capturing a frame, so
291
   * returning false here makes them wait until the LOD transition is
292
   * complete — no frame is captured with missing tiles.
293
   */
294
  get isLoaded(): boolean {
295
    const baseLoaded = super.isLoaded;
×
296
    if (!baseLoaded) {
×
297
      return false;
×
298
    }
299

NEW
300
    const isExporting = (this.context as any)?.deck?.props?._isExport;
×
301
    if (!isExporting) {
×
302
      return true;
×
303
    }
304

305
    const {tileset3d, layerMap} = this.state as any;
×
306
    if (!tileset3d) {
×
307
      return true;
×
308
    }
309

310
    for (const tile of tileset3d.tiles as Tile3D[]) {
×
311
      if (!tile.selected) {
×
312
        continue;
×
313
      }
314
      const cache = layerMap[tile.id];
×
315
      if (!cache?.layer) {
×
316
        return false;
×
317
      }
318
    }
319

320
    return true;
×
321
  }
322
}
323
(KeplerTile3DLayer as any).layerName = 'KeplerTile3DLayer';
13✔
324

325
export const TILE3D_LAYER_TYPE = LAYER_TYPES.tile3d;
13✔
326

327
export type Tile3DLayerVisConfigSettings = {
328
  opacity: VisConfigNumber;
329
  pointSize: VisConfigNumber;
330
};
331

332
export type Tile3DLayerVisConfig = {
333
  opacity: number;
334
  pointSize: number;
335
};
336

337
export const tile3DVisConfigs = {
13✔
338
  opacity: {
339
    ...LAYER_VIS_CONFIGS.opacity,
340
    defaultValue: 1,
341
    property: 'opacity'
342
  } as VisConfigNumber,
343
  pointSize: {
344
    type: 'number' as const,
345
    defaultValue: 2,
346
    label: 'layerVisConfigs.pointSize',
347
    isRanged: false,
348
    range: [0.5, 20],
349
    step: 0.5,
350
    group: 'display' as const,
351
    property: 'pointSize',
352
    allowCustomValue: false
353
  } as VisConfigNumber
354
};
355

356
const EMPTY_EXTENSIONS: any[] = [];
13✔
357
const DEPTH_TEST_PARAMS = {depthTest: true};
13✔
358

359
function getTile3DProviderFromUrl(url = ''): (typeof TILE3D_PROVIDERS)[string] | null {
×
360
  for (const key of Object.keys(TILE3D_PROVIDERS)) {
×
361
    if (url.includes(TILE3D_PROVIDERS[key].urlKey)) {
×
362
      return TILE3D_PROVIDERS[key];
×
363
    }
364
  }
365
  return null;
×
366
}
367

368
export default class Tile3DLayer extends Layer {
369
  declare visConfigSettings: Tile3DLayerVisConfigSettings;
370

371
  private _cachedDataAndLoader: {
372
    data: string;
373
    loadOptions?: Record<string, unknown>;
374
    loader?: any;
375
    _cacheKey?: string;
376
  } | null = null;
27✔
377

378
  private _layerCallbacks: BindedLayerCallbacks | null = null;
27✔
379
  private _hasFittedBounds = false;
27✔
380

381
  constructor(props: {dataId: string} & Record<string, any>) {
382
    super(props);
27✔
383
    this.registerVisConfig(tile3DVisConfigs);
27✔
384
    this.meta = {};
27✔
385
  }
386

387
  static findDefaultLayerProps(dataset: KeplerDataset): FindDefaultLayerPropsReturnValue {
388
    if (dataset.type !== DatasetType.TILE_3D) {
93!
389
      return {props: []};
93✔
390
    }
391

392
    return {
×
393
      props: [
394
        {
395
          label: dataset.label,
396
          isVisible: true,
397
          color: [255, 255, 255] as [number, number, number]
398
        }
399
      ]
400
    };
401
  }
402

403
  get type(): string {
404
    return TILE3D_LAYER_TYPE;
×
405
  }
406

407
  get name(): string {
408
    return '3D Tile';
27✔
409
  }
410

411
  get requireData(): boolean {
412
    return false;
27✔
413
  }
414

415
  get requiredLayerColumns(): string[] {
416
    return [];
27✔
417
  }
418

419
  get layerIcon(): typeof Tile3DLayerIcon {
420
    return Tile3DLayerIcon;
27✔
421
  }
422

423
  get supportedDatasetTypes(): DatasetType[] {
424
    return [DatasetType.TILE_3D];
×
425
  }
426

427
  get visualChannels() {
428
    return {};
×
429
  }
430

431
  shouldRenderLayer(): boolean {
432
    return Boolean(this.type && this.config.isVisible);
×
433
  }
434

435
  getHoverData(): any {
436
    return null;
×
437
  }
438

439
  formatLayerData(datasets: KeplerDatasets): Record<string, any> {
440
    const {dataId} = this.config;
×
441
    if (!dataId || !datasets[dataId]) {
×
442
      return {};
×
443
    }
444

445
    const dataset = datasets[dataId];
×
446
    this.updateLayerMeta(dataset);
×
447
    const metadata = (dataset.metadata || {}) as Tile3DDatasetMetadata;
×
448

449
    return {
×
450
      tile3dUrl: metadata.tile3dUrl,
451
      tile3dAccessToken: metadata.tile3dAccessToken
452
    };
453
  }
454

455
  updateLayerMeta(dataset: KeplerDataset): void {
456
    if (dataset.type !== DatasetType.TILE_3D) {
×
457
      return;
×
458
    }
459
    const metadata = (dataset.metadata || {}) as Tile3DDatasetMetadata;
×
460
    const provider = getTile3DProviderFromUrl(metadata.tile3dUrl);
×
461
    this.updateMeta({
×
462
      provider,
463
      attribution: provider?.attribution ?? null
×
464
    });
465
  }
466

467
  _getDataAndLoaderOptions(
468
    tileUrl: string,
469
    accessToken?: string
470
  ): {data: string; loadOptions?: Record<string, unknown>; loader?: any} {
471
    const cacheKey = `${tileUrl}::${accessToken || ''}`;
×
472
    if (this._cachedDataAndLoader && this._cachedDataAndLoader._cacheKey === cacheKey) {
×
473
      return this._cachedDataAndLoader;
×
474
    }
475

476
    const provider = getTile3DProviderFromUrl(tileUrl);
×
477
    let result: {data: string; loadOptions?: Record<string, unknown>; loader?: any};
478

479
    if (provider === TILE3D_PROVIDERS.google) {
×
480
      const separator = tileUrl.includes('?') ? '&' : '?';
×
481
      result = {
×
482
        data: `${tileUrl}${separator}key=${accessToken || ''}`,
×
483
        loader: Tiles3DLoader,
484
        loadOptions: {
485
          fetch: {headers: {'X-GOOG-API-KEY': accessToken || ''}}
×
486
        }
487
      };
488
    } else if (provider === TILE3D_PROVIDERS.cesium) {
×
489
      result = {
×
490
        data: tileUrl,
491
        loader: CesiumIonLoader,
492
        loadOptions: {
493
          'cesium-ion': {accessToken}
494
        }
495
      };
496
    } else if (provider === TILE3D_PROVIDERS.arcgis) {
×
497
      result = {
×
498
        data: tileUrl,
499
        loader: I3SLoader,
500
        loadOptions: {
501
          i3s: {useCompressedTextures: false}
502
        }
503
      };
504
    } else {
505
      result = {
×
506
        data: tileUrl,
507
        loader: Tiles3DLoader
508
      };
509
    }
510

511
    this._cachedDataAndLoader = {...result, _cacheKey: cacheKey};
×
512
    return this._cachedDataAndLoader;
×
513
  }
514

515
  _onTilesetLoad = (tileset3d: Tileset3D): void => {
27✔
516
    this._extractBoundsFromTileset(tileset3d);
×
517

518
    const tileUrl = tileset3d.url || '';
×
519
    const provider = getTile3DProviderFromUrl(tileUrl);
×
520
    const isGoogle = provider === TILE3D_PROVIDERS.google;
×
521

522
    if (!this._hasFittedBounds && this.meta?.bounds && !isGoogle) {
×
523
      this._hasFittedBounds = true;
×
524
      this._layerCallbacks?.onFitBounds?.(this.meta.bounds);
×
525
    }
526

527
    if (isGoogle) {
×
528
      tileset3d.options.onTraversalComplete = selectedTiles => {
×
529
        const credits = new Set<string>();
×
530
        selectedTiles.forEach((tile: Tile3D) => {
×
531
          const {copyright} = (tile as any).content?.gltf?.asset || {};
×
532
          if (copyright) copyright.split(';').forEach(c => credits.add(c));
×
533
        });
534
        const title = Array.from(credits).join('; ');
×
535
        if (this.meta?.attribution?.title !== title) {
×
536
          this.updateMeta({
×
537
            attribution: {
538
              ...TILE3D_PROVIDERS.google.attribution,
539
              title
540
            }
541
          });
542
        }
543
        return selectedTiles;
×
544
      };
545
    }
546
  };
547

548
  _extractBoundsFromTileset(tileset3d: Tileset3D): void {
549
    try {
×
550
      const root = tileset3d.root;
×
551
      if (root) {
×
552
        const bbox = (root as any).boundingBox;
×
553
        if (bbox && bbox.length === 2) {
×
554
          const [min, max] = bbox;
×
555
          // bbox = [[westDeg, southDeg, minHeight], [eastDeg, northDeg, maxHeight]]
556
          this.updateMeta({
×
557
            bounds: [min[0], min[1], max[0], max[1]]
558
          });
559
          return;
×
560
        }
561
      }
562
    } catch {
563
      // boundingBox getter may throw for malformed volumes
564
    }
565

566
    // Fallback: approximate bounds from cartographicCenter and zoom
567
    const center = tileset3d.cartographicCenter;
×
568
    if (center) {
×
569
      const lng = center[0];
×
570
      const lat = center[1];
×
571
      const zoom = tileset3d.zoom || 14;
×
572
      // Approximate half-span in degrees based on zoom level
573
      const span = 180 / Math.pow(2, zoom);
×
574
      this.updateMeta({
×
575
        bounds: [lng - span, lat - span, lng + span, lat + span]
576
      });
577
    }
578
  }
579

580
  _onTileLoad = (tile: Tile3D): void => {
27✔
581
    // I3S materials often lack metallicFactor/roughnessFactor.
582
    // PBR spec defaults metallicFactor to 1 (fully metallic),
583
    // causing black surfaces when lighting/shadow effects are active.
584
    const pbr = tile.content?.material?.pbrMetallicRoughness;
×
585
    if (pbr) {
×
586
      if (pbr.metallicFactor === undefined) {
×
587
        pbr.metallicFactor = 0;
×
588
      }
589
      if (pbr.roughnessFactor === undefined) {
×
590
        pbr.roughnessFactor = 1;
×
591
      }
592
    }
593
  };
594

595
  _onTileUnload = (_tile: Tile3D): void => {
27✔
596
    /* noop */
597
  };
598

599
  renderLayer(opts: any): KeplerTile3DLayer[] {
600
    const {data, layerCallbacks} = opts;
×
601
    const {tile3dUrl, tile3dAccessToken} = data || {};
×
602
    if (!tile3dUrl) {
×
603
      return [];
×
604
    }
605

606
    this._layerCallbacks = layerCallbacks || null;
×
607

608
    const {visConfig} = this.config;
×
609
    const defaultLayerProps = this.getDefaultDeckLayerProps(opts);
×
610
    const {
611
      data: tileData,
612
      loadOptions,
613
      loader
614
    } = this._getDataAndLoaderOptions(tile3dUrl, tile3dAccessToken);
×
615

616
    const {color} = this.config;
×
617
    const pointColor: [number, number, number, number] = color
×
618
      ? [color[0], color[1], color[2], 255]
619
      : [255, 255, 255, 255];
620

621
    return [
×
622
      new KeplerTile3DLayer({
623
        id: defaultLayerProps.id,
624
        coordinateSystem: defaultLayerProps.coordinateSystem,
625
        wrapLongitude: defaultLayerProps.wrapLongitude,
626
        visible: defaultLayerProps.visible,
627
        data: tileData,
628
        loader,
629
        loadOptions,
630
        onTilesetLoad: this._onTilesetLoad,
631
        onTileLoad: this._onTileLoad,
632
        onTileUnload: this._onTileUnload,
633
        getPointColor: pointColor,
634
        pointSize: visConfig.pointSize ?? 2,
×
635
        pickable: false,
636
        opacity: visConfig.opacity ?? 1,
×
637
        extensions: EMPTY_EXTENSIONS,
638
        parameters: DEPTH_TEST_PARAMS
639
      })
640
    ];
641
  }
642
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc